feat(im): 新增通讯录界面

im
YunaiV 2026-04-30 14:07:03 +08:00
parent a762dfff84
commit 0c7d1f0df6
11 changed files with 567 additions and 156 deletions

View File

@ -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)
}
/** 占位提示:语音 / 视频聊天能力尚未接入,先以"开发中"友好提示 */

View File

@ -0,0 +1,120 @@
<template>
<!--
通讯录 - 好友分组
- 自治折叠状态 + 关键字过滤 + 字母分桶 本组件内闭环
- 选中态由父级 activeId 透传chat / delete 透传到父级走 store 改造
-->
<div>
<!-- 折叠分组头字号对齐微信 PC15pxhover 浅底色反馈 -->
<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 @AIhutool
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>

View File

@ -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>

View File

@ -0,0 +1,62 @@
<template>
<!--
通讯录 - 群聊分组
- 自治折叠状态 + 关键字过滤本组件内闭环
- 选中态由父级 activeId 透传避免子组件持有 selection 知识
-->
<div>
<!-- 折叠分组头字号对齐微信 PC15pxhover 浅底色反馈 -->
<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>

View File

@ -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>

View File

@ -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>

View File

@ -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 {}
}
/** 右键菜单:置顶 / 免打扰 / 删除 */

View File

@ -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 排序态(无后端字段) */

View File

@ -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>

View File

@ -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
}

View File

@ -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 {}
}
/**