✨ feat(im): 增加群邀请的功能
parent
0ab8b292f2
commit
368b385267
|
|
@ -6,25 +6,17 @@
|
||||||
- 右:已勾选预览
|
- 右:已勾选预览
|
||||||
- 提交:先 createGroup 再 inviteGroupMember,最后让父页 reload
|
- 提交:先 createGroup 再 inviteGroupMember,最后让父页 reload
|
||||||
-->
|
-->
|
||||||
<el-dialog
|
<el-dialog v-model="visible" title="新建群聊" width="620px" :close-on-click-modal="false">
|
||||||
v-model="visible"
|
|
||||||
title="新建群聊"
|
|
||||||
width="620px"
|
|
||||||
:close-on-click-modal="false"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<el-input
|
<el-input v-model="groupName" placeholder="请输入群名称" maxlength="20" show-word-limit />
|
||||||
v-model="groupName"
|
|
||||||
placeholder="请输入群名称"
|
|
||||||
maxlength="20"
|
|
||||||
show-word-limit
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex gap-2.5">
|
<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>
|
<el-input v-model="searchText" placeholder="搜索好友" size="small" clearable>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<el-icon><Search /></el-icon>
|
<Icon icon="ant-design:search-outlined" />
|
||||||
</template>
|
</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<el-scrollbar class="h-[400px]">
|
<el-scrollbar class="h-[400px]">
|
||||||
|
|
@ -34,7 +26,7 @@
|
||||||
:friend="f"
|
:friend="f"
|
||||||
:menu="false"
|
:menu="false"
|
||||||
:active="false"
|
:active="false"
|
||||||
@click="toggleCheck(f)"
|
@click="handleToggleCheck(f)"
|
||||||
>
|
>
|
||||||
<el-checkbox
|
<el-checkbox
|
||||||
:model-value="f.isCheck"
|
:model-value="f.isCheck"
|
||||||
|
|
@ -46,10 +38,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center text-lg text-[#409eff]">
|
<div class="flex items-center text-lg text-[#409eff]">
|
||||||
<el-icon><DArrowRight /></el-icon>
|
<Icon icon="ant-design:double-right-outlined" />
|
||||||
</div>
|
</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
|
<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)]"
|
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>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { Search, DArrowRight } from '@element-plus/icons-vue'
|
import Icon from '@/components/Icon/src/Icon.vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { useMessage } from '@/hooks/web/useMessage'
|
||||||
|
|
||||||
import { createGroup } from '@/api/im/group'
|
import { createGroup } from '@/api/im/group'
|
||||||
import { inviteGroupMember } from '@/api/im/group/member'
|
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 {
|
interface FriendCheckable extends FriendLite {
|
||||||
isCheck?: boolean
|
isCheck?: boolean
|
||||||
|
|
@ -93,8 +89,7 @@ interface FriendCheckable extends FriendLite {
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
/** 全量好友(由调用方从 friendStore 传入) */
|
friends?: FriendLite[] // 全量好友(由调用方从 friendStore 传入)
|
||||||
friends?: FriendLite[]
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
friends: () => []
|
friends: () => []
|
||||||
|
|
@ -103,20 +98,23 @@ const props = withDefaults(
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
/** 创建成功,携带新群编号 */
|
created: [groupId: number] // 创建成功,携带新群编号
|
||||||
created: [groupId: number]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
/** 弹窗显隐:把父侧 v-model 转双向计算 */
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (v) => emit('update:modelValue', v)
|
set: (value) => emit('update:modelValue', value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const groupName = ref('')
|
const groupName = ref('')
|
||||||
const searchText = ref('')
|
const searchText = ref('')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
/** 工作副本(带 isCheck 标记),与 prop 隔离 */
|
// TODO @AI:checked 改成这个变量;
|
||||||
const workingFriends = ref<FriendCheckable[]>([])
|
const workingFriends = ref<FriendCheckable[]>([]) // 工作副本(带 isCheck 标记),与 prop 隔离
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
visible,
|
visible,
|
||||||
|
|
@ -133,40 +131,52 @@ watch(
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 左侧展示的好友:按搜索关键字过滤 workingFriends */
|
||||||
const shownFriends = computed(() =>
|
const shownFriends = computed(() =>
|
||||||
workingFriends.value.filter((f) => f.nickname.includes(searchText.value))
|
workingFriends.value.filter((f) => f.nickname.includes(searchText.value))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 已勾选的好友:右侧预览 + 提交时取 memberUserIds */
|
||||||
const checkedFriends = computed(() => workingFriends.value.filter((f) => f.isCheck))
|
const checkedFriends = computed(() => workingFriends.value.filter((f) => f.isCheck))
|
||||||
|
|
||||||
function toggleCheck(f: FriendCheckable) {
|
/** 行点击:切换勾选态,让点击整行与点 checkbox 等价 */
|
||||||
|
function handleToggleCheck(f: FriendCheckable) {
|
||||||
f.isCheck = !f.isCheck
|
f.isCheck = !f.isCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 创建群聊:建群 → 拉人 → upsert groupStore,最后 emit('created') 让父页跳转新会话 */
|
||||||
async function handleOk() {
|
async function handleOk() {
|
||||||
const name = groupName.value.trim()
|
const name = groupName.value.trim()
|
||||||
if (!name) {
|
if (!name) {
|
||||||
ElMessage.warning('请输入群名称')
|
message.warning('请输入群名称')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const memberUserIds = checkedFriends.value.map((f) => f.id)
|
const memberUserIds = checkedFriends.value.map((f) => f.id)
|
||||||
if (memberUserIds.length === 0) {
|
if (memberUserIds.length === 0) {
|
||||||
ElMessage.warning('请至少选择一位好友')
|
message.warning('请至少选择一位好友')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
|
// 1.1 新建群聊
|
||||||
const group = await createGroup({ name })
|
const group = await createGroup({ name })
|
||||||
if (!group?.id) {
|
if (!group?.id) {
|
||||||
throw new Error('创建群失败:未返回群编号')
|
throw new Error('创建群失败:未返回群编号')
|
||||||
}
|
}
|
||||||
|
// 1.2 拉好友入群
|
||||||
await inviteGroupMember({ groupId: group.id, memberUserIds })
|
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)
|
emit('created', group.id)
|
||||||
visible.value = false
|
visible.value = false
|
||||||
} catch (e: any) {
|
|
||||||
console.error('[IM] 创建群失败', e)
|
|
||||||
ElMessage.error(e?.message || '创建群失败')
|
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
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