refactor(im): 抽象 IM 选择类弹窗为 PickerPanel 体系,对齐微信 PC

- 拆「业务壳 + 纯 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
im
YunaiV 2026-05-08 14:06:48 +08:00
parent 40ac2daca8
commit 312df4c73d
32 changed files with 2295 additions and 1468 deletions

View File

@ -127,7 +127,7 @@
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import { useUserStore } from '@/store/modules/user'
@ -141,30 +141,23 @@ import { getSimpleUserListByNickname, type UserVO } from '@/api/system/user'
defineOptions({ name: 'ImFriendAddDialog' })
const props = withDefaults(
defineProps<{
modelValue: boolean
/** 预填目标用户:不为空时跳过搜索步骤,直接进入申请表单(群成员加好友 / 名片加好友等场景) */
presetUser?: UserVO | null
/** 添加来源;参见 ImFriendAddSourceEnum */
addSource?: number
/** 来源附带信息addSource=ImFriendAddSource.GROUP 时传群名,话术拼为「我是 XX 群的 YY」 */
addSourceExtra?: string
}>(),
{
presetUser: null,
addSource: ImFriendAddSource.SEARCH
const visible = ref(false)
/** 预填目标用户:非 null 时跳过搜索步骤,直接进入申请表单(群成员加好友 / 名片加好友等场景) */
const presetUser = ref<UserVO | null>(null)
/** 添加来源;参见 ImFriendAddSourceEnum默认 SEARCH */
const addSource = ref<number>(ImFriendAddSource.SEARCH)
/** 来源附带信息addSource=ImFriendAddSource.GROUP 时传群名,话术拼为「我是 XX 群的 YY」 */
const addSourceExtra = ref<string>('')
defineExpose({
/** 打开加好友弹窗reset → 灌参 → visible=true不传 opts 走搜索模式 */
open(opts?: { presetUser?: UserVO | null; addSource?: number; addSourceExtra?: string }) {
presetUser.value = opts?.presetUser ?? null
addSource.value = opts?.addSource ?? ImFriendAddSource.SEARCH
addSourceExtra.value = opts?.addSourceExtra ?? ''
resetAll()
visible.value = true
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
/** 弹窗显隐:把父侧 v-model 转双向计算 */
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const friendStore = useFriendStore()
@ -197,22 +190,15 @@ const submitting = ref(false)
const dialogTitle = computed(() => (step.value === 'apply' ? '申请添加朋友' : '添加好友'))
/** 是否预填模式presetUser 不为空 → 跳过搜索,关闭即销毁,无「取消返回搜索」按钮) */
const presetMode = computed(() => !!props.presetUser)
/** 每次重新打开都把搜索态 / 申请态清空,避免上次的数据泄漏到下次 */
watch(visible, (open) => {
if (open) {
resetAll()
}
})
const presetMode = computed(() => !!presetUser.value)
function resetAll() {
keyword.value = ''
users.value = []
searched.value = false
// targetUser presetUser addSource
if (props.presetUser) {
targetUser.value = props.presetUser
if (presetUser.value) {
targetUser.value = presetUser.value
applyContent.value = buildPresetApplyContent()
displayName.value = ''
step.value = 'apply'
@ -232,7 +218,7 @@ function buildPresetApplyContent(): string {
return ''
}
// YY
const groupExtra = props.addSource === ImFriendAddSource.GROUP ? props.addSourceExtra : ''
const groupExtra = addSource.value === ImFriendAddSource.GROUP ? addSourceExtra.value : ''
return groupExtra ? `我是"${groupExtra}"的${myNickname}` : `我是${myNickname}`
}
@ -282,7 +268,7 @@ async function handleSubmitApply() {
toUserId: targetUser.value.id,
applyContent: applyContent.value.trim() || undefined,
displayName: displayName.value.trim() || undefined,
addSource: props.addSource
addSource: addSource.value
})
// silent loadFriendInfo WS FRIEND_ADD
if (requestId === null) {

View File

@ -0,0 +1,121 @@
<template>
<!--
设置群管理员一个弹窗合并增 / 提交时跟当前管理员列表 diff
- dialog 壳本组件持有选择 UI 委托 GroupMemberPickerPanelgrid 形态对齐当前视觉
- 群主从候选里隐藏不能设为管理员
- 对外接口ref + open({ groupId, members, currentAdminIds, hideIds, maxSize }) + emit reload()
-->
<el-dialog
v-model="visible"
title="设置群管理员"
width="700px"
:close-on-click-modal="false"
class="im-picker-dialog"
>
<div class="h-[480px]">
<GroupMemberPickerPanel
v-model:selected-ids="selectedIds"
:members="members"
:hide-ids="hideIds"
:max-size="maxSize"
selected-display="grid"
/>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleOk"></el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { addGroupAdmin, removeGroupAdmin } from '@/api/im/group'
import { GROUP_ADMIN_MAX_COUNT } from '@/views/im/utils/constants'
import GroupMemberPickerPanel from '../picker/GroupMemberPickerPanel.vue'
import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupAdminSetDialog' })
const emit = defineEmits<{
/** 管理员变更成功;父侧通常用来 reload 群数据 */
reload: []
}>()
const message = useMessage()
const visible = ref(false)
const submitting = ref(false)
const groupId = ref(0)
const members = ref<GroupMemberLite[]>([])
/** 当前管理员 userId 列表:默认勾选 + 提交时 diff */
const currentAdminIds = ref<number[]>([])
const hideIds = ref<number[]>([])
const maxSize = ref(GROUP_ADMIN_MAX_COUNT)
const selectedIds = ref<number[]>([])
defineExpose({
/** 打开设置管理员弹窗reset → 灌参 → visible=true */
open(opts: {
groupId: number
members: GroupMemberLite[]
/** 当前管理员 userId 列表(默认勾选) */
currentAdminIds: number[]
/** 隐藏 userId群主 */
hideIds?: number[]
/** 已选数上限;不传走 GROUP_ADMIN_MAX_COUNT */
maxSize?: number
}) {
groupId.value = opts.groupId
members.value = opts.members
currentAdminIds.value = [...opts.currentAdminIds]
hideIds.value = opts.hideIds ? [...opts.hideIds] : []
maxSize.value = opts.maxSize ?? GROUP_ADMIN_MAX_COUNT
selectedIds.value = [...opts.currentAdminIds]
submitting.value = false
visible.value = true
}
})
/** 跟当前管理员列表做差集,分别拿到要新增 / 撤销的 userId */
async function handleOk() {
if (!groupId.value) {
return
}
const previousIds = currentAdminIds.value
const previousIdSet = new Set(previousIds)
const nextIds = selectedIds.value
const nextIdSet = new Set(nextIds)
const addedIds = nextIds.filter((id) => !previousIdSet.has(id))
const removedIds = previousIds.filter((id) => !nextIdSet.has(id))
if (addedIds.length === 0 && removedIds.length === 0) {
visible.value = false
return
}
submitting.value = true
try {
if (addedIds.length > 0) {
await addGroupAdmin({ groupId: groupId.value, userIds: addedIds })
}
if (removedIds.length > 0) {
await removeGroupAdmin({ groupId: groupId.value, userIds: removedIds })
}
message.success(`已更新群管理员(新增 ${addedIds.length} 位,撤销 ${removedIds.length} 位)`)
emit('reload')
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -1,87 +1,24 @@
<template>
<!--
新建群聊对话框
- 顶部群名称输入
- 好友列表checkbox 前置
- 已勾选预览每行可 x 移除locked 不渲染 x
- 提交 createGroup inviteGroupMember最后让父页 reload
- lockedIds锁定不可取消的好友 id私聊侧 "+创建群" 入口用来锁定对方
发起群聊选好友 默认按所选成员名生成群名 createGroup
- dialog 壳本组件持有选择 UI 委托 FriendPickerPanel
- lockedIds 由调用方通过 open({ lockedIds }) 传入私聊侧 +建群锁定对方
- 不再要求先输入群名 / 不再展示进群审批开关对齐微信 PC
- 对外接口ref + open({ lockedIds }) + emit created(groupId)
-->
<el-dialog v-model="visible" title="新建群聊" width="620px" :close-on-click-modal="false">
<div class="flex flex-col gap-3">
<el-input v-model="groupName" placeholder="请输入群名称" maxlength="20" show-word-limit />
<!-- TODO @AI暂时不用这个入口对齐微信 -->
<div class="flex items-center gap-2 text-13px text-[var(--el-text-color-secondary)]">
<span class="shrink-0">进群需要群主 / 群管理确认</span>
<el-switch v-model="joinApproval" />
</div>
<div class="flex gap-2.5">
<div
class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]"
>
<el-input v-model="searchText" placeholder="搜索好友" clearable>
<template #prefix>
<Icon
icon="ant-design:search-outlined"
class="text-[var(--el-text-color-placeholder)]"
/>
</template>
</el-input>
<el-scrollbar class="h-[400px]">
<FriendItem
v-for="friend in shownFriends"
:key="friend.id"
:friend="friend"
:menu="false"
:active="false"
@click="handleToggleCheck(friend)"
>
<template #prefix>
<el-checkbox
:model-value="friend.checked"
:disabled="friend.disabled"
@click.stop
@change="(value) => handleCheckChange(friend, !!value)"
/>
</template>
</FriendItem>
</el-scrollbar>
</div>
<div class="flex items-center text-lg text-[#409eff]">
<Icon icon="ant-design:double-right-outlined" />
</div>
<div
class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]"
>
<!-- 标题高度对齐左侧 el-input default32px保证两侧第一项起点在同一水平 -->
<div
class="h-8 pl-2.5 leading-8 text-13px text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]"
>
已勾选 {{ checkedFriends.length }} 位好友
</div>
<el-scrollbar class="h-[400px]">
<FriendItem
v-for="friend in checkedFriends"
:key="friend.id"
:friend="friend"
:menu="false"
:active="false"
>
<!-- locked 的好友不渲染 x避免误以为可移除 -->
<Icon
v-if="!friend.disabled"
icon="ant-design:close-outlined"
class="im-group-create-dialog__remove"
@click.stop="handleUncheck(friend)"
/>
</FriendItem>
</el-scrollbar>
</div>
</div>
<el-dialog
v-model="visible"
title="发起群聊"
width="720px"
:close-on-click-modal="false"
class="im-picker-dialog"
>
<div class="h-[480px]">
<FriendPickerPanel
v-model:selected-ids="selectedIds"
:friends="friends"
:locked-ids="lockedIds"
/>
</div>
<template #footer>
@ -94,131 +31,91 @@
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { computed, ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { createGroup } from '@/api/im/group'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import FriendItem from '../friend/FriendItem.vue'
import { buildDefaultGroupName } from '../../../utils/group'
import FriendPickerPanel from '../picker/FriendPickerPanel.vue'
import type { FriendLite } from '../../types'
defineOptions({ name: 'ImGroupCreateDialog' })
interface FriendCheckable extends FriendLite {
checked?: boolean
disabled?: boolean // locked 的好友:勾选态由 lockedIds 强制为 trueUI 上 checkbox / x 都不响应
}
const props = withDefaults(
defineProps<{
modelValue: boolean
friends?: FriendLite[] // friendStore
lockedIds?: number[] // id + "+"
}>(),
{
friends: () => [],
lockedIds: () => []
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
created: [groupId: number] //
/** 创建成功,携带新群编号;父侧通常用来跳转到新群会话 */
created: [groupId: number]
}>()
const message = useMessage()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
/** 弹窗显隐:把父侧 v-model 转双向计算 */
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const groupName = ref('')
const joinApproval = ref<boolean>(false) //
const searchText = ref('')
const visible = ref(false)
const submitting = ref(false)
const workingFriends = ref<FriendCheckable[]>([]) // 工作副本(带 checked / disabled 标记 prop 隔离
const lockedIds = ref<number[]>([])
const selectedIds = ref<number[]>([])
watch(
visible,
(open) => {
if (!open) {
return
}
groupName.value = ''
joinApproval.value = false
searchText.value = ''
workingFriends.value = props.friends
.filter((friend) => !friend.deleted)
.map((friend) => {
const locked = props.lockedIds.some((id) => id === friend.id)
return { ...friend, checked: locked, disabled: locked }
})
},
{ immediate: true }
)
/** 左侧展示的好友:按搜索关键字过滤 workingFriends */
const shownFriends = computed(() =>
workingFriends.value.filter((friend) => friend.nickname.includes(searchText.value))
)
/** 已勾选的好友:右侧预览 + 提交时取 memberUserIds */
const checkedFriends = computed(() => workingFriends.value.filter((friend) => friend.checked))
/**
* 完成按钮可点群名非空 + 至少有 1 个非 locked 勾选
*
* locked 是入口侧自动选的如私聊对方不算"用户主动选择"否则用户什么都没勾就能建 2 人群体验上等于私聊
*/
const canSubmit = computed(() => {
if (!groupName.value.trim()) {
return false
defineExpose({
/** 打开发起群聊弹窗reset → 灌参 → visible=true */
open(opts?: { lockedIds?: number[] }) {
lockedIds.value = opts?.lockedIds ? [...opts.lockedIds] : []
selectedIds.value = []
submitting.value = false
visible.value = true
}
return checkedFriends.value.some((friend) => !friend.disabled)
})
/** 行点击切换勾选态locked 的不响应 */
function handleToggleCheck(friend: FriendCheckable) {
if (friend.disabled) {
return
/** 全量好友:直接复用 friendStore Lite 视图(带拼音字段供分桶用) */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendsLite)
/** 完成按钮可点:至少有 1 个非 locked 勾选locked 是入口锁定项,不算"用户主动选择" */
const canSubmit = computed(() => selectedIds.value.length > 0)
/** 拿到所有要进群的好友locked + selected建群默认群名按这批人生成 */
function resolveMembersToInvite(): FriendLite[] {
const seen = new Set<number>()
const result: FriendLite[] = []
const byId = new Map(friends.value.map((f) => [f.id, f]))
for (const id of lockedIds.value) {
if (seen.has(id)) {
continue
}
const friend = byId.get(id)
if (friend) {
seen.add(id)
result.push(friend)
}
}
friend.checked = !friend.checked
}
/** checkbox change直接落 valuelocked 已由 :disabled 拦截,这里再守一层) */
function handleCheckChange(friend: FriendCheckable, value: boolean) {
if (friend.disabled) {
return
for (const id of selectedIds.value) {
if (seen.has(id)) {
continue
}
const friend = byId.get(id)
if (friend) {
seen.add(id)
result.push(friend)
}
}
friend.checked = value
return result
}
/** 右侧 x 点击取消勾选locked 不渲染 x到这里说明非 locked */
function handleUncheck(friend: FriendCheckable) {
friend.checked = false
}
/** 创建群聊:建群(同时邀请初始成员)→ upsert groupStore → emit('created') 让父页跳转新会话 */
/** 创建群聊:建群(同时邀请初始成员)→ upsert groupStore → emit created 让父页跳转新会话 */
async function handleOk() {
const name = groupName.value.trim()
const memberUserIds = checkedFriends.value.map((friend) => friend.id)
// canSubmit disabled
if (!name || memberUserIds.length === 0) {
const members = resolveMembersToInvite()
if (members.length === 0) {
return
}
submitting.value = true
try {
// 1.
const group = await createGroup({ name, memberUserIds, joinApproval: joinApproval.value })
const memberUserIds = members.map((m) => m.id)
const name = buildDefaultGroupName(members)
const group = await createGroup({ name, memberUserIds, joinApproval: false })
if (!group?.id) {
throw new Error('创建群失败:未返回群编号')
}
// 2.1 upsert groupStore fetchGroups VO
// upsert groupStore fetchGroups VO
groupStore.upsertGroup({
id: group.id,
name: group.name,
@ -226,7 +123,6 @@ async function handleOk() {
notice: group.notice,
ownerUserId: group.ownerUserId
})
// 2.2 + emit +
message.success('群聊创建成功')
emit('created', group.id)
visible.value = false
@ -236,15 +132,10 @@ async function handleOk() {
}
</script>
<style scoped>
/* 右侧已选行的 x默认浅灰hover 转危险色,提示"点了就移除" */
.im-group-create-dialog__remove {
font-size: 14px;
color: var(--el-text-color-placeholder);
cursor: pointer;
transition: color 0.15s;
}
.im-group-create-dialog__remove:hover {
color: var(--el-color-danger);
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -1,143 +1,104 @@
<template>
<!--
邀请好友入群对话框
- 好友列表checkbox 前置
- 已勾选预览每行可 x 移除
- 已在群内的好友 disabled复用 GroupCreateDialog 的视觉
添加群成员选好友邀请入群已在群成员置灰不计入已选
- dialog 壳本组件持有选择 UI 委托 FriendPickerPanel
- 已在群成员通过 disabledIds 传入不再走 checked+disabled "已勾选灰态"
- 对外接口ref + open({ groupId }) + emit reload(friendIds)
-->
<el-dialog v-model="visible" title="邀请好友" width="620px" :close-on-click-modal="false">
<div class="flex gap-2.5">
<div
class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]"
>
<el-input v-model="searchText" placeholder="搜索好友" clearable>
<template #prefix>
<Icon
icon="ant-design:search-outlined"
class="text-[var(--el-text-color-placeholder)]"
/>
</template>
</el-input>
<el-scrollbar class="h-[400px]">
<FriendItem
v-for="friend in shownFriends"
:key="friend.id"
:friend="friend"
:menu="false"
:active="false"
@click="handleToggleCheck(friend)"
>
<template #prefix>
<el-checkbox
:model-value="friend.checked"
:disabled="friend.disabled"
@click.stop
@change="(value) => handleCheckChange(friend, !!value)"
/>
</template>
</FriendItem>
</el-scrollbar>
</div>
<div class="flex items-center text-lg text-[#409eff]">
<Icon icon="ant-design:double-right-outlined" />
</div>
<div
class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]"
>
<!-- 标题高度对齐左侧 el-input default32px保证两侧第一项起点在同一水平 -->
<div
class="h-8 pl-2.5 leading-8 text-13px text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]"
>
已勾选 {{ checkedFriends.length }} 位好友
</div>
<el-scrollbar class="h-[400px]">
<FriendItem
v-for="friend in checkedFriends"
:key="friend.id"
:friend="friend"
:menu="false"
:active="false"
>
<Icon
icon="ant-design:close-outlined"
class="im-group-member-add-dialog__remove"
@click.stop="handleUncheck(friend)"
/>
</FriendItem>
</el-scrollbar>
</div>
<el-dialog
v-model="visible"
title="添加群成员"
width="720px"
:close-on-click-modal="false"
class="im-picker-dialog"
>
<div class="h-[480px]">
<FriendPickerPanel
v-model:selected-ids="selectedIds"
:friends="friends"
:disabled-ids="disabledIds"
/>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleOk">
完成
添加
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { computed, ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { CommonStatusEnum } from '@/utils/constants'
import { inviteGroupMember } from '@/api/im/group/member'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { useUserStore } from '@/store/modules/user'
import { ImGroupMemberRole } from '@/views/im/utils/constants'
import { useGroupStore } from '../../store/groupStore'
import FriendItem from '../friend/FriendItem.vue'
import type { FriendLite } from '../../types'
import FriendPickerPanel from '../picker/FriendPickerPanel.vue'
import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupMemberAddDialog' })
interface FriendCheckable extends FriendLite {
checked?: boolean
disabled?: boolean // checkbox +
}
const props = withDefaults(
defineProps<{
modelValue: boolean
groupId?: number
members?: GroupMemberLite[] //
friends?: FriendLite[] // friendStore
}>(),
{
members: () => [],
friends: () => []
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
reload: [friendIds: number[]] // id
/** 邀请成功,携带被邀请的好友 id 列表;父侧通常用来 reload 群成员 */
reload: [friendIds: number[]]
}>()
const message = useMessage()
const userStore = useUserStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const userStore = useUserStore()
/** 弹窗显隐:把父侧 v-model 转双向计算 */
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
const visible = ref(false)
const submitting = ref(false)
const groupId = ref(0)
const selectedIds = ref<number[]>([])
defineExpose({
/** 打开添加群成员弹窗reset → 灌参 → visible=true */
open(opts: { groupId: number }) {
groupId.value = opts.groupId
selectedIds.value = []
submitting.value = false
visible.value = true
}
})
/** 是否走审批:群开启 joinApproval + 当前用户是普通成员;群主 / 管理员邀请直进,不落审批记录 */
/** 当前群成员列表:从 groupStore 现取,避免随 groupId 变化时父侧 prop 更新延迟 */
const members = computed<GroupMemberLite[]>(() => {
const group = groupStore.getGroup(groupId.value)
return (group?.members || []).map((member) => ({
userId: member.userId,
nickname: member.nickname,
showName: member.displayUserName || member.nickname,
avatar: member.avatar,
status: member.status,
role: member.role
}))
})
/** 全量好友:直接复用 friendStore Lite 视图 */
const friends = computed(() => friendStore.getActiveFriendsLite)
/** 已在群里的好友 id传给 Panel 的 disabledIds 置灰 + 不计入已选 */
const disabledIds = computed<number[]>(() =>
members.value
.filter((member) => member.status !== CommonStatusEnum.DISABLE)
.map((member) => member.userId)
)
/** 是否走审批分支:群开启 joinApproval + 当前用户是普通成员(群主 / 管理员邀请直进) */
const willGoApproval = computed(() => {
if (!props.groupId) {
return false
}
const group = groupStore.getGroup(props.groupId)
const group = groupStore.getGroup(groupId.value)
if (!group?.joinApproval) {
return false
}
const myId = Number(userStore.getUser?.id)
const myId = Number(userStore.getUser?.id) || 0
if (!myId) {
return false
}
@ -146,86 +107,28 @@ const willGoApproval = computed(() => {
return false
}
// members admin
const myRole = props.members.find((member) => member.userId === myId)?.role
const myRole = members.value.find((member) => member.userId === myId)?.role
if (myRole == null) {
return false
}
return myRole !== ImGroupMemberRole.ADMIN
})
const searchText = ref('')
const submitting = ref(false)
const workingFriends = ref<FriendCheckable[]>([]) // 工作副本(带 checked / disabled 标记 prop 隔离
watch(
visible,
(open) => {
if (!open) {
return
}
searchText.value = ''
workingFriends.value = props.friends
.filter((friend) => !friend.deleted)
.map((friend) => {
const inGroup = props.members.some(
(member) => member.status !== CommonStatusEnum.DISABLE && member.userId === friend.id
)
return {
...friend,
checked: inGroup,
disabled: inGroup
}
})
},
{ immediate: true }
)
/** 左侧展示的好友:按搜索关键字过滤 workingFriends */
const shownFriends = computed(() =>
workingFriends.value.filter((friend) => friend.nickname.includes(searchText.value))
)
/** 本次将被邀请的好友:勾选 + 非已在群成员disabled 不计入) */
const checkedFriends = computed(() =>
workingFriends.value.filter((friend) => friend.checked && !friend.disabled)
)
/** 完成按钮可点:至少有 1 个新邀请的好友 */
const canSubmit = computed(() => checkedFriends.value.length > 0)
/** 行点击切换勾选态已在群disabled的不响应 */
function handleToggleCheck(friend: FriendCheckable) {
if (friend.disabled) {
return
}
friend.checked = !friend.checked
}
/** checkbox change直接落 valuedisabled 已由属性拦截,这里再守一层) */
function handleCheckChange(friend: FriendCheckable, value: boolean) {
if (friend.disabled) {
return
}
friend.checked = value
}
/** 右侧 x 点击取消勾选disabled 不会进右侧列表,到这里说明非 disabled */
function handleUncheck(friend: FriendCheckable) {
friend.checked = false
}
/** 添加按钮可点:至少有 1 个新邀请的好友 */
const canSubmit = computed(() => selectedIds.value.length > 0)
/** 邀请入群:调 /im/group/invite成功后 emit reload 让父侧刷新群成员 */
async function handleOk() {
if (!props.groupId) {
if (!groupId.value) {
return
}
const memberUserIds = checkedFriends.value.map((friend) => friend.id)
const memberUserIds = [...selectedIds.value]
if (memberUserIds.length === 0) {
return
}
submitting.value = true
try {
await inviteGroupMember({ groupId: props.groupId, memberUserIds })
await inviteGroupMember({ groupId: groupId.value, memberUserIds })
//
message.success(willGoApproval.value ? '邀请已发起,等待群主 / 管理员审批' : '邀请成功')
emit('reload', memberUserIds)
@ -236,15 +139,11 @@ async function handleOk() {
}
</script>
<style scoped>
/* 右侧已选行的 x默认浅灰hover 转危险色,提示"点了就移除" */
.im-group-member-add-dialog__remove {
font-size: 14px;
color: var(--el-text-color-placeholder);
cursor: pointer;
transition: color 0.15s;
}
.im-group-member-add-dialog__remove:hover {
color: var(--el-color-danger);
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -2,7 +2,7 @@
<!--
群成员宫格单元
- 宫格展示的最小单位头像在上名字在下列宽 = size + 16自适应 size 留呼吸空间
- GroupMemberSelector 右侧已选区ConversationGroupSide 群成员区循环使用
- GroupMemberPickerPanel 右侧已选区grid 形态ConversationGroupSide 群成员区循环使用
-->
<div
class="relative flex flex-col items-center px-0.5 py-1"

View File

@ -0,0 +1,103 @@
<template>
<!--
移除群成员选成员 removeGroupMember 批量踢人
- dialog 壳本组件持有选择 UI 委托 GroupMemberPickerPanel
- 群主 / 管理员视角的不可移除成员通过 hideIds 隐藏由调用方传入
- 对外接口ref + open({ groupId, members, hideIds }) + emit reload()
-->
<el-dialog
v-model="visible"
title="移出群成员"
width="700px"
:close-on-click-modal="false"
class="im-picker-dialog"
>
<div class="h-[480px]">
<GroupMemberPickerPanel
v-model:selected-ids="selectedIds"
:members="members"
:hide-ids="hideIds"
:max-size="50"
/>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="selectedIds.length === 0"
@click="handleOk"
>
移出
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { removeGroupMember } from '@/api/im/group/member'
import GroupMemberPickerPanel from '../picker/GroupMemberPickerPanel.vue'
import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupMemberRemoveDialog' })
const emit = defineEmits<{
/** 移出成功;父侧通常用来 reload 群数据 */
reload: []
}>()
const message = useMessage()
const visible = ref(false)
const submitting = ref(false)
const groupId = ref(0)
const members = ref<GroupMemberLite[]>([])
const hideIds = ref<number[]>([])
const selectedIds = ref<number[]>([])
defineExpose({
/** 打开移除群成员弹窗reset → 灌参 → visible=true */
open(opts: {
groupId: number
members: GroupMemberLite[]
/** 隐藏 userId群主始终隐藏管理员视角额外隐藏其它管理员 */
hideIds?: number[]
}) {
groupId.value = opts.groupId
members.value = opts.members
hideIds.value = opts.hideIds ? [...opts.hideIds] : []
selectedIds.value = []
submitting.value = false
visible.value = true
}
})
/** 一次性批量踢人:选中成员 userId 数组传给后端,比循环调 N 次接口省往返 */
async function handleOk() {
if (!groupId.value || selectedIds.value.length === 0) {
return
}
submitting.value = true
try {
const memberUserIds = [...selectedIds.value]
await removeGroupMember({ groupId: groupId.value, memberUserIds })
message.success(`已移除 ${memberUserIds.length} 位成员`)
emit('reload')
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -1,179 +0,0 @@
<template>
<!--
群成员选择器
- 搜索 + 群成员列表 checkbox
- 已勾选的成员宫格预览
- 确定时 emit complete抛出选中的成员列表
-->
<el-dialog v-model="visible" :title="title" width="700px" :close-on-click-modal="false">
<div class="flex gap-2.5">
<div
class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]"
>
<el-input v-model="searchText" placeholder="搜索" clearable>
<template #suffix>
<Icon icon="ant-design:search-outlined" />
</template>
</el-input>
<div class="h-[400px]">
<PagedScroller :items="showMembers" :page-size="30">
<template #default="{ item }">
<GroupMemberItem
:member="item as GroupMemberFlag"
:height="46"
@click="handleToggleCheck(item as GroupMemberFlag)"
>
<el-checkbox
:model-value="(item as GroupMemberFlag).checked"
:disabled="(item as GroupMemberFlag).locked"
@click.stop
@change="(val: boolean) => handleCheckChange(item as GroupMemberFlag, val)"
/>
</GroupMemberItem>
</template>
</PagedScroller>
</div>
</div>
<div class="flex items-center text-xl text-[#409eff]">
<Icon icon="ant-design:double-right-outlined" />
</div>
<div
class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]"
>
<div
class="h-10 pl-2.5 leading-10 text-13px text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]"
>
已勾选 {{ checkedMembers.length }} 位成员
</div>
<el-scrollbar class="h-[400px]">
<div class="flex flex-wrap p-2.5">
<GroupMemberGrid v-for="m in checkedMembers" :key="m.userId" :member="m" />
</div>
</el-scrollbar>
</div>
</div>
<template #footer>
<el-button @click="visible = false"> </el-button>
<el-button type="primary" @click="handleOk"> </el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import { CommonStatusEnum } from '@/utils/constants'
import GroupMemberItem from './GroupMemberItem.vue'
import GroupMemberGrid from './GroupMemberGrid.vue'
import PagedScroller from '../PagedScroller.vue'
import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupMemberSelector' })
/** 选择器内部扩展:加上 checked / locked / hide 标记 */
export interface GroupMemberFlag extends GroupMemberLite {
checked?: boolean
locked?: boolean
hide?: boolean
}
const props = withDefaults(
defineProps<{
modelValue: boolean
title?: string
members?: GroupMemberLite[] // status/avatar
checkedIds?: number[] // userId
lockedIds?: number[] // userId
hideIds?: number[] // userId
maxSize?: number // -1
}>(),
{
title: '选择成员',
members: () => [],
checkedIds: () => [],
lockedIds: () => [],
hideIds: () => [],
maxSize: -1
}
)
const emit = defineEmits<{
'update:modelValue': [value: boolean]
complete: [members: GroupMemberFlag[]] // ""
}>()
const message = useMessage()
/** 弹窗显隐:把父侧 v-model 转双向计算 */
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const searchText = ref('')
const workingMembers = ref<GroupMemberFlag[]>([])
watch(
visible,
(v) => {
if (v) rebuild()
},
{ immediate: true }
)
/** 重建工作副本:把 checkedIds / lockedIds / hideIds 翻译成每个 member 的 flag */
function rebuild() {
workingMembers.value = props.members.map((member) => ({
...member,
checked: props.checkedIds.some((id) => id === member.userId),
locked: props.lockedIds.some((id) => id === member.userId),
hide: props.hideIds.some((id) => id === member.userId)
}))
}
/** 当前展示的成员:过滤 hide / DISABLE / 不匹配关键字 */
const showMembers = computed(() =>
workingMembers.value.filter(
(member) =>
!member.hide &&
member.status !== CommonStatusEnum.DISABLE &&
member.showName.includes(searchText.value)
)
)
/** 已勾选成员:右侧宫格预览 + complete 抛参 */
const checkedMembers = computed(() => workingMembers.value.filter((member) => member.checked))
/** 落勾选并校验上限:超过 maxSize 时自动取消并提示,避免出现"勾上但实际不算"的中间态 */
function applyCheck(member: GroupMemberFlag, checked: boolean) {
member.checked = checked
if (props.maxSize > 0 && checkedMembers.value.length > props.maxSize) {
message.error(`最多选择 ${props.maxSize} 位成员`)
member.checked = false
}
}
/** 行点击切换勾选态locked 的不响应 */
function handleToggleCheck(member: GroupMemberFlag) {
if (member.locked) {
return
}
applyCheck(member, !member.checked)
}
/** checkbox change直接落 checkedlocked 已由 disabled 拦截) */
function handleCheckChange(member: GroupMemberFlag, checked: boolean) {
applyCheck(member, checked)
}
/** 确定:把已勾选成员通过 complete 抛给父侧 */
function handleOk() {
emit('complete', checkedMembers.value)
visible.value = false
}
</script>

View File

@ -0,0 +1,123 @@
<template>
<!--
转让群主 1 位新群主 二次确认 transferGroupOwner
- dialog 壳本组件持有选择 UI 委托 GroupMemberPickerPanel
- 当前用户从候选里隐藏不能转给自己
- maxSize=1 限定单选
- 对外接口ref + open({ groupId, members, hideIds }) + emit reload()
-->
<el-dialog
v-model="visible"
title="选择新群主"
width="700px"
:close-on-click-modal="false"
class="im-picker-dialog"
>
<div class="h-[480px]">
<GroupMemberPickerPanel
v-model:selected-ids="selectedIds"
:members="members"
:hide-ids="hideIds"
:max-size="1"
/>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="submitting"
:disabled="selectedIds.length === 0"
@click="handleOk"
>
确定
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { useMessage } from '@/hooks/web/useMessage'
import { transferGroupOwner } from '@/api/im/group'
import GroupMemberPickerPanel from '../picker/GroupMemberPickerPanel.vue'
import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupOwnerTransferDialog' })
const emit = defineEmits<{
/** 转让成功;父侧通常用来 reload 群数据 */
reload: []
}>()
const message = useMessage()
const visible = ref(false)
const submitting = ref(false)
const groupId = ref(0)
const members = ref<GroupMemberLite[]>([])
const hideIds = ref<number[]>([])
const selectedIds = ref<number[]>([])
defineExpose({
/** 打开转让群主弹窗reset → 灌参 → visible=true */
open(opts: {
groupId: number
members: GroupMemberLite[]
/** 隐藏 userId当前用户不能转给自己 */
hideIds?: number[]
}) {
groupId.value = opts.groupId
members.value = opts.members
hideIds.value = opts.hideIds ? [...opts.hideIds] : []
selectedIds.value = []
submitting.value = false
visible.value = true
}
})
/** 选中的新群主对象(取数组首项) */
const newOwner = computed<GroupMemberLite | undefined>(() => {
if (selectedIds.value.length === 0) {
return undefined
}
return members.value.find((member) => member.userId === selectedIds.value[0])
})
/** 二次确认转让:转让后旧群主降为普通成员,无法撤销 */
async function handleOk() {
if (!groupId.value || !newOwner.value) {
return
}
try {
await message.confirm(
`确定将群主转让给 ${newOwner.value.showName}?转让后你将变为普通成员,无法撤销。`,
'确认转让群主'
)
} catch {
return
}
submitting.value = true
try {
await transferGroupOwner({
groupId: groupId.value,
newOwnerUserId: newOwner.value.userId
})
message.success('群主转让成功')
emit('reload')
visible.value = false
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -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<number | undefined>()
const loading = ref(false)
const groupList = ref<ImGroupRequestRespVO[]>([])
const actingId = ref<number | null>(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<ImGroupRequestRespVO[]>(() =>
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)
}
}
)

View File

@ -0,0 +1,359 @@
<template>
<!--
会话选择面板用于推荐名片 / 转发消息等"选已有会话"场景
- 搜索 + 最近转发横向头像 + 创建聊天入口 + 最近聊天列表圆形勾选
- 已选数标题 + 已选会话列表按点击顺序+ footer slot
- Panel 不带 el-dialog dialog 由业务壳持有
- footer slot 渲染在右栏已选列表下方业务壳放预览卡 / 留言 / 提交按钮
-->
<div class="flex h-full im-conversation-picker">
<!-- 左栏 -->
<div
class="flex flex-col w-[280px] border-r border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]"
>
<!-- 搜索框 -->
<div class="flex-shrink-0 px-3 py-2">
<el-input v-model="keyword" placeholder="搜索" clearable>
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</el-input>
</div>
<el-scrollbar class="flex-1">
<!-- 最近转发横向头像区keyword 为空 + 有最近转发数据时展示 -->
<template v-if="showRecentSection">
<div class="flex justify-between items-center pl-3 pr-2 pb-1.5">
<span class="text-13px text-[var(--el-text-color-secondary)]">最近转发</span>
<span
class="px-1 cursor-pointer text-13px text-[var(--el-color-primary)] hover:opacity-80"
@click="recentRemoveMode = !recentRemoveMode"
>
{{ recentRemoveMode ? '完成' : '移除' }}
</span>
</div>
<div
class="flex gap-2 pl-3 pr-2 pt-1 pb-2 overflow-x-auto im-conversation-picker__recent"
>
<div
v-for="conversation in recentForwardConversations"
:key="getConversationKey(conversation)"
class="flex flex-col flex-shrink-0 gap-1 items-center"
:class="{ 'cursor-pointer': !recentRemoveMode }"
@click="handleRecentTileClick(conversation)"
>
<div class="relative">
<UserAvatar
:url="conversation.avatar"
:name="conversation.name"
:size="36"
:clickable="false"
/>
<!-- 移除模式右上角 × 圆角标点击把这条 key recentForwardConversationKeys 删掉 -->
<span
v-if="recentRemoveMode"
class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full cursor-pointer im-conversation-picker__recent-remove"
@click.stop="emit('remove-recent', getConversationKey(conversation))"
>
<Icon icon="ant-design:close-outlined" :size="10" />
</span>
<!-- 非移除模式右上角圆形勾选指示器未选灰空心圈选中绿底白对勾 -->
<span
v-else
class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full transition-colors"
:class="
isSelected(conversation)
? 'im-conversation-picker__recent-badge'
: 'border border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
"
>
<Icon
v-if="isSelected(conversation)"
icon="ant-design:check-outlined"
:size="10"
color="#fff"
/>
</span>
</div>
<span
class="overflow-hidden max-w-[48px] text-12px truncate text-[var(--el-text-color-primary)]"
>
{{ conversation.name }}
</span>
</div>
</div>
</template>
<!-- 创建聊天入口keyword 为空 + showCreateChat=true 时展示 -->
<div
v-if="showCreateChat && !keyword.trim()"
class="flex gap-2.5 items-center px-3 py-1.5 cursor-pointer hover:bg-[var(--el-fill-color)]"
@click="emit('create-chat')"
>
<span
class="flex flex-shrink-0 justify-center items-center w-8 h-8 rounded-full bg-[var(--el-fill-color)] text-[var(--el-text-color-secondary)]"
>
<Icon icon="ant-design:plus-outlined" :size="16" />
</span>
<span class="text-sm text-[var(--el-text-color-primary)]">创建聊天</span>
</div>
<!-- 最近聊天分组标题 -->
<div class="px-3 pb-1.5 text-13px text-[var(--el-text-color-secondary)]">最近聊天</div>
<!-- 会话列表 -->
<div
v-for="conversation in shownConversations"
:key="getConversationKey(conversation)"
class="flex gap-2.5 items-center px-3 py-1.5 cursor-pointer hover:bg-[var(--el-fill-color)]"
@click="handleToggle(conversation)"
>
<!-- 圆形勾选指示器未选灰色空心圆选中实心微信绿 + 白对勾 -->
<span
class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
:class="
isSelected(conversation)
? 'im-conversation-picker__check--checked'
: 'border border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
"
>
<Icon
v-if="isSelected(conversation)"
icon="ant-design:check-outlined"
:size="12"
color="#fff"
/>
</span>
<UserAvatar
:url="conversation.avatar"
:name="conversation.name"
:size="32"
:clickable="false"
/>
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ conversation.name }}
</span>
</div>
<!-- 空态 -->
<div
v-if="shownConversations.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
{{ keyword ? '没有满足条件的会话' : '暂无会话' }}
</div>
</el-scrollbar>
</div>
<!-- 右栏 -->
<div class="flex flex-col flex-1 min-w-0">
<!-- 标题 0/1发送给多个分别发送给与微信文案一致 -->
<div
class="flex-shrink-0 px-4 py-3 border-b text-13px text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]"
>
{{ sendTitle }}
</div>
<!-- 已选预览 selectedKeys 数组顺序点击顺序展示 -->
<el-scrollbar class="flex-1">
<div
v-for="conversation in selectedConversations"
:key="getConversationKey(conversation)"
class="flex gap-2.5 items-center px-4 py-2"
>
<UserAvatar
:url="conversation.avatar"
:name="conversation.name"
:size="32"
:clickable="false"
/>
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ conversation.name }}
</span>
<Icon
icon="ant-design:close-outlined"
:size="14"
class="flex-shrink-0 cursor-pointer im-conversation-picker__remove"
@click="handleToggle(conversation)"
/>
</div>
<div
v-if="selectedConversations.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
从左侧选择好友或群聊
</div>
</el-scrollbar>
<!-- 业务壳塞预览卡 / 留言 / 提交按钮的位置 -->
<div
v-if="$slots.footer"
class="flex-shrink-0 border-t border-[var(--el-border-color-lighter)]"
>
<slot name="footer"></slot>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import UserAvatar from '../user/UserAvatar.vue'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import type { Conversation } from '../../types'
defineOptions({ name: 'ImConversationPickerPanel' })
const props = withDefaults(
defineProps<{
/** 全量会话列表 */
conversations: Conversation[]
/** 已选会话 keyv-modelkey 由 getConversationKey 生成 */
selectedKeys: string[]
/** 最近转发会话 key 列表;展示在左栏顶部横向头像区 */
recentForwardConversationKeys?: string[]
/** 隐藏 key从候选 / 已选 / 最近转发里都剔除(不能转发回自己、推荐名片自身的会话等) */
hideKeys?: string[]
/** 已选数上限;不传或 <=0 时不限 */
maxSize?: number
/** 是否展示「创建聊天」入口 */
showCreateChat?: boolean
}>(),
{
recentForwardConversationKeys: () => [],
hideKeys: () => [],
maxSize: 0,
showCreateChat: false
}
)
const emit = defineEmits<{
'update:selectedKeys': [value: string[]]
'create-chat': []
/** 用户在「最近转发」段进入移除模式后点 ×;业务壳收到后调 conversationStore.removeRecentForwardConversationKey 落盘 */
'remove-recent': [key: string]
}>()
const message = useMessage()
const keyword = ref('')
/** 「最近转发」段是否处于移除模式true 时头像右上角变 × 不再切勾选 */
const recentRemoveMode = ref(false)
/** 全量会话的 key→Conversation 映射,已选 / 最近转发反查共用,避免每次 O(N) 扫 */
const byKey = computed(() => {
const map = new Map<string, Conversation>()
for (const conversation of props.conversations) {
map.set(getConversationKey(conversation), conversation)
}
return map
})
/** 隐藏集合:每次过滤复用 */
const hideSet = computed(() => new Set(props.hideKeys))
/** 已选集合:圆形指示器 isSelected 走 set 快查 */
const selectedSet = computed(() => new Set(props.selectedKeys))
/** 候选会话:剔除 hideKeys */
const candidateConversations = computed(() =>
props.conversations.filter((c) => !hideSet.value.has(getConversationKey(c)))
)
/** 左栏展示列表:在候选基础上按 keyword 过滤 */
const shownConversations = computed(() =>
filterConversationsByKeyword(candidateConversations.value, keyword.value)
)
/** 最近转发的会话对象列表:从 recentForwardConversationKeys 反查;剔除 hide / 不存在的 key */
const recentForwardConversations = computed(() =>
props.recentForwardConversationKeys
.map((key) => byKey.value.get(key))
.filter((c): c is Conversation => c != null && !hideSet.value.has(getConversationKey(c)))
)
/** 是否展示「最近转发」段keyword 为空 + 有数据时才展示,搜索时让位 */
const showRecentSection = computed(
() => !keyword.value.trim() && recentForwardConversations.value.length > 0
)
/** 已选会话列表:按 selectedKeys 数组顺序(即点击顺序)反查 */
const selectedConversations = computed(() =>
props.selectedKeys
.map((key) => byKey.value.get(key))
.filter((c): c is Conversation => c != null)
)
/** 右栏标题文案:单选「发送给」、多选「分别发送给」 */
const sendTitle = computed(() => (props.selectedKeys.length > 1 ? '分别发送给' : '发送给'))
/** 是否已选中:左栏圆形指示器 / 最近转发头像角标共用 */
function isSelected(conversation: Conversation): boolean {
return selectedSet.value.has(getConversationKey(conversation))
}
/** 「最近转发」头像点击:移除模式下不切勾选(移除由 × 角标处理) */
function handleRecentTileClick(conversation: Conversation) {
if (recentRemoveMode.value) {
return
}
handleToggle(conversation)
}
/** 切换选中态:左栏 row / 最近转发头像 / 右栏 × 移除都走这里 */
function handleToggle(conversation: Conversation) {
const key = getConversationKey(conversation)
const next = [...props.selectedKeys]
const index = next.indexOf(key)
if (index >= 0) {
next.splice(index, 1)
} else {
if (props.maxSize > 0 && next.length >= props.maxSize) {
message.error(`最多选择 ${props.maxSize} 个会话`)
return
}
next.push(key)
}
emit('update:selectedKeys', next)
}
</script>
<style scoped>
/* 选中态圆形指示器:微信绿底色 + 白对勾,不污染主题色 */
.im-conversation-picker__check--checked,
.im-conversation-picker__recent-badge {
background-color: #07c160;
border: 1px solid #07c160;
}
/* 最近转发移除模式的 × 角标:浅灰底 + 次要字色 */
.im-conversation-picker__recent-remove {
background-color: var(--el-fill-color-dark);
color: var(--el-text-color-primary);
}
/* 最近转发头像横向滚动条做窄一点,避免占视觉 */
.im-conversation-picker__recent::-webkit-scrollbar {
height: 4px;
}
.im-conversation-picker__recent::-webkit-scrollbar-thumb {
background-color: var(--el-border-color);
border-radius: 2px;
}
/* 已选行 × 移除常驻浅灰hover 转危险色 */
.im-conversation-picker__remove {
color: var(--el-text-color-placeholder);
transition: color 0.15s;
}
.im-conversation-picker__remove:hover {
color: var(--el-color-danger);
}
</style>

View File

@ -0,0 +1,276 @@
<template>
<!--
好友选择面板用于新建群聊 / 邀请好友 / 推荐时创建聊天等好友选择场景
- 搜索 + 字母分桶好友列表圆形勾选
- 已选数标题 + 已选好友列表按点击顺序
- Panel 不带 el-dialog dialog 由业务壳持有
- 三态语义hide > locked > disabled详见 contract
-->
<div class="flex h-full im-friend-picker">
<!-- 左栏 -->
<div
class="flex flex-col flex-1 min-w-0 border-r border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]"
>
<!-- 搜索框 -->
<div class="flex-shrink-0 px-3 py-2">
<el-input v-model="keyword" placeholder="搜索好友" clearable>
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</el-input>
</div>
<el-scrollbar class="flex-1">
<template v-for="bucket in buckets" :key="bucket.letter">
<!-- 字母分桶 header浅底 + 小字号 -->
<div
class="pt-1 pb-0.5 px-3.5 text-12px text-[var(--el-text-color-secondary)] bg-[var(--el-fill-color-lighter)]"
>
{{ bucket.letter }}
</div>
<div
v-for="friend in bucket.list"
:key="friend.id"
class="flex gap-2.5 items-center px-3 py-2 cursor-pointer hover:bg-[var(--el-fill-color)]"
:class="{
'opacity-60 cursor-not-allowed hover:bg-transparent': isDisabled(friend)
}"
@click="handleToggle(friend)"
>
<!-- 圆形勾选指示器未选灰色空心圆选中实心微信绿 + 白对勾锁定 / 禁用走灰底 -->
<span
class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
:class="getCheckClass(friend)"
>
<Icon
v-if="isSelected(friend) || isLocked(friend)"
icon="ant-design:check-outlined"
:size="12"
color="#fff"
/>
</span>
<UserAvatar
:id="friend.id"
:url="friend.avatar"
:name="friend.nickname"
:size="36"
:clickable="false"
/>
<!-- 行内名字备注优先列表里不重复展示昵称 -->
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ friend.displayName || friend.nickname }}
</span>
</div>
</template>
<!-- 空态 -->
<div
v-if="filtered.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
{{ keyword ? '没有匹配的好友' : '暂无好友' }}
</div>
</el-scrollbar>
</div>
<!-- 右栏 -->
<div class="flex flex-col flex-1 min-w-0">
<!-- 标题已选数高度对齐左侧 input default32px保证两侧第一项起点同水平 -->
<div
class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b text-13px text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]"
>
已选择 {{ selectedCount }} 个好友
</div>
<!-- 已选预览 selectedIds + lockedIds 数组顺序点击顺序展示 -->
<el-scrollbar class="flex-1">
<div
v-for="friend in selectedFriends"
:key="friend.id"
class="flex gap-2.5 items-center px-4 py-2"
>
<UserAvatar
:id="friend.id"
:url="friend.avatar"
:name="friend.nickname"
:size="36"
:clickable="false"
/>
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ friend.displayName || friend.nickname }}
</span>
<!-- 锁定项不渲染 ×避免误以为可移除 -->
<Icon
v-if="!isLocked(friend)"
icon="ant-design:close-outlined"
:size="14"
class="flex-shrink-0 cursor-pointer im-friend-picker__remove"
@click="handleToggle(friend)"
/>
</div>
<div
v-if="selectedFriends.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
请从左侧选择好友
</div>
</el-scrollbar>
<!-- 业务壳塞额外内容的位置FriendPickerPanel 主流场景不需要 footer -->
<div
v-if="$slots.footer"
class="flex-shrink-0 border-t border-[var(--el-border-color-lighter)]"
>
<slot name="footer"></slot>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import UserAvatar from '../user/UserAvatar.vue'
import { useFriendBuckets } from '../../composables/useFriendBuckets'
import { useSelectedItems } from '../../composables/useSelectedItems'
import type { FriendLite } from '../../types'
defineOptions({ name: 'ImFriendPickerPanel' })
const props = withDefaults(
defineProps<{
/** 全量好友列表 */
friends: FriendLite[]
/** 已选好友 idv-model按数组顺序即为点击顺序 */
selectedIds: number[]
/** 锁定 id默认勾选、不可取消、计入已选数典型私聊侧 +建群锁定对方) */
lockedIds?: number[]
/** 禁用 id列表里展示置灰、不可勾选、不计入已选数典型邀请入群时已在群成员 */
disabledIds?: number[]
/** 隐藏 id不展示hide > locked > disabled */
hideIds?: number[]
/** 已选数上限;不传或 <=0 时不限 */
maxSize?: number
}>(),
{
lockedIds: () => [],
disabledIds: () => [],
hideIds: () => [],
maxSize: 0
}
)
const emit = defineEmits<{
'update:selectedIds': [value: number[]]
}>()
const message = useMessage()
const keyword = ref('')
/** id → friend 映射,已选反查 / 三态判定共用,避免每次 O(N) 扫 */
const byId = computed(() => {
const map = new Map<number, FriendLite>()
for (const friend of props.friends) {
map.set(friend.id, friend)
}
return map
})
/** 三态 id 集合:每次过滤复用 */
const hideSet = computed(() => new Set(props.hideIds))
const lockedSet = computed(() => new Set(props.lockedIds))
const disabledSet = computed(() => new Set(props.disabledIds))
const selectedSet = computed(() => new Set(props.selectedIds))
/** 候选好友:剔除 hideIdshide 优先级最高) */
const candidates = computed(() =>
props.friends.filter((friend) => !hideSet.value.has(friend.id))
)
/** 委托 useFriendBuckets搜索 + 字母分桶共用一套规则 */
const { filtered, buckets } = useFriendBuckets(candidates, keyword)
/** 已选数 + 已选好友列表:三态优先级 + 顺序拼接由 useSelectedItems 统一承担 */
const { selectedCount, selectedItems: selectedFriends } = useSelectedItems<FriendLite>(
() => props.selectedIds,
() => props.lockedIds,
() => props.disabledIds,
() => props.hideIds,
byId
)
/** 是否被锁定 */
function isLocked(friend: FriendLite): boolean {
return lockedSet.value.has(friend.id)
}
/** 是否被禁用locked / hide 已被前置过滤,剩下的才算 disabled */
function isDisabled(friend: FriendLite): boolean {
return !lockedSet.value.has(friend.id) && disabledSet.value.has(friend.id)
}
/** 是否选中locked 视为永远选中 */
function isSelected(friend: FriendLite): boolean {
return selectedSet.value.has(friend.id)
}
/** 圆形勾选指示器的 class选中 / 锁定走绿底,禁用灰底,未选空心圆 */
function getCheckClass(friend: FriendLite): string {
if (isLocked(friend) || isSelected(friend)) {
return 'im-friend-picker__check--checked'
}
if (isDisabled(friend)) {
return 'im-friend-picker__check--disabled'
}
return 'border border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
}
/** 切换选中态locked / disabled 不响应;右栏 × 移除 / 行 click 都走这里 */
function handleToggle(friend: FriendLite) {
if (isLocked(friend) || isDisabled(friend)) {
return
}
const next = [...props.selectedIds]
const index = next.indexOf(friend.id)
if (index >= 0) {
next.splice(index, 1)
} else {
if (props.maxSize > 0 && selectedCount.value >= props.maxSize) {
message.error(`最多选择 ${props.maxSize} 位好友`)
return
}
next.push(friend.id)
}
emit('update:selectedIds', next)
}
</script>
<style scoped>
/* 选中 / 锁定圆形指示器:微信绿底 + 白对勾,不污染主题色 */
.im-friend-picker__check--checked {
background-color: #07c160;
border: 1px solid #07c160;
}
/* 禁用项:浅灰底 + 浅灰边,提示「不可选」 */
.im-friend-picker__check--disabled {
background-color: var(--el-fill-color);
border: 1px solid var(--el-border-color);
}
/* 已选行 × 移除常驻浅灰hover 转危险色 */
.im-friend-picker__remove {
color: var(--el-text-color-placeholder);
transition: color 0.15s;
}
.im-friend-picker__remove:hover {
color: var(--el-color-danger);
}
</style>

View File

@ -0,0 +1,254 @@
<template>
<!--
群成员选择面板用于移除成员 / 设置管理员 / 转让群主 / 禁言成员等场景
- 搜索 + 群成员列表圆形勾选
- 已选数标题 + 已选成员list / grid 两种形态可配默认 list 对齐微信新视觉
- Panel 不带 el-dialog dialog 由业务壳持有
- 三态语义hide > locked > disabled
-->
<div class="flex h-full im-group-member-picker">
<!-- 左栏 -->
<div
class="flex flex-col flex-1 min-w-0 border-r border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]"
>
<!-- 搜索框 -->
<div class="flex-shrink-0 px-3 py-2">
<el-input v-model="keyword" placeholder="搜索成员" clearable>
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</el-input>
</div>
<div class="flex-1 min-h-0">
<PagedScroller :items="shownMembers" :page-size="30">
<template #default="{ item }">
<GroupMemberItem
:member="(item as GroupMemberLite)"
:height="46"
@click="handleToggle(item as GroupMemberLite)"
>
<span
class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
:class="getCheckClass(item as GroupMemberLite)"
>
<Icon
v-if="isSelected(item as GroupMemberLite) || isLocked(item as GroupMemberLite)"
icon="ant-design:check-outlined"
:size="12"
color="#fff"
/>
</span>
</GroupMemberItem>
</template>
</PagedScroller>
</div>
</div>
<!-- 右栏 -->
<div class="flex flex-col flex-1 min-w-0">
<!-- 标题已选数高度对齐左侧 input default32px -->
<div
class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b text-13px text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]"
>
已选择 {{ selectedCount }} 位成员
</div>
<el-scrollbar class="flex-1">
<!-- list 形态纵向行每行带 × 移除locked 不渲染 × -->
<template v-if="selectedDisplay === 'list'">
<div
v-for="member in selectedMembers"
:key="member.userId"
class="flex gap-2.5 items-center px-4 py-2"
>
<UserAvatar
:id="member.userId"
:url="member.avatar"
:name="member.nickname"
:size="36"
:clickable="false"
/>
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ member.showName }}
</span>
<Icon
v-if="!isLocked(member)"
icon="ant-design:close-outlined"
:size="14"
class="flex-shrink-0 cursor-pointer im-group-member-picker__remove"
@click="handleToggle(member)"
/>
</div>
</template>
<!-- grid 形态宫格预览管理员设置等场景沿用 -->
<div v-else class="flex flex-wrap p-2.5">
<GroupMemberGrid
v-for="member in selectedMembers"
:key="member.userId"
:member="member"
/>
</div>
<div
v-if="selectedMembers.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
请从左侧选择成员
</div>
</el-scrollbar>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import { CommonStatusEnum } from '@/utils/constants'
import GroupMemberItem from '../group/GroupMemberItem.vue'
import GroupMemberGrid from '../group/GroupMemberGrid.vue'
import UserAvatar from '../user/UserAvatar.vue'
import PagedScroller from '../PagedScroller.vue'
import { useSelectedItems } from '../../composables/useSelectedItems'
import type { GroupMemberLite } from '../group/GroupMember.vue'
defineOptions({ name: 'ImGroupMemberPickerPanel' })
const props = withDefaults(
defineProps<{
/** 群成员列表 */
members: GroupMemberLite[]
/** 已选 userIdv-model按数组顺序即为点击顺序 */
selectedIds: number[]
/** 锁定 userId默认勾选、不可取消、计入已选数 */
lockedIds?: number[]
/** 禁用 userId列表里展示置灰、不可勾选、不计入已选数 */
disabledIds?: number[]
/** 隐藏 userId不展示hide > locked > disabled */
hideIds?: number[]
/** 已选数上限;不传或 <=0 时不限 */
maxSize?: number
/** 已选区展示形态:默认 list 对齐微信新视觉 */
selectedDisplay?: 'list' | 'grid'
}>(),
{
lockedIds: () => [],
disabledIds: () => [],
hideIds: () => [],
maxSize: 0,
selectedDisplay: 'list'
}
)
const emit = defineEmits<{
'update:selectedIds': [value: number[]]
}>()
const message = useMessage()
const keyword = ref('')
/** userId → member 映射,已选反查 / 三态判定共用 */
const byId = computed(() => {
const map = new Map<number, GroupMemberLite>()
for (const member of props.members) {
map.set(member.userId, member)
}
return map
})
const hideSet = computed(() => new Set(props.hideIds))
const lockedSet = computed(() => new Set(props.lockedIds))
const disabledSet = computed(() => new Set(props.disabledIds))
const selectedSet = computed(() => new Set(props.selectedIds))
/** 当前展示的成员:剔除 hideIds、剔除已退群DISABLE、按关键字大小写无关过滤 */
const shownMembers = computed(() => {
const keywordLower = keyword.value.trim().toLowerCase()
return props.members.filter(
(member) =>
!hideSet.value.has(member.userId) &&
member.status !== CommonStatusEnum.DISABLE &&
(!keywordLower || member.showName.toLowerCase().includes(keywordLower))
)
})
/** 已选数 + 已选成员列表:三态优先级 + 顺序拼接由 useSelectedItems 统一承担 */
const { selectedCount, selectedItems: selectedMembers } = useSelectedItems<GroupMemberLite>(
() => props.selectedIds,
() => props.lockedIds,
() => props.disabledIds,
() => props.hideIds,
byId
)
/** 是否被锁定 */
function isLocked(member: GroupMemberLite): boolean {
return lockedSet.value.has(member.userId)
}
/** 是否被禁用locked / hide 已被前置过滤,剩下的才算 disabled */
function isDisabled(member: GroupMemberLite): boolean {
return !lockedSet.value.has(member.userId) && disabledSet.value.has(member.userId)
}
/** 是否选中locked 视为永远选中 */
function isSelected(member: GroupMemberLite): boolean {
return selectedSet.value.has(member.userId)
}
/** 圆形勾选指示器的 class */
function getCheckClass(member: GroupMemberLite): string {
if (isLocked(member) || isSelected(member)) {
return 'im-group-member-picker__check--checked'
}
if (isDisabled(member)) {
return 'im-group-member-picker__check--disabled'
}
return 'border border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
}
/** 切换选中态locked / disabled 不响应;右栏 × / 行 click 都走这里 */
function handleToggle(member: GroupMemberLite) {
if (isLocked(member) || isDisabled(member)) {
return
}
const next = [...props.selectedIds]
const index = next.indexOf(member.userId)
if (index >= 0) {
next.splice(index, 1)
} else {
if (props.maxSize > 0 && selectedCount.value >= props.maxSize) {
message.error(`最多选择 ${props.maxSize} 位成员`)
return
}
next.push(member.userId)
}
emit('update:selectedIds', next)
}
</script>
<style scoped>
.im-group-member-picker__check--checked {
background-color: #07c160;
border: 1px solid #07c160;
}
.im-group-member-picker__check--disabled {
background-color: var(--el-fill-color);
border: 1px solid var(--el-border-color);
}
.im-group-member-picker__remove {
color: var(--el-text-color-placeholder);
transition: color 0.15s;
}
.im-group-member-picker__remove:hover {
color: var(--el-color-danger);
}
</style>

View File

@ -0,0 +1,13 @@
// IM mixin
// <style scoped lang="scss"> @use + @include
@mixin styles {
:deep(.el-dialog__body) {
padding: 0;
border-top: 1px solid var(--el-border-color-lighter);
}
:deep(.el-dialog__header) {
margin-right: 0;
padding-bottom: 16px;
}
}

View File

@ -1,166 +1,107 @@
<template>
<!--
把名片推荐给朋友用户 / 群通用对齐微信 PC 双栏布局
- 左栏搜索 + 最近聊天列表圆形单/多选指示
- 右栏已选预览每行可移除+ 名片预览卡 + 留言 + 取消/发送
- 选中时按 1 个走发送多个走分别发送(n)文案与微信一致
把名片推荐给朋友用户 / 群通用
- dialog 壳由本组件持有选择 UI 委托 ConversationPickerPanel / FriendPickerPanel
- view='conversation'选已有会话发名片默认视图
- view='contact'创建聊天入口进入选好友建群再发名片业务壳层切视图两个 Panel 互不知道对方
- 1 发送多个走分别发送(n)文案与微信一致
- 失败的消息以 FAILED 状态留在对应会话气泡里供右键重试
- 对外接口ref + open({ target })不再走 v-model
-->
<el-dialog
v-model="visible"
:title="dialogTitle"
width="720px"
:close-on-click-modal="false"
class="im-recommend-dialog"
@open="resetForm"
class="im-picker-dialog im-recommend-dialog"
>
<div class="flex h-[480px]">
<!-- ============ 左栏搜索 + 会话列表 ============ -->
<div
class="flex flex-col w-[280px] border-r border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]"
<template #header>
<div class="flex gap-2 items-center">
<Icon
v-if="view === 'contact'"
icon="ant-design:arrow-left-outlined"
:size="16"
class="cursor-pointer im-recommend-dialog__back"
@click="view = 'conversation'"
/>
<span class="text-base text-[var(--el-text-color-primary)]">
{{ headerTitle }}
</span>
</div>
</template>
<div class="h-[480px]">
<!-- 会话视图选已有会话发送 -->
<ConversationPickerPanel
v-if="view === 'conversation'"
v-model:selected-keys="selectedKeys"
:conversations="candidateConversations"
:recent-forward-conversation-keys="conversationStore.recentForwardConversationKeys"
:hide-keys="hideKeys"
:show-create-chat="true"
@create-chat="handleSwitchToContact"
@remove-recent="conversationStore.removeRecentForwardConversationKey"
>
<!-- 搜索框 -->
<div class="px-3 py-3 flex-shrink-0">
<el-input v-model="keyword" placeholder="搜索" clearable size="small">
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</el-input>
</div>
<template #footer>
<div class="flex flex-col gap-3 px-4 py-3">
<!-- 名片预览卡 -->
<CardBubble v-if="target" :card="target" />
<div class="px-3 pb-1.5 text-13px text-[var(--el-text-color-secondary)] flex-shrink-0">
最近聊天
</div>
<!-- 会话列表 -->
<el-scrollbar class="flex-1">
<div
v-for="conversation in shownConversations"
:key="getConversationKey(conversation)"
class="flex gap-2.5 items-center px-3 py-1.5 cursor-pointer hover:bg-[var(--el-fill-color)]"
@click="handleToggle(conversation)"
>
<!-- 圆形单选指示选中绿底白对勾未选浅灰圈 div 实现避开 el-checkbox 方框观感 -->
<span
class="flex flex-shrink-0 items-center justify-center w-5 h-5 rounded-full transition-colors"
:class="
isSelected(conversation)
? 'bg-[#07c160]'
: 'border border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
"
>
<Icon
v-if="isSelected(conversation)"
icon="ant-design:check-outlined"
:size="12"
color="#fff"
<!-- 留言单行右侧表情按钮触发 FacePicker选中 emoji 拼到末尾 -->
<div class="relative">
<el-input v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言">
<template #suffix>
<Icon
icon="ant-design:smile-outlined"
:size="18"
class="cursor-pointer text-[var(--el-text-color-secondary)] hover:text-[var(--el-color-primary)]"
@click.stop="emojiVisible = !emojiVisible"
/>
</template>
</el-input>
<FacePicker
v-model:visible="emojiVisible"
mode="emoji"
class="bottom-full right-0 mb-2"
@select-emoji="handleEmojiSelect"
/>
</span>
<UserAvatar
:url="conversation.avatar"
:name="conversation.name"
:size="32"
:clickable="false"
/>
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ conversation.name }}
</span>
</div>
<div
v-if="shownConversations.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
{{ keyword ? '没有满足条件的会话' : '暂无会话' }}
</div>
</el-scrollbar>
</div>
</div>
<!-- ============ 右栏已选 + 名片卡 + 留言 + 按钮 ============ -->
<div class="flex flex-col flex-1 min-w-0">
<!-- 标题单选发送给/ 多选分别发送给与底部按钮文案保持一致 -->
<div
class="px-4 py-3 text-13px text-[var(--el-text-color-secondary)] flex-shrink-0 border-b border-[var(--el-border-color-lighter)]"
>
{{ sendTitle }}
</div>
<!-- 已选预览每行 头像 + 名字 + × 移除 -->
<el-scrollbar class="flex-1">
<div
v-for="conversation in selectedConversations"
:key="getConversationKey(conversation)"
class="flex gap-2.5 items-center px-4 py-2"
>
<UserAvatar
:url="conversation.avatar"
:name="conversation.name"
:size="28"
:clickable="false"
/>
<span
class="flex-1 min-w-0 overflow-hidden text-13px truncate text-[var(--el-text-color-primary)]"
>
{{ conversation.name }}
</span>
<Icon
icon="ant-design:close-outlined"
:size="14"
class="im-recommend__remove flex-shrink-0 cursor-pointer"
@click="handleToggle(conversation)"
/>
<!-- 操作按钮 0/1 显示发送多个显示分别发送(n) -->
<div class="flex gap-2 justify-end">
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="sending"
:disabled="selectedKeys.length === 0"
@click="handleSend"
>
{{ selectedKeys.length > 1 ? `分别发送(${selectedKeys.length}` : '发送' }}
</el-button>
</div>
</div>
<div
v-if="selectedConversations.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
从左侧选择联系人或群聊
</div>
</el-scrollbar>
</template>
</ConversationPickerPanel>
<!-- 名片预览卡 + 留言 + 按钮 -->
<div
class="flex flex-col gap-3 px-4 py-3 flex-shrink-0 border-t border-[var(--el-border-color-lighter)]"
>
<CardBubble v-if="target" :card="target" />
<!-- 留言单行右侧表情按钮触发 FacePicker(mode=emoji)所选 emoji 直接拼接到输入末尾 -->
<div class="relative">
<el-input v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言">
<template #suffix>
<Icon
icon="ant-design:smile-outlined"
:size="18"
class="cursor-pointer text-[var(--el-text-color-secondary)] hover:text-[var(--el-color-primary)]"
@click.stop="emojiVisible = !emojiVisible"
/>
</template>
</el-input>
<!-- bottom-full picker 下沿贴 input 顶部向上弹出right-0 对齐 input 右侧表情按钮 -->
<FacePicker
v-model:visible="emojiVisible"
mode="emoji"
class="bottom-full right-0 mb-2"
@select-emoji="handleEmojiSelect"
/>
</div>
<!-- 操作按钮 0/1 显示发送多个显示分别发送(n) -->
<div class="flex gap-2 justify-end">
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="sending"
:disabled="selectedKeys.length === 0"
@click="handleSend"
>
{{ selectedKeys.length > 1 ? `分别发送(${selectedKeys.length}` : '发送' }}
</el-button>
</div>
</div>
</div>
<!-- 好友视图选好友建群后发送 -->
<FriendPickerPanel
v-else
v-model:selected-ids="selectedFriendIds"
:friends="friends"
/>
</div>
<!-- 好友视图的 dialog footer建群并发送 -->
<template v-if="view === 'contact'" #footer>
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="sending"
:disabled="selectedFriendIds.length === 0"
@click="handleCreateGroupAndSend"
>
创建群聊并发送
</el-button>
</template>
</el-dialog>
</template>
@ -169,126 +110,114 @@ import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import { createGroup } from '@/api/im/group'
import CardBubble from '@/views/im/home/components/card/CardBubble.vue'
import UserAvatar from './UserAvatar.vue'
import ConversationPickerPanel from '../picker/ConversationPickerPanel.vue'
import FriendPickerPanel from '../picker/FriendPickerPanel.vue'
import FacePicker from '../../pages/conversation/components/input/FacePicker.vue'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import { useMessageSender } from '../../composables/useMessageSender'
import { ImMessageType, isGroupConversation } from '../../../utils/constants'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { serializeMessage, type CardMessage, type CardTarget } from '../../../utils/message'
import type { Conversation } from '../../types'
import {
ImConversationType,
ImMessageType,
isGroupConversation
} from '../../../utils/constants'
import { getConversationKey } from '../../../utils/conversation'
import { buildDefaultGroupName } from '../../../utils/group'
import { serializeMessage, type CardTarget } from '../../../utils/message'
import type { Conversation, FriendLite } from '../../types'
defineOptions({ name: 'ImRecommendCardDialog' })
const props = defineProps<{
modelValue: boolean
/** 被推荐的名片对象(用户 / 群通用);为 null 时不渲染(弹窗也不应被打开) */
target: CardTarget | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const message = useMessage()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const { sendRaw, send } = useMessageSender()
/** 弹窗显隐:把父侧 v-model 转双向计算 */
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/** 弹窗标题:群名片走「把这个群推荐给朋友」,否则「把他推荐给朋友」 */
const dialogTitle = computed(() =>
isGroupConversation(props.target?.targetType) ? '把这个群推荐给朋友' : '把他推荐给朋友'
)
const keyword = ref('')
const visible = ref(false)
const target = ref<CardTarget | null>(null)
/** 当前视图:默认会话选择,「创建聊天」入口切到好友选择 */
const view = ref<'conversation' | 'contact'>('conversation')
const selectedKeys = ref<string[]>([])
const selectedFriendIds = ref<number[]>([])
const leaveMessage = ref('')
const sending = ref(false)
/** 表情面板显隐:右侧 smile icon 切换 */
const emojiVisible = ref(false)
/** 已勾选的会话 key 列表type:targetId 组合主键selectedSet 派生用于 row 快查 */
const selectedKeys = ref<string[]>([])
/** 已选 key 集合handlerToggle 写数组row isSelected 走 set 快查避免 O(N) 扫描 */
const selectedSet = computed(() => new Set(selectedKeys.value))
/** 右栏标题:选中多个时改「分别发送给」与底部按钮文案保持一致 */
const sendTitle = computed(() => (selectedKeys.value.length > 1 ? '分别发送给' : '发送给'))
defineExpose({
/** 打开推荐弹窗reset → 灌参 → visible=true */
open(opts: { target: CardTarget }) {
target.value = opts.target
view.value = 'conversation'
selectedKeys.value = []
selectedFriendIds.value = []
leaveMessage.value = ''
emojiVisible.value = false
sending.value = false
visible.value = true
}
})
/** 弹窗打开时复位el-dialog @open 比 watch 更直观 */
function resetForm() {
keyword.value = ''
leaveMessage.value = ''
selectedKeys.value = []
emojiVisible.value = false
}
/** 弹窗标题:会话视图按 target 类型分文案;好友视图固定为「选择好友」 */
const headerTitle = computed(() => {
if (view.value === 'contact') {
return '选择好友'
}
return isGroupConversation(target.value?.targetType) ? '把这个群推荐给朋友' : '把他推荐给朋友'
})
/** 选中 emoji直接拼到留言末尾FacePicker 自身 emit('update:visible', false) 关闭面板 */
/** 候选会话:从 store 拿排序后的列表hide 由 Panel 接 hideKeys 过滤) */
const candidateConversations = computed<Conversation[]>(
() => conversationStore.getSortedConversations
)
/** 隐藏 key不能把名片推回名片本身的会话用户名片避免自推、群名片避免推回该群 */
const hideKeys = computed<string[]>(() => {
const t = target.value
if (!t) {
return []
}
return [getConversationKey({ type: t.targetType, targetId: t.targetId })]
})
/** 好友视图候选列表:直接复用 friendStore Lite 视图 */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendsLite)
/** 把选中的 emoji 拼到留言末尾FacePicker 自身负责关闭面板 */
function handleEmojiSelect(emoji: string) {
leaveMessage.value = `${leaveMessage.value}${emoji}`
}
/** 候选会话:过滤掉名片对象本身的会话(同 type + 同 id用户名片避免自推、群名片避免推回该群 */
const candidateConversations = computed<Conversation[]>(() => {
const target = props.target
if (!target) {
return conversationStore.getSortedConversations
}
return conversationStore.getSortedConversations.filter(
(c) => !(c.type === target.targetType && c.targetId === target.targetId)
)
})
/** 按搜索关键字过滤展示列表(仅按 name 模糊匹配) */
const shownConversations = computed(() =>
filterConversationsByKeyword(candidateConversations.value, keyword.value)
)
/** 已选会话:右栏预览渲染用,按 selectedKeys 顺序展示 */
const selectedConversations = computed<Conversation[]>(() => {
const keys = selectedSet.value
return candidateConversations.value.filter((c) => keys.has(getConversationKey(c)))
})
/** 是否已选中:圆形指示 + 右栏预览过滤都走它 */
function isSelected(conversation: Conversation): boolean {
return selectedSet.value.has(getConversationKey(conversation))
}
/** 切换选中态:左栏 row 点击 / 右栏 × 移除都走这里 */
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)
}
}
/** 构造名片消息 contentJSON 字符串CardTarget 字段已与 CardMessage 对齐spread 即可 */
function buildCardContent(target: CardTarget): string {
const payload: CardMessage = { ...target }
return serializeMessage(payload)
/** 切到好友视图:清掉之前在会话视图输入的留言,避免在不可见输入框里把留言静默发到新群 */
function handleSwitchToContact() {
view.value = 'contact'
leaveMessage.value = ''
emojiVisible.value = false
}
/**
* 确认发送每个选中会话先发 CARDCARD 成功后才发留言保证先看到名片的顺序意图CARD 失败时不发留言避免错序
* 确认发送会话视图每个选中会话先发 CARDCARD 成功后才发留言保证先看到名片的顺序意图
*
* 文案聚合全部成功已转发全部失败转发失败AB部分失败已转发 XY 失败具体列出失败会话名方便定位
* 文案聚合全部成功已转发全部失败转发失败AB部分失败已转发 XY 失败
* 失败的消息以 FAILED 状态留在对应会话气泡里可右键重试
*/
async function handleSend() {
const target = props.target
if (!target?.targetId || selectedKeys.value.length === 0) {
const card = target.value
if (!card?.targetId || selectedKeys.value.length === 0) {
return
}
const targets = selectedConversations.value
const cardContent = buildCardContent(target)
const byKey = new Map(candidateConversations.value.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 cardContent = serializeMessage({ ...card })
const leaveText = leaveMessage.value.trim()
sending.value = true
try {
@ -302,6 +231,8 @@ async function handleSend() {
})
const results = await Promise.all(tasks)
const failedNames = results.filter((r) => !r.ok).map((r) => r.conversation.name || '未命名会话')
// ""
conversationStore.pushRecentForwardConversationKeys(targets.map((c) => getConversationKey(c)))
if (failedNames.length === 0) {
message.success('已转发')
} else if (failedNames.length === targets.length) {
@ -314,25 +245,86 @@ async function handleSend() {
sending.value = false
}
}
/**
* 好友视图发送先建群同时邀请所选好友 给新群发名片 发留言 关弹窗
*
* 跟会话视图的差别先要 createGroup 拿到 groupId之后构造一个 GROUP 类型的 conversation 对象给 sendRaw
* sendRaw 内部会自动 insertMessage 把新群登记进 store最近转发列表也能正常推
*/
async function handleCreateGroupAndSend() {
const card = target.value
if (!card?.targetId || selectedFriendIds.value.length === 0) {
return
}
const byId = new Map(friends.value.map((f) => [f.id, f]))
const members = selectedFriendIds.value
.map((id) => byId.get(id))
.filter((f): f is FriendLite => f != null)
if (members.length === 0) {
return
}
sending.value = true
try {
const memberUserIds = members.map((m) => m.id)
const name = buildDefaultGroupName(members)
const group = await createGroup({ name, memberUserIds, joinApproval: false })
if (!group?.id) {
throw new Error('创建群失败:未返回群编号')
}
// upsert groupStore fetchGroups
groupStore.upsertGroup({
id: group.id,
name: group.name,
avatar: group.avatar,
notice: group.notice,
ownerUserId: group.ownerUserId
})
// conversation sendRaw sendRaw insertMessage
const newConversation: Conversation = {
type: ImConversationType.GROUP,
targetId: group.id,
name: group.name || name,
avatar: group.avatar || '',
unreadCount: 0,
messages: [],
lastContent: '',
lastSendTime: 0
}
const cardOk = await sendRaw(ImMessageType.CARD, serializeMessage({ ...card }), {
conversation: newConversation
})
if (!cardOk) {
message.warning('群已创建,但名片发送失败,请稍后在群里重试')
visible.value = false
return
}
const leaveText = leaveMessage.value.trim()
if (leaveText) {
await send(leaveText, { conversation: newConversation })
}
conversationStore.pushRecentForwardConversationKeys([getConversationKey(newConversation)])
message.success('已创建群聊并发送')
visible.value = false
} finally {
sending.value = false
}
}
</script>
<style scoped>
/* 双栏布局要顶到 dialog 边缘:把 el-dialog body 默认 padding / header 下边距清零,两栏 border 自然分隔 */
.im-recommend-dialog :deep(.el-dialog__body) {
padding: 0;
border-top: 1px solid var(--el-border-color-lighter);
}
.im-recommend-dialog :deep(.el-dialog__header) {
margin-right: 0;
padding-bottom: 16px;
<style scoped lang="scss">
@use '../picker/picker-dialog' as picker;
.im-picker-dialog {
@include picker.styles;
}
/* 已选行 × 移除常驻显示hover 转危险色 */
.im-recommend__remove {
color: var(--el-text-color-placeholder);
/* 返回箭头 hover 高亮,提示可点击 */
.im-recommend-dialog__back {
color: var(--el-text-color-secondary);
transition: color 0.15s;
}
.im-recommend__remove:hover {
color: var(--el-color-danger);
.im-recommend-dialog__back:hover {
color: var(--el-color-primary);
}
</style>

View File

@ -171,15 +171,10 @@
</template>
<!-- 加好友弹窗携带预填用户跳过搜索步骤直接进申请表单理由按 addSource 区分话术 -->
<FriendAddDialog
v-model="addFriendVisible"
:preset-user="presetUserForAdd"
:add-source="addSource"
:add-source-extra="addSourceExtra"
/>
<FriendAddDialog ref="friendAddDialogRef" />
<!-- 把他推荐给朋友弹窗 friend 态下出现入口 -->
<RecommendCardDialog v-model="recommendVisible" :target="recommendTarget" />
<RecommendCardDialog ref="recommendDialogRef" />
</div>
</template>
@ -348,19 +343,21 @@ function handleComingSoon(featureName: string) {
// ==================== / ====================
// + props.user FriendAddDialog
const addFriendVisible = ref(false)
const presetUserForAdd = ref<UserVO | null>(null)
/** 加好友弹窗 refhandleAddFriend 调 open({ presetUser, addSource, addSourceExtra }) 触发 */
const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>()
/** 推荐名片弹窗 refhandleRecommend 调用 open({ target }) 打开 */
const recommendDialogRef = ref<InstanceType<typeof RecommendCardDialog>>()
/** 把他推荐给朋友:弹 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 同步多端 */

View File

@ -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<FriendLite[]> | ComputedRef<FriendLite[]>,
keyword: Ref<string>
): {
filtered: ComputedRef<FriendLite[]>
buckets: ComputedRef<FriendBucket[]>
} {
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<FriendBucket[]>(() => {
const map = new Map<string, FriendLite[]>()
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 }
}

View File

@ -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<T>(
selectedIds: () => readonly number[],
lockedIds: () => readonly number[],
disabledIds: () => readonly number[],
hideIds: () => readonly number[],
byId: Ref<Map<number, T>> | ComputedRef<Map<number, T>>
): {
selectedCount: ComputedRef<number>
selectedItems: ComputedRef<T[]>
} {
const hideSet = computed(() => new Set(hideIds()))
const disabledSet = computed(() => new Set(disabledIds()))
const selectedCount = computed(() => {
const merged = new Set<number>()
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<number>()
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 }
}

View File

@ -2,12 +2,13 @@
<!--
通讯录 - 好友分组
- 自治折叠状态 + 关键字过滤 + 字母分桶 本组件内闭环
- 字母分桶 / 拼音搜索委托 useFriendBuckets与选择类弹窗 FriendPickerPanel 共用一份规则
- 选中态由父级 activeId 透传chat / delete 透传到父级走 store 改造
-->
<div>
<!-- 折叠分组头字号对齐微信 PC15pxhover 浅底色反馈 -->
<div
class="flex gap-2 items-center px-3.5 py-2.5 text-15px text-[var(--el-text-color-primary)] cursor-pointer select-none hover:bg-[var(--el-fill-color-light)]"
class="flex gap-2 items-center px-3.5 py-2.5 cursor-pointer select-none text-15px text-[var(--el-text-color-primary)] hover:bg-[var(--el-fill-color-light)]"
@click="expanded = !expanded"
>
<Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" />
@ -43,9 +44,10 @@
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { ref, toRef } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import FriendItem from '../../components/friend/FriendItem.vue'
import { useFriendBuckets } from '../../composables/useFriendBuckets'
import type { FriendLite } from '../../types'
defineOptions({ name: 'ImContactFriendList' })
@ -64,83 +66,5 @@ const emit = defineEmits<{
const expanded = ref(true)
/** 拼音首字母拼接「lao zhang」→ "lz",用于支持「输 lz 搜老张」 */
function pinyinInitials(pinyin?: string): string {
if (!pinyin) {
return ''
}
return pinyin
.split(' ')
.map((word) => word.charAt(0))
.join('')
}
/** 关键字过滤:备注 / 昵称 / 全拼 / 首字母任一命中即可,记不住哪个就按哪个搜 */
const filtered = computed(() => {
const keywordLower = props.keyword.trim().toLowerCase()
return props.friends.filter((friend) => {
if (friend.deleted) {
return false
}
if (!keywordLower) {
return true
}
// laozhanglao zhang
const nicknamePinyin = friend.nicknamePinyin || ''
const displayNamePinyin = friend.displayNamePinyin || ''
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)
)
})
})
/** 桶排序键:优先备注拼音,回落昵称拼音 / 名字本身(兜底英文 / 数字场景) */
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() : '#'
}
interface FriendBucket {
letter: string
list: FriendLite[]
}
/** 字母分桶A-Z 优先,"#" 兜底;桶内按拼音 / 名字自然序 */
const buckets = computed<FriendBucket[]>(() => {
const map = new Map<string, FriendLite[]>()
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) => {
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)))
}))
})
const { filtered, buckets } = useFriendBuckets(toRef(props, 'friends'), toRef(props, 'keyword'))
</script>

View File

@ -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<FriendRequest>(() => {
const friendRequests = computed<FriendRequest[]>(() => friendStore.friendRequests)
/** 好友列表的展示快照:附带后端算好的拼音,给 FriendList 做字母分桶 / 拼音搜索 */
const friends = computed<FriendLite[]>(() =>
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<FriendLite[]>(() => friendStore.getActiveFriendsLite)
const groups = computed<GroupLite[]>(() =>
groupStore.groups.map((group: Group) => ({

View File

@ -36,7 +36,7 @@
<div
class="im-conversation-group-side__tile-wrap"
title="邀请好友入群"
@click="inviteVisible = true"
@click="handleOpenInvite"
>
<div class="im-conversation-group-side__icon-tile">
<Icon icon="ant-design:plus-outlined" />
@ -49,7 +49,7 @@
v-if="isOwnerOrAdmin"
class="im-conversation-group-side__tile-wrap"
title="移出群成员"
@click="removeVisible = true"
@click="handleOpenRemove"
>
<div class="im-conversation-group-side__icon-tile">
<Icon icon="ant-design:minus-outlined" />
@ -257,7 +257,7 @@
<div
v-if="group"
class="im-conversation-group-side__row im-conversation-group-side__row--clickable"
@click="recommendCardVisible = true"
@click="handleShareGroupCard"
>
<span class="im-conversation-group-side__label">分享群名片</span>
<Icon
@ -301,7 +301,7 @@
<div
v-if="isOwnerOrAdmin && !!group.joinApproval"
class="im-conversation-group-side__row im-conversation-group-side__row--clickable"
@click="requestListVisible = true"
@click="handleOpenRequestList"
>
<span class="im-conversation-group-side__label">- 进群申请</span>
<Icon
@ -320,7 +320,7 @@
<div class="im-conversation-group-side__section">
<div
class="im-conversation-group-side__row im-conversation-group-side__row--clickable"
@click="adminVisible = true"
@click="handleOpenAdminSet"
>
<span class="im-conversation-group-side__label">群管理员</span>
<Icon
@ -331,7 +331,7 @@
</div>
<div
class="im-conversation-group-side__row im-conversation-group-side__row--clickable"
@click="transferOwnerVisible = true"
@click="handleOpenTransferOwner"
>
<span class="im-conversation-group-side__label">群主管理权转让</span>
<Icon
@ -371,46 +371,18 @@
<!-- ==================== 子对话框 ==================== -->
<!-- 邀请新成员 / 选成员移除 -->
<GroupMemberAddDialog
v-model="inviteVisible"
:group-id="group?.id"
:members="members"
:friends="friends"
@reload="$emit('reload')"
/>
<GroupMemberSelector
v-model="removeVisible"
title="选择成员进行移除"
:members="members"
:hide-ids="removeHideIds"
:max-size="50"
@complete="handleRemoveComplete"
/>
<GroupMemberAddDialog ref="inviteDialogRef" @reload="$emit('reload')" />
<GroupMemberRemoveDialog ref="removeDialogRef" @reload="$emit('reload')" />
<!-- 群主操作管理员设置一个弹窗合并增 / 提交时 diff+ 群主管理权转让 -->
<GroupMemberSelector
v-model="adminVisible"
title="设置群管理员"
:members="members"
:checked-ids="adminCheckedIds"
:hide-ids="adminHideIds"
:max-size="GROUP_ADMIN_MAX_COUNT"
@complete="handleAdminUpdate"
/>
<GroupMemberSelector
v-model="transferOwnerVisible"
title="选择新群主"
:members="members"
:hide-ids="transferOwnerHideIds"
:max-size="1"
@complete="handleTransferOwnerComplete"
/>
<GroupAdminSetDialog ref="adminSetDialogRef" @reload="$emit('reload')" />
<GroupOwnerTransferDialog ref="ownerTransferDialogRef" @reload="$emit('reload')" />
<!-- 进群申请列表仅当开启审批 + 当前用户是 owner / admin 时入口可见 -->
<GroupRequestListDialog v-model="requestListVisible" :group-id="group?.id" />
<GroupRequestListDialog ref="requestListDialogRef" />
<!-- 分享群名片把当前群作为名片消息推荐给其他会话 -->
<RecommendCardDialog v-model="recommendCardVisible" :target="recommendCardTarget" />
<RecommendCardDialog ref="recommendCardDialogRef" />
</el-drawer>
</template>
@ -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)
/** 邀请好友入群弹窗 refhandleOpenInvite 调 open({ groupId }) 打开 */
const inviteDialogRef = ref<InstanceType<typeof GroupMemberAddDialog>>()
/** 打开邀请好友入群弹窗 */
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) {
}
}
// ==================== ====================
/** 进群申请列表弹窗 refhandleOpenRequestList 调 open({ groupId }) 触发 */
const requestListDialogRef = ref<InstanceType<typeof GroupRequestListDialog>>()
/** 打开当前群的进群申请列表 */
function handleOpenRequestList() {
if (!props.group?.id) {
return
}
requestListDialogRef.value?.open({ groupId: props.group.id })
}
// ==================== ====================
/** 分享群名片弹窗 refhandleShareGroupCard 调用 open({ target }) 打开 */
const recommendCardDialogRef = ref<InstanceType<typeof RecommendCardDialog>>()
/** 分享群名片:把当前群作为名片消息推荐给其他会话 */
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<InstanceType<typeof GroupMemberRemoveDialog>>()
/** 设置群管理员弹窗 ref */
const adminSetDialogRef = ref<InstanceType<typeof GroupAdminSetDialog>>()
/** 转让群主弹窗 ref */
const ownerTransferDialogRef = ref<InstanceType<typeof GroupOwnerTransferDialog>>()
// ---------- ----------
/** 移除群成员的 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')
}
</script>

View File

@ -33,7 +33,7 @@
<div
class="im-conversation-private-side__tile-wrap im-conversation-private-side__tile-wrap--clickable"
title="发起群聊"
@click="createGroupVisible = true"
@click="handleOpenCreateGroup"
>
<div class="im-conversation-private-side__icon-tile">
<Icon icon="ant-design:plus-outlined" />
@ -119,12 +119,7 @@
</div>
<!-- 子对话框发起群聊锁定对方为已选 -->
<GroupCreateDialog
v-model="createGroupVisible"
:friends="friends"
:locked-ids="lockedIds"
@created="handleGroupCreated"
/>
<GroupCreateDialog ref="createGroupDialogRef" @created="handleGroupCreated" />
</el-drawer>
</template>
@ -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<number[]>(() =>
props.friend ? [props.friend.friendUserId] : []
)
/** 发起群聊弹窗 refhandleOpenCreateGroup 调 open({ lockedIds }) 锁定对方 */
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>()
/** 打开发起群聊弹窗:把对方默认勾上且不可取消,对应微信"基于私聊发起群聊" */
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) => {

View File

@ -6,7 +6,7 @@
- 点击横幅打开 GroupRequestListDialog含历史已处理记录不再就地展开
-->
<div v-if="canManage && pendingCount > 0" class="im-group-request-pending">
<div class="im-group-request-pending__row" @click="dialogVisible = true">
<div class="im-group-request-pending__row" @click="handleOpen">
<Icon
icon="ant-design:user-add-outlined"
:size="14"
@ -21,7 +21,7 @@
</div>
<!-- 申请列表 dialog复用同一组件避免群管理面板与会话顶部各写一份 -->
<GroupRequestListDialog v-model="dialogVisible" :group-id="groupId" />
<GroupRequestListDialog ref="requestListDialogRef" />
</div>
</template>
@ -45,7 +45,13 @@ const userStore = useUserStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const dialogVisible = ref(false)
/** 申请列表弹窗 refhandleOpen 调 open({ groupId }) 触发 */
const requestListDialogRef = ref<InstanceType<typeof GroupRequestListDialog>>()
/** 打开当前群的进群申请列表 */
function handleOpen() {
requestListDialogRef.value?.open({ groupId: props.groupId })
}
/** 当前群(含 ownerUserId / members */
const group = computed(() => groupStore.getGroup(props.groupId))

View File

@ -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 ====================

View File

@ -33,7 +33,7 @@
icon="ep:chat-dot-round"
:size="20"
class="message-panel__header-icon cursor-pointer"
@click="historyVisible = true"
@click="historyDialogRef?.open()"
/>
</el-tooltip>
<!-- 通话入口暂未开放先放占位图标对齐微信 PC -->
@ -145,21 +145,19 @@
:group="groupInfo"
:conversation="conversationStore.activeConversation"
:members="groupMembers"
:friends="friends"
@reload="reloadGroupData"
@open-history="historyVisible = true"
@open-history="historyDialogRef?.open()"
/>
<ConversationPrivateSide
v-else
v-model="sideVisible"
:conversation="conversationStore.activeConversation"
:friend="privateFriend"
:friends="friends"
@open-history="historyVisible = true"
@open-history="historyDialogRef?.open()"
/>
<!-- 历史消息抽屉 -->
<MessageHistory v-model="historyVisible" @locate="handleLocate" />
<MessageHistory ref="historyDialogRef" @locate="handleLocate" />
<!-- 转发弹窗 / 合并消息详情 MessagePanel 子树内挂载子组件通过 inject 触发 -->
<MessageForwardDialog ref="forwardDialogRef" />
@ -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<GroupMemberLite[]>(() => {
})
})
/** 好友列表:群侧用于"邀请入群",私聊侧用于"+创建群",统一从 friendStore 映射成 FriendLite 窄接口 */
const friends = computed<FriendLite[]>(() =>
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<InstanceType<typeof MessageHistory>>()
const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref
const muteMemberDialogRef = ref<InstanceType<typeof GroupMuteMemberDialog>>()

View File

@ -1,195 +1,105 @@
<template>
<!--
转发消息逐条 / 合并选目标会话 + 留言后批量发送
- dialog 壳本组件持有选择 UI 委托 ConversationPickerPanel
- footer slot 塞预览卡合并 / 逐条不同视觉+ 留言 + 提交按钮
- 对外接口沿用ref + open({ mode, messages, sourceConversation })
-->
<el-dialog
v-model="visible"
:title="dialogTitle"
width="720px"
:close-on-click-modal="false"
class="im-forward-dialog"
@open="resetForm"
class="im-picker-dialog"
>
<div class="flex h-[480px]">
<!-- ============ 左栏搜索 + 会话列表 ============ -->
<div
class="flex flex-col w-[280px] border-r border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]"
<div class="h-[480px]">
<ConversationPickerPanel
v-model:selected-keys="selectedKeys"
:conversations="candidateConversations"
:recent-forward-conversation-keys="conversationStore.recentForwardConversationKeys"
@remove-recent="conversationStore.removeRecentForwardConversationKey"
>
<div class="px-3 py-3 flex-shrink-0">
<el-input v-model="keyword" placeholder="搜索" clearable size="small">
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
</el-input>
</div>
<div class="px-3 pb-1.5 text-13px text-[var(--el-text-color-secondary)] flex-shrink-0">
最近聊天
</div>
<el-scrollbar class="flex-1">
<div
v-for="conversation in shownConversations"
:key="getConversationKey(conversation)"
class="flex gap-2.5 items-center px-3 py-1.5 cursor-pointer hover:bg-[var(--el-fill-color)]"
@click="handleToggle(conversation)"
>
<span
class="flex flex-shrink-0 items-center justify-center w-5 h-5 rounded-full transition-colors"
:class="
isSelected(conversation)
? 'bg-[#07c160]'
: 'border border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
"
<template #footer>
<div class="flex flex-col gap-3 px-4 py-3">
<!-- 合并模式预览[聊天记录] 标题 + 摘要列表预览卡 -->
<div
v-if="state.mode === ImForwardMode.MERGE && mergePreview"
class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]"
>
<Icon
v-if="isSelected(conversation)"
icon="ant-design:check-outlined"
:size="12"
color="#fff"
<div class="px-3 py-2 text-sm font-medium truncate text-[var(--el-text-color-primary)]">
{{ mergePreview.title }}
</div>
<div class="flex flex-col px-3 pb-2 gap-0.5">
<div
v-for="(line, idx) in mergePreview.lines"
:key="idx"
class="text-12px text-[var(--el-text-color-secondary)] truncate"
>
{{ line }}
</div>
</div>
<div
class="px-3 py-1 text-12px border-t text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
>
聊天记录
</div>
</div>
<!-- 逐条模式预览消息数 + 首条摘要 -->
<div
v-else-if="state.mode === ImForwardMode.SINGLE && singlePreviewLines.length > 0"
class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]"
>
<div class="flex flex-col px-3 py-2 gap-0.5">
<div
v-for="(line, idx) in singlePreviewLines"
:key="idx"
class="text-13px text-[var(--el-text-color-primary)] truncate"
>
{{ line }}
</div>
</div>
<div
class="px-3 py-1 text-12px border-t text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
>
{{ state.messages.length }} 条消息
</div>
</div>
<!-- 留言单行右侧表情按钮触发 FacePicker选中 emoji 拼到末尾 -->
<div class="relative">
<el-input v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言">
<template #suffix>
<Icon
icon="ant-design:smile-outlined"
:size="18"
class="cursor-pointer text-[var(--el-text-color-secondary)] hover:text-[var(--el-color-primary)]"
@click.stop="emojiVisible = !emojiVisible"
/>
</template>
</el-input>
<FacePicker
v-model:visible="emojiVisible"
mode="emoji"
class="bottom-full right-0 mb-2"
@select-emoji="handleEmojiSelect"
/>
</span>
<UserAvatar
:url="conversation.avatar"
:name="conversation.name"
:size="32"
:clickable="false"
/>
<span
class="flex-1 min-w-0 overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"
>
{{ conversation.name }}
</span>
</div>
<div
v-if="shownConversations.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
{{ keyword ? '没有满足条件的会话' : '暂无会话' }}
</div>
</el-scrollbar>
</div>
<!-- ============ 右栏已选 + 预览卡 + 留言 + 按钮 ============ -->
<div class="flex flex-col flex-1 min-w-0">
<div
class="px-4 py-3 text-13px text-[var(--el-text-color-secondary)] flex-shrink-0 border-b border-[var(--el-border-color-lighter)]"
>
{{ sendTitle }}
</div>
<!-- 已选预览每行 头像 + 名字 + × 移除 -->
<el-scrollbar class="flex-1">
<div
v-for="conversation in selectedConversations"
:key="getConversationKey(conversation)"
class="flex gap-2.5 items-center px-4 py-2"
>
<UserAvatar
:url="conversation.avatar"
:name="conversation.name"
:size="28"
:clickable="false"
/>
<span
class="flex-1 min-w-0 overflow-hidden text-13px truncate text-[var(--el-text-color-primary)]"
>
{{ conversation.name }}
</span>
<Icon
icon="ant-design:close-outlined"
:size="14"
class="im-forward__remove flex-shrink-0 cursor-pointer"
@click="handleToggle(conversation)"
/>
</div>
<div
v-if="selectedConversations.length === 0"
class="py-10 text-13px text-center text-[var(--el-text-color-disabled)]"
>
从左侧选择联系人或群聊
</div>
</el-scrollbar>
<!-- 待转发预览 + 留言 + 按钮 -->
<div
class="flex flex-col gap-3 px-4 py-3 flex-shrink-0 border-t border-[var(--el-border-color-lighter)]"
>
<!-- 合并模式[聊天记录] 标题 + 摘要列表预览卡 -->
<div
v-if="state.mode === ImForwardMode.MERGE && mergePreview"
class="flex flex-col w-full rounded-md overflow-hidden bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]"
>
<div class="px-3 py-2 text-sm font-medium text-[var(--el-text-color-primary)] truncate">
{{ mergePreview.title }}
</div>
<div class="px-3 pb-2 flex flex-col gap-0.5">
<div
v-for="(line, idx) in mergePreview.lines"
:key="idx"
class="text-12px text-[var(--el-text-color-secondary)] truncate"
<div class="flex gap-2 justify-end">
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="sending"
:disabled="selectedKeys.length === 0"
@click="handleSend"
>
{{ line }}
</div>
</div>
<div
class="px-3 py-1 text-12px border-t text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
>
聊天记录
{{ confirmButtonText }}
</el-button>
</div>
</div>
<!-- 单条 / 逐条模式消息数 + 首条摘要预览 -->
<div
v-else-if="state.mode === ImForwardMode.SINGLE && singlePreviewLines.length > 0"
class="flex flex-col w-full rounded-md overflow-hidden bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]"
>
<div class="flex flex-col gap-0.5 px-3 py-2">
<div
v-for="(line, idx) in singlePreviewLines"
:key="idx"
class="text-13px text-[var(--el-text-color-primary)] truncate"
>
{{ line }}
</div>
</div>
<div
class="px-3 py-1 text-12px border-t text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
>
{{ state.messages.length }} 条消息
</div>
</div>
<!-- 留言右侧表情按钮触发 FacePicker(mode=emoji)所选 emoji 拼到末尾 -->
<div class="relative">
<el-input v-model="leaveMessage" :maxlength="100" placeholder="给朋友留言">
<template #suffix>
<Icon
icon="ant-design:smile-outlined"
:size="18"
class="cursor-pointer text-[var(--el-text-color-secondary)] hover:text-[var(--el-color-primary)]"
@click.stop="emojiVisible = !emojiVisible"
/>
</template>
</el-input>
<FacePicker
v-model:visible="emojiVisible"
mode="emoji"
class="bottom-full right-0 mb-2"
@select-emoji="handleEmojiSelect"
/>
</div>
<div class="flex gap-2 justify-end">
<el-button @click="visible = false">取消</el-button>
<el-button
type="primary"
:loading="sending"
:disabled="selectedKeys.length === 0"
@click="handleSend"
>
{{ confirmButtonText }}
</el-button>
</div>
</div>
</div>
</template>
</ConversationPickerPanel>
</div>
</el-dialog>
</template>
@ -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<string[]>([])
const leaveMessage = ref('')
const sending = ref(false)
const selectedKeys = ref<string[]>([])
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<Conversation[]>(
() => conversationStore.getSortedConversations
)
const shownConversations = computed(() =>
filterConversationsByKeyword(candidateConversations.value, keyword.value)
)
const selectedConversations = computed<Conversation[]>(() => {
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 + 序列化 contentmerge 模式下一次构造,预览 / 发送共用 */
@ -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() {
}
</script>
<style scoped>
/* 双栏布局要顶到 dialog 边缘 */
.im-forward-dialog :deep(.el-dialog__body) {
padding: 0;
border-top: 1px solid var(--el-border-color-lighter);
}
.im-forward-dialog :deep(.el-dialog__header) {
margin-right: 0;
padding-bottom: 16px;
}
<style scoped lang="scss">
@use '@/views/im/home/components/picker/picker-dialog' as picker;
/* 已选行 × 移除常驻显示hover 转危险色 */
.im-forward__remove {
color: var(--el-text-color-placeholder);
transition: color 0.15s;
}
.im-forward__remove:hover {
color: var(--el-color-danger);
.im-picker-dialog {
@include picker.styles;
}
</style>

View File

@ -18,11 +18,11 @@
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="createGroupVisible = true">
<el-dropdown-item @click="handleOpenCreateGroup">
<Icon icon="ant-design:message-outlined" :size="16" />
<span>发起群聊</span>
</el-dropdown-item>
<el-dropdown-item @click="addFriendVisible = true">
<el-dropdown-item @click="friendAddDialogRef?.open()">
<Icon icon="ant-design:user-add-outlined" :size="16" />
<span>添加朋友</span>
</el-dropdown-item>
@ -90,12 +90,8 @@
<MessagePanel />
<!-- 添加朋友 / 发起群聊弹窗 -->
<FriendAddDialog v-model="addFriendVisible" />
<GroupCreateDialog
v-model="createGroupVisible"
:friends="friends"
@created="handleGroupCreated"
/>
<FriendAddDialog ref="friendAddDialogRef" />
<GroupCreateDialog ref="createGroupDialogRef" @created="handleGroupCreated" />
</div>
</template>
@ -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<InstanceType<typeof FriendAddDialog>>()
// ==================== ====================
/** GroupCreateDialog 需要全量好友列表来勾选成员,结构与通讯录里好友/群分组保持一致 */
const friends = computed<FriendLite[]>(() =>
friendStore.getActiveFriends.map((friend: Friend) => ({
id: friend.friendUserId,
nickname: friend.nickname,
avatar: friend.avatar,
deleted: friend.status === CommonStatusEnum.DISABLE
}))
)
/** 发起群聊弹窗 refhandleOpenCreateGroup 调 open() 打开 */
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>()
/** 打开发起群聊弹窗:无锁定项的全局入口 */
function handleOpenCreateGroup() {
createGroupDialogRef.value?.open()
}
/** 处理建群成功 */
function handleGroupCreated(groupId: number) {

View File

@ -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<ConversationStoreMeta>(
StorageKeys.conversationMeta(userId)
)
// 顺手把最近转发列表也恢复出来;和 meta 并发读
const [meta, recent] = await Promise.all([
imStorage.getItem<ConversationStoreMeta>(StorageKeys.conversationMeta(userId)),
imStorage.getItem<string[]>(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 列表落到 IDBfire-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) {

View File

@ -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<void> | 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 => {

View File

@ -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
}
/**

View File

@ -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',

View File

@ -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 || '群聊'
}

View File

@ -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 偏好。 */