feat(im): 优化群聊的功能界面

im
YunaiV 2026-04-30 16:59:56 +08:00
parent 368b385267
commit 4b4c4fab11
8 changed files with 305 additions and 176 deletions

View File

@ -11,6 +11,9 @@
@click="$emit('click', friend)" @click="$emit('click', friend)"
@contextmenu.prevent="handleContextMenu" @contextmenu.prevent="handleContextMenu"
> >
<!-- prefix slot放在头像前给选择类弹窗的 checkbox / 圆点用不传则不渲染 -->
<slot name="prefix"></slot>
<!-- 头像 -->
<UserAvatar <UserAvatar
:id="friend.id" :id="friend.id"
:url="friend.avatar" :url="friend.avatar"
@ -18,8 +21,8 @@
:size="42" :size="42"
:clickable="false" :clickable="false"
/> />
<!-- 单行展示 displayName 优先昵称仅在好友详情面板展示列表里不重复 -->
<div class="flex flex-1 min-w-0"> <div class="flex flex-1 min-w-0">
<!-- 单行展示 displayName 优先昵称仅在好友详情面板展示列表里不重复 -->
<div class="overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]"> <div class="overflow-hidden text-sm truncate text-[var(--el-text-color-primary)]">
{{ friend.displayName || friend.nickname }} {{ friend.displayName || friend.nickname }}
</div> </div>
@ -55,12 +58,13 @@ const emit = defineEmits<{
const uiStore = useImUiStore() const uiStore = useImUiStore()
function handleContextMenu(e: MouseEvent) { /** 右键菜单:发送消息 / 删除好友 */
function handleContextMenu(event: MouseEvent) {
if (!props.menu) { if (!props.menu) {
return return
} }
uiStore.openContextMenu( uiStore.openContextMenu(
{ x: e.clientX, y: e.clientY }, { x: event.clientX, y: event.clientY },
[ [
{ key: 'chat', name: '发送消息' }, { key: 'chat', name: '发送消息' },
{ key: 'delete', name: '删除好友' } { key: 'delete', name: '删除好友' }

View File

@ -2,9 +2,10 @@
<!-- <!--
新建群聊对话框 新建群聊对话框
- 顶部群名称输入 - 顶部群名称输入
- 好友列表checkbox - 好友列表checkbox 前置
- 已勾选预览 - 已勾选预览每行可 x 移除locked 不渲染 x
- 提交 createGroup inviteGroupMember最后让父页 reload - 提交 createGroup inviteGroupMember最后让父页 reload
- lockedIds锁定不可取消的好友 id私聊侧 "+创建群" 入口用来锁定对方
--> -->
<el-dialog v-model="visible" title="新建群聊" width="620px" :close-on-click-modal="false"> <el-dialog v-model="visible" title="新建群聊" width="620px" :close-on-click-modal="false">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
@ -14,25 +15,31 @@
<div <div
class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]" class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]"
> >
<el-input v-model="searchText" placeholder="搜索好友" size="small" clearable> <el-input v-model="searchText" placeholder="搜索好友" clearable>
<template #suffix> <template #prefix>
<Icon icon="ant-design:search-outlined" /> <Icon
icon="ant-design:search-outlined"
class="text-[var(--el-text-color-placeholder)]"
/>
</template> </template>
</el-input> </el-input>
<el-scrollbar class="h-[400px]"> <el-scrollbar class="h-[400px]">
<FriendItem <FriendItem
v-for="f in shownFriends" v-for="friend in shownFriends"
:key="f.id" :key="friend.id"
:friend="f" :friend="friend"
:menu="false" :menu="false"
:active="false" :active="false"
@click="handleToggleCheck(f)" @click="handleToggleCheck(friend)"
> >
<el-checkbox <template #prefix>
:model-value="f.isCheck" <el-checkbox
@click.stop :model-value="friend.checked"
@change="(v) => (f.isCheck = !!v)" :disabled="friend.disabled"
/> @click.stop
@change="(value) => handleCheckChange(friend, !!value)"
/>
</template>
</FriendItem> </FriendItem>
</el-scrollbar> </el-scrollbar>
</div> </div>
@ -44,27 +51,38 @@
<div <div
class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]" class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]"
> >
<!-- 标题高度对齐左侧 el-input default32px保证两侧第一项起点在同一水平 -->
<div <div
class="h-10 pl-2.5 leading-10 text-13px text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]" class="h-8 pl-2.5 leading-8 text-13px text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]"
> >
已勾选 {{ checkedFriends.length }} 位好友 已勾选 {{ checkedFriends.length }} 位好友
</div> </div>
<el-scrollbar class="h-[400px]"> <el-scrollbar class="h-[400px]">
<FriendItem <FriendItem
v-for="f in checkedFriends" v-for="friend in checkedFriends"
:key="f.id" :key="friend.id"
:friend="f" :friend="friend"
:menu="false" :menu="false"
:active="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> </el-scrollbar>
</div> </div>
</div> </div>
</div> </div>
<template #footer> <template #footer>
<el-button @click="visible = false"> </el-button> <el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleOk"> </el-button> <el-button type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleOk">
完成
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@ -83,16 +101,19 @@ import type { FriendLite } from '../../types'
defineOptions({ name: 'ImGroupCreateDialog' }) defineOptions({ name: 'ImGroupCreateDialog' })
interface FriendCheckable extends FriendLite { interface FriendCheckable extends FriendLite {
isCheck?: boolean checked?: boolean
disabled?: boolean // locked 的好友:勾选态由 lockedIds 强制为 trueUI 上 checkbox / x 都不响应
} }
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
modelValue: boolean modelValue: boolean
friends?: FriendLite[] // friendStore friends?: FriendLite[] // friendStore
lockedIds?: number[] // id + "+"
}>(), }>(),
{ {
friends: () => [] friends: () => [],
lockedIds: () => []
} }
) )
@ -113,47 +134,73 @@ const visible = computed({
const groupName = ref('') const groupName = ref('')
const searchText = ref('') const searchText = ref('')
const submitting = ref(false) const submitting = ref(false)
// TODO @AIchecked const workingFriends = ref<FriendCheckable[]>([]) // 工作副本(带 checked / disabled 标记 prop 隔离
const workingFriends = ref<FriendCheckable[]>([]) // isCheck prop
watch( watch(
visible, visible,
(v) => { (open) => {
if (!v) { if (!open) {
return return
} }
groupName.value = '' groupName.value = ''
searchText.value = '' searchText.value = ''
workingFriends.value = props.friends workingFriends.value = props.friends
.filter((f) => !f.deleted) .filter((friend) => !friend.deleted)
.map((f) => ({ ...f, isCheck: false })) .map((friend) => {
const locked = props.lockedIds.some((id) => id === friend.id)
return { ...friend, checked: locked, disabled: locked }
})
}, },
{ immediate: true } { immediate: true }
) )
/** 左侧展示的好友:按搜索关键字过滤 workingFriends */ /** 左侧展示的好友:按搜索关键字过滤 workingFriends */
const shownFriends = computed(() => const shownFriends = computed(() =>
workingFriends.value.filter((f) => f.nickname.includes(searchText.value)) workingFriends.value.filter((friend) => friend.nickname.includes(searchText.value))
) )
/** 已勾选的好友:右侧预览 + 提交时取 memberUserIds */ /** 已勾选的好友:右侧预览 + 提交时取 memberUserIds */
const checkedFriends = computed(() => workingFriends.value.filter((f) => f.isCheck)) const checkedFriends = computed(() => workingFriends.value.filter((friend) => friend.checked))
/** 行点击:切换勾选态,让点击整行与点 checkbox 等价 */ /**
function handleToggleCheck(f: FriendCheckable) { * 完成按钮可点群名非空 + 至少有 1 个非 locked 勾选
f.isCheck = !f.isCheck *
* locked 是入口侧自动选的如私聊对方不算"用户主动选择"否则用户什么都没勾就能建 2 人群体验上等于私聊
*/
const canSubmit = computed(() => {
if (!groupName.value.trim()) {
return false
}
return checkedFriends.value.some((friend) => !friend.disabled)
})
/** 行点击切换勾选态locked 的不响应 */
function handleToggleCheck(friend: FriendCheckable) {
if (friend.disabled) {
return
}
friend.checked = !friend.checked
}
/** checkbox change直接落 valuelocked 已由 :disabled 拦截,这里再守一层) */
function handleCheckChange(friend: FriendCheckable, value: boolean) {
if (friend.disabled) {
return
}
friend.checked = value
}
/** 右侧 x 点击取消勾选locked 不渲染 x到这里说明非 locked */
function handleUncheck(friend: FriendCheckable) {
friend.checked = false
} }
/** 创建群聊:建群 → 拉人 → upsert groupStore最后 emit('created') 让父页跳转新会话 */ /** 创建群聊:建群 → 拉人 → upsert groupStore最后 emit('created') 让父页跳转新会话 */
async function handleOk() { async function handleOk() {
const name = groupName.value.trim() const name = groupName.value.trim()
if (!name) { const memberUserIds = checkedFriends.value.map((friend) => friend.id)
message.warning('请输入群名称') // canSubmit disabled
return if (!name || memberUserIds.length === 0) {
}
const memberUserIds = checkedFriends.value.map((f) => f.id)
if (memberUserIds.length === 0) {
message.warning('请至少选择一位好友')
return return
} }
submitting.value = true submitting.value = true
@ -165,6 +212,7 @@ async function handleOk() {
} }
// 1.2 // 1.2
await inviteGroupMember({ groupId: group.id, memberUserIds }) await inviteGroupMember({ groupId: group.id, memberUserIds })
// 2.1 upsert groupStore fetchGroups VO // 2.1 upsert groupStore fetchGroups VO
groupStore.upsertGroup({ groupStore.upsertGroup({
id: group.id, id: group.id,
@ -182,3 +230,16 @@ async function handleOk() {
} }
} }
</script> </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>

View File

@ -1,38 +1,40 @@
<template> <template>
<!-- <!--
邀请好友入群对话框 邀请好友入群对话框
- 好友列表 checkbox - 好友列表checkbox 前置
- 已勾选预览 - 已勾选预览每行可 x 移除
- 已在群内的好友标记为 disabled - 已在群内的好友 disabled复用 GroupCreateDialog 的视觉
- TODO 接入 /im/group/inviteTODO 这个是不是已经接入了
--> -->
<el-dialog v-model="visible" title="邀请好友" width="620px" :close-on-click-modal="false"> <el-dialog v-model="visible" title="邀请好友" width="620px" :close-on-click-modal="false">
<div class="flex gap-2.5"> <div class="flex gap-2.5">
<div <div
class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]" class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]"
> >
<el-input v-model="searchText" placeholder="搜索好友" size="small" clearable> <el-input v-model="searchText" placeholder="搜索好友" clearable>
<template #suffix> <template #prefix>
<Icon icon="ant-design:search-outlined" /> <Icon
icon="ant-design:search-outlined"
class="text-[var(--el-text-color-placeholder)]"
/>
</template> </template>
</el-input> </el-input>
<el-scrollbar class="h-[400px]"> <el-scrollbar class="h-[400px]">
<!-- TODO @ai: friend? -->
<FriendItem <FriendItem
v-for="f in shownFriends" v-for="friend in shownFriends"
:key="f.id" :key="friend.id"
:friend="f" :friend="friend"
:menu="false" :menu="false"
:active="false" :active="false"
@click="handleToggleCheck(f)" @click="handleToggleCheck(friend)"
> >
<!-- TODO @AIchecked --> <template #prefix>
<el-checkbox <el-checkbox
:model-value="f.isCheck" :model-value="friend.checked"
:disabled="f.disabled" :disabled="friend.disabled"
@click.stop @click.stop
@change="(v) => (f.isCheck = !!v)" @change="(value) => handleCheckChange(friend, !!value)"
/> />
</template>
</FriendItem> </FriendItem>
</el-scrollbar> </el-scrollbar>
</div> </div>
@ -44,26 +46,35 @@
<div <div
class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]" class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]"
> >
<!-- 标题高度对齐左侧 el-input default32px保证两侧第一项起点在同一水平 -->
<div <div
class="h-10 pl-2.5 leading-10 text-13px text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]" class="h-8 pl-2.5 leading-8 text-13px text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]"
> >
已勾选 {{ checkCount }} 位好友 已勾选 {{ checkedFriends.length }} 位好友
</div> </div>
<el-scrollbar class="h-[400px]"> <el-scrollbar class="h-[400px]">
<FriendItem <FriendItem
v-for="f in checkedFriends" v-for="friend in checkedFriends"
:key="f.id" :key="friend.id"
:friend="f" :friend="friend"
:menu="false" :menu="false"
:active="false" :active="false"
/> >
<Icon
icon="ant-design:close-outlined"
class="im-group-member-add-dialog__remove"
@click.stop="handleUncheck(friend)"
/>
</FriendItem>
</el-scrollbar> </el-scrollbar>
</div> </div>
</div> </div>
<template #footer> <template #footer>
<el-button @click="visible = false"> </el-button> <el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleOk"> </el-button> <el-button type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleOk">
完成
</el-button>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
@ -74,6 +85,7 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage' import { useMessage } from '@/hooks/web/useMessage'
import { CommonStatusEnum } from '@/utils/constants' import { CommonStatusEnum } from '@/utils/constants'
import { inviteGroupMember } from '@/api/im/group/member'
import FriendItem from '../friend/FriendItem.vue' import FriendItem from '../friend/FriendItem.vue'
import type { FriendLite } from '../../types' import type { FriendLite } from '../../types'
import type { GroupMemberLite } from './GroupMember.vue' import type { GroupMemberLite } from './GroupMember.vue'
@ -81,8 +93,8 @@ import type { GroupMemberLite } from './GroupMember.vue'
defineOptions({ name: 'ImGroupMemberAddDialog' }) defineOptions({ name: 'ImGroupMemberAddDialog' })
interface FriendCheckable extends FriendLite { interface FriendCheckable extends FriendLite {
isCheck?: boolean checked?: boolean
disabled?: boolean disabled?: boolean // checkbox +
} }
const props = withDefaults( const props = withDefaults(
@ -112,15 +124,16 @@ const visible = computed({
}) })
const searchText = ref('') const searchText = ref('')
/** 本地工作副本(带 isCheck / disabled 标记);和 props.friends 区分以避免直接改 prop */ const submitting = ref(false)
const workingFriends = ref<FriendCheckable[]>([]) const workingFriends = ref<FriendCheckable[]>([]) // 工作副本(带 checked / disabled 标记 prop 隔离
watch( watch(
visible, visible,
(v) => { (open) => {
if (!v) { if (!open) {
return return
} }
searchText.value = ''
workingFriends.value = props.friends workingFriends.value = props.friends
.filter((friend) => !friend.deleted) .filter((friend) => !friend.deleted)
.map((friend) => { .map((friend) => {
@ -129,8 +142,8 @@ watch(
) )
return { return {
...friend, ...friend,
disabled: inGroup, checked: inGroup,
isCheck: inGroup disabled: inGroup
} }
}) })
}, },
@ -139,36 +152,68 @@ watch(
/** 左侧展示的好友:按搜索关键字过滤 workingFriends */ /** 左侧展示的好友:按搜索关键字过滤 workingFriends */
const shownFriends = computed(() => const shownFriends = computed(() =>
workingFriends.value.filter((f) => f.nickname.includes(searchText.value)) workingFriends.value.filter((friend) => friend.nickname.includes(searchText.value))
) )
/** 本次将被邀请的好友:勾选 + 非已在群成员disabled 不计入) */ /** 本次将被邀请的好友:勾选 + 非已在群成员disabled 不计入) */
const checkedFriends = computed(() => workingFriends.value.filter((f) => f.isCheck && !f.disabled)) const checkedFriends = computed(() =>
workingFriends.value.filter((friend) => friend.checked && !friend.disabled)
)
/** 已勾选数量:右侧标题展示 */ /** 完成按钮可点:至少有 1 个新邀请的好友 */
const checkCount = computed(() => checkedFriends.value.length) const canSubmit = computed(() => checkedFriends.value.length > 0)
/** 行点击切换勾选态已在群disabled的不响应 */ /** 行点击切换勾选态已在群disabled的不响应 */
function handleToggleCheck(f: FriendCheckable) { function handleToggleCheck(friend: FriendCheckable) {
if (!f.disabled) { if (friend.disabled) {
f.isCheck = !f.isCheck
}
}
// TODO @AI
/**
* 邀请入群占位实现 reload 让父侧关弹窗 / 刷新
*
* TODO 接入 /im/group/invite
*/
async function handleOk() {
const ids = checkedFriends.value.map((f) => f.id)
if (ids.length === 0) {
message.warning('请选择至少一个好友')
return return
} }
message.info('邀请入群接口待接入,当前为占位实现') friend.checked = !friend.checked
emit('reload', ids) }
visible.value = false
/** checkbox change直接落 valuedisabled 已由属性拦截,这里再守一层) */
function handleCheckChange(friend: FriendCheckable, value: boolean) {
if (friend.disabled) {
return
}
friend.checked = value
}
/** 右侧 x 点击取消勾选disabled 不会进右侧列表,到这里说明非 disabled */
function handleUncheck(friend: FriendCheckable) {
friend.checked = false
}
/** 邀请入群:调 /im/group/invite成功后 emit reload 让父侧刷新群成员 */
async function handleOk() {
if (!props.groupId) {
return
}
const memberUserIds = checkedFriends.value.map((friend) => friend.id)
if (memberUserIds.length === 0) {
return
}
submitting.value = true
try {
await inviteGroupMember({ groupId: props.groupId, memberUserIds })
message.success('邀请成功')
emit('reload', memberUserIds)
visible.value = false
} finally {
submitting.value = false
}
} }
</script> </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>

View File

@ -1,8 +1,9 @@
<template> <template>
<!-- <!--
私聊侧边抽屉 私聊侧边抽屉
- 整体结构对齐 ConversationGroupSide宫格 + 信息行 + 开关 - 整体结构对齐 ConversationGroupSide宫格 + 信息行 + 开关
- "添加 / 清空聊天记录" 按钮在 WeChat 里有但目前后端没建群-from-私聊 / 清空消息能力先不加避免做半吊子 - 顶部好友宫格 + "+" tile + 调起 GroupCreateDialog 并锁定对方对齐微信"基于私聊发起群聊"
- "清空聊天记录"按钮在 WeChat 里有但目前后端没建消息清空能力先不加避免做半吊子
--> -->
<el-drawer <el-drawer
v-model="visible" v-model="visible"
@ -14,7 +15,7 @@
> >
<div v-if="friend" class="im-conversation-private-side flex flex-col h-full"> <div v-if="friend" class="im-conversation-private-side flex flex-col h-full">
<div class="im-conversation-private-side__scroll flex-1 overflow-y-auto"> <div class="im-conversation-private-side__scroll flex-1 overflow-y-auto">
<!-- 好友宫格单个头像 tile对齐 GroupSide 视觉让两种抽屉看起来是一家的 --> <!-- 好友宫格tile + "+" tile对齐 GroupSide 视觉让两种抽屉看起来是一家的 -->
<div class="im-conversation-private-side__section im-conversation-private-side__friend"> <div class="im-conversation-private-side__section im-conversation-private-side__friend">
<div class="im-conversation-private-side__tile-wrap"> <div class="im-conversation-private-side__tile-wrap">
<UserAvatar <UserAvatar
@ -28,6 +29,18 @@
{{ displayName }} {{ displayName }}
</div> </div>
</div> </div>
<!-- + tile点击调起 GroupCreateDialog把对方 id 作为 lockedIds 传入 -->
<div
class="im-conversation-private-side__tile-wrap im-conversation-private-side__tile-wrap--clickable"
title="发起群聊"
@click="createGroupVisible = true"
>
<div class="im-conversation-private-side__icon-tile">
<Icon icon="ant-design:plus-outlined" />
</div>
<div class="im-conversation-private-side__tile-label">添加</div>
</div>
</div> </div>
<div class="im-conversation-private-side__spacer"></div> <div class="im-conversation-private-side__spacer"></div>
@ -105,6 +118,14 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 子对话框发起群聊锁定对方为已选 -->
<GroupCreateDialog
v-model="createGroupVisible"
:friends="friends"
:locked-ids="lockedIds"
@created="handleGroupCreated"
/>
</el-drawer> </el-drawer>
</template> </template>
@ -112,13 +133,15 @@
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue' import Icon from '@/components/Icon/src/Icon.vue'
import UserAvatar from '../../../../components/user/UserAvatar.vue' import UserAvatar from '../../../../components/user/UserAvatar.vue'
import GroupCreateDialog from '../../../../components/group/GroupCreateDialog.vue'
import { useMessage } from '@/hooks/web/useMessage' import { useMessage } from '@/hooks/web/useMessage'
import { useConversationStore } from '@/views/im/home/store/conversationStore' import { useConversationStore } from '@/views/im/home/store/conversationStore'
import { useFriendStore } from '@/views/im/home/store/friendStore' import { useFriendStore } from '@/views/im/home/store/friendStore'
import { useGroupStore } from '@/views/im/home/store/groupStore'
import { getFriendDisplayName } from '@/views/im/utils/user' import { getFriendDisplayName } from '@/views/im/utils/user'
import { ImConversationType } from '@/views/im/utils/constants' import { ImConversationType } from '@/views/im/utils/constants'
import type { Conversation, Friend } from '../../../../types' import type { Conversation, Friend, FriendLite } from '../../../../types'
defineOptions({ name: 'ImConversationPrivateSide' }) defineOptions({ name: 'ImConversationPrivateSide' })
@ -127,9 +150,11 @@ const props = withDefaults(
modelValue?: boolean // v-model modelValue?: boolean // v-model
conversation?: Conversation | null // 当前会话(取置顶 / 免打扰态 conversation?: Conversation | null // 当前会话(取置顶 / 免打扰态
friend?: Friend // 对方好友信息(取头像 / 昵称 friend?: Friend // 对方好友信息(取头像 / 昵称
friends?: FriendLite[] // "+" GroupCreateDialog
}>(), }>(),
{ {
modelValue: false modelValue: false,
friends: () => []
} }
) )
@ -145,13 +170,20 @@ const visible = computed({
const conversationStore = useConversationStore() const conversationStore = useConversationStore()
const friendStore = useFriendStore() const friendStore = useFriendStore()
const groupStore = useGroupStore()
const message = useMessage() const message = useMessage()
/** tile 标签 / 后续聊天界面用的展示名:备注优先 */ /** tile 标签 / 后续聊天界面用的展示名:备注优先 */
const displayName = computed(() => (props.friend ? getFriendDisplayName(props.friend) : '')) const displayName = computed(() => (props.friend ? getFriendDisplayName(props.friend) : ''))
/** GroupCreateDialog 锁定 id把对方默认勾上且不可取消对应微信"基于私聊发起群聊" */
const lockedIds = computed<number[]>(() =>
props.friend ? [props.friend.friendUserId] : []
)
const displayNamePopoverVisible = ref(false) const displayNamePopoverVisible = ref(false)
const editDisplayName = ref('') const editDisplayName = ref('')
const createGroupVisible = ref(false)
// popover // popover
watch(displayNamePopoverVisible, (open) => { watch(displayNamePopoverVisible, (open) => {
@ -190,8 +222,8 @@ function handleMutedChange(value: boolean | string | number) {
if (type !== ImConversationType.PRIVATE) { if (type !== ImConversationType.PRIVATE) {
return return
} }
friendStore.setMuted(targetId, next).catch((e) => { friendStore.setMuted(targetId, next).catch((error) => {
console.error('[IM ConversationPrivateSide] 切换免打扰失败', { targetId }, e) console.error('[IM ConversationPrivateSide] 切换免打扰失败', { targetId }, error)
message.error('切换免打扰失败') message.error('切换免打扰失败')
conversationStore.setMuted(type, targetId, !next) conversationStore.setMuted(type, targetId, !next)
}) })
@ -204,6 +236,22 @@ function handleTopChange(value: boolean | string | number) {
} }
conversationStore.setTop(props.conversation.type, props.conversation.targetId, !!value) conversationStore.setTop(props.conversation.type, props.conversation.targetId, !!value)
} }
/** 群创建成功:跳到新群会话 + 关掉本侧抽屉,让用户专注新群 */
function handleGroupCreated(groupId: number) {
const group = groupStore.getGroup(groupId)
if (!group) {
return
}
conversationStore.openConversation(
groupId,
ImConversationType.GROUP,
group.name,
group.avatar || '',
{ muted: !!group.muted }
)
visible.value = false
}
</script> </script>
<style scoped> <style scoped>
@ -220,8 +268,11 @@ function handleTopChange(value: boolean | string | number) {
background-color: var(--el-bg-color); background-color: var(--el-bg-color);
} }
/* 好友宫格区:留白和 GroupSide__members 对齐,单 tile 居左展示 */ /* 好友宫格区:留白和 GroupSide__members 对齐,friend tile + "+" tile 横排 */
.im-conversation-private-side__friend { .im-conversation-private-side__friend {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 16px 16px 14px; padding: 16px 16px 14px;
} }
@ -231,6 +282,9 @@ function handleTopChange(value: boolean | string | number) {
align-items: center; align-items: center;
width: 66px; width: 66px;
} }
.im-conversation-private-side__tile-wrap--clickable {
cursor: pointer;
}
.im-conversation-private-side__tile-label { .im-conversation-private-side__tile-label {
width: 100%; width: 100%;
@ -244,6 +298,33 @@ function handleTopChange(value: boolean | string | number) {
white-space: nowrap; white-space: nowrap;
} }
/* "+" 加号 tile浅底 + 虚线边hover 走主色让交互可读,与 GroupSide 一致 */
.im-conversation-private-side__icon-tile {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
font-size: 20px;
color: var(--el-text-color-regular);
background-color: var(--el-fill-color-lighter);
border: 1px dashed var(--el-border-color);
border-radius: 6px;
transition:
color 0.18s,
border-color 0.18s,
background-color 0.18s;
}
.im-conversation-private-side__tile-wrap--clickable:hover .im-conversation-private-side__icon-tile {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
/* el-icon 全局 color 在暗色模式下被主题盖过;:deep(svg) 锁 fill 到当前色 */
.im-conversation-private-side__icon-tile :deep(svg) {
fill: currentColor !important;
}
/* section 间隔条 */ /* section 间隔条 */
.im-conversation-private-side__spacer { .im-conversation-private-side__spacer {
flex-shrink: 0; flex-shrink: 0;

View File

@ -84,7 +84,7 @@
:group="groupInfo" :group="groupInfo"
:conversation="conversationStore.activeConversation" :conversation="conversationStore.activeConversation"
:members="groupMembers" :members="groupMembers"
:friends="groupFriends" :friends="friends"
@reload="reloadGroupData" @reload="reloadGroupData"
@open-history="historyVisible = true" @open-history="historyVisible = true"
/> />
@ -93,6 +93,7 @@
v-model="sideVisible" v-model="sideVisible"
:conversation="conversationStore.activeConversation" :conversation="conversationStore.activeConversation"
:friend="privateFriend" :friend="privateFriend"
:friends="friends"
@open-history="historyVisible = true" @open-history="historyVisible = true"
/> />
@ -201,8 +202,8 @@ const groupMembers = computed<GroupMemberLite[]>(() => {
}) })
}) })
/** 好友列表(用于"邀请入群"对话框):把 friendStore 的全量好友 map 成 FriendLite 窄接口 */ /** 好友列表:群侧用于"邀请入群",私聊侧用于"+创建群",统一从 friendStore 映射成 FriendLite 窄接口 */
const groupFriends = computed<FriendLite[]>(() => const friends = computed<FriendLite[]>(() =>
friendStore.getActiveFriends.map((friend) => ({ friendStore.getActiveFriends.map((friend) => ({
id: friend.friendUserId, id: friend.friendUserId,
nickname: friend.nickname, nickname: friend.nickname,

View File

@ -1,21 +0,0 @@
<template>
<ContentWrap>
<!-- TODO @芋艿这个只是临时的 -->
<div class="im-manager-placeholder">我是好友管理测试界面</div>
</ContentWrap>
</template>
<script lang="ts" setup>
defineOptions({ name: 'ImManagerFriend' })
</script>
<style scoped>
.im-manager-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
font-size: 18px;
color: #606266;
}
</style>

View File

@ -1,21 +0,0 @@
<template>
<ContentWrap>
<!-- TODO @芋艿这个只是临时的 -->
<div class="im-manager-placeholder">我是群管理测试界面</div>
</ContentWrap>
</template>
<script lang="ts" setup>
defineOptions({ name: 'ImManagerGroup' })
</script>
<style scoped>
.im-manager-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
font-size: 18px;
color: #606266;
}
</style>

View File

@ -1,21 +0,0 @@
<template>
<ContentWrap>
<!-- TODO @芋艿这个只是临时的 -->
<div class="im-manager-placeholder">我是消息管理测试界面</div>
</ContentWrap>
</template>
<script lang="ts" setup>
defineOptions({ name: 'ImManagerMessage' })
</script>
<style scoped>
.im-manager-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
font-size: 18px;
color: #606266;
}
</style>