✨ feat(im): 增加群邀请的功能
parent
0ab8b292f2
commit
368b385267
|
|
@ -6,25 +6,17 @@
|
|||
- 右:已勾选预览
|
||||
- 提交:先 createGroup 再 inviteGroupMember,最后让父页 reload
|
||||
-->
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="新建群聊"
|
||||
width="620px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<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
|
||||
/>
|
||||
<el-input v-model="groupName" placeholder="请输入群名称" maxlength="20" show-word-limit />
|
||||
|
||||
<div class="flex gap-2.5">
|
||||
<div class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]">
|
||||
<div
|
||||
class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]"
|
||||
>
|
||||
<el-input v-model="searchText" placeholder="搜索好友" size="small" clearable>
|
||||
<template #suffix>
|
||||
<el-icon><Search /></el-icon>
|
||||
<Icon icon="ant-design:search-outlined" />
|
||||
</template>
|
||||
</el-input>
|
||||
<el-scrollbar class="h-[400px]">
|
||||
|
|
@ -34,7 +26,7 @@
|
|||
:friend="f"
|
||||
:menu="false"
|
||||
:active="false"
|
||||
@click="toggleCheck(f)"
|
||||
@click="handleToggleCheck(f)"
|
||||
>
|
||||
<el-checkbox
|
||||
:model-value="f.isCheck"
|
||||
|
|
@ -46,10 +38,12 @@
|
|||
</div>
|
||||
|
||||
<div class="flex items-center text-lg text-[#409eff]">
|
||||
<el-icon><DArrowRight /></el-icon>
|
||||
<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="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)]"
|
||||
>
|
||||
|
|
@ -77,14 +71,16 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Search, DArrowRight } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import Icon from '@/components/Icon/src/Icon.vue'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
|
||||
import { createGroup } from '@/api/im/group'
|
||||
import { inviteGroupMember } from '@/api/im/group/member'
|
||||
import FriendItem, { type FriendLite } from '../../friend/components/FriendItem.vue'
|
||||
import { useGroupStore } from '../../store/groupStore'
|
||||
import FriendItem from '../friend/FriendItem.vue'
|
||||
import type { FriendLite } from '../../types'
|
||||
|
||||
defineOptions({ name: 'ImCreateGroupDialog' })
|
||||
defineOptions({ name: 'ImGroupCreateDialog' })
|
||||
|
||||
interface FriendCheckable extends FriendLite {
|
||||
isCheck?: boolean
|
||||
|
|
@ -93,8 +89,7 @@ interface FriendCheckable extends FriendLite {
|
|||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
/** 全量好友(由调用方从 friendStore 传入) */
|
||||
friends?: FriendLite[]
|
||||
friends?: FriendLite[] // 全量好友(由调用方从 friendStore 传入)
|
||||
}>(),
|
||||
{
|
||||
friends: () => []
|
||||
|
|
@ -103,20 +98,23 @@ const props = withDefaults(
|
|||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** 创建成功,携带新群编号 */
|
||||
created: [groupId: number]
|
||||
created: [groupId: number] // 创建成功,携带新群编号
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
/** 弹窗显隐:把父侧 v-model 转双向计算 */
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const groupName = ref('')
|
||||
const searchText = ref('')
|
||||
const submitting = ref(false)
|
||||
/** 工作副本(带 isCheck 标记),与 prop 隔离 */
|
||||
const workingFriends = ref<FriendCheckable[]>([])
|
||||
// TODO @AI:checked 改成这个变量;
|
||||
const workingFriends = ref<FriendCheckable[]>([]) // 工作副本(带 isCheck 标记),与 prop 隔离
|
||||
|
||||
watch(
|
||||
visible,
|
||||
|
|
@ -133,40 +131,52 @@ watch(
|
|||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 左侧展示的好友:按搜索关键字过滤 workingFriends */
|
||||
const shownFriends = computed(() =>
|
||||
workingFriends.value.filter((f) => f.nickname.includes(searchText.value))
|
||||
)
|
||||
|
||||
/** 已勾选的好友:右侧预览 + 提交时取 memberUserIds */
|
||||
const checkedFriends = computed(() => workingFriends.value.filter((f) => f.isCheck))
|
||||
|
||||
function toggleCheck(f: FriendCheckable) {
|
||||
/** 行点击:切换勾选态,让点击整行与点 checkbox 等价 */
|
||||
function handleToggleCheck(f: FriendCheckable) {
|
||||
f.isCheck = !f.isCheck
|
||||
}
|
||||
|
||||
/** 创建群聊:建群 → 拉人 → upsert groupStore,最后 emit('created') 让父页跳转新会话 */
|
||||
async function handleOk() {
|
||||
const name = groupName.value.trim()
|
||||
if (!name) {
|
||||
ElMessage.warning('请输入群名称')
|
||||
message.warning('请输入群名称')
|
||||
return
|
||||
}
|
||||
const memberUserIds = checkedFriends.value.map((f) => f.id)
|
||||
if (memberUserIds.length === 0) {
|
||||
ElMessage.warning('请至少选择一位好友')
|
||||
message.warning('请至少选择一位好友')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
// 1.1 新建群聊
|
||||
const group = await createGroup({ name })
|
||||
if (!group?.id) {
|
||||
throw new Error('创建群失败:未返回群编号')
|
||||
}
|
||||
// 1.2 拉好友入群
|
||||
await inviteGroupMember({ groupId: group.id, memberUserIds })
|
||||
ElMessage.success('群聊创建成功')
|
||||
// 2.1 直接 upsert 进 groupStore,省一次 fetchGroups——服务端返回 VO 已经够建会话了
|
||||
groupStore.upsertGroup({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
avatar: group.avatar,
|
||||
notice: group.notice,
|
||||
ownerUserId: group.ownerUserId
|
||||
})
|
||||
// 2.2 提示成功 + emit 让父页跳转新会话 + 关弹窗
|
||||
message.success('群聊创建成功')
|
||||
emit('created', group.id)
|
||||
visible.value = false
|
||||
} catch (e: any) {
|
||||
console.error('[IM] 创建群失败', e)
|
||||
ElMessage.error(e?.message || '创建群失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
<template>
|
||||
<!--
|
||||
邀请好友入群对话框
|
||||
- 左:好友列表(带 checkbox)
|
||||
- 右:已勾选预览
|
||||
- 已在群内的好友标记为 disabled
|
||||
- TODO 接入 /im/group/invite;TODO 这个是不是已经接入了?
|
||||
-->
|
||||
<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="搜索好友" size="small" clearable>
|
||||
<template #suffix>
|
||||
<Icon icon="ant-design:search-outlined" />
|
||||
</template>
|
||||
</el-input>
|
||||
<el-scrollbar class="h-[400px]">
|
||||
<!-- TODO @ai: friend? -->
|
||||
<FriendItem
|
||||
v-for="f in shownFriends"
|
||||
:key="f.id"
|
||||
:friend="f"
|
||||
:menu="false"
|
||||
:active="false"
|
||||
@click="handleToggleCheck(f)"
|
||||
>
|
||||
<!-- TODO @AI:checked? -->
|
||||
<el-checkbox
|
||||
:model-value="f.isCheck"
|
||||
:disabled="f.disabled"
|
||||
@click.stop
|
||||
@change="(v) => (f.isCheck = !!v)"
|
||||
/>
|
||||
</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)]"
|
||||
>
|
||||
<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)]"
|
||||
>
|
||||
已勾选 {{ checkCount }} 位好友
|
||||
</div>
|
||||
<el-scrollbar class="h-[400px]">
|
||||
<FriendItem
|
||||
v-for="f in checkedFriends"
|
||||
:key="f.id"
|
||||
:friend="f"
|
||||
:menu="false"
|
||||
:active="false"
|
||||
/>
|
||||
</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 FriendItem from '../friend/FriendItem.vue'
|
||||
import type { FriendLite } from '../../types'
|
||||
import type { GroupMemberLite } from './GroupMember.vue'
|
||||
|
||||
defineOptions({ name: 'ImGroupMemberAddDialog' })
|
||||
|
||||
interface FriendCheckable extends FriendLite {
|
||||
isCheck?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
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 列表
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
/** 弹窗显隐:把父侧 v-model 转双向计算 */
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const searchText = ref('')
|
||||
/** 本地工作副本(带 isCheck / disabled 标记);和 props.friends 区分以避免直接改 prop */
|
||||
const workingFriends = ref<FriendCheckable[]>([])
|
||||
|
||||
watch(
|
||||
visible,
|
||||
(v) => {
|
||||
if (!v) {
|
||||
return
|
||||
}
|
||||
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,
|
||||
disabled: inGroup,
|
||||
isCheck: inGroup
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 左侧展示的好友:按搜索关键字过滤 workingFriends */
|
||||
const shownFriends = computed(() =>
|
||||
workingFriends.value.filter((f) => f.nickname.includes(searchText.value))
|
||||
)
|
||||
|
||||
/** 本次将被邀请的好友:勾选 + 非已在群成员(disabled 不计入) */
|
||||
const checkedFriends = computed(() => workingFriends.value.filter((f) => f.isCheck && !f.disabled))
|
||||
|
||||
/** 已勾选数量:右侧标题展示 */
|
||||
const checkCount = computed(() => checkedFriends.value.length)
|
||||
|
||||
/** 行点击:切换勾选态,已在群(disabled)的不响应 */
|
||||
function handleToggleCheck(f: FriendCheckable) {
|
||||
if (!f.disabled) {
|
||||
f.isCheck = !f.isCheck
|
||||
}
|
||||
}
|
||||
|
||||
// TODO @AI:这里接入下?
|
||||
/**
|
||||
* 邀请入群:占位实现——抛 reload 让父侧关弹窗 / 刷新
|
||||
*
|
||||
* TODO 接入 /im/group/invite
|
||||
*/
|
||||
async function handleOk() {
|
||||
const ids = checkedFriends.value.map((f) => f.id)
|
||||
if (ids.length === 0) {
|
||||
message.warning('请选择至少一个好友')
|
||||
return
|
||||
}
|
||||
message.info('邀请入群接口待接入,当前为占位实现')
|
||||
emit('reload', ids)
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<!--
|
||||
群成员宫格单元
|
||||
- 宫格展示的最小单位:头像在上、名字在下;列宽 = size + 16,自适应 size 留呼吸空间
|
||||
- 被 GroupMemberSelector 右侧已选区、ConversationGroupSide 群成员区循环使用
|
||||
-->
|
||||
<div
|
||||
class="relative flex flex-col items-center px-0.5 py-1"
|
||||
:style="{ width: `${size! + 16}px` }"
|
||||
>
|
||||
<UserAvatar
|
||||
:id="member.userId"
|
||||
:url="member.avatar"
|
||||
:name="member.nickname"
|
||||
:size="size"
|
||||
:clickable="clickable"
|
||||
/>
|
||||
<div
|
||||
class="w-full mt-1 overflow-hidden text-12px leading-[18px] text-center truncate text-[var(--el-text-color-regular)]"
|
||||
>
|
||||
{{ member.showName }}
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UserAvatar from '../user/UserAvatar.vue'
|
||||
import type { GroupMemberLite } from './GroupMember.vue'
|
||||
|
||||
defineOptions({ name: 'ImGroupMemberGrid' })
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
member: GroupMemberLite
|
||||
clickable?: boolean // 头像点击是否弹 UserInfoCard;选择器宫格里需要保持关闭,避免和勾选交互冲突
|
||||
size?: number // 头像像素大小;默认 38(兼容选择器右侧已选区),群信息抽屉传 50 对齐微信 PC
|
||||
}>(),
|
||||
{
|
||||
clickable: false,
|
||||
size: 38
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
<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() {
|
||||
// TODO @AI:member 不要缩写成 m;
|
||||
workingMembers.value = props.members.map((m) => ({
|
||||
...m,
|
||||
checked: props.checkedIds.some((id) => id === m.userId),
|
||||
locked: props.lockedIds.some((id) => id === m.userId),
|
||||
hide: props.hideIds.some((id) => id === m.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((m) => m.checked))
|
||||
|
||||
/** 落勾选并校验上限:超过 maxSize 时自动取消并提示,避免出现"勾上但实际不算"的中间态 */
|
||||
// TODO @AI:member?
|
||||
// TODO @AI:val 改成 checked?
|
||||
function applyCheck(m: GroupMemberFlag, val: boolean) {
|
||||
m.checked = val
|
||||
if (props.maxSize > 0 && checkedMembers.value.length > props.maxSize) {
|
||||
message.error(`最多选择 ${props.maxSize} 位成员`)
|
||||
m.checked = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 行点击:切换勾选态,locked 的不响应 */
|
||||
// TODO @AI:member?
|
||||
function handleToggleCheck(m: GroupMemberFlag) {
|
||||
if (m.locked) {
|
||||
return
|
||||
}
|
||||
applyCheck(m, !m.checked)
|
||||
}
|
||||
|
||||
/** checkbox change:直接落 val(locked 已由 disabled 拦截) */
|
||||
// TODO @AI:member?
|
||||
function handleCheckChange(m: GroupMemberFlag, val: boolean) {
|
||||
applyCheck(m, val)
|
||||
}
|
||||
|
||||
/** 确定:把已勾选成员通过 complete 抛给父侧 */
|
||||
function handleOk() {
|
||||
emit('complete', checkedMembers.value)
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,192 +0,0 @@
|
|||
<template>
|
||||
<!--
|
||||
邀请好友入群对话框(对应 boxim group/AddGroupMember.vue)
|
||||
- 左:好友列表(带 checkbox)
|
||||
- 右:已勾选预览
|
||||
- 已在群内的好友标记为 disabled
|
||||
- TODO 接入 /im/group/invite
|
||||
-->
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="邀请好友"
|
||||
width="620px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="im-add-group-member">
|
||||
<div class="im-add-group-member__left">
|
||||
<el-input v-model="searchText" placeholder="搜索好友" size="small" clearable>
|
||||
<template #suffix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-scrollbar class="im-add-group-member__scroll">
|
||||
<FriendItem
|
||||
v-for="f in shownFriends"
|
||||
:key="f.id"
|
||||
:friend="f"
|
||||
:menu="false"
|
||||
:active="false"
|
||||
@click="toggleCheck(f)"
|
||||
>
|
||||
<el-checkbox
|
||||
:model-value="f.isCheck"
|
||||
:disabled="f.disabled"
|
||||
@click.stop
|
||||
@change="(v: boolean) => (f.isCheck = v)"
|
||||
/>
|
||||
</FriendItem>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<div class="im-add-group-member__arrow">
|
||||
<el-icon><DArrowRight /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="im-add-group-member__right">
|
||||
<div class="im-add-group-member__tip">已勾选 {{ checkCount }} 位好友</div>
|
||||
<el-scrollbar class="im-add-group-member__scroll">
|
||||
<FriendItem
|
||||
v-for="f in checkedFriends"
|
||||
:key="f.id"
|
||||
:friend="f"
|
||||
:menu="false"
|
||||
:active="false"
|
||||
/>
|
||||
</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 { Search, DArrowRight } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import FriendItem, { type FriendLite } from '../../friend/components/FriendItem.vue'
|
||||
import type { GroupMemberLite } from '../../chat/components/ChatGroupMember.vue'
|
||||
|
||||
defineOptions({ name: 'ImAddGroupMemberDialog' })
|
||||
|
||||
interface FriendCheckable extends FriendLite {
|
||||
isCheck?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
groupId?: string | number
|
||||
/** 本群现有成员,用来判断好友是否已在群里 */
|
||||
members?: GroupMemberLite[]
|
||||
/** 全量好友(由调用方从 friendStore 传入) */
|
||||
friends?: FriendLite[]
|
||||
}>(),
|
||||
{
|
||||
members: () => [],
|
||||
friends: () => []
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** 邀请完成,携带被邀请的好友 id 列表 */
|
||||
reload: [friendIds: (string | number)[]]
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const searchText = ref('')
|
||||
const friends = ref<FriendCheckable[]>([])
|
||||
|
||||
watch(
|
||||
visible,
|
||||
(v) => {
|
||||
if (!v) return
|
||||
friends.value = props.friends
|
||||
.filter((f) => !f.deleted)
|
||||
.map((f) => {
|
||||
const inGroup = props.members.some(
|
||||
(m) => !m.quit && String(m.userId) === String(f.id)
|
||||
)
|
||||
return {
|
||||
...f,
|
||||
disabled: inGroup,
|
||||
isCheck: inGroup
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const shownFriends = computed(() =>
|
||||
friends.value.filter((f) => f.nickName.includes(searchText.value))
|
||||
)
|
||||
|
||||
const checkedFriends = computed(() =>
|
||||
friends.value.filter((f) => f.isCheck && !f.disabled)
|
||||
)
|
||||
|
||||
const checkCount = computed(() => checkedFriends.value.length)
|
||||
|
||||
function toggleCheck(f: FriendCheckable) {
|
||||
if (!f.disabled) f.isCheck = !f.isCheck
|
||||
}
|
||||
|
||||
// TODO 接入 /im/group/invite
|
||||
async function handleOk() {
|
||||
const ids = checkedFriends.value.map((f) => f.id)
|
||||
if (ids.length === 0) {
|
||||
ElMessage.warning('请选择至少一个好友')
|
||||
return
|
||||
}
|
||||
ElMessage.info('邀请入群接口待接入,当前为占位实现')
|
||||
emit('reload', ids)
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-add-group-member {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.im-add-group-member__left,
|
||||
.im-add-group-member__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.im-add-group-member__scroll {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.im-add-group-member__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.im-add-group-member__tip {
|
||||
height: 40px;
|
||||
padding-left: 10px;
|
||||
line-height: 40px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
<template>
|
||||
<!--
|
||||
群成员选择器(对应 boxim group/GroupMemberSelector.vue)
|
||||
- 左:搜索 + 群成员列表(带 checkbox)
|
||||
- 右:已勾选的成员(宫格预览)
|
||||
- 确定时 emit complete,抛出选中的成员列表
|
||||
-->
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="im-group-member-selector">
|
||||
<div class="im-group-member-selector__left">
|
||||
<el-input v-model="searchText" placeholder="搜索" clearable>
|
||||
<template #suffix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="im-group-member-selector__scroll">
|
||||
<PagedScroller :items="showMembers" :page-size="30">
|
||||
<template #default="{ item }">
|
||||
<GroupMemberItem
|
||||
:member="(item as GroupMemberFlag)"
|
||||
:height="46"
|
||||
@click="toggleCheck(item as GroupMemberFlag)"
|
||||
>
|
||||
<el-checkbox
|
||||
:model-value="(item as GroupMemberFlag).checked"
|
||||
:disabled="(item as GroupMemberFlag).locked"
|
||||
@click.stop
|
||||
@change="(val: boolean) => onCheckChange(item as GroupMemberFlag, val)"
|
||||
/>
|
||||
</GroupMemberItem>
|
||||
</template>
|
||||
</PagedScroller>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="im-group-member-selector__arrow">
|
||||
<el-icon><DArrowRight /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="im-group-member-selector__right">
|
||||
<div class="im-group-member-selector__tip">已勾选 {{ checkedMembers.length }} 位成员</div>
|
||||
<el-scrollbar class="im-group-member-selector__scroll">
|
||||
<div class="im-group-member-selector__grid">
|
||||
<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 { Search, DArrowRight } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import GroupMemberItem from './GroupMemberItem.vue'
|
||||
import GroupMemberGrid from './GroupMemberGrid.vue'
|
||||
import PagedScroller from '../../../components/PagedScroller.vue'
|
||||
import type { GroupMemberLite } from '../../chat/components/ChatGroupMember.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
|
||||
/** 传入的群成员列表(已经有 quit/headImage 等基础字段) */
|
||||
members?: GroupMemberLite[]
|
||||
/** 默认选中的 userId 列表 */
|
||||
checkedIds?: (string | number)[]
|
||||
/** 锁定的 userId 列表(不能取消) */
|
||||
lockedIds?: (string | number)[]
|
||||
/** 隐藏的 userId 列表(不展示) */
|
||||
hideIds?: (string | number)[]
|
||||
/** 最多可选数量,-1 表示不限制 */
|
||||
maxSize?: number
|
||||
}>(),
|
||||
{
|
||||
title: '选择成员',
|
||||
members: () => [],
|
||||
checkedIds: () => [],
|
||||
lockedIds: () => [],
|
||||
hideIds: () => [],
|
||||
maxSize: -1
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** 点击"确定"时抛出被勾选的成员列表 */
|
||||
complete: [members: GroupMemberFlag[]]
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const searchText = ref('')
|
||||
const workingMembers = ref<GroupMemberFlag[]>([])
|
||||
|
||||
watch(
|
||||
visible,
|
||||
(v) => {
|
||||
if (v) rebuild()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function rebuild() {
|
||||
workingMembers.value = props.members.map((m) => ({
|
||||
...m,
|
||||
checked: props.checkedIds.some((id) => String(id) === String(m.userId)),
|
||||
locked: props.lockedIds.some((id) => String(id) === String(m.userId)),
|
||||
hide: props.hideIds.some((id) => String(id) === String(m.userId))
|
||||
}))
|
||||
}
|
||||
|
||||
const showMembers = computed(() =>
|
||||
workingMembers.value.filter(
|
||||
(m) => !m.hide && !m.quit && m.showNickName.includes(searchText.value)
|
||||
)
|
||||
)
|
||||
|
||||
const checkedMembers = computed(() => workingMembers.value.filter((m) => m.checked))
|
||||
|
||||
function toggleCheck(m: GroupMemberFlag) {
|
||||
if (m.locked) return
|
||||
m.checked = !m.checked
|
||||
if (props.maxSize > 0 && checkedMembers.value.length > props.maxSize) {
|
||||
ElMessage.error(`最多选择 ${props.maxSize} 位成员`)
|
||||
m.checked = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCheckChange(m: GroupMemberFlag, val: boolean) {
|
||||
m.checked = val
|
||||
if (props.maxSize > 0 && checkedMembers.value.length > props.maxSize) {
|
||||
ElMessage.error(`最多选择 ${props.maxSize} 位成员`)
|
||||
m.checked = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleOk() {
|
||||
emit('complete', checkedMembers.value)
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-group-member-selector {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.im-group-member-selector__left,
|
||||
.im-group-member-selector__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.im-group-member-selector__scroll {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.im-group-member-selector__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.im-group-member-selector__tip {
|
||||
height: 40px;
|
||||
padding-left: 10px;
|
||||
line-height: 40px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.im-group-member-selector__grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue