✨ feat(im): 优化群聊的功能界面
parent
368b385267
commit
4b4c4fab11
|
|
@ -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: '删除好友' }
|
||||||
|
|
|
||||||
|
|
@ -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 default(32px),保证两侧第一项起点在同一水平 -->
|
||||||
<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 强制为 true,UI 上 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 @AI:checked 改成这个变量;
|
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:直接落 value(locked 已由 :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>
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,40 @@
|
||||||
<template>
|
<template>
|
||||||
<!--
|
<!--
|
||||||
邀请好友入群对话框
|
邀请好友入群对话框
|
||||||
- 左:好友列表(带 checkbox)
|
- 左:好友列表(checkbox 前置)
|
||||||
- 右:已勾选预览
|
- 右:已勾选预览(每行可 x 移除)
|
||||||
- 已在群内的好友标记为 disabled
|
- 已在群内的好友 disabled,复用 GroupCreateDialog 的视觉
|
||||||
- TODO 接入 /im/group/invite;TODO 这个是不是已经接入了?
|
|
||||||
-->
|
-->
|
||||||
<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 @AI:checked? -->
|
<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 default(32px),保证两侧第一项起点在同一水平 -->
|
||||||
<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:直接落 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 邀请入群:调 /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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
Loading…
Reference in New Issue