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
parent
40ac2daca8
commit
312df4c73d
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
<template>
|
||||
<!--
|
||||
设置群管理员:一个弹窗合并增 / 删,提交时跟当前管理员列表 diff
|
||||
- dialog 壳本组件持有;选择 UI 委托 GroupMemberPickerPanel(grid 形态对齐当前视觉)
|
||||
- 群主从候选里隐藏(不能设为管理员)
|
||||
- 对外接口: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>
|
||||
|
|
@ -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 default(32px),保证两侧第一项起点在同一水平 -->
|
||||
<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 强制为 true,UI 上 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:直接落 value(locked 已由 :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>
|
||||
|
|
|
|||
|
|
@ -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 default(32px),保证两侧第一项起点在同一水平 -->
|
||||
<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:直接落 value(disabled 已由属性拦截,这里再守一层) */
|
||||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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:直接落 checked(locked 已由 disabled 拦截) */
|
||||
function handleCheckChange(member: GroupMemberFlag, checked: boolean) {
|
||||
applyCheck(member, checked)
|
||||
}
|
||||
|
||||
/** 确定:把已勾选成员通过 complete 抛给父侧 */
|
||||
function handleOk() {
|
||||
emit('complete', checkedMembers.value)
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
/** 已选会话 key(v-model);key 由 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>
|
||||
|
|
@ -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 default(32px),保证两侧第一项起点同水平 -->
|
||||
<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[]
|
||||
/** 已选好友 id(v-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))
|
||||
|
||||
/** 候选好友:剔除 hideIds(hide 优先级最高) */
|
||||
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>
|
||||
|
|
@ -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 default(32px) -->
|
||||
<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[]
|
||||
/** 已选 userId(v-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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
/** 构造名片消息 content(JSON 字符串);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
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认发送:每个选中会话先发 CARD,CARD 成功后才发留言(保证「先看到名片」的顺序意图,CARD 失败时不发留言避免错序)
|
||||
* 确认发送(会话视图):每个选中会话先发 CARD,CARD 成功后才发留言(保证「先看到名片」的顺序意图)
|
||||
*
|
||||
* 文案聚合:全部成功「已转发」、全部失败「转发失败:A、B」、部分失败「已转发,但 X、Y 失败」(具体列出失败会话名方便定位);
|
||||
* 文案聚合:全部成功「已转发」、全部失败「转发失败:A、B」、部分失败「已转发,但 X、Y 失败」;
|
||||
* 失败的消息以 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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
/** 加好友弹窗 ref:handleAddFriend 调 open({ presetUser, addSource, addSourceExtra }) 触发 */
|
||||
const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>()
|
||||
|
||||
/** 推荐名片弹窗 ref:handleRecommend 调用 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 同步多端 */
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
@ -2,12 +2,13 @@
|
|||
<!--
|
||||
通讯录 - 好友分组
|
||||
- 自治:折叠状态 + 关键字过滤 + 字母分桶 本组件内闭环
|
||||
- 字母分桶 / 拼音搜索委托 useFriendBuckets,与选择类弹窗 FriendPickerPanel 共用一份规则
|
||||
- 选中态由父级 activeId 透传;chat / delete 透传到父级走 store 改造
|
||||
-->
|
||||
<div>
|
||||
<!-- 折叠分组头:字号对齐微信 PC(15px),hover 浅底色反馈 -->
|
||||
<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
|
||||
}
|
||||
// 全拼搜索去掉空格,让「laozhang」也能命中「lao 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>
|
||||
|
|
|
|||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
/** 邀请好友入群弹窗 ref:handleOpenInvite 调 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) {
|
|||
}
|
||||
}
|
||||
|
||||
// ==================== 进群审批 ====================
|
||||
|
||||
/** 进群申请列表弹窗 ref:handleOpenRequestList 调 open({ groupId }) 触发 */
|
||||
const requestListDialogRef = ref<InstanceType<typeof GroupRequestListDialog>>()
|
||||
|
||||
/** 打开当前群的进群申请列表 */
|
||||
function handleOpenRequestList() {
|
||||
if (!props.group?.id) {
|
||||
return
|
||||
}
|
||||
requestListDialogRef.value?.open({ groupId: props.group.id })
|
||||
}
|
||||
|
||||
// ==================== 分享群名片 ====================
|
||||
|
||||
/** 分享群名片弹窗 ref:handleShareGroupCard 调用 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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] : []
|
||||
)
|
||||
/** 发起群聊弹窗 ref:handleOpenCreateGroup 调 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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
/** 申请列表弹窗 ref:handleOpen 调 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))
|
||||
|
|
|
|||
|
|
@ -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 ====================
|
||||
|
||||
|
|
|
|||
|
|
@ -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>>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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 + 序列化 content;merge 模式下一次构造,预览 / 发送共用 */
|
||||
|
|
@ -337,7 +207,7 @@ const mergePreview = computed(() => {
|
|||
return { title: payload.title, lines }
|
||||
})
|
||||
|
||||
/** 单条 / 逐条模式预览:取前 N 条 */
|
||||
/** 逐条模式预览:取前 N 条摘要 */
|
||||
const singlePreviewLines = computed(() =>
|
||||
state.messages.slice(0, MERGE_FORWARD_PREVIEW_LINES).map((m) => summarizeMessageContent(m))
|
||||
)
|
||||
|
|
@ -383,7 +253,15 @@ async function handleSend() {
|
|||
message.warning('没有可转发的消息')
|
||||
return
|
||||
}
|
||||
const targets = selectedConversations.value
|
||||
// 反查已选 conversation 对象(按 selectedKeys 数组顺序,即点击顺序)
|
||||
const candidates = candidateConversations.value
|
||||
const byKey = new Map(candidates.map((c) => [getConversationKey(c), c]))
|
||||
const targets = selectedKeys.value
|
||||
.map((key) => byKey.get(key))
|
||||
.filter((c): c is Conversation => c != null)
|
||||
if (targets.length === 0) {
|
||||
return
|
||||
}
|
||||
const leaveText = leaveMessage.value.trim()
|
||||
sending.value = true
|
||||
try {
|
||||
|
|
@ -397,6 +275,8 @@ async function handleSend() {
|
|||
})
|
||||
const results = await Promise.all(tasks)
|
||||
const failedNames = results.filter((r) => !r.ok).map((r) => r.target.name || '未命名会话')
|
||||
// 命中的目标统一推到最近转发列表(部分失败也推:用户的"意图"已表达)
|
||||
conversationStore.pushRecentForwardConversationKeys(targets.map((c) => getConversationKey(c)))
|
||||
if (failedNames.length === 0) {
|
||||
message.success('已转发')
|
||||
} else if (failedNames.length === targets.length) {
|
||||
|
|
@ -414,23 +294,11 @@ async function handleSend() {
|
|||
}
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}))
|
||||
)
|
||||
/** 发起群聊弹窗 ref:handleOpenCreateGroup 调 open() 打开 */
|
||||
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>()
|
||||
|
||||
/** 打开发起群聊弹窗:无锁定项的全局入口 */
|
||||
function handleOpenCreateGroup() {
|
||||
createGroupDialogRef.value?.open()
|
||||
}
|
||||
|
||||
/** 处理建群成功 */
|
||||
function handleGroupCreated(groupId: number) {
|
||||
|
|
|
|||
|
|
@ -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 列表落到 IDB;fire-and-forget,按 userId 分桶 */
|
||||
persistRecentForwardConversationKeys() {
|
||||
const userId = getCurrentUserId()
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
void imStorage
|
||||
.setItem(
|
||||
StorageKeys.recentForwardConversationKeys(userId),
|
||||
toRaw(this.recentForwardConversationKeys)
|
||||
)
|
||||
.catch((e) => console.warn('[IM] 最近转发列表持久化失败', e))
|
||||
},
|
||||
|
||||
// ==================== 其它 ====================
|
||||
|
||||
/** 更新 privateMessageMaxId / groupMessageMaxId 游标 */
|
||||
updateMaxId(conversationType: number, messageId?: number) {
|
||||
if (!messageId) {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 || '群聊'
|
||||
}
|
||||
|
|
@ -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 偏好。 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue