feat(im):规范 Vben IM 组件目录并修复聊天端迁移问题

文件命名与目录整理:
- IM home/manager 组件文件统一 PascalCase → kebab-case,并新增各级 components/index.ts barrel 导出
- manager 选择器按业务模块就近收敛到频道、素材、群组目录,删除根 components 下的重复实现
- UserMultiSelect 改为复用 system/user/components/UserSelect,并补充多选与 getUserList 回显能力
- 合并 statistics 子组件导出,MessageContentPreview 调整为 content-preview

问题修复:
- 群聊发送按钮由 Element Plus split-button 写法改为 antd DropdownButton,恢复「发送回执消息」入口
- 修复 scoped 下暗色模式选择器塌缩导致整页发红的问题
- 修复会话「+」菜单图标与文字折行问题
- 修复推荐名片、转发、添加好友弹窗冒出多余 antd 默认底栏的问题

代码规范:
- 清理 IM 模块类型别名、注释和工具方法写法,保持 Vben 规范
- constants.ts 内容类型判定集合由数组改为 Set
- 优化 message/image/pull 等工具函数的 lint 写法
pull/367/head
YunaiV 2026-06-18 05:53:25 -07:00
parent 24813f00f5
commit 2cbec901e1
112 changed files with 862 additions and 439 deletions

View File

@ -41,6 +41,13 @@ export function getUser(id: number) {
return requestClient.get<SystemUserApi.User>(`/system/user/get?id=${id}`); return requestClient.get<SystemUserApi.User>(`/system/user/get?id=${id}`);
} }
/** 查询用户列表 */
export function getUserList(ids: number[]) {
return requestClient.get<SystemUserApi.User[]>('/system/user/list', {
params: { ids: ids.join(',') },
});
}
/** 新增用户 */ /** 新增用户 */
export function createUser(data: SystemUserApi.User) { export function createUser(data: SystemUserApi.User) {
return requestClient.post('/system/user/create', data); return requestClient.post('/system/user/create', data);

View File

@ -8,7 +8,7 @@ import {
getCardLabelInfo getCardLabelInfo
} from '#/views/im/utils/message' } from '#/views/im/utils/message'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
defineOptions({ name: 'ImCardBubble' }) defineOptions({ name: 'ImCardBubble' })

View File

@ -0,0 +1,2 @@
export { default as CardBubble } from './card-bubble.vue';
export { default as CardLineLabel } from './card-line-label.vue';

View File

@ -14,7 +14,7 @@ import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImFriendAddSource } from '../../../utils/constants' import { ImFriendAddSource } from '../../../utils/constants'
import { getGenderColor, getGenderIcon } from '../../../utils/user' import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { useFriendStore } from '../../store/friendStore' import { useFriendStore } from '../../store/friendStore'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
defineOptions({ name: 'ImFriendAddDialog' }) defineOptions({ name: 'ImFriendAddDialog' })
@ -159,7 +159,13 @@ async function handleSubmitApply() {
- 第一层 search按昵称搜索用户列表 - 第一层 search按昵称搜索用户列表
- 第二层 apply选中用户后展开申请添加朋友表单申请理由 + 备注 - 第二层 apply选中用户后展开申请添加朋友表单申请理由 + 备注
--> -->
<Modal v-model:open="visible" :title="dialogTitle" width="480px" :mask-closable="false"> <Modal
v-model:open="visible"
:title="dialogTitle"
width="480px"
:mask-closable="false"
:footer="step === 'apply' ? undefined : null"
>
<!-- 第一层搜索 + 用户列表 --> <!-- 第一层搜索 + 用户列表 -->
<template v-if="step === 'search'"> <template v-if="step === 'search'">
<Input <Input

View File

@ -2,7 +2,7 @@
import type { FriendLite } from '../../types' import type { FriendLite } from '../../types'
import { useImUiStore } from '../../store/uiStore' import { useImUiStore } from '../../store/uiStore'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
defineOptions({ name: 'ImFriendItem' }) defineOptions({ name: 'ImFriendItem' })

View File

@ -0,0 +1,2 @@
export { default as FriendAddDialog } from './friend-add-dialog.vue';
export { default as FriendItem } from './friend-item.vue';

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupMemberLite } from './GroupMember.vue' import type { GroupMemberLite } from './group-member.vue'
import { ref } from 'vue' import { ref } from 'vue'
@ -8,7 +8,7 @@ import { Button, message, Modal } from 'ant-design-vue'
import { addGroupAdmin, removeGroupAdmin } from '#/api/im/group' import { addGroupAdmin, removeGroupAdmin } from '#/api/im/group'
import { GROUP_ADMIN_MAX_COUNT } from '#/views/im/utils/config' import { GROUP_ADMIN_MAX_COUNT } from '#/views/im/utils/config'
import GroupMemberPickerPanel from '../picker/GroupMemberPickerPanel.vue' import { GroupMemberPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupAdminSetDialog' }) defineOptions({ name: 'ImGroupAdminSetDialog' })

View File

@ -11,7 +11,7 @@ import {
import { getMemberDisplayName } from '../../../utils/user' import { getMemberDisplayName } from '../../../utils/user'
import { useFriendStore } from '../../store/friendStore' import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore' import { useGroupStore } from '../../store/groupStore'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupAvatar' }) defineOptions({ name: 'ImGroupAvatar' })

View File

@ -10,7 +10,7 @@ import { createGroup } from '#/api/im/group'
import { buildDefaultGroupName } from '../../../utils/group' import { buildDefaultGroupName } from '../../../utils/group'
import { useFriendStore } from '../../store/friendStore' import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore' import { useGroupStore } from '../../store/groupStore'
import FriendPickerPanel from '../picker/FriendPickerPanel.vue' import { FriendPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupCreateDialog' }) defineOptions({ name: 'ImGroupCreateDialog' })

View File

@ -15,7 +15,7 @@ import { getGroupDisplayName } from '../../../utils/user'
import { useConversationStore } from '../../store/conversationStore' import { useConversationStore } from '../../store/conversationStore'
import { useGroupStore } from '../../store/groupStore' import { useGroupStore } from '../../store/groupStore'
import { useImUiStore } from '../../store/uiStore' import { useImUiStore } from '../../store/uiStore'
import GroupInfo from './GroupInfo.vue' import GroupInfo from './group-info.vue'
defineOptions({ name: 'ImGroupInfoCard' }) defineOptions({ name: 'ImGroupInfoCard' })

View File

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { Friend, GroupLite, GroupMember } from '../../types' import type { Friend, GroupLite, GroupMember } from '../../types'
import type { GroupMemberLite } from './GroupMember.vue' import type { GroupMemberLite } from './group-member.vue'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
@ -13,8 +13,8 @@ import { getCurrentUserId } from '#/views/im/utils/auth'
import { getMemberDisplayName, isGroupQuit } from '../../../utils/user' import { getMemberDisplayName, isGroupQuit } from '../../../utils/user'
import { useFriendStore } from '../../store/friendStore' import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore' import { useGroupStore } from '../../store/groupStore'
import GroupAvatar from './GroupAvatar.vue' import GroupAvatar from './group-avatar.vue'
import GroupMemberGrid from './GroupMemberGrid.vue' import GroupMemberGrid from './group-member-grid.vue'
defineOptions({ name: 'ImGroupInfo' }) defineOptions({ name: 'ImGroupInfo' })

View File

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupLite } from '../../types' import type { GroupLite } from '../../types'
import GroupAvatar from './GroupAvatar.vue' import GroupAvatar from './group-avatar.vue'
defineOptions({ name: 'ImGroupItem' }) defineOptions({ name: 'ImGroupItem' })

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupMemberLite } from './GroupMember.vue' import type { GroupMemberLite } from './group-member.vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@ -14,7 +14,7 @@ import { ImGroupMemberRole } from '#/views/im/utils/constants'
import { useFriendStore } from '../../store/friendStore' import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore' import { useGroupStore } from '../../store/groupStore'
import FriendPickerPanel from '../picker/FriendPickerPanel.vue' import { FriendPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupMemberAddDialog' }) defineOptions({ name: 'ImGroupMemberAddDialog' })

View File

@ -1,8 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupMemberLite } from './GroupMember.vue' import type { GroupMemberLite } from './group-member.vue'
import { ImFriendAddSource } from '../../../utils/constants' import { ImFriendAddSource } from '../../../utils/constants'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupMemberGrid' }) defineOptions({ name: 'ImGroupMemberGrid' })

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupMemberLite } from './GroupMember.vue' import type { GroupMemberLite } from './group-member.vue'
import { computed } from 'vue' import { computed } from 'vue'
@ -7,7 +7,7 @@ import { DICT_TYPE } from '@vben/constants'
import { getDictLabel } from '@vben/hooks' import { getDictLabel } from '@vben/hooks'
import { ImGroupMemberRole } from '../../../utils/constants' import { ImGroupMemberRole } from '../../../utils/constants'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupMemberItem' }) defineOptions({ name: 'ImGroupMemberItem' })

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupMemberLite } from './GroupMember.vue' import type { GroupMemberLite } from './group-member.vue'
import { ref } from 'vue' import { ref } from 'vue'
@ -7,7 +7,7 @@ import { Button, message, Modal } from 'ant-design-vue'
import { removeGroupMember } from '#/api/im/group/member' import { removeGroupMember } from '#/api/im/group/member'
import GroupMemberPickerPanel from '../picker/GroupMemberPickerPanel.vue' import { GroupMemberPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupMemberRemoveDialog' }) defineOptions({ name: 'ImGroupMemberRemoveDialog' })

View File

@ -2,7 +2,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { ImFriendAddSource } from '../../../utils/constants' import { ImFriendAddSource } from '../../../utils/constants'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupMember' }) defineOptions({ name: 'ImGroupMember' })

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupMemberLite } from './GroupMember.vue' import type { GroupMemberLite } from './group-member.vue'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@ -9,7 +9,7 @@ import { Button, message, Modal } from 'ant-design-vue'
import { transferGroupOwner } from '#/api/im/group' import { transferGroupOwner } from '#/api/im/group'
import GroupMemberPickerPanel from '../picker/GroupMemberPickerPanel.vue' import { GroupMemberPickerPanel } from '../picker'
defineOptions({ name: 'ImGroupOwnerTransferDialog' }) defineOptions({ name: 'ImGroupOwnerTransferDialog' })

View File

@ -11,7 +11,7 @@ import { getGroupRequestListByGroupId } from '#/api/im/group/request'
import { ImGroupRequestHandleResult } from '#/views/im/utils/constants' import { ImGroupRequestHandleResult } from '#/views/im/utils/constants'
import { useGroupRequestStore } from '../../store/groupRequestStore' import { useGroupRequestStore } from '../../store/groupRequestStore'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
defineOptions({ name: 'ImGroupRequestListDialog' }) defineOptions({ name: 'ImGroupRequestListDialog' })
@ -166,6 +166,7 @@ function updateLocalResult(id: number, handleResult: number) {
v-model:open="visible" v-model:open="visible"
title="进群申请" title="进群申请"
width="560px" width="560px"
:footer="null"
:mask-closable="false" :mask-closable="false"
class="im-group-request-list__dialog" class="im-group-request-list__dialog"
> >

View File

@ -0,0 +1,15 @@
export { default as GroupAdminSetDialog } from './group-admin-set-dialog.vue';
export { default as GroupAvatar } from './group-avatar.vue';
export { default as GroupCreateDialog } from './group-create-dialog.vue';
export { default as GroupInfoCard } from './group-info-card.vue';
export { default as GroupInfo } from './group-info.vue';
export { default as GroupItem } from './group-item.vue';
export { default as GroupMemberAddDialog } from './group-member-add-dialog.vue';
export { default as GroupMemberGrid } from './group-member-grid.vue';
export { default as GroupMemberItem } from './group-member-item.vue';
export { default as GroupMemberRemoveDialog } from './group-member-remove-dialog.vue';
export { default as GroupMember } from './group-member.vue';
export type { GroupMemberLite } from './group-member.vue';
export { default as GroupMuteMemberDialog } from './group-mute-member-dialog.vue';
export { default as GroupOwnerTransferDialog } from './group-owner-transfer-dialog.vue';
export { default as GroupRequestListDialog } from './group-request-list-dialog.vue';

View File

@ -0,0 +1,4 @@
export { default as ContextMenu } from './context-menu.vue';
export { default as PagedScroller } from './paged-scroller.vue';
export { default as ResizableAside } from './resizable-aside.vue';
export { default as ToolBar } from './tool-bar.vue';

View File

@ -11,6 +11,7 @@ const props = withDefaults(
threshold?: number // px threshold?: number // px
}>(), }>(),
{ {
itemKey: undefined,
pageSize: 30, pageSize: 30,
threshold: 30 threshold: 30
} }

View File

@ -9,8 +9,8 @@ import { Input, message } from 'ant-design-vue'
import { ImConversationType } from '../../../utils/constants' import { ImConversationType } from '../../../utils/constants'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation' import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import GroupAvatar from '../group/GroupAvatar.vue' import { GroupAvatar } from '../group'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
defineOptions({ name: 'ImConversationPickerPanel' }) defineOptions({ name: 'ImConversationPickerPanel' })

View File

@ -7,10 +7,10 @@ import { IconifyIcon as Icon } from '@vben/icons'
import { Input, message } from 'ant-design-vue' import { Input, message } from 'ant-design-vue'
import { PagedScroller } from '..'
import { useFriendBuckets } from '../../composables/useFriendBuckets' import { useFriendBuckets } from '../../composables/useFriendBuckets'
import { useSelectedItems } from '../../composables/useSelectedItems' import { useSelectedItems } from '../../composables/useSelectedItems'
import PagedScroller from '../PagedScroller.vue' import { UserAvatar } from '../user'
import UserAvatar from '../user/UserAvatar.vue'
defineOptions({ name: 'ImFriendPickerPanel' }) defineOptions({ name: 'ImFriendPickerPanel' })

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupMemberLite } from '../group/GroupMember.vue' import type { GroupMemberLite } from '../group'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@ -8,11 +8,10 @@ import { IconifyIcon as Icon } from '@vben/icons'
import { Input, message } from 'ant-design-vue' import { Input, message } from 'ant-design-vue'
import { PagedScroller } from '..'
import { useSelectedItems } from '../../composables/useSelectedItems' import { useSelectedItems } from '../../composables/useSelectedItems'
import GroupMemberGrid from '../group/GroupMemberGrid.vue' import { GroupMemberGrid, GroupMemberItem } from '../group'
import GroupMemberItem from '../group/GroupMemberItem.vue' import { UserAvatar } from '../user'
import PagedScroller from '../PagedScroller.vue'
import UserAvatar from '../user/UserAvatar.vue'
defineOptions({ name: 'ImGroupMemberPickerPanel' }) defineOptions({ name: 'ImGroupMemberPickerPanel' })

View File

@ -0,0 +1,3 @@
export { default as ConversationPickerPanel } from './conversation-picker-panel.vue';
export { default as FriendPickerPanel } from './friend-picker-panel.vue';
export { default as GroupMemberPickerPanel } from './group-member-picker-panel.vue';

View File

@ -0,0 +1,8 @@
export { default as RtcCallContainer } from './rtc-call-container.vue';
export { default as RtcCallIncoming } from './rtc-call-incoming.vue';
export { default as RtcCallInviting } from './rtc-call-inviting.vue';
export { default as RtcCallMemberPickerDialog } from './rtc-call-member-picker-dialog.vue';
export { default as RtcCallParticipantTile } from './rtc-call-participant-tile.vue';
export type { CallParticipantVM } from './rtc-call-participant-tile.vue';
export { default as RtcCallRunning } from './rtc-call-running.vue';
export { default as RtcGroupCallBanner } from './rtc-group-call-banner.vue';

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { CallParticipantVM } from './RtcCallParticipantTile.vue' import type { CallParticipantVM } from './rtc-call-participant-tile.vue'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
@ -26,10 +26,10 @@ import { getSenderAvatar, getSenderDisplayName } from '#/views/im/utils/user'
import { useLiveKitRoom } from '../../composables/useLiveKitRoom' import { useLiveKitRoom } from '../../composables/useLiveKitRoom'
import { useRtcStore } from '../../store/rtcStore' import { useRtcStore } from '../../store/rtcStore'
import RtcCallIncoming from './RtcCallIncoming.vue' import RtcCallIncoming from './rtc-call-incoming.vue'
import RtcCallInviting from './RtcCallInviting.vue' import RtcCallInviting from './rtc-call-inviting.vue'
import RtcCallMemberPickerDialog from './RtcCallMemberPickerDialog.vue' import RtcCallMemberPickerDialog from './rtc-call-member-picker-dialog.vue'
import RtcCallRunning from './RtcCallRunning.vue' import RtcCallRunning from './rtc-call-running.vue'
defineOptions({ name: 'ImRtcCallContainer' }) defineOptions({ name: 'ImRtcCallContainer' })

View File

@ -8,7 +8,7 @@ import { getDictLabel } from '@vben/hooks'
import { IconifyIcon as Icon } from '@vben/icons' import { IconifyIcon as Icon } from '@vben/icons'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers' import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
const props = defineProps<{ const props = defineProps<{
accepting?: boolean accepting?: boolean

View File

@ -2,7 +2,7 @@
import { IconifyIcon as Icon } from '@vben/icons' import { IconifyIcon as Icon } from '@vben/icons'
import { useMediaStreamElement } from '../../composables/useMediaStreamElement' import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
const props = defineProps<{ const props = defineProps<{
cameraEnabled: boolean cameraEnabled: boolean
@ -17,9 +17,9 @@ const props = defineProps<{
defineEmits<{ defineEmits<{
cancel: [] cancel: []
'toggle-camera': [] toggleCamera: []
'toggle-mic': [] toggleMic: []
'toggle-speaker': [] toggleSpeaker: []
}>() }>()
const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream) const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localStream)
@ -61,7 +61,7 @@ const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localS
<div class="flex flex-shrink-0 gap-4 justify-around items-center pt-4 px-5 pb-5"> <div class="flex flex-shrink-0 gap-4 justify-around items-center pt-4 px-5 pb-5">
<div <div
class="flex flex-col gap-2 items-center cursor-pointer select-none" class="flex flex-col gap-2 items-center cursor-pointer select-none"
@click="$emit('toggle-mic')" @click="$emit('toggleMic')"
> >
<!-- ant-design 系列里 mic audio-muted-outlined 变体speaker / camera 没有 muted 变体off 态借 tabler:*-off 表达斜线 --> <!-- ant-design 系列里 mic audio-muted-outlined 变体speaker / camera 没有 muted 变体off 态借 tabler:*-off 表达斜线 -->
<span <span
@ -91,7 +91,7 @@ const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localS
<div <div
v-if="isVideo" v-if="isVideo"
class="flex flex-col gap-2 items-center cursor-pointer select-none" class="flex flex-col gap-2 items-center cursor-pointer select-none"
@click="$emit('toggle-camera')" @click="$emit('toggleCamera')"
> >
<span <span
class="flex justify-center items-center w-12 h-12 rounded-full" class="flex justify-center items-center w-12 h-12 rounded-full"
@ -109,7 +109,7 @@ const localVideoRef = useMediaStreamElement<HTMLVideoElement>(() => props.localS
<div <div
v-else v-else
class="flex flex-col gap-2 items-center cursor-pointer select-none" class="flex flex-col gap-2 items-center cursor-pointer select-none"
@click="$emit('toggle-speaker')" @click="$emit('toggleSpeaker')"
> >
<span <span
class="flex justify-center items-center w-12 h-12 rounded-full" class="flex justify-center items-center w-12 h-12 rounded-full"

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupMemberLite } from '../group/GroupMember.vue' import type { GroupMemberLite } from '../group'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@ -8,7 +8,7 @@ import { Button, Modal } from 'ant-design-vue'
import { getCurrentUserId } from '#/views/im/utils/auth' import { getCurrentUserId } from '#/views/im/utils/auth'
import { useGroupStore } from '../../store/groupStore' import { useGroupStore } from '../../store/groupStore'
import GroupMemberPickerPanel from '../picker/GroupMemberPickerPanel.vue' import { GroupMemberPickerPanel } from '../picker'
defineOptions({ name: 'ImRtcCallMemberPickerDialog' }) defineOptions({ name: 'ImRtcCallMemberPickerDialog' })

View File

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { useMediaStreamElement } from '../../composables/useMediaStreamElement' import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
export interface CallParticipantVM { export interface CallParticipantVM {
userId: number userId: number

View File

@ -6,8 +6,8 @@ import { IconifyIcon as Icon } from '@vben/icons'
import { formatCallDuration } from '#/views/im/utils/time' import { formatCallDuration } from '#/views/im/utils/time'
import { useMediaStreamElement } from '../../composables/useMediaStreamElement' import { useMediaStreamElement } from '../../composables/useMediaStreamElement'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
import RtcCallParticipantTile, { type CallParticipantVM } from './RtcCallParticipantTile.vue' import RtcCallParticipantTile, { type CallParticipantVM } from './rtc-call-participant-tile.vue'
const props = defineProps<{ const props = defineProps<{
cameraEnabled: boolean cameraEnabled: boolean
@ -34,12 +34,12 @@ const props = defineProps<{
}>() }>()
defineEmits<{ defineEmits<{
'add-member': [] addMember: []
hangup: [] hangup: []
'toggle-camera': [] toggleCamera: []
'toggle-mic': [] toggleMic: []
'toggle-screen-share': [] toggleScreenShare: []
'toggle-speaker': [] toggleSpeaker: []
}>() }>()
/** 网格列数;按人数自适应;返回 UnoCSS class 字面量让 JIT 扫描器静态识别 */ /** 网格列数;按人数自适应;返回 UnoCSS class 字面量让 JIT 扫描器静态识别 */
@ -180,7 +180,7 @@ const formattedDuration = computed(() =>
> >
<div <div
class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
@click="$emit('toggle-mic')" @click="$emit('toggleMic')"
> >
<span <span
class="flex justify-center items-center w-[52px] h-[52px] rounded-full" class="flex justify-center items-center w-[52px] h-[52px] rounded-full"
@ -197,7 +197,7 @@ const formattedDuration = computed(() =>
</div> </div>
<div <div
class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
@click="$emit('toggle-speaker')" @click="$emit('toggleSpeaker')"
> >
<span <span
class="flex justify-center items-center w-[52px] h-[52px] rounded-full" class="flex justify-center items-center w-[52px] h-[52px] rounded-full"
@ -216,7 +216,7 @@ const formattedDuration = computed(() =>
<div <div
v-if="isVideo || isGroup" v-if="isVideo || isGroup"
class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
@click="$emit('toggle-camera')" @click="$emit('toggleCamera')"
> >
<span <span
class="flex justify-center items-center w-[52px] h-[52px] rounded-full" class="flex justify-center items-center w-[52px] h-[52px] rounded-full"
@ -235,7 +235,7 @@ const formattedDuration = computed(() =>
<template v-if="isGroup"> <template v-if="isGroup">
<div <div
class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
@click="$emit('toggle-screen-share')" @click="$emit('toggleScreenShare')"
> >
<span <span
class="flex justify-center items-center w-[52px] h-[52px] rounded-full" class="flex justify-center items-center w-[52px] h-[52px] rounded-full"
@ -252,7 +252,7 @@ const formattedDuration = computed(() =>
</div> </div>
<div <div
class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]" class="flex flex-col gap-2 items-center cursor-pointer select-none min-w-[64px]"
@click="$emit('add-member')" @click="$emit('addMember')"
> >
<span class="flex justify-center items-center w-[52px] h-[52px] text-white rounded-full bg-white/15"> <span class="flex justify-center items-center w-[52px] h-[52px] text-white rounded-full bg-white/15">
<Icon icon="ant-design:plus-outlined" :size="22" /> <Icon icon="ant-design:plus-outlined" :size="22" />

View File

@ -12,7 +12,7 @@ import { getCurrentUserId } from '#/views/im/utils/auth'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers' import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { useRtcStore } from '../../store/rtcStore' import { useRtcStore } from '../../store/rtcStore'
import UserAvatar from '../user/UserAvatar.vue' import { UserAvatar } from '../user'
defineOptions({ name: 'ImRtcGroupCallBanner' }) defineOptions({ name: 'ImRtcGroupCallBanner' })

View File

@ -10,7 +10,7 @@ import { Badge } from 'ant-design-vue'
import { useConversationStore } from '../store/conversationStore' import { useConversationStore } from '../store/conversationStore'
import { useFriendStore } from '../store/friendStore' import { useFriendStore } from '../store/friendStore'
import { useImUiStore } from '../store/uiStore' import { useImUiStore } from '../store/uiStore'
import UserAvatar from './user/UserAvatar.vue' import { UserAvatar } from './user'
defineOptions({ name: 'ImToolBar' }) defineOptions({ name: 'ImToolBar' })

View File

@ -0,0 +1,5 @@
export { default as RecommendCardDialog } from './recommend-card-dialog.vue';
export { default as UserAvatar } from './user-avatar.vue';
export { default as UserInfoCard } from './user-info-card.vue';
export { default as UserInfo } from './user-info.vue';
export type { UserInfoRelation } from './user-info.vue';

View File

@ -8,7 +8,7 @@ import { IconifyIcon as Icon } from '@vben/icons'
import { Button, Input, message, Modal } from 'ant-design-vue' import { Button, Input, message, Modal } from 'ant-design-vue'
import { createGroup } from '#/api/im/group' import { createGroup } from '#/api/im/group'
import CardBubble from '#/views/im/home/components/card/CardBubble.vue' import { CardBubble } from '#/views/im/home/components/card'
import { ImContentType, ImConversationType, isGroupConversation } from '../../../utils/constants' import { ImContentType, ImConversationType, isGroupConversation } from '../../../utils/constants'
import { getConversationKey } from '../../../utils/conversation' import { getConversationKey } from '../../../utils/conversation'
@ -16,12 +16,12 @@ import { buildDefaultGroupName } from '../../../utils/group'
import { type CardTarget, serializeMessage } from '../../../utils/message' import { type CardTarget, serializeMessage } from '../../../utils/message'
import { isGroupQuit } from '../../../utils/user' import { isGroupQuit } from '../../../utils/user'
import { useMessageSender } from '../../composables/useMessageSender' import { useMessageSender } from '../../composables/useMessageSender'
import FacePicker from '../../pages/conversation/components/input/FacePicker.vue' import { FacePicker } from '../../pages/conversation/components/input'
import { useConversationStore } from '../../store/conversationStore' import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore' import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore' import { useGroupStore } from '../../store/groupStore'
import ConversationPickerPanel from '../picker/ConversationPickerPanel.vue' import { ConversationPickerPanel } from '../picker'
import FriendPickerPanel from '../picker/FriendPickerPanel.vue' import { FriendPickerPanel } from '../picker'
defineOptions({ name: 'ImRecommendCardDialog' }) defineOptions({ name: 'ImRecommendCardDialog' })
@ -222,6 +222,7 @@ async function handleCreateGroupAndSend() {
v-model:open="visible" v-model:open="visible"
width="720px" width="720px"
:mask-closable="false" :mask-closable="false"
:footer="view === 'conversation' ? null : undefined"
class="im-picker-dialog im-recommend-dialog" class="im-picker-dialog im-recommend-dialog"
> >
<template #header> <template #header>

View File

@ -26,11 +26,16 @@ const props = withDefaults(
user?: User // user?: User //
}>(), }>(),
{ {
size: 42, addSourceExtra: undefined,
radius: '15%',
clickable: true, clickable: true,
id: undefined,
name: undefined,
previewable: false, previewable: false,
previewZIndex: 2000, previewZIndex: 2000,
radius: '15%',
size: 42,
url: undefined,
user: undefined,
addSource: ImFriendAddSource.SEARCH addSource: ImFriendAddSource.SEARCH
} }
) )

View File

@ -9,7 +9,7 @@ import { getFriendDisplayName } from '../../../utils/user'
import { useConversationStore } from '../../store/conversationStore' import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore' import { useFriendStore } from '../../store/friendStore'
import { useImUiStore } from '../../store/uiStore' import { useImUiStore } from '../../store/uiStore'
import UserInfo, { type UserInfoRelation } from './UserInfo.vue' import UserInfo, { type UserInfoRelation } from './user-info.vue'
defineOptions({ name: 'ImUserInfoCard' }) defineOptions({ name: 'ImUserInfoCard' })

View File

@ -17,9 +17,9 @@ import { ImFriendAddSource } from '../../../utils/constants'
import { toUserCardTarget } from '../../../utils/message' import { toUserCardTarget } from '../../../utils/message'
import { getGenderColor, getGenderIcon } from '../../../utils/user' import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { useFriendStore } from '../../store/friendStore' import { useFriendStore } from '../../store/friendStore'
import FriendAddDialog from '../friend/FriendAddDialog.vue' import { FriendAddDialog } from '../friend'
import RecommendCardDialog from './RecommendCardDialog.vue' import RecommendCardDialog from './recommend-card-dialog.vue'
import UserAvatar from './UserAvatar.vue' import UserAvatar from './user-avatar.vue'
defineOptions({ name: 'ImUserInfo' }) defineOptions({ name: 'ImUserInfo' })

View File

@ -73,12 +73,11 @@ export function useFriendBuckets(
const map = new Map<string, FriendLite[]>() const map = new Map<string, FriendLite[]>()
for (const friend of filtered.value) { for (const friend of filtered.value) {
const letter = getBucketLetter(friend) const letter = getBucketLetter(friend)
if (!map.has(letter)) { const bucket = map.get(letter) ?? []
map.set(letter, []) bucket.push(friend)
} map.set(letter, bucket)
map.get(letter)!.push(friend)
} }
const letters = [...map.keys()].sort((a, b) => { const letters = [...map.keys()].toSorted((a, b) => {
// '#' 永远排末尾A-Z 走 localeCompare // '#' 永远排末尾A-Z 走 localeCompare
if (a === '#') { if (a === '#') {
return 1 return 1
@ -90,7 +89,9 @@ export function useFriendBuckets(
}) })
return letters.map((letter) => ({ return letters.map((letter) => ({
letter, letter,
list: map.get(letter)!.sort((a, b) => getSortKey(a).localeCompare(getSortKey(b))) list: (map.get(letter) ?? []).toSorted((a, b) =>
getSortKey(a).localeCompare(getSortKey(b))
)
})) }))
}) })

View File

@ -248,7 +248,7 @@ export const useMessagePuller = () => {
}) })
} }
/** 同一时刻只允许一次 pullIndex.vue 的手动调用与重连 watch 触发可能并发,共用同一个 promise 即可去重 */ /** 同一时刻只允许一次 pullindex.vue 的手动调用与重连 watch 触发可能并发,共用同一个 promise 即可去重 */
let pullPromise: null | Promise<void> = null let pullPromise: null | Promise<void> = null
let pullAbortController: AbortController | null = null let pullAbortController: AbortController | null = null
@ -267,7 +267,7 @@ export const useMessagePuller = () => {
*/ */
let pullEpoch = 0 let pullEpoch = 0
/** 显式取消:仅由 Index.vue onUnmounted离开 IM / 切账号 / 路由跳出)调用 */ /** 显式取消:仅由 index.vue onUnmounted离开 IM / 切账号 / 路由跳出)调用 */
const cancelPull = () => { const cancelPull = () => {
pullEpoch++ pullEpoch++
pullAbortController?.abort() pullAbortController?.abort()
@ -391,7 +391,7 @@ export const useMessagePuller = () => {
conversationStore.sortConversationList() conversationStore.sortConversationList()
// 重连 / 冷启动后补齐当前激活私聊会话的「对方已读位置」 // 重连 / 冷启动后补齐当前激活私聊会话的「对方已读位置」
// 离线期间错过的 RECEIPT 推送会被这里补回;其他私聊会话等用户点开时由 Index.vue 的 watch 触发 // 离线期间错过的 RECEIPT 推送会被这里补回;其他私聊会话等用户点开时由 index.vue 的 watch 触发
// 私聊已读关闭时跳过,避免打到已禁用接口触发错误日志 // 私聊已读关闭时跳过,避免打到已禁用接口触发错误日志
const active = conversationStore.activeConversation const active = conversationStore.activeConversation
if (MESSAGE_PRIVATE_READ_ENABLED && active && active.type === ImConversationType.PRIVATE) { if (MESSAGE_PRIVATE_READ_ENABLED && active && active.type === ImConversationType.PRIVATE) {
@ -438,7 +438,7 @@ export const useMessagePuller = () => {
/** /**
* WS minId update_time + id / / * WS minId update_time + id / /
* Index.vue pullOnce + store * index.vue pullOnce + store
* store pullStateEvents pullOnce * store pullStateEvents pullOnce
*/ */
watch( watch(

View File

@ -187,7 +187,7 @@ export const useMessageSender = () => {
} }
/** /**
* MessageInput.vue * message-input.vue
* true / false / false sendRaw * true / false / false sendRaw
*/ */
const send = async (text: string, options?: SendExtOptions): Promise<boolean> => { const send = async (text: string, options?: SendExtOptions): Promise<boolean> => {

View File

@ -8,11 +8,10 @@ import { preferences } from '@vben/preferences'
import { ImConversationType } from '../utils/constants' import { ImConversationType } from '../utils/constants'
import { initDb, stopRequests, StorageKeys } from '../utils/db' import { initDb, stopRequests, StorageKeys } from '../utils/db'
import ContextMenu from './components/ContextMenu.vue' import { ContextMenu, ToolBar } from './components'
import GroupInfoCard from './components/group/GroupInfoCard.vue' import { GroupInfoCard } from './components/group'
import RtcCallContainer from './components/rtc/RtcCallContainer.vue' import { RtcCallContainer } from './components/rtc'
import ToolBar from './components/ToolBar.vue' import { UserInfoCard } from './components/user'
import UserInfoCard from './components/user/UserInfoCard.vue'
import { useMessagePuller } from './composables/useMessagePuller' import { useMessagePuller } from './composables/useMessagePuller'
import { useMessageSender } from './composables/useMessageSender' import { useMessageSender } from './composables/useMessageSender'
import { useVoicePlayer } from './composables/useVoicePlayer' import { useVoicePlayer } from './composables/useVoicePlayer'
@ -209,7 +208,9 @@ watch(
- 右侧 <router-view>按路由渲染 MessagePage / FriendPage / GroupPage - 右侧 <router-view>按路由渲染 MessagePage / FriendPage / GroupPage
- 挂载全局弹层UserInfoCard / GroupInfoCard / ContextMenu - 挂载全局弹层UserInfoCard / GroupInfoCard / ContextMenu
--> -->
<div class="im-home flex w-full h-full overflow-hidden"> <div
class="im-home flex w-full h-full overflow-hidden bg-[var(--ant-color-bg-layout)] text-[var(--ant-color-text)]"
>
<ToolBar /> <ToolBar />
<!-- <!--
keep-alive 缓存子页面 keep-alive 缓存子页面
@ -237,10 +238,54 @@ watch(
:global(:root) { :global(:root) {
--im-border-color-lighter: #e8eaed; --im-border-color-lighter: #e8eaed;
--im-resize-line-color: #d8dde5; --im-resize-line-color: #d8dde5;
/*
* antd 4.x 不输出 --ant-color-* 全局 CSS 变量 React antd theme.cssVar 支持
* IM 聊天端大量直接使用 var(--ant-color-*)此处用 Vben design token 兜底定义
* 映射规则与 useAntdDesignTokens 喂给 antd token 完全一致随主题/自动切换
* 缺失时这些变量为空 背景透明边框/填充失效典型表情面板透出输入框占位文字
*/
--ant-color-bg-container: hsl(var(--card));
--ant-color-bg-elevated: hsl(var(--popover));
--ant-color-bg-layout: hsl(var(--background-deep));
--ant-color-border: hsl(var(--border));
--ant-color-border-secondary: hsl(var(--border));
--ant-color-text: hsl(var(--foreground));
--ant-color-text-secondary: hsl(var(--foreground) / 65%);
--ant-color-text-placeholder: hsl(var(--foreground) / 45%);
--ant-color-text-disabled: hsl(var(--foreground) / 30%);
/*
* fill 系列浅色下用 Element Plus 风格的冷调浅灰实色而非半透明深色
* 否则面板会话列表 / 消息面板等叠在灰底上会显脏发暗 Vue3+EP 的干净白差距明显
* 取值依组件实际用法secondary 多用于面板底取最浅对齐 EP --el-bg-color-page #f5f7fa
* tertiary 多用于 hover / 图标块比面板略深保证 hover 可见深色在 .dark 里另行覆盖
*/
--ant-color-fill: #e2e6ec;
--ant-color-fill-secondary: #f4f6f9;
--ant-color-fill-tertiary: #eaedf2;
--ant-color-fill-dark: #d5dae2;
--ant-color-primary: hsl(var(--primary));
--ant-color-primary-hover: hsl(var(--primary) / 80%);
--ant-color-primary-bg: hsl(var(--primary) / 12%);
--ant-color-primary-bg-hover: hsl(var(--primary) / 18%);
--ant-color-info: hsl(var(--primary));
--ant-color-success: hsl(var(--success));
--ant-color-success-bg: hsl(var(--success) / 12%);
--ant-color-success-bg-hover: hsl(var(--success) / 18%);
--ant-color-warning: hsl(var(--warning));
--ant-color-warning-bg: hsl(var(--warning) / 12%);
--ant-color-warning-bg-hover: hsl(var(--warning) / 18%);
--ant-color-error: hsl(var(--destructive));
} }
:global(.dark) { :global(.dark) {
--im-border-color-lighter: rgb(255 255 255 / 12%); --im-border-color-lighter: rgb(255 255 255 / 12%);
--im-resize-line-color: rgb(255 255 255 / 18%); --im-resize-line-color: rgb(255 255 255 / 18%);
/* 深色下 fill 回到「基于前景色的半透明亮色」,叠在深底上得到自然的微亮表面 */
--ant-color-fill: hsl(var(--foreground) / 12%);
--ant-color-fill-secondary: hsl(var(--foreground) / 8%);
--ant-color-fill-tertiary: hsl(var(--foreground) / 4%);
--ant-color-fill-dark: hsl(var(--foreground) / 18%);
} }
</style> </style>

View File

@ -5,7 +5,7 @@ import { ref, toRef } from 'vue'
import { IconifyIcon as Icon } from '@vben/icons' import { IconifyIcon as Icon } from '@vben/icons'
import FriendItem from '../../components/friend/FriendItem.vue' import { FriendItem } from '../../components/friend'
import { useFriendBuckets } from '../../composables/useFriendBuckets' import { useFriendBuckets } from '../../composables/useFriendBuckets'
defineOptions({ name: 'ImContactFriendList' }) defineOptions({ name: 'ImContactFriendList' })

View File

@ -12,8 +12,8 @@ import { Button, Input, message } from 'ant-design-vue'
import { getCurrentUserId } from '#/views/im/utils/auth' import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImFriendRequestHandleResult } from '../../../utils/constants' import { ImFriendRequestHandleResult } from '../../../utils/constants'
import UserAvatar from '../../components/user/UserAvatar.vue' import { UserAvatar } from '../../components/user'
import UserInfo from '../../components/user/UserInfo.vue' import { UserInfo } from '../../components/user'
import { useFriendStore } from '../../store/friendStore' import { useFriendStore } from '../../store/friendStore'
defineOptions({ name: 'ImContactFriendRequestDetail' }) defineOptions({ name: 'ImContactFriendRequestDetail' })

View File

@ -11,7 +11,7 @@ import { Badge } from 'ant-design-vue'
import { getCurrentUserId } from '#/views/im/utils/auth' import { getCurrentUserId } from '#/views/im/utils/auth'
import UserAvatar from '../../components/user/UserAvatar.vue' import { UserAvatar } from '../../components/user'
import { useFriendStore } from '../../store/friendStore' import { useFriendStore } from '../../store/friendStore'
defineOptions({ name: 'ImContactFriendRequestList' }) defineOptions({ name: 'ImContactFriendRequestList' })

View File

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupLite } from '../../types' import type { GroupLite } from '../../types'
import GroupInfo from '../../components/group/GroupInfo.vue' import { GroupInfo } from '../../components/group'
defineOptions({ name: 'ImContactGroupDetail' }) defineOptions({ name: 'ImContactGroupDetail' })

View File

@ -5,7 +5,7 @@ import { computed, ref } from 'vue'
import { IconifyIcon as Icon } from '@vben/icons' import { IconifyIcon as Icon } from '@vben/icons'
import GroupItem from '../../components/group/GroupItem.vue' import { GroupItem } from '../../components/group'
defineOptions({ name: 'ImContactGroupList' }) defineOptions({ name: 'ImContactGroupList' })

View File

@ -12,16 +12,16 @@ import { Input, message } from 'ant-design-vue'
import { ImConversationType } from '../../../utils/constants' import { ImConversationType } from '../../../utils/constants'
import { StorageKeys } from '../../../utils/db' import { StorageKeys } from '../../../utils/db'
import { getFriendDisplayName, getGroupDisplayName, isGroupQuit } from '../../../utils/user' import { getFriendDisplayName, getGroupDisplayName, isGroupQuit } from '../../../utils/user'
import ResizableAside from '../../components/ResizableAside.vue' import { ResizableAside } from '../../components'
import UserInfo from '../../components/user/UserInfo.vue' import { UserInfo } from '../../components/user'
import { useConversationStore } from '../../store/conversationStore' import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore' import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore' import { useGroupStore } from '../../store/groupStore'
import FriendList from './FriendList.vue' import FriendList from './friend-list.vue'
import FriendRequestDetail from './FriendRequestDetail.vue' import FriendRequestDetail from './friend-request-detail.vue'
import FriendRequestList from './FriendRequestList.vue' import FriendRequestList from './friend-request-list.vue'
import GroupDetail from './GroupDetail.vue' import GroupDetail from './group-detail.vue'
import GroupList from './GroupList.vue' import GroupList from './group-list.vue'
defineOptions({ name: 'ImContactPage' }) defineOptions({ name: 'ImContactPage' })

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue' import type { GroupMemberLite } from '../../../../components/group'
import type { Conversation, GroupLite } from '../../../../types' import type { Conversation, GroupLite } from '../../../../types'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
@ -21,13 +21,13 @@ import { ImConversationType, ImGroupMemberRole } from '#/views/im/utils/constant
import { toGroupCardTarget } from '#/views/im/utils/message' import { toGroupCardTarget } from '#/views/im/utils/message'
import { isGroupQuit } from '#/views/im/utils/user' import { isGroupQuit } from '#/views/im/utils/user'
import GroupAdminSetDialog from '../../../../components/group/GroupAdminSetDialog.vue' import { GroupAdminSetDialog } from '../../../../components/group'
import GroupMemberAddDialog from '../../../../components/group/GroupMemberAddDialog.vue' import { GroupMemberAddDialog } from '../../../../components/group'
import GroupMemberGrid from '../../../../components/group/GroupMemberGrid.vue' import { GroupMemberGrid } from '../../../../components/group'
import GroupMemberRemoveDialog from '../../../../components/group/GroupMemberRemoveDialog.vue' import { GroupMemberRemoveDialog } from '../../../../components/group'
import GroupOwnerTransferDialog from '../../../../components/group/GroupOwnerTransferDialog.vue' import { GroupOwnerTransferDialog } from '../../../../components/group'
import GroupRequestListDialog from '../../../../components/group/GroupRequestListDialog.vue' import { GroupRequestListDialog } from '../../../../components/group'
import RecommendCardDialog from '../../../../components/user/RecommendCardDialog.vue' import { RecommendCardDialog } from '../../../../components/user'
import { useConversationStore } from '../../../../store/conversationStore' import { useConversationStore } from '../../../../store/conversationStore'
import { useGroupStore } from '../../../../store/groupStore' import { useGroupStore } from '../../../../store/groupStore'
@ -827,7 +827,8 @@ function handleOpenTransferOwner() {
border-top: 1px solid var(--im-border-color-lighter); border-top: 1px solid var(--im-border-color-lighter);
} }
:global(.dark) .im-conversation-group-side__modal { /* 整体放进 :global(),避免 Vue scoped 把 `:global(.dark) .xxx` 塌缩成裸 `.dark` 而把变量刷到 <html> */
:global(.dark .im-conversation-group-side__modal) {
--im-conversation-side-bg: rgb(255 255 255 / 5%); --im-conversation-side-bg: rgb(255 255 255 / 5%);
} }
</style> </style>

View File

@ -13,8 +13,8 @@ import { formatConversationTime } from '#/views/im/utils/time'
import { getSenderDisplayName } from '#/views/im/utils/user' import { getSenderDisplayName } from '#/views/im/utils/user'
import { ImContentType, ImConversationType, isNormalMessage } from '../../../../../utils/constants' import { ImContentType, ImConversationType, isNormalMessage } from '../../../../../utils/constants'
import GroupAvatar from '../../../../components/group/GroupAvatar.vue' import { GroupAvatar } from '../../../../components/group'
import UserAvatar from '../../../../components/user/UserAvatar.vue' import { UserAvatar } from '../../../../components/user'
import { useConversationStore } from '../../../../store/conversationStore' import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore' import { useFriendStore } from '../../../../store/friendStore'
import { useGroupRequestStore } from '../../../../store/groupRequestStore' import { useGroupRequestStore } from '../../../../store/groupRequestStore'

View File

@ -13,8 +13,8 @@ import { useGroupStore } from '#/views/im/home/store/groupStore'
import { ImConversationType } from '#/views/im/utils/constants' import { ImConversationType } from '#/views/im/utils/constants'
import { getFriendDisplayName } from '#/views/im/utils/user' import { getFriendDisplayName } from '#/views/im/utils/user'
import GroupCreateDialog from '../../../../components/group/GroupCreateDialog.vue' import { GroupCreateDialog } from '../../../../components/group'
import UserAvatar from '../../../../components/user/UserAvatar.vue' import { UserAvatar } from '../../../../components/user'
defineOptions({ name: 'ImConversationPrivateSide' }) defineOptions({ name: 'ImConversationPrivateSide' })

View File

@ -0,0 +1,3 @@
export { default as ConversationGroupSide } from './conversation-group-side.vue';
export { default as ConversationItem } from './conversation-item.vue';
export { default as ConversationPrivateSide } from './conversation-private-side.vue';

View File

@ -0,0 +1,3 @@
export * from './conversation';
export * from './input';
export * from './message';

View File

@ -0,0 +1,5 @@
export { default as FacePicker } from './face-picker.vue';
export { default as MentionPicker } from './mention-picker.vue';
export { default as MessageInput } from './message-input.vue';
export { default as MessageMultiSelectBar } from './message-multi-select-bar.vue';
export { default as VoiceRecorder } from './voice-recorder.vue';

View File

@ -7,7 +7,7 @@ import { IconifyIcon as Icon } from '@vben/icons'
import { getCurrentUserId } from '#/views/im/utils/auth' import { getCurrentUserId } from '#/views/im/utils/auth'
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '#/views/im/utils/constants' import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '#/views/im/utils/constants'
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue' import { GroupMember, type GroupMemberLite } from '../../../../components/group'
defineOptions({ name: 'ImMentionPicker' }) defineOptions({ name: 'ImMentionPicker' })

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue' import type { GroupMemberLite } from '../../../../components/group'
import type { Conversation } from '#/views/im/home/types' import type { Conversation } from '#/views/im/home/types'
@ -8,7 +8,7 @@ import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from
import { IconifyIcon as Icon } from '@vben/icons' import { IconifyIcon as Icon } from '@vben/icons'
import { isOpenableUrl } from '@vben/utils' import { isOpenableUrl } from '@vben/utils'
import { Button, Dropdown, Menu, message, Tooltip } from 'ant-design-vue' import { Button, DropdownButton, Menu, message, Tooltip } from 'ant-design-vue'
import { uploadFile } from '#/api/infra/file' import { uploadFile } from '#/api/infra/file'
import { import {
@ -32,10 +32,10 @@ import {
} from '#/views/im/utils/message' } from '#/views/im/utils/message'
import { getMemberDisplayName } from '#/views/im/utils/user' import { getMemberDisplayName } from '#/views/im/utils/user'
import ReplyPreview from '../message/ReplyPreview.vue' import { ReplyPreview } from '../message'
import FacePicker from './FacePicker.vue' import FacePicker from './face-picker.vue'
import MentionPicker from './MentionPicker.vue' import MentionPicker from './mention-picker.vue'
import VoiceRecorder from './VoiceRecorder.vue' import VoiceRecorder from './voice-recorder.vue'
defineOptions({ name: 'ImMessageInput' }) defineOptions({ name: 'ImMessageInput' })
@ -1110,9 +1110,9 @@ async function onVideoPicked(e: Event) {
</div> </div>
<!-- 群聊 + 群已读开启发送按钮 + 下拉菜单点主按钮普通发送 / 发送回执消息对齐微信 PC --> <!-- 群聊 + 群已读开启发送按钮 + 下拉菜单点主按钮普通发送 / 发送回执消息对齐微信 PC -->
<Dropdown <!-- 注意ant-design-vue 的分裂按钮是 DropdownButton Dropdown split-button 属性那是 Element Plus 写法 -->
<DropdownButton
v-if="isGroup && MESSAGE_GROUP_READ_ENABLED" v-if="isGroup && MESSAGE_GROUP_READ_ENABLED"
split-button
type="primary" type="primary"
:disabled="!canSend" :disabled="!canSend"
@click="handleSend()" @click="handleSend()"
@ -1123,7 +1123,7 @@ async function onVideoPicked(e: Event) {
<Menu.Item key="receipt">发送回执消息</Menu.Item> <Menu.Item key="receipt">发送回执消息</Menu.Item>
</Menu> </Menu>
</template> </template>
</Dropdown> </DropdownButton>
<!-- 私聊或群已读关闭普通发送按钮无群回执入口 --> <!-- 私聊或群已读关闭普通发送按钮无群回执入口 -->
<Button v-else type="primary" :disabled="!canSend" @click="handleSend()"> <Button v-else type="primary" :disabled="!canSend" @click="handleSend()">
@ -1186,13 +1186,16 @@ async function onVideoPicked(e: Event) {
border-color: #ffa39e; border-color: #ffa39e;
} }
:global(.dark) .message-input__mute-overlay--warning { /* .dark :global() Vue scoped
`:global(.dark) .xxx` 错误塌缩成裸 `.dark`导致这些颜色被直接刷到 <html>
暗色下整页发红/发暗黄BEM 类名唯一整体 global 不会误伤其它元素 */
:global(.dark .message-input__mute-overlay--warning) {
color: #ffd666; color: #ffd666;
background: rgb(77 56 21 / 88%); background: rgb(77 56 21 / 88%);
border-color: #ad6800; border-color: #ad6800;
} }
:global(.dark) .message-input__mute-overlay--error { :global(.dark .message-input__mute-overlay--error) {
color: #ff7875; color: #ff7875;
background: rgb(91 33 33 / 88%); background: rgb(91 33 33 / 88%);
border-color: #a8071a; border-color: #a8071a;

View File

@ -0,0 +1,2 @@
export { default as MessageForwardDialog } from './message-forward-dialog.vue';
export { default as MessageMergeDetailDialog } from './message-merge-detail-dialog.vue';

View File

@ -8,8 +8,8 @@ import { IconifyIcon as Icon } from '@vben/icons'
import { Button, Input, message, Modal } from 'ant-design-vue' import { Button, Input, message, Modal } from 'ant-design-vue'
import { createGroup } from '#/api/im/group' import { createGroup } from '#/api/im/group'
import ConversationPickerPanel from '#/views/im/home/components/picker/ConversationPickerPanel.vue' import { ConversationPickerPanel } from '#/views/im/home/components/picker'
import FriendPickerPanel from '#/views/im/home/components/picker/FriendPickerPanel.vue' import { FriendPickerPanel } from '#/views/im/home/components/picker'
import { useMessageMultiSelect } from '#/views/im/home/composables/useMessageMultiSelect' import { useMessageMultiSelect } from '#/views/im/home/composables/useMessageMultiSelect'
import { useMessageSender } from '#/views/im/home/composables/useMessageSender' import { useMessageSender } from '#/views/im/home/composables/useMessageSender'
import { useConversationStore } from '#/views/im/home/store/conversationStore' import { useConversationStore } from '#/views/im/home/store/conversationStore'
@ -31,7 +31,7 @@ import {
} from '#/views/im/utils/message' } from '#/views/im/utils/message'
import { isGroupQuit } from '#/views/im/utils/user' import { isGroupQuit } from '#/views/im/utils/user'
import FacePicker from '../../input/FacePicker.vue' import { FacePicker } from '../../input'
defineOptions({ name: 'ImMessageForwardDialog' }) defineOptions({ name: 'ImMessageForwardDialog' })
@ -304,6 +304,7 @@ async function handleCreateGroupAndSend() {
v-model:open="visible" v-model:open="visible"
width="720px" width="720px"
:mask-closable="false" :mask-closable="false"
:footer="view === 'conversation' ? null : undefined"
class="im-picker-dialog im-forward-dialog" class="im-picker-dialog im-forward-dialog"
> >
<template #header> <template #header>

View File

@ -5,12 +5,12 @@ import { IconifyIcon as Icon } from '@vben/icons'
import { Modal } from 'ant-design-vue' import { Modal } from 'ant-design-vue'
import UserAvatar from '#/views/im/home/components/user/UserAvatar.vue' import { UserAvatar } from '#/views/im/home/components/user'
import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer' import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer'
import { type MergeMessage, parseMessage } from '#/views/im/utils/message' import { type MergeMessage, parseMessage } from '#/views/im/utils/message'
import { formatMergeItemTime } from '#/views/im/utils/time' import { formatMergeItemTime } from '#/views/im/utils/time'
import MessageBubble from '../MessageBubble.vue' import { MessageBubble } from '..'
defineOptions({ name: 'ImMessageMergeDetailDialog' }) defineOptions({ name: 'ImMessageMergeDetailDialog' })
@ -60,6 +60,7 @@ function handleClose() {
<Modal <Modal
v-model:open="visible" v-model:open="visible"
width="560px" width="560px"
:footer="null"
:mask-closable="false" :mask-closable="false"
class="im-merge-detail-dialog" class="im-merge-detail-dialog"
:closable="true" :closable="true"

View File

@ -6,7 +6,7 @@ import { IconifyIcon as Icon } from '@vben/icons'
import { getCurrentUserId } from '#/views/im/utils/auth' import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImGroupMemberRole } from '#/views/im/utils/constants' import { ImGroupMemberRole } from '#/views/im/utils/constants'
import GroupRequestListDialog from '../../../../components/group/GroupRequestListDialog.vue' import { GroupRequestListDialog } from '../../../../components/group'
import { useGroupRequestStore } from '../../../../store/groupRequestStore' import { useGroupRequestStore } from '../../../../store/groupRequestStore'
import { useGroupStore } from '../../../../store/groupStore' import { useGroupStore } from '../../../../store/groupStore'

View File

@ -0,0 +1,10 @@
export { default as GroupPinnedMessage } from './group-pinned-message.vue';
export { default as GroupRequestPending } from './group-request-pending.vue';
export { default as MaterialBubble } from './material-bubble.vue';
export { default as MessageBubble } from './message-bubble.vue';
export { default as MessageHistory } from './message-history.vue';
export { default as MessageItem } from './message-item.vue';
export { default as MessagePanel } from './message-panel.vue';
export { default as MessageReadStatus } from './message-read-status.vue';
export { default as ReplyPreview } from './reply-preview.vue';
export { default as TipSegments } from './tip-segments.vue';

View File

@ -6,7 +6,7 @@ import { formatFileSize, openSafeUrl } from '@vben/utils'
import { Image } from 'ant-design-vue' import { Image } from 'ant-design-vue'
import CardBubble from '#/views/im/home/components/card/CardBubble.vue' import { CardBubble } from '#/views/im/home/components/card'
import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer' import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer'
import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config' import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config'
import { ImContentType } from '#/views/im/utils/constants' import { ImContentType } from '#/views/im/utils/constants'
@ -27,8 +27,8 @@ import {
} from '#/views/im/utils/message' } from '#/views/im/utils/message'
import { formatSeconds } from '#/views/im/utils/time' import { formatSeconds } from '#/views/im/utils/time'
import MaterialBubble from './MaterialBubble.vue' import MaterialBubble from './material-bubble.vue'
import TipSegments from './TipSegments.vue' import TipSegments from './tip-segments.vue'
defineOptions({ name: 'ImMessageBubble' }) defineOptions({ name: 'ImMessageBubble' })
@ -384,7 +384,8 @@ onBeforeUnmount(() => {
border-color: transparent transparent transparent #95ec69; border-color: transparent transparent transparent #95ec69;
} }
:global(.dark) .message-bubble--other { /* 整体放进 :global(),避免 Vue scoped 把 `:global(.dark) .xxx` 塌缩成裸 `.dark` 而把变量刷到 <html> */
:global(.dark .message-bubble--other) {
--im-message-bubble-other-bg: rgb(255 255 255 / 12%); --im-message-bubble-other-bg: rgb(255 255 255 / 12%);
} }

View File

@ -47,15 +47,15 @@ import {
getSenderRealNickname getSenderRealNickname
} from '#/views/im/utils/user' } from '#/views/im/utils/user'
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue' import { GroupMember, type GroupMemberLite } from '../../../../components/group'
import UserAvatar from '../../../../components/user/UserAvatar.vue' import { UserAvatar } from '../../../../components/user'
import { useConversationStore } from '../../../../store/conversationStore' import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore' import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore' import { useGroupStore } from '../../../../store/groupStore'
import { useMessageStore } from '../../../../store/messageStore' import { useMessageStore } from '../../../../store/messageStore'
import { IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys' import { IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys'
import MessageBubble from './MessageBubble.vue' import MessageBubble from './message-bubble.vue'
import TipSegments from './TipSegments.vue' import TipSegments from './tip-segments.vue'
defineOptions({ name: 'ImMessageHistory' }) defineOptions({ name: 'ImMessageHistory' })
@ -508,6 +508,7 @@ function locateMessage(messageId: number) {
v-model:open="visible" v-model:open="visible"
:title="title" :title="title"
width="640px" width="640px"
:footer="null"
class="im-message-history__dialog" class="im-message-history__dialog"
@after-open-change="(open) => open && onDialogOpen()" @after-open-change="(open) => open && onDialogOpen()"
> >

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue' import type { GroupMemberLite } from '../../../../components/group'
import type { Message } from '../../../../types' import type { Message } from '../../../../types'
import { computed, inject } from 'vue' import { computed, inject } from 'vue'
@ -60,7 +60,7 @@ import {
isGroupQuit isGroupQuit
} from '#/views/im/utils/user' } from '#/views/im/utils/user'
import UserAvatar from '../../../../components/user/UserAvatar.vue' import { UserAvatar } from '../../../../components/user'
import { mediaTypeHandlers, useMediaUploader } from '../../../../composables/useMediaUploader' import { mediaTypeHandlers, useMediaUploader } from '../../../../composables/useMediaUploader'
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect' import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
import { useMessageSender } from '../../../../composables/useMessageSender' import { useMessageSender } from '../../../../composables/useMessageSender'
@ -76,10 +76,10 @@ import {
IM_MERGE_DETAIL_DIALOG_KEY, IM_MERGE_DETAIL_DIALOG_KEY,
IM_RTC_REDIAL_KEY IM_RTC_REDIAL_KEY
} from './forward/keys' } from './forward/keys'
import MessageBubble from './MessageBubble.vue' import MessageBubble from './message-bubble.vue'
import MessageReadStatus from './MessageReadStatus.vue' import MessageReadStatus from './message-read-status.vue'
import ReplyPreview from './ReplyPreview.vue' import ReplyPreview from './reply-preview.vue'
import TipSegments from './TipSegments.vue' import TipSegments from './tip-segments.vue'
defineOptions({ name: 'ImMessageItem' }) defineOptions({ name: 'ImMessageItem' })

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { GroupMemberLite } from '../../../../components/group/GroupMember.vue' import type { GroupMemberLite } from '../../../../components/group'
import type { GroupLite } from '../../../../types' import type { GroupLite } from '../../../../types'
import { computed, nextTick, provide, ref, watch } from 'vue' import { computed, nextTick, provide, ref, watch } from 'vue'
@ -14,9 +14,11 @@ import { getClientConversationId } from '#/views/im/utils/db'
import { resolveCallEndReasonText } from '#/views/im/utils/message' import { resolveCallEndReasonText } from '#/views/im/utils/message'
import { getMemberDisplayName, isGroupQuit } from '#/views/im/utils/user' import { getMemberDisplayName, isGroupQuit } from '#/views/im/utils/user'
import GroupMuteMemberDialog from '../../../../components/group/GroupMuteMemberDialog.vue' import { GroupMuteMemberDialog } from '../../../../components/group'
import RtcCallMemberPickerDialog from '../../../../components/rtc/RtcCallMemberPickerDialog.vue' import {
import RtcGroupCallBanner from '../../../../components/rtc/RtcGroupCallBanner.vue' RtcCallMemberPickerDialog,
RtcGroupCallBanner
} from '../../../../components/rtc'
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect' import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
import { useVoicePlayer } from '../../../../composables/useVoicePlayer' import { useVoicePlayer } from '../../../../composables/useVoicePlayer'
import { useConversationStore } from '../../../../store/conversationStore' import { useConversationStore } from '../../../../store/conversationStore'
@ -25,21 +27,21 @@ import { useGroupStore } from '../../../../store/groupStore'
import { useMessageStore } from '../../../../store/messageStore' import { useMessageStore } from '../../../../store/messageStore'
import { useRtcStore } from '../../../../store/rtcStore' import { useRtcStore } from '../../../../store/rtcStore'
import { useImUiStore } from '../../../../store/uiStore' import { useImUiStore } from '../../../../store/uiStore'
import ConversationGroupSide from '../conversation/ConversationGroupSide.vue' import { ConversationGroupSide } from '../conversation'
import ConversationPrivateSide from '../conversation/ConversationPrivateSide.vue' import { ConversationPrivateSide } from '../conversation'
import MessageInput from '../input/MessageInput.vue' import { MessageInput } from '../input'
import MessageMultiSelectBar from '../input/MessageMultiSelectBar.vue' import { MessageMultiSelectBar } from '../input'
import { MessageForwardDialog } from './forward'
import { MessageMergeDetailDialog } from './forward'
import { import {
IM_FORWARD_DIALOG_KEY, IM_FORWARD_DIALOG_KEY,
IM_MERGE_DETAIL_DIALOG_KEY, IM_MERGE_DETAIL_DIALOG_KEY,
IM_RTC_REDIAL_KEY IM_RTC_REDIAL_KEY
} from './forward/keys' } from './forward/keys'
import MessageForwardDialog from './forward/MessageForwardDialog.vue' import GroupPinnedMessage from './group-pinned-message.vue'
import MessageMergeDetailDialog from './forward/MessageMergeDetailDialog.vue' import GroupRequestPending from './group-request-pending.vue'
import GroupPinnedMessage from './GroupPinnedMessage.vue' import MessageHistory from './message-history.vue'
import GroupRequestPending from './GroupRequestPending.vue' import MessageItem from './message-item.vue'
import MessageHistory from './MessageHistory.vue'
import MessageItem from './MessageItem.vue'
defineOptions({ name: 'ImMessagePanel' }) defineOptions({ name: 'ImMessagePanel' })

View File

@ -10,8 +10,8 @@ import { Popover, TabPane, Tabs } from 'ant-design-vue'
import { getGroupReadUsers as apiGetGroupReadUsers } from '#/api/im/message/group' import { getGroupReadUsers as apiGetGroupReadUsers } from '#/api/im/message/group'
import { ImConversationType, ImMessageReceiptStatus } from '../../../../../utils/constants' import { ImConversationType, ImMessageReceiptStatus } from '../../../../../utils/constants'
import GroupMember, { type GroupMemberLite } from '../../../../components/group/GroupMember.vue' import { PagedScroller } from '../../../../components'
import PagedScroller from '../../../../components/PagedScroller.vue' import { GroupMember, type GroupMemberLite } from '../../../../components/group'
import { useMessageStore } from '../../../../store/messageStore' import { useMessageStore } from '../../../../store/messageStore'
defineOptions({ name: 'ImMessageReadStatus' }) defineOptions({ name: 'ImMessageReadStatus' })

View File

@ -4,7 +4,7 @@ import { computed } from 'vue'
import { IconifyIcon as Icon } from '@vben/icons' import { IconifyIcon as Icon } from '@vben/icons'
import { formatFileSize } from '@vben/utils' import { formatFileSize } from '@vben/utils'
import CardLineLabel from '#/views/im/home/components/card/CardLineLabel.vue' import { CardLineLabel } from '#/views/im/home/components/card'
import { ImContentType } from '#/views/im/utils/constants' import { ImContentType } from '#/views/im/utils/constants'
import { getClientConversationId } from '#/views/im/utils/db' import { getClientConversationId } from '#/views/im/utils/db'
import { import {

View File

@ -10,14 +10,13 @@ import { Button, Dropdown, Input, Menu } from 'ant-design-vue'
import { ImConversationType } from '../../../utils/constants' import { ImConversationType } from '../../../utils/constants'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation' import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { StorageKeys } from '../../../utils/db' import { StorageKeys } from '../../../utils/db'
import FriendAddDialog from '../../components/friend/FriendAddDialog.vue' import { ResizableAside } from '../../components'
import GroupCreateDialog from '../../components/group/GroupCreateDialog.vue' import { FriendAddDialog } from '../../components/friend'
import ResizableAside from '../../components/ResizableAside.vue' import { GroupCreateDialog } from '../../components/group'
import { useConversationStore } from '../../store/conversationStore' import { useConversationStore } from '../../store/conversationStore'
import { useGroupStore } from '../../store/groupStore' import { useGroupStore } from '../../store/groupStore'
import { useImUiStore } from '../../store/uiStore' import { useImUiStore } from '../../store/uiStore'
import ConversationItem from './components/conversation/ConversationItem.vue' import { ConversationItem, MessagePanel } from './components'
import MessagePanel from './components/message/MessagePanel.vue'
defineOptions({ name: 'ImMessagePage' }) defineOptions({ name: 'ImMessagePage' })
@ -200,12 +199,16 @@ watch(() => uiStore.nextUnreadJumpNonce, jumpToNextUnread)
<template #overlay> <template #overlay>
<Menu> <Menu>
<Menu.Item @click="handleOpenCreateGroup"> <Menu.Item @click="handleOpenCreateGroup">
<Icon icon="ant-design:message-outlined" :size="16" /> <span class="inline-flex items-center gap-2">
<span>发起群聊</span> <Icon icon="ant-design:message-outlined" :size="16" />
<span>发起群聊</span>
</span>
</Menu.Item> </Menu.Item>
<Menu.Item @click="friendAddDialogRef?.open()"> <Menu.Item @click="friendAddDialogRef?.open()">
<Icon icon="ant-design:user-add-outlined" :size="16" /> <span class="inline-flex items-center gap-2">
<span>添加朋友</span> <Icon icon="ant-design:user-add-outlined" :size="16" />
<span>添加朋友</span>
</span>
</Menu.Item> </Menu.Item>
</Menu> </Menu>
</template> </template>

View File

@ -11,7 +11,7 @@ import { ImFriendAddSource } from '../../utils/constants'
* *
* N 1 * N 1
* open DOM store * open DOM store
* `Index.vue` * `index.vue`
*/ */
export const useImUiStore = defineStore('imUiStore', () => { export const useImUiStore = defineStore('imUiStore', () => {
// ==================== 用户名片 UserInfoCard ==================== // ==================== 用户名片 UserInfoCard ====================

View File

@ -0,0 +1 @@
export { default as ChannelSelect } from './select.vue';

View File

@ -0,0 +1 @@
export { default as MaterialSelect } from './select.vue';

View File

@ -8,7 +8,7 @@ import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form'; import { z } from '#/adapter/form';
import { getRangePickerDefaultProps } from '#/utils'; import { getRangePickerDefaultProps } from '#/utils';
import ChannelSelect from '#/views/im/manager/components/ChannelSelect.vue'; import { ChannelSelect } from '#/views/im/manager/channel/list/components';
/** 新增/修改的表单 */ /** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] { export function useFormSchema(): VbenFormSchema[] {

View File

@ -6,9 +6,9 @@ import { useVbenModal } from '@vben/common-ui';
import { Form, FormItem, message, Radio, RadioGroup } from 'ant-design-vue'; import { Form, FormItem, message, Radio, RadioGroup } from 'ant-design-vue';
import { sendManagerChannelMessage } from '#/api/im/manager/channel/message'; import { sendManagerChannelMessage } from '#/api/im/manager/channel/message';
import ChannelSelect from '#/views/im/manager/components/ChannelSelect.vue'; import { ChannelSelect } from '#/views/im/manager/channel/list/components';
import MaterialSelect from '#/views/im/manager/components/MaterialSelect.vue'; import { MaterialSelect } from '#/views/im/manager/channel/material/components';
import UserMultiSelect from '#/views/im/manager/components/UserMultiSelect.vue'; import { UserSelect } from '#/views/system/user/components';
const emit = defineEmits(['success']); const emit = defineEmits(['success']);
@ -89,8 +89,9 @@ const [Modal, modalApi] = useVbenModal({
</RadioGroup> </RadioGroup>
</FormItem> </FormItem>
<FormItem v-if="formData.receiverUserType === 'users'" label="接收用户" required> <FormItem v-if="formData.receiverUserType === 'users'" label="接收用户" required>
<UserMultiSelect <UserSelect
v-model="formData.receiverUserIds" v-model="formData.receiverUserIds"
multiple
placeholder="请选择接收用户" placeholder="请选择接收用户"
/> />
</FormItem> </FormItem>

View File

@ -1,74 +0,0 @@
<script lang="ts" setup>
import type { ImManagerGroupApi } from '#/api/im/manager/group';
import { computed, onMounted, ref } from 'vue';
import { Select } from 'ant-design-vue';
import { getManagerGroupPage } from '#/api/im/manager/group';
defineOptions({ name: 'ImManagerGroupSelect' });
const props = withDefaults(
defineProps<{
allowClear?: boolean;
disabled?: boolean;
modelValue?: number;
placeholder?: string;
}>(),
{
allowClear: true,
disabled: false,
modelValue: undefined,
placeholder: '请选择群',
},
);
const emit = defineEmits<{
'update:modelValue': [value: number | undefined];
}>();
const loading = ref(false);
const groupList = ref<ImManagerGroupApi.Group[]>([]);
const value = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
const options = computed(() =>
groupList.value.map((item) => ({
label: `${item.name} (${item.id})`,
value: item.id,
})),
);
/** 加载群列表 */
async function loadGroupList() {
loading.value = true;
try {
const data = await getManagerGroupPage({
pageNo: 1,
pageSize: 100,
});
groupList.value = data.list || [];
} finally {
loading.value = false;
}
}
onMounted(loadGroupList);
</script>
<template>
<Select
v-model:value="value"
:allow-clear="allowClear"
:disabled="disabled"
:loading="loading"
:options="options"
:placeholder="placeholder"
class="w-full"
show-search
/>
</template>

View File

@ -1,107 +0,0 @@
<script lang="ts" setup>
import type { SystemUserApi } from '#/api/system/user';
import { computed, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Input, Tag } from 'ant-design-vue';
import { getUser } from '#/api/system/user';
import UserSelectDialog from '#/views/system/user/components/select-dialog.vue';
defineOptions({ name: 'ImManagerUserMultiSelect' });
const props = withDefaults(
defineProps<{
disabled?: boolean
modelValue?: number[]
placeholder?: string
}>(),
{
disabled: false,
modelValue: () => [],
placeholder: '请选择用户',
},
);
const emit = defineEmits<{
change: [rows: SystemUserApi.User[]];
'update:modelValue': [value: number[]];
}>();
const dialogRef = ref<InstanceType<typeof UserSelectDialog>>();
const selectedRows = ref<SystemUserApi.User[]>([]);
const displayText = computed(() => {
if (selectedRows.value.length === 0) {
return '';
}
return selectedRows.value
.map((item) => item.nickname || item.username || item.id)
.join('');
});
/** 回显已选用户 */
async function resolveSelectedUsers(ids: number[]) {
const knownMap = new Map(selectedRows.value.map((item) => [item.id, item]));
const list: SystemUserApi.User[] = [];
for (const id of ids) {
const cached = knownMap.get(id);
if (cached) {
list.push(cached);
continue;
}
list.push(await getUser(id));
}
selectedRows.value = list;
}
watch(
() => props.modelValue,
(ids) => {
void resolveSelectedUsers(ids || []);
},
{ immediate: true },
);
/** 打开用户选择弹窗 */
function handleClick() {
if (props.disabled) {
return;
}
dialogRef.value?.open(props.modelValue || [], { multiple: true });
}
/** 弹窗选中回调 */
function handleSelected(rows: SystemUserApi.User[]) {
selectedRows.value = rows;
emit(
'update:modelValue',
rows.map((item) => item.id!),
);
emit('change', rows);
}
</script>
<template>
<div class="w-full">
<Input
:disabled="disabled"
:placeholder="placeholder"
:value="displayText"
readonly
@click="handleClick"
>
<template #suffix>
<IconifyIcon class="size-4" icon="lucide:search" />
</template>
</Input>
<div v-if="selectedRows.length > 0" class="mt-2 flex flex-wrap gap-1">
<Tag v-for="item in selectedRows" :key="item.id">
{{ item.nickname || item.username || item.id }}
</Tag>
</div>
<UserSelectDialog ref="dialogRef" @selected="handleSelected" />
</div>
</template>

View File

@ -7,7 +7,7 @@ import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks'; import { getDictOptions } from '@vben/hooks';
import { getRangePickerDefaultProps } from '#/utils'; import { getRangePickerDefaultProps } from '#/utils';
import UserSelect from '#/views/system/user/components/select.vue'; import { UserSelect } from '#/views/system/user/components';
/** 好友关系搜索表单 */ /** 好友关系搜索表单 */
export function useFriendGridFormSchema(): VbenFormSchema[] { export function useFriendGridFormSchema(): VbenFormSchema[] {

View File

@ -0,0 +1,2 @@
export { default as GroupSelectDialog } from './select-dialog.vue';
export { default as GroupSelect } from './select.vue';

View File

@ -0,0 +1,271 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ImManagerGroupApi } from '#/api/im/manager/group';
import { nextTick, ref } from 'vue';
import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { Button, message, Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import {
getManagerGroup,
getManagerGroupPage,
} from '#/api/im/manager/group';
const emit = defineEmits<{
selected: [rows: ImManagerGroupApi.Group[]];
}>();
const open = ref(false); //
const multiple = ref(false); //
const selectedRows = ref<ImManagerGroupApi.Group[]>([]); //
const preSelectedIds = ref<number[]>([]); //
/** 群选择弹窗的搜索表单 */
function useGroupSelectGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '群名称',
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '请输入群名称',
},
},
{
fieldName: 'status',
label: '群状态',
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.IM_GROUP_STATUS, 'number'),
placeholder: '请选择群状态',
},
},
];
}
/** 群选择弹窗的字段 */
function useGroupSelectGridColumns(
isMultiple = false,
): VxeTableGridOptions<ImManagerGroupApi.Group>['columns'] {
return [
{
type: isMultiple ? 'checkbox' : 'radio',
width: 50,
},
{
field: 'id',
title: '编号',
width: 100,
},
{
field: 'name',
title: '群名称',
minWidth: 160,
},
{
field: 'ownerNickname',
title: '群主',
minWidth: 160,
},
{
field: 'memberCount',
title: '成员数',
width: 90,
},
{
field: 'status',
title: '群状态',
width: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.IM_GROUP_STATUS },
},
},
];
}
/** 获取多选记录,包含 VXE reserve 跨页记录 */
function getMultipleSelectedRows() {
const selectedMap = new Map<number, ImManagerGroupApi.Group>();
const records = [
...(gridApi.grid.getCheckboxReserveRecords?.() ?? []),
...(gridApi.grid.getCheckboxRecords?.() ?? []),
] as ImManagerGroupApi.Group[];
records.forEach((row) => {
selectedMap.set(row.id, row);
});
return [...selectedMap.values()];
}
/** 处理多选勾选变化 */
function handleCheckboxSelectChange() {
selectedRows.value = getMultipleSelectedRows();
}
/** 处理单选切换 */
function handleRadioChange(row: ImManagerGroupApi.Group) {
selectedRows.value = [row];
}
/** 多选模式下切换行勾选 */
async function toggleMultipleRow(row: ImManagerGroupApi.Group) {
const selected = gridApi.grid.isCheckedByCheckboxRow(row);
await gridApi.grid.setCheckboxRow(row, !selected);
selectedRows.value = getMultipleSelectedRows();
}
/** 处理行双击:单选直接确认,多选切换勾选 */
async function handleCellDblclick({ row }: { row: ImManagerGroupApi.Group }) {
if (multiple.value) {
await toggleMultipleRow(row);
return;
}
selectedRows.value = [row];
await gridApi.grid.setRadioRow(row);
handleConfirm();
}
/** 回显预选群 */
async function applyPreSelection() {
if (preSelectedIds.value.length === 0) {
return;
}
const rows = gridApi.grid.getData() as ImManagerGroupApi.Group[];
for (const row of rows) {
if (!preSelectedIds.value.includes(row.id)) {
continue;
}
if (multiple.value) {
await gridApi.grid.setCheckboxRow(row, true);
} else {
await gridApi.grid.setRadioRow(row);
selectedRows.value = [row];
return;
}
}
if (multiple.value) {
selectedRows.value = getMultipleSelectedRows();
return;
}
const targetId = preSelectedIds.value[0];
if (targetId) {
selectedRows.value = [await getManagerGroup(targetId)];
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGroupSelectGridFormSchema(),
},
gridOptions: {
columns: useGroupSelectGridColumns(false),
height: 520,
keepSource: true,
checkboxConfig: {
highlight: true,
range: true,
reserve: true,
},
radioConfig: {
highlight: true,
trigger: 'row',
},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getManagerGroupPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<ImManagerGroupApi.Group>,
gridEvents: {
cellDblclick: handleCellDblclick,
checkboxAll: handleCheckboxSelectChange,
checkboxChange: handleCheckboxSelectChange,
radioChange: ({ row }: { row: ImManagerGroupApi.Group }) => {
handleRadioChange(row);
},
},
});
/** 重置查询和选择状态 */
async function resetQueryState() {
selectedRows.value = [];
await gridApi.grid.clearCheckboxRow();
await gridApi.grid.clearCheckboxReserve();
await gridApi.grid.clearRadioRow();
await gridApi.formApi.resetForm();
}
/** 打开群选择弹窗 */
async function openModal(
selectedIds?: number[],
options?: { multiple?: boolean },
) {
open.value = true;
multiple.value = options?.multiple ?? false;
preSelectedIds.value = selectedIds || [];
await nextTick();
gridApi.setGridOptions({
columns: useGroupSelectGridColumns(multiple.value),
});
await resetQueryState();
await gridApi.query();
await nextTick();
await applyPreSelection();
}
/** 关闭群选择弹窗 */
function closeModal() {
open.value = false;
}
/** 确认选择群 */
function handleConfirm() {
const rows = multiple.value ? getMultipleSelectedRows() : selectedRows.value;
if (rows.length === 0) {
message.warning(multiple.value ? '请至少选择一条数据' : '请选择一条数据');
return;
}
emit('selected', multiple.value ? rows : [rows[0]!]);
open.value = false;
}
defineExpose({ open: openModal });
</script>
<template>
<Modal
v-model:open="open"
title="群选择"
width="70%"
:destroy-on-close="true"
@ok="handleConfirm"
@cancel="closeModal"
>
<Grid table-title="" />
<template #footer>
<Button @click="closeModal"></Button>
<Button type="primary" @click="handleConfirm"></Button>
</template>
</Modal>
</template>

View File

@ -0,0 +1,129 @@
<script lang="ts" setup>
import type { ImManagerGroupApi } from '#/api/im/manager/group';
import { computed, ref, useAttrs, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Input, Tooltip } from 'ant-design-vue';
import { getManagerGroup } from '#/api/im/manager/group';
import GroupSelectDialog from './select-dialog.vue';
defineOptions({ name: 'ImManagerGroupSelect', inheritAttrs: false });
const props = withDefaults(
defineProps<{
allowClear?: boolean;
disabled?: boolean;
modelValue?: number;
placeholder?: string;
}>(),
{
allowClear: true,
disabled: false,
modelValue: undefined,
placeholder: '请选择群',
},
);
const emit = defineEmits<{
change: [item: ImManagerGroupApi.Group | undefined];
'update:modelValue': [value: number | undefined];
}>();
const attrs = useAttrs();
const dialogRef = ref<InstanceType<typeof GroupSelectDialog>>(); //
const hovering = ref(false); //
const selectedItem = ref<ImManagerGroupApi.Group>(); //
/** 输入框显示文本 */
const displayLabel = computed(() => selectedItem.value?.name ?? '');
/** 是否显示清除图标 */
const showClear = computed(() => {
return props.allowClear && !props.disabled && hovering.value && props.modelValue != null;
});
/** 根据编号查询群信息(用于编辑回显) */
async function resolveItemById(id: number | undefined) {
if (id == null) {
selectedItem.value = undefined;
return;
}
if (selectedItem.value?.id === id) {
return;
}
selectedItem.value = await getManagerGroup(id);
}
watch(() => props.modelValue, resolveItemById, { immediate: true });
/** 清空已选群 */
function clearSelected() {
selectedItem.value = undefined;
emit('update:modelValue', undefined);
emit('change', undefined);
}
/** 打开群选择弹窗 */
function handleClick(event: MouseEvent) {
if (props.disabled) {
return;
}
const target = event.target as HTMLElement;
if (showClear.value && target.closest('.ant-input-suffix')) {
event.stopPropagation();
clearSelected();
return;
}
dialogRef.value?.open(props.modelValue == null ? [] : [props.modelValue]);
}
/** 弹窗选中回调 */
function handleSelected(rows: ImManagerGroupApi.Group[]) {
const item = rows[0];
if (!item) {
return;
}
selectedItem.value = item;
emit('update:modelValue', item.id);
emit('change', item);
}
</script>
<template>
<div
v-bind="attrs"
class="w-full"
:class="disabled ? 'cursor-not-allowed' : 'cursor-pointer'"
@click="handleClick"
@mouseenter="hovering = true"
@mouseleave="hovering = false"
>
<Tooltip :mouse-enter-delay="0.5" :open="selectedItem ? undefined : false">
<template #title>
<div v-if="selectedItem" class="leading-6">
<div>群名称{{ selectedItem.name || '-' }}</div>
<div>群主{{ selectedItem.ownerNickname || '-' }}</div>
<div>成员数{{ selectedItem.memberCount ?? '-' }}</div>
</div>
</template>
<Input
:disabled="disabled"
:placeholder="placeholder"
:value="displayLabel"
readonly
>
<template #suffix>
<IconifyIcon
class="size-4"
:icon="showClear ? 'lucide:circle-x' : 'lucide:search'"
/>
</template>
</Input>
</Tooltip>
</div>
<GroupSelectDialog ref="dialogRef" @selected="handleSelected" />
</template>

View File

@ -7,8 +7,8 @@ import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks'; import { getDictOptions } from '@vben/hooks';
import { getRangePickerDefaultProps } from '#/utils'; import { getRangePickerDefaultProps } from '#/utils';
import GroupSelect from '#/views/im/manager/components/GroupSelect.vue'; import { GroupSelect } from '#/views/im/manager/group/components';
import UserSelect from '#/views/system/user/components/select.vue'; import { UserSelect } from '#/views/system/user/components';
/** 群搜索表单 */ /** 群搜索表单 */
export function useGroupGridFormSchema(): VbenFormSchema[] { export function useGroupGridFormSchema(): VbenFormSchema[] {

View File

@ -7,7 +7,7 @@ import { formatFileSize, openSafeUrl } from '@vben/utils';
import { Image } from 'ant-design-vue'; import { Image } from 'ant-design-vue';
import CardLineLabel from '#/views/im/home/components/card/CardLineLabel.vue'; import { CardLineLabel } from '#/views/im/home/components/card';
import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config'; import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config';
import { import {
ImContentType, ImContentType,

View File

@ -7,8 +7,8 @@ import { DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks'; import { getDictOptions } from '@vben/hooks';
import { getRangePickerDefaultProps } from '#/utils'; import { getRangePickerDefaultProps } from '#/utils';
import GroupSelect from '#/views/im/manager/components/GroupSelect.vue'; import { GroupSelect } from '#/views/im/manager/group/components';
import UserSelect from '#/views/system/user/components/select.vue'; import { UserSelect } from '#/views/system/user/components';
/** 私聊消息搜索表单 */ /** 私聊消息搜索表单 */
export function usePrivateGridFormSchema(): VbenFormSchema[] { export function usePrivateGridFormSchema(): VbenFormSchema[] {

View File

@ -19,8 +19,8 @@ import {
IM_AT_ALL_USER_ID, IM_AT_ALL_USER_ID,
} from '#/views/im/utils/constants'; } from '#/views/im/utils/constants';
import { MessageContentPreview } from '..';
import { useGroupGridColumns, useGroupGridFormSchema } from '../data'; import { useGroupGridColumns, useGroupGridFormSchema } from '../data';
import MessageContentPreview from '../MessageContentPreview.vue';
import Detail from './modules/detail.vue'; import Detail from './modules/detail.vue';
defineOptions({ name: 'ImGroupMessage' }); defineOptions({ name: 'ImGroupMessage' });

View File

@ -20,7 +20,7 @@ import {
IM_AT_ALL_USER_ID, IM_AT_ALL_USER_ID,
} from '#/views/im/utils/constants'; } from '#/views/im/utils/constants';
import MessageContentPreview from '../../MessageContentPreview.vue'; import { MessageContentPreview } from '../..';
const visible = ref(false); const visible = ref(false);
const detail = ref<ImManagerGroupMessageApi.GroupMessage>({} as ImManagerGroupMessageApi.GroupMessage); const detail = ref<ImManagerGroupMessageApi.GroupMessage>({} as ImManagerGroupMessageApi.GroupMessage);

View File

@ -0,0 +1 @@
export { default as MessageContentPreview } from './content-preview.vue';

View File

@ -12,11 +12,11 @@ import { getManagerPrivateMessagePage } from '#/api/im/manager/message/private';
import { formatUserLabel } from '#/views/im/manager/utils/format'; import { formatUserLabel } from '#/views/im/manager/utils/format';
import { MESSAGE_PRIVATE_READ_ENABLED } from '#/views/im/utils/config'; import { MESSAGE_PRIVATE_READ_ENABLED } from '#/views/im/utils/config';
import { MessageContentPreview } from '..';
import { import {
usePrivateGridColumns, usePrivateGridColumns,
usePrivateGridFormSchema, usePrivateGridFormSchema,
} from '../data'; } from '../data';
import MessageContentPreview from '../MessageContentPreview.vue';
import Detail from './modules/detail.vue'; import Detail from './modules/detail.vue';
defineOptions({ name: 'ImPrivateMessage' }); defineOptions({ name: 'ImPrivateMessage' });

Some files were not shown because too many files have changed in this diff Show More