✨ feat(im): 新增通讯录界面
parent
a762dfff84
commit
0c7d1f0df6
|
|
@ -300,15 +300,9 @@ async function saveRemark() {
|
|||
if (next === (props.displayName || '')) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await friendStore.setDisplayName(userId, next)
|
||||
message.success('已更新备注')
|
||||
emit('saved', next)
|
||||
} catch (e) {
|
||||
// TODO @AI:这里也是,需要有个 userId 之类的上下文
|
||||
console.error('[IM] 更新备注失败', e)
|
||||
message.error('更新备注失败')
|
||||
}
|
||||
await friendStore.setDisplayName(userId, next)
|
||||
message.success('已更新备注')
|
||||
emit('saved', next)
|
||||
}
|
||||
|
||||
function cancelEditRemark() {
|
||||
|
|
@ -328,16 +322,11 @@ async function handleAddFriend() {
|
|||
if (!props.user?.id) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await friendStore.addFriend(props.user.id, {
|
||||
nickname: props.user.nickname,
|
||||
avatar: props.user.avatar
|
||||
})
|
||||
message.success('已添加好友')
|
||||
} catch (e: any) {
|
||||
console.error('[IM] 添加好友失败', e)
|
||||
message.error(e?.message || '添加好友失败')
|
||||
}
|
||||
await friendStore.addFriend(props.user.id, {
|
||||
nickname: props.user.nickname,
|
||||
avatar: props.user.avatar
|
||||
})
|
||||
message.success('已添加好友')
|
||||
}
|
||||
|
||||
/** 删除联系人:confirm → friendStore.deleteFriend(内部级联清会话)→ 通知父级关浮层 / 清选中 */
|
||||
|
|
@ -346,17 +335,15 @@ async function handleDeleteFriend() {
|
|||
return
|
||||
}
|
||||
const target = props.user
|
||||
// 二次确认(用户点取消时 message.confirm 抛 reject,吃掉直接 return)
|
||||
try {
|
||||
await message.confirm(`确定删除好友「${target.nickname || ''}」吗?`, '删除联系人')
|
||||
await friendStore.deleteFriend(target.id)
|
||||
message.success('已删除好友')
|
||||
emit('deleted', target)
|
||||
} catch (e) {
|
||||
if (e !== 'cancel' && e !== 'close') {
|
||||
console.error('[IM] 删除好友失败', e)
|
||||
message.error('删除好友失败')
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await friendStore.deleteFriend(target.id)
|
||||
message.success('已删除好友')
|
||||
emit('deleted', target)
|
||||
}
|
||||
|
||||
/** 占位提示:语音 / 视频聊天能力尚未接入,先以"开发中"友好提示 */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
<template>
|
||||
<!--
|
||||
通讯录 - 好友分组
|
||||
- 自治:折叠状态 + 关键字过滤 + 字母分桶 本组件内闭环
|
||||
- 选中态由父级 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)]"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" />
|
||||
<span class="flex-1">好友</span>
|
||||
<span class="text-sm text-[var(--el-text-color-secondary)]">{{ filtered.length }}</span>
|
||||
</div>
|
||||
<div v-show="expanded">
|
||||
<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>
|
||||
<FriendItem
|
||||
v-for="friend in bucket.list"
|
||||
:key="friend.id"
|
||||
:friend="friend"
|
||||
:active="activeId === friend.id"
|
||||
@click="emit('select', friend)"
|
||||
@chat="emit('chat', $event)"
|
||||
@delete="emit('delete', $event)"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-if="filtered.length === 0"
|
||||
class="py-3 text-12px text-center text-[var(--el-text-color-disabled)]"
|
||||
>
|
||||
{{ keyword ? '没有匹配的好友' : '暂无好友' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import Icon from '@/components/Icon/src/Icon.vue'
|
||||
import FriendItem from '../../components/friend/FriendItem.vue'
|
||||
import type { FriendLite } from '../../types'
|
||||
|
||||
defineOptions({ name: 'ImContactFriendList' })
|
||||
|
||||
const props = defineProps<{
|
||||
friends: FriendLite[]
|
||||
keyword: string
|
||||
activeId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [friend: FriendLite]
|
||||
chat: [friend: FriendLite]
|
||||
delete: [friend: FriendLite]
|
||||
}>()
|
||||
|
||||
const expanded = ref(true)
|
||||
|
||||
/** 关键字过滤:兼顾备注 + 原昵称,记不住哪个就按哪个搜 */
|
||||
const filtered = computed(() => {
|
||||
const keywordLower = props.keyword.trim().toLowerCase()
|
||||
return props.friends.filter((friend) => {
|
||||
if (friend.deleted) {
|
||||
return false
|
||||
}
|
||||
if (!keywordLower) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
(friend.nickname || '').toLowerCase().includes(keywordLower) ||
|
||||
(friend.displayName || '').toLowerCase().includes(keywordLower)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 字母分桶:
|
||||
* - ASCII 字母直接取首字母大写
|
||||
* - 中文 / 其它非拉丁字符统一进 "#"(项目未引 pinyin 库,留中文按 "#" 显示,避免引入新依赖)
|
||||
* - 桶内按显示名 localeCompare 自然序
|
||||
*/
|
||||
interface FriendBucket {
|
||||
letter: string
|
||||
list: FriendLite[]
|
||||
}
|
||||
|
||||
// TODO @AI:需要增加拼音返回;我们要讨论下,hutool 有拼音库;可能要引入下库;
|
||||
const buckets = computed<FriendBucket[]>(() => {
|
||||
const map = new Map<string, FriendLite[]>()
|
||||
for (const friend of filtered.value) {
|
||||
const name = (friend.displayName || friend.nickname || '').trim()
|
||||
const first = name.charAt(0)
|
||||
const letter = /^[a-zA-Z]$/.test(first) ? first.toUpperCase() : '#'
|
||||
if (!map.has(letter)) {
|
||||
map.set(letter, [])
|
||||
}
|
||||
map.get(letter)!.push(friend)
|
||||
}
|
||||
// letter 排序:A-Z 在前,"#" 兜底
|
||||
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) =>
|
||||
(a.displayName || a.nickname || '').localeCompare(b.displayName || b.nickname || '')
|
||||
)
|
||||
}))
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<!--
|
||||
通讯录 - 群聊详情
|
||||
- 头像 + 群名 + 成员数 + 成员宫格 + "进入群聊",纯展示不做群管理
|
||||
- 成员列表按 group.id 懒拉,组件内部维护;切换群时清空避免上一条群成员闪现
|
||||
-->
|
||||
<div class="flex justify-center pt-12 px-6">
|
||||
<div class="w-full max-w-[320px] flex flex-col gap-3 items-center">
|
||||
<UserAvatar
|
||||
:url="group.showImage || group.showImageThumb"
|
||||
:name="group.showGroupName || group.name"
|
||||
:size="72"
|
||||
:clickable="false"
|
||||
previewable
|
||||
/>
|
||||
<div
|
||||
class="text-lg font-semibold leading-snug text-[var(--el-text-color-primary)] truncate w-full text-center"
|
||||
>
|
||||
{{ group.showGroupName || group.name }}
|
||||
</div>
|
||||
<div class="text-13px text-[var(--el-text-color-secondary)]"> {{ memberCount }} 位成员 </div>
|
||||
<!-- 成员宫格:纯展示,宽度跟着 320 容器自动换行;不带"邀请 +"瓦片 -->
|
||||
<div class="flex flex-wrap gap-2 justify-center w-full pt-2">
|
||||
<GroupMemberGrid v-for="member in members" :key="member.userId" :member="member" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<el-button type="primary" @click="emit('chat', group)">进入群聊</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import UserAvatar from '../../components/user/UserAvatar.vue'
|
||||
import GroupMemberGrid from '../../components/group/GroupMemberGrid.vue'
|
||||
import type { Friend, GroupLite, GroupMember } from '../../types'
|
||||
import type { GroupMemberLite } from '../../components/group/GroupMember.vue'
|
||||
import { useFriendStore } from '../../store/friendStore'
|
||||
import { useGroupStore } from '../../store/groupStore'
|
||||
import { getMemberDisplayName } from '../../../utils/user'
|
||||
|
||||
defineOptions({ name: 'ImContactGroupDetail' })
|
||||
|
||||
const props = defineProps<{
|
||||
group: GroupLite
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
chat: [group: GroupLite]
|
||||
}>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const friendStore = useFriendStore()
|
||||
|
||||
const members = ref<GroupMemberLite[]>([])
|
||||
|
||||
/** 群人数文案:优先后端 memberCount,其次按已拉到的列表长度兜底;都没有给 0 */
|
||||
const memberCount = computed(() => props.group.memberCount || members.value.length || 0)
|
||||
|
||||
/** 切换群 / 首挂:拉成员;竞态用 group.id 比对丢弃陈旧响应避免上一条群成员错位 */
|
||||
watch(
|
||||
() => props.group?.id,
|
||||
async (id) => {
|
||||
members.value = []
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
const list = await groupStore.fetchGroupMembers(id)
|
||||
if (props.group?.id !== id) {
|
||||
return
|
||||
}
|
||||
members.value = list.map((member) =>
|
||||
convertGroupMemberLite(member, friendStore.getFriend(member.userId))
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** 群成员 → 列表项 */
|
||||
function convertGroupMemberLite(member: GroupMember, friend: Friend | undefined): GroupMemberLite {
|
||||
return {
|
||||
userId: member.userId,
|
||||
showName: getMemberDisplayName(member, friend),
|
||||
nickname: member.nickname,
|
||||
avatar: member.avatar,
|
||||
status: member.status
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<template>
|
||||
<!--
|
||||
通讯录 - 群聊分组
|
||||
- 自治:折叠状态 + 关键字过滤本组件内闭环
|
||||
- 选中态由父级 activeId 透传,避免子组件持有 selection 知识
|
||||
-->
|
||||
<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)]"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" />
|
||||
<span class="flex-1">群聊</span>
|
||||
<span class="text-sm text-[var(--el-text-color-secondary)]">{{ filtered.length }}</span>
|
||||
</div>
|
||||
<div v-show="expanded">
|
||||
<GroupItem
|
||||
v-for="group in filtered"
|
||||
:key="group.id"
|
||||
:group="group"
|
||||
:active="activeId === group.id"
|
||||
@click="emit('select', group)"
|
||||
/>
|
||||
<div
|
||||
v-if="filtered.length === 0"
|
||||
class="py-3 text-12px text-center text-[var(--el-text-color-disabled)]"
|
||||
>
|
||||
{{ keyword ? '没有匹配的群聊' : '暂无群聊' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import Icon from '@/components/Icon/src/Icon.vue'
|
||||
import GroupItem from '../../components/group/GroupItem.vue'
|
||||
import type { GroupLite } from '../../types'
|
||||
|
||||
defineOptions({ name: 'ImContactGroupList' })
|
||||
|
||||
const props = defineProps<{
|
||||
groups: GroupLite[]
|
||||
keyword: string
|
||||
activeId?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [group: GroupLite] }>()
|
||||
|
||||
const expanded = ref(true)
|
||||
|
||||
const filtered = computed(() => {
|
||||
const keywordLower = props.keyword.trim().toLowerCase()
|
||||
if (!keywordLower) {
|
||||
return props.groups
|
||||
}
|
||||
return props.groups.filter((group) =>
|
||||
(group.showGroupName || group.name || '').toLowerCase().includes(keywordLower)
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
<template>
|
||||
<!--
|
||||
通讯录 Tab 整页(参考微信 PC 通讯录)
|
||||
- 左:搜索 + GroupList / FriendList 两个折叠分组(好友按字母分桶)
|
||||
- 右:选中项的详情,好友走共享 <UserInfo>(relation=friend),群聊走 GroupDetail
|
||||
- 本页仅做:选中分发 + 数据源转换 + 跨组件事件落 store
|
||||
-->
|
||||
<div class="flex flex-1 h-full min-w-0 bg-[var(--el-bg-color)]">
|
||||
<ResizableAside :default-width="260" :storage-key="StorageKeys.asideWidth">
|
||||
<!-- 顶部:仅搜索框 -->
|
||||
<div
|
||||
class="flex flex-shrink-0 items-center px-4 py-2 border-b border-[var(--el-border-color-lighter)]"
|
||||
>
|
||||
<el-input v-model="keyword" placeholder="搜索" clearable class="flex-1">
|
||||
<template #prefix>
|
||||
<Icon icon="ant-design:search-outlined" />
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 列表主体:拆 GroupList / FriendList 两个子组件,各自管理折叠 + 过滤;本页只透传选中态 -->
|
||||
<el-scrollbar class="flex-1">
|
||||
<GroupList
|
||||
:groups="groups"
|
||||
:keyword="keyword"
|
||||
:active-id="selection?.type === 'group' ? selection.group.id : undefined"
|
||||
@select="handleSelectGroup"
|
||||
/>
|
||||
<FriendList
|
||||
:friends="friends"
|
||||
:keyword="keyword"
|
||||
:active-id="selection?.type === 'friend' ? selection.friend.id : undefined"
|
||||
@select="handleSelectFriend"
|
||||
@chat="handleChatFriend"
|
||||
@delete="handleDeleteFriend"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
</ResizableAside>
|
||||
|
||||
<!-- 右侧详情区 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- 空态:占位图标 + 提示文案 -->
|
||||
<div
|
||||
v-if="!selection"
|
||||
class="flex flex-col items-center justify-center h-full gap-3 text-[var(--el-text-color-secondary)]"
|
||||
>
|
||||
<Icon
|
||||
icon="ant-design:contacts-outlined"
|
||||
:size="64"
|
||||
class="text-[var(--el-text-color-placeholder)]"
|
||||
/>
|
||||
<span class="text-sm">在左侧选择好友或群聊查看详情</span>
|
||||
</div>
|
||||
<!-- 好友详情 -->
|
||||
<div v-else-if="selection.type === 'friend'" class="flex justify-center pt-12 px-6">
|
||||
<div class="w-full max-w-[320px]">
|
||||
<UserInfo
|
||||
:user="friendUser"
|
||||
:display-name="selection.friend.displayName || ''"
|
||||
relation="friend"
|
||||
@chat="handleChatFriend(selection.friend)"
|
||||
@deleted="selection = null"
|
||||
@saved="onRemarkSaved"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 群详情 -->
|
||||
<GroupDetail
|
||||
v-else-if="selection.type === 'group'"
|
||||
:group="selection.group"
|
||||
@chat="handleChatGroup"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Icon from '@/components/Icon/src/Icon.vue'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ResizableAside from '../../components/ResizableAside.vue'
|
||||
import UserInfo from '../../components/user/UserInfo.vue'
|
||||
import FriendList from './FriendList.vue'
|
||||
import GroupList from './GroupList.vue'
|
||||
import GroupDetail from './GroupDetail.vue'
|
||||
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, Group, GroupLite, User } from '../../types'
|
||||
import { ImConversationType } from '../../../utils/constants'
|
||||
import { StorageKeys } from '../../../utils/storage'
|
||||
import { CommonStatusEnum } from '@/utils/constants'
|
||||
|
||||
defineOptions({ name: 'ImContactPage' })
|
||||
|
||||
const router = useRouter()
|
||||
const conversationStore = useConversationStore()
|
||||
const friendStore = useFriendStore()
|
||||
const groupStore = useGroupStore()
|
||||
const message = useMessage()
|
||||
|
||||
/** 用 type 判别选中是好友还是群聊 */
|
||||
type Selection = { type: 'friend'; friend: FriendLite } | { type: 'group'; group: GroupLite }
|
||||
|
||||
const selection = ref<Selection | null>(null)
|
||||
const keyword = ref('')
|
||||
|
||||
const friends = computed<FriendLite[]>(() =>
|
||||
friendStore.getActiveFriends.map((friend: Friend) => ({
|
||||
id: friend.friendUserId,
|
||||
nickname: friend.nickname,
|
||||
avatar: friend.avatar,
|
||||
displayName: friend.displayName,
|
||||
deleted: friend.status === CommonStatusEnum.DISABLE
|
||||
}))
|
||||
)
|
||||
|
||||
const groups = computed<GroupLite[]>(() =>
|
||||
groupStore.groups.map((group: Group) => ({
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
// 优先用群备注 displayGroupName,没设置时回落到原群名;避免点"进入群聊"时把已同步的备注会话名刷回原名
|
||||
showGroupName: getGroupDisplayName(group),
|
||||
showImage: group.avatar,
|
||||
showImageThumb: group.avatar,
|
||||
memberCount: group.memberCount
|
||||
}))
|
||||
)
|
||||
|
||||
const friendUser = computed<User | null>(() => {
|
||||
if (selection.value?.type !== 'friend') {
|
||||
return null
|
||||
}
|
||||
const friend = selection.value.friend
|
||||
return {
|
||||
id: friend.id,
|
||||
nickname: friend.nickname,
|
||||
avatar: friend.avatar
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([friendStore.fetchFriends(), groupStore.fetchGroups()])
|
||||
})
|
||||
|
||||
/** 选中好友 → 切到好友详情 */
|
||||
function handleSelectFriend(friend: FriendLite) {
|
||||
selection.value = { type: 'friend', friend }
|
||||
}
|
||||
|
||||
/** 选中群聊 → 切到群详情 */
|
||||
function handleSelectGroup(group: GroupLite) {
|
||||
selection.value = { type: 'group', group }
|
||||
}
|
||||
|
||||
/** 进入与该好友的私聊会话 */
|
||||
function handleChatFriend(friend: FriendLite) {
|
||||
// 从 friendStore 同步备注 + 免打扰,避免新建会话用过期数据
|
||||
const entry = friendStore.getFriend(friend.id)
|
||||
const conversationName = entry ? getFriendDisplayName(entry) : friend.nickname
|
||||
conversationStore.openConversation(
|
||||
friend.id,
|
||||
ImConversationType.PRIVATE,
|
||||
conversationName,
|
||||
friend.avatar || '',
|
||||
{ muted: !!entry?.muted }
|
||||
)
|
||||
router.push({ name: 'ImHomeConversation' })
|
||||
}
|
||||
|
||||
/** 进入该群的群聊会话 */
|
||||
function handleChatGroup(group: GroupLite) {
|
||||
const entry = groupStore.getGroup(group.id)
|
||||
conversationStore.openConversation(
|
||||
group.id,
|
||||
ImConversationType.GROUP,
|
||||
group.showGroupName || group.name || '',
|
||||
group.showImage || group.showImageThumb || '',
|
||||
{ muted: !!entry?.muted }
|
||||
)
|
||||
router.push({ name: 'ImHomeConversation' })
|
||||
}
|
||||
|
||||
/** 删除好友:二次确认 → store 落库 → 清空当前选中 */
|
||||
async function handleDeleteFriend(friend: FriendLite) {
|
||||
try {
|
||||
await message.confirm(`确定删除好友「${friend.nickname}」吗?`, '删除联系人')
|
||||
// friendStore.deleteFriend 内部已经级联清理对应私聊会话
|
||||
await friendStore.deleteFriend(friend.id)
|
||||
if (selection.value?.type === 'friend' && selection.value.friend.id === friend.id) {
|
||||
selection.value = null
|
||||
}
|
||||
message.success('已删除好友')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 备注已保存:UserInfo 内部已经走完 friendStore 落库 + 提示,本侧只负责同步 selection 持的旧 FriendLite 副本 */
|
||||
function onRemarkSaved(displayName: string) {
|
||||
if (selection.value?.type === 'friend') {
|
||||
selection.value.friend.displayName = displayName
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -431,15 +431,10 @@ async function saveName() {
|
|||
if (!props.group) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateGroup({ id: props.group.id, name: editName.value })
|
||||
namePopoverVisible.value = false
|
||||
message.success('保存成功')
|
||||
emit('reload')
|
||||
} catch (error) {
|
||||
console.error('[IM ConversationGroupSide] 保存群名失败', error)
|
||||
message.error('保存失败')
|
||||
}
|
||||
await updateGroup({ id: props.group.id, name: editName.value })
|
||||
namePopoverVisible.value = false
|
||||
message.success('保存成功')
|
||||
emit('reload')
|
||||
}
|
||||
|
||||
/** 群主:保存群公告 */
|
||||
|
|
@ -447,15 +442,10 @@ async function saveNotice() {
|
|||
if (!props.group) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateGroup({ id: props.group.id, notice: editNotice.value })
|
||||
noticePopoverVisible.value = false
|
||||
message.success('保存成功')
|
||||
emit('reload')
|
||||
} catch (error) {
|
||||
console.error('[IM ConversationGroupSide] 保存群公告失败', error)
|
||||
message.error('保存失败')
|
||||
}
|
||||
await updateGroup({ id: props.group.id, notice: editNotice.value })
|
||||
noticePopoverVisible.value = false
|
||||
message.success('保存成功')
|
||||
emit('reload')
|
||||
}
|
||||
|
||||
/** 备注:仅本地 localStorage 落盘(后端无字段;多端不同步是已知限制) */
|
||||
|
|
@ -478,18 +468,13 @@ async function saveRemark() {
|
|||
if (!props.group) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await updateGroupMember({
|
||||
groupId: props.group.id,
|
||||
displayUserName: editRemark.value
|
||||
})
|
||||
remarkPopoverVisible.value = false
|
||||
message.success('保存成功')
|
||||
emit('reload')
|
||||
} catch (error) {
|
||||
console.error('[IM ConversationGroupSide] 保存本群昵称失败', error)
|
||||
message.error('保存失败')
|
||||
}
|
||||
await updateGroupMember({
|
||||
groupId: props.group.id,
|
||||
displayUserName: editRemark.value
|
||||
})
|
||||
remarkPopoverVisible.value = false
|
||||
message.success('保存成功')
|
||||
emit('reload')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -524,26 +509,19 @@ async function handleQuit() {
|
|||
if (!props.group) {
|
||||
return
|
||||
}
|
||||
// 1. 二次确认(用户点取消时 message.confirm 抛 reject,吃掉直接 return)
|
||||
// 二次确认(用户点取消时 message.confirm 抛 reject,吃掉直接 return)
|
||||
try {
|
||||
await message.confirm('退出群聊后将不再接收群里的消息,确认退出吗?', '确认退出')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const groupId = props.group.id
|
||||
try {
|
||||
// 2. 调后端 /im/group/quit
|
||||
await quitGroup(groupId)
|
||||
// 3. 同步清本地:会话列表 + 群 store;不清的话冷启动前还会残留这条群 / 会话
|
||||
conversationStore.removeConversation(ImConversationType.GROUP, groupId)
|
||||
groupStore.removeGroup(groupId)
|
||||
// 4. 关抽屉,让用户回到主面板
|
||||
message.success('已退出群聊')
|
||||
visible.value = false
|
||||
} catch (error) {
|
||||
console.error('[IM ConversationGroupSide] 退出群聊失败', { groupId }, error)
|
||||
message.error('退出群聊失败')
|
||||
}
|
||||
await quitGroup(groupId)
|
||||
// 同步清本地:会话列表 + 群 store;不清的话冷启动前还会残留这条群 / 会话
|
||||
conversationStore.removeConversation(ImConversationType.GROUP, groupId)
|
||||
groupStore.removeGroup(groupId)
|
||||
message.success('已退出群聊')
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
/** 移除群成员(仅群主入口)*/
|
||||
|
|
@ -551,20 +529,13 @@ async function handleRemoveComplete(members: GroupMemberFlag[]) {
|
|||
if (!props.group || members.length === 0) {
|
||||
return
|
||||
}
|
||||
const groupId = props.group.id
|
||||
try {
|
||||
// 1. 一次性批量踢人:把选中成员 userId 数组传给后端,比循环调 N 次接口省往返
|
||||
await removeGroupMember({
|
||||
groupId,
|
||||
memberUserIds: members.map((member) => member.userId)
|
||||
})
|
||||
// 2. emit reload 让父组件重新拉群成员,UI 刷新看到被踢的人消失
|
||||
message.success(`已移除 ${members.length} 位成员`)
|
||||
emit('reload')
|
||||
} catch (error) {
|
||||
console.error('[IM ConversationGroupSide] 移除群成员失败', { groupId }, error)
|
||||
message.error('移除成员失败')
|
||||
}
|
||||
// 一次性批量踢人:把选中成员 userId 数组传给后端,比循环调 N 次接口省往返
|
||||
await removeGroupMember({
|
||||
groupId: props.group.id,
|
||||
memberUserIds: members.map((member) => member.userId)
|
||||
})
|
||||
message.success(`已移除 ${members.length} 位成员`)
|
||||
emit('reload')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -192,14 +192,12 @@ function handleMuted() {
|
|||
})
|
||||
}
|
||||
|
||||
/** 删除会话:二次确认后软删(用户取消走 catch 静默) */
|
||||
/** 删除会话:二次确认后软删 */
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await message.confirm(`确定删除与「${props.conversation.name}」的会话吗?`, '删除会话')
|
||||
conversationStore.removeConversation(props.conversation.type, props.conversation.targetId)
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** 右键菜单:置顶 / 免打扰 / 删除 */
|
||||
|
|
|
|||
|
|
@ -172,26 +172,29 @@ async function handleSaveDisplayName() {
|
|||
if (!props.friend) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await friendStore.setDisplayName(props.friend.friendUserId, editDisplayName.value)
|
||||
displayNamePopoverVisible.value = false
|
||||
message.success('保存成功')
|
||||
} catch (error) {
|
||||
console.error('[IM ConversationPrivateSide] 保存好友备注失败', error)
|
||||
message.error('保存失败')
|
||||
}
|
||||
await friendStore.setDisplayName(props.friend.friendUserId, editDisplayName.value)
|
||||
displayNamePopoverVisible.value = false
|
||||
message.success('保存成功')
|
||||
}
|
||||
|
||||
/** 切免打扰:双写 conversationStore(本地排序状态)+ friendStore(与后端同步) */
|
||||
/**
|
||||
* 切免打扰:乐观双写 conversationStore + friendStore;后端失败回滚 conversation 状态,保持与 ConversationItem.handleMuted 一致
|
||||
*/
|
||||
function handleMutedChange(value: boolean | string | number) {
|
||||
if (!props.conversation) {
|
||||
return
|
||||
}
|
||||
const next = !!value
|
||||
conversationStore.setMuted(props.conversation.type, props.conversation.targetId, next)
|
||||
if (props.conversation.type === ImConversationType.PRIVATE) {
|
||||
friendStore.setMuted(props.conversation.targetId, next)
|
||||
const { type, targetId } = props.conversation
|
||||
conversationStore.setMuted(type, targetId, next)
|
||||
if (type !== ImConversationType.PRIVATE) {
|
||||
return
|
||||
}
|
||||
friendStore.setMuted(targetId, next).catch((e) => {
|
||||
console.error('[IM ConversationPrivateSide] 切换免打扰失败', { targetId }, e)
|
||||
message.error('切换免打扰失败')
|
||||
conversationStore.setMuted(type, targetId, !next)
|
||||
})
|
||||
}
|
||||
|
||||
/** 切置顶:纯本地 conversationStore 排序态(无后端字段) */
|
||||
|
|
|
|||
|
|
@ -117,7 +117,6 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef } from 'vue'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
|
||||
import Icon from '@/components/Icon/src/Icon.vue'
|
||||
import { updateFile } from '@/api/infra/file'
|
||||
|
|
@ -145,7 +144,6 @@ const conversationStore = useConversationStore()
|
|||
const groupStore = useGroupStore()
|
||||
const friendStore = useFriendStore()
|
||||
const { send, sendRaw } = useMessageSender()
|
||||
const message = useMessage()
|
||||
|
||||
const editorRef = useTemplateRef<HTMLDivElement>('editorRef')
|
||||
const imageInputRef = useTemplateRef<HTMLInputElement>('imageInputRef')
|
||||
|
|
@ -625,37 +623,27 @@ function onKeydown(e: KeyboardEvent) {
|
|||
// ==================== 图片 / 文件上传 ====================
|
||||
/** 上传并发送 IMAGE 消息;文件选择器和粘贴板都复用这条 */
|
||||
async function uploadAndSendImage(file: File) {
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const url = ((await updateFile(form)) as { data?: string })?.data
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
await sendRaw(ImMessageType.IMAGE, serializeMessage<ImageMessage>({ url }))
|
||||
} catch (err) {
|
||||
console.error('[IM] 图片上传失败:', err)
|
||||
message.error('图片上传失败')
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const url = ((await updateFile(form)) as { data?: string })?.data
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
await sendRaw(ImMessageType.IMAGE, serializeMessage<ImageMessage>({ url }))
|
||||
}
|
||||
|
||||
/** 上传并发送 FILE 消息;附原始 name / size 让接收端展示文件名和体积 */
|
||||
async function uploadAndSendFile(file: File) {
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const url = ((await updateFile(form)) as { data?: string })?.data
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
await sendRaw(
|
||||
ImMessageType.FILE,
|
||||
serializeMessage<FileMessage>({ url, name: file.name, size: file.size })
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[IM] 文件上传失败:', err)
|
||||
message.error('文件上传失败')
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const url = ((await updateFile(form)) as { data?: string })?.data
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
await sendRaw(
|
||||
ImMessageType.FILE,
|
||||
serializeMessage<FileMessage>({ url, name: file.name, size: file.size })
|
||||
)
|
||||
}
|
||||
|
||||
/** 图片选完即上传 + 发送 IMAGE 消息(不放入 editor,整体走 sendRaw) */
|
||||
|
|
@ -682,23 +670,18 @@ async function onFilePicked(e: Event) {
|
|||
const voiceVisible = ref(false)
|
||||
/** VoiceRecorder 录完后回传 blob,包成 webm 文件上传,发送 VOICE 消息 */
|
||||
async function onVoiceSend(payload: { blob: Blob; duration: number }) {
|
||||
try {
|
||||
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
// request.upload 返回完整 axios response(不是 res.data,跟 get/post/put 不一样),URL 在 .data 里取
|
||||
const url = ((await updateFile(form)) as { data?: string })?.data
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
await sendRaw(
|
||||
ImMessageType.VOICE,
|
||||
serializeMessage<AudioMessage>({ url, duration: payload.duration })
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('[IM] 语音上传失败:', err)
|
||||
message.error('语音上传失败')
|
||||
const file = new File([payload.blob], `voice-${Date.now()}.webm`, { type: payload.blob.type })
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
// request.upload 返回完整 axios response(不是 res.data,跟 get/post/put 不一样),URL 在 .data 里取
|
||||
const url = ((await updateFile(form)) as { data?: string })?.data
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
await sendRaw(
|
||||
ImMessageType.VOICE,
|
||||
serializeMessage<AudioMessage>({ url, duration: payload.duration })
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -289,7 +289,6 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMessage } from '@/hooks/web/useMessage'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import Icon from '@/components/Icon/src/Icon.vue'
|
||||
|
|
@ -337,7 +336,6 @@ const conversationStore = useConversationStore()
|
|||
const groupStore = useGroupStore()
|
||||
const friendStore = useFriendStore()
|
||||
const { convertPrivateMessage, convertGroupMessage } = useMessagePuller()
|
||||
const message = useMessage()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
|
|
@ -598,9 +596,6 @@ async function loadEarlier() {
|
|||
conversation.value.targetId,
|
||||
earlier
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[IM] 加载更早历史消息失败', error)
|
||||
message.error('加载历史消息失败')
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -198,7 +198,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -286,9 +285,7 @@ const senderAvatar = computed(() => {
|
|||
}
|
||||
if (conversation.type === ImConversationType.GROUP) {
|
||||
const group = groupStore.getGroup(conversation.targetId)
|
||||
return (
|
||||
group?.members?.find((member) => member.userId === props.message.senderId)?.avatar || ''
|
||||
)
|
||||
return group?.members?.find((member) => member.userId === props.message.senderId)?.avatar || ''
|
||||
}
|
||||
return conversation.avatar || ''
|
||||
})
|
||||
|
|
@ -554,9 +551,7 @@ async function handleRecall() {
|
|||
try {
|
||||
await confirmDialog('确定要撤回这条消息吗?', '撤回消息')
|
||||
await recall(props.message)
|
||||
} catch {
|
||||
// ElMessageBox 在用户点取消时会 reject,吃掉即可
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue