fix: lint

This commit is contained in:
xingyu4j
2026-06-26 16:29:43 +08:00
parent 1b5a42f7f4
commit 76f4bd8275
77 changed files with 8040 additions and 6016 deletions

View File

@@ -1,54 +1,56 @@
<script lang="ts" setup>
import type { Conversation } from './types'
import type { Conversation } from './types';
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { preferences } from '@vben/preferences'
import { preferences } from '@vben/preferences';
import { ImConversationType } from '../utils/constants'
import { initDb, stopRequests, StorageKeys } from '../utils/db'
import { ContextMenu, ToolBar } from './components'
import { GroupInfoCard } from './components/group'
import { RtcCallContainer } from './components/rtc'
import { UserInfoCard } from './components/user'
import { useMessagePuller } from './composables/useMessagePuller'
import { useMessageSender } from './composables/useMessageSender'
import { useVoicePlayer } from './composables/useVoicePlayer'
import { useChannelStore } from './store/channelStore'
import { useConversationStore } from './store/conversationStore'
import { useFaceStore } from './store/faceStore'
import { useFriendStore } from './store/friendStore'
import { useGroupRequestStore } from './store/groupRequestStore'
import { useGroupStore } from './store/groupStore'
import { useMessageStore } from './store/messageStore'
import { useImWebSocketStore } from './store/websocketStore'
import { ImConversationType } from '../utils/constants';
import { initDb, stopRequests, StorageKeys } from '../utils/db';
import { ContextMenu, ToolBar } from './components';
import { GroupInfoCard } from './components/group';
import { RtcCallContainer } from './components/rtc';
import { UserInfoCard } from './components/user';
import { useMessagePuller } from './composables/useMessagePuller';
import { useMessageSender } from './composables/useMessageSender';
import { useVoicePlayer } from './composables/useVoicePlayer';
import { useChannelStore } from './store/channelStore';
import { useConversationStore } from './store/conversationStore';
import { useFaceStore } from './store/faceStore';
import { useFriendStore } from './store/friendStore';
import { useGroupRequestStore } from './store/groupRequestStore';
import { useGroupStore } from './store/groupStore';
import { useMessageStore } from './store/messageStore';
import { useImWebSocketStore } from './store/websocketStore';
defineOptions({ name: 'ImIndex' })
defineOptions({ name: 'ImIndex' });
const route = useRoute()
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const webSocketStore = useImWebSocketStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const faceStore = useFaceStore()
const channelStore = useChannelStore()
const { pullOnce, cancelPull } = useMessagePuller()
const { readActive, syncPrivateReadStatus } = useMessageSender()
const voicePlayer = useVoicePlayer()
const childRouteReady = ref(false) // 子路由是否可挂载
const route = useRoute();
const conversationStore = useConversationStore();
const messageStore = useMessageStore();
const webSocketStore = useImWebSocketStore();
const friendStore = useFriendStore();
const groupStore = useGroupStore();
const groupRequestStore = useGroupRequestStore();
const faceStore = useFaceStore();
const channelStore = useChannelStore();
const { pullOnce, cancelPull } = useMessagePuller();
const { readActive, syncPrivateReadStatus } = useMessageSender();
const voicePlayer = useVoicePlayer();
const childRouteReady = ref(false); // 子路由是否可挂载
/** 初始化:先吃本地缓存让首屏立即渲染,再远端刷新最新数据,最后建实时通信拉离线消息 */
onMounted(async () => {
// 0.1 系统表情包后台预拉:独立链路与首屏 IDB / 远端拉取并发,消除表情面板首次展开白屏;失败仅记日志,不阻塞主流程
void faceStore.ensureFacePackList().catch((error) => console.warn('[IM] 后台预拉表情包失败', error))
void faceStore
.ensureFacePackList()
.catch((error) => console.warn('[IM] 后台预拉表情包失败', error));
// 1.1 整段 loading=true 阻断会话列表抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息
conversationStore.loading = true
conversationStore.loading = true;
try {
// 1.2 打开当前用户 IM DB
await initDb()
await initDb();
// 1.3 多个 store 并发从 IDB 读取本地缓存
const cacheResults = await Promise.all([
conversationStore.loadConversationList(),
@@ -56,109 +58,122 @@ onMounted(async () => {
friendStore.loadFriendData(),
groupStore.loadGroupList(),
channelStore.loadChannelList(),
groupRequestStore.loadGroupRequestList()
])
const hasFriendRows = cacheResults[2]
const hasGroupRows = cacheResults[3]
const hasChannelRows = cacheResults[4]
groupStore.markAllGroupActiveCallsExpired()
groupStore.markAllGroupMembersExpired()
childRouteReady.value = true
groupRequestStore.loadGroupRequestList(),
]);
const hasFriendRows = cacheResults[2];
const hasGroupRows = cacheResults[3];
const hasChannelRows = cacheResults[4];
groupStore.markAllGroupActiveCallsExpired();
groupStore.markAllGroupMembersExpired();
childRouteReady.value = true;
// 1.4 我管理的群下未处理加群申请红点:首登用 unhandled-list服务端直接过滤未处理语义精准、启动轻
// pullGroupRequests 只在重连 / 后续补偿时跑(见 useMessagePuller.pullStateEvents不进首登主链路
void groupRequestStore
.fetchUnhandledGroupRequestList()
.catch((error) => console.warn('[IM] 拉取未处理加群申请失败', error))
.catch((error) => console.warn('[IM] 拉取未处理加群申请失败', error));
// 2. 好友主数据恢复走 pull群列表用快照刷新覆盖离线期间自己的入群 / 退群状态变化
// 2.1 有缓存异步背景增量刷新失败仅记日志IDB 数据已经够撑首屏pullOnce 也能正常入库)
// 2.2 无缓存(首登 / 切账号回切):必须 await + 失败抛出中断本轮 onMounted
// 否则 pullOnce 会用 senderId 数字给会话起名落到 IDB 后续基本无法自愈;无缓存分支并发 Promise.all 省一个 RTT
const requiredFetches: Promise<unknown>[] = []
const requiredFetches: Promise<unknown>[] = [];
if (hasFriendRows) {
void friendStore.pullFriends().catch((error) => console.warn('[IM] 后台增量拉好友失败', error))
void friendStore
.pullFriends()
.catch((error) => console.warn('[IM] 后台增量拉好友失败', error));
} else {
requiredFetches.push(friendStore.pullFriends())
requiredFetches.push(friendStore.pullFriends());
}
if (hasGroupRows) {
void groupStore.fetchGroupList(true).catch((error) => console.warn('[IM] 后台刷新群列表失败', error))
void groupStore
.fetchGroupList(true)
.catch((error) => console.warn('[IM] 后台刷新群列表失败', error));
} else {
requiredFetches.push(groupStore.fetchGroupList(true))
requiredFetches.push(groupStore.fetchGroupList(true));
}
// 2.3 频道无增量 pull 接口,继续走 list
if (hasChannelRows) {
void channelStore.fetchChannelList().catch((error) => console.warn('[IM] 后台刷频道列表失败', error))
void channelStore
.fetchChannelList()
.catch((error) => console.warn('[IM] 后台刷频道列表失败', error));
} else {
requiredFetches.push(channelStore.fetchChannelList())
requiredFetches.push(channelStore.fetchChannelList());
}
// 2.4 执行加载
if (requiredFetches.length > 0) {
await Promise.all(requiredFetches)
await Promise.all(requiredFetches);
}
// 2.5 好友申请增量补偿:首登也要跑,离线期间好友申请变更不会影响好友主表
void friendStore
.pullFriendRequests()
.catch((error) => console.warn('[IM] 后台增量拉好友申请失败', error))
.catch((error) => console.warn('[IM] 后台增量拉好友申请失败', error));
// 3. 会话读位置先补偿,消息入库时可直接过滤已读历史消息
await conversationStore
.pullConversationReads()
.catch((error) => console.warn('[IM] 拉取会话读位置失败', error))
.catch((error) => console.warn('[IM] 拉取会话读位置失败', error));
// 4. 实时通信:建 WebSocket 长连接 + 拉离线消息pullOnce finally 把 loading 归位)
webSocketStore.connect()
await pullOnce()
webSocketStore.connect();
await pullOnce();
// 5. 默认选中第一个会话;若置顶分组处于折叠态,需跳过被折叠隐藏的置顶项,避免自动展开折叠
const sorted = conversationStore.getSortedConversationList
const firstVisible = pickFirstVisibleConversation(sorted)
const sorted = conversationStore.getSortedConversationList;
const firstVisible = pickFirstVisibleConversation(sorted);
if (firstVisible && !conversationStore.activeConversation) {
conversationStore.setActiveConversation(firstVisible)
conversationStore.setActiveConversation(firstVisible);
}
} catch (error) {
// 1. 首拉失败:手动复位 loadingpullOnce 没跑到,它的 finally 兜不到这里),否则后续会话列表写入全被早 return 阻断
// 2. WebSocket 不在这里 disconnect——路由离开会走 onUnmounted 自然清理,用户也可以刷新重试
conversationStore.loading = false
console.error('[IM] 初始化失败', error)
conversationStore.loading = false;
console.error('[IM] 初始化失败', error);
}
})
});
/**
* 选首屏自动激活的会话;置顶分组折叠时跳过被折叠隐藏的置顶项,避免激活后被自动顶上来
*
* 折叠态判定只看用户开关;非置顶 / 有未读的置顶项始终可见,全是可折叠置顶时回退到 sorted[0] 兜底
*/
function pickFirstVisibleConversation(sorted: Conversation[]): Conversation | undefined {
function pickFirstVisibleConversation(
sorted: Conversation[],
): Conversation | undefined {
if (sorted.length === 0) {
return undefined
return undefined;
}
const pinnedExpanded =
localStorage.getItem(StorageKeys.localStorage.conversationPinnedExpanded) === 'true'
localStorage.getItem(
StorageKeys.localStorage.conversationPinnedExpanded,
) === 'true';
if (pinnedExpanded) {
return sorted[0]
return sorted[0];
}
return sorted.find((c) => !c.top || (!c.silent && (c.unreadCount || 0) > 0)) ?? sorted[0]
return (
sorted.find((c) => !c.top || (!c.silent && (c.unreadCount || 0) > 0)) ??
sorted[0]
);
}
/** 标签关闭前 flush 草稿队列debounce 默认 trail-edge 触发,最后一次输入可能还压在队列里 */
function onBeforeUnload() {
conversationStore.flushConversationDraftSave()
conversationStore.flushConversationDraftSave();
}
window.addEventListener('beforeunload', onBeforeUnload)
window.addEventListener('beforeunload', onBeforeUnload);
/** 离开 IM 主壳:取消 pull、断开 WebSocket、保存草稿、停止语音、解绑 unload并结束当前 IM session */
onUnmounted(() => {
cancelPull()
webSocketStore.disconnect()
conversationStore.flushConversationDraftSave()
faceStore.clear()
cancelPull();
webSocketStore.disconnect();
conversationStore.flushConversationDraftSave();
faceStore.clear();
// 模块级单例 audio 不会随视图卸载自动停,主动停掉避免切路由后语音继续响
voicePlayer.stop()
window.removeEventListener('beforeunload', onBeforeUnload)
voicePlayer.stop();
window.removeEventListener('beforeunload', onBeforeUnload);
// 停止当前 IM session 并清理各 store 内存
void stopRequests()
})
void stopRequests();
});
/**
* 当前会话切换:本地清零未读 + 上报后端已读 + 私聊补"对方已读到哪条"
@@ -169,20 +184,20 @@ onUnmounted(() => {
watch(
() => [
conversationStore.activeConversation?.type,
conversationStore.activeConversation?.targetId
conversationStore.activeConversation?.targetId,
],
async ([type, targetId]) => {
if (!targetId) {
return
return;
}
// 本地清零未读 + 上报后端已读,让其它端 / 对方 UI 同步
await readActive()
await readActive();
// 私聊补一次"对方已读到哪条",弥补离线 / 多端漏掉的 RECEIPT 推送
if (type === ImConversationType.PRIVATE) {
void syncPrivateReadStatus(targetId)
void syncPrivateReadStatus(targetId);
}
}
)
},
);
/**
* 浏览器标签 title 拼上未读数前缀;例:(63条未读)芋道源码
@@ -194,12 +209,13 @@ watch(
[() => conversationStore.getTotalUnreadCount, () => route.fullPath],
([count]) => {
nextTick(() => {
const base = preferences.app.name
document.title = count > 0 ? `(${count > 99 ? '99+' : count}条未读)${base}` : base
})
const base = preferences.app.name;
document.title =
count > 0 ? `(${count > 99 ? '99+' : count}条未读)${base}` : base;
});
},
{ immediate: true }
)
{ immediate: true },
);
</script>
<template>

View File

@@ -1,17 +1,17 @@
<script lang="ts" setup>
import type { GroupLite } from '../../types'
import type { GroupLite } from '../../types';
import { GroupInfo } from '../../components/group'
import { GroupInfo } from '../../components/group';
defineOptions({ name: 'ImContactGroupDetail' })
defineOptions({ name: 'ImContactGroupDetail' });
defineProps<{
group: GroupLite
}>()
group: GroupLite;
}>();
const emit = defineEmits<{
chat: [group: GroupLite]
}>()
chat: [group: GroupLite];
}>();
</script>
<template>

View File

@@ -1,33 +1,35 @@
<script lang="ts" setup>
import type { GroupLite } from '../../types'
import type { GroupLite } from '../../types';
import { computed, ref } from 'vue'
import { computed, ref } from 'vue';
import { IconifyIcon as Icon } from '@vben/icons'
import { IconifyIcon as Icon } from '@vben/icons';
import { GroupItem } from '../../components/group'
import { GroupItem } from '../../components/group';
defineOptions({ name: 'ImContactGroupList' })
defineOptions({ name: 'ImContactGroupList' });
const props = defineProps<{
activeId?: number
groups: GroupLite[]
keyword: string
}>()
activeId?: number;
groups: GroupLite[];
keyword: string;
}>();
const emit = defineEmits<{ select: [group: GroupLite] }>()
const emit = defineEmits<{ select: [group: GroupLite] }>();
const expanded = ref(true)
const expanded = ref(true);
const filtered = computed(() => {
const keywordLower = props.keyword.trim().toLowerCase()
const keywordLower = props.keyword.trim().toLowerCase();
if (!keywordLower) {
return props.groups
return props.groups;
}
return props.groups.filter((group) =>
(group.showGroupName || group.name || '').toLowerCase().includes(keywordLower)
)
})
(group.showGroupName || group.name || '')
.toLowerCase()
.includes(keywordLower),
);
});
</script>
<template>
@@ -42,9 +44,14 @@ const filtered = computed(() => {
class="flex gap-2 items-center px-3.5 py-2.5 text-15px text-[var(--ant-color-text)] cursor-pointer select-none hover:bg-[var(--ant-color-fill-secondary)]"
@click="expanded = !expanded"
>
<Icon :icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'" :size="14" />
<Icon
:icon="expanded ? 'ep:caret-bottom' : 'ep:caret-right'"
:size="14"
/>
<span class="flex-1">群聊</span>
<span class="text-sm text-[var(--ant-color-text-secondary)]">{{ filtered.length }}</span>
<span class="text-sm text-[var(--ant-color-text-secondary)]">{{
filtered.length
}}</span>
</div>
<div v-show="expanded">
<GroupItem

View File

@@ -1,58 +1,73 @@
<script lang="ts" setup>
import type { FriendLite, FriendRequest, Group, GroupLite, User } from '../../types'
import type {
FriendLite,
FriendRequest,
Group,
GroupLite,
User,
} from '../../types';
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { confirm } from '@vben/common-ui'
import { IconifyIcon as Icon } from '@vben/icons'
import { confirm } from '@vben/common-ui';
import { IconifyIcon as Icon } from '@vben/icons';
import { Input, message } from 'ant-design-vue'
import { Input, message } from 'ant-design-vue';
import { ImConversationType } from '../../../utils/constants'
import { StorageKeys } from '../../../utils/db'
import { getFriendDisplayName, getGroupDisplayName, isGroupQuit } from '../../../utils/user'
import { ResizableAside } from '../../components'
import { UserInfo } from '../../components/user'
import { useConversationStore } from '../../store/conversationStore'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import FriendList from './friend-list.vue'
import FriendRequestDetail from './friend-request-detail.vue'
import FriendRequestList from './friend-request-list.vue'
import GroupDetail from './group-detail.vue'
import GroupList from './group-list.vue'
import { ImConversationType } from '../../../utils/constants';
import { StorageKeys } from '../../../utils/db';
import {
getFriendDisplayName,
getGroupDisplayName,
isGroupQuit,
} from '../../../utils/user';
import { ResizableAside } from '../../components';
import { UserInfo } from '../../components/user';
import { useConversationStore } from '../../store/conversationStore';
import { useFriendStore } from '../../store/friendStore';
import { useGroupStore } from '../../store/groupStore';
import FriendList from './friend-list.vue';
import FriendRequestDetail from './friend-request-detail.vue';
import FriendRequestList from './friend-request-list.vue';
import GroupDetail from './group-detail.vue';
import GroupList from './group-list.vue';
defineOptions({ name: 'ImContactPage' })
defineOptions({ name: 'ImContactPage' });
const router = useRouter()
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const router = useRouter();
const conversationStore = useConversationStore();
const friendStore = useFriendStore();
const groupStore = useGroupStore();
/** 用 type 判别选中是好友 / 群聊 / 好友申请 */
type Selection =
| { friend: FriendLite; type: 'friend'; }
| { group: GroupLite; type: 'group'; }
| { request: FriendRequest; type: 'request'; }
| { friend: FriendLite; type: 'friend' }
| { group: GroupLite; type: 'group' }
| { request: FriendRequest; type: 'request' };
const selection = ref<null | Selection>(null)
const keyword = ref('')
const selection = ref<null | Selection>(null);
const keyword = ref('');
/** 选中申请详情:详情用 store 里的最新副本(同意 / 拒绝后状态会变) */
const currentRequest = computed<FriendRequest>(() => {
const req = selection.value?.type === 'request' ? selection.value.request : null
const req =
selection.value?.type === 'request' ? selection.value.request : null;
if (!req) {
return {} as FriendRequest
return {} as FriendRequest;
}
return friendStore.getFriendRequest(req.id) || req
})
return friendStore.getFriendRequest(req.id) || req;
});
/** 我相关的申请列表(用 friendStore 里的实时副本,便于通知到达后自动刷新) */
const friendRequests = computed<FriendRequest[]>(() => friendStore.friendRequests)
const friendRequests = computed<FriendRequest[]>(
() => friendStore.friendRequests,
);
/** 好友列表的展示快照:附带后端算好的拼音,给 FriendList 做字母分桶 / 拼音搜索 */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
const friends = computed<FriendLite[]>(
() => friendStore.getActiveFriendLiteList,
);
const groups = computed<GroupLite[]>(() =>
// 通讯录只展示当前仍在群的;已退群历史群只留在 store 里供消息展示群名 / 头像,不进通讯录
@@ -64,9 +79,9 @@ const groups = computed<GroupLite[]>(() =>
showGroupName: getGroupDisplayName(group), // 优先用群备注 groupRemark没设置时回落到原群名避免点"进入群聊"时把已同步的备注会话名刷回原名
showImage: group.avatar,
showImageThumb: group.avatar,
memberCount: group.memberCount
}))
)
memberCount: group.memberCount,
})),
);
/**
* store 列表变化时同步 selection 持的对象副本:对端推送 / 跨端动作改 store 后,右侧详情能跟上:
@@ -76,146 +91,153 @@ const groups = computed<GroupLite[]>(() =>
watch(
friends,
(list) => {
const selected = selection.value
const selected = selection.value;
if (selected?.type !== 'friend') {
return
return;
}
const fresh = list.find((friend) => friend.id === selected.friend.id)
const fresh = list.find((friend) => friend.id === selected.friend.id);
if (!fresh) {
selection.value = null
selection.value = null;
} else if (fresh !== selected.friend) {
selection.value = { type: 'friend', friend: fresh }
selection.value = { type: 'friend', friend: fresh };
}
},
{ deep: true }
)
{ deep: true },
);
watch(
groups,
(list) => {
const selected = selection.value
const selected = selection.value;
if (selected?.type !== 'group') {
return
return;
}
const fresh = list.find((group) => group.id === selected.group.id)
const fresh = list.find((group) => group.id === selected.group.id);
if (!fresh) {
selection.value = null
selection.value = null;
} else if (fresh !== selected.group) {
selection.value = { type: 'group', group: fresh }
selection.value = { type: 'group', group: fresh };
}
},
{ deep: true }
)
{ deep: true },
);
watch(
friendRequests,
(list) => {
const selected = selection.value
const selected = selection.value;
if (selected?.type !== 'request') {
return
return;
}
const fresh = list.find((request) => request.id === selected.request.id)
const fresh = list.find((request) => request.id === selected.request.id);
if (!fresh) {
selection.value = null
selection.value = null;
} else if (fresh !== selected.request) {
selection.value = { type: 'request', request: fresh }
selection.value = { type: 'request', request: fresh };
}
},
{ deep: true }
)
{ deep: true },
);
const friendUser = computed<null | User>(() => {
if (selection.value?.type !== 'friend') {
return null
return null;
}
const friend = selection.value.friend
const friend = selection.value.friend;
return {
id: friend.id,
nickname: friend.nickname,
avatar: friend.avatar
}
})
avatar: friend.avatar,
};
});
onMounted(async () => {
await Promise.all([
friendStore.fetchFriendList(),
friendStore.fetchFriendRequestList(),
groupStore.fetchGroupList()
])
})
groupStore.fetchGroupList(),
]);
});
/** 选中好友 → 切到好友详情 */
function handleSelectFriend(friend: FriendLite) {
selection.value = { type: 'friend', friend }
selection.value = { type: 'friend', friend };
}
/** 选中群聊 → 切到群详情 */
function handleSelectGroup(group: GroupLite) {
selection.value = { type: 'group', group }
selection.value = { type: 'group', group };
}
/** 选中好友申请 → 切到「新的朋友」详情 */
function handleSelectRequest(request: FriendRequest) {
selection.value = { type: 'request', request }
selection.value = { type: 'request', request };
}
/** 申请详情里点「发消息」:直接进与对端的私聊会话 */
function handleChatPeer(peerUserId: number) {
const friend = friendStore.getFriend(peerUserId)
const conversationName = friend ? getFriendDisplayName(friend) : String(peerUserId)
const friend = friendStore.getFriend(peerUserId);
const conversationName = friend
? getFriendDisplayName(friend)
: String(peerUserId);
conversationStore.openConversation(
peerUserId,
ImConversationType.PRIVATE,
conversationName,
friend?.avatar || '',
{ silent: !!friend?.silent }
)
router.push({ name: 'ImHomeConversation' })
{ silent: !!friend?.silent },
);
router.push({ name: 'ImHomeConversation' });
}
/** 进入与该好友的私聊会话 */
function handleChatFriend(friend: FriendLite) {
// 从 friendStore 同步备注 + 免打扰,避免新建会话用过期数据
const entry = friendStore.getFriend(friend.id)
const conversationName = entry ? getFriendDisplayName(entry) : friend.nickname
const entry = friendStore.getFriend(friend.id);
const conversationName = entry
? getFriendDisplayName(entry)
: friend.nickname;
conversationStore.openConversation(
friend.id,
ImConversationType.PRIVATE,
conversationName,
friend.avatar || '',
{ silent: !!entry?.silent }
)
router.push({ name: 'ImHomeConversation' })
{ silent: !!entry?.silent },
);
router.push({ name: 'ImHomeConversation' });
}
/** 进入该群的群聊会话 */
function handleChatGroup(group: GroupLite) {
const entry = groupStore.getGroup(group.id)
const entry = groupStore.getGroup(group.id);
conversationStore.openConversation(
group.id,
ImConversationType.GROUP,
group.showGroupName || group.name || '',
group.showImage || group.showImageThumb || '',
{ silent: !!entry?.silent }
)
router.push({ name: 'ImHomeConversation' })
{ silent: !!entry?.silent },
);
router.push({ name: 'ImHomeConversation' });
}
/** 删除好友:二次确认 → store 落库 → 清空当前选中 */
async function handleDeleteFriend(friend: FriendLite) {
try {
await confirm(`确定删除好友「${friend.nickname}」吗?`, '删除联系人')
await confirm(`确定删除好友「${friend.nickname}」吗?`, '删除联系人');
// friendStore.deleteFriend 内部已经级联清理对应私聊会话
await friendStore.deleteFriend(friend.id)
if (selection.value?.type === 'friend' && selection.value.friend.id === friend.id) {
selection.value = null
await friendStore.deleteFriend(friend.id);
if (
selection.value?.type === 'friend' &&
selection.value.friend.id === friend.id
) {
selection.value = null;
}
message.success('已删除好友')
message.success('已删除好友');
} catch {}
}
/** 备注已保存UserInfo 内部已经走完 friendStore 落库 + 提示,本侧只负责同步 selection 持的旧 FriendLite 副本 */
function onRemarkSaved(displayName: string) {
if (selection.value?.type === 'friend') {
selection.value.friend.displayName = displayName
selection.value.friend.displayName = displayName;
}
}
</script>
@@ -228,12 +250,20 @@ function onRemarkSaved(displayName: string) {
- 本页仅做选中分发 + 数据源转换 + 跨组件事件落 store
-->
<div class="flex flex-1 h-full min-w-0 bg-[var(--ant-color-bg-container)]">
<ResizableAside :default-width="260" :storage-key="StorageKeys.localStorage.asideWidth">
<ResizableAside
:default-width="260"
:storage-key="StorageKeys.localStorage.asideWidth"
>
<!-- 顶部仅搜索框h-14 与消息 Tab 顶部对齐避免切换时搜索框上下抖动 -->
<div
class="flex flex-shrink-0 items-center h-14 px-4 border-b border-b-solid border-[var(--im-border-color-lighter)]"
>
<Input v-model:value="keyword" placeholder="搜索" allow-clear class="flex-1">
<Input
v-model:value="keyword"
placeholder="搜索"
allow-clear
class="flex-1"
>
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
@@ -245,19 +275,25 @@ function onRemarkSaved(displayName: string) {
<div class="flex-1 overflow-y-auto">
<FriendRequestList
:requests="friendRequests"
:active-id="selection?.type === 'request' ? selection.request.id : undefined"
:active-id="
selection?.type === 'request' ? selection.request.id : undefined
"
@select="handleSelectRequest"
/>
<GroupList
:groups="groups"
:keyword="keyword"
:active-id="selection?.type === 'group' ? selection.group.id : undefined"
: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"
:active-id="
selection?.type === 'friend' ? selection.friend.id : undefined
"
@select="handleSelectFriend"
@chat="handleChatFriend"
@delete="handleDeleteFriend"
@@ -280,7 +316,10 @@ function onRemarkSaved(displayName: string) {
<span class="text-sm">在左侧选择好友或群聊查看详情</span>
</div>
<!-- 好友详情 -->
<div v-else-if="selection.type === 'friend'" class="flex justify-center pt-12 px-6">
<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"

View File

@@ -1,118 +1,131 @@
<script lang="ts" setup>
import type { ImFacePackApi } from '#/api/im/face/pack'
import type { ImFaceUserItemApi } from '#/api/im/face/userItem'
import type { ImFacePackApi } from '#/api/im/face/pack';
import type { ImFaceUserItemApi } from '#/api/im/face/userItem';
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import { IconifyIcon as Icon } from '@vben/icons'
import { confirm } from '@vben/common-ui';
import { IconifyIcon as Icon } from '@vben/icons';
import { message, Tooltip } from 'ant-design-vue'
import { message, Tooltip } from 'ant-design-vue';
import { uploadFile } from '#/api/infra/file'
import { useFaceStore } from '#/views/im/home/store/faceStore'
import { IM_EMOJI_LIST } from '#/views/im/utils/emoji'
import { probeImageSize } from '#/views/im/utils/image'
import { uploadFile } from '#/api/infra/file';
import { useFaceStore } from '#/views/im/home/store/faceStore';
import { IM_EMOJI_LIST } from '#/views/im/utils/emoji';
import { probeImageSize } from '#/views/im/utils/image';
defineOptions({ name: 'ImFacePicker' })
defineOptions({ name: 'ImFacePicker' });
const props = withDefaults(
defineProps<{
/** fullemoji + 个人表情 + 系统包聊天主输入用emoji仅 emoji留言 / 评论场景) */
mode?: FacePickerMode
visible: boolean
mode?: FacePickerMode;
visible: boolean;
}>(),
{ mode: 'full' }
)
{ mode: 'full' },
);
const emit = defineEmits<{
/** 选中 Unicode emoji如 😀),调用方应插入到输入框走 TEXT 通道 */
selectEmoji: [emoji: string]
selectEmoji: [emoji: string];
/** 选中表情贴图,调用方应走 FACE 消息发送 */
selectFace: [face: { height: number; name?: string; url: string; width: number; }]
'update:visible': [value: boolean]
}>()
selectFace: [
face: { height: number; name?: string; url: string; width: number },
];
'update:visible': [value: boolean];
}>();
/** 面板模式 */
type FacePickerMode = 'emoji' | 'full'
type FacePickerMode = 'emoji' | 'full';
const rootRef = useTemplateRef<HTMLDivElement>('rootRef')
const uploadInputRef = useTemplateRef<HTMLInputElement>('uploadInputRef')
const rootRef = useTemplateRef<HTMLDivElement>('rootRef');
const uploadInputRef = useTemplateRef<HTMLInputElement>('uploadInputRef');
const faceStore = useFaceStore()
const faceStore = useFaceStore();
// tab 标识常量pack:N 类用 packTabKey() 拼出,避免散落字符串字面量
const FACE_TAB = {
EMOJI: 'emoji',
MINE: 'mine'
} as const
const packTabKey = (packId: number) => `pack:${packId}`
MINE: 'mine',
} as const;
const packTabKey = (packId: number) => `pack:${packId}`;
const activeTab = ref<string>(FACE_TAB.EMOJI) // 当前激活的 tab
const activeTab = ref<string>(FACE_TAB.EMOJI); // 当前激活的 tab
/** 是否完整模式(含个人 / 系统包 tab */
const isFullMode = computed(() => props.mode === 'full')
const isFullMode = computed(() => props.mode === 'full');
const uploading = ref(false) // 上传中标记,避免连续点击触发并发上传
const uploading = ref(false); // 上传中标记,避免连续点击触发并发上传
/** 选 emoji 字符:插到输入框;选完不关面板,方便用户连发多个 */
function handleSelectEmoji(emoji: string) {
emit('selectEmoji', emoji)
emit('selectEmoji', emoji);
}
/** 选个人表情:直接发;点完关面板,对齐微信 */
function handleSelectFaceUserItem(item: ImFaceUserItemApi.FaceUserItem) {
emit('selectFace', { url: item.url, width: item.width, height: item.height, name: item.name })
emit('update:visible', false)
emit('selectFace', {
url: item.url,
width: item.width,
height: item.height,
name: item.name,
});
emit('update:visible', false);
}
/** 选系统表情包内表情:直接发;点完关面板 */
function handleSelectPackItem(item: ImFacePackApi.FacePackUserItem) {
emit('selectFace', { url: item.url, width: item.width, height: item.height, name: item.name })
emit('update:visible', false)
emit('selectFace', {
url: item.url,
width: item.width,
height: item.height,
name: item.name,
});
emit('update:visible', false);
}
/** 长按 / 右键删除个人表情 */
async function handleDeleteUserItem(item: ImFaceUserItemApi.FaceUserItem) {
if (!confirm('确认删除该表情?')) {
return
return;
}
await faceStore.removeFaceUserItem(item.id)
await faceStore.removeFaceUserItem(item.id);
}
/** 点 + 触发文件选择 */
function onUploadClick() {
uploadInputRef.value?.click()
uploadInputRef.value?.click();
}
/** 文件选完即上传,成功后写入 faceStore 个人表情列表 */
async function onUploadPicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) {
return
return;
}
uploading.value = true
let size: { height: number; width: number; }
uploading.value = true;
let size: { height: number; width: number };
try {
size = await probeImageSize(file)
size = await probeImageSize(file);
} catch (error) {
console.warn('[IM] 解析个人表情失败', error)
message.error('图片解析失败')
uploading.value = false
return
console.warn('[IM] 解析个人表情失败', error);
message.error('图片解析失败');
uploading.value = false;
return;
}
try {
const url = await uploadFile({ file })
const url = await uploadFile({ file });
if (!url) {
message.error('上传失败')
return
message.error('上传失败');
return;
}
const payload = { url, width: size.width, height: size.height }
await faceStore.addFaceUserItem(payload)
const payload = { url, width: size.width, height: size.height };
await faceStore.addFaceUserItem(payload);
} finally {
uploading.value = false
uploading.value = false;
}
}
@@ -121,32 +134,32 @@ watch(
() => props.visible,
(visible) => {
if (visible) {
document.addEventListener('click', handleDocumentClick)
document.addEventListener('click', handleDocumentClick);
if (isFullMode.value) {
// 系统包通常已被 home onMounted 预拉过ensureXxx 内部 promise 缓存避免重复请求
void faceStore.ensureFacePackList()
void faceStore.ensureFaceUserItemList()
void faceStore.ensureFacePackList();
void faceStore.ensureFaceUserItemList();
}
} else {
document.removeEventListener('click', handleDocumentClick)
document.removeEventListener('click', handleDocumentClick);
}
},
{ immediate: true }
)
{ immediate: true },
);
/** 点击面板外部关闭 */
function handleDocumentClick(e: MouseEvent) {
if (!props.visible || !rootRef.value) {
return
return;
}
if (!rootRef.value.contains(e.target as Node)) {
emit('update:visible', false)
emit('update:visible', false);
}
}
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
})
document.removeEventListener('click', handleDocumentClick);
});
</script>
<template>
@@ -166,7 +179,10 @@ onUnmounted(() => {
<!-- 主内容区高度固定 + tab v-show 切换避免每次切 tab 重建 scrollbar 造成滚动位置丢失 -->
<div class="relative h-[300px] overflow-hidden">
<!-- emoji 网格 -->
<div v-show="activeTab === FACE_TAB.EMOJI" style="height: 300px; overflow-y: auto">
<div
v-show="activeTab === FACE_TAB.EMOJI"
style="height: 300px; overflow-y: auto"
>
<div class="grid grid-cols-10 gap-0.5 p-2">
<button
v-for="emoji in IM_EMOJI_LIST"
@@ -181,7 +197,11 @@ onUnmounted(() => {
</div>
<!-- 个人表情5 列方格无名字标签末尾+上传 -->
<div v-if="isFullMode" v-show="activeTab === FACE_TAB.MINE" style="height: 300px; overflow-y: auto">
<div
v-if="isFullMode"
v-show="activeTab === FACE_TAB.MINE"
style="height: 300px; overflow-y: auto"
>
<div class="grid grid-cols-5 gap-2 p-3">
<!-- 上传入口固定放第一格dashed border 与表情格子区分视觉语义对齐 el-upload 观感 -->
<button
@@ -192,7 +212,11 @@ onUnmounted(() => {
@click="onUploadClick"
>
<Icon
:icon="uploading ? 'eos-icons:bubble-loading' : 'ant-design:plus-outlined'"
:icon="
uploading
? 'eos-icons:bubble-loading'
: 'ant-design:plus-outlined'
"
:size="22"
/>
</button>
@@ -344,10 +368,10 @@ onUnmounted(() => {
top: calc(100% - 1px);
left: 10px;
content: '';
border-color: var(--ant-color-bg-container) transparent transparent transparent;
border-color: var(--ant-color-bg-container) transparent transparent
transparent;
border-style: solid;
border-width: 6px 6px 0;
filter: drop-shadow(0 2px 2px rgb(0 0 0 / 8%));
}
</style>

View File

@@ -1,41 +1,46 @@
<script lang="ts" setup>
import { computed, ref, useTemplateRef, watch } from 'vue'
import type { GroupMemberLite } from '../../../../components/group';
import { CommonStatusEnum } from '@vben/constants'
import { IconifyIcon as Icon } from '@vben/icons'
import { computed, ref, useTemplateRef, watch } from 'vue';
import { getCurrentUserId } from '#/views/im/utils/auth'
import { IM_AT_ALL_NICKNAME, IM_AT_ALL_USER_ID } from '#/views/im/utils/constants'
import { CommonStatusEnum } from '@vben/constants';
import { IconifyIcon as Icon } from '@vben/icons';
import { GroupMember, type GroupMemberLite } from '../../../../components/group'
import { getCurrentUserId } from '#/views/im/utils/auth';
import {
IM_AT_ALL_NICKNAME,
IM_AT_ALL_USER_ID,
} from '#/views/im/utils/constants';
defineOptions({ name: 'ImMentionPicker' })
import { GroupMember } from '../../../../components/group';
defineOptions({ name: 'ImMentionPicker' });
const props = withDefaults(
defineProps<{
canAtAll?: boolean // 当前用户是否能 @ 全员(群主 / 管理员),父组件按角色算好传入
members: GroupMemberLite[] // 当前群的成员列表
canAtAll?: boolean; // 当前用户是否能 @ 全员(群主 / 管理员),父组件按角色算好传入
members: GroupMemberLite[]; // 当前群的成员列表
// 浮层位置x 横坐标 + top / bottom 二选一bottom 锚定时 picker 下沿贴 @ 上方)
position?: { bottom?: number; top?: number; x: number; }
searchText?: string // @ 后输入的过滤文本
visible: boolean // 是否显示
position?: { bottom?: number; top?: number; x: number };
searchText?: string; // @ 后输入的过滤文本
visible: boolean; // 是否显示
}>(),
{
searchText: '',
position: () => ({ x: 0, bottom: 0 })
}
)
position: () => ({ x: 0, bottom: 0 }),
},
);
const emit = defineEmits<{
select: [member: GroupMemberLite]
'update:visible': [value: boolean]
}>()
select: [member: GroupMemberLite];
'update:visible': [value: boolean];
}>();
const scrollRef = useTemplateRef<HTMLDivElement>('scrollRef')
const activeIdx = ref(0)
const scrollRef = useTemplateRef<HTMLDivElement>('scrollRef');
const activeIdx = ref(0);
/** 当前登录用户 id成员列表过滤掉自己 */
const selfUserId = computed(() => getCurrentUserId())
const selfUserId = computed(() => getCurrentUserId());
/**
* 虚拟"所有人"项:群主 / 管理员canAtAll=true+ 关键字命中"所有人"前缀时存在
@@ -45,18 +50,18 @@ const selfUserId = computed(() => getCurrentUserId())
*/
const allItem = computed<GroupMemberLite | null>(() => {
if (!props.canAtAll) {
return null
return null;
}
if (!IM_AT_ALL_NICKNAME.startsWith(props.searchText)) {
return null
return null;
}
// @所有人 是个伪成员nickname 给 IM_AT_ALL_NICKNAME 让头像 :name 行为对齐普通成员
return {
userId: IM_AT_ALL_USER_ID,
showName: IM_AT_ALL_NICKNAME,
nickname: IM_AT_ALL_NICKNAME
}
})
nickname: IM_AT_ALL_NICKNAME,
};
});
/** 真成员:过滤自己 / 退群 / 不匹配关键字;不截断数量,浮层 max-height + el-scrollbar 撑滚动 */
const memberItems = computed<GroupMemberLite[]>(() =>
@@ -65,83 +70,89 @@ const memberItems = computed<GroupMemberLite[]>(() =>
member.userId !== selfUserId.value &&
member.status !== CommonStatusEnum.DISABLE &&
!!member.showName &&
member.showName.startsWith(props.searchText)
)
)
member.showName.startsWith(props.searchText),
),
);
/** 键盘导航与 pickActive 走的扁平列表allItem 在前、memberItems 在后 */
const showMembers = computed<GroupMemberLite[]>(() => {
return allItem.value ? [allItem.value, ...memberItems.value] : memberItems.value
})
return allItem.value
? [allItem.value, ...memberItems.value]
: memberItems.value;
});
/** 候选列表变化(用户输入关键词在过滤)→ 重置高亮到首项 + 滚回顶 */
watch(showMembers, (list) => {
activeIdx.value = list.length > 0 ? 0 : -1
scrollToTop()
})
activeIdx.value = list.length > 0 ? 0 : -1;
scrollToTop();
});
/** 浮层重新打开 → 重置高亮 + 滚回顶(避免上次的中间状态残留) */
watch(
() => props.visible,
(v) => {
if (v) {
activeIdx.value = showMembers.value.length > 0 ? 0 : -1
scrollToTop()
activeIdx.value = showMembers.value.length > 0 ? 0 : -1;
scrollToTop();
}
}
)
},
);
/** el-scrollbar 没暴露 scrollTo直接拿内部 wrap 调 scrollTop */
function scrollToTop() {
const scrollWrap = scrollRef.value
const scrollWrap = scrollRef.value;
if (scrollWrap) {
scrollWrap.scrollTop = 0
scrollWrap.scrollTop = 0;
}
}
/** 键盘上下导航时把高亮项滚到可视区:超出底边下推、超出顶边上拉,否则不动 */
function scrollToActive() {
const scrollWrap = scrollRef.value
const scrollWrap = scrollRef.value;
if (!scrollWrap) {
return
return;
}
const itemHeight = 40
const activeOffsetTop = activeIdx.value * itemHeight
if (activeOffsetTop + itemHeight > scrollWrap.scrollTop + scrollWrap.clientHeight) {
scrollWrap.scrollTop = activeOffsetTop + itemHeight - scrollWrap.clientHeight
const itemHeight = 40;
const activeOffsetTop = activeIdx.value * itemHeight;
if (
activeOffsetTop + itemHeight >
scrollWrap.scrollTop + scrollWrap.clientHeight
) {
scrollWrap.scrollTop =
activeOffsetTop + itemHeight - scrollWrap.clientHeight;
} else if (activeOffsetTop < scrollWrap.scrollTop) {
scrollWrap.scrollTop = activeOffsetTop
scrollWrap.scrollTop = activeOffsetTop;
}
}
/** 选中一项emit 给 MessageInput 落 token同时关掉浮层 */
function handleSelect(member: GroupMemberLite) {
emit('select', member)
emit('update:visible', false)
emit('select', member);
emit('update:visible', false);
}
// 暴露给父组件的键盘导航方法
defineExpose({
moveUp() {
if (activeIdx.value > 0) {
activeIdx.value--
scrollToActive()
activeIdx.value--;
scrollToActive();
}
},
moveDown() {
if (activeIdx.value < showMembers.value.length - 1) {
activeIdx.value++
scrollToActive()
activeIdx.value++;
scrollToActive();
}
},
pickActive() {
const member = showMembers.value[activeIdx.value]
const member = showMembers.value[activeIdx.value];
if (activeIdx.value >= 0 && member) {
handleSelect(member)
handleSelect(member);
}
},
hasCandidates: () => showMembers.value.length > 0
})
hasCandidates: () => showMembers.value.length > 0,
});
</script>
<template>
@@ -155,9 +166,9 @@ defineExpose({
v-show="visible && showMembers.length > 0"
class="message-input__mention-picker !fixed z-100 w-50 rounded-md bg-[var(--ant-color-bg-container)] shadow-[0_4px_16px_rgba(0,0,0,0.12)]"
:style="{
left: `${position.x }px`,
top: position.top != null ? `${position.top }px` : 'auto',
bottom: position.bottom != null ? `${position.bottom }px` : 'auto'
left: `${position.x}px`,
top: position.top != null ? `${position.top}px` : 'auto',
bottom: position.bottom != null ? `${position.bottom}px` : 'auto',
}"
>
<div ref="scrollRef" style="max-height: 300px; overflow-y: auto">
@@ -165,7 +176,9 @@ defineExpose({
<div
v-if="allItem"
class="flex items-center gap-2.5 px-[5px] h-10 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
:class="{ 'bg-[#e1eaf7] dark:bg-[var(--ant-color-primary-bg)]': activeIdx === 0 }"
:class="{
'bg-[#e1eaf7] dark:bg-[var(--ant-color-primary-bg)]': activeIdx === 0,
}"
@click.stop="handleSelect(allItem)"
>
<div
@@ -173,7 +186,9 @@ defineExpose({
>
<Icon icon="ep:user-filled" :size="18" />
</div>
<span class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]">
<span
class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
>
{{ allItem.showName }}
</span>
</div>

View File

@@ -1,107 +1,114 @@
<script lang="ts" setup>
import type { Message } from '#/views/im/home/types'
import type { Message } from '#/views/im/home/types';
import { computed, inject } from 'vue'
import { computed, inject } from 'vue';
import { confirm } from '@vben/common-ui'
import { IconifyIcon as Icon } from '@vben/icons'
import { confirm } from '@vben/common-ui';
import { IconifyIcon as Icon } from '@vben/icons';
import { useMessageMultiSelect } from '#/views/im/home/composables/useMessageMultiSelect'
import { useConversationStore } from '#/views/im/home/store/conversationStore'
import { useMessageStore } from '#/views/im/home/store/messageStore'
import { ImForwardMode, isNormalMessage } from '#/views/im/utils/constants'
import { getClientConversationId } from '#/views/im/utils/db'
import { useMessageMultiSelect } from '#/views/im/home/composables/useMessageMultiSelect';
import { useConversationStore } from '#/views/im/home/store/conversationStore';
import { useMessageStore } from '#/views/im/home/store/messageStore';
import { ImForwardMode, isNormalMessage } from '#/views/im/utils/constants';
import { getClientConversationId } from '#/views/im/utils/db';
import { IM_FORWARD_DIALOG_KEY } from '../message/forward/keys'
import { IM_FORWARD_DIALOG_KEY } from '../message/forward/keys';
defineOptions({ name: 'ImMessageMultiSelectBar' })
defineOptions({ name: 'ImMessageMultiSelectBar' });
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY)
const multiSelect = useMessageMultiSelect()
const conversationStore = useConversationStore();
const messageStore = useMessageStore();
const openForwardDialog = inject(IM_FORWARD_DIALOG_KEY);
const multiSelect = useMessageMultiSelect();
/** 选中条数 */
const selectedCount = computed(() => multiSelect.state.selectedClientMessageIds.length)
const selectedCount = computed(
() => multiSelect.state.selectedClientMessageIds.length,
);
/** 当前会话内已选消息 */
function getSelectedMessages(): Message[] {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation) {
return []
return [];
}
const ids = multiSelect.selectedIdSet.value
const ids = multiSelect.selectedIdSet.value;
return messageStore
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
.filter((message) => ids.has(message.clientMessageId) && isNormalMessage(message.type))
.getMessages(
getClientConversationId(conversation.type, conversation.targetId),
)
.filter(
(message) =>
ids.has(message.clientMessageId) && isNormalMessage(message.type),
);
}
/** 逐条转发:开 ForwardDialog 单条模式 */
function handleForwardOneByOne() {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation) {
return
return;
}
const messages = getSelectedMessages()
const messages = getSelectedMessages();
if (messages.length === 0) {
return
return;
}
openForwardDialog?.({
mode: ImForwardMode.SINGLE,
messages,
sourceConversation: conversation
})
sourceConversation: conversation,
});
}
/** 合并转发:开 ForwardDialog 合并模式 */
function handleForwardMerged() {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation) {
return
return;
}
const messages = getSelectedMessages()
const messages = getSelectedMessages();
if (messages.length === 0) {
return
return;
}
openForwardDialog?.({
mode: ImForwardMode.MERGE,
messages,
sourceConversation: conversation
})
sourceConversation: conversation,
});
}
/** 删除:弹确认框 → 本地批量移除(不同步后端,对齐微信「删除」语义) */
async function handleDelete() {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation) {
return
return;
}
const messages = getSelectedMessages()
const messages = getSelectedMessages();
if (messages.length === 0) {
return
return;
}
try {
await confirm(`确认删除选中的 ${messages.length} 条消息?`, {
cancelText: '取消',
confirmText: '确定',
icon: 'warning',
title: '删除确认'
})
title: '删除确认',
});
} catch {
return
return;
}
for (const m of messages) {
messageStore.removeMessage(conversation.type, conversation.targetId, {
id: m.id,
clientMessageId: m.clientMessageId
})
clientMessageId: m.clientMessageId,
});
}
multiSelect.exit()
multiSelect.exit();
}
/** 取消多选 */
function handleCancel() {
multiSelect.exit()
multiSelect.exit();
}
</script>

View File

@@ -6,257 +6,276 @@ import {
onUnmounted,
ref,
useTemplateRef,
watch
} from 'vue'
watch,
} from 'vue';
import { Button, message } from 'ant-design-vue'
import { Button, message } from 'ant-design-vue';
import { formatSeconds } from '#/views/im/utils/time'
import { formatSeconds } from '#/views/im/utils/time';
defineOptions({ name: 'ImVoiceRecorder' })
defineOptions({ name: 'ImVoiceRecorder' });
const props = withDefaults(
defineProps<{
maxDuration?: number // 最长录制秒数
modelValue: boolean // 是否显示
maxDuration?: number; // 最长录制秒数
modelValue: boolean; // 是否显示
}>(),
{
maxDuration: 60
}
)
maxDuration: 60,
},
);
const emit = defineEmits<{
send: [payload: { blob: Blob; duration: number; extension: string; mimeType: string }] // 录制完成数据
'update:modelValue': [value: boolean]
}>()
send: [
payload: {
blob: Blob;
duration: number;
extension: string;
mimeType: string;
},
]; // 录制完成数据
'update:modelValue': [value: boolean];
}>();
const VOICE_MIME_TYPE_OPTIONS = [
{ extension: 'webm', mimeType: 'audio/webm;codecs=opus' },
{ extension: 'webm', mimeType: 'audio/webm' },
{ extension: 'm4a', mimeType: 'audio/mp4' },
{ extension: 'ogg', mimeType: 'audio/ogg;codecs=opus' },
{ extension: 'ogg', mimeType: 'audio/ogg' }
]
{ extension: 'ogg', mimeType: 'audio/ogg' },
];
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
set: (v) => emit('update:modelValue', v),
});
const rootRef = useTemplateRef<HTMLDivElement>('rootRef')
const rootRef = useTemplateRef<HTMLDivElement>('rootRef');
/** 录制状态 */
type Status = 'idle' | 'preview' | 'recording'
const status = ref<Status>('idle')
const duration = ref(0)
const previewUrl = ref('')
type Status = 'idle' | 'preview' | 'recording';
const status = ref<Status>('idle');
const duration = ref(0);
const previewUrl = ref('');
let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = []
let mediaStream: MediaStream | null = null
let timer: null | ReturnType<typeof setInterval> = null
let recordedBlob: Blob | null = null
let discarding = false
let recordingMimeType = ''
let recordingExtension = ''
let recordedMimeType = ''
let recordedExtension = ''
let mediaRecorder: MediaRecorder | null = null;
let audioChunks: Blob[] = [];
let mediaStream: MediaStream | null = null;
let timer: null | ReturnType<typeof setInterval> = null;
let recordedBlob: Blob | null = null;
let discarding = false;
let recordingMimeType = '';
let recordingExtension = '';
let recordedMimeType = '';
let recordedExtension = '';
/** 计时器文案 */
const timerText = computed(() => formatSeconds(duration.value))
const timerText = computed(() => formatSeconds(duration.value));
/** 监听面板显示状态 */
watch(visible, (v) => {
if (v) {
document.addEventListener('click', handleDocumentClick)
document.addEventListener('click', handleDocumentClick);
} else {
document.removeEventListener('click', handleDocumentClick)
resetAll()
document.removeEventListener('click', handleDocumentClick);
resetAll();
}
})
});
/** 处理外部点击 */
function handleDocumentClick(e: MouseEvent) {
if (!props.modelValue || !rootRef.value) {
return
return;
}
if (rootRef.value.contains(e.target as Node)) {
return
return;
}
if (status.value !== 'idle') {
return
return;
}
visible.value = false
visible.value = false;
}
/** 获取支持的录音格式 */
function getSupportedVoiceMimeType() {
if (typeof MediaRecorder === 'undefined' || !MediaRecorder.isTypeSupported) {
return undefined
return undefined;
}
return VOICE_MIME_TYPE_OPTIONS.find((item) => MediaRecorder.isTypeSupported(item.mimeType))
return VOICE_MIME_TYPE_OPTIONS.find((item) =>
MediaRecorder.isTypeSupported(item.mimeType),
);
}
/** 获取录音文件后缀 */
function getVoiceExtension(mimeType: string) {
const normalizedMimeType = mimeType.split(';')[0]
const normalizedMimeType = mimeType.split(';')[0];
if (normalizedMimeType === 'audio/mp4') {
return 'm4a'
return 'm4a';
}
if (normalizedMimeType === 'audio/ogg') {
return 'ogg'
return 'ogg';
}
return 'webm'
return 'webm';
}
/** 创建录音器 */
function createVoiceRecorder(stream: MediaStream) {
const supportedMimeType = getSupportedVoiceMimeType()
const supportedMimeType = getSupportedVoiceMimeType();
const recorder = supportedMimeType
? new MediaRecorder(stream, { mimeType: supportedMimeType.mimeType })
: new MediaRecorder(stream)
recordingMimeType = recorder.mimeType || supportedMimeType?.mimeType || ''
recordingExtension = supportedMimeType?.extension || (recordingMimeType ? getVoiceExtension(recordingMimeType) : '')
return recorder
: new MediaRecorder(stream);
recordingMimeType = recorder.mimeType || supportedMimeType?.mimeType || '';
recordingExtension =
supportedMimeType?.extension ||
(recordingMimeType ? getVoiceExtension(recordingMimeType) : '');
return recorder;
}
/** 开始录制 */
async function startRecord() {
if (typeof MediaRecorder === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
message.error('当前浏览器不支持录音(需要 HTTPS 或 localhost')
return
if (
typeof MediaRecorder === 'undefined' ||
!navigator.mediaDevices?.getUserMedia
) {
message.error('当前浏览器不支持录音(需要 HTTPS 或 localhost');
return;
}
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
message.error('无法获取麦克风权限')
return
message.error('无法获取麦克风权限');
return;
}
audioChunks = []
discarding = false
audioChunks = [];
discarding = false;
try {
mediaRecorder = createVoiceRecorder(mediaStream)
mediaRecorder = createVoiceRecorder(mediaStream);
} catch {
cleanupStream()
message.error('当前浏览器不支持录音格式')
return
cleanupStream();
message.error('当前浏览器不支持录音格式');
return;
}
mediaRecorder.addEventListener('dataavailable', (event: BlobEvent) => {
if (event.data.size > 0) {
audioChunks.push(event.data)
audioChunks.push(event.data);
}
})
});
mediaRecorder.addEventListener('stop', () => {
if (discarding) {
return
return;
}
recordedMimeType = recordingMimeType || mediaRecorder?.mimeType || audioChunks[0]?.type || 'audio/webm'
recordedExtension = recordingExtension || getVoiceExtension(recordedMimeType)
recordedBlob = new Blob(audioChunks, { type: recordedMimeType })
previewUrl.value = URL.createObjectURL(recordedBlob)
status.value = 'preview'
})
mediaRecorder.start()
status.value = 'recording'
duration.value = 0
recordedMimeType =
recordingMimeType ||
mediaRecorder?.mimeType ||
audioChunks[0]?.type ||
'audio/webm';
recordedExtension =
recordingExtension || getVoiceExtension(recordedMimeType);
recordedBlob = new Blob(audioChunks, { type: recordedMimeType });
previewUrl.value = URL.createObjectURL(recordedBlob);
status.value = 'preview';
});
mediaRecorder.start();
status.value = 'recording';
duration.value = 0;
timer = setInterval(() => {
duration.value++
duration.value++;
if (duration.value >= props.maxDuration) {
stopRecord()
stopRecord();
}
}, 1000)
}, 1000);
}
/** 停止录制 */
function stopRecord() {
if (duration.value < 1) {
message.warning('录音时间太短')
resetAll()
return
message.warning('录音时间太短');
resetAll();
return;
}
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
mediaRecorder.stop();
}
cleanupStream()
cleanupStream();
if (timer) {
clearInterval(timer)
timer = null
clearInterval(timer);
timer = null;
}
}
/** 重新录制 */
function restart() {
clearPreview()
duration.value = 0
status.value = 'idle'
clearPreview();
duration.value = 0;
status.value = 'idle';
}
/** 发送录音 */
function handleSend() {
if (!recordedBlob) {
return
return;
}
emit('send', {
blob: recordedBlob,
duration: duration.value,
extension: recordedExtension,
mimeType: recordedMimeType || recordedBlob.type
})
visible.value = false
mimeType: recordedMimeType || recordedBlob.type,
});
visible.value = false;
}
/** 取消录制 */
function handleCancel() {
visible.value = false
visible.value = false;
}
/** 重置录制资源 */
function resetAll() {
discarding = true
discarding = true;
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop()
mediaRecorder.stop();
}
cleanupStream()
cleanupStream();
if (timer) {
clearInterval(timer)
timer = null
clearInterval(timer);
timer = null;
}
audioChunks = []
duration.value = 0
status.value = 'idle'
recordingMimeType = ''
recordingExtension = ''
recordedMimeType = ''
recordedExtension = ''
clearPreview()
audioChunks = [];
duration.value = 0;
status.value = 'idle';
recordingMimeType = '';
recordingExtension = '';
recordedMimeType = '';
recordedExtension = '';
clearPreview();
}
/** 释放预览音频 */
function clearPreview() {
recordedBlob = null
recordedBlob = null;
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
URL.revokeObjectURL(previewUrl.value);
previewUrl.value = '';
}
}
/** 关闭麦克风采集 */
function cleanupStream() {
mediaStream?.getTracks().forEach((t) => t.stop())
mediaStream = null
mediaStream?.getTracks().forEach((t) => t.stop());
mediaStream = null;
}
onMounted(() => {
if (props.modelValue) {
document.addEventListener('click', handleDocumentClick)
document.addEventListener('click', handleDocumentClick);
}
})
});
onBeforeUnmount(resetAll)
onBeforeUnmount(resetAll);
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
})
document.removeEventListener('click', handleDocumentClick);
});
</script>
<template>
@@ -269,14 +288,18 @@ onUnmounted(() => {
>
<div class="flex flex-col items-center gap-3">
<!-- 录制时长 -->
<div class="text-[28px] font-medium tabular-nums text-[var(--ant-color-text)]">
<div
class="text-[28px] font-medium tabular-nums text-[var(--ant-color-text)]"
>
{{ timerText }}
</div>
<!-- 状态文案 -->
<div class="text-13px text-[var(--ant-color-text-secondary)]">
<span v-if="status === 'idle'">点击下方按钮开始录制</span>
<span v-else-if="status === 'recording'">录制中最长 {{ maxDuration }} </span>
<span v-else-if="status === 'recording'">
录制中最长 {{ maxDuration }}
</span>
<span v-else>录制完成可试听后发送</span>
</div>
@@ -284,7 +307,9 @@ onUnmounted(() => {
<div
v-if="status !== 'preview'"
class="w-12 h-12 rounded-full bg-[var(--ant-color-border)]"
:class="{ 'im-voice-recorder__pulse bg-[#f56c6c]': status === 'recording' }"
:class="{
'im-voice-recorder__pulse bg-[#f56c6c]': status === 'recording',
}"
></div>
<audio v-else :src="previewUrl" controls class="w-full"></audio>
</div>
@@ -293,11 +318,15 @@ onUnmounted(() => {
<div class="flex justify-end gap-2 mt-3">
<template v-if="status === 'idle'">
<Button size="small" @click="handleCancel">取消</Button>
<Button size="small" type="primary" @click="startRecord">开始录制</Button>
<Button size="small" type="primary" @click="startRecord">
开始录制
</Button>
</template>
<template v-else-if="status === 'recording'">
<Button size="small" @click="handleCancel">取消</Button>
<Button size="small" type="primary" @click="stopRecord">停止录制</Button>
<Button size="small" type="primary" @click="stopRecord">
停止录制
</Button>
</template>
<template v-else>
<Button size="small" @click="handleCancel">取消</Button>
@@ -315,7 +344,8 @@ onUnmounted(() => {
top: calc(100% - 1px);
left: 110px;
content: '';
border-color: var(--ant-color-bg-container) transparent transparent transparent;
border-color: var(--ant-color-bg-container) transparent transparent
transparent;
border-style: solid;
border-width: 6px 6px 0;
filter: drop-shadow(0 2px 2px rgb(0 0 0 / 8%));

View File

@@ -1,24 +1,25 @@
import type { InjectionKey } from 'vue'
import type { InjectionKey } from 'vue';
import type { Conversation, Message } from '#/views/im/home/types'
import type { ImForwardModeValue } from '#/views/im/utils/constants'
import type { Conversation, Message } from '#/views/im/home/types';
import type { ImForwardModeValue } from '#/views/im/utils/constants';
/** 打开转发弹窗 */
export type OpenForwardDialog = (opts: {
messages: Message[]
mode: ImForwardModeValue
sourceConversation: Conversation
}) => void
messages: Message[];
mode: ImForwardModeValue;
sourceConversation: Conversation;
}) => void;
/** 打开合并消息详情弹窗 */
export type OpenMergeDetailDialog = (content: string) => void
export type OpenMergeDetailDialog = (content: string) => void;
/** 重拨 RTC 通话;点私聊 RTC_CALL_END 气泡触发 */
export type RtcRedial = (mediaType: number) => void
export type RtcRedial = (mediaType: number) => void;
/** MessagePanel 通过 provide 暴露给子树 */
export const IM_FORWARD_DIALOG_KEY: InjectionKey<OpenForwardDialog> = Symbol('IM_FORWARD_DIALOG')
export const IM_MERGE_DETAIL_DIALOG_KEY: InjectionKey<OpenMergeDetailDialog> = Symbol(
'IM_MERGE_DETAIL_DIALOG'
)
export const IM_RTC_REDIAL_KEY: InjectionKey<RtcRedial> = Symbol('IM_RTC_REDIAL')
export const IM_FORWARD_DIALOG_KEY: InjectionKey<OpenForwardDialog> =
Symbol('IM_FORWARD_DIALOG');
export const IM_MERGE_DETAIL_DIALOG_KEY: InjectionKey<OpenMergeDetailDialog> =
Symbol('IM_MERGE_DETAIL_DIALOG');
export const IM_RTC_REDIAL_KEY: InjectionKey<RtcRedial> =
Symbol('IM_RTC_REDIAL');

View File

@@ -1,87 +1,98 @@
<script lang="ts" setup>
import type { Conversation, FriendLite, Message } from '#/views/im/home/types'
import type { Conversation, FriendLite, Message } from '#/views/im/home/types';
import type { ImForwardModeValue } from '#/views/im/utils/constants';
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref } from 'vue';
import { IconifyIcon as Icon } from '@vben/icons'
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 { ConversationPickerPanel } from '#/views/im/home/components/picker'
import { FriendPickerPanel } from '#/views/im/home/components/picker'
import { useMessageMultiSelect } from '#/views/im/home/composables/useMessageMultiSelect'
import { useMessageSender } from '#/views/im/home/composables/useMessageSender'
import { useConversationStore } from '#/views/im/home/store/conversationStore'
import { useFriendStore } from '#/views/im/home/store/friendStore'
import { useGroupStore } from '#/views/im/home/store/groupStore'
import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config'
import { createGroup } from '#/api/im/group';
import {
ConversationPickerPanel,
FriendPickerPanel,
} from '#/views/im/home/components/picker';
import { useMessageMultiSelect } from '#/views/im/home/composables/useMessageMultiSelect';
import { useMessageSender } from '#/views/im/home/composables/useMessageSender';
import { useConversationStore } from '#/views/im/home/store/conversationStore';
import { useFriendStore } from '#/views/im/home/store/friendStore';
import { useGroupStore } from '#/views/im/home/store/groupStore';
import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config';
import {
ImContentType,
ImConversationType,
ImForwardMode,
type ImForwardModeValue
} from '#/views/im/utils/constants'
import { getConversationKey, summarizeMessageContent } from '#/views/im/utils/conversation'
import { buildDefaultGroupName } from '#/views/im/utils/group'
} from '#/views/im/utils/constants';
import {
getConversationKey,
summarizeMessageContent,
} from '#/views/im/utils/conversation';
import { buildDefaultGroupName } from '#/views/im/utils/group';
import {
buildMergeMessagePayload,
removeQuotePayload,
serializeMessage
} from '#/views/im/utils/message'
import { getGroupDisplayName, isGroupQuit } from '#/views/im/utils/user'
serializeMessage,
} from '#/views/im/utils/message';
import { getGroupDisplayName, isGroupQuit } from '#/views/im/utils/user';
import { FacePicker } from '../../input'
import { FacePicker } from '../../input';
defineOptions({ name: 'ImMessageForwardDialog' })
defineOptions({ name: 'ImMessageForwardDialog' });
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const { sendRaw, send } = useMessageSender()
const multiSelect = useMessageMultiSelect()
const conversationStore = useConversationStore();
const friendStore = useFriendStore();
const groupStore = useGroupStore();
const { sendRaw, send } = useMessageSender();
const multiSelect = useMessageMultiSelect();
const state = reactive({
mode: ImForwardMode.SINGLE as ImForwardModeValue,
messages: [] as Message[],
sourceConversation: null as Conversation | null
})
const visible = ref(false)
const view = ref<'contact' | 'conversation'>('conversation') // 当前视图:默认会话选择,「创建聊天」入口切到好友选择
const selectedKeys = ref<string[]>([])
const selectedFriendIds = ref<number[]>([])
const leaveMessage = ref('')
const sending = ref(false)
const emojiVisible = ref(false) // emoji picker 显隐:右侧笑脸按钮切换
sourceConversation: null as Conversation | null,
});
const visible = ref(false);
const view = ref<'contact' | 'conversation'>('conversation'); // 当前视图:默认会话选择,「创建聊天」入口切到好友选择
const selectedKeys = ref<string[]>([]);
const selectedFriendIds = ref<number[]>([]);
const leaveMessage = ref('');
const sending = ref(false);
const emojiVisible = ref(false); // emoji picker 显隐:右侧笑脸按钮切换
defineExpose({
/** 打开转发弹窗reset → 灌参 → visible=true */
open(opts: { messages: Message[]; mode: ImForwardModeValue; sourceConversation: Conversation }) {
state.mode = opts.mode
state.messages = opts.messages
state.sourceConversation = opts.sourceConversation
view.value = 'conversation'
selectedKeys.value = []
selectedFriendIds.value = []
leaveMessage.value = ''
emojiVisible.value = false
sending.value = false
visible.value = true
}
})
open(opts: {
messages: Message[];
mode: ImForwardModeValue;
sourceConversation: Conversation;
}) {
state.mode = opts.mode;
state.messages = opts.messages;
state.sourceConversation = opts.sourceConversation;
view.value = 'conversation';
selectedKeys.value = [];
selectedFriendIds.value = [];
leaveMessage.value = '';
emojiVisible.value = false;
sending.value = false;
visible.value = true;
},
});
/** 弹窗标题:会话视图按 mode 区分「逐条 / 合并转发」;好友视图固定为「选择好友」 */
const headerTitle = computed(() => {
if (view.value === 'contact') {
return '选择好友'
return '选择好友';
}
return state.mode === ImForwardMode.MERGE ? '合并转发' : '逐条转发'
})
return state.mode === ImForwardMode.MERGE ? '合并转发' : '逐条转发';
});
/** 确认按钮文案:单选「发送」、多选「分别发送(n)」 */
const confirmButtonText = computed(() =>
selectedKeys.value.length > 1 ? `分别发送(${selectedKeys.value.length}` : '发送'
)
selectedKeys.value.length > 1
? `分别发送(${selectedKeys.value.length}`
: '发送',
);
/** 候选会话:从 store 拿排序后的列表(转发回原会话也允许,与微信一致);公众号 / 频道单向消息不接受转发,从候选里剔除 */
const candidateConversations = computed<Conversation[]>(() =>
@@ -92,23 +103,25 @@ const candidateConversations = computed<Conversation[]>(() =>
!(
conversation.type === ImConversationType.GROUP &&
isGroupQuit(groupStore.getGroup(conversation.targetId))
)
)
)
),
),
);
/** 好友视图候选列表:直接复用 friendStore Lite 视图 */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
const friends = computed<FriendLite[]>(
() => friendStore.getActiveFriendLiteList,
);
/** 切到好友视图:清掉之前在会话视图输入的留言,避免在不可见输入框里把留言静默发到新群 */
function handleSwitchToContact() {
view.value = 'contact'
leaveMessage.value = ''
emojiVisible.value = false
view.value = 'contact';
leaveMessage.value = '';
emojiVisible.value = false;
}
/** 选中 emoji拼到留言末尾FacePicker 自身负责关闭面板 */
function handleEmojiSelect(emoji: string) {
leaveMessage.value = `${leaveMessage.value}${emoji}`
leaveMessage.value = `${leaveMessage.value}${emoji}`;
}
/** 合并 payload + 序列化 contentmerge 模式下一次构造,预览 / 发送共用 */
@@ -118,33 +131,41 @@ const mergeBundle = computed(() => {
!state.sourceConversation ||
state.messages.length === 0
) {
return null
return null;
}
const payload = buildMergeMessagePayload(state.messages, state.sourceConversation)
return { payload, content: serializeMessage(payload) }
})
const payload = buildMergeMessagePayload(
state.messages,
state.sourceConversation,
);
return { payload, content: serializeMessage(payload) };
});
/** 合并模式预览:从 messages 前 N 条派生「{昵称}{摘要}」 */
const mergePreview = computed(() => {
const payload = mergeBundle.value?.payload
const payload = mergeBundle.value?.payload;
if (!payload) {
return null
return null;
}
const lines = payload.messages
.slice(0, MESSAGE_MERGE_PREVIEW_LINES)
.map((item) => `${item.senderNickname}${summarizeMessageContent(item)}`)
return { title: payload.title, lines }
})
.map((item) => `${item.senderNickname}${summarizeMessageContent(item)}`);
return { title: payload.title, lines };
});
/** 逐条模式预览:取前 N 条摘要 */
const singlePreviewLines = computed(() =>
state.messages.slice(0, MESSAGE_MERGE_PREVIEW_LINES).map((m) => summarizeMessageContent(m))
)
state.messages
.slice(0, MESSAGE_MERGE_PREVIEW_LINES)
.map((m) => summarizeMessageContent(m)),
);
/** 待发送的逐条消息:剥离 quote 一次,发送多目标时复用 */
const cleanedSinglePayloads = computed(() =>
state.messages.map((m) => ({ type: m.type, content: removeQuotePayload(m.content) }))
)
state.messages.map((m) => ({
type: m.type,
content: removeQuotePayload(m.content),
})),
);
/**
* 给单个目标发送转发消息merge 一次 sendRaw、single 按时间序逐条 sendRaw
@@ -153,19 +174,21 @@ const cleanedSinglePayloads = computed(() =>
*/
async function forwardToTarget(target: Conversation): Promise<boolean> {
if (state.mode === ImForwardMode.MERGE) {
const content = mergeBundle.value?.content
const content = mergeBundle.value?.content;
if (!content) {
return false
return false;
}
return sendRaw(ImContentType.MERGE, content, { conversation: target })
return sendRaw(ImContentType.MERGE, content, { conversation: target });
}
for (const payload of cleanedSinglePayloads.value) {
const ok = await sendRaw(payload.type, payload.content, { conversation: target })
const ok = await sendRaw(payload.type, payload.content, {
conversation: target,
});
if (!ok) {
return false
return false;
}
}
return true
return true;
}
/**
@@ -176,49 +199,55 @@ async function forwardToTarget(target: Conversation): Promise<boolean> {
*/
async function handleSend() {
if (selectedKeys.value.length === 0) {
return
return;
}
if (state.messages.length === 0) {
message.warning('没有可转发的消息')
return
message.warning('没有可转发的消息');
return;
}
// 反查已选 conversation 对象(按 selectedKeys 数组顺序,即点击顺序)
const candidates = candidateConversations.value
const byKey = new Map(candidates.map((c) => [getConversationKey(c), c]))
const candidates = candidateConversations.value;
const byKey = new Map(candidates.map((c) => [getConversationKey(c), c]));
const targets = selectedKeys.value
.map((key) => byKey.get(key))
.filter((c): c is Conversation => c != null)
.filter((c): c is Conversation => c !== null);
if (targets.length === 0) {
return
return;
}
const leaveText = leaveMessage.value.trim()
sending.value = true
const leaveText = leaveMessage.value.trim();
sending.value = true;
try {
const tasks = targets.map(async (target) => {
const forwardOk = await forwardToTarget(target)
const forwardOk = await forwardToTarget(target);
if (!forwardOk) {
return { target, ok: false }
return { target, ok: false };
}
const ok = leaveText ? await send(leaveText, { conversation: target }) : true
return { target, ok }
})
const results = await Promise.all(tasks)
const failedNames = results.filter((r) => !r.ok).map((r) => r.target.name || '未命名会话')
const ok = leaveText
? await send(leaveText, { conversation: target })
: true;
return { target, ok };
});
const results = await Promise.all(tasks);
const failedNames = results
.filter((r) => !r.ok)
.map((r) => r.target.name || '未命名会话');
// 命中的目标统一推到最近转发列表(部分失败也推:用户的"意图"已表达)
conversationStore.pushRecentForwardConversationKeyList(targets.map((c) => getConversationKey(c)))
conversationStore.pushRecentForwardConversationKeyList(
targets.map((c) => getConversationKey(c)),
);
if (failedNames.length === 0) {
message.success('已转发')
message.success('已转发');
} else if (failedNames.length === targets.length) {
message.error(`转发失败:${failedNames.join('、')}`)
message.error(`转发失败:${failedNames.join('、')}`);
} else {
message.warning(`已转发,但 ${failedNames.join('、')} 失败`)
message.warning(`已转发,但 ${failedNames.join('、')} 失败`);
}
if (multiSelect.state.active) {
multiSelect.exit()
multiSelect.exit();
}
visible.value = false
visible.value = false;
} finally {
sending.value = false
sending.value = false;
}
}
@@ -230,26 +259,30 @@ async function handleSend() {
*/
async function handleCreateGroupAndSend() {
if (selectedFriendIds.value.length === 0) {
return
return;
}
if (state.messages.length === 0) {
message.warning('没有可转发的消息')
return
message.warning('没有可转发的消息');
return;
}
const byId = new Map(friends.value.map((f) => [f.id, f]))
const byId = new Map(friends.value.map((f) => [f.id, f]));
const members = selectedFriendIds.value
.map((id) => byId.get(id))
.filter((f): f is FriendLite => f != null)
.filter((f): f is FriendLite => f !== null);
if (members.length === 0) {
return
return;
}
sending.value = true
sending.value = true;
try {
const memberUserIds = members.map((m) => m.id)
const name = buildDefaultGroupName(members)
const group = await createGroup({ name, memberUserIds, joinApproval: false })
const memberUserIds = members.map((m) => m.id);
const name = buildDefaultGroupName(members);
const group = await createGroup({
name,
memberUserIds,
joinApproval: false,
});
if (!group?.id) {
throw new Error('创建群失败:未返回群编号')
throw new Error('创建群失败:未返回群编号');
}
// upsert 进 groupStore省一次 fetchGroupList
groupStore.upsertGroup({
@@ -257,8 +290,8 @@ async function handleCreateGroupAndSend() {
name: group.name,
avatar: group.avatar,
notice: group.notice,
ownerUserId: group.ownerUserId
})
ownerUserId: group.ownerUserId,
});
// 给新群构造一个临时 conversation 对象给 forwardToTarget 用sendRaw 内部会自动 insertMessage 登记
const newConversation: Conversation = {
type: ImConversationType.GROUP,
@@ -267,26 +300,28 @@ async function handleCreateGroupAndSend() {
avatar: group.avatar || '',
unreadCount: 0,
lastContent: '',
lastSendTime: 0
}
const forwardOk = await forwardToTarget(newConversation)
lastSendTime: 0,
};
const forwardOk = await forwardToTarget(newConversation);
if (forwardOk) {
const leaveText = leaveMessage.value.trim()
const leaveText = leaveMessage.value.trim();
if (leaveText) {
await send(leaveText, { conversation: newConversation })
await send(leaveText, { conversation: newConversation });
}
conversationStore.pushRecentForwardConversationKeyList([getConversationKey(newConversation)])
message.success('已创建群聊并转发')
conversationStore.pushRecentForwardConversationKeyList([
getConversationKey(newConversation),
]);
message.success('已创建群聊并转发');
} else {
message.warning('群已创建,但消息转发失败,请稍后在群里重试')
message.warning('群已创建,但消息转发失败,请稍后在群里重试');
}
// 统一退多选 + 关弹窗:成功 / 失败都要退源会话的多选态,避免遗留
if (multiSelect.state.active) {
multiSelect.exit()
multiSelect.exit();
}
visible.value = false
visible.value = false;
} finally {
sending.value = false
sending.value = false;
}
}
</script>
@@ -328,7 +363,9 @@ async function handleCreateGroupAndSend() {
v-if="view === 'conversation'"
v-model:selected-keys="selectedKeys"
:conversations="candidateConversations"
:recent-forward-conversation-keys="conversationStore.recentForwardConversationKeys"
:recent-forward-conversation-keys="
conversationStore.recentForwardConversationKeys
"
:show-create-chat="true"
@create-chat="handleSwitchToContact"
@remove-recent="conversationStore.removeRecentForwardConversationKey"
@@ -363,7 +400,10 @@ async function handleCreateGroupAndSend() {
<!-- 逐条模式预览消息数 + 首条摘要 -->
<div
v-else-if="state.mode === ImForwardMode.SINGLE && singlePreviewLines.length > 0"
v-else-if="
state.mode === ImForwardMode.SINGLE &&
singlePreviewLines.length > 0
"
class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--ant-color-bg-container)] border border-solid border-[var(--ant-color-border-secondary)]"
>
<div class="flex flex-col px-3 py-2 gap-0.5">
@@ -384,7 +424,11 @@ async function handleCreateGroupAndSend() {
<!-- 留言(单行):右侧表情按钮触发 FacePicker选中 emoji 拼到末尾 -->
<div class="relative">
<Input v-model:value="leaveMessage" :maxlength="100" placeholder="给朋友留言">
<Input
v-model:value="leaveMessage"
:maxlength="100"
placeholder="给朋友留言"
>
<template #suffix>
<Icon
icon="ant-design:smile-outlined"
@@ -418,7 +462,11 @@ async function handleCreateGroupAndSend() {
</ConversationPickerPanel>
<!-- 好友视图:选好友建群后转发 -->
<FriendPickerPanel v-else v-model:selected-ids="selectedFriendIds" :friends="friends" />
<FriendPickerPanel
v-else
v-model:selected-ids="selectedFriendIds"
:friends="friends"
/>
</div>
<!-- 好友视图的 dialog footer建群并转发 -->

View File

@@ -1,59 +1,60 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import type { MergeMessage } from '#/views/im/utils/message';
import { IconifyIcon as Icon } from '@vben/icons'
import { computed, ref } from 'vue';
import { Modal } from 'ant-design-vue'
import { IconifyIcon as Icon } from '@vben/icons';
import { UserAvatar } from '#/views/im/home/components/user'
import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer'
import { type MergeMessage, parseMessage } from '#/views/im/utils/message'
import { formatMergeItemTime } from '#/views/im/utils/time'
import { Modal } from 'ant-design-vue';
import { MessageBubble } from '..'
import { UserAvatar } from '#/views/im/home/components/user';
import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer';
import { parseMessage } from '#/views/im/utils/message';
import { formatMergeItemTime } from '#/views/im/utils/time';
defineOptions({ name: 'ImMessageMergeDetailDialog' })
import { MessageBubble } from '..';
const voicePlayer = useVoicePlayer()
const visible = ref(false)
defineOptions({ name: 'ImMessageMergeDetailDialog' });
const stack = ref<MergeMessage[]>([]) // 嵌套层级栈,存 parsed payload 避免切层重 parse
const voicePlayer = useVoicePlayer();
const visible = ref(false);
const stack = ref<MergeMessage[]>([]); // 嵌套层级栈,存 parsed payload 避免切层重 parse
defineExpose({
/** 打开详情弹窗,传入顶层合并消息 content */
open(content: string) {
const payload = parseMessage<MergeMessage>(content)
stack.value = payload ? [payload] : []
visible.value = true
}
})
const payload = parseMessage<MergeMessage>(content);
stack.value = payload ? [payload] : [];
visible.value = true;
},
});
/** 当前层 payload */
const currentPayload = computed<MergeMessage | null>(
() => stack.value[stack.value.length - 1] ?? null
)
() => stack.value[stack.value.length - 1] ?? null,
);
/** 嵌套合并气泡点击:解析 content 后压栈进入下一层 */
function handleNestedOpen(content: string) {
const payload = parseMessage<MergeMessage>(content)
const payload = parseMessage<MergeMessage>(content);
if (payload) {
stack.value.push(payload)
stack.value.push(payload);
}
}
/** 顶部返回箭头点击:弹出栈顶回到上一层 */
function handleBack() {
if (stack.value.length > 1) {
stack.value.pop()
stack.value.pop();
}
}
/** 弹窗关闭:清栈 + 停语音,下次打开从顶层重新开始 */
function handleClose() {
stack.value = []
voicePlayer.stop()
stack.value = [];
voicePlayer.stop();
}
</script>
<template>
@@ -74,7 +75,9 @@ function handleClose() {
class="cursor-pointer text-[var(--ant-color-text-secondary)] hover:text-[var(--ant-color-text)]"
@click="handleBack"
/>
<span class="text-base font-medium truncate">{{ currentPayload?.title || '聊天记录' }}</span>
<span class="text-base font-medium truncate">{{
currentPayload?.title || '聊天记录'
}}</span>
</div>
</template>
@@ -99,10 +102,14 @@ function handleClose() {
/>
<div class="flex-1 min-w-0">
<div class="flex gap-2 items-baseline">
<span class="text-13px font-medium text-[var(--ant-color-text)] truncate">
<span
class="text-13px font-medium text-[var(--ant-color-text)] truncate"
>
{{ item.senderNickname }}
</span>
<span class="text-12px text-[var(--ant-color-text-secondary)] flex-shrink-0">
<span
class="text-12px text-[var(--ant-color-text-secondary)] flex-shrink-0"
>
{{ formatMergeItemTime(item.sendTime) }}
</span>
</div>

View File

@@ -1,114 +1,130 @@
<script lang="ts" setup>
import type { Message } from '../../../../types'
import type { Message } from '../../../../types';
import { computed, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue';
import { IconifyIcon as Icon } from '@vben/icons'
import { IconifyIcon as Icon } from '@vben/icons';
import { Button, message } from 'ant-design-vue'
import { Button, message } from 'ant-design-vue';
import { unpinGroupMessage as apiUnpinGroupMessage } from '#/api/im/group'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImConversationType, ImGroupMemberRole } from '#/views/im/utils/constants'
import { resolveConversationLastContent } from '#/views/im/utils/conversation'
import { getSenderDisplayName, isGroupQuit } from '#/views/im/utils/user'
import { unpinGroupMessage as apiUnpinGroupMessage } from '#/api/im/group';
import { getCurrentUserId } from '#/views/im/utils/auth';
import {
ImConversationType,
ImGroupMemberRole,
} from '#/views/im/utils/constants';
import { resolveConversationLastContent } from '#/views/im/utils/conversation';
import { getSenderDisplayName, isGroupQuit } from '#/views/im/utils/user';
import { useGroupStore } from '../../../../store/groupStore'
import { useGroupStore } from '../../../../store/groupStore';
defineOptions({ name: 'ImGroupPinnedMessage' })
defineOptions({ name: 'ImGroupPinnedMessage' });
const props = defineProps<{
/** 当前群编号(自行从 groupStore 拿完整 Group跟随响应式 */
groupId: number
}>()
groupId: number;
}>();
const emit = defineEmits<{
/** 点击置顶消息 → 父级 MessagePanel 滚动定位到原消息位置 */
locate: [messageId: number]
}>()
locate: [messageId: number];
}>();
const groupStore = useGroupStore()
const groupStore = useGroupStore();
/** 当前群(含 pinnedMessages */
const group = computed(() => groupStore.getGroup(props.groupId))
const group = computed(() => groupStore.getGroup(props.groupId));
const expanded = ref(false)
const removingId = ref<null | number>(null)
const expanded = ref(false);
const removingId = ref<null | number>(null);
// 切群时重置展开 / 移除中状态:本地 ref 不跟随 groupId否则上一群"展开"或"移除中"会带到新群
watch(
() => props.groupId,
() => {
expanded.value = false
removingId.value = null
}
)
expanded.value = false;
removingId.value = null;
},
);
/** 当前群置顶消息列表(直接走 group.value跟随响应式 */
const pinnedMessages = computed<Message[]>(() => group.value?.pinnedMessages ?? [])
const pinnedMessages = computed<Message[]>(
() => group.value?.pinnedMessages ?? [],
);
/** 顶部胶囊展示的最新一条即列表最后一条pin 顺序追加) */
const latest = computed<Message | null>(
() => pinnedMessages.value[pinnedMessages.value.length - 1] ?? null
)
() => pinnedMessages.value[pinnedMessages.value.length - 1] ?? null,
);
/** 当前用户是否群主 / 管理员(决定是否显示「移除」入口) */
const canManage = computed(() => {
// 历史退群群:本地缓存残留时也不给「移除」入口
if (isGroupQuit(group.value)) {
return false
return false;
}
const myId = getCurrentUserId()
const role = group.value?.members?.find((m) => m.userId === myId)?.role
return role === ImGroupMemberRole.OWNER || role === ImGroupMemberRole.ADMIN
})
const myId = getCurrentUserId();
const role = group.value?.members?.find((m) => m.userId === myId)?.role;
return role === ImGroupMemberRole.OWNER || role === ImGroupMemberRole.ADMIN;
});
/** 顶部胶囊点击:单条直接跳转原消息位置;多条切换展开 / 折叠 */
function handleTopClick() {
if (!latest.value) {
return
return;
}
if (pinnedMessages.value.length === 1) {
handleLocate(latest.value)
return
handleLocate(latest.value);
return;
}
expanded.value = !expanded.value
expanded.value = !expanded.value;
}
/** 点击置顶消息行 → 触发跳转 + 收起弹出层 */
function handleLocate(message: Message) {
if (!message.id) {
return
return;
}
emit('locate', message.id)
expanded.value = false
emit('locate', message.id);
expanded.value = false;
}
/** 置顶消息发送人显示名 */
function getSenderName(message: Message): string {
return group.value
? getSenderDisplayName(message.senderId, ImConversationType.GROUP, group.value.id)
: ''
? getSenderDisplayName(
message.senderId,
ImConversationType.GROUP,
group.value.id,
)
: '';
}
/** 置顶消息预览文本:复用会话最后一条摘要逻辑([图片] / [文件] / 文本等) */
function getPreview(message: Message): string {
return group.value
? resolveConversationLastContent(message, ImConversationType.GROUP, group.value.id)
: ''
? resolveConversationLastContent(
message,
ImConversationType.GROUP,
group.value.id,
)
: '';
}
/** 移除置顶:调后端 APIloading 期间禁止重复点;后端广播 GROUP_MESSAGE_UNPIN 由 dispatcher 自动同步本地 */
async function handleRemove(pinnedMessage: Message) {
if (!group.value || !pinnedMessage.id || removingId.value !== null) {
return
return;
}
removingId.value = pinnedMessage.id
removingId.value = pinnedMessage.id;
try {
await apiUnpinGroupMessage({ id: group.value.id, messageId: pinnedMessage.id })
message.success('已取消置顶')
await apiUnpinGroupMessage({
id: group.value.id,
messageId: pinnedMessage.id,
});
message.success('已取消置顶');
} finally {
removingId.value = null
removingId.value = null;
}
}
</script>
@@ -129,7 +145,9 @@ async function handleRemove(pinnedMessage: Message) {
:size="14"
class="flex-shrink-0 text-[var(--ant-color-warning)]"
/>
<span class="flex-shrink-0 text-[var(--ant-color-text-secondary)]">{{ getSenderName(latest) }}</span>
<span class="flex-shrink-0 text-[var(--ant-color-text-secondary)]">
{{ getSenderName(latest) }}
</span>
<span class="flex-1 min-w-0 truncate">{{ getPreview(latest) }}</span>
<!-- 单条移除按钮多条折叠 N 多条展开收起箭头 -->
<Button
@@ -143,11 +161,15 @@ async function handleRemove(pinnedMessage: Message) {
移除
</Button>
<template v-else-if="pinnedMessages.length > 1">
<span class="flex-shrink-0 text-[var(--ant-color-text-secondary)] text-12px">
<span
class="flex-shrink-0 text-[var(--ant-color-text-secondary)] text-12px"
>
{{ pinnedMessages.length }}
</span>
<Icon
:icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
:icon="
expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'
"
:size="11"
class="flex-shrink-0 text-[var(--ant-color-text-placeholder)]"
/>

View File

@@ -1,50 +1,54 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { computed, ref } from 'vue';
import { IconifyIcon as Icon } from '@vben/icons'
import { IconifyIcon as Icon } from '@vben/icons';
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImGroupMemberRole } from '#/views/im/utils/constants'
import { getCurrentUserId } from '#/views/im/utils/auth';
import { ImGroupMemberRole } from '#/views/im/utils/constants';
import { GroupRequestListDialog } from '../../../../components/group'
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
import { useGroupStore } from '../../../../store/groupStore'
import { GroupRequestListDialog } from '../../../../components/group';
import { useGroupRequestStore } from '../../../../store/groupRequestStore';
import { useGroupStore } from '../../../../store/groupStore';
defineOptions({ name: 'ImGroupRequestPending' })
defineOptions({ name: 'ImGroupRequestPending' });
const props = defineProps<{
groupId: number
}>()
groupId: number;
}>();
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const groupStore = useGroupStore();
const groupRequestStore = useGroupRequestStore();
const requestListDialogRef = ref<InstanceType<typeof GroupRequestListDialog>>() // 申请列表弹窗 refhandleOpen 调 open({ groupId }) 触发
const requestListDialogRef = ref<InstanceType<typeof GroupRequestListDialog>>(); // 申请列表弹窗 refhandleOpen 调 open({ groupId }) 触发
/** 打开当前群的进群申请列表 */
function handleOpen() {
requestListDialogRef.value?.open({ groupId: props.groupId })
requestListDialogRef.value?.open({ groupId: props.groupId });
}
/** 当前群(含 ownerUserId / members */
const group = computed(() => groupStore.getGroup(props.groupId))
const group = computed(() => groupStore.getGroup(props.groupId));
/** 当前用户在群里的角色;优先用 group.members懒加载未到时回退到 ownerUserId 直判 */
const myRole = computed(() => {
const myId = getCurrentUserId()
const myId = getCurrentUserId();
if (group.value?.ownerUserId === myId) {
return ImGroupMemberRole.OWNER
return ImGroupMemberRole.OWNER;
}
return group.value?.members?.find((m) => m.userId === myId)?.role
})
return group.value?.members?.find((m) => m.userId === myId)?.role;
});
/** 仅群主 / 管理员可见 */
const canManage = computed(
() => myRole.value === ImGroupMemberRole.OWNER || myRole.value === ImGroupMemberRole.ADMIN
)
() =>
myRole.value === ImGroupMemberRole.OWNER ||
myRole.value === ImGroupMemberRole.ADMIN,
);
/** 当前群未处理申请数;从 store 派生 */
const pendingCount = computed(() => groupRequestStore.getUnhandledGroupRequestCount(props.groupId))
const pendingCount = computed(() =>
groupRequestStore.getUnhandledGroupRequestCount(props.groupId),
);
</script>
<template>
@@ -67,7 +71,9 @@ const pendingCount = computed(() => groupRequestStore.getUnhandledGroupRequestCo
:size="14"
class="im-group-request-pending__icon flex-shrink-0 text-[var(--ant-color-success)]"
/>
<span class="flex-1 min-w-0 truncate"> 新进群申请{{ pendingCount }} </span>
<span class="flex-1 min-w-0 truncate">
新进群申请{{ pendingCount }}
</span>
<Icon
icon="ant-design:right-outlined"
:size="11"

View File

@@ -1,64 +1,69 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import type { MaterialMessage } from '#/views/im/utils/message';
import { IconifyIcon as Icon } from '@vben/icons'
import { openSafeUrl } from '@vben/utils'
import { computed, ref } from 'vue';
import { Modal, Spin } from 'ant-design-vue'
import { IconifyIcon as Icon } from '@vben/icons';
import { openSafeUrl } from '@vben/utils';
import { getChannelMaterial } from '#/api/im/channel/material'
import { useChannelStore } from '#/views/im/home/store/channelStore'
import { useConversationStore } from '#/views/im/home/store/conversationStore'
import { ImConversationType } from '#/views/im/utils/constants'
import { type MaterialMessage, parseMessage } from '#/views/im/utils/message'
import { Modal, Spin } from 'ant-design-vue';
import { getChannelMaterial } from '#/api/im/channel/material';
import { useChannelStore } from '#/views/im/home/store/channelStore';
import { useConversationStore } from '#/views/im/home/store/conversationStore';
import { ImConversationType } from '#/views/im/utils/constants';
import { parseMessage } from '#/views/im/utils/message';
const props = defineProps<{
content: string
}>()
content: string;
}>();
const conversationStore = useConversationStore()
const channelStore = useChannelStore()
const conversationStore = useConversationStore();
const channelStore = useChannelStore();
/** 当前是否在公众号 / 频道会话里:决定走大卡片还是紧凑转发卡片 */
const isChannelView = computed(
() => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
() =>
conversationStore.activeConversation?.type === ImConversationType.CHANNEL,
);
/** 反序列化 content JSON 为 payload 对象 */
const payload = computed<MaterialMessage>(
() => parseMessage<MaterialMessage>(props.content) ?? {}
)
() => parseMessage<MaterialMessage>(props.content) ?? {},
);
/** 来源频道;紧凑卡底部渲染头像 + 名称 */
const sourceChannel = computed(() =>
payload.value.channelId ? channelStore.getChannel(payload.value.channelId) : undefined
)
payload.value.channelId
? channelStore.getChannel(payload.value.channelId)
: undefined,
);
const detailVisible = ref(false)
const detailLoading = ref(false)
const detailHtml = ref('')
const detailVisible = ref(false);
const detailLoading = ref(false);
const detailHtml = ref('');
// 点击行为url 非空跳外链;为空则按 payload.materialId 拉富文本正文,全屏 dialog 渲染
const onClick = async () => {
if (payload.value.url) {
openSafeUrl(payload.value.url)
return
openSafeUrl(payload.value.url);
return;
}
if (!payload.value.materialId) {
return
return;
}
detailVisible.value = true
detailLoading.value = true
detailHtml.value = ''
detailVisible.value = true;
detailLoading.value = true;
detailHtml.value = '';
try {
const material = await getChannelMaterial(payload.value.materialId)
detailHtml.value = material?.content ?? ''
const material = await getChannelMaterial(payload.value.materialId);
detailHtml.value = material?.content ?? '';
} catch (error) {
console.error('[Material] 拉取正文失败', error)
console.error('[Material] 拉取正文失败', error);
} finally {
detailLoading.value = false
detailLoading.value = false;
}
}
};
</script>
<template>
@@ -68,8 +73,14 @@ const onClick = async () => {
class="material-card cursor-pointer w-full overflow-hidden rounded-lg bg-[var(--ant-color-bg-container)] border border-solid border-[var(--im-border-color-lighter)]"
@click="onClick"
>
<img v-if="payload.coverUrl" class="block w-full h-[200px] object-cover" :src="payload.coverUrl" />
<div class="px-3.5 py-3 text-15px font-600 leading-[1.4] text-[var(--ant-color-text)] line-clamp-2">
<img
v-if="payload.coverUrl"
class="block w-full h-[200px] object-cover"
:src="payload.coverUrl"
/>
<div
class="px-3.5 py-3 text-15px font-600 leading-[1.4] text-[var(--ant-color-text)] line-clamp-2"
>
{{ payload.title || '(无标题)' }}
</div>
</div>
@@ -82,7 +93,9 @@ const onClick = async () => {
>
<div class="flex gap-2.5 items-start">
<div class="flex flex-1 flex-col gap-1.5 min-w-0">
<div class="text-15px font-600 leading-[1.4] text-[var(--ant-color-text)] line-clamp-2 break-all">
<div
class="text-15px font-600 leading-[1.4] text-[var(--ant-color-text)] line-clamp-2 break-all"
>
{{ payload.title || '(无标题)' }}
</div>
<div
@@ -98,7 +111,9 @@ const onClick = async () => {
:src="payload.coverUrl"
/>
</div>
<div class="flex items-center gap-1.5 mt-2.5 pt-2 border-t border-t-solid border-[var(--im-border-color-lighter)] text-12px text-[var(--ant-color-text-secondary)]">
<div
class="flex items-center gap-1.5 mt-2.5 pt-2 border-t border-t-solid border-[var(--im-border-color-lighter)] text-12px text-[var(--ant-color-text-secondary)]"
>
<img
v-if="sourceChannel?.avatar"
class="w-4 h-4 rounded-full object-cover flex-shrink-0"
@@ -119,8 +134,12 @@ const onClick = async () => {
destroy-on-close
>
<Spin :spinning="detailLoading" wrapper-class-name="w-full">
<div class="material-detail-body max-w-[720px] mx-auto px-5 pt-6 pb-20 min-h-[60vh]">
<div class="text-[22px] font-600 leading-[1.4] text-[var(--ant-color-text)] mb-5">
<div
class="material-detail-body max-w-[720px] mx-auto px-5 pt-6 pb-20 min-h-[60vh]"
>
<div
class="text-[22px] font-600 leading-[1.4] text-[var(--ant-color-text)] mb-5"
>
{{ payload.title || '' }}
</div>
<div

View File

@@ -1,142 +1,145 @@
<script lang="ts" setup>
import { computed, onBeforeUnmount } from 'vue'
import type {
AudioMessage,
CardMessage,
FaceMessage,
FileMessage,
ImageMessage,
MentionCandidate,
MergeMessage,
TextMessage,
VideoMessage,
} from '#/views/im/utils/message';
import { IconifyIcon as Icon } from '@vben/icons'
import { formatFileSize, openSafeUrl } from '@vben/utils'
import { computed, onBeforeUnmount } from 'vue';
import { Image } from 'ant-design-vue'
import { IconifyIcon as Icon } from '@vben/icons';
import { formatFileSize, openSafeUrl } from '@vben/utils';
import { CardBubble } from '#/views/im/home/components/card'
import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer'
import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config'
import { ImContentType } from '#/views/im/utils/constants'
import { summarizeMessageContent } from '#/views/im/utils/conversation'
import { Image } from 'ant-design-vue';
import { CardBubble } from '#/views/im/home/components/card';
import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer';
import { MESSAGE_MERGE_PREVIEW_LINES } from '#/views/im/utils/config';
import { ImContentType } from '#/views/im/utils/constants';
import { summarizeMessageContent } from '#/views/im/utils/conversation';
import {
type AudioMessage,
type CardMessage,
type FaceMessage,
type FileMessage,
getFileIconInfo,
type ImageMessage,
type MentionCandidate,
type MergeMessage,
parseMessage,
parseTextSegments,
type TextMessage,
type VideoMessage
} from '#/views/im/utils/message'
import { formatSeconds } from '#/views/im/utils/time'
} from '#/views/im/utils/message';
import { formatSeconds } from '#/views/im/utils/time';
import MaterialBubble from './material-bubble.vue'
import TipSegments from './tip-segments.vue'
import MaterialBubble from './material-bubble.vue';
import TipSegments from './tip-segments.vue';
defineOptions({ name: 'ImMessageBubble' })
defineOptions({ name: 'ImMessageBubble' });
const props = defineProps<{
/** 消息 contentJSON 字符串) */
content: string
content: string;
/** TEXT 气泡的 @ mention 候选名字;不传则文本里的 @xxx 退化为普通文本 */
mentions?: MentionCandidate[]
mentions?: MentionCandidate[];
/** 是否自己发送,影响气泡配色(绿底 vs 灰底) */
selfSend?: boolean
selfSend?: boolean;
/** 内容类型,对齐 ImContentType */
type: number
type: number;
/** 媒体上传进度0-100非 null 即视为上传中,渲染遮罩 / 进度条 */
uploadProgress?: null | number
}>()
uploadProgress?: null | number;
}>();
const emit = defineEmits<{
/** 名片点击:调用方决定弹卡片 / 跳群等行为 */
clickCard: [card: CardMessage, e: MouseEvent]
clickCard: [card: CardMessage, e: MouseEvent];
/** 合并消息气泡点击:调用方决定开 dialog 或栈内 push */
openMerge: [content: string]
}>()
openMerge: [content: string];
}>();
/** 各 type 判定 */
const isText = computed(() => props.type === ImContentType.TEXT)
const isImage = computed(() => props.type === ImContentType.IMAGE)
const isFile = computed(() => props.type === ImContentType.FILE)
const isVoice = computed(() => props.type === ImContentType.VOICE)
const isVideo = computed(() => props.type === ImContentType.VIDEO)
const isFace = computed(() => props.type === ImContentType.FACE)
const isCard = computed(() => props.type === ImContentType.CARD)
const isMerge = computed(() => props.type === ImContentType.MERGE)
const isMaterial = computed(() => props.type === ImContentType.MATERIAL)
const isText = computed(() => props.type === ImContentType.TEXT);
const isImage = computed(() => props.type === ImContentType.IMAGE);
const isFile = computed(() => props.type === ImContentType.FILE);
const isVoice = computed(() => props.type === ImContentType.VOICE);
const isVideo = computed(() => props.type === ImContentType.VIDEO);
const isFace = computed(() => props.type === ImContentType.FACE);
const isCard = computed(() => props.type === ImContentType.CARD);
const isMerge = computed(() => props.type === ImContentType.MERGE);
const isMaterial = computed(() => props.type === ImContentType.MATERIAL);
/** 媒体上传中uploadProgress 非 null 即视为上传中 */
const isUploading = computed(() => props.uploadProgress != null)
const uploadProgress = computed(() => props.uploadProgress ?? 0)
const uploadProgressText = computed(() => `${uploadProgress.value}%`)
const isUploading = computed(() => props.uploadProgress !== null);
const uploadProgress = computed(() => props.uploadProgress ?? 0);
const uploadProgressText = computed(() => `${uploadProgress.value}%`);
/**
* 单一 parse 入口content 一变只 parse 一次,按 type 分发到下面 7 个 payload
*
* 各类型 payload 共用同一棵 JSON 树,避免 7 个 computed 各自重 parse 同一份 content
*/
const parsedContent = computed<unknown>(() => parseMessage(props.content))
const parsedContent = computed<unknown>(() => parseMessage(props.content));
const textPayload = computed(() =>
isText.value ? (parsedContent.value as null | TextMessage) : null
)
isText.value ? (parsedContent.value as null | TextMessage) : null,
);
/** 文本气泡 segment 数组mention 高亮 + URL 自动识别 + 普通文本三段拼接 */
const textSegments = computed(() => {
const content = textPayload.value?.content
const content = textPayload.value?.content;
if (!content) {
return []
return [];
}
return parseTextSegments(content, props.mentions || [])
})
return parseTextSegments(content, props.mentions || []);
});
const imagePayload = computed(() =>
isImage.value ? (parsedContent.value as ImageMessage | null) : null
)
isImage.value ? (parsedContent.value as ImageMessage | null) : null,
);
const filePayload = computed(() =>
isFile.value ? (parsedContent.value as FileMessage | null) : null
)
isFile.value ? (parsedContent.value as FileMessage | null) : null,
);
const voicePayload = computed(() =>
isVoice.value ? (parsedContent.value as AudioMessage | null) : null
)
isVoice.value ? (parsedContent.value as AudioMessage | null) : null,
);
const videoPayload = computed(() =>
isVideo.value ? (parsedContent.value as null | VideoMessage) : null
)
isVideo.value ? (parsedContent.value as null | VideoMessage) : null,
);
const cardPayload = computed(() =>
isCard.value ? (parsedContent.value as CardMessage | null) : null
)
isCard.value ? (parsedContent.value as CardMessage | null) : null,
);
const mergePayload = computed(() =>
isMerge.value ? (parsedContent.value as MergeMessage | null) : null
)
isMerge.value ? (parsedContent.value as MergeMessage | null) : null,
);
/** 合并消息内嵌前 N 条派生「{昵称}{摘要}」 */
const mergePreviewLines = computed(() => {
if (!mergePayload.value) {
return []
return [];
}
return mergePayload.value.messages
.slice(0, MESSAGE_MERGE_PREVIEW_LINES)
.map((item) => `${item.senderNickname}${summarizeMessageContent(item)}`)
})
.map((item) => `${item.senderNickname}${summarizeMessageContent(item)}`);
});
const FACE_DIMENSION_MAX = 2048 // 表情 payload非法宽高派生成 undefined让 <img> 走 CSS max-w / max-h 兜底
const FACE_DIMENSION_MAX = 2048; // 表情 payload非法宽高派生成 undefined让 <img> 走 CSS max-w / max-h 兜底
const facePayload = computed(() => {
if (!isFace.value) {
return null
return null;
}
const raw = parsedContent.value as FaceMessage | null
const raw = parsedContent.value as FaceMessage | null;
if (!raw) {
return null
return null;
}
const sanitize = (v: number | undefined) =>
v && v > 0 && v <= FACE_DIMENSION_MAX ? v : undefined
return { ...raw, width: sanitize(raw.width), height: sanitize(raw.height) }
})
v && v > 0 && v <= FACE_DIMENSION_MAX ? v : undefined;
return { ...raw, width: sanitize(raw.width), height: sanitize(raw.height) };
});
/** 文件图标 + 配色:按扩展名分发 */
const fileIconInfo = computed(() => getFileIconInfo(filePayload.value?.name))
const fileIconInfo = computed(() => getFileIconInfo(filePayload.value?.name));
/** 文本 / 文件 / 语音气泡的整体 class含 selfSend 配色 + ::before 三角的 side class */
function bubbleClass(variant: 'file' | 'text' | 'voice'): string[] {
const isSelf = props.selfSend
const side = isSelf ? 'message-bubble--self' : 'message-bubble--other'
const isSelf = props.selfSend;
const side = isSelf ? 'message-bubble--self' : 'message-bubble--other';
switch (variant) {
case 'file': {
return [
@@ -144,20 +147,18 @@ function bubbleClass(variant: 'file' | 'text' | 'voice'): string[] {
'message-bubble--file',
isSelf
? 'bg-[#95ec69] border-[var(--ant-color-border-secondary)]'
: 'bg-[var(--ant-color-bg-container)] border-[var(--ant-color-border-secondary)] hover:border-[#409eff]'
]
: 'bg-[var(--ant-color-bg-container)] border-[var(--ant-color-border-secondary)] hover:border-[#409eff]',
];
}
case 'text': {
return [
side,
'message-bubble--text',
isSelf
? 'text-black bg-[#95ec69]'
: 'text-[var(--ant-color-text)]'
]
isSelf ? 'text-black bg-[#95ec69]' : 'text-[var(--ant-color-text)]',
];
}
case 'voice': {
return [side, 'message-bubble--voice', isSelf ? 'bg-[#95ec69]' : '']
return [side, 'message-bubble--voice', isSelf ? 'bg-[#95ec69]' : ''];
}
}
}
@@ -165,32 +166,32 @@ function bubbleClass(variant: 'file' | 'text' | 'voice'): string[] {
/** 文件点击 → 新窗口下载;上传中跳过 */
function handleFileClick() {
if (isUploading.value || !filePayload.value?.url) {
return
return;
}
openSafeUrl(filePayload.value.url)
openSafeUrl(filePayload.value.url);
}
const voicePlayer = useVoicePlayer() // 语音点击:托管给 useVoicePlayer 全局互斥播放,新点的语音会停掉旧的
const voicePlayer = useVoicePlayer(); // 语音点击:托管给 useVoicePlayer 全局互斥播放,新点的语音会停掉旧的
/**
* 实例级唯一播放 key每个 MessageBubble 实例独立一份
*
* 不用 url 当 key 是为了避免「主面板 / 历史抽屉 / 合并详情同一条语音」共享身份:那样三处气泡会
* 同时显示播放态,且任何一处卸载都会 stop 掉别处仍可见的播放
*/
const voiceKey = Symbol('im-message-bubble-voice')
const voicePlaying = computed(() => voicePlayer.isPlaying(voiceKey))
const voiceKey = Symbol('im-message-bubble-voice');
const voicePlaying = computed(() => voicePlayer.isPlaying(voiceKey));
function handleVoiceClick() {
const url = voicePayload.value?.url
const url = voicePayload.value?.url;
if (!url) {
return
return;
}
voicePlayer.play(voiceKey, url)
voicePlayer.play(voiceKey, url);
}
/** 气泡卸载兜底:传 key 让 stop 自己判别「是不是我」,不会误伤别人的播放 */
onBeforeUnmount(() => {
voicePlayer.stop(voiceKey)
})
voicePlayer.stop(voiceKey);
});
</script>
<template>
@@ -222,29 +223,43 @@ onBeforeUnmount(() => {
<div
v-else-if="isFile && filePayload"
class="relative flex gap-3 items-center min-w-[260px] max-w-[340px] px-3.5 py-3 border border-solid rounded transition-colors"
:class="[bubbleClass('file'), isUploading ? 'cursor-default' : 'cursor-pointer']"
:class="[
bubbleClass('file'),
isUploading ? 'cursor-default' : 'cursor-pointer',
]"
@click="handleFileClick"
>
<div class="flex-1 min-w-0">
<div class="overflow-hidden text-sm font-medium truncate text-[var(--ant-color-text)]">
<div
class="overflow-hidden text-sm font-medium truncate text-[var(--ant-color-text)]"
>
{{ filePayload.name }}
</div>
<div class="mt-1 text-12px text-[var(--ant-color-text-secondary)]">
{{ formatFileSize(filePayload.size) }}
</div>
<div v-if="isUploading" class="flex gap-2 items-center mt-1.5">
<div class="overflow-hidden flex-1 h-1 rounded bg-[var(--ant-color-fill-dark)]">
<div
class="overflow-hidden flex-1 h-1 rounded bg-[var(--ant-color-fill-dark)]"
>
<div
class="h-full bg-[var(--ant-color-primary)] transition-[width] duration-150"
:style="{ width: `${uploadProgress }%` }"
:style="{ width: `${uploadProgress}%` }"
></div>
</div>
<span class="text-11px text-[var(--ant-color-text-secondary)] tabular-nums">
<span
class="text-11px text-[var(--ant-color-text-secondary)] tabular-nums"
>
{{ uploadProgressText }}
</span>
</div>
</div>
<Icon :icon="fileIconInfo.icon" :color="fileIconInfo.color" :size="40" class="flex-shrink-0" />
<Icon
:icon="fileIconInfo.icon"
:color="fileIconInfo.color"
:size="40"
class="flex-shrink-0"
/>
</div>
<!-- 语音 -->
@@ -316,7 +331,9 @@ onBeforeUnmount(() => {
class="flex flex-col w-[260px] rounded-md overflow-hidden cursor-pointer bg-[var(--ant-color-bg-container)] border border-solid border-[var(--ant-color-border)] hover:border-[#409eff]"
@click="emit('openMerge', content)"
>
<div class="px-3 py-2 text-sm font-medium text-[var(--ant-color-text)] truncate">
<div
class="px-3 py-2 text-sm font-medium text-[var(--ant-color-text)] truncate"
>
{{ mergePayload.title }}
</div>
<div class="flex flex-col gap-0.5 px-3 pb-2">
@@ -378,7 +395,8 @@ onBeforeUnmount(() => {
.message-bubble--other::before {
left: -5px;
border-color: transparent var(--im-message-bubble-other-bg) transparent transparent;
border-color: transparent var(--im-message-bubble-other-bg) transparent
transparent;
border-width: 5px 6px 5px 0;
}

View File

@@ -1,109 +1,117 @@
<script lang="ts" setup>
import type { Message } from '#/views/im/home/types'
import type { GroupMemberLite } from '../../../../components/group';
import { computed, inject, ref, watch } from 'vue'
import type { Message } from '#/views/im/home/types';
import type {
CardMessage,
FaceMessage,
FileMessage,
MergeMessage,
TextMessage,
} from '#/views/im/utils/message';
import { IconifyIcon as Icon } from '@vben/icons'
import { useUserStore } from '@vben/stores'
import { computed, inject, ref, watch } from 'vue';
import { Button, Calendar, Input, Modal, Popover, Tag } from 'ant-design-vue'
import dayjs from 'dayjs'
import { IconifyIcon as Icon } from '@vben/icons';
import { useUserStore } from '@vben/stores';
import { getGroupMessageList as apiGetGroupMessageList } from '#/api/im/message/group'
import { getPrivateMessageList as apiGetPrivateMessageList } from '#/api/im/message/private'
import { useMessagePuller } from '#/views/im/home/composables/useMessagePuller'
import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer'
import { Button, Calendar, Input, Modal, Popover, Tag } from 'ant-design-vue';
import dayjs from 'dayjs';
import { getGroupMessageList as apiGetGroupMessageList } from '#/api/im/message/group';
import { getPrivateMessageList as apiGetPrivateMessageList } from '#/api/im/message/private';
import { useMessagePuller } from '#/views/im/home/composables/useMessagePuller';
import { useVoicePlayer } from '#/views/im/home/composables/useVoicePlayer';
import {
ImContentType,
ImConversationType,
isFriendChatTip,
isGroupNotification
} from '#/views/im/utils/constants'
isGroupNotification,
} from '#/views/im/utils/constants';
import {
buildFacePreviewText,
buildRecallTip,
buildRecallTipSegments,
getConversationKey
} from '#/views/im/utils/conversation'
import { getClientConversationId } from '#/views/im/utils/db'
getConversationKey,
} from '#/views/im/utils/conversation';
import { getClientConversationId } from '#/views/im/utils/db';
import {
getCardLabelInfo,
parseMessage,
resolveFriendNotificationSegments,
resolveFriendNotificationText,
resolveGroupNotificationSegments
} from '#/views/im/utils/message'
import {
type CardMessage,
type FaceMessage,
type FileMessage,
getCardLabelInfo,
type MergeMessage,
parseMessage,
type TextMessage
} from '#/views/im/utils/message'
import { formatHistoryTime } from '#/views/im/utils/time'
resolveGroupNotificationSegments,
} from '#/views/im/utils/message';
import { formatHistoryTime } from '#/views/im/utils/time';
import {
getMemberDisplayName,
getSenderDisplayName,
getSenderRealNickname
} from '#/views/im/utils/user'
getSenderRealNickname,
} from '#/views/im/utils/user';
import { GroupMember, type GroupMemberLite } from '../../../../components/group'
import { UserAvatar } from '../../../../components/user'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useMessageStore } from '../../../../store/messageStore'
import { IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys'
import MessageBubble from './message-bubble.vue'
import TipSegments from './tip-segments.vue'
import { GroupMember } from '../../../../components/group';
import { UserAvatar } from '../../../../components/user';
import { useConversationStore } from '../../../../store/conversationStore';
import { useFriendStore } from '../../../../store/friendStore';
import { useGroupStore } from '../../../../store/groupStore';
import { useMessageStore } from '../../../../store/messageStore';
import { IM_MERGE_DETAIL_DIALOG_KEY } from './forward/keys';
import MessageBubble from './message-bubble.vue';
import TipSegments from './tip-segments.vue';
defineOptions({ name: 'ImMessageHistory' })
defineOptions({ name: 'ImMessageHistory' });
const emit = defineEmits<{
// 历史消息行上的"定位"按钮:通知父组件 MessagePanel 滚到对应消息位置 + 关掉自己
locate: [messageId: number]
}>()
locate: [messageId: number];
}>();
const userStore = useUserStore()
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const groupStore = useGroupStore()
const friendStore = useFriendStore()
const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY)
const voicePlayer = useVoicePlayer()
const { convertPrivateMessage, convertGroupMessage } = useMessagePuller()
const userStore = useUserStore();
const conversationStore = useConversationStore();
const messageStore = useMessageStore();
const groupStore = useGroupStore();
const friendStore = useFriendStore();
const openMergeDetail = inject(IM_MERGE_DETAIL_DIALOG_KEY);
const voicePlayer = useVoicePlayer();
const { convertPrivateMessage, convertGroupMessage } = useMessagePuller();
const visible = ref(false)
const visible = ref(false);
defineExpose({
/** 打开历史消息抽屉 */
open() {
visible.value = true
}
})
visible.value = true;
},
});
const conversation = computed(() => conversationStore.activeConversation)
const isGroup = computed(() => conversation.value?.type === ImConversationType.GROUP)
const conversation = computed(() => conversationStore.activeConversation);
const isGroup = computed(
() => conversation.value?.type === ImConversationType.GROUP,
);
const allMessages = computed<Message[]>(() =>
conversation.value
? messageStore.getMessages(
getClientConversationId(conversation.value.type, conversation.value.targetId)
getClientConversationId(
conversation.value.type,
conversation.value.targetId,
),
)
: []
)
: [],
);
/** 单条消息的发送人显示名:渲染时按 conversation 上下文走 WeChat 优先级实时算 */
function senderDisplayNameOf(message: Message): string {
return getSenderDisplayName(
message.senderId,
conversation.value?.type ?? 0,
conversation.value?.targetId ?? 0
)
conversation.value?.targetId ?? 0,
);
}
/** 群广播事件 segments 的成员名解析器;按当前会话 targetId 走 getSenderDisplayName */
function resolveGroupMemberName(message: Message): (userId: number) => string {
return (id: number) => getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0)
return (id: number) =>
getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0);
}
/** 单条消息的发送人真实昵称:给 UserAvatar 色卡 / alt 用,永远是 nickname 不掺备注 */
@@ -111,8 +119,8 @@ function senderRealNicknameOf(message: Message): string {
return getSenderRealNickname(
message.senderId,
conversation.value?.type ?? 0,
conversation.value?.targetId ?? 0
)
conversation.value?.targetId ?? 0,
);
}
/** 单条撤回消息的 tip 文案buildRecallTip 内部按 conversation 上下文实时算 sender 名 */
@@ -121,8 +129,8 @@ function recallTipOf(message: Message): string {
message.senderId,
message.selfSend,
conversation.value?.type ?? 0,
conversation.value?.targetId ?? 0
)
conversation.value?.targetId ?? 0,
);
}
/** 单条撤回消息的 tip segmentssender 名段挂可点击 mention */
@@ -131,8 +139,8 @@ function recallTipSegmentsOf(message: Message) {
message.senderId,
message.selfSend,
conversation.value?.type ?? 0,
conversation.value?.targetId ?? 0
)
conversation.value?.targetId ?? 0,
);
}
// ==================== 标题 ====================
@@ -144,54 +152,54 @@ function recallTipSegmentsOf(message: Message) {
*/
const title = computed(() => {
if (!conversation.value) {
return '聊天记录'
return '聊天记录';
}
const name = conversation.value.name
const name = conversation.value.name;
if (isGroup.value) {
return `"${name}"的聊天记录(${allMessages.value.length})`
return `"${name}"的聊天记录(${allMessages.value.length})`;
}
return `与"${name}"的聊天记录`
})
return `与"${name}"的聊天记录`;
});
// ==================== 搜索 + 筛选 chip ====================
/** 当前激活的筛选条件 —— 单 chip 模式(同时只 1 个),点击 tab 落新值,× 清空 */
type ActiveFilter =
| { day: string; kind: 'date'; } // YYYY-MM-DD
| { day: string; kind: 'date' } // YYYY-MM-DD
| { kind: 'file' }
| { kind: 'image' }
| { kind: 'member'; nickname: string; userId: number; }
| { kind: 'voice' }
| { kind: 'member'; nickname: string; userId: number }
| { kind: 'voice' };
const keyword = ref('')
const activeFilter = ref<ActiveFilter | null>(null)
const keyword = ref('');
const activeFilter = ref<ActiveFilter | null>(null);
/** chip 文案:日期 / 群成员 多带值,其他直接 tab 名 */
const filterChipLabel = computed(() => {
if (!activeFilter.value) {
return ''
return '';
}
switch (activeFilter.value.kind) {
case 'date': {
return `日期:${activeFilter.value.day}`
return `日期:${activeFilter.value.day}`;
}
case 'file': {
return '文件'
return '文件';
}
case 'image': {
return '图片'
return '图片';
}
case 'member': {
return `@${activeFilter.value.nickname}`
return `@${activeFilter.value.nickname}`;
}
case 'voice': {
return '语音'
return '语音';
}
default: {
return ''
return '';
}
}
})
});
/** 点 tab 落筛选;同 kind 重复点击 → 当 toggle 关掉(避免迷惑) */
function setFilter(filter: ActiveFilter) {
@@ -200,70 +208,70 @@ function setFilter(filter: ActiveFilter) {
filter.kind !== 'date' &&
filter.kind !== 'member'
) {
activeFilter.value = null
return
activeFilter.value = null;
return;
}
activeFilter.value = filter
activeFilter.value = filter;
}
/** chip × 关闭 / 重置 */
function clearFilter() {
activeFilter.value = null
activeFilter.value = null;
}
// ==================== 日期 popover ====================
const datePopoverVisible = ref(false)
const datePickerValue = ref(dayjs())
const datePopoverVisible = ref(false);
const datePickerValue = ref(dayjs());
/** 日期 popover 确定:把 Date → YYYY-MM-DD 落到 activeFilter关 popover */
function onDateConfirm() {
if (!datePickerValue.value) {
datePopoverVisible.value = false
return
datePopoverVisible.value = false;
return;
}
const day = datePickerValue.value.format('YYYY-MM-DD')
activeFilter.value = { kind: 'date', day }
datePopoverVisible.value = false
const day = datePickerValue.value.format('YYYY-MM-DD');
activeFilter.value = { kind: 'date', day };
datePopoverVisible.value = false;
}
// ==================== 群成员 popover ====================
const memberPopoverVisible = ref(false)
const memberSearchKeyword = ref('')
const memberPopoverVisible = ref(false);
const memberSearchKeyword = ref('');
/** 群成员 picker 列表:从 groupStore 拉 + 适配 GroupMemberLite + 关键字过滤 */
const filteredMembersForPicker = computed<GroupMemberLite[]>(() => {
if (!isGroup.value || !conversation.value) {
return []
return [];
}
const group = groupStore.getGroup(conversation.value.targetId)
const group = groupStore.getGroup(conversation.value.targetId);
const all = (group?.members || []).map((member) => {
const friend = friendStore.getFriend(member.userId)
const friend = friendStore.getFriend(member.userId);
return {
userId: member.userId,
showName: getMemberDisplayName(member, friend),
nickname: member.nickname,
avatar: member.avatar,
status: member.status
}
})
const trimmedKeyword = memberSearchKeyword.value.trim()
status: member.status,
};
});
const trimmedKeyword = memberSearchKeyword.value.trim();
if (!trimmedKeyword) {
return all
return all;
}
return all.filter((member) => member.showName.includes(trimmedKeyword))
})
return all.filter((member) => member.showName.includes(trimmedKeyword));
});
/** 群成员 picker 选择:落 activeFilter + 关 popover + 清搜索词 */
function onMemberSelect(member: GroupMemberLite) {
activeFilter.value = {
kind: 'member',
userId: member.userId,
nickname: member.showName
}
memberPopoverVisible.value = false
memberSearchKeyword.value = ''
nickname: member.showName,
};
memberPopoverVisible.value = false;
memberSearchKeyword.value = '';
}
// ==================== 列表过滤 ====================
@@ -271,26 +279,28 @@ function onMemberSelect(member: GroupMemberLite) {
/** activeFilter 命中:默认无筛选时全部命中 */
function matchesActiveFilter(message: Message): boolean {
if (!activeFilter.value) {
return true
return true;
}
switch (activeFilter.value.kind) {
case 'date': {
return dayjs(message.sendTime).format('YYYY-MM-DD') === activeFilter.value.day
return (
dayjs(message.sendTime).format('YYYY-MM-DD') === activeFilter.value.day
);
}
case 'file': {
return message.type === ImContentType.FILE
return message.type === ImContentType.FILE;
}
case 'image': {
return message.type === ImContentType.IMAGE
return message.type === ImContentType.IMAGE;
}
case 'member': {
return message.senderId === activeFilter.value.userId
return message.senderId === activeFilter.value.userId;
}
case 'voice': {
return message.type === ImContentType.VOICE
return message.type === ImContentType.VOICE;
}
default: {
return true
return true;
}
}
}
@@ -301,19 +311,23 @@ function matchesActiveFilter(message: Message): boolean {
* 关键字命中走 textSnippetOf —— 文本拿原文、媒体拿"[图片]"等占位词、文件拿文件名
*/
const currentList = computed<Message[]>(() => {
const trimmedKeyword = keyword.value.trim()
let list = allMessages.value.filter((message) => matchesActiveFilter(message))
const trimmedKeyword = keyword.value.trim();
let list = allMessages.value.filter((message) =>
matchesActiveFilter(message),
);
if (trimmedKeyword) {
list = list.filter((message) => textSnippetOf(message).includes(trimmedKeyword))
list = list.filter((message) =>
textSnippetOf(message).includes(trimmedKeyword),
);
}
return list.toReversed()
})
return list.toReversed();
});
// ==================== 加载更早消息 ====================
const HISTORY_PAGE_SIZE = 50
const loadingMore = ref(false)
const hasMore = ref(true)
const HISTORY_PAGE_SIZE = 50;
const loadingMore = ref(false);
const hasMore = ref(true);
/**
* 加载更早消息:拿当前最早一条 id 作 maxId不含调 list 接口拉一页 + convert + prepend
@@ -326,67 +340,73 @@ const hasMore = ref(true)
async function loadEarlier() {
// 重入 / 到顶 / 无会话 早退:避免重复请求或在 conversation 切换间隙触发
if (loadingMore.value || !hasMore.value || !conversation.value) {
return
return;
}
// 仅 PRIVATE / GROUP 走分页接口CHANNEL 单向广播、没有 list 接口,落到 else 会误调私聊接口receiverId 传 channelId
const requestedType = conversation.value.type
if (requestedType !== ImConversationType.PRIVATE && requestedType !== ImConversationType.GROUP) {
return
const requestedType = conversation.value.type;
if (
requestedType !== ImConversationType.PRIVATE &&
requestedType !== ImConversationType.GROUP
) {
return;
}
// 快照当前会话主键await 期间用户切走 / 关闭面板时丢弃响应,避免旧会话历史被 prepend 到新会话造成串号
const requestedKey = getConversationKey(conversation.value)
const requestedTargetId = conversation.value.targetId
const requestedIsGroup = requestedType === ImConversationType.GROUP
const requestedKey = getConversationKey(conversation.value);
const requestedTargetId = conversation.value.targetId;
const requestedIsGroup = requestedType === ImConversationType.GROUP;
loadingMore.value = true
loadingMore.value = true;
try {
// 算 maxId不含作为后端游标取当前会话本地缓存里最早一条服务端 id
// 本地乐观占位消息没有服务端 id要剔除
// 全是占位 / 列表为空时 reduce 不更新初值POSITIVE_INFINITY转成 undefined → 后端从最新拉
let earliestId = Number.POSITIVE_INFINITY
let earliestId = Number.POSITIVE_INFINITY;
for (const message of allMessages.value) {
if (message.id && message.id > 0) {
earliestId = Math.min(earliestId, message.id)
earliestId = Math.min(earliestId, message.id);
}
}
const maxId = Number.isFinite(earliestId) ? earliestId : undefined
const maxId = Number.isFinite(earliestId) ? earliestId : undefined;
// 调后端 list 接口:私聊 / 群聊接口签名不同,分支调度;返回结果用 useMessagePuller
// 暴露的 convert 函数转成本地 Message与 puller 同一份字段映射,避免分歧)
let earlier: Message[] = []
let pageLength = 0
let earlier: Message[] = [];
let pageLength = 0;
if (requestedIsGroup) {
const list = await apiGetGroupMessageList({
groupId: requestedTargetId,
maxId,
limit: HISTORY_PAGE_SIZE
})
earlier = (list || []).map((message) => convertGroupMessage(message))
pageLength = list?.length ?? 0
limit: HISTORY_PAGE_SIZE,
});
earlier = (list || []).map((message) => convertGroupMessage(message));
pageLength = list?.length ?? 0;
} else {
const list = await apiGetPrivateMessageList({
receiverId: requestedTargetId,
maxId,
limit: HISTORY_PAGE_SIZE
})
earlier = (list || []).map((message) => convertPrivateMessage(message))
pageLength = list?.length ?? 0
limit: HISTORY_PAGE_SIZE,
});
earlier = (list || []).map((message) => convertPrivateMessage(message));
pageLength = list?.length ?? 0;
}
// await 期间 active 可能被外部置 null / 换主键:直接丢弃响应;不更新 hasMore旧会话到顶不代表新会话到顶也不 prepend
if (!conversation.value || getConversationKey(conversation.value) !== requestedKey) {
return
if (
!conversation.value ||
getConversationKey(conversation.value) !== requestedKey
) {
return;
}
// 返回数量 < limit 视为到顶 —— 关闭"加载更早"按钮,避免后续点击空跑接口
if (pageLength < HISTORY_PAGE_SIZE) {
hasMore.value = false
hasMore.value = false;
}
// 合并到 messageStoreprependMessageList 内部去重 + 升序合并 + 落 IndexedDB
// 主聊天面板的 messages 是同一份引用,老消息也会一起出现在主面板里(符合预期)
messageStore.prependMessageList(requestedType, requestedTargetId, earlier)
messageStore.prependMessageList(requestedType, requestedTargetId, earlier);
} finally {
loadingMore.value = false
loadingMore.value = false;
}
}
@@ -394,91 +414,94 @@ async function loadEarlier() {
/** 弹窗打开时把上次的 chip / 搜索 / 加载状态都清干净,避免上次的状态残留迷惑 */
function onDialogOpen() {
activeFilter.value = null
keyword.value = ''
hasMore.value = true
datePopoverVisible.value = false
memberPopoverVisible.value = false
memberSearchKeyword.value = ''
datePickerValue.value = dayjs()
activeFilter.value = null;
keyword.value = '';
hasMore.value = true;
datePopoverVisible.value = false;
memberPopoverVisible.value = false;
memberSearchKeyword.value = '';
datePickerValue.value = dayjs();
// 本地无消息时立即拉一次maxId=undefined 从最新开始),避免新设备 / 缓存清后弹窗只显示"暂无消息"
if (allMessages.value.length === 0 && hasMore.value && conversation.value) {
void loadEarlier()
void loadEarlier();
}
}
/** 抽屉关闭时复位 + 停语音 */
watch(visible, (value) => {
if (!value) {
activeFilter.value = null
keyword.value = ''
voicePlayer.stop()
activeFilter.value = null;
keyword.value = '';
voicePlayer.stop();
}
})
});
/**
* 抽屉开着时外部切了 active conversationdialog 的 title / 列表 / isGroup 全部跟着新 conversation 走,
* 这里把分页态一并重置;否则旧会话残留的 loadingMore=true / hasMore=false 会让新会话"加载更早"按钮失效
*/
watch(conversation, () => {
loadingMore.value = false
hasMore.value = true
})
loadingMore.value = false;
hasMore.value = true;
});
// ==================== helper ====================
/** 取头像 url自己用 userStore群里查 groupStore 成员,私聊用 conversation.avatar */
function getAvatar(message: Message): string {
if (message.selfSend) {
return userStore.userInfo?.avatar || ''
return userStore.userInfo?.avatar || '';
}
if (!conversation.value) {
return ''
return '';
}
if (isGroup.value) {
const group = groupStore.getGroup(conversation.value.targetId)
return group?.members?.find((member) => member.userId === message.senderId)?.avatar || ''
const group = groupStore.getGroup(conversation.value.targetId);
return (
group?.members?.find((member) => member.userId === message.senderId)
?.avatar || ''
);
}
return conversation.value.avatar || ''
return conversation.value.avatar || '';
}
/** 关键字命中文本:文本类返回原文、文件返回文件名(利于按文件名搜)、其他返回占位词 */
function textSnippetOf(message: Message): string {
if (isFriendChatTip(message.type)) {
return resolveFriendNotificationText(message)
return resolveFriendNotificationText(message);
}
switch (message.type) {
case ImContentType.CARD: {
const card = parseMessage<CardMessage>(message.content)
return `[${getCardLabelInfo(card).label}] ${card?.name ?? ''}`
const card = parseMessage<CardMessage>(message.content);
return `[${getCardLabelInfo(card).label}] ${card?.name ?? ''}`;
}
case ImContentType.FACE: {
return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
return buildFacePreviewText(parseMessage<FaceMessage>(message.content));
}
case ImContentType.FILE: {
return parseMessage<FileMessage>(message.content)?.name ?? '[文件]'
return parseMessage<FileMessage>(message.content)?.name ?? '[文件]';
}
case ImContentType.IMAGE: {
return '[图片]'
return '[图片]';
}
case ImContentType.MERGE: {
const merge = parseMessage<MergeMessage>(message.content)
return merge?.title ?? '[聊天记录]'
const merge = parseMessage<MergeMessage>(message.content);
return merge?.title ?? '[聊天记录]';
}
case ImContentType.RECALL: {
return recallTipOf(message)
return recallTipOf(message);
}
case ImContentType.TEXT: {
return parseMessage<TextMessage>(message.content)?.content ?? ''
return parseMessage<TextMessage>(message.content)?.content ?? '';
}
case ImContentType.VIDEO: {
return '[视频]'
return '[视频]';
}
case ImContentType.VOICE: {
return '[语音]'
return '[语音]';
}
default: {
return ''
return '';
}
}
}
@@ -489,10 +512,10 @@ function textSnippetOf(message: Message): string {
*/
function locateMessage(messageId: number) {
if (!messageId) {
return
return;
}
emit('locate', messageId)
visible.value = false
emit('locate', messageId);
visible.value = false;
}
</script>
@@ -545,21 +568,27 @@ function locateMessage(messageId: number) {
>
<span
class="im-message-history__tab cursor-pointer"
:class="{ 'im-message-history__tab--active': activeFilter?.kind === 'file' }"
:class="{
'im-message-history__tab--active': activeFilter?.kind === 'file',
}"
@click="setFilter({ kind: 'file' })"
>
文件
</span>
<span
class="im-message-history__tab cursor-pointer"
:class="{ 'im-message-history__tab--active': activeFilter?.kind === 'image' }"
:class="{
'im-message-history__tab--active': activeFilter?.kind === 'image',
}"
@click="setFilter({ kind: 'image' })"
>
图片
</span>
<span
class="im-message-history__tab cursor-pointer"
:class="{ 'im-message-history__tab--active': activeFilter?.kind === 'voice' }"
:class="{
'im-message-history__tab--active': activeFilter?.kind === 'voice',
}"
@click="setFilter({ kind: 'voice' })"
>
语音
@@ -573,13 +602,17 @@ function locateMessage(messageId: number) {
>
<span
class="im-message-history__tab cursor-pointer"
:class="{ 'im-message-history__tab--active': activeFilter?.kind === 'date' }"
:class="{
'im-message-history__tab--active': activeFilter?.kind === 'date',
}"
>
日期
</span>
<template #content>
<div class="im-message-history__date-panel">
<div class="px-2 pt-1 pb-2 text-13px font-medium text-[var(--ant-color-text)]">
<div
class="px-2 pt-1 pb-2 text-13px font-medium text-[var(--ant-color-text)]"
>
选择发送日期
</div>
<Calendar
@@ -588,8 +621,12 @@ function locateMessage(messageId: number) {
class="im-message-history__calendar"
/>
<div class="flex gap-2 justify-end px-2 pt-2">
<Button size="small" @click="datePopoverVisible = false">取消</Button>
<Button size="small" type="primary" @click="onDateConfirm">确定</Button>
<Button size="small" @click="datePopoverVisible = false">
取消
</Button>
<Button size="small" type="primary" @click="onDateConfirm">
确定
</Button>
</div>
</div>
</template>
@@ -604,13 +641,20 @@ function locateMessage(messageId: number) {
>
<span
class="im-message-history__tab cursor-pointer"
:class="{ 'im-message-history__tab--active': activeFilter?.kind === 'member' }"
:class="{
'im-message-history__tab--active':
activeFilter?.kind === 'member',
}"
>
群成员
</span>
<template #content>
<div>
<Input v-model:value="memberSearchKeyword" placeholder="搜索群成员" size="small">
<Input
v-model:value="memberSearchKeyword"
placeholder="搜索群成员"
size="small"
>
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
@@ -639,14 +683,19 @@ function locateMessage(messageId: number) {
<!-- 消息列表 -->
<div class="flex-1 overflow-y-auto">
<template v-for="message in currentList" :key="message.id || message.clientMessageId">
<template
v-for="message in currentList"
:key="message.id || message.clientMessageId"
>
<!-- 好友会话事件FRIEND_ADD / FRIEND_DELETE居中灰色不挂头像 / sender
跟主聊天面板里 MessageItem 的渲染语义对齐 -->
<div
v-if="isFriendChatTip(message.type)"
class="px-4 py-3 text-12px text-center italic text-[var(--ant-color-text-secondary)] border-b border-b-solid border-[var(--ant-color-border-secondary)]"
>
<TipSegments :segments="resolveFriendNotificationSegments(message)" />
<TipSegments
:segments="resolveFriendNotificationSegments(message)"
/>
</div>
<!-- 群广播事件跟好友事件同灰色样式mention 段挂点击弹 UserInfoCard -->
@@ -655,7 +704,12 @@ function locateMessage(messageId: number) {
class="px-4 py-3 text-12px text-center italic text-[var(--ant-color-text-secondary)] border-b border-b-solid border-[var(--ant-color-border-secondary)]"
>
<TipSegments
:segments="resolveGroupNotificationSegments(message, resolveGroupMemberName(message))"
:segments="
resolveGroupNotificationSegments(
message,
resolveGroupMemberName(message),
)
"
/>
</div>
@@ -678,7 +732,9 @@ function locateMessage(messageId: number) {
{{ senderDisplayNameOf(message) }}
</span>
<span class="im-message-history__meta relative flex-shrink-0">
<span class="block text-right">{{ formatHistoryTime(message.sendTime) }}</span>
<span class="block text-right">{{
formatHistoryTime(message.sendTime)
}}</span>
<!-- 定位到聊天位置absolute 浮在时间下方,行 hover 才显示,
不参与右侧栏 flex 排版(避免隐藏时占位让""和内容之间留空);
仅有真实 id 的消息才支持(本地占位消息不行) -->
@@ -806,7 +862,8 @@ function locateMessage(messageId: number) {
padding: 1px 0;
}
.im-message-history__calendar :deep(.ant-picker-cell .ant-picker-calendar-date) {
.im-message-history__calendar
:deep(.ant-picker-cell .ant-picker-calendar-date) {
height: 28px;
padding: 2px 4px 0;
margin: 0 2px;

View File

@@ -1,140 +1,155 @@
<script lang="ts" setup>
import type { GroupMemberLite } from '../../../../components/group'
import type { GroupLite } from '../../../../types'
import type { GroupMemberLite } from '../../../../components/group';
import type { GroupLite } from '../../../../types';
import { computed, nextTick, provide, ref, watch } from 'vue'
import { computed, nextTick, provide, ref, watch } from 'vue';
import { IconifyIcon as Icon } from '@vben/icons'
import { IconifyIcon as Icon } from '@vben/icons';
import { message, Popover, Tooltip } from 'ant-design-vue'
import { message, Popover, Tooltip } from 'ant-design-vue';
import { createCall } from '#/api/im/rtc'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImConversationType, ImRtcCallMediaType, ImRtcCallStatus } from '#/views/im/utils/constants'
import { getClientConversationId } from '#/views/im/utils/db'
import { resolveCallEndReasonText } from '#/views/im/utils/message'
import { getGroupDisplayName, getMemberDisplayName, isGroupQuit } from '#/views/im/utils/user'
import { createCall } from '#/api/im/rtc';
import { getCurrentUserId } from '#/views/im/utils/auth';
import {
ImConversationType,
ImRtcCallMediaType,
ImRtcCallStatus,
} from '#/views/im/utils/constants';
import { getClientConversationId } from '#/views/im/utils/db';
import { resolveCallEndReasonText } from '#/views/im/utils/message';
import {
getGroupDisplayName,
getMemberDisplayName,
isGroupQuit,
} from '#/views/im/utils/user';
import { GroupMuteMemberDialog } from '../../../../components/group'
import { GroupMuteMemberDialog } from '../../../../components/group';
import {
RtcCallMemberPickerDialog,
RtcGroupCallBanner
} from '../../../../components/rtc'
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect'
import { useVoicePlayer } from '../../../../composables/useVoicePlayer'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useMessageStore } from '../../../../store/messageStore'
import { useRtcStore } from '../../../../store/rtcStore'
import { useImUiStore } from '../../../../store/uiStore'
import { ConversationGroupSide } from '../conversation'
import { ConversationPrivateSide } from '../conversation'
import { MessageInput } from '../input'
import { MessageMultiSelectBar } from '../input'
import { MessageForwardDialog } from './forward'
import { MessageMergeDetailDialog } from './forward'
RtcGroupCallBanner,
} from '../../../../components/rtc';
import { useMessageMultiSelect } from '../../../../composables/useMessageMultiSelect';
import { useVoicePlayer } from '../../../../composables/useVoicePlayer';
import { useConversationStore } from '../../../../store/conversationStore';
import { useFriendStore } from '../../../../store/friendStore';
import { useGroupStore } from '../../../../store/groupStore';
import { useMessageStore } from '../../../../store/messageStore';
import { useRtcStore } from '../../../../store/rtcStore';
import { useImUiStore } from '../../../../store/uiStore';
import {
ConversationGroupSide,
ConversationPrivateSide,
} from '../conversation';
import { MessageInput, MessageMultiSelectBar } from '../input';
import { MessageForwardDialog, MessageMergeDetailDialog } from './forward';
import {
IM_FORWARD_DIALOG_KEY,
IM_MERGE_DETAIL_DIALOG_KEY,
IM_RTC_REDIAL_KEY
} from './forward/keys'
import GroupPinnedMessage from './group-pinned-message.vue'
import GroupRequestPending from './group-request-pending.vue'
import MessageHistory from './message-history.vue'
import MessageItem from './message-item.vue'
IM_RTC_REDIAL_KEY,
} from './forward/keys';
import GroupPinnedMessage from './group-pinned-message.vue';
import GroupRequestPending from './group-request-pending.vue';
import MessageHistory from './message-history.vue';
import MessageItem from './message-item.vue';
defineOptions({ name: 'ImMessagePanel' })
defineOptions({ name: 'ImMessagePanel' });
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const friendStore = useFriendStore()
const uiStore = useImUiStore()
const groupStore = useGroupStore()
const rtcStore = useRtcStore()
const listRef = ref<HTMLElement>()
const conversationStore = useConversationStore();
const messageStore = useMessageStore();
const friendStore = useFriendStore();
const uiStore = useImUiStore();
const groupStore = useGroupStore();
const rtcStore = useRtcStore();
const listRef = ref<HTMLElement>();
// ==================== 转发 / 合并消息详情:本地 dialog 浮层 ====================
// MessageItem / MessageMultiSelectBar / MessageHistory 通过 inject 触发;不挂全局 store
const forwardDialogRef = ref<InstanceType<typeof MessageForwardDialog>>()
const mergeDetailDialogRef = ref<InstanceType<typeof MessageMergeDetailDialog>>()
const forwardDialogRef = ref<InstanceType<typeof MessageForwardDialog>>();
const mergeDetailDialogRef =
ref<InstanceType<typeof MessageMergeDetailDialog>>();
provide(IM_FORWARD_DIALOG_KEY, (opts) => forwardDialogRef.value?.open(opts))
provide(IM_MERGE_DETAIL_DIALOG_KEY, (content) => mergeDetailDialogRef.value?.open(content))
provide(IM_FORWARD_DIALOG_KEY, (opts) => forwardDialogRef.value?.open(opts));
provide(IM_MERGE_DETAIL_DIALOG_KEY, (content) =>
mergeDetailDialogRef.value?.open(content),
);
provide(IM_RTC_REDIAL_KEY, (mediaType: number) => {
if (isPrivate.value) {
void startPrivateCall(mediaType)
void startPrivateCall(mediaType);
}
}) // 私聊 RTC_CALL_END 气泡点击重拨MessageItem 注入后调用
}); // 私聊 RTC_CALL_END 气泡点击重拨MessageItem 注入后调用
// ==================== 多选模式 ====================
// 模块级单例 statecomposable本组件仅做切会话退出 + template 显隐判定
const multiSelect = useMessageMultiSelect()
const voicePlayer = useVoicePlayer()
const multiSelect = useMessageMultiSelect();
const voicePlayer = useVoicePlayer();
/** 切会话退出多选 + 停语音;避免上一会话的勾选 / 播放态泄漏到新会话type+targetId 一起监听,私聊与群聊 id 同号时也能触发) */
watch(
() => [
conversationStore.activeConversation?.type,
conversationStore.activeConversation?.targetId
conversationStore.activeConversation?.targetId,
],
() => {
multiSelect.exit()
voicePlayer.stop()
}
)
multiSelect.exit();
voicePlayer.stop();
},
);
const messages = computed(() => {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
return conversation
? messageStore.getMessages(getClientConversationId(conversation.type, conversation.targetId))
: []
})
? messageStore.getMessages(
getClientConversationId(conversation.type, conversation.targetId),
)
: [];
});
const isGroup = computed(
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
)
() => conversationStore.activeConversation?.type === ImConversationType.GROUP,
);
const isPrivate = computed(
() => conversationStore.activeConversation?.type === ImConversationType.PRIVATE
)
() =>
conversationStore.activeConversation?.type === ImConversationType.PRIVATE,
);
const isChannel = computed(
() => conversationStore.activeConversation?.type === ImConversationType.CHANNEL
)
() =>
conversationStore.activeConversation?.type === ImConversationType.CHANNEL,
);
/** 当前激活会话是否历史退群群:禁群通话、隐藏群申请横幅等操作入口;聊天历史、群名头像照常展示 */
const isQuitGroup = computed(() => {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
return (
conversation?.type === ImConversationType.GROUP &&
isGroupQuit(groupStore.getGroup(conversation.targetId))
)
})
);
});
/** 私聊会话且对端不是有效好友(本端 friend 记录缺失或 DISABLE单边删除语义下「被对方删除」不触发本端横幅 */
const showNotFriendBanner = computed(() => {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation || conversation.type !== ImConversationType.PRIVATE) {
return false
return false;
}
return !friendStore.isActiveFriend(conversation.targetId)
})
return !friendStore.isActiveFriend(conversation.targetId);
});
/** 点击「对方还不是你的朋友」胶囊:打开 UserInfoCard引导用户重新添加 */
function handleNotFriendClick(event: MouseEvent) {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation) {
return
return;
}
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
uiStore.openUserInfoCard(
{
id: conversation.targetId,
nickname: conversation.name,
avatar: conversation.avatar
avatar: conversation.avatar,
},
{ x: rect.left, y: rect.bottom + 4 }
)
{ x: rect.left, y: rect.bottom + 4 },
);
}
/**
@@ -144,24 +159,24 @@ function handleNotFriendClick(event: MouseEvent) {
* 而 groupInfo.memberCount 跟群信息一起来,能更早显示人数避免"先空再蹦"
*/
const headerMemberCount = computed(() => {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return 0
return 0;
}
const group = groupStore.getGroup(conversation.targetId)
return group?.memberCount ?? group?.members?.length ?? 0
})
const group = groupStore.getGroup(conversation.targetId);
return group?.memberCount ?? group?.members?.length ?? 0;
});
/** 顶部副标题:仅当群备注 ≠ 原群名时显示原群名(对齐微信 PC 双行 header */
const headerSubtitle = computed(() => {
const remark = groupInfo.value?.groupRemark
const name = groupInfo.value?.name
return remark && name && remark !== name ? name : ''
})
const remark = groupInfo.value?.groupRemark;
const name = groupInfo.value?.name;
return remark && name && remark !== name ? name : '';
});
const BOTTOM_THRESHOLD = 80 // "是否停留在底部"的阈值:距离底部 < 80px 视为底部
const showJumpToBottom = ref(false) // 当前是否已不在底部(显示"回到底部"按钮)
const newMessageCount = ref(0) // 不在底部期间累计的新消息数
const BOTTOM_THRESHOLD = 80; // "是否停留在底部"的阈值:距离底部 < 80px 视为底部
const showJumpToBottom = ref(false); // 当前是否已不在底部(显示"回到底部"按钮)
const newMessageCount = ref(0); // 不在底部期间累计的新消息数
/**
* 当前激活的群详情:优先 groupStore带详细字段未加载完时用 activeConversation 兜底
@@ -172,20 +187,22 @@ const newMessageCount = ref(0) // 不在底部期间累计的新消息数
*/
const groupInfo = computed<
| (GroupLite & {
groupRemark?: string
notice?: string
ownerId?: number
remarkNickName?: string
groupRemark?: string;
notice?: string;
ownerId?: number;
remarkNickName?: string;
})
| undefined
>(() => {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return undefined
return undefined;
}
const group = groupStore.getGroup(conversation.targetId)
const selfMember = group?.members?.find((member) => member.userId === getCurrentUserId())
const showGroupName = group ? getGroupDisplayName(group) : conversation.name
const group = groupStore.getGroup(conversation.targetId);
const selfMember = group?.members?.find(
(member) => member.userId === getCurrentUserId(),
);
const showGroupName = group ? getGroupDisplayName(group) : conversation.name;
return {
id: conversation.targetId,
name: group?.name || conversation.name,
@@ -196,166 +213,182 @@ const groupInfo = computed<
groupRemark: group?.groupRemark,
ownerId: group?.ownerUserId,
memberCount: group?.memberCount,
joinApproval: group?.joinApproval
}
})
joinApproval: group?.joinApproval,
};
});
/** 群成员列表:直接取 groupStore 缓存map 成 GroupMemberLite 给下游消费(@-mention / 邀请等) */
const groupMembers = computed<GroupMemberLite[]>(() => {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return []
return [];
}
const group = groupStore.getGroup(conversation.targetId)
const group = groupStore.getGroup(conversation.targetId);
return (group?.members || []).map((member) => {
// 显示名走「好友备注 > 群备注 > 真实昵称」三级;头像走 nickname 保稳定
const friend = friendStore.getFriend(member.userId)
const friend = friendStore.getFriend(member.userId);
return {
userId: member.userId,
showName: getMemberDisplayName(member, friend),
nickname: member.nickname,
avatar: member.avatar,
status: member.status,
role: member.role
}
})
})
role: member.role,
};
});
});
/** 切换到群会话时同步群信息 + 成员 */
async function ensureGroupData(groupId: number) {
// 远程拉群信息(群名 / 公告 / 群主等元数据)
await groupStore.fetchGroupInfo(groupId).catch((error) => {
console.warn('[IM MessagePanel] fetchGroupInfo 失败', { groupId }, error)
})
console.warn('[IM MessagePanel] fetchGroupInfo 失败', { groupId }, error);
});
if (isGroupQuit(groupStore.getGroup(groupId))) {
return
return;
}
// 先从 IDB 同步加载群成员,让首帧立即出成员名 / 头像
await groupStore.loadGroupMemberList(groupId).catch((error) => {
console.warn('[IM MessagePanel] loadGroupMemberList 失败', { groupId }, error)
return null
})
const group = groupStore.getGroup(groupId)
console.warn(
'[IM MessagePanel] loadGroupMemberList 失败',
{ groupId },
error,
);
return null;
});
const group = groupStore.getGroup(groupId);
if (!group?.membersLoaded || group.membersExpired) {
groupStore.fetchGroupMemberList(groupId, true).catch((error) => {
console.warn('[IM MessagePanel] fetchGroupMemberList 失败', { groupId }, error)
})
console.warn(
'[IM MessagePanel] fetchGroupMemberList 失败',
{ groupId },
error,
);
});
}
}
/** 群信息抽屉里点"刷新":强拉一次最新群元数据 + 群成员 */
function reloadGroupData() {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return
return;
}
groupStore.fetchGroupInfo(conversation.targetId, true)
groupStore.fetchGroupMemberList(conversation.targetId, true)
groupStore.fetchGroupInfo(conversation.targetId, true);
groupStore.fetchGroupMemberList(conversation.targetId, true);
}
const historyDialogRef = ref<InstanceType<typeof MessageHistory>>() // 历史消息抽屉 ref「聊天历史」icon / 抽屉「查找聊天内容」入口都调 open() 触发
const sideVisible = ref(false) // 信息抽屉开关:群聊 / 私聊共用一个 ref
const muteMemberDialogRef = ref<InstanceType<typeof GroupMuteMemberDialog>>()
const callMemberPickerRef = ref<InstanceType<typeof RtcCallMemberPickerDialog>>()
const pendingMediaType = ref<null | number>(null) // 群通话发起:成员选择弹窗打开期间临时持有的 mediaType
const historyDialogRef = ref<InstanceType<typeof MessageHistory>>(); // 历史消息抽屉 ref「聊天历史」icon / 抽屉「查找聊天内容」入口都调 open() 触发
const sideVisible = ref(false); // 信息抽屉开关:群聊 / 私聊共用一个 ref
const muteMemberDialogRef = ref<InstanceType<typeof GroupMuteMemberDialog>>();
const callMemberPickerRef =
ref<InstanceType<typeof RtcCallMemberPickerDialog>>();
const pendingMediaType = ref<null | number>(null); // 群通话发起:成员选择弹窗打开期间临时持有的 mediaType
/** 消息右键菜单「禁言」→ 打开时长选择弹窗 */
function handleMuteMember(groupId: number, userId: number, displayName: string) {
muteMemberDialogRef.value?.open(groupId, userId, displayName)
function handleMuteMember(
groupId: number,
userId: number,
displayName: string,
) {
muteMemberDialogRef.value?.open(groupId, userId, displayName);
}
/** 信息抽屉的 toggle跟 header 上 3 点图标按钮共用 */
function toggleSide() {
sideVisible.value = !sideVisible.value
sideVisible.value = !sideVisible.value;
}
const callPopoverVisible = ref(false) // 私聊通话入口popover 触发;点 语音 / 视频 直接发起
const callInviting = ref(false) // 通话发起中
const callPopoverVisible = ref(false); // 私聊通话入口popover 触发;点 语音 / 视频 直接发起
const callInviting = ref(false); // 通话发起中
async function startPrivateCall(mediaType: number) {
callPopoverVisible.value = false
const conversation = conversationStore.activeConversation
callPopoverVisible.value = false;
const conversation = conversationStore.activeConversation;
if (!conversation) {
return
return;
}
await doInvite({
conversationType: ImConversationType.PRIVATE,
mediaType,
inviteeIds: [conversation.targetId]
})
inviteeIds: [conversation.targetId],
});
}
/** 群通话入口:默认语音直接弹选人;与微信群通话一致,进通话后用户按需开摄像头 */
function handleGroupCall() {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation) {
return
return;
}
pendingMediaType.value = ImRtcCallMediaType.VOICE
callMemberPickerRef.value?.open({ groupId: conversation.targetId, mode: 'invite' })
pendingMediaType.value = ImRtcCallMediaType.VOICE;
callMemberPickerRef.value?.open({
groupId: conversation.targetId,
mode: 'invite',
});
}
/** 选人弹窗确认;带选中 ID 发起群通话 */
async function onCallMemberPicked(selectedIds: number[]) {
const conversation = conversationStore.activeConversation
const mediaType = pendingMediaType.value
pendingMediaType.value = null
if (!conversation || mediaType == null || selectedIds.length === 0) {
return
const conversation = conversationStore.activeConversation;
const mediaType = pendingMediaType.value;
pendingMediaType.value = null;
if (!conversation || mediaType === null || selectedIds.length === 0) {
return;
}
await doInvite({
conversationType: ImConversationType.GROUP,
mediaType,
groupId: conversation.targetId,
inviteeIds: selectedIds
})
inviteeIds: selectedIds,
});
}
/** 实际调 create 接口;统一处理成功 / ENDED如忙线立即结束 */
async function doInvite(reqVO: {
conversationType: number
groupId?: number
inviteeIds: number[]
mediaType: number
conversationType: number;
groupId?: number;
inviteeIds: number[];
mediaType: number;
}) {
if (callInviting.value) {
return
return;
}
if (rtcStore.isActive) {
message.warning('当前已有通话')
return
message.warning('当前已有通话');
return;
}
callInviting.value = true
callInviting.value = true;
try {
const data = await createCall(reqVO)
const data = await createCall(reqVO);
// 后端已 INSERT + 立即 end如忙线toast 提示,不进 INVITING 阶段chat tip 由 RTC_CALL_END 推送写入消息流
if (data.status === ImRtcCallStatus.ENDED) {
message.warning(resolveCallEndReasonText(data.endReason))
return
message.warning(resolveCallEndReasonText(data.endReason));
return;
}
// 正常进入 INVITING 阶段:走 store 逻辑发起通话,后续状态更新 / 消息流更新由 RTC 模块监听推送处理
rtcStore.startInviting(data)
rtcStore.startInviting(data);
} finally {
callInviting.value = false
callInviting.value = false;
}
}
/** 当前私聊对应的好友(抽屉头部展示用) */
const privateFriend = computed(() => {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation || conversation.type !== ImConversationType.PRIVATE) {
return undefined
return undefined;
}
return friendStore.getFriend(conversation.targetId)
})
return friendStore.getFriend(conversation.targetId);
});
/** 计算距离底部的像素 */
function distanceFromBottom(): number {
const el = listRef.value
const el = listRef.value;
if (!el) {
return 0
return 0;
}
return el.scrollHeight - el.scrollTop - el.clientHeight
return el.scrollHeight - el.scrollTop - el.clientHeight;
}
/**
@@ -364,11 +397,11 @@ function distanceFromBottom(): number {
* - 不在底部:显示"回到底部"浮窗,新消息会累计到 newMessageCount
*/
function handleScroll() {
const dist = distanceFromBottom()
const atBottom = dist <= BOTTOM_THRESHOLD
showJumpToBottom.value = !atBottom
const dist = distanceFromBottom();
const atBottom = dist <= BOTTOM_THRESHOLD;
showJumpToBottom.value = !atBottom;
if (atBottom) {
newMessageCount.value = 0
newMessageCount.value = 0;
}
}
@@ -380,33 +413,36 @@ function handleScroll() {
async function scrollToBottom(smooth = false) {
// 1. 滚到当前 scrollHeight 的底部(图片 / 视频还在加载时只是大致到底)
// 1.1 等 v-for 把新消息真正渲染进 DOM 后再算 scrollHeight否则差最后一条的位置
await nextTick()
await nextTick();
if (!listRef.value) {
return
return;
}
// 1.2 触发滚动smooth 仅 user 主动点「回到底部」用,初始 / 自动滚走 auto 避免动画拖拽感
listRef.value.scrollTo({
top: listRef.value.scrollHeight,
behavior: smooth ? 'smooth' : 'auto'
})
newMessageCount.value = 0
showJumpToBottom.value = false
behavior: smooth ? 'smooth' : 'auto',
});
newMessageCount.value = 0;
showJumpToBottom.value = false;
// 1.3 记下「期望停下的 scrollTop」图片 / 视频加载完底部会上移scrollTop 没动就说明用户没手动滚走
// 不能用 distanceFromBottom 判断:底部上移会让 distance 变大,被误判为「用户滚走了」直接放弃补滚
const expectedScrollTop = listRef.value.scrollHeight - listRef.value.clientHeight
const expectedScrollTop =
listRef.value.scrollHeight - listRef.value.clientHeight;
// 2. 等媒体加载完后补滚到真实底部
// 2.1 等容器内未加载完的图片 / 视频元数据;加载完后 scrollHeight 会增长到真实底部
await waitMediaSettled()
await waitMediaSettled();
if (!listRef.value) {
return
return;
}
// 2.2 仅在用户没手动滚走时scrollTop 仍贴近 expectedScrollTop才补滚避免等待期间用户上翻被打断
if (Math.abs(listRef.value.scrollTop - expectedScrollTop) > BOTTOM_THRESHOLD) {
return
if (
Math.abs(listRef.value.scrollTop - expectedScrollTop) > BOTTOM_THRESHOLD
) {
return;
}
// 2.3 补滚到新底部
listRef.value.scrollTo({ top: listRef.value.scrollHeight, behavior: 'auto' })
listRef.value.scrollTo({ top: listRef.value.scrollHeight, behavior: 'auto' });
}
/**
@@ -417,13 +453,19 @@ async function scrollToBottom(smooth = false) {
function waitMediaSettled(): Promise<void> {
// 1. 收集容器内未加载完的图片 / 视频
if (!listRef.value) {
return Promise.resolve()
return Promise.resolve();
}
// 1.1 一次扫 img + video按 element 类型分别看「complete / readyState」过滤 pending
const pendingMedia = [...listRef.value.querySelectorAll<HTMLImageElement | HTMLVideoElement>('img, video')].filter((el) => (el instanceof HTMLImageElement ? !el.complete : el.readyState < 1))
const pendingMedia = [
...listRef.value.querySelectorAll<HTMLImageElement | HTMLVideoElement>(
'img, video',
),
].filter((el) =>
el instanceof HTMLImageElement ? !el.complete : el.readyState < 1,
);
// 1.2 没有 pending 直接返回,省掉 Promise.race / setTimeout 闭包构造
if (pendingMedia.length === 0) {
return Promise.resolve()
return Promise.resolve();
}
// 2. 等所有 pending 资源 load / error最长 2s 兜底
@@ -432,15 +474,16 @@ function waitMediaSettled(): Promise<void> {
pendingMedia.map(
(el) =>
new Promise<void>((resolve) => {
const loadedEvent = el instanceof HTMLImageElement ? 'load' : 'loadedmetadata'
el.addEventListener(loadedEvent, () => resolve(), { once: true })
el.addEventListener('error', () => resolve(), { once: true })
})
)
).then(() => undefined)
const loadedEvent =
el instanceof HTMLImageElement ? 'load' : 'loadedmetadata';
el.addEventListener(loadedEvent, () => resolve(), { once: true });
el.addEventListener('error', () => resolve(), { once: true });
}),
),
).then(() => undefined);
// 2.2 2s 超时兜底,防止超大资源 / 网络挂起把整个滚动跟进永久卡住
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000))
return Promise.race([loadAll, timeout])
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000));
return Promise.race([loadAll, timeout]);
}
/**
@@ -454,22 +497,24 @@ function waitMediaSettled(): Promise<void> {
*/
async function handleLocate(messageId: number) {
if (!messageId) {
return
return;
}
await nextTick()
await nextTick();
if (!listRef.value) {
return
return;
}
const target = listRef.value.querySelector<HTMLElement>(`[data-message-id="${messageId}"]`)
const target = listRef.value.querySelector<HTMLElement>(
`[data-message-id="${messageId}"]`,
);
if (!target) {
message.warning('原消息不在视野')
return
message.warning('原消息不在视野');
return;
}
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
target.classList.add('message-panel__message-anchor--highlight')
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
target.classList.add('message-panel__message-anchor--highlight');
setTimeout(() => {
target.classList.remove('message-panel__message-anchor--highlight')
}, 1600)
target.classList.remove('message-panel__message-anchor--highlight');
}, 1600);
}
/**
@@ -481,21 +526,21 @@ watch(
() => messages.value.length,
(newLen, oldLen) => {
// 仅处理新增delta > 0删除 / 撤回让 length 减少时不动滚动状态
const delta = (newLen || 0) - (oldLen || 0)
const delta = (newLen || 0) - (oldLen || 0);
if (delta <= 0) {
return
return;
}
// 用 BOTTOM_THRESHOLD80px做容差用户稍微往上翻几行就视作"不在底部"
// 否则一直 auto-scroll 会把人正在读的内容顶走,体验糟糕
const dist = distanceFromBottom()
const dist = distanceFromBottom();
if (dist <= BOTTOM_THRESHOLD) {
scrollToBottom()
scrollToBottom();
} else {
newMessageCount.value += delta
showJumpToBottom.value = true
newMessageCount.value += delta;
showJumpToBottom.value = true;
}
}
)
},
);
/**
* 切换会话:清空"在不在底部"相关状态、强制滚到底部、群会话预拉资料
@@ -505,26 +550,28 @@ watch(
watch(
() => [
conversationStore.activeConversation?.type,
conversationStore.activeConversation?.targetId
conversationStore.activeConversation?.targetId,
],
([type, targetId]) => {
// 切会话时上一会话的「未读累计 + 浮窗显示」必须清掉,否则会带到新会话里看起来很突兀
newMessageCount.value = 0
showJumpToBottom.value = false
newMessageCount.value = 0;
showJumpToBottom.value = false;
// 抽屉里展示的群信息 / 好友信息属于上一会话,切会话时统一关掉
sideVisible.value = false
scrollToBottom()
sideVisible.value = false;
scrollToBottom();
// 仅群聊预拉详情 / 成员(私聊对端在首屏 fetchFriendList 时就拉了)
if (targetId && type === ImConversationType.GROUP) {
ensureGroupData(targetId)
ensureGroupData(targetId);
}
},
{ immediate: true }
)
{ immediate: true },
);
</script>
<template>
<div class="flex flex-1 flex-col min-w-0 bg-[var(--ant-color-fill-secondary)]">
<div
class="flex flex-1 flex-col min-w-0 bg-[var(--ant-color-fill-secondary)]"
>
<template v-if="conversationStore.activeConversation">
<!-- 顶部 header第一行群名 + 右侧图标第二行嵌入置顶气泡仅群聊 + 有置顶 -->
<div
@@ -648,7 +695,11 @@ watch(
<Icon icon="ant-design:user-outlined" :size="11" />
</span>
<span>对方还不是你的朋友</span>
<Icon icon="ep:arrow-right" :size="12" class="text-[var(--ant-color-text-secondary)]" />
<Icon
icon="ep:arrow-right"
:size="12"
class="text-[var(--ant-color-text-secondary)]"
/>
</div>
</div>
</div>
@@ -701,7 +752,10 @@ watch(
<!-- 底部输入框频道单向消息无需输入框多选模式底栏作为浮层盖在上面保持下方输入框尺寸不变 -->
<div v-if="!isChannel" class="relative">
<MessageInput />
<MessageMultiSelectBar v-if="multiSelect.state.active" class="absolute inset-0 z-10" />
<MessageMultiSelectBar
v-if="multiSelect.state.active"
class="absolute inset-0 z-10"
/>
</div>
<!-- 右侧信息抽屉群聊 / 私聊各自一份 -->
@@ -730,10 +784,16 @@ watch(
<MessageMergeDetailDialog ref="mergeDetailDialogRef" />
<!-- 禁言时长选择弹窗 -->
<GroupMuteMemberDialog ref="muteMemberDialogRef" @success="reloadGroupData" />
<GroupMuteMemberDialog
ref="muteMemberDialogRef"
@success="reloadGroupData"
/>
<!-- 群通话成员选择弹窗 -->
<RtcCallMemberPickerDialog ref="callMemberPickerRef" @success="onCallMemberPicked" />
<RtcCallMemberPickerDialog
ref="callMemberPickerRef"
@success="onCallMemberPicked"
/>
</template>
<div
v-else

View File

@@ -1,36 +1,40 @@
<script lang="ts" setup>
import type { Message } from '../../../../types'
import type { GroupMemberLite } from '../../../../components/group';
import type { Message } from '../../../../types';
import { computed, ref } from 'vue'
import { computed, ref } from 'vue';
import { CommonStatusEnum } from '@vben/constants'
import { CommonStatusEnum } from '@vben/constants';
import { Popover, TabPane, Tabs } from 'ant-design-vue'
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 { PagedScroller } from '../../../../components'
import { GroupMember, type GroupMemberLite } from '../../../../components/group'
import { useMessageStore } from '../../../../store/messageStore'
import {
ImConversationType,
ImMessageReceiptStatus,
} from '../../../../../utils/constants';
import { PagedScroller } from '../../../../components';
import { GroupMember } from '../../../../components/group';
import { useMessageStore } from '../../../../store/messageStore';
defineOptions({ name: 'ImMessageReadStatus' })
defineOptions({ name: 'ImMessageReadStatus' });
const props = defineProps<{
// 当前群编号;供 loadReadUsers 作为 /im/message/group/get-read-users 的入参
groupId: number
groupId: number;
// 当前群所有成员(外部 MessagePanel.groupMembers 传入;没有就传空数组,未读列表会变空但不报错)
groupMembers: GroupMemberLite[]
message: Message
}>()
groupMembers: GroupMemberLite[];
message: Message;
}>();
const messageStore = useMessageStore()
const messageStore = useMessageStore();
// popover 开关show 时拉已读名单,关闭后保留 readUserIds 缓存(重开同一条消息不再请求)
const popVisible = ref(false)
const activeTab = ref<'read' | 'unread'>('read')
const popVisible = ref(false);
const activeTab = ref<'read' | 'unread'>('read');
// 服务端返回的"已读这条消息的 userId 列表",未读靠 visibleMembers 减去这份得到
const readUserIds = ref<number[]>([])
const readUserIds = ref<number[]>([]);
/**
* 标签文案:
@@ -40,11 +44,11 @@ const readUserIds = ref<number[]>([])
*/
const label = computed(() => {
if (props.message.receiptStatus === ImMessageReceiptStatus.DONE) {
return '全部已读'
return '全部已读';
}
const readCount = props.message.readCount || 0
return readCount > 0 ? `${readCount} 人已读` : '未读'
})
const readCount = props.message.readCount || 0;
return readCount > 0 ? `${readCount} 人已读` : '未读';
});
/**
* 这条消息"应该被谁看到"的可见成员集合(已读 / 未读两个 tab 共用基底)
@@ -56,25 +60,29 @@ const label = computed(() => {
* 3. 已退群status === DISABLE他读没读已经不关心UI 不展示
*/
const visibleMembers = computed<GroupMemberLite[]>(() => {
const receiverUserIds = props.message.receiverUserIds
const isDirected = !!receiverUserIds && receiverUserIds.length > 0
const receiverUserIds = props.message.receiverUserIds;
const isDirected = !!receiverUserIds && receiverUserIds.length > 0;
return props.groupMembers.filter(
(member) =>
member.status !== CommonStatusEnum.DISABLE &&
member.userId !== props.message.senderId &&
(!isDirected || receiverUserIds.includes(member.userId))
)
})
(!isDirected || receiverUserIds.includes(member.userId)),
);
});
/** 已读 = 可见成员 ∩ readUserIds */
const readMembers = computed(() =>
visibleMembers.value.filter((member) => readUserIds.value.includes(member.userId))
)
visibleMembers.value.filter((member) =>
readUserIds.value.includes(member.userId),
),
);
/** 未读 = 可见成员 readUserIds */
const unreadMembers = computed(() =>
visibleMembers.value.filter((member) => !readUserIds.value.includes(member.userId))
)
visibleMembers.value.filter(
(member) => !readUserIds.value.includes(member.userId),
),
);
/**
* 拉取已读用户 id 列表
@@ -89,27 +97,27 @@ const unreadMembers = computed(() =>
*/
async function loadReadUsers() {
if (!props.message.id) {
return
return;
}
try {
const userIds = await apiGetGroupReadUsers({
groupId: props.groupId,
messageId: props.message.id
})
readUserIds.value = userIds || []
const readCount = readUserIds.value.length
messageId: props.message.id,
});
readUserIds.value = userIds || [];
const readCount = readUserIds.value.length;
// 全可见成员都已读 → 更新为 DONE让外面 label 直接命中「全部已读」分支;
// 否则只更新 readCountreceiptStatus 维持不变PENDING
const allRead = readCount > 0 && readCount >= visibleMembers.value.length
const allRead = readCount > 0 && readCount >= visibleMembers.value.length;
messageStore.applyMessageReadReceipt({
conversationType: ImConversationType.GROUP,
targetId: props.groupId,
groupMessageId: props.message.id,
readCount,
receiptStatus: allRead ? ImMessageReceiptStatus.DONE : undefined
})
receiptStatus: allRead ? ImMessageReceiptStatus.DONE : undefined,
});
} catch (error) {
console.error('[IM] 拉取群已读列表失败:', error)
console.error('[IM] 拉取群已读列表失败:', error);
}
}
</script>
@@ -138,9 +146,18 @@ async function loadReadUsers() {
<template #content>
<Tabs v-model:active-key="activeTab" centered>
<TabPane :tab="`已读(${readMembers.length})`" key="read">
<PagedScroller :items="readMembers" :page-size="20" item-key="userId" class="h-75">
<PagedScroller
:items="readMembers"
:page-size="20"
item-key="userId"
class="h-75"
>
<template #default="{ item }">
<GroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" />
<GroupMember
:member="item as GroupMemberLite"
:height="40"
:clickable="false"
/>
</template>
</PagedScroller>
<div
@@ -151,9 +168,18 @@ async function loadReadUsers() {
</div>
</TabPane>
<TabPane :tab="`未读(${unreadMembers.length})`" key="unread">
<PagedScroller :items="unreadMembers" :page-size="20" item-key="userId" class="h-75">
<PagedScroller
:items="unreadMembers"
:page-size="20"
item-key="userId"
class="h-75"
>
<template #default="{ item }">
<GroupMember :member="item as GroupMemberLite" :height="40" :clickable="false" />
<GroupMember
:member="item as GroupMemberLite"
:height="40"
:clickable="false"
/>
</template>
</PagedScroller>
<div

View File

@@ -1,82 +1,90 @@
<script lang="ts" setup>
import { computed } from 'vue'
import type {
AudioMessage,
CardMessage,
FaceMessage,
FileMessage,
ImageMessage,
MaterialMessage,
QuoteMessage,
TextMessage,
VideoMessage,
} from '#/views/im/utils/message';
import { IconifyIcon as Icon } from '@vben/icons'
import { formatFileSize } from '@vben/utils'
import { computed } from 'vue';
import { CardLineLabel } from '#/views/im/home/components/card'
import { ImContentType } from '#/views/im/utils/constants'
import { getClientConversationId } from '#/views/im/utils/db'
import {
type AudioMessage,
type CardMessage,
type FaceMessage,
type FileMessage,
getFileIconInfo,
type ImageMessage,
type MaterialMessage,
parseMessage,
type QuoteMessage,
type TextMessage,
type VideoMessage
} from '#/views/im/utils/message'
import { formatSeconds } from '#/views/im/utils/time'
import { getSenderDisplayName } from '#/views/im/utils/user'
import { IconifyIcon as Icon } from '@vben/icons';
import { formatFileSize } from '@vben/utils';
import { useConversationStore } from '../../../../store/conversationStore'
import { useMessageStore } from '../../../../store/messageStore'
import { CardLineLabel } from '#/views/im/home/components/card';
import { ImContentType } from '#/views/im/utils/constants';
import { getClientConversationId } from '#/views/im/utils/db';
import { getFileIconInfo, parseMessage } from '#/views/im/utils/message';
import { formatSeconds } from '#/views/im/utils/time';
import { getSenderDisplayName } from '#/views/im/utils/user';
defineOptions({ name: 'ImReplyPreview' })
import { useConversationStore } from '../../../../store/conversationStore';
import { useMessageStore } from '../../../../store/messageStore';
defineOptions({ name: 'ImReplyPreview' });
const props = withDefaults(
defineProps<{
/** 气泡内为 true 支持点击跳转,输入条为 false */
clickable?: boolean
clickable?: boolean;
/** 输入条为 true 显示 × 关闭按钮 */
closable?: boolean
closable?: boolean;
/** 自己发送的气泡为 true把竖线镜像到右侧与气泡同侧 */
mirrored?: boolean
quote: QuoteMessage
mirrored?: boolean;
quote: QuoteMessage;
}>(),
{
clickable: false,
closable: false,
mirrored: false
}
)
mirrored: false,
},
);
const emit = defineEmits<{
close: []
locate: [messageId: number]
}>()
close: [];
locate: [messageId: number];
}>();
const MAX_TEXT_PREVIEW_LEN = 60 // 文本摘要在引用块里展示的最大字符数
const MAX_TEXT_PREVIEW_LEN = 60; // 文本摘要在引用块里展示的最大字符数
const conversationStore = useConversationStore()
const messageStore = useMessageStore()
const conversationStore = useConversationStore();
const messageStore = useMessageStore();
/** 在当前会话消息列表里查找原消息,仅用于实时判断是否已撤回;摘要 / 缩略图都从 quote.content 直接派生 */
const liveMessage = computed(() => {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation || !props.quote.messageId) {
return undefined
return undefined;
}
return messageStore
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
.find((message) => message.id === props.quote.messageId)
})
.getMessages(
getClientConversationId(conversation.type, conversation.targetId),
)
.find((message) => message.id === props.quote.messageId);
});
/** 命中本地缓存且 type === RECALL 才判定为已撤回;不在缓存的当快照仍有效 */
const isRecalled = computed(() => liveMessage.value?.type === ImContentType.RECALL)
const isRecalled = computed(
() => liveMessage.value?.type === ImContentType.RECALL,
);
/** 渲染时实时算,与气泡上方显示名走同一套规则,避免备注变更后引用块陈旧 */
const senderName = computed(() => {
const conversation = conversationStore.activeConversation
const conversation = conversationStore.activeConversation;
if (!conversation) {
return ''
return '';
}
return getSenderDisplayName(props.quote.senderId, conversation.type, conversation.targetId)
})
return getSenderDisplayName(
props.quote.senderId,
conversation.type,
conversation.targetId,
);
});
/** quote.content 解析一次缓存,让多个 computed 复用,长会话每条引用气泡少一次 JSON.parse */
type AnyQuotePayload = Partial<
@@ -88,49 +96,53 @@ type AnyQuotePayload = Partial<
MaterialMessage &
TextMessage &
VideoMessage
>
const parsedPayload = computed(() => parseMessage<AnyQuotePayload>(props.quote.content))
>;
const parsedPayload = computed(() =>
parseMessage<AnyQuotePayload>(props.quote.content),
);
const isText = computed(() => props.quote.type === ImContentType.TEXT)
const isFile = computed(() => props.quote.type === ImContentType.FILE)
const isVoice = computed(() => props.quote.type === ImContentType.VOICE)
const isCard = computed(() => props.quote.type === ImContentType.CARD)
const isFace = computed(() => props.quote.type === ImContentType.FACE)
const isMaterial = computed(() => props.quote.type === ImContentType.MATERIAL)
const isText = computed(() => props.quote.type === ImContentType.TEXT);
const isFile = computed(() => props.quote.type === ImContentType.FILE);
const isVoice = computed(() => props.quote.type === ImContentType.VOICE);
const isCard = computed(() => props.quote.type === ImContentType.CARD);
const isFace = computed(() => props.quote.type === ImContentType.FACE);
const isMaterial = computed(() => props.quote.type === ImContentType.MATERIAL);
/** 文本超过 MAX_TEXT_PREVIEW_LEN 截断,长内容不撑爆引用块 */
const textPreview = computed(() => {
const text = parsedPayload.value?.content ?? ''
return text.length <= MAX_TEXT_PREVIEW_LEN ? text : `${text.slice(0, Math.max(0, MAX_TEXT_PREVIEW_LEN))}`
})
const text = parsedPayload.value?.content ?? '';
return text.length <= MAX_TEXT_PREVIEW_LEN
? text
: `${text.slice(0, Math.max(0, MAX_TEXT_PREVIEW_LEN))}`;
});
/** 文件 icon按扩展名挑色跟主气泡渲染同源 */
const fileIcon = computed(() => getFileIconInfo(parsedPayload.value?.name))
const fileIcon = computed(() => getFileIconInfo(parsedPayload.value?.name));
/** 缩略图 URL图片 / 视频 / 表情贴图 / 频道素材封面从 quote.content 直接取,不依赖本地缓存 */
const thumbnailUrl = computed<string | undefined>(() => {
if (isRecalled.value) {
return undefined
return undefined;
}
const { type } = props.quote
const { type } = props.quote;
if (type === ImContentType.IMAGE) {
return parsedPayload.value?.thumbnailUrl || parsedPayload.value?.url
return parsedPayload.value?.thumbnailUrl || parsedPayload.value?.url;
}
if (type === ImContentType.VIDEO || type === ImContentType.MATERIAL) {
return parsedPayload.value?.coverUrl
return parsedPayload.value?.coverUrl;
}
if (type === ImContentType.FACE) {
return parsedPayload.value?.url
return parsedPayload.value?.url;
}
return undefined
})
return undefined;
});
/** 仅 clickable 且未撤回时触发跳转 */
function onClick() {
if (!props.clickable || isRecalled.value) {
return
return;
}
emit('locate', props.quote.messageId)
emit('locate', props.quote.messageId);
}
</script>
@@ -154,9 +166,11 @@ function onClick() {
? 'pl-1 pr-2 border-r-2 border-r-solid border-r-[var(--ant-color-border)]'
: 'pl-2 pr-1 border-l-2 border-l-solid border-l-[var(--ant-color-border)]',
{
'cursor-pointer hover:text-[var(--ant-color-text)]': clickable && !isRecalled,
'hover:bg-[var(--ant-color-fill-secondary)]': (clickable && !isRecalled) || closable
}
'cursor-pointer hover:text-[var(--ant-color-text)]':
clickable && !isRecalled,
'hover:bg-[var(--ant-color-fill-secondary)]':
(clickable && !isRecalled) || closable,
},
]"
@click="onClick"
>
@@ -166,11 +180,18 @@ function onClick() {
<span v-if="isRecalled" class="italic">原消息已撤回</span>
<!-- 文本 -->
<span v-else-if="isText" class="min-w-0 line-clamp-2 break-words">{{ textPreview }}</span>
<span v-else-if="isText" class="min-w-0 line-clamp-2 break-words">{{
textPreview
}}</span>
<!-- 文件icon + 文件名 + 大小 -->
<template v-else-if="isFile">
<Icon :icon="fileIcon.icon" :color="fileIcon.color" :size="14" class="flex-shrink-0" />
<Icon
:icon="fileIcon.icon"
:color="fileIcon.color"
:size="14"
class="flex-shrink-0"
/>
<span v-if="parsedPayload?.name" class="min-w-0 line-clamp-2 break-words">
{{ parsedPayload.name }}
</span>
@@ -208,7 +229,10 @@ function onClick() {
<!-- 频道素材[频道] + 标题 + 封面缩略图 -->
<template v-else-if="isMaterial">
<span class="flex-shrink-0">[频道]</span>
<span v-if="parsedPayload?.title" class="min-w-0 line-clamp-2 break-words">
<span
v-if="parsedPayload?.title"
class="min-w-0 line-clamp-2 break-words"
>
{{ parsedPayload.title }}
</span>
</template>

View File

@@ -5,31 +5,31 @@
- text 段原样输出
-->
<script lang="ts" setup>
import type { TipSegment } from '#/views/im/utils/message'
import type { TipSegment } from '#/views/im/utils/message';
import { IM_AT_ALL_USER_ID } from '#/views/im/utils/constants'
import { openMentionUserInfoCardAtEvent } from '#/views/im/utils/user'
import { IM_AT_ALL_USER_ID } from '#/views/im/utils/constants';
import { openMentionUserInfoCardAtEvent } from '#/views/im/utils/user';
defineOptions({ name: 'ImTipSegments' })
defineOptions({ name: 'ImTipSegments' });
defineProps<{
segments: TipSegment[]
}>()
segments: TipSegment[];
}>();
/** @全体成员是广播 mention仅高亮配色不挂可点击交互 */
function isClickableMention(segment: { userId: number }): boolean {
return segment.userId !== IM_AT_ALL_USER_ID
return segment.userId !== IM_AT_ALL_USER_ID;
}
/** mention 段点击fallbackName 取 segment 文本,避免 friend / member 都查不到时弹空 */
function handleMentionClick(
segment: { text: string; type: 'mention'; userId: number; },
event: MouseEvent
segment: { text: string; type: 'mention'; userId: number },
event: MouseEvent,
) {
if (!isClickableMention(segment)) {
return
return;
}
openMentionUserInfoCardAtEvent(segment.userId, event, segment.text)
openMentionUserInfoCardAtEvent(segment.userId, event, segment.text);
}
</script>
@@ -40,7 +40,9 @@ function handleMentionClick(
class="text-[#576b95]"
:class="{ 'cursor-pointer hover:underline': isClickableMention(segment) }"
@click.stop="handleMentionClick(segment, $event)"
>{{ segment.text }}</span>
>
{{ segment.text }}
</span>
<a
v-else-if="segment.type === 'link'"
:href="segment.href"
@@ -48,7 +50,9 @@ function handleMentionClick(
rel="noopener noreferrer"
class="text-[#576b95] hover:underline break-all"
@click.stop
>{{ segment.text }}</a>
>
{{ segment.text }}
</a>
<span v-else>{{ segment.text }}</span>
</template>
</template>

View File

@@ -1,64 +1,74 @@
<script lang="ts" setup>
import type { Conversation } from '../../types'
import type { Conversation } from '../../types';
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue';
import { IconifyIcon as Icon } from '@vben/icons'
import { IconifyIcon as Icon } from '@vben/icons';
import { Button, Dropdown, Input, Menu } from 'ant-design-vue'
import { Button, Dropdown, Input, Menu } from 'ant-design-vue';
import { ImConversationType } from '../../../utils/constants'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { StorageKeys } from '../../../utils/db'
import { getGroupDisplayName } from '../../../utils/user'
import { ResizableAside } from '../../components'
import { FriendAddDialog } from '../../components/friend'
import { GroupCreateDialog } from '../../components/group'
import { useConversationStore } from '../../store/conversationStore'
import { useGroupStore } from '../../store/groupStore'
import { useImUiStore } from '../../store/uiStore'
import { ConversationItem, MessagePanel } from './components'
import { ImConversationType } from '../../../utils/constants';
import {
filterConversationsByKeyword,
getConversationKey,
} from '../../../utils/conversation';
import { StorageKeys } from '../../../utils/db';
import { getGroupDisplayName } from '../../../utils/user';
import { ResizableAside } from '../../components';
import { FriendAddDialog } from '../../components/friend';
import { GroupCreateDialog } from '../../components/group';
import { useConversationStore } from '../../store/conversationStore';
import { useGroupStore } from '../../store/groupStore';
import { useImUiStore } from '../../store/uiStore';
import { ConversationItem, MessagePanel } from './components';
defineOptions({ name: 'ImMessagePage' })
defineOptions({ name: 'ImMessagePage' });
const conversationStore = useConversationStore()
const groupStore = useGroupStore()
const uiStore = useImUiStore()
const conversationStore = useConversationStore();
const groupStore = useGroupStore();
const uiStore = useImUiStore();
// ==================== 会话列表 ====================
const keyword = ref('')
const keyword = ref('');
const sortedConversations = computed(() => conversationStore.getSortedConversationList)
const sortedConversations = computed(
() => conversationStore.getSortedConversationList,
);
/** 顶部搜索框过滤会话:只按 name 模糊匹配,避免命中 lastContent 等次要字段干扰 */
const filteredConversations = computed(() =>
filterConversationsByKeyword(sortedConversations.value, keyword.value)
)
filterConversationsByKeyword(sortedConversations.value, keyword.value),
);
// ==================== 置顶相关 ====================
const PINNED_FOLD_THRESHOLD = 3 // 置顶超过该数量时显示折叠入口;以下数量直接铺开(避免单条置顶就出折叠头视觉太重)
const PINNED_FOLD_THRESHOLD = 3; // 置顶超过该数量时显示折叠入口;以下数量直接铺开(避免单条置顶就出折叠头视觉太重)
// 置顶折叠展开态localStorage 持久化,刷新后保留用户上次的选择,对齐微信
const pinnedExpanded = ref(
localStorage.getItem(StorageKeys.localStorage.conversationPinnedExpanded) === 'true'
)
localStorage.getItem(StorageKeys.localStorage.conversationPinnedExpanded) ===
'true',
);
/** toggle + 写盘 */
function togglePinnedExpanded() {
pinnedExpanded.value = !pinnedExpanded.value
pinnedExpanded.value = !pinnedExpanded.value;
localStorage.setItem(
StorageKeys.localStorage.conversationPinnedExpanded,
String(pinnedExpanded.value)
)
String(pinnedExpanded.value),
);
}
/** 置顶会话:单独切片,给折叠头计数 + 折叠区渲染用 */
const pinnedConversations = computed(() => filteredConversations.value.filter((c) => c.top))
const pinnedConversations = computed(() =>
filteredConversations.value.filter((c) => c.top),
);
/** 非置顶会话:折叠态下始终铺开在折叠头之下 */
const normalConversations = computed(() => filteredConversations.value.filter((c) => !c.top))
const normalConversations = computed(() =>
filteredConversations.value.filter((c) => !c.top),
);
/**
* 置顶分两堆visible折叠头之上 = 未读 + 当前激活)/ foldable折叠头之下一次 partition 完成
@@ -66,32 +76,34 @@ const normalConversations = computed(() => filteredConversations.value.filter((c
* 当前激活会话也"钉"在 visible避免点开未读置顶 → 立刻被读 → 列表一闪重排回折叠的体验
*/
const pinnedGroups = computed(() => {
const visible: Conversation[] = []
const foldable: Conversation[] = []
const visible: Conversation[] = [];
const foldable: Conversation[] = [];
for (const conversation of pinnedConversations.value) {
if (isActiveConversation(conversation) || hasUnreadBadge(conversation)) {
visible.push(conversation)
visible.push(conversation);
} else {
foldable.push(conversation)
foldable.push(conversation);
}
}
return { visible, foldable }
})
return { visible, foldable };
});
/** 折叠时只渲 visible未读 / 激活穿透);展开时渲全部 —— 展开后不分组,避免点击折叠区跨组上跳 */
const renderedPinnedConversations = computed(() =>
pinnedExpanded.value ? pinnedConversations.value : pinnedGroups.value.visible
)
pinnedExpanded.value ? pinnedConversations.value : pinnedGroups.value.visible,
);
/** 置顶折叠时是否上浮到折叠头之上:仅以数字徽标为准;免打扰即便有未读也只展示小红点,不参与上浮 */
function hasUnreadBadge(conversation: Conversation): boolean {
return !conversation.silent && (conversation.unreadCount || 0) > 0
return !conversation.silent && (conversation.unreadCount || 0) > 0;
}
/** 是否为当前激活会话 */
function isActiveConversation(conversation: Conversation): boolean {
const active = conversationStore.activeConversation
return !!active && getConversationKey(active) === getConversationKey(conversation)
const active = conversationStore.activeConversation;
return (
!!active && getConversationKey(active) === getConversationKey(conversation)
);
}
/**
@@ -99,36 +111,38 @@ function isActiveConversation(conversation: Conversation): boolean {
* 置顶数 < 阈值也不分组,避免单条置顶就出折叠头视觉太重
*/
const showPinnedSection = computed(
() => !keyword.value.trim() && pinnedConversations.value.length >= PINNED_FOLD_THRESHOLD
)
() =>
!keyword.value.trim() &&
pinnedConversations.value.length >= PINNED_FOLD_THRESHOLD,
);
// ==================== 添加朋友 ====================
const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>() // 添加朋友弹窗 ref右上角 +-下拉「添加朋友」入口调 open() 触发
const friendAddDialogRef = ref<InstanceType<typeof FriendAddDialog>>(); // 添加朋友弹窗 ref右上角 +-下拉「添加朋友」入口调 open() 触发
// ==================== 建群相关 ====================
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>() // 发起群聊弹窗 refhandleOpenCreateGroup 调 open() 打开
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>(); // 发起群聊弹窗 refhandleOpenCreateGroup 调 open() 打开
/** 打开发起群聊弹窗:无锁定项的全局入口 */
function handleOpenCreateGroup() {
createGroupDialogRef.value?.open()
createGroupDialogRef.value?.open();
}
/** 处理建群成功 */
function handleGroupCreated(groupId: number) {
// GroupCreateDialog 已经 upsertGroup 把新群写进 store这里只 get + 打开会话
const group = groupStore.getGroup(groupId)
const group = groupStore.getGroup(groupId);
if (!group) {
return
return;
}
conversationStore.openConversation(
groupId,
ImConversationType.GROUP,
getGroupDisplayName(group),
group.avatar || '',
{ silent: !!group.silent }
)
{ silent: !!group.silent },
);
}
// ==================== 滚动到下一个未读 ====================
@@ -136,59 +150,78 @@ function handleGroupCreated(groupId: number) {
// 含免打扰会话(小红点也算未读);通过维护 lastJumpedConversationKey 让连续点击顺序穿过整个未读列表
/** 上次命中的未读会话 key为空时从未读列表头开始 */
let lastJumpedConversationKey: null | string = null
let lastJumpedConversationKey: null | string = null;
/** 滚动到下一个未读会话(含免打扰);目标被搜索框过滤或藏在置顶折叠区时先解除拦截再滚 */
async function jumpToNextUnread() {
// 含免打扰的全量未读会话;空则直接返回
const unreadList = sortedConversations.value.filter((c) => (c.unreadCount || 0) > 0)
const unreadList = sortedConversations.value.filter(
(c) => (c.unreadCount || 0) > 0,
);
if (unreadList.length === 0) {
return
return;
}
// 从上次命中那条往后推一位首次或上次目标已读完不在列表里时refIndex=-1从头开始
const refIndex = lastJumpedConversationKey
? unreadList.findIndex((c) => getConversationKey(c) === lastJumpedConversationKey)
: -1
const target = unreadList[(refIndex + 1) % unreadList.length]
? unreadList.findIndex(
(c) => getConversationKey(c) === lastJumpedConversationKey,
)
: -1;
const target = unreadList[(refIndex + 1) % unreadList.length];
if (!target) {
return
return;
}
const key = getConversationKey(target)
lastJumpedConversationKey = key
const key = getConversationKey(target);
lastJumpedConversationKey = key;
// 目标被搜索关键字过滤掉:清空 keyword 让它重新进入可见列表
if (keyword.value && !filteredConversations.value.some((c) => getConversationKey(c) === key)) {
keyword.value = ''
if (
keyword.value &&
!filteredConversations.value.some((c) => getConversationKey(c) === key)
) {
keyword.value = '';
}
// 目标藏在置顶折叠区:临时展开;不写 localStorage刷新后还原折叠态
const inFoldable = pinnedGroups.value.foldable.some((c) => getConversationKey(c) === key)
const inFoldable = pinnedGroups.value.foldable.some(
(c) => getConversationKey(c) === key,
);
if (inFoldable && !pinnedExpanded.value) {
pinnedExpanded.value = true
pinnedExpanded.value = true;
}
// 等 keyword / pinnedExpanded 变化反应到 DOM 后再去取目标元素
await nextTick()
const el = document.querySelector(`[data-conversation-key="${key}"]`) as HTMLElement | null
await nextTick();
const el = document.querySelector(
`[data-conversation-key="${key}"]`,
) as HTMLElement | null;
if (!el) {
return
return;
}
// block: 'start' 把目标会话顶到列表可视区第一行;对齐微信"切到下一个未读=列表的第一条"
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
watch(() => uiStore.nextUnreadJumpNonce, jumpToNextUnread)
watch(() => uiStore.nextUnreadJumpNonce, jumpToNextUnread);
</script>
<template>
<!-- 消息 Tab左侧会话列表 + 右侧聊天面板 -->
<div class="flex flex-1 min-w-0 h-full">
<!-- 左侧会话列表可拖拽宽度 -->
<ResizableAside :default-width="260" :storage-key="StorageKeys.localStorage.asideWidth">
<ResizableAside
:default-width="260"
:storage-key="StorageKeys.localStorage.asideWidth"
>
<!-- 顶部搜索框 + "+" 号下拉对齐微信 PC发起群聊 / 添加朋友h-14 与右侧 MessagePanel 头部对齐 -->
<div
class="flex flex-shrink-0 gap-2 items-center h-14 px-4 border-b border-b-solid border-[var(--im-border-color-lighter)]"
>
<Input v-model:value="keyword" placeholder="搜索" allow-clear class="flex-1">
<Input
v-model:value="keyword"
placeholder="搜索"
allow-clear
class="flex-1"
>
<template #prefix>
<Icon icon="ant-design:search-outlined" />
</template>
@@ -248,7 +281,11 @@ watch(() => uiStore.nextUnreadJumpNonce, jumpToNextUnread)
}}
</span>
<Icon
:icon="pinnedExpanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
:icon="
pinnedExpanded
? 'ant-design:up-outlined'
: 'ant-design:down-outlined'
"
:size="11"
class="text-[var(--ant-color-text-placeholder)]"
/>
@@ -276,6 +313,9 @@ watch(() => uiStore.nextUnreadJumpNonce, jumpToNextUnread)
<!-- 添加朋友 / 发起群聊弹窗 -->
<FriendAddDialog ref="friendAddDialogRef" />
<GroupCreateDialog ref="createGroupDialogRef" @created="handleGroupCreated" />
<GroupCreateDialog
ref="createGroupDialogRef"
@created="handleGroupCreated"
/>
</div>
</template>

View File

@@ -1,12 +1,12 @@
import type { ImManagerChannelApi } from '#/api/im/manager/channel'
import type { ImManagerChannelApi } from '#/api/im/manager/channel';
import { acceptHMRUpdate, defineStore } from 'pinia'
import { acceptHMRUpdate, defineStore } from 'pinia';
import { getSimpleChannelList } from '#/api/im/manager/channel'
import { getSimpleChannelList } from '#/api/im/manager/channel';
import { ImConversationType } from '../../utils/constants'
import { getDb } from '../../utils/db'
import { useConversationStore } from './conversationStore'
import { ImConversationType } from '../../utils/constants';
import { getDb } from '../../utils/db';
import { useConversationStore } from './conversationStore';
/**
* IM 频道 Store
@@ -17,13 +17,13 @@ import { useConversationStore } from './conversationStore'
export const useChannelStore = defineStore('imChannelStore', {
state: () => ({
channels: [] as ImManagerChannelApi.Channel[],
loaded: false
loaded: false,
}),
getters: {
getChannel(state): (id: number) => ImManagerChannelApi.Channel | undefined {
return (id: number) => state.channels.find((c) => c.id === id)
}
return (id: number) => state.channels.find((c) => c.id === id);
},
},
actions: {
@@ -32,15 +32,16 @@ export const useChannelStore = defineStore('imChannelStore', {
/** 从 IndexedDB 恢复频道列表 */
async loadChannelList(): Promise<boolean> {
try {
const cached = await getDb().getAll<ImManagerChannelApi.Channel>('channels')
const cached =
await getDb().getAll<ImManagerChannelApi.Channel>('channels');
if (!cached || cached.length === 0) {
return false
return false;
}
this.channels = cached
return true
this.channels = cached;
return true;
} catch (error) {
console.warn('[IM channelStore] 本地频道缓存读取失败', error)
return false
console.warn('[IM channelStore] 本地频道缓存读取失败', error);
return false;
}
},
@@ -48,13 +49,15 @@ export const useChannelStore = defineStore('imChannelStore', {
saveChannelList(): void {
void getDb()
.transaction(['channels'], 'readwrite', async (tx) => {
const db = getDb()
await db.clearStore('channels', tx)
const db = getDb();
await db.clearStore('channels', tx);
for (const channel of this.channels) {
await db.put('channels', channel, tx)
await db.put('channels', channel, tx);
}
})
.catch((error) => console.warn('[IM channelStore] 本地频道缓存写入失败', error))
.catch((error) =>
console.warn('[IM channelStore] 本地频道缓存写入失败', error),
);
},
// ==================== 远端拉取 ====================
@@ -62,47 +65,51 @@ export const useChannelStore = defineStore('imChannelStore', {
/** 拉取启用的频道精简列表;成功后回填会话列表已有的频道 name / avatar覆盖 IDB 旧占位 */
async fetchChannelList(force = false) {
if (this.loaded && !force) {
return
return;
}
try {
this.channels = (await getSimpleChannelList()) || []
this.loaded = true
this.syncChannelConversationMetadata()
this.saveChannelList()
this.channels = (await getSimpleChannelList()) || [];
this.loaded = true;
this.syncChannelConversationMetadata();
this.saveChannelList();
} catch (error) {
console.warn('[IM channelStore] fetchChannelList 失败', error)
console.warn('[IM channelStore] fetchChannelList 失败', error);
}
},
/** 用最新的频道信息覆盖已有 CHANNEL 会话的 name / avatar */
syncChannelConversationMetadata() {
const conversationStore = useConversationStore()
const indexed = new Map(this.channels.map((c) => [c.id, c]))
const conversationStore = useConversationStore();
const indexed = new Map(this.channels.map((c) => [c.id, c]));
conversationStore.conversations.forEach((conversation) => {
if (conversation.type !== ImConversationType.CHANNEL) {
return
return;
}
const channel = indexed.get(conversation.targetId)
const channel = indexed.get(conversation.targetId);
if (!channel) {
return
return;
}
conversationStore.updateConversation(ImConversationType.CHANNEL, conversation.targetId, {
name: channel.name,
avatar: channel.avatar
})
})
conversationStore.updateConversation(
ImConversationType.CHANNEL,
conversation.targetId,
{
name: channel.name,
avatar: channel.avatar,
},
);
});
},
/** 清空频道内存 */
clear() {
this.channels = []
this.loaded = false
}
}
})
this.channels = [];
this.loaded = false;
},
},
});
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useChannelStore, import.meta.hot))
import.meta.hot.accept(acceptHMRUpdate(useChannelStore, import.meta.hot));
}
export const useChannelStoreWithOut = () => useChannelStore()
export const useChannelStoreWithOut = () => useChannelStore();

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,16 @@
import type { ImFacePackApi } from '#/api/im/face/pack'
import type { ImFaceUserItemApi } from '#/api/im/face/userItem'
import type { ImFacePackApi } from '#/api/im/face/pack';
import type { ImFaceUserItemApi } from '#/api/im/face/userItem';
import { ref } from 'vue'
import { ref } from 'vue';
import { acceptHMRUpdate, defineStore } from 'pinia'
import { acceptHMRUpdate, defineStore } from 'pinia';
import { getFacePackList as apiGetFacePackList } from '#/api/im/face/pack'
import { createFaceUserItem as apiCreateFaceUserItem, deleteFaceUserItem as apiDeleteFaceUserItem, getFaceUserItemList as apiGetFaceUserItemList } from '#/api/im/face/userItem'
import { getFacePackList as apiGetFacePackList } from '#/api/im/face/pack';
import {
createFaceUserItem as apiCreateFaceUserItem,
deleteFaceUserItem as apiDeleteFaceUserItem,
getFaceUserItemList as apiGetFaceUserItemList,
} from '#/api/im/face/userItem';
/**
* IM 表情面板数据 store系统表情包 + 个人表情)
@@ -16,14 +20,13 @@ import { createFaceUserItem as apiCreateFaceUserItem, deleteFaceUserItem as apiD
* - 个人表情切到「收藏」tab / 长按消息「添加到表情」时按需拉
*/
export const useFaceStore = defineStore('imFace', () => {
/** 系统表情包列表(含每个包的 items运营管理后台维护 */
const facePacks = ref<ImFacePackApi.FacePackUser[]>([])
const facePacks = ref<ImFacePackApi.FacePackUser[]>([]);
/** 个人表情包列表(用户长按「添加到表情」/ 上传产生) */
const faceUserItems = ref<ImFaceUserItemApi.FaceUserItem[]>([])
const faceUserItems = ref<ImFaceUserItemApi.FaceUserItem[]>([]);
/** clear() 时递增;旧账号请求返回后不写入新账号内存 */
let storeEpoch = 0
let storeEpoch = 0;
/**
* 系统表情包拉取 promiseensureFacePackList 内 cache
@@ -31,51 +34,51 @@ export const useFaceStore = defineStore('imFace', () => {
* - resolve 后保留对象 = 后续调用 await 立即返回,不再发请求
* - reject 后置回 null让调用方下次重试
*/
let facePacksPromise: null | Promise<void> = null
let facePacksPromise: null | Promise<void> = null;
/** 按需拉取系统表情包(已拉过则直接复用 cached promise */
async function ensureFacePackList(): Promise<void> {
if (!facePacksPromise) {
const requestEpoch = storeEpoch
const requestEpoch = storeEpoch;
facePacksPromise = apiGetFacePackList()
.then((data) => {
if (requestEpoch !== storeEpoch) {
return
return;
}
facePacks.value = data || []
facePacks.value = data || [];
})
.catch((error) => {
console.warn('[IM] 拉取表情包失败', error)
console.warn('[IM] 拉取表情包失败', error);
if (requestEpoch === storeEpoch) {
facePacksPromise = null
facePacksPromise = null;
}
throw error
})
throw error;
});
}
return facePacksPromise
return facePacksPromise;
}
/** 个人表情拉取 promise语义同上 */
let faceUserItemsPromise: null | Promise<void> = null
let faceUserItemsPromise: null | Promise<void> = null;
/** 按需拉取个人表情(已拉过则直接复用 cached promise */
async function ensureFaceUserItemList(): Promise<void> {
if (!faceUserItemsPromise) {
const requestEpoch = storeEpoch
const requestEpoch = storeEpoch;
faceUserItemsPromise = apiGetFaceUserItemList()
.then((data) => {
if (requestEpoch !== storeEpoch) {
return
return;
}
faceUserItems.value = data || []
faceUserItems.value = data || [];
})
.catch((error) => {
console.warn('[IM] 拉取个人表情失败', error)
console.warn('[IM] 拉取个人表情失败', error);
if (requestEpoch === storeEpoch) {
faceUserItemsPromise = null
faceUserItemsPromise = null;
}
throw error
})
throw error;
});
}
return faceUserItemsPromise
return faceUserItemsPromise;
}
/**
@@ -83,15 +86,17 @@ export const useFaceStore = defineStore('imFace', () => {
*
* 来源1. 用户在表情面板「+」上传图片 2. 长按消息「添加到表情」
*/
async function addFaceUserItem(reqVO: ImFaceUserItemApi.FaceUserItemSaveReqVO): Promise<boolean> {
const requestEpoch = storeEpoch
const id = await apiCreateFaceUserItem(reqVO)
async function addFaceUserItem(
reqVO: ImFaceUserItemApi.FaceUserItemSaveReqVO,
): Promise<boolean> {
const requestEpoch = storeEpoch;
const id = await apiCreateFaceUserItem(reqVO);
if (!id) {
return false
return false;
}
// 已切账号时跳过旧请求结果
if (requestEpoch !== storeEpoch) {
return false
return false;
}
// id 不在缓存里才插入;服务端唯一约束兜底了 race本地理论上不会拿到重复 id
if (!faceUserItems.value.some((item) => item.id === id)) {
@@ -100,36 +105,38 @@ export const useFaceStore = defineStore('imFace', () => {
url: reqVO.url,
name: reqVO.name,
width: reqVO.width,
height: reqVO.height
})
height: reqVO.height,
});
}
return true
return true;
}
/** 删除个人表情;本地立即移除 */
async function removeFaceUserItem(id: number): Promise<boolean> {
const requestEpoch = storeEpoch
const requestEpoch = storeEpoch;
try {
await apiDeleteFaceUserItem(id)
await apiDeleteFaceUserItem(id);
// 已切账号时跳过旧请求结果
if (requestEpoch !== storeEpoch) {
return false
return false;
}
faceUserItems.value = faceUserItems.value.filter((item) => item.id !== id)
return true
faceUserItems.value = faceUserItems.value.filter(
(item) => item.id !== id,
);
return true;
} catch (error) {
console.warn('[IM] 删除个人表情失败', { id }, error)
return false
console.warn('[IM] 删除个人表情失败', { id }, error);
return false;
}
}
/** 清空表情缓存 */
function clear(): void {
facePacks.value = []
faceUserItems.value = []
facePacksPromise = null
faceUserItemsPromise = null
storeEpoch++
facePacks.value = [];
faceUserItems.value = [];
facePacksPromise = null;
faceUserItemsPromise = null;
storeEpoch++;
}
return {
@@ -139,13 +146,13 @@ export const useFaceStore = defineStore('imFace', () => {
ensureFaceUserItemList,
addFaceUserItem,
removeFaceUserItem,
clear
}
})
clear,
};
});
/** 在 setup 外(路由守卫等)取 store 实例的工具方法 */
export const useFaceStoreWithOut = () => useFaceStore()
export const useFaceStoreWithOut = () => useFaceStore();
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useFaceStore, import.meta.hot))
import.meta.hot.accept(acceptHMRUpdate(useFaceStore, import.meta.hot));
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,25 @@
import type { ImGroupRequestApi } from '#/api/im/group/request'
import type { ImGroupRequestApi } from '#/api/im/group/request';
import { acceptHMRUpdate, defineStore } from 'pinia'
import { acceptHMRUpdate, defineStore } from 'pinia';
import { agreeGroupRequest as apiAgreeGroupRequest, getMyGroupRequest as apiGetMyGroupRequest, getUnhandledRequestList as apiGetUnhandledRequestList, pullMyGroupRequestList as apiPullMyGroupRequestList, refuseGroupRequest as apiRefuseGroupRequest } from '#/api/im/group/request'
import { getCurrentUserId } from '#/views/im/utils/auth'
import { ImGroupRequestHandleResult } from '#/views/im/utils/constants'
import {
agreeGroupRequest as apiAgreeGroupRequest,
getMyGroupRequest as apiGetMyGroupRequest,
getUnhandledRequestList as apiGetUnhandledRequestList,
pullMyGroupRequestList as apiPullMyGroupRequestList,
refuseGroupRequest as apiRefuseGroupRequest,
} from '#/api/im/group/request';
import { getCurrentUserId } from '#/views/im/utils/auth';
import { ImGroupRequestHandleResult } from '#/views/im/utils/constants';
import { getDb, StorageKeys } from '../../utils/db'
import { runIncrementalPull } from '../../utils/pull'
import { getDb, StorageKeys } from '../../utils/db';
import { runIncrementalPull } from '../../utils/pull';
type PendingRequest = { epoch: number; promise: Promise<void>; userId: number; }
type PendingRequest = { epoch: number; promise: Promise<void>; userId: number };
/** clear() 时递增;旧账号 in-flight 的 pullGroupRequests 结果 resolve 后比对一致才写 store防跨账号红点污染与 friendStore 同口径) */
let storeEpoch = 0
let pendingUnhandledFetch: null | PendingRequest = null
let storeEpoch = 0;
let pendingUnhandledFetch: null | PendingRequest = null;
/**
* IM 加群申请 Store
@@ -33,7 +39,7 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', {
/** 我管理的所有群下未处理申请列表(按 id 倒序) */
unhandledList: [] as ImGroupRequestApi.GroupRequestRespVO[],
/** fetchUnhandledGroupRequestList 是否成功执行过;避免横幅显示 0 然后跳数字的闪烁 */
loaded: false
loaded: false,
}),
getters: {
@@ -41,38 +47,45 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', {
* 各群下未处理申请数的 MapO(N) 扫一次缓存供 ConversationItem 等 N 处复用,避免 N×M 重复 filter
*/
getUnhandledGroupRequestCountMap(state): Map<number, number> {
const map = new Map<number, number>()
const map = new Map<number, number>();
for (const request of state.unhandledList) {
map.set(request.groupId, (map.get(request.groupId) ?? 0) + 1)
map.set(request.groupId, (map.get(request.groupId) ?? 0) + 1);
}
return map
return map;
},
/** 指定群下的未处理申请数 */
getUnhandledGroupRequestCount(): (groupId: number) => number {
return (groupId: number) => this.getUnhandledGroupRequestCountMap.get(groupId) ?? 0
return (groupId: number) =>
this.getUnhandledGroupRequestCountMap.get(groupId) ?? 0;
},
/** 指定群下的未处理申请列表 */
getUnhandledGroupRequestListByGroupId:
(state) =>
(groupId: number): ImGroupRequestApi.GroupRequestRespVO[] =>
state.unhandledList.filter((r) => r.groupId === groupId)
state.unhandledList.filter((r) => r.groupId === groupId),
},
actions: {
/** 从 IndexedDB 恢复加群申请 */
async loadGroupRequestList(): Promise<boolean> {
try {
const cached = await getDb().getAll<ImGroupRequestApi.GroupRequestRespVO>('groupRequests')
const cached =
await getDb().getAll<ImGroupRequestApi.GroupRequestRespVO>(
'groupRequests',
);
if (!cached || cached.length === 0) {
return false
return false;
}
this.unhandledList = cached
.filter((request) => request.handleResult === ImGroupRequestHandleResult.UNHANDLED)
.toSorted((requestA, requestB) => requestB.id - requestA.id)
return true
.filter(
(request) =>
request.handleResult === ImGroupRequestHandleResult.UNHANDLED,
)
.toSorted((requestA, requestB) => requestB.id - requestA.id);
return true;
} catch (error) {
console.warn('[IM groupRequestStore] 本地加群申请缓存读取失败', error)
return false
console.warn('[IM groupRequestStore] 本地加群申请缓存读取失败', error);
return false;
}
},
@@ -80,55 +93,69 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', {
saveGroupRequestList(): void {
void getDb()
.transaction(['groupRequests'], 'readwrite', async (tx) => {
const db = getDb()
await db.clearStore('groupRequests', tx)
const db = getDb();
await db.clearStore('groupRequests', tx);
for (const request of this.unhandledList) {
await db.put('groupRequests', request, tx)
await db.put('groupRequests', request, tx);
}
})
.catch((error) => console.warn('[IM groupRequestStore] 本地加群申请缓存写入失败', error))
.catch((error) =>
console.warn(
'[IM groupRequestStore] 本地加群申请缓存写入失败',
error,
),
);
},
/** 保存单条加群申请 */
async saveGroupRequestRecord(request: ImGroupRequestApi.GroupRequestRespVO): Promise<void> {
await getDb().put('groupRequests', request)
async saveGroupRequestRecord(
request: ImGroupRequestApi.GroupRequestRespVO,
): Promise<void> {
await getDb().put('groupRequests', request);
},
/** 保存单条加群申请 */
saveGroupRequest(request: ImGroupRequestApi.GroupRequestRespVO): void {
void this.saveGroupRequestRecord(request).catch((error) =>
console.warn('[IM groupRequestStore] 本地加群申请写入失败', error)
)
console.warn('[IM groupRequestStore] 本地加群申请写入失败', error),
);
},
/** 拉取我管理的所有群下未处理申请;进 IM 后 / 升级 admin 后 / WS 推送有冲突时调用 */
async fetchUnhandledGroupRequestList() {
const requestEpoch = storeEpoch
const requestUserId = getCurrentUserId()
const requestEpoch = storeEpoch;
const requestUserId = getCurrentUserId();
if (
pendingUnhandledFetch?.epoch === requestEpoch &&
pendingUnhandledFetch.userId === requestUserId
) {
return pendingUnhandledFetch.promise
return pendingUnhandledFetch.promise;
}
const promise = (async () => {
const list = await apiGetUnhandledRequestList()
if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
return
const list = await apiGetUnhandledRequestList();
if (
requestEpoch !== storeEpoch ||
getCurrentUserId() !== requestUserId
) {
return;
}
this.unhandledList = list || []
this.loaded = true
this.saveGroupRequestList()
this.unhandledList = list || [];
this.loaded = true;
this.saveGroupRequestList();
})().finally(() => {
if (
pendingUnhandledFetch?.epoch === requestEpoch &&
pendingUnhandledFetch.userId === requestUserId
) {
pendingUnhandledFetch = null
pendingUnhandledFetch = null;
}
})
pendingUnhandledFetch = { epoch: requestEpoch, userId: requestUserId, promise }
return promise
});
pendingUnhandledFetch = {
epoch: requestEpoch,
userId: requestUserId,
promise,
};
return promise;
},
/**
@@ -137,16 +164,16 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', {
* 同一对 group_id, user_id 复用记录时 requestId 不变但 applyContent / inviterUserId 会刷新,所以无条件 fetch + 排到头部
*/
async addGroupRequestById(requestId: number) {
const requestEpoch = storeEpoch
const requestUserId = getCurrentUserId()
const request = await apiGetMyGroupRequest(requestId)
const requestEpoch = storeEpoch;
const requestUserId = getCurrentUserId();
const request = await apiGetMyGroupRequest(requestId);
if (!request) {
return
return;
}
if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
return
return;
}
this.upsertGroupRequest(request)
this.upsertGroupRequest(request);
},
/**
@@ -156,18 +183,23 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', {
*/
upsertGroupRequest(request: ImGroupRequestApi.GroupRequestRespVO) {
void this.upsertGroupRequestForPull(request).catch((error) =>
console.warn('[IM groupRequestStore] 本地加群申请写入失败', error)
)
console.warn('[IM groupRequestStore] 本地加群申请写入失败', error),
);
},
/** 本地合并 / 新增单条加群申请 */
async upsertGroupRequestForPull(request: ImGroupRequestApi.GroupRequestRespVO): Promise<void> {
async upsertGroupRequestForPull(
request: ImGroupRequestApi.GroupRequestRespVO,
): Promise<void> {
if (request.handleResult !== ImGroupRequestHandleResult.UNHANDLED) {
await this.removeGroupRequestByIdForPull(request.id)
return
await this.removeGroupRequestByIdForPull(request.id);
return;
}
this.unhandledList = [request, ...this.unhandledList.filter((r) => r.id !== request.id)]
await this.saveGroupRequestRecord(request)
this.unhandledList = [
request,
...this.unhandledList.filter((r) => r.id !== request.id),
];
await this.saveGroupRequestRecord(request);
},
/**
@@ -178,65 +210,72 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', {
*/
async pullGroupRequests() {
// 快照 epoch账号在拉取途中切换clear() → epoch++)时丢弃旧账号那几页结果,防跨账号红点污染
const requestEpoch = storeEpoch
const requestUserId = getCurrentUserId()
const isActive = () => requestEpoch === storeEpoch && getCurrentUserId() === requestUserId
const requestEpoch = storeEpoch;
const requestUserId = getCurrentUserId();
const isActive = () =>
requestEpoch === storeEpoch && getCurrentUserId() === requestUserId;
await runIncrementalPull(
StorageKeys.settings.groupRequestPullCursor,
apiPullMyGroupRequestList,
async (records) => {
if (!isActive()) {
return false
return false;
}
await Promise.all(records.map((vo) => this.upsertGroupRequestForPull(vo)))
return true
await Promise.all(
records.map((vo) => this.upsertGroupRequestForPull(vo)),
);
return true;
},
isActive
)
isActive,
);
if (isActive()) {
this.loaded = true
this.loaded = true;
}
},
/** WS 收到 1505 / 1506 或本端处理完一条:按 requestId 从列表移除 */
removeGroupRequestById(requestId: number) {
this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId)
this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId);
void getDb()
.delete('groupRequests', requestId)
.catch((error) => console.warn('[IM groupRequestStore] 本地加群申请删除失败', error))
.catch((error) =>
console.warn('[IM groupRequestStore] 本地加群申请删除失败', error),
);
},
/** 删除单条加群申请 */
async removeGroupRequestByIdForPull(requestId: number): Promise<void> {
this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId)
await getDb().delete('groupRequests', requestId)
this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId);
await getDb().delete('groupRequests', requestId);
},
/** 同意申请;本端处理后立即从列表移除,避免被反复点击 */
async agreeGroupRequest(requestId: number) {
await apiAgreeGroupRequest(requestId)
this.removeGroupRequestById(requestId)
await apiAgreeGroupRequest(requestId);
this.removeGroupRequestById(requestId);
},
/** 拒绝申请 */
async refuseGroupRequest(requestId: number, handleContent?: string) {
await apiRefuseGroupRequest(requestId, handleContent)
this.removeGroupRequestById(requestId)
await apiRefuseGroupRequest(requestId, handleContent);
this.removeGroupRequestById(requestId);
},
/** 清空加群申请内存 */
clear() {
this.unhandledList = []
this.loaded = false
this.unhandledList = [];
this.loaded = false;
// 账号切换:递增 epoch 废弃旧账号 in-flight 的 pullGroupRequests 结果,避免写进新账号红点列表
storeEpoch++
pendingUnhandledFetch = null
}
}
})
storeEpoch++;
pendingUnhandledFetch = null;
},
},
});
export const useGroupRequestStoreWithOut = () => useGroupRequestStore()
export const useGroupRequestStoreWithOut = () => useGroupRequestStore();
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useGroupRequestStore, import.meta.hot))
import.meta.hot.accept(
acceptHMRUpdate(useGroupRequestStore, import.meta.hot),
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,91 +1,94 @@
import type { ImRtcApi } from '#/api/im/rtc'
import type {
ImRtcCallEndReasonValue,
ImRtcCallStageValue,
ImRtcParticipantStatusValue,
} from '../../utils/constants';
import { computed, ref } from 'vue'
import type { ImRtcApi } from '#/api/im/rtc';
import { defineStore } from 'pinia'
import { computed, ref } from 'vue';
import { getCurrentUserId } from '#/views/im/utils/auth'
import { defineStore } from 'pinia';
import { getCurrentUserId } from '#/views/im/utils/auth';
import {
ImConversationType,
type ImRtcCallEndReasonValue,
ImRtcCallStage,
type ImRtcCallStageValue,
ImRtcCallStatus,
type ImRtcParticipantStatusValue
} from '../../utils/constants'
import { useFriendStore } from './friendStore'
import { useGroupStore } from './groupStore'
} from '../../utils/constants';
import { useFriendStore } from './friendStore';
import { useGroupStore } from './groupStore';
type GroupActiveCallCache = {
participantsLoaded?: boolean // 是否已拉取完整参与者列表
} & ImRtcApi.RtcGroupCallRespVO
participantsLoaded?: boolean; // 是否已拉取完整参与者列表
} & ImRtcApi.RtcGroupCallRespVO;
// RTC_CALL 通话信令载荷;按 status 区分子类型语义
export interface ImRtcCallNotification {
status: ImRtcParticipantStatusValue
room: string
conversationType: number
mediaType: number
groupId?: number
status: ImRtcParticipantStatusValue;
room: string;
conversationType: number;
mediaType: number;
groupId?: number;
// INVITE 专属:被叫接通需要的 LiveKit 连接参数 + 主叫展示信息
livekitUrl?: string
token?: string
inviterUserId?: number
inviterNickname?: string
inviterAvatar?: string
livekitUrl?: string;
token?: string;
inviterUserId?: number;
inviterNickname?: string;
inviterAvatar?: string;
// INVITE 专属:本次被邀请人列表;包含收件人自身,前端来电小条按需过滤展示「邀请的其他人」
inviteeIds?: number[]
inviteeIds?: number[];
// REJECT 专属:操作者展示信息(其它子类型走 RTC_CALL_END
operatorUserId?: number
operatorNickname?: string
operatorAvatar?: string
operatorUserId?: number;
operatorNickname?: string;
operatorAvatar?: string;
}
// RTC_PARTICIPANT_CONNECTED 通话参与者加入载荷LiveKit webhook 转推)
export interface ImRtcParticipantConnectedNotification {
room: string
userId: number
conversationType: number
groupId?: number
room: string;
userId: number;
conversationType: number;
groupId?: number;
// 群聊场景非邀请成员首次填充胶囊条用
mediaType?: number
inviterUserId?: number
mediaType?: number;
inviterUserId?: number;
}
// RTC_PARTICIPANT_DISCONNECTED 通话参与者离开载荷LiveKit webhook 转推)
export interface ImRtcParticipantDisconnectedNotification {
room: string
userId: number
conversationType: number
groupId?: number
room: string;
userId: number;
conversationType: number;
groupId?: number;
}
// RTC_CALL_END 通话结束载荷(入消息流;私聊渲染消息气泡,群聊渲染系统提示行)
export interface ImRtcCallEndNotification {
room: string
conversationType: number
mediaType: number
endReason: ImRtcCallEndReasonValue
durationSeconds?: number
room: string;
conversationType: number;
mediaType: number;
endReason: ImRtcCallEndReasonValue;
durationSeconds?: number;
// 操作者聚合字段HANGUP/CANCEL/REJECT 触发人webhook 兜底为 null
operatorUserId?: number
operatorNickname?: string
operatorAvatar?: string
operatorUserId?: number;
operatorNickname?: string;
operatorAvatar?: string;
}
export const useRtcStore = defineStore('imRtc', () => {
/** 当前阶段 */
const stage = ref<ImRtcCallStageValue>(ImRtcCallStage.IDLE)
const stage = ref<ImRtcCallStageValue>(ImRtcCallStage.IDLE);
/** 当前通话invite / accept / refreshToken 拿到的完整信息 */
const call = ref<ImRtcApi.RtcCallRespVO | null>(null)
const call = ref<ImRtcApi.RtcCallRespVO | null>(null);
/** 来电载荷;仅 INCOMING 阶段使用status 固定 INVITING其它字段 INVITE 专属 */
const incomingPayload = ref<ImRtcCallNotification | null>(null)
const incomingPayload = ref<ImRtcCallNotification | null>(null);
/** 进入 RUNNING 的时间戳用于通话时长展示reset 时清零 */
const startedAt = ref(0)
const startedAt = ref(0);
/** 是否处于通话相关阶段 */
const isActive = computed(() => stage.value !== ImRtcCallStage.IDLE)
const isActive = computed(() => stage.value !== ImRtcCallStage.IDLE);
/**
* 对端展示名;按阶段 + 会话类型分支:
@@ -93,57 +96,61 @@ export const useRtcStore = defineStore('imRtc', () => {
*/
const peerNickname = computed<string>(() => {
if (stage.value === ImRtcCallStage.INCOMING) {
return incomingPayload.value?.inviterNickname || ''
return incomingPayload.value?.inviterNickname || '';
}
const c = call.value
if (!c) return ''
const c = call.value;
if (!c) return '';
if (c.conversationType === ImConversationType.GROUP) {
return useGroupStore().getGroup(c.groupId ?? 0)?.name || ''
return useGroupStore().getGroup(c.groupId ?? 0)?.name || '';
}
const peerUserId = resolvePrivatePeerUserId(c)
return (peerUserId && useFriendStore().getFriend(peerUserId)?.nickname) || ''
})
const peerUserId = resolvePrivatePeerUserId(c);
return (
(peerUserId && useFriendStore().getFriend(peerUserId)?.nickname) || ''
);
});
/** 对端头像;策略同 peerNickname */
const peerAvatar = computed<string>(() => {
if (stage.value === ImRtcCallStage.INCOMING) {
return incomingPayload.value?.inviterAvatar || ''
return incomingPayload.value?.inviterAvatar || '';
}
const c = call.value
if (!c) return ''
const c = call.value;
if (!c) return '';
if (c.conversationType === ImConversationType.GROUP) {
return useGroupStore().getGroup(c.groupId ?? 0)?.avatar || ''
return useGroupStore().getGroup(c.groupId ?? 0)?.avatar || '';
}
const peerUserId = resolvePrivatePeerUserId(c)
return (peerUserId && useFriendStore().getFriend(peerUserId)?.avatar) || ''
})
const peerUserId = resolvePrivatePeerUserId(c);
return (peerUserId && useFriendStore().getFriend(peerUserId)?.avatar) || '';
});
/** 私聊场景对端 userId自己是主叫则取首个 invitee否则取 inviter */
function resolvePrivatePeerUserId(c: ImRtcApi.RtcCallRespVO): number | undefined {
const myId = getCurrentUserId()
return c.inviterId === myId ? c.inviteeIds?.[0] : c.inviterId
function resolvePrivatePeerUserId(
c: ImRtcApi.RtcCallRespVO,
): number | undefined {
const myId = getCurrentUserId();
return c.inviterId === myId ? c.inviteeIds?.[0] : c.inviterId;
}
/** 群活跃通话索引groupId -> 群通话摘要;用于群聊顶部胶囊条 */
const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map())
const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map());
/**
* 已退出 / 已拒绝的用户编号集合;群通话场景内 pending 占位渲染时排除;
* 来源:参与者离开通知 + 群通话单人拒绝的 operatorUserId通话结束reset时清空
*/
const leftUserIds = ref<Set<number>>(new Set())
const leftUserIds = ref<Set<number>>(new Set());
/** 是否已记录某 userId 已退出 / 拒绝 */
function isUserLeft(userId: number): boolean {
return leftUserIds.value.has(userId)
return leftUserIds.value.has(userId);
}
/** 标记某个 userId 已退出 / 拒绝;用于 pending 占位渲染时排除 */
function markUserLeft(userId: number) {
if (!userId || leftUserIds.value.has(userId)) {
return
return;
}
leftUserIds.value = new Set([...leftUserIds.value, userId])
leftUserIds.value = new Set([...leftUserIds.value, userId]);
}
/**
@@ -152,29 +159,29 @@ export const useRtcStore = defineStore('imRtc', () => {
* 私聊:按 status 走RUNNING已加入已有通话场景→ RUNNINGCREATED → INVITING 等被叫接通
*/
function startInviting(data: ImRtcApi.RtcCallRespVO) {
call.value = data
call.value = data;
// 群通话场景写入本地胶囊条缓存
syncGroupActiveCall(data)
syncGroupActiveCall(data);
// 更新 stage 状态
if (data.conversationType === ImConversationType.GROUP) {
stage.value = ImRtcCallStage.RUNNING
startedAt.value = Date.now()
return
stage.value = ImRtcCallStage.RUNNING;
startedAt.value = Date.now();
return;
}
const running = data.status === ImRtcCallStatus.RUNNING
stage.value = running ? ImRtcCallStage.RUNNING : ImRtcCallStage.INVITING
const running = data.status === ImRtcCallStatus.RUNNING;
stage.value = running ? ImRtcCallStage.RUNNING : ImRtcCallStage.INVITING;
if (running) {
startedAt.value = Date.now()
startedAt.value = Date.now();
}
}
/** 被叫收到来电;切到 INCOMING接收 RTC_CALL(INVITE) payload */
function showIncoming(payload: ImRtcCallNotification) {
if (isActive.value) {
return
return;
}
incomingPayload.value = payload
stage.value = ImRtcCallStage.INCOMING
incomingPayload.value = payload;
stage.value = ImRtcCallStage.INCOMING;
// 按 inviter 兜底首次填充胶囊条
syncGroupActiveCall({
conversationType: payload.conversationType,
@@ -183,19 +190,19 @@ export const useRtcStore = defineStore('imRtc', () => {
mediaType: payload.mediaType,
inviterId: payload.inviterUserId ?? 0,
joinedUserIds: payload.inviterUserId ? [payload.inviterUserId] : [],
inviteeIds: payload.inviteeIds
})
inviteeIds: payload.inviteeIds,
});
}
/** 进入通话中阶段 */
function enterRunning(data: ImRtcApi.RtcCallRespVO) {
call.value = data
call.value = data;
// 离开 INCOMING 阶段;清空来电载荷
incomingPayload.value = null
stage.value = ImRtcCallStage.RUNNING
startedAt.value = Date.now()
incomingPayload.value = null;
stage.value = ImRtcCallStage.RUNNING;
startedAt.value = Date.now();
// 接通后用 RespVO 完整覆盖胶囊条
syncGroupActiveCall(data)
syncGroupActiveCall(data);
}
/**
@@ -204,16 +211,16 @@ export const useRtcStore = defineStore('imRtc', () => {
* 被叫场景通知载荷无 joinedUserIds调用方按主叫人兜底后续 getActiveCall / 参与者事件刷新成完整列表
*/
function syncGroupActiveCall(input: {
conversationType: number
groupId?: number
inviteeIds?: number[]
inviterId: number
joinedUserIds?: number[]
mediaType: number
room: string
conversationType: number;
groupId?: number;
inviteeIds?: number[];
inviterId: number;
joinedUserIds?: number[];
mediaType: number;
room: string;
}) {
if (input.conversationType !== ImConversationType.GROUP || !input.groupId) {
return
return;
}
// 写入或更新群活跃通话缓存
setGroupCall({
@@ -222,31 +229,31 @@ export const useRtcStore = defineStore('imRtc', () => {
mediaType: input.mediaType,
inviterId: input.inviterId,
joinedUserIds: input.joinedUserIds ?? [],
inviteeIds: input.inviteeIds ?? []
})
inviteeIds: input.inviteeIds ?? [],
});
}
/** 重置;通话结束统一调用 */
function reset() {
stage.value = ImRtcCallStage.IDLE
call.value = null
incomingPayload.value = null
startedAt.value = 0
leftUserIds.value = new Set()
stage.value = ImRtcCallStage.IDLE;
call.value = null;
incomingPayload.value = null;
startedAt.value = 0;
leftUserIds.value = new Set();
}
/** 通话中追加被邀请人;让 participants 网格出现 pending 占位、胶囊条同步更新 */
function appendInvitees(userIds: number[]) {
if (!call.value || userIds.length === 0) {
return
return;
}
const existing = call.value.inviteeIds ?? []
const merged = [...new Set([...existing, ...userIds])]
const existing = call.value.inviteeIds ?? [];
const merged = [...new Set([...existing, ...userIds])];
if (merged.length === existing.length) {
return
return;
}
call.value = { ...call.value, inviteeIds: merged }
syncGroupActiveCall(call.value)
call.value = { ...call.value, inviteeIds: merged };
syncGroupActiveCall(call.value);
}
// ==================== 群通话胶囊条状态 ====================
@@ -256,97 +263,117 @@ export const useRtcStore = defineStore('imRtc', () => {
* 房内成员同步交给 LiveKit 客户端事件ParticipantConnected / Disconnected
* 胶囊条不实时刷新 joinedUserIds / inviteeIds展开 / 加入时再走 getActiveCall 接口拉最新
*/
function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO, participantsLoaded?: boolean) {
function setGroupCall(
payload: ImRtcApi.RtcGroupCallRespVO,
participantsLoaded?: boolean,
) {
if (!payload?.groupId) {
return
return;
}
useGroupStore().markGroupActiveCallLoaded(payload.groupId)
useGroupStore().markGroupActiveCallLoaded(payload.groupId);
// 浅比较room / mediaType / joinedUserIds / inviteeIds 都没变就跳过,避免下游 watcher 无意义重算
const existing = groupActiveCalls.value.get(payload.groupId)
const existing = groupActiveCalls.value.get(payload.groupId);
const nextParticipantsLoaded =
participantsLoaded ?? (existing?.room === payload.room && !!existing.participantsLoaded)
participantsLoaded ??
(existing?.room === payload.room && !!existing.participantsLoaded);
if (
existing &&
isSameGroupCall(existing, payload) &&
!!existing.participantsLoaded === nextParticipantsLoaded
) {
return
return;
}
const newGroupActiveCalls = new Map(groupActiveCalls.value)
const newGroupActiveCalls = new Map(groupActiveCalls.value);
newGroupActiveCalls.set(payload.groupId, {
...payload,
participantsLoaded: nextParticipantsLoaded
})
groupActiveCalls.value = newGroupActiveCalls
participantsLoaded: nextParticipantsLoaded,
});
groupActiveCalls.value = newGroupActiveCalls;
}
/** 清空指定群的通话缓存 */
function clearGroupCallCache(groupId?: number) {
if (!groupId) {
groupActiveCalls.value = new Map()
return
groupActiveCalls.value = new Map();
return;
}
const next = new Map(groupActiveCalls.value)
next.delete(groupId)
groupActiveCalls.value = next
const next = new Map(groupActiveCalls.value);
next.delete(groupId);
groupActiveCalls.value = next;
}
/** 判断群通话是否已补齐 */
function isGroupCallParticipantsLoaded(groupId: number, room?: string): boolean {
const call = groupActiveCalls.value.get(groupId)
function isGroupCallParticipantsLoaded(
groupId: number,
room?: string,
): boolean {
const call = groupActiveCalls.value.get(groupId);
return (
!!groupId &&
!!room &&
!!call &&
call.room === room &&
!!call.participantsLoaded
)
);
}
/** 两条群通话摘要内容相等room / mediaType / inviterId / 两个 userId 数组逐项相等) */
function isSameGroupCall(a: ImRtcApi.RtcGroupCallRespVO, b: ImRtcApi.RtcGroupCallRespVO): boolean {
if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) {
return false
function isSameGroupCall(
a: ImRtcApi.RtcGroupCallRespVO,
b: ImRtcApi.RtcGroupCallRespVO,
): boolean {
if (
a.room !== b.room ||
a.mediaType !== b.mediaType ||
a.inviterId !== b.inviterId
) {
return false;
}
return isSameNumberList(a.joinedUserIds ?? [], b.joinedUserIds ?? []) &&
return (
isSameNumberList(a.joinedUserIds ?? [], b.joinedUserIds ?? []) &&
isSameNumberList(a.inviteeIds ?? [], b.inviteeIds ?? [])
);
}
/** 判断两个用户编号列表是否逐项相等 */
function isSameNumberList(a: number[], b: number[]): boolean {
if (a.length !== b.length) {
return false
return false;
}
return a.every((item, index) => item === b[index])
return a.every((item, index) => item === b[index]);
}
/** 群通话结束:从 groupActiveCalls 移除;胶囊条消失 */
function removeGroupCall(groupId: number, room?: string) {
if (!groupId) {
return
return;
}
const existing = groupActiveCalls.value.get(groupId)
const existing = groupActiveCalls.value.get(groupId);
if (room && existing?.room !== room) {
return
return;
}
clearGroupCallCache(groupId)
useGroupStore().markGroupActiveCallLoaded(groupId)
clearGroupCallCache(groupId);
useGroupStore().markGroupActiveCallLoaded(groupId);
}
/** 获取群当前活跃通话;用于胶囊条按 groupId 查询 */
function getGroupCall(groupId: number): ImRtcApi.RtcGroupCallRespVO | undefined {
return groupActiveCalls.value.get(groupId)
function getGroupCall(
groupId: number,
): ImRtcApi.RtcGroupCallRespVO | undefined {
return groupActiveCalls.value.get(groupId);
}
/** 通话参与者加入:把 userId 加进 joinedUserIds群聊场景无活跃记录时首次填充胶囊条 */
function applyParticipantConnected(payload: ImRtcParticipantConnectedNotification) {
const isGroup = payload.conversationType === ImConversationType.GROUP
function applyParticipantConnected(
payload: ImRtcParticipantConnectedNotification,
) {
const isGroup = payload.conversationType === ImConversationType.GROUP;
if (!isGroup || !payload.groupId) {
return
return;
}
// 胶囊条懒填充:本端可能在通话开始后才打开该群会话,没收到过 setGroupCall
// 此处用加入通知建一条最小记录inviteeIds 留空,展开 popover / 加入时再走 getActiveCall 补
const existing = groupActiveCalls.value.get(payload.groupId)
const existing = groupActiveCalls.value.get(payload.groupId);
if (!existing) {
setGroupCall({
room: payload.room,
@@ -354,72 +381,94 @@ export const useRtcStore = defineStore('imRtc', () => {
mediaType: payload.mediaType ?? 0,
inviterId: payload.inviterUserId ?? 0,
joinedUserIds: [payload.userId],
inviteeIds: []
})
return
inviteeIds: [],
});
return;
}
if (existing.room !== payload.room) {
return
return;
}
const joined = existing.joinedUserIds ?? []
const joined = existing.joinedUserIds ?? [];
if (joined.includes(payload.userId)) {
return
return;
}
setGroupCall({ ...existing, joinedUserIds: [...joined, payload.userId] })
setGroupCall({ ...existing, joinedUserIds: [...joined, payload.userId] });
}
/** 通话参与者离开:从 joinedUserIds 移除;同时标记 leftUserIdspending 占位渲染排除) */
function applyParticipantDisconnected(payload: ImRtcParticipantDisconnectedNotification) {
markUserLeft(payload.userId)
const isGroup = payload.conversationType === ImConversationType.GROUP
function applyParticipantDisconnected(
payload: ImRtcParticipantDisconnectedNotification,
) {
markUserLeft(payload.userId);
const isGroup = payload.conversationType === ImConversationType.GROUP;
if (!isGroup || !payload.groupId) {
return
return;
}
dropFromGroupActiveCall(payload.groupId, payload.room, payload.userId)
dropFromGroupActiveCall(payload.groupId, payload.room, payload.userId);
}
/** 群通话单人拒绝邀请:标记 leftUserIds + 从胶囊条 inviteeIds 移除(私聊拒绝走 RTC_CALL_END不入本通道 */
function applyParticipantRejected(
payload: Pick<ImRtcCallNotification, 'conversationType' | 'groupId' | 'operatorUserId' | 'room'>
payload: Pick<
ImRtcCallNotification,
'conversationType' | 'groupId' | 'operatorUserId' | 'room'
>,
) {
if (!payload.operatorUserId) {
return
return;
}
markUserLeft(payload.operatorUserId)
if (payload.conversationType === ImConversationType.GROUP && payload.groupId) {
dropFromGroupActiveCall(payload.groupId, payload.room, payload.operatorUserId)
markUserLeft(payload.operatorUserId);
if (
payload.conversationType === ImConversationType.GROUP &&
payload.groupId
) {
dropFromGroupActiveCall(
payload.groupId,
payload.room,
payload.operatorUserId,
);
}
}
/** 群通话单人振铃超时;对 banner 的处理与拒接一致(语义独立、实现共享) */
function applyParticipantNoAnswer(
payload: Pick<ImRtcCallNotification, 'conversationType' | 'groupId' | 'operatorUserId' | 'room'>
payload: Pick<
ImRtcCallNotification,
'conversationType' | 'groupId' | 'operatorUserId' | 'room'
>,
) {
applyParticipantRejected(payload)
applyParticipantRejected(payload);
}
/** 从指定群活跃通话的 joined / pending 列表里同步移除某用户;用于 disconnect / reject 让胶囊条不再展示 */
function dropFromGroupActiveCall(groupId: number, room: string, userId: number) {
const existing = groupActiveCalls.value.get(groupId)
function dropFromGroupActiveCall(
groupId: number,
room: string,
userId: number,
) {
const existing = groupActiveCalls.value.get(groupId);
if (!existing || existing.room !== room) {
return
return;
}
const joined = existing.joinedUserIds ?? []
const invitee = existing.inviteeIds ?? []
const nextJoined = joined.filter((id) => id !== userId)
const nextInvitee = invitee.filter((id) => id !== userId)
if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) {
return
const joined = existing.joinedUserIds ?? [];
const invitee = existing.inviteeIds ?? [];
const nextJoined = joined.filter((id) => id !== userId);
const nextInvitee = invitee.filter((id) => id !== userId);
if (
nextJoined.length === joined.length &&
nextInvitee.length === invitee.length
) {
return;
}
if (nextJoined.length === 0 && nextInvitee.length === 0) {
removeGroupCall(groupId, room)
return
removeGroupCall(groupId, room);
return;
}
setGroupCall({
...existing,
joinedUserIds: nextJoined,
inviteeIds: nextInvitee
})
inviteeIds: nextInvitee,
});
}
return {
@@ -445,6 +494,6 @@ export const useRtcStore = defineStore('imRtc', () => {
applyParticipantConnected,
applyParticipantDisconnected,
applyParticipantRejected,
applyParticipantNoAnswer
}
})
applyParticipantNoAnswer,
};
});

View File

@@ -1,10 +1,10 @@
import type { GroupLite, User } from '../types'
import type { GroupLite, User } from '../types';
import { reactive, ref } from 'vue'
import { reactive, ref } from 'vue';
import { acceptHMRUpdate, defineStore } from 'pinia'
import { acceptHMRUpdate, defineStore } from 'pinia';
import { ImFriendAddSource } from '../../utils/constants'
import { ImFriendAddSource } from '../../utils/constants';
/**
* IM 全局 UI store
@@ -23,24 +23,24 @@ export const useImUiStore = defineStore('imUiStore', () => {
position: { x: 0, y: 0 },
// addSource / addSourceExtra 跟随触发点带入「加好友」来源(群成员入口 = GROUP + 群名;其余默认搜索)
addSource: ImFriendAddSource.SEARCH as number,
addSourceExtra: '' as string
})
addSourceExtra: '' as string,
});
/** 打开用户名片 */
function openUserInfoCard(
user: User,
position: { x: number; y: number },
addSource: number = ImFriendAddSource.SEARCH,
addSourceExtra: string = ''
addSourceExtra: string = '',
) {
const viewportWidth = document.documentElement.clientWidth
const viewportHeight = document.documentElement.clientHeight
userInfoCard.user = user
userInfoCard.position.x = Math.min(position.x, viewportWidth - 350)
userInfoCard.position.y = Math.min(position.y, viewportHeight - 220)
userInfoCard.addSource = addSource
userInfoCard.addSourceExtra = addSourceExtra
userInfoCard.show = true
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
userInfoCard.user = user;
userInfoCard.position.x = Math.min(position.x, viewportWidth - 350);
userInfoCard.position.y = Math.min(position.y, viewportHeight - 220);
userInfoCard.addSource = addSource;
userInfoCard.addSourceExtra = addSourceExtra;
userInfoCard.show = true;
}
/** 鼠标点击位置 + 20px 横向偏移打开名片:避免名片直接覆盖触发元素,对齐头像 / 名片消息等点击交互的统一观感 */
@@ -48,14 +48,19 @@ export const useImUiStore = defineStore('imUiStore', () => {
user: User,
e: MouseEvent,
addSource: number = ImFriendAddSource.SEARCH,
addSourceExtra: string = ''
addSourceExtra: string = '',
) {
openUserInfoCard(user, { x: e.clientX + 20, y: e.clientY }, addSource, addSourceExtra)
openUserInfoCard(
user,
{ x: e.clientX + 20, y: e.clientY },
addSource,
addSourceExtra,
);
}
/** 关闭用户名片 */
function closeUserInfoCard() {
userInfoCard.show = false
userInfoCard.show = false;
}
// ==================== 群名片 GroupInfoCard ====================
@@ -63,33 +68,33 @@ export const useImUiStore = defineStore('imUiStore', () => {
const groupInfoCard = reactive({
show: false,
group: null as GroupLite | null,
position: { x: 0, y: 0 }
})
position: { x: 0, y: 0 },
});
/** 鼠标点击位置 + 20px 横向偏移打开群名片,对齐 UserInfoCard 的统一观感 */
function openGroupInfoCardAtEvent(group: GroupLite, e: MouseEvent) {
const viewportWidth = document.documentElement.clientWidth
const viewportHeight = document.documentElement.clientHeight
groupInfoCard.group = group
groupInfoCard.position.x = Math.min(e.clientX + 20, viewportWidth - 350)
groupInfoCard.position.y = Math.min(e.clientY, viewportHeight - 220)
groupInfoCard.show = true
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
groupInfoCard.group = group;
groupInfoCard.position.x = Math.min(e.clientX + 20, viewportWidth - 350);
groupInfoCard.position.y = Math.min(e.clientY, viewportHeight - 220);
groupInfoCard.show = true;
}
/** 关闭群名片 */
function closeGroupInfoCard() {
groupInfoCard.show = false
groupInfoCard.show = false;
}
// ==================== 右键菜单 ContextMenu ====================
// 右键菜单虽然是一个组件挂在主壳上,但其触发时机分散在各列表
interface ContextMenuItem {
key: string
name: string
disabled?: boolean
divided?: boolean // 是否在该项上方显示分割线(用于把"删除"等危险操作与上面的常规项隔开)
danger?: boolean // 是否走危险操作样式(红色文字)
icon?: string // 可选 iconify 图标名(如 ant-design:delete-outlined不传则不渲染前置图标
key: string;
name: string;
disabled?: boolean;
divided?: boolean; // 是否在该项上方显示分割线(用于把"删除"等危险操作与上面的常规项隔开)
danger?: boolean; // 是否走危险操作样式(红色文字)
icon?: string; // 可选 iconify 图标名(如 ant-design:delete-outlined不传则不渲染前置图标
}
const contextMenu = reactive({
@@ -97,36 +102,36 @@ export const useImUiStore = defineStore('imUiStore', () => {
position: { x: 0, y: 0 },
items: [] as ContextMenuItem[],
/** 选中回调:每次 open 时由调用方传入 */
onSelect: null as ((item: ContextMenuItem) => void) | null
})
onSelect: null as ((item: ContextMenuItem) => void) | null,
});
/** 打开右键菜单 */
function openContextMenu(
position: { x: number; y: number },
items: ContextMenuItem[],
onSelect: (item: ContextMenuItem) => void
onSelect: (item: ContextMenuItem) => void,
) {
contextMenu.position = position
contextMenu.items = items
contextMenu.onSelect = onSelect
contextMenu.show = true
contextMenu.position = position;
contextMenu.items = items;
contextMenu.onSelect = onSelect;
contextMenu.show = true;
}
/** 关闭右键菜单 */
function closeContextMenu() {
contextMenu.show = false
contextMenu.onSelect = null
contextMenu.show = false;
contextMenu.onSelect = null;
}
// ==================== 消息 Tab 跳转下一未读 ====================
// 在 ImHomeConversation 页面再次点击工具栏「消息」时触发;
// 通过递增 nonce 让 conversation/index.vue 的 watch 感知后执行滚动 + 高亮
const nextUnreadJumpNonce = ref(0)
const nextUnreadJumpNonce = ref(0);
/** 请求滚动到下一个未读会话(含免打扰) */
function requestNextUnreadJump() {
nextUnreadJumpNonce.value++
nextUnreadJumpNonce.value++;
}
return {
@@ -144,12 +149,12 @@ export const useImUiStore = defineStore('imUiStore', () => {
closeContextMenu,
nextUnreadJumpNonce,
requestNextUnreadJump
}
})
requestNextUnreadJump,
};
});
// dev: 让 Pinia 的 actions / state 改动支持 HMR避免每次改 store 都得硬刷
// 否则 Vite 把新模块推下来后,老 store 实例的 action 闭包仍指向旧函数体
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useImUiStore, import.meta.hot))
import.meta.hot.accept(acceptHMRUpdate(useImUiStore, import.meta.hot));
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,181 +2,184 @@
// 后端 WebSocket 统一帧结构:{ type, content }
export interface WebSocketFrame {
type: string // 帧类型,对齐 ImWebSocketMessageType
content: string // 帧内容JSON 字符串)
type: string; // 帧类型,对齐 ImWebSocketMessageType
content: string; // 帧内容JSON 字符串)
}
// IM WebSocket 通知 DTO对齐后端 ImNotificationWebSocketDTO
export interface ImNotificationWebSocketDTO {
conversationType: number // 会话类型
contentType: number // 内容类型
payload: Record<string, any> // 负载数据
conversationType: number; // 会话类型
contentType: number; // 内容类型
payload: Record<string, any>; // 负载数据
}
// 无会话在线通知(对齐后端 conversationType = NONE 的独立 payload
export interface ImNoConversationNotification {
type: number // 内容类型
[key: string]: any
type: number; // 内容类型
[key: string]: any;
}
// 私聊消息 DTO对齐后端 ImPrivateMessageNotification
export interface ImPrivateMessageNotification {
id: number // 消息编号
clientMessageId: string // 客户端消息编号
senderId: number // 发送人编号
receiverId: number // 接收人编号
type: number // 内容类型
content: string // 消息内容
status: number // 消息状态
receiptStatus?: number // 回执状态(不需要 / 待完成 / 已完成)
sendTime: string // 发送时间
id: number; // 消息编号
clientMessageId: string; // 客户端消息编号
senderId: number; // 发送人编号
receiverId: number; // 接收人编号
type: number; // 内容类型
content: string; // 消息内容
status: number; // 消息状态
receiptStatus?: number; // 回执状态(不需要 / 待完成 / 已完成)
sendTime: string; // 发送时间
}
// 群聊消息 DTO对齐后端 ImGroupMessageNotification
export interface ImGroupMessageNotification {
id: number // 消息编号
clientMessageId: string // 客户端消息编号
senderId: number // 发送人编号
groupId: number // 群编号
type: number // 内容类型
content: string // 消息内容
status: number // 消息状态
sendTime: string // 发送时间
atUserIds?: number[] // 群 @ 目标用户列表
receiverUserIds?: number[] // 群定向接收用户列表
readCount?: number // 群回执已读人数type = RECEIPT 时使用)
receiptStatus?: number // 群回执状态type = RECEIPT 时使用)
readId?: number // 已读位置
id: number; // 消息编号
clientMessageId: string; // 客户端消息编号
senderId: number; // 发送人编号
groupId: number; // 群编号
type: number; // 内容类型
content: string; // 消息内容
status: number; // 消息状态
sendTime: string; // 发送时间
atUserIds?: number[]; // 群 @ 目标用户列表
receiverUserIds?: number[]; // 群定向接收用户列表
readCount?: number; // 群回执已读人数type = RECEIPT 时使用)
receiptStatus?: number; // 群回执状态type = RECEIPT 时使用)
readId?: number; // 已读位置
}
// 消息已读同步通知(对齐后端 ImMessageReadNotification
export interface ImMessageReadNotification {
id: number // 已读位置
type: number // 内容类型
senderId?: number // 发送人编号
receiverId?: number // 私聊接收人编号
groupId?: number // 群编号
channelId?: number // 频道编号
readId?: number // 已读位置
id: number; // 已读位置
type: number; // 内容类型
senderId?: number; // 发送人编号
receiverId?: number; // 私聊接收人编号
groupId?: number; // 群编号
channelId?: number; // 频道编号
readId?: number; // 已读位置
}
// 消息回执通知(对齐后端 ImMessageReceiptNotification
export interface ImMessageReceiptNotification {
id: number // 消息编号
type: number // 内容类型
senderId?: number // 已读方用户编号
receiverId?: number // 私聊接收人编号
groupId?: number // 群编号
readCount?: number // 群回执已读人数
receiptStatus?: number // 群回执状态
id: number; // 消息编号
type: number; // 内容类型
senderId?: number; // 已读方用户编号
receiverId?: number; // 私聊接收人编号
groupId?: number; // 群编号
readCount?: number; // 群回执已读人数
receiptStatus?: number; // 群回执状态
}
// ==================== 本地会话 / 消息结构 ====================
/** 引用消息 */
export interface QuoteMessage {
messageId: number // 引用消息编号
senderId: number // 引用消息发送人编号
type: number // 引用内容类型
content: string // 引用消息内容
messageId: number; // 引用消息编号
senderId: number; // 引用消息发送人编号
type: number; // 引用内容类型
content: string; // 引用消息内容
}
// 会话数据结构(前端自有结构,后端无对应实体)
export interface Conversation {
// ========== 核心标识 ==========
targetId: number // 会话目标编号:私聊=对方 userId群聊=groupId
type: number // 会话类型,对齐 ImConversationType
targetId: number; // 会话目标编号:私聊=对方 userId群聊=groupId
type: number; // 会话类型,对齐 ImConversationType
// ========== 展示字段 ==========
name: string // 展示名称(私聊=好友昵称;群聊=群名)
avatar: string // 头像
unreadCount: number // 未读数
name: string; // 展示名称(私聊=好友昵称;群聊=群名)
avatar: string; // 头像
unreadCount: number; // 未读数
// ========== 最后一条消息事实索引 ==========
lastContent: string // 会话列表展示的最后一条消息摘要
lastSendTime: number // 最后一条消息时间,用于排序
lastSenderId?: number // 发送人编号
lastMessageType?: number // 内容类型,对齐 ImContentType
lastMessageId?: number // 最后一条服务端消息编号
lastClientMessageId?: string // 最后一条客户端消息编号
lastMessageStatus?: number // 最后一条消息状态
lastReceiptStatus?: number // 最后一条群回执状态
lastSelfSend?: boolean // 是否自己发的
lastSenderDisplayName?: string // 发送人显示名快照——仅作 utils/user.getSenderDisplayName 实时算不出真名时的 fallback
lastContent: string; // 会话列表展示的最后一条消息摘要
lastSendTime: number; // 最后一条消息时间,用于排序
lastSenderId?: number; // 发送人编号
lastMessageType?: number; // 内容类型,对齐 ImContentType
lastMessageId?: number; // 最后一条服务端消息编号
lastClientMessageId?: string; // 最后一条客户端消息编号
lastMessageStatus?: number; // 最后一条消息状态
lastReceiptStatus?: number; // 最后一条群回执状态
lastSelfSend?: boolean; // 是否自己发的
lastSenderDisplayName?: string; // 发送人显示名快照——仅作 utils/user.getSenderDisplayName 实时算不出真名时的 fallback
// ========== UI 状态 ==========
deleted?: boolean // 是否已删除(软删标记,持久化时过滤)
top?: boolean // 是否置顶(排序时优先)
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean // 群聊:是否有人 @我
atAll?: boolean // 群聊:是否有人 @全体成员
reportedReadMessageId?: number // 已上报到服务端的最大已读消息编号
deleted?: boolean; // 是否已删除(软删标记,持久化时过滤)
top?: boolean; // 是否置顶(排序时优先)
silent?: boolean; // 是否免打扰(不展示未读徽标 + 不响提示音)
atMe?: boolean; // 群聊:是否有人 @我
atAll?: boolean; // 群聊:是否有人 @全体成员
reportedReadMessageId?: number; // 已上报到服务端的最大已读消息编号
draft?: {
html: string // 输入框 HTML
plain: string // 输入框纯文本
reply?: QuoteMessage // 引用消息
} // 输入框草稿
html: string; // 输入框 HTML
plain: string; // 输入框纯文本
reply?: QuoteMessage; // 引用消息
}; // 输入框草稿
}
// 消息数据结构
export interface Message {
// ========== 后端字段(对齐 ImPrivateMessageNotification / ImGroupMessageNotification ==========
id?: number // 服务端消息编号,发送中为空
clientMessageId: string // 客户端消息编号,本地生成用于合并去重
type: number // 内容类型,对齐 ImContentType
content: string // 消息内容JSON 字符串
status: number // 消息状态,对齐 ImMessageStatus
sendTime: number // 发送时间(前端转毫秒时间戳;后端为 LocalDateTime 字符串)
senderId: number // 发送人编号
atUserIds?: number[] // 群 @ 目标用户列表
receiverUserIds?: number[] // 群定向接收用户列表
receiptStatus?: number // 回执状态,对齐 ImMessageReceiptStatus私聊 / 群 / 频道通用)
readCount?: number // 群回执已读人数(仅群消息)
materialId?: number // 关联频道素材编号(仅频道消息 type=MATERIAL
id?: number; // 服务端消息编号,发送中为空
clientMessageId: string; // 客户端消息编号,本地生成用于合并去重
type: number; // 内容类型,对齐 ImContentType
content: string; // 消息内容JSON 字符串
status: number; // 消息状态,对齐 ImMessageStatus
sendTime: number; // 发送时间(前端转毫秒时间戳;后端为 LocalDateTime 字符串)
senderId: number; // 发送人编号
atUserIds?: number[]; // 群 @ 目标用户列表
receiverUserIds?: number[]; // 群定向接收用户列表
receiptStatus?: number; // 回执状态,对齐 ImMessageReceiptStatus私聊 / 群 / 频道通用)
readCount?: number; // 群回执已读人数(仅群消息)
materialId?: number; // 关联频道素材编号(仅频道消息 type=MATERIAL
// ========== 前端扩展字段 ==========
// 发送人显示名一律渲染时实时算utils/user.getSenderDisplayName / getSenderRealNickname
// 不在 Message 上存任何名字快照,避免备注 / 群昵称变更后历史消息显示陈旧
targetId: number // 会话目标编号(私聊=对端 userId / 群聊=groupId与 Conversation.targetId 一致
selfSend: boolean // 是否自己发送(前端按 senderId 计算)
uploadProgress?: number // 媒体消息上传进度0-100status=SENDING 期间持续更新ack 后置 undefined
targetId: number; // 会话目标编号(私聊=对端 userId / 群聊=groupId与 Conversation.targetId 一致
selfSend: boolean; // 是否自己发送(前端按 senderId 计算)
uploadProgress?: number; // 媒体消息上传进度0-100status=SENDING 期间持续更新ack 后置 undefined
// 媒体消息内存中保留的原始 File下划线前缀表示不进 JSON / 不持久化IDB 恢复后必为 undefined
// 失败重试时按它重走上传;页面刷新后该字段丢失,恢复阶段直接 drop 整条消息
_localFile?: File
_ackMerging?: boolean // ack 合并中标记,不持久化
_localFile?: File;
_ackMerging?: boolean; // ack 合并中标记,不持久化
}
// ==================== IndexedDB 本地存储结构 ====================
/** 会话 IndexedDB 存储结构 */
export interface ConversationDO extends Conversation {
clientConversationId: string // `${type}:${targetId}`
clientConversationId: string; // `${type}:${targetId}`
}
export interface ConversationRead {
conversationType: number // 会话类型,对齐 ImConversationType
targetId: number // 会话目标编号
messageId: number // 当前用户已读到的最大消息编号
updateTime?: number // 更新时间
conversationType: number; // 会话类型,对齐 ImConversationType
targetId: number; // 会话目标编号
messageId: number; // 当前用户已读到的最大消息编号
updateTime?: number; // 更新时间
}
/** 会话读位置 IndexedDB 存储结构 */
export interface ConversationReadDO extends ConversationRead {
clientConversationId: string // `${conversationType}:${targetId}`
clientConversationId: string; // `${conversationType}:${targetId}`
}
/** 消息 IndexedDB 存储结构 */
export interface MessageDO extends Omit<Message, '_ackMerging' | '_localFile' | 'uploadProgress'> {
messageKey: string // `${conversationType}:${id}` 或 `client:${clientMessageId}`
conversationType: number // 会话类型,对齐 ImConversationType
clientConversationId: string // ConversationDO.clientConversationId
export interface MessageDO extends Omit<
Message,
'_ackMerging' | '_localFile' | 'uploadProgress'
> {
messageKey: string; // `${conversationType}:${id}` 或 `client:${clientMessageId}`
conversationType: number; // 会话类型,对齐 ImConversationType
clientConversationId: string; // ConversationDO.clientConversationId
}
/** 设置 IndexedDB 存储结构 */
export interface SettingDO<T = unknown> {
key: string
value: T
updateTime?: number
key: string;
value: T;
updateTime?: number;
}
// ==================== 群 / 群成员 ====================
@@ -184,50 +187,55 @@ export interface SettingDO<T = unknown> {
// 群实体(前端内部结构)
export interface Group {
// ========== 后端字段(对齐 ImGroupApi.GroupRespVO ==========
id: number // 群编号
name: string // 群名称
avatar?: string // 群头像
notice?: string // 群公告
ownerUserId?: number // 群主用户编号
pinnedMessages?: Message[] // 群置顶消息列表
mutedAll?: boolean // 是否全群禁言
banned?: boolean // 是否被管理员封禁
joinApproval?: boolean // 进群是否需群主 / 管理员审批
joinStatus?: number // 当前登录用户在该群的成员状态(参见 CommonStatusEnum0 在群 / 1 已退群);历史退群群仍返回,供展示历史消息的群名 / 头像
id: number; // 群编号
name: string; // 群名称
avatar?: string; // 群头像
notice?: string; // 群公告
ownerUserId?: number; // 群主用户编号
pinnedMessages?: Message[]; // 群置顶消息列表
mutedAll?: boolean; // 是否全群禁言
banned?: boolean; // 是否被管理员封禁
joinApproval?: boolean; // 进群是否需群主 / 管理员审批
joinStatus?: number; // 当前登录用户在该群的成员状态(参见 CommonStatusEnum0 在群 / 1 已退群);历史退群群仍返回,供展示历史消息的群名 / 头像
// ========== 前端扩展字段user-per-group 维度) ==========
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
members?: GroupMember[] // 群成员缓存(按需懒加载)
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
activeCallLoaded?: boolean // 群活跃通话是否已探测,本轮会话内存标记,不持久化
activeCallExpired?: boolean // 群活跃通话探测是否已过期
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
membersExpired?: boolean // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
memberCount?: number // 成员总数
silent?: boolean; // 是否免打扰。从当前用户的 GroupMember 回填
groupRemark?: string; // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
members?: GroupMember[]; // 群成员缓存(按需懒加载)
infoLoaded?: boolean; // 群详情是否已加载,本轮会话内存标记,不持久化
activeCallLoaded?: boolean; // 群活跃通话是否已探测,本轮会话内存标记,不持久化
activeCallExpired?: boolean; // 群活跃通话探测是否已过期
membersLoaded?: boolean; // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
membersExpired?: boolean; // 群成员缓存是否已过期;重连 / 重新进入 IM 后只标记不删除,下次进入群会话再刷新
memberCount?: number; // 成员总数
}
/** 群 IndexedDB 存储结构 */
export type GroupDO = Omit<
Group,
'activeCallExpired' | 'activeCallLoaded' | 'infoLoaded' | 'members' | 'membersExpired' | 'membersLoaded'
>
| 'activeCallExpired'
| 'activeCallLoaded'
| 'infoLoaded'
| 'members'
| 'membersExpired'
| 'membersLoaded'
>;
// 群成员实体(前端内部结构)
export interface GroupMember {
// ========== 后端字段(对齐 ImGroupMemberApi.GroupMemberRespVO ==========
id?: number // 群成员关系记录编号
groupId: number // 群编号
userId: number // 用户编号
avatar?: string // 头像
nickname: string // 用户昵称
displayUserName?: string // 该成员在群内自定义昵称(每个 member 一份;不与 nickname 合并,由消费方按需取舍)
status?: number // 在群 / 退群状态,对齐 CommonStatusEnum
role?: number // 成员角色,参见 ImGroupMemberRole 枚举1=群主 2=管理员 3=普通成员
muteEndTime?: string // 禁言到期时间ISO 字符串)
id?: number; // 群成员关系记录编号
groupId: number; // 群编号
userId: number; // 用户编号
avatar?: string; // 头像
nickname: string; // 用户昵称
displayUserName?: string; // 该成员在群内自定义昵称(每个 member 一份;不与 nickname 合并,由消费方按需取舍)
status?: number; // 在群 / 退群状态,对齐 CommonStatusEnum
role?: number; // 成员角色,参见 ImGroupMemberRole 枚举1=群主 2=管理员 3=普通成员
muteEndTime?: string; // 禁言到期时间ISO 字符串)
// ========== 前端扩展字段 ==========
isOwner?: boolean // 是否群主(前端从 Group.ownerUserId 计算)
isOwner?: boolean; // 是否群主(前端从 Group.ownerUserId 计算)
}
// ==================== 好友 ====================
@@ -235,20 +243,20 @@ export interface GroupMember {
// 好友实体(前端内部结构)
export interface Friend {
// ========== 后端字段(对齐 ImFriendApi.FriendRespVO ==========
id?: number // 好友关系记录编号(本地乐观新增时可能暂缺)
friendUserId: number // 好友用户编号(与 Conversation.targetId 对齐)
nickname: string // 好友昵称对方真实昵称永远不被备注覆盖UI 显示走 displayName || nickname
nicknamePinyin?: string // 昵称的拼音(后端用 Pinyin4j 算好回填,小写无空格)
avatar?: string // 好友头像
silent?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
displayName?: string // 好友展示备注:仅自己可见的别名(单字段不歧义,不带 Friend 前缀)
displayNamePinyin?: string // 备注的拼音(后端用 Pinyin4j 算好回填,小写无空格)
status?: number // 好友状态,对齐 CommonStatusEnumDISABLE = 已删除,软删保留记录)
addSource?: number // 添加来源;参见 ImFriendAddSourceEnum
pinned?: boolean // 是否置顶联系人
blocked?: boolean // 是否拉黑(仅自己可见,单边屏蔽对方私聊消息)
addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
id?: number; // 好友关系记录编号(本地乐观新增时可能暂缺)
friendUserId: number; // 好友用户编号(与 Conversation.targetId 对齐)
nickname: string; // 好友昵称对方真实昵称永远不被备注覆盖UI 显示走 displayName || nickname
nicknamePinyin?: string; // 昵称的拼音(后端用 Pinyin4j 算好回填,小写无空格)
avatar?: string; // 好友头像
silent?: boolean; // 是否免打扰(不展示未读徽标 + 不响提示音)
displayName?: string; // 好友展示备注:仅自己可见的别名(单字段不歧义,不带 Friend 前缀)
displayNamePinyin?: string; // 备注的拼音(后端用 Pinyin4j 算好回填,小写无空格)
status?: number; // 好友状态,对齐 CommonStatusEnumDISABLE = 已删除,软删保留记录)
addSource?: number; // 添加来源;参见 ImFriendAddSourceEnum
pinned?: boolean; // 是否置顶联系人
blocked?: boolean; // 是否拉黑(仅自己可见,单边屏蔽对方私聊消息)
addTime?: number; // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
deleteTime?: number; // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
}
/**
@@ -256,33 +264,33 @@ export interface Friend {
*/
export interface FriendRequest {
// ========== 后端字段(对齐 ImFriendRequestApi.FriendRequestRespVO ==========
id: number // 申请编号
fromUserId: number // 发起方用户编号
toUserId: number // 接收方用户编号
handleResult: number // 处理结果0=未处理1=同意2=拒绝
applyContent?: string // 申请理由(发起方填写)
handleContent?: string // 处理理由(接收方拒绝时可选填)
addSource?: number // 添加来源;参见 ImFriendAddSourceEnum
handleTime?: number // 处理时间(毫秒时间戳)
createTime: number // 申请创建时间(毫秒时间戳)
id: number; // 申请编号
fromUserId: number; // 发起方用户编号
toUserId: number; // 接收方用户编号
handleResult: number; // 处理结果0=未处理1=同意2=拒绝
applyContent?: string; // 申请理由(发起方填写)
handleContent?: string; // 处理理由(接收方拒绝时可选填)
addSource?: number; // 添加来源;参见 ImFriendAddSourceEnum
handleTime?: number; // 处理时间(毫秒时间戳)
createTime: number; // 申请创建时间(毫秒时间戳)
// ========== 聚合字段(自 AdminUser仅展示用 ==========
fromNickname?: string // 发起方昵称
fromAvatar?: string // 发起方头像
toNickname?: string // 接收方昵称
toAvatar?: string // 接收方头像
fromNickname?: string; // 发起方昵称
fromAvatar?: string; // 发起方头像
toNickname?: string; // 接收方昵称
toAvatar?: string; // 接收方头像
}
// ==================== 用户名片 ====================
// 用户精简信息(对齐后端 UserSimpleRespVO名片 / 头像 hover 等场景共用)
export interface User {
id: number
nickname?: string
avatar?: string
sex?: number
deptId?: number
deptName?: string
id: number;
nickname?: string;
avatar?: string;
sex?: number;
deptId?: number;
deptName?: string;
}
// ==================== 列表行展示用 Lite 类型 ====================
@@ -293,12 +301,12 @@ export interface User {
* - 软删status === DISABLE由上游 friendStore.getActiveFriendList / getActiveFriendLiteList 统一过滤掉
*/
export interface FriendLite {
id: number
nickname: string
nicknamePinyin?: string // 昵称拼音(用于字母分桶 / 拼音搜索)
avatar?: string
displayName?: string
displayNamePinyin?: string // 备注拼音(优先于 nicknamePinyin 参与分桶)
id: number;
nickname: string;
nicknamePinyin?: string; // 昵称拼音(用于字母分桶 / 拼音搜索)
avatar?: string;
displayName?: string;
displayNamePinyin?: string; // 备注拼音(优先于 nicknamePinyin 参与分桶)
}
/**
@@ -307,12 +315,12 @@ export interface FriendLite {
* - showImageThumb高频列表用缩略图避免拉原图阻塞滚动
*/
export interface GroupLite {
id: number
name?: string
showGroupName?: string
showImage?: string
showImageThumb?: string
memberCount?: number
ownerId?: number
joinApproval?: boolean // 进群是否需群主 / 管理员审批
id: number;
name?: string;
showGroupName?: string;
showImage?: string;
showImageThumb?: string;
memberCount?: number;
ownerId?: number;
joinApproval?: boolean; // 进群是否需群主 / 管理员审批
}

View File

@@ -11,10 +11,10 @@ defineOptions({ name: 'ImManagerChannelSelect' });
const props = withDefaults(
defineProps<{
allowClear?: boolean
disabled?: boolean
modelValue?: number
placeholder?: string
allowClear?: boolean;
disabled?: boolean;
modelValue?: number;
placeholder?: string;
}>(),
{
allowClear: true,

View File

@@ -1,10 +1,10 @@
import type { VbenFormSchema } from '#/adapter/form'
import type { VxeTableGridOptions } from '#/adapter/vxe-table'
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants'
import { getDictOptions } from '@vben/hooks'
import { CommonStatusEnum, DICT_TYPE } from '@vben/constants';
import { getDictOptions } from '@vben/hooks';
import { z } from '#/adapter/form'
import { z } from '#/adapter/form';
/** 新增/修改的表单 */
export function useFormSchema(): VbenFormSchema[] {
@@ -14,46 +14,49 @@ export function useFormSchema(): VbenFormSchema[] {
component: 'Input',
dependencies: {
triggerFields: [''],
show: () => false
}
show: () => false,
},
},
{
fieldName: 'code',
label: '频道编码',
component: 'Input',
componentProps: {
placeholder: '如 system_notice'
placeholder: '如 system_notice',
},
rules: z
.string({ message: '频道编码不能为空' })
.regex(/^[a-z][a-z0-9_]*$/, '只能由小写字母 / 数字 / 下划线组成,且以字母开头')
.regex(
/^[a-z][a-z0-9_]*$/,
'只能由小写字母 / 数字 / 下划线组成,且以字母开头',
),
},
{
fieldName: 'name',
label: '频道名称',
component: 'Input',
componentProps: {
placeholder: '如 系统公告'
placeholder: '如 系统公告',
},
rules: 'required'
rules: 'required',
},
{
fieldName: 'avatar',
label: '频道头像',
component: 'ImageUpload',
componentProps: {
maxNumber: 1
maxNumber: 1,
},
rules: 'required'
rules: 'required',
},
{
fieldName: 'sort',
label: '排序',
component: 'InputNumber',
componentProps: {
min: 0
min: 0,
},
rules: z.number().default(0)
rules: z.number().default(0),
},
{
fieldName: 'status',
@@ -62,11 +65,11 @@ export function useFormSchema(): VbenFormSchema[] {
componentProps: {
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
buttonStyle: 'solid',
optionType: 'button'
optionType: 'button',
},
rules: z.number().default(CommonStatusEnum.ENABLE)
}
]
rules: z.number().default(CommonStatusEnum.ENABLE),
},
];
}
/** 列表的搜索表单 */
@@ -78,8 +81,8 @@ export function useGridFormSchema(): VbenFormSchema[] {
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '频道业务码'
}
placeholder: '频道业务码',
},
},
{
fieldName: 'name',
@@ -87,8 +90,8 @@ export function useGridFormSchema(): VbenFormSchema[] {
component: 'Input',
componentProps: {
allowClear: true,
placeholder: '频道名称'
}
placeholder: '频道名称',
},
},
{
fieldName: 'status',
@@ -97,10 +100,10 @@ export function useGridFormSchema(): VbenFormSchema[] {
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
placeholder: '请选择状态'
}
}
]
placeholder: '请选择状态',
},
},
];
}
/** 列表的字段 */
@@ -109,28 +112,28 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
{
field: 'id',
title: '编号',
width: 80
width: 80,
},
{
field: 'avatar',
title: '头像',
width: 80,
slots: { default: 'avatar' }
slots: { default: 'avatar' },
},
{
field: 'code',
title: '编码',
minWidth: 160
minWidth: 160,
},
{
field: 'name',
title: '名称',
minWidth: 140
minWidth: 140,
},
{
field: 'sort',
title: '排序',
width: 80
width: 80,
},
{
field: 'status',
@@ -138,20 +141,20 @@ export function useGridColumns(): VxeTableGridOptions['columns'] {
width: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.COMMON_STATUS }
}
props: { type: DICT_TYPE.COMMON_STATUS },
},
},
{
field: 'createTime',
title: '创建时间',
minWidth: 180,
formatter: 'formatDateTime'
formatter: 'formatDateTime',
},
{
title: '操作',
width: 160,
fixed: 'right',
slots: { default: 'actions' }
}
]
slots: { default: 'actions' },
},
];
}

View File

@@ -1,58 +1,61 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table'
import type { ImManagerChannelApi } from '#/api/im/manager/channel'
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ImManagerChannelApi } from '#/api/im/manager/channel';
import { Page, useVbenModal } from '@vben/common-ui'
import { Page, useVbenModal } from '@vben/common-ui';
import { Image, message } from 'ant-design-vue'
import { Image, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'
import { deleteManagerChannel, getManagerChannelPage } from '#/api/im/manager/channel'
import { $t } from '#/locales'
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
deleteManagerChannel,
getManagerChannelPage,
} from '#/api/im/manager/channel';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data'
import Form from './modules/form.vue'
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
defineOptions({ name: 'ImManagerChannel' })
defineOptions({ name: 'ImManagerChannel' });
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: Form,
destroyOnClose: true
})
destroyOnClose: true,
});
/** 刷新表格 */
function handleRefresh() {
gridApi.query()
gridApi.query();
}
/** 创建频道 */
function handleCreate() {
formModalApi.setData(null).open()
formModalApi.setData(null).open();
}
/** 编辑频道 */
function handleEdit(row: ImManagerChannelApi.Channel) {
formModalApi.setData(row).open()
formModalApi.setData(row).open();
}
/** 删除频道 */
async function handleDelete(row: ImManagerChannelApi.Channel) {
const hideLoading = message.loading({
content: $t('ui.actionMessage.deleting', [row.name]),
duration: 0
})
duration: 0,
});
try {
await deleteManagerChannel(row.id)
message.success($t('ui.actionMessage.deleteSuccess', [row.name]))
handleRefresh()
await deleteManagerChannel(row.id);
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
handleRefresh();
} finally {
hideLoading()
hideLoading();
}
}
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema()
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
@@ -64,21 +67,21 @@ const [Grid, gridApi] = useVbenVxeGrid({
return await getManagerChannelPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues
})
}
}
...formValues,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true
}
} as VxeTableGridOptions<ImManagerChannelApi.Channel>
})
search: true,
},
} as VxeTableGridOptions<ImManagerChannelApi.Channel>,
});
</script>
<template>
@@ -93,8 +96,8 @@ const [Grid, gridApi] = useVbenVxeGrid({
type: 'primary',
icon: ACTION_ICON.ADD,
auth: ['im:manager:channel:create'],
onClick: handleCreate
}
onClick: handleCreate,
},
]"
/>
</template>
@@ -109,7 +112,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
type: 'link',
icon: ACTION_ICON.EDIT,
auth: ['im:manager:channel:update'],
onClick: handleEdit.bind(null, row)
onClick: handleEdit.bind(null, row),
},
{
label: $t('common.delete'),
@@ -119,9 +122,9 @@ const [Grid, gridApi] = useVbenVxeGrid({
auth: ['im:manager:channel:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
confirm: handleDelete.bind(null, row)
}
}
confirm: handleDelete.bind(null, row),
},
},
]"
/>
</template>

View File

@@ -1,78 +1,80 @@
<script lang="ts" setup>
import type { ImManagerChannelApi } from '#/api/im/manager/channel'
import type { ImManagerChannelApi } from '#/api/im/manager/channel';
import { computed, ref } from 'vue'
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui'
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue'
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form'
import { useVbenForm } from '#/adapter/form';
import {
createManagerChannel,
getManagerChannel,
updateManagerChannel
} from '#/api/im/manager/channel'
import { $t } from '#/locales'
updateManagerChannel,
} from '#/api/im/manager/channel';
import { $t } from '#/locales';
import { useFormSchema } from '../data'
import { useFormSchema } from '../data';
const emit = defineEmits(['success'])
const formData = ref<ImManagerChannelApi.Channel>()
const emit = defineEmits(['success']);
const formData = ref<ImManagerChannelApi.Channel>();
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['频道'])
: $t('ui.actionTitle.create', ['频道'])
})
: $t('ui.actionTitle.create', ['频道']);
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full'
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 100
labelWidth: 100,
},
layout: 'horizontal',
schema: useFormSchema(),
showDefaultActions: false
})
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate()
const { valid } = await formApi.validate();
if (!valid) {
return
return;
}
modalApi.lock()
const data = (await formApi.getValues()) as ImManagerChannelApi.Channel
modalApi.lock();
const data = (await formApi.getValues()) as ImManagerChannelApi.Channel;
try {
await (formData.value?.id ? updateManagerChannel(data) : createManagerChannel(data))
await modalApi.close()
emit('success')
message.success($t('ui.actionMessage.operationSuccess'))
await (formData.value?.id
? updateManagerChannel(data)
: createManagerChannel(data));
await modalApi.close();
emit('success');
message.success($t('ui.actionMessage.operationSuccess'));
} finally {
modalApi.unlock()
modalApi.unlock();
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined
return
formData.value = undefined;
return;
}
const data = modalApi.getData<ImManagerChannelApi.Channel>()
const data = modalApi.getData<ImManagerChannelApi.Channel>();
if (!data || !data.id) {
return
return;
}
modalApi.lock()
modalApi.lock();
try {
formData.value = await getManagerChannel(data.id)
await formApi.setValues(formData.value)
formData.value = await getManagerChannel(data.id);
await formApi.setValues(formData.value);
} finally {
modalApi.unlock()
modalApi.unlock();
}
}
})
},
});
</script>
<template>

View File

@@ -11,10 +11,10 @@ defineOptions({ name: 'ImManagerMaterialSelect' });
const props = withDefaults(
defineProps<{
allowClear?: boolean
channelId?: number
modelValue?: number
placeholder?: string
allowClear?: boolean;
channelId?: number;
modelValue?: number;
placeholder?: string;
}>(),
{
allowClear: true,

View File

@@ -13,7 +13,10 @@ import {
} from '#/api/im/manager/channel/message';
import { $t } from '#/locales';
import { useMessageGridColumns, useMessageGridFormSchema } from '../material/data';
import {
useMessageGridColumns,
useMessageGridFormSchema,
} from '../material/data';
import SendForm from './modules/send-form.vue';
defineOptions({ name: 'ImManagerChannelMessage' });
@@ -105,7 +108,10 @@ const [Grid, gridApi] = useVbenVxeGrid({
/>
</template>
<template #receivers="{ row }">
<Tag v-if="!row.receiverUserIds || row.receiverUserIds.length === 0" color="warning">
<Tag
v-if="!row.receiverUserIds || row.receiverUserIds.length === 0"
color="warning"
>
全员
</Tag>
<span v-else>{{ row.receiverUserIds.length }} </span>

View File

@@ -88,7 +88,11 @@ const [Modal, modalApi] = useVbenModal({
<Radio value="users">指定用户</Radio>
</RadioGroup>
</FormItem>
<FormItem v-if="formData.receiverUserType === 'users'" label="接收用户" required>
<FormItem
v-if="formData.receiverUserType === 'users'"
label="接收用户"
required
>
<UserSelect
v-model="formData.receiverUserIds"
multiple

View File

@@ -17,10 +17,7 @@ import {
} from '#/api/im/manager/face/pack';
import { $t } from '#/locales';
import {
usePackGridColumns,
usePackGridFormSchema,
} from './data';
import { usePackGridColumns, usePackGridFormSchema } from './data';
import Form from './modules/form.vue';
import ItemDrawer from './modules/item-drawer.vue';

View File

@@ -205,7 +205,9 @@ const [Grid, gridApi] = useVbenVxeGrid({
icon: ACTION_ICON.DELETE,
auth: ['im:manager:face-pack-item:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name || row.id]),
title: $t('ui.actionMessage.deleteConfirm', [
row.name || row.id,
]),
confirm: handleDelete.bind(null, row),
},
},

View File

@@ -74,7 +74,8 @@ const [Modal, modalApi] = useVbenModal({
return;
}
modalApi.lock();
const data = (await formApi.getValues()) as ImManagerFacePackItemApi.FacePackItem;
const data =
(await formApi.getValues()) as ImManagerFacePackItemApi.FacePackItem;
try {
data.packId = data.packId || packId.value;
await (formData.value?.id

View File

@@ -14,7 +14,10 @@ import {
import { $t } from '#/locales';
import { formatUserLabel } from '#/views/im/manager/utils/format';
import { useUserItemGridColumns, useUserItemGridFormSchema } from '../pack/data';
import {
useUserItemGridColumns,
useUserItemGridFormSchema,
} from '../pack/data';
defineOptions({ name: 'ImManagerFaceUserItem' });
@@ -94,7 +97,9 @@ const [Grid, gridApi] = useVbenVxeGrid({
icon: ACTION_ICON.DELETE,
auth: ['im:manager:face-user-item:delete'],
popConfirm: {
title: $t('ui.actionMessage.deleteConfirm', [row.name || row.id]),
title: $t('ui.actionMessage.deleteConfirm', [
row.name || row.id,
]),
confirm: handleDelete.bind(null, row),
},
},

View File

@@ -171,7 +171,10 @@ export function useFriendRequestGridFormSchema(): VbenFormSchema[] {
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.IM_FRIEND_REQUEST_HANDLE_RESULT, 'number'),
options: getDictOptions(
DICT_TYPE.IM_FRIEND_REQUEST_HANDLE_RESULT,
'number',
),
placeholder: '请选择处理结果',
},
},

View File

@@ -11,10 +11,7 @@ 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';
import { getManagerGroup, getManagerGroupPage } from '#/api/im/manager/group';
const emit = defineEmits<{
selected: [rows: ImManagerGroupApi.Group[]];
@@ -246,7 +243,7 @@ function handleConfirm() {
message.warning(multiple.value ? '请至少选择一条数据' : '请选择一条数据');
return;
}
emit('selected', multiple.value ? rows : [rows[0]!]);
emit('selected', multiple.value ? rows : [rows[0]]);
open.value = false;
}

View File

@@ -43,12 +43,17 @@ const displayLabel = computed(() => selectedItem.value?.name ?? '');
/** 是否显示清除图标 */
const showClear = computed(() => {
return props.allowClear && !props.disabled && hovering.value && props.modelValue != null;
return (
props.allowClear &&
!props.disabled &&
hovering.value &&
props.modelValue !== null
);
});
/** 根据编号查询群信息(用于编辑回显) */
async function resolveItemById(id: number | undefined) {
if (id == null) {
if (id === null) {
selectedItem.value = undefined;
return;
}
@@ -78,7 +83,7 @@ function handleClick(event: MouseEvent) {
clearSelected();
return;
}
dialogRef.value?.open(props.modelValue == null ? [] : [props.modelValue]);
dialogRef.value?.open(props.modelValue === null ? [] : [props.modelValue]);
}
/** 弹窗选中回调 */

View File

@@ -157,7 +157,10 @@ export function useGroupRequestGridFormSchema(): VbenFormSchema[] {
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.IM_GROUP_REQUEST_HANDLE_RESULT, 'number'),
options: getDictOptions(
DICT_TYPE.IM_GROUP_REQUEST_HANDLE_RESULT,
'number',
),
placeholder: '请选择处理结果',
},
},

View File

@@ -120,7 +120,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
<Tooltip v-if="row.banned" :title="row.bannedReason">
<DictTag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="row.banned" />
</Tooltip>
<DictTag v-else :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="row.banned" />
<DictTag
v-else
:type="DICT_TYPE.INFRA_BOOLEAN_STRING"
:value="row.banned"
/>
</template>
<template #mutedAll="{ row }">
<Tag v-if="row.mutedAll" color="error">已禁言</Tag>
@@ -148,7 +152,9 @@ const [Grid, gridApi] = useVbenVxeGrid({
danger: !row.banned,
icon: ACTION_ICON.CLOSE,
auth: ['im:manager:group:ban'],
onClick: row.banned ? handleUnban.bind(null, row) : handleBan.bind(null, row),
onClick: row.banned
? handleUnban.bind(null, row)
: handleBan.bind(null, row),
},
{
label: '解散',

View File

@@ -77,12 +77,17 @@ defineExpose({ open });
<DescriptionsItem label="群主">
{{ formatUserLabel(detail.ownerNickname, detail.ownerUserId) }}
</DescriptionsItem>
<DescriptionsItem label="成员数">{{ detail.memberCount || 0 }}</DescriptionsItem>
<DescriptionsItem label="成员数">
{{ detail.memberCount || 0 }}
</DescriptionsItem>
<DescriptionsItem label="群状态">
<DictTag :type="DICT_TYPE.IM_GROUP_STATUS" :value="detail.status" />
</DescriptionsItem>
<DescriptionsItem label="封禁状态">
<DictTag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="detail.banned" />
<DictTag
:type="DICT_TYPE.INFRA_BOOLEAN_STRING"
:value="detail.banned"
/>
<span v-if="detail.banned" class="ml-2 text-gray-400">
{{ detail.bannedReason }}
</span>
@@ -119,10 +124,16 @@ defineExpose({ open });
</Avatar>
</template>
<template v-else-if="column.dataIndex === 'role'">
<DictTag :type="DICT_TYPE.IM_GROUP_MEMBER_ROLE" :value="record.role" />
<DictTag
:type="DICT_TYPE.IM_GROUP_MEMBER_ROLE"
:value="record.role"
/>
</template>
<template v-else-if="column.dataIndex === 'silent'">
<DictTag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="record.silent" />
<DictTag
:type="DICT_TYPE.INFRA_BOOLEAN_STRING"
:value="record.silent"
/>
</template>
<template v-else-if="column.dataIndex === 'status'">
<DictTag :type="DICT_TYPE.COMMON_STATUS" :value="record.status" />
@@ -134,7 +145,11 @@ defineExpose({ open });
{{ formatDateTimeText(record.quitTime) }}
</template>
<template v-else-if="column.dataIndex === 'muteEndTime'">
<template v-if="record.muteEndTime && new Date(record.muteEndTime) > new Date()">
<template
v-if="
record.muteEndTime && new Date(record.muteEndTime) > new Date()
"
>
<Tag color="error">禁言中</Tag>
<div class="mt-1 text-xs text-gray-400">
{{ formatDateTimeText(record.muteEndTime) }}

View File

@@ -52,7 +52,11 @@ const cardPayload = computed(() => {
}
return {
...payload.value,
name: payload.value.name || payload.value.nickname || payload.value.userId || '',
name:
payload.value.name ||
payload.value.nickname ||
payload.value.userId ||
'',
};
});
@@ -132,7 +136,10 @@ function openVideo() {
</script>
<template>
<span v-if="type === ImContentType.TEXT" class="whitespace-pre-wrap break-all">
<span
v-if="type === ImContentType.TEXT"
class="whitespace-pre-wrap break-all"
>
{{ textContent }}
</span>
@@ -234,10 +241,16 @@ function openVideo() {
<span v-else-if="type === ImContentType.READ" class="text-xs text-gray-400">
[已读回执]
</span>
<span v-else-if="type === ImContentType.RECEIPT" class="text-xs text-gray-400">
<span
v-else-if="type === ImContentType.RECEIPT"
class="text-xs text-gray-400"
>
[回执]
</span>
<span v-else-if="isGroupNotification(type ?? -1)" class="text-xs text-gray-400">
<span
v-else-if="isGroupNotification(type ?? -1)"
class="text-xs text-gray-400"
>
{{ groupTipText }}
</span>
<span v-else-if="isFriendChatTip(type ?? -1)" class="text-xs text-gray-400">
@@ -247,7 +260,10 @@ function openVideo() {
v-else-if="isRtcCallTip(type ?? -1)"
class="inline-flex items-center gap-1.5 text-xs text-gray-400"
>
<IconifyIcon class="size-4 rotate-[135deg]" icon="ant-design:phone-outlined" />
<IconifyIcon
class="size-4 rotate-[135deg]"
icon="ant-design:phone-outlined"
/>
<span>{{ rtcCallTipText }}</span>
</span>
<span v-else class="whitespace-pre-wrap break-all">{{ fallbackText }}</span>

View File

@@ -61,7 +61,9 @@ export function usePrivateGridFormSchema(): VbenFormSchema[] {
}
/** 私聊消息字段 */
export function usePrivateGridColumns(showReadColumns: boolean): VxeTableGridOptions['columns'] {
export function usePrivateGridColumns(
showReadColumns: boolean,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',
@@ -179,7 +181,9 @@ export function useGroupGridFormSchema(): VbenFormSchema[] {
}
/** 群聊消息字段 */
export function useGroupGridColumns(showReadColumns: boolean): VxeTableGridOptions['columns'] {
export function useGroupGridColumns(
showReadColumns: boolean,
): VxeTableGridOptions['columns'] {
return [
{
field: 'id',

View File

@@ -47,7 +47,9 @@ const [Grid] = useVbenVxeGrid({
return await getManagerGroupMessagePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
groupId: route.query.groupId ? Number(route.query.groupId) : undefined,
groupId: route.query.groupId
? Number(route.query.groupId)
: undefined,
...formValues,
});
},
@@ -79,8 +81,12 @@ const [Grid] = useVbenVxeGrid({
<template v-if="row.atUserIds?.length">
<span v-for="(userId, index) in row.atUserIds" :key="userId">
<span v-if="Number(index) > 0"></span>
<template v-if="userId === IM_AT_ALL_USER_ID">@{{ IM_AT_ALL_NICKNAME }}</template>
<template v-else>@{{ row.atUserNicknames?.[index] || userId }}</template>
<template v-if="userId === IM_AT_ALL_USER_ID">
@{{ IM_AT_ALL_NICKNAME }}
</template>
<template v-else>
@{{ row.atUserNicknames?.[index] || userId }}
</template>
</span>
</template>
<span v-else>-</span>

View File

@@ -23,7 +23,9 @@ import {
import { MessageContentPreview } from '../..';
const visible = ref(false);
const detail = ref<ImManagerGroupMessageApi.GroupMessage>({} as ImManagerGroupMessageApi.GroupMessage);
const detail = ref<ImManagerGroupMessageApi.GroupMessage>(
{} as ImManagerGroupMessageApi.GroupMessage,
);
/** 打开详情 */
function open(row: ImManagerGroupMessageApi.GroupMessage) {
@@ -35,7 +37,12 @@ defineExpose({ open });
</script>
<template>
<Modal v-model:open="visible" :footer="null" title="群聊消息详情" width="700px">
<Modal
v-model:open="visible"
:footer="null"
title="群聊消息详情"
width="700px"
>
<Descriptions bordered :column="2">
<DescriptionsItem label="编号">{{ detail.id }}</DescriptionsItem>
<DescriptionsItem label="客户端编号">
@@ -53,7 +60,11 @@ defineExpose({ open });
<DescriptionsItem label="状态">
<DictTag :type="DICT_TYPE.IM_MESSAGE_STATUS" :value="detail.status" />
</DescriptionsItem>
<DescriptionsItem v-if="MESSAGE_GROUP_READ_ENABLED" label="回执" :span="2">
<DescriptionsItem
v-if="MESSAGE_GROUP_READ_ENABLED"
label="回执"
:span="2"
>
<DictTag
:type="DICT_TYPE.IM_MESSAGE_RECEIPT_STATUS"
:value="detail.receiptStatus"
@@ -85,7 +96,11 @@ defineExpose({ open });
/>
</DescriptionsItem>
<DescriptionsItem label="原始 JSON" :span="2">
<pre class="m-0 whitespace-pre-wrap break-all rounded bg-gray-100 p-2 font-mono text-xs">{{ formatJsonText(detail.content) }}</pre>
<pre
class="m-0 whitespace-pre-wrap break-all rounded bg-gray-100 p-2 font-mono text-xs"
>
{{ formatJsonText(detail.content) }}
</pre>
</DescriptionsItem>
</Descriptions>
</Modal>

View File

@@ -13,10 +13,7 @@ import { formatUserLabel } from '#/views/im/manager/utils/format';
import { MESSAGE_PRIVATE_READ_ENABLED } from '#/views/im/utils/config';
import { MessageContentPreview } from '..';
import {
usePrivateGridColumns,
usePrivateGridFormSchema,
} from '../data';
import { usePrivateGridColumns, usePrivateGridFormSchema } from '../data';
import Detail from './modules/detail.vue';
defineOptions({ name: 'ImPrivateMessage' });

View File

@@ -32,7 +32,12 @@ defineExpose({ open });
</script>
<template>
<Modal v-model:open="visible" :footer="null" title="私聊消息详情" width="700px">
<Modal
v-model:open="visible"
:footer="null"
title="私聊消息详情"
width="700px"
>
<Descriptions bordered :column="2">
<DescriptionsItem label="编号">{{ detail.id }}</DescriptionsItem>
<DescriptionsItem label="客户端编号">
@@ -68,7 +73,11 @@ defineExpose({ open });
/>
</DescriptionsItem>
<DescriptionsItem label="原始 JSON" :span="2">
<pre class="m-0 whitespace-pre-wrap break-all rounded bg-gray-100 p-2 font-mono text-xs">{{ formatJsonText(detail.content) }}</pre>
<pre
class="m-0 whitespace-pre-wrap break-all rounded bg-gray-100 p-2 font-mono text-xs"
>
{{ formatJsonText(detail.content) }}
</pre>
</DescriptionsItem>
</Descriptions>
</Modal>

View File

@@ -26,7 +26,10 @@ export function useRtcGridFormSchema(): VbenFormSchema[] {
component: 'Select',
componentProps: {
allowClear: true,
options: getDictOptions(DICT_TYPE.IM_RTC_CALL_CONVERSATION_TYPE, 'number'),
options: getDictOptions(
DICT_TYPE.IM_RTC_CALL_CONVERSATION_TYPE,
'number',
),
placeholder: '请选择会话类型',
},
},

View File

@@ -51,21 +51,34 @@ defineExpose({ open });
</script>
<template>
<Drawer v-model:open="visible" destroy-on-close title="通话记录详情" width="900">
<Drawer
v-model:open="visible"
destroy-on-close
title="通话记录详情"
width="900"
>
<Descriptions bordered :column="2">
<DescriptionsItem label="编号">{{ detail.id }}</DescriptionsItem>
<DescriptionsItem label="业务通话编号">{{ detail.room }}</DescriptionsItem>
<DescriptionsItem label="业务通话编号">
{{ detail.room }}
</DescriptionsItem>
<DescriptionsItem label="发起人">
{{ formatUserLabel(detail.inviterNickname, detail.inviterUserId) }}
</DescriptionsItem>
<DescriptionsItem label="会话类型">
<DictTag :type="DICT_TYPE.IM_RTC_CALL_CONVERSATION_TYPE" :value="detail.conversationType" />
<DictTag
:type="DICT_TYPE.IM_RTC_CALL_CONVERSATION_TYPE"
:value="detail.conversationType"
/>
</DescriptionsItem>
<DescriptionsItem label="群">
{{ formatGroupLabel(detail.groupName, detail.groupId) }}
</DescriptionsItem>
<DescriptionsItem label="媒体类型">
<DictTag :type="DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE" :value="detail.mediaType" />
<DictTag
:type="DICT_TYPE.IM_RTC_CALL_MEDIA_TYPE"
:value="detail.mediaType"
/>
</DescriptionsItem>
<DescriptionsItem label="通话状态">
<DictTag :type="DICT_TYPE.IM_RTC_CALL_STATUS" :value="detail.status" />
@@ -102,12 +115,24 @@ defineExpose({ open });
>
<template #bodyCell="{ column, record, text }">
<template v-if="column.dataIndex === 'role'">
<DictTag :type="DICT_TYPE.IM_RTC_PARTICIPANT_ROLE" :value="record.role" />
<DictTag
:type="DICT_TYPE.IM_RTC_PARTICIPANT_ROLE"
:value="record.role"
/>
</template>
<template v-else-if="column.dataIndex === 'status'">
<DictTag :type="DICT_TYPE.IM_RTC_PARTICIPANT_STATUS" :value="record.status" />
<DictTag
:type="DICT_TYPE.IM_RTC_PARTICIPANT_STATUS"
:value="record.status"
/>
</template>
<template v-else-if="['inviteTime', 'acceptTime', 'leaveTime'].includes(column.dataIndex as string)">
<template
v-else-if="
['inviteTime', 'acceptTime', 'leaveTime'].includes(
column.dataIndex as string,
)
"
>
{{ formatDateTimeText(text) }}
</template>
<template v-else>

View File

@@ -43,7 +43,8 @@ const [Modal, modalApi] = useVbenModal({
return;
}
modalApi.lock();
const data = (await formApi.getValues()) as ImManagerSensitiveWordApi.SensitiveWord;
const data =
(await formApi.getValues()) as ImManagerSensitiveWordApi.SensitiveWord;
try {
await (formData.value?.id
? updateManagerSensitiveWord(data)

View File

@@ -83,7 +83,9 @@ async function loadData() {
xAxis: { type: 'value', name: '消息数' },
yAxis: {
type: 'category',
data: sorted.map((item) => `${item.nickname || item.userId}(${item.userId})`),
data: sorted.map(
(item) => `${item.nickname || item.userId}(${item.userId})`,
),
axisLabel: { overflow: 'truncate', width: 110 },
},
series: [

View File

@@ -78,7 +78,11 @@ const cards = computed(() => {
<template>
<div class="grid grid-cols-4 gap-4 max-xl:grid-cols-2 max-md:grid-cols-1">
<Card v-for="card in cards" :key="card.title" :body-style="{ padding: '16px' }">
<Card
v-for="card in cards"
:key="card.title"
:body-style="{ padding: '16px' }"
>
<div class="flex items-center">
<div
class="mr-3 flex size-12 shrink-0 items-center justify-center rounded"
@@ -90,7 +94,10 @@ const cards = computed(() => {
<div class="mb-1 text-sm text-muted-foreground">{{ card.title }}</div>
<div class="truncate text-2xl font-semibold leading-none">
{{ card.value }}
<span v-if="card.suffix" class="ml-1 text-xs font-normal text-gray-400">
<span
v-if="card.suffix"
class="ml-1 text-xs font-normal text-gray-400"
>
{{ card.suffix }}
</span>
</div>

View File

@@ -7,10 +7,7 @@ import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Card, Select } from 'ant-design-vue';
import {
getMessageTrend,
getUserTrend,
} from '#/api/im/manager/statistics';
import { getMessageTrend, getUserTrend } from '#/api/im/manager/statistics';
const props = defineProps<{
type: 'message' | 'user';

View File

@@ -7,11 +7,7 @@ import { Page } from '@vben/common-ui';
import { getStatisticsOverview } from '#/api/im/manager/statistics';
import {
DistributionChart,
OverviewCards,
TrendChart,
} from './components';
import { DistributionChart, OverviewCards, TrendChart } from './components';
defineOptions({ name: 'ImManagerStatistics' });
@@ -35,7 +31,9 @@ onMounted(loadOverview);
<TrendChart type="user" />
</div>
<div class="grid grid-cols-3 gap-4 max-2xl:grid-cols-2 max-xl:grid-cols-1">
<div
class="grid grid-cols-3 gap-4 max-2xl:grid-cols-2 max-xl:grid-cols-1"
>
<DistributionChart type="messageType" />
<DistributionChart type="groupSize" />
<DistributionChart type="topSenders" />

View File

@@ -1,14 +1,14 @@
import { useAccessStore, useUserStore } from '@vben/stores'
import { useAccessStore, useUserStore } from '@vben/stores';
// TODO DONE @AI已使用 Vben 的 useUserStore / useAccessStore 获取登录信息;
/** 获取当前用户编号 */
export function getCurrentUserId(): number {
const userInfo = useUserStore().userInfo
return Number(userInfo?.id ?? userInfo?.userId ?? 0)
const userInfo = useUserStore().userInfo;
return Number(userInfo?.id ?? userInfo?.userId ?? 0);
}
/** 获取刷新令牌 */
export function getRefreshToken(): null | string {
return useAccessStore().refreshToken
return useAccessStore().refreshToken;
}

View File

@@ -1,5 +1,5 @@
import { useChannelStore } from '../home/store/channelStore'
import { ImConversationType } from './constants'
import { useChannelStore } from '../home/store/channelStore';
import { ImConversationType } from './constants';
/**
* 构建频道会话描述type / targetId / name / avatar
@@ -8,11 +8,11 @@ import { ImConversationType } from './constants'
* 抽到 utils/channel.ts 后useMessagePuller / websocketStore 共用同一份占位逻辑,避免多处复制;类似 utils/user.ts 取昵称的工具集。
*/
export const buildChannelConversationStub = (channelId: number) => {
const channel = useChannelStore().getChannel(channelId)
const channel = useChannelStore().getChannel(channelId);
return {
type: ImConversationType.CHANNEL,
targetId: channelId,
name: channel?.name || `频道 ${channelId}`,
avatar: channel?.avatar || ''
}
}
avatar: channel?.avatar || '',
};
};

View File

@@ -11,13 +11,13 @@
// ==================== 后端镜像(与 ImProperties 默认值对齐) ====================
/** 群最大成员人数(对齐 yudao.im.group.max-member */
export const GROUP_MAX_MEMBER = 500
export const GROUP_MAX_MEMBER = 500;
/** 单群管理员人数上限(对齐 yudao.im.group.admin-max-count */
export const GROUP_ADMIN_MAX_COUNT = 3
export const GROUP_ADMIN_MAX_COUNT = 3;
/** 单群置顶消息条数上限(对齐 yudao.im.group.pin-max-count */
export const GROUP_PIN_MAX_COUNT = 5
export const GROUP_PIN_MAX_COUNT = 5;
/**
* 是否启用私聊已读功能(对齐 yudao.im.message.private-read-enabled
@@ -25,7 +25,7 @@ export const GROUP_PIN_MAX_COUNT = 5
* 关闭后:进入私聊会话不再上报已读位置;气泡的「已读 / 未读」标签隐藏;
* 管理后台私聊消息列表的「状态」列与详情中的状态字段隐藏
*/
export const MESSAGE_PRIVATE_READ_ENABLED = true
export const MESSAGE_PRIVATE_READ_ENABLED = true;
/**
* 是否启用群聊已读功能(含群消息回执,对齐 yudao.im.message.group-read-enabled
@@ -33,41 +33,41 @@ export const MESSAGE_PRIVATE_READ_ENABLED = true
* 关闭后:进入群会话不再上报已读位置;输入框的「发送回执消息」入口隐藏;
* 群消息气泡上的「N 人已读」popover 隐藏;管理后台群消息列表的「状态」「回执」列与详情中对应字段隐藏
*/
export const MESSAGE_GROUP_READ_ENABLED = true
export const MESSAGE_GROUP_READ_ENABLED = true;
/** 消息撤回时间限制(分钟,对齐 yudao.im.message.recall-timeout-minutes */
export const MESSAGE_RECALL_TIMEOUT_MINUTES = 5
export const MESSAGE_RECALL_TIMEOUT_MINUTES = 5;
/** 私聊离线消息最大拉取天数(对齐 yudao.im.message.private-pull-max-days */
export const MESSAGE_PRIVATE_PULL_MAX_DAYS = 30
export const MESSAGE_PRIVATE_PULL_MAX_DAYS = 30;
/** 群聊离线消息最大拉取天数(对齐 yudao.im.message.group-pull-max-days */
export const MESSAGE_GROUP_PULL_MAX_DAYS = 30
export const MESSAGE_GROUP_PULL_MAX_DAYS = 30;
// ==================== 前端独有:拉取 / 分页 ====================
/** 每次拉取私聊消息的最大条数(后端上限 1000前端取保守值 100 */
export const MESSAGE_PRIVATE_PULL_SIZE = 100
export const MESSAGE_PRIVATE_PULL_SIZE = 100;
/** 每次拉取群聊消息的最大条数(后端上限 1000前端取保守值 100 */
export const MESSAGE_GROUP_PULL_SIZE = 100
export const MESSAGE_GROUP_PULL_SIZE = 100;
/** 「我相关」好友申请列表的单次拉取条数(游标分页 page size */
export const FRIEND_REQUEST_PAGE_SIZE = 100
export const FRIEND_REQUEST_PAGE_SIZE = 100;
/** 「我相关」加群申请列表的单次拉取条数 */
export const GROUP_REQUEST_PAGE_SIZE = 100
export const GROUP_REQUEST_PAGE_SIZE = 100;
// ==================== 上传安全策略 ====================
// 数值与后端 Spring multipart 配置对齐(`spring.servlet.multipart.max-file-size = 16MB`
// 后端调大后这里同步调;不要单边放宽,否则用户上传完到后端 413
/** 图片 / 视频 / 普通文件单文件上限MB对齐后端 max-file-size */
export const MESSAGE_IMAGE_MAX_MB = 16
export const MESSAGE_VIDEO_MAX_MB = 16
export const MESSAGE_FILE_MAX_MB = 16
export const MESSAGE_IMAGE_MAX_MB = 16;
export const MESSAGE_VIDEO_MAX_MB = 16;
export const MESSAGE_FILE_MAX_MB = 16;
/** 语音单文件上限MB60s @ 64kbps 约 0.5MB5MB 已远超录制可能产出的大小 */
export const MESSAGE_VOICE_MAX_MB = 5
export const MESSAGE_VOICE_MAX_MB = 5;
/** 可执行 / 脚本类扩展名黑名单接收端点击下载后本地双击就跑html 本地打开还能执行脚本 */
export const DANGEROUS_FILE_EXTENSIONS = [
@@ -90,26 +90,26 @@ export const DANGEROUS_FILE_EXTENSIONS = [
'ps1',
'reg',
'html',
'htm'
]
'htm',
];
// ==================== 前端独有UI 阈值 ====================
/** 消息之间渲染「时间分隔条」的阈值10 分钟 */
export const MESSAGE_TIME_TIP_GAP_MS = 10 * 60 * 1000
export const MESSAGE_TIME_TIP_GAP_MS = 10 * 60 * 1000;
/**
* 撤回菜单可见的时间窗:自己发送的消息超过这个时长就不能再撤回,菜单回退为「删除」
*
* 比后端 MESSAGE_RECALL_TIMEOUT_MINUTES5 分钟)严格,对齐微信 PC 的 2 分钟
*/
export const MESSAGE_RECALL_WINDOW_MS = 2 * 60 * 1000
export const MESSAGE_RECALL_WINDOW_MS = 2 * 60 * 1000;
/** 合并转发消息MERGE 类型)气泡内预览的最大行数(对齐微信「聊天记录」气泡) */
export const MESSAGE_MERGE_PREVIEW_LINES = 3
export const MESSAGE_MERGE_PREVIEW_LINES = 3;
/** 最近转发会话 key 列表的最大保留数量(对齐微信 PC 横向头像区可见容量) */
export const CONVERSATION_RECENT_FORWARD_MAX = 12
export const CONVERSATION_RECENT_FORWARD_MAX = 12;
// ==================== 前端独有RTC 通话兜底 ====================
@@ -117,17 +117,17 @@ export const CONVERSATION_RECENT_FORWARD_MAX = 12
* 振铃超时兜底;通话存活期间 timer 调用 noAnswerCallCheck 接口的间隔;
* 实际超时阈值由后端 yudao.im.rtc.invite-timeout-minutes 决定,前端仅决定触发频率
*/
export const RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS = 60 * 1000
export const RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS = 60 * 1000;
// ==================== 前端独有WebSocket 自动重连 ====================
// 指数退避base * 2^attempt上限封顶每次再叠加 0~jitter ms 随机偏移
// 避免服务端重启时全量客户端在同一秒打过来形成「惊群」;不设次数上限,持续重连直到链路恢复
/** 首次重连等待,单位 ms */
export const WS_RECONNECT_BASE_MS = 1000
export const WS_RECONNECT_BASE_MS = 1000;
/** 退避上限,单位 ms连续失败 5 次后稳定在 30s 不再增长 */
export const WS_RECONNECT_MAX_MS = 30 * 1000
export const WS_RECONNECT_MAX_MS = 30 * 1000;
/** 退避叠加的随机抖动上限,单位 ms */
export const WS_RECONNECT_JITTER_MS = 1000
export const WS_RECONNECT_JITTER_MS = 1000;

View File

@@ -58,8 +58,8 @@ export const ImContentType = {
GROUP_MEMBER_SETTING_UPDATE: 1530, // 群成员个人设置变更silent / groupRemark 个人多端同步
GROUP_MESSAGE_PIN: 1531, // 群消息置顶自有扩展OpenIM 无)
GROUP_MESSAGE_UNPIN: 1532, // 群消息取消置顶自有扩展OpenIM 无)
GROUP_BANNED: 1533 // 群封禁变更自有扩展OpenIM 无)
} as const
GROUP_BANNED: 1533, // 群封禁变更自有扩展OpenIM 无)
} as const;
/** 判断是否「群广播事件」:[GROUP_CREATE, GROUP_BANNED] 段位都算,仅 GROUP_MEMBER_SETTING_UPDATE 是个人信号排除 */
export function isGroupNotification(type: number): boolean {
@@ -67,12 +67,15 @@ export function isGroupNotification(type: number): boolean {
type >= ImContentType.GROUP_CREATE &&
type <= ImContentType.GROUP_BANNED &&
type !== ImContentType.GROUP_MEMBER_SETTING_UPDATE
)
);
}
/** 判断是否「好友通知事件」1201-1210 段位 */
export function isFriendNotification(type: number): boolean {
return type >= ImContentType.FRIEND_REQUEST_APPROVED && type <= ImContentType.FRIEND_UPDATE
return (
type >= ImContentType.FRIEND_REQUEST_APPROVED &&
type <= ImContentType.FRIEND_UPDATE
);
}
/** 判断是否「加群申请通知事件」1503/1505/1506 */
@@ -81,17 +84,21 @@ export function isGroupRequestNotification(type: number): boolean {
type === ImContentType.GROUP_REQUEST_RECEIVED ||
type === ImContentType.GROUP_REQUEST_APPROVED ||
type === ImContentType.GROUP_REQUEST_REJECTED
)
);
}
/** 判断是否「会话内的好友事件气泡」FRIEND_ADD / FRIEND_DELETE 直接渲染成灰色提示,与群事件同处理 */
export function isFriendChatTip(type: number): boolean {
return type === ImContentType.FRIEND_ADD || type === ImContentType.FRIEND_DELETE
return (
type === ImContentType.FRIEND_ADD || type === ImContentType.FRIEND_DELETE
);
}
/** 判断是否「会话内的通话事件气泡」RTC_CALL_START / RTC_CALL_END 渲染成灰色提示 */
export function isRtcCallTip(type: number): boolean {
return type === ImContentType.RTC_CALL_START || type === ImContentType.RTC_CALL_END
return (
type === ImContentType.RTC_CALL_START || type === ImContentType.RTC_CALL_END
);
}
/**
@@ -114,12 +121,12 @@ const ImContentTypeNormals = new Set<number>([
ImContentType.MERGE,
ImContentType.TEXT,
ImContentType.VIDEO,
ImContentType.VOICE
])
ImContentType.VOICE,
]);
/** 判断是否"普通消息" */
export function isNormalMessage(type: number): boolean {
return ImContentTypeNormals.has(type)
return ImContentTypeNormals.has(type);
}
/** IM 媒体内容类型集合:发送依赖本地 File 上传,刷新后 _localFile 丢失即不可恢复 */
@@ -127,12 +134,12 @@ const ImContentTypeMedia = new Set<number>([
ImContentType.FILE,
ImContentType.IMAGE,
ImContentType.VIDEO,
ImContentType.VOICE
])
ImContentType.VOICE,
]);
/** 判断是否「媒体消息」:图片 / 文件 / 语音 / 视频 */
export function isMediaMessageType(type: number): boolean {
return ImContentTypeMedia.has(type)
return ImContentTypeMedia.has(type);
}
/**
@@ -144,47 +151,48 @@ export const ImMessageStatus = {
FAILED: -2, // 发送失败(前端独有)
SENDING: -1, // 发送中(前端独有)
NORMAL: 0, // 正常
RECALL: 2 // 已撤回
} as const
RECALL: 2, // 已撤回
} as const;
/** IM 会话类型枚举 */
export const ImConversationType = {
NONE: 0, // 无会话
PRIVATE: 1, // 私聊
GROUP: 2, // 群聊
CHANNEL: 3 // 频道 / 公众号
} as const
CHANNEL: 3, // 频道 / 公众号
} as const;
/** ImConversationType 取值(用于消息 payload 字段类型收窄) */
export type ImConversationTypeValue = (typeof ImConversationType)[keyof typeof ImConversationType]
export type ImConversationTypeValue =
(typeof ImConversationType)[keyof typeof ImConversationType];
/** 是否私聊会话;同时收窄类型 */
export function isPrivateConversation(type: number | undefined): boolean {
return type === ImConversationType.PRIVATE
return type === ImConversationType.PRIVATE;
}
/** 是否群聊会话;同时收窄类型 */
export function isGroupConversation(type: number | undefined): boolean {
return type === ImConversationType.GROUP
return type === ImConversationType.GROUP;
}
/** 是否频道会话;同时收窄类型 */
export function isChannelConversation(type: number | undefined): boolean {
return type === ImConversationType.CHANNEL
return type === ImConversationType.CHANNEL;
}
/** IM 通话媒体类型(对齐后端 ImRtcCallMediaTypeEnum */
export const ImRtcCallMediaType = {
VOICE: 1,
VIDEO: 2
} as const
VIDEO: 2,
} as const;
/** IM 通话状态(对齐后端 ImRtcCallStatusEnum */
export const ImRtcCallStatus = {
CREATED: 10, // 创建:私聊等被叫接听;群聊发起人已进房等其他人加入
RUNNING: 20, // 进行中:第一个非发起人接通后进入
ENDED: 30 // 已结束
} as const
ENDED: 30, // 已结束
} as const;
/** IM 通话结束原因(对齐后端 ImRtcCallEndReasonEnum */
export const ImRtcCallEndReason = {
@@ -193,11 +201,12 @@ export const ImRtcCallEndReason = {
CANCEL: 3, // 主叫接通前主动取消
NO_ANSWER: 4, // 振铃超时未接通
BUSY: 5, // 私聊呼叫时对方正忙
ERROR: 9 // 网络中断 / 设备失败
} as const
ERROR: 9, // 网络中断 / 设备失败
} as const;
/** ImRtcCallEndReason 取值类型 */
export type ImRtcCallEndReasonValue = (typeof ImRtcCallEndReason)[keyof typeof ImRtcCallEndReason]
export type ImRtcCallEndReasonValue =
(typeof ImRtcCallEndReason)[keyof typeof ImRtcCallEndReason];
/** IM 通话参与者状态(对齐后端 ImRtcParticipantStatusEnum同时作为 RTC_CALL 信令 status 字段取值 */
export const ImRtcParticipantStatus = {
@@ -205,12 +214,12 @@ export const ImRtcParticipantStatus = {
JOINED: 20, // 接听 / 已加入
REJECTED: 30, // 拒接
NO_ANSWER: 40, // 主叫取消,被邀请方未应答
LEFT: 50 // 挂断离开
} as const
LEFT: 50, // 挂断离开
} as const;
/** ImRtcParticipantStatus 取值类型 */
export type ImRtcParticipantStatusValue =
(typeof ImRtcParticipantStatus)[keyof typeof ImRtcParticipantStatus]
(typeof ImRtcParticipantStatus)[keyof typeof ImRtcParticipantStatus];
/**
* IM 通话 UI 阶段;前端独有,用于驱动 inviting / incoming / running 三种弹窗切换;
@@ -220,60 +229,61 @@ export const ImRtcCallStage = {
IDLE: 'idle', // 空闲;后端无对应(本端无活跃通话)
INVITING: 'inviting', // 主叫等待对方接受;对应后端 ImRtcCallStatus.CREATED自己是主叫
INCOMING: 'incoming', // 被叫来电响铃;对应后端 ImRtcCallStatus.CREATED自己是被叫
RUNNING: 'running' // 通话中;对应后端 ImRtcCallStatus.RUNNING
} as const
RUNNING: 'running', // 通话中;对应后端 ImRtcCallStatus.RUNNING
} as const;
/** ImRtcCallStage 取值类型 */
export type ImRtcCallStageValue = (typeof ImRtcCallStage)[keyof typeof ImRtcCallStage]
export type ImRtcCallStageValue =
(typeof ImRtcCallStage)[keyof typeof ImRtcCallStage];
/** IM WebSocket 外层帧类型 */
export const ImWebSocketMessageType = {
NOTIFICATION: 'im-notification' // IM 通知
} as const
NOTIFICATION: 'im-notification', // IM 通知
} as const;
/** IM 消息回执状态枚举(对齐后端 ImMessageReceiptStatusEnum */
export const ImMessageReceiptStatus = {
NO_RECEIPT: 0, // 不需要回执
PENDING: 1, // 待完成
DONE: 2 // 已完成
} as const
DONE: 2, // 已完成
} as const;
/** 群成员角色(对齐后端 ImGroupMemberRoleEnum */
export const ImGroupMemberRole = {
OWNER: 1, // 群主
ADMIN: 2, // 管理员
NORMAL: 3 // 普通成员
} as const
NORMAL: 3, // 普通成员
} as const;
/** 加群来源(对齐后端 ImGroupAddSourceEnum */
export const ImGroupAddSource = {
SEARCH: 1, // 搜索
INVITE: 2, // 邀请
QR_CODE: 3, // 扫码
SHARE_LINK: 4 // 分享链接
} as const
SHARE_LINK: 4, // 分享链接
} as const;
/** 加群申请处理结果(对齐后端 ImGroupRequestHandleResultEnum */
export const ImGroupRequestHandleResult = {
UNHANDLED: 0, // 未处理
AGREED: 1, // 同意
REFUSED: 2 // 拒绝
} as const
REFUSED: 2, // 拒绝
} as const;
/** 好友添加来源(对齐后端 ImFriendAddSourceEnum */
export const ImFriendAddSource = {
SEARCH: 1, // 搜索
GROUP: 2, // 群聊
QR_CODE: 3, // 扫码
CARD: 4 // 名片
} as const
CARD: 4, // 名片
} as const;
/** 好友申请处理结果(对齐后端 ImFriendRequestHandleResultEnum */
export const ImFriendRequestHandleResult = {
UNHANDLED: 0, // 未处理
AGREED: 1, // 同意
REFUSED: 2 // 拒绝
} as const
REFUSED: 2, // 拒绝
} as const;
/**
* @全体成员 的特殊 userId 标识atUserIds 中包含 -1 表示 @ 全体成员
@@ -281,16 +291,17 @@ export const ImFriendRequestHandleResult = {
* 与后端约定:群消息 atUserIds 数组里出现 -1 时,所有成员都收到提醒
* MentionPicker 渲染虚拟项 + conversationStore.applyAt 判定 atAll 都靠这个值
*/
export const IM_AT_ALL_USER_ID = -1
export const IM_AT_ALL_USER_ID = -1;
/** @全体成员 的展示名(对齐微信 PC */
export const IM_AT_ALL_NICKNAME = '所有人'
export const IM_AT_ALL_NICKNAME = '所有人';
/** 转发模式SINGLE 逐条原样转 / MERGE 打包成 MergeMessage */
export const ImForwardMode = {
SINGLE: 'single',
MERGE: 'merge'
} as const
MERGE: 'merge',
} as const;
/** ImForwardMode 取值类型 */
export type ImForwardModeValue = (typeof ImForwardMode)[keyof typeof ImForwardMode]
export type ImForwardModeValue =
(typeof ImForwardMode)[keyof typeof ImForwardMode];

View File

@@ -6,48 +6,63 @@
// 2. fallbackName 由调用方传入典型来源Conversation.lastSenderDisplayName 快照),透传到 getSenderDisplayName 内部,算不出真名时兜底
// ====================================================================
import type { Message } from '../home/types'
import type { Message } from '../home/types';
import type {
CardMessage,
FaceMessage,
FileMessage,
MaterialMessage,
TextMessage,
TipSegment,
} from './message';
import { ImContentType, ImConversationType, isFriendChatTip, isGroupNotification, isRtcCallTip } from './constants'
import {
type CardMessage,
type FaceMessage,
type FileMessage,
ImContentType,
ImConversationType,
isFriendChatTip,
isGroupNotification,
isRtcCallTip,
} from './constants';
import {
getCardLabelInfo,
type MaterialMessage,
parseMessage,
resolveFriendNotificationText,
resolveGroupNotificationText,
resolveRtcCallLastContent,
segmentsToText,
type TextMessage,
tipMention,
type TipSegment,
tipText
} from './message'
import { resolveFriendNotificationText, resolveGroupNotificationText, resolveRtcCallLastContent } from './message'
import { getSenderDisplayName } from './user'
tipText,
} from './message';
import { getSenderDisplayName } from './user';
/** 会话主键:`type-targetId` 拼成稳定字符串,给 v-for :key、active 比对、map key 等场景共用 */
export function getConversationKey(conversation: { targetId: number; type: number; }): string {
return `${conversation.type}-${conversation.targetId}`
export function getConversationKey(conversation: {
targetId: number;
type: number;
}): string {
return `${conversation.type}-${conversation.targetId}`;
}
/** 按昵称模糊过滤会话列表:空 keyword 原样返回,命中走 toLowerCase 不区分大小写 */
export function filterConversationsByKeyword<T extends { name?: string }>(
list: T[],
keyword: string
keyword: string,
): T[] {
const trimmed = keyword.trim().toLowerCase()
const trimmed = keyword.trim().toLowerCase();
if (!trimmed) {
return list
return list;
}
return list.filter((c) => (c.name || '').toLowerCase().includes(trimmed))
return list.filter((c) => (c.name || '').toLowerCase().includes(trimmed));
}
/**
* 表情消息的统一文本预览:摘要 / 历史搜索 / 引用块 / 管理后台预览共用一份
* 有 name 时走 `[表情] name`(系统包),无 name 时走 `[表情]`(个人表情通常无 name
*/
export function buildFacePreviewText(facePayload: null | undefined | { name?: string }): string {
return facePayload?.name ? `[表情] ${facePayload.name}` : '[表情]'
export function buildFacePreviewText(
facePayload: null | undefined | { name?: string },
): string {
return facePayload?.name ? `[表情] ${facePayload.name}` : '[表情]';
}
/**
@@ -60,24 +75,24 @@ export function buildRecallTipSegments(
selfSend: boolean,
conversationType: number,
conversationTargetId: number,
fallbackName?: string
fallbackName?: string,
): TipSegment[] {
if (selfSend) {
return [tipText('你撤回了一条消息')]
return [tipText('你撤回了一条消息')];
}
const senderDisplayName = getSenderDisplayName(
senderId,
conversationType,
conversationTargetId,
fallbackName
)
fallbackName,
);
if (!senderId) {
return [tipText(`${senderDisplayName || '对方'} 撤回了一条消息`)]
return [tipText(`${senderDisplayName || '对方'} 撤回了一条消息`)];
}
return [
tipMention(senderId, senderDisplayName || '对方'),
tipText(' 撤回了一条消息')
]
tipText(' 撤回了一条消息'),
];
}
/** 撤回提示文案:自己撤回固定文案,对方撤回带 sender 名(实时算 + fallbackName 兜底) */
@@ -86,11 +101,17 @@ export function buildRecallTip(
selfSend: boolean,
conversationType: number,
conversationTargetId: number,
fallbackName?: string
fallbackName?: string,
): string {
return segmentsToText(
buildRecallTipSegments(senderId, selfSend, conversationType, conversationTargetId, fallbackName)
)
buildRecallTipSegments(
senderId,
selfSend,
conversationType,
conversationTargetId,
fallbackName,
),
);
}
/**
@@ -101,47 +122,47 @@ export function buildRecallTip(
*/
export function summarizeMessageContent(
message: Pick<Message, 'content' | 'type'>,
opts?: { withFileName?: boolean }
opts?: { withFileName?: boolean },
): string {
switch (message.type) {
case ImContentType.CARD: {
return `[${getCardLabelInfo(parseMessage<CardMessage>(message.content)).label}]`
return `[${getCardLabelInfo(parseMessage<CardMessage>(message.content)).label}]`;
}
case ImContentType.FACE: {
return buildFacePreviewText(parseMessage<FaceMessage>(message.content))
return buildFacePreviewText(parseMessage<FaceMessage>(message.content));
}
case ImContentType.FILE: {
if (opts?.withFileName) {
const file = parseMessage<FileMessage>(message.content)
return file?.name ? `[文件] ${file.name}` : '[文件]'
const file = parseMessage<FileMessage>(message.content);
return file?.name ? `[文件] ${file.name}` : '[文件]';
}
return '[文件]'
return '[文件]';
}
case ImContentType.IMAGE: {
return '[图片]'
return '[图片]';
}
case ImContentType.MATERIAL: {
const material = parseMessage<MaterialMessage>(message.content)
return material?.title ? `[频道] ${material.title}` : '[频道]'
const material = parseMessage<MaterialMessage>(message.content);
return material?.title ? `[频道] ${material.title}` : '[频道]';
}
case ImContentType.MERGE: {
return '[聊天记录]'
return '[聊天记录]';
}
case ImContentType.RTC_CALL_END:
case ImContentType.RTC_CALL_START: {
return '[语音通话]'
return '[语音通话]';
}
case ImContentType.TEXT: {
return parseMessage<TextMessage>(message.content)?.content ?? ''
return parseMessage<TextMessage>(message.content)?.content ?? '';
}
case ImContentType.VIDEO: {
return '[视频]'
return '[视频]';
}
case ImContentType.VOICE: {
return '[语音]'
return '[语音]';
}
default: {
return ''
return '';
}
}
}
@@ -151,7 +172,7 @@ export function resolveConversationLastContent(
message: Message,
conversationType: number,
conversationTargetId: number,
fallbackName?: string
fallbackName?: string,
): string {
if (message.type === ImContentType.RECALL) {
return buildRecallTip(
@@ -159,19 +180,19 @@ export function resolveConversationLastContent(
message.selfSend,
conversationType,
conversationTargetId,
fallbackName
)
fallbackName,
);
}
if (isFriendChatTip(message.type)) {
return resolveFriendNotificationText(message)
return resolveFriendNotificationText(message);
}
if (isGroupNotification(message.type)) {
return resolveGroupNotificationText(message, (id) =>
getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0)
)
getSenderDisplayName(id, ImConversationType.GROUP, message.targetId ?? 0),
);
}
if (isRtcCallTip(message.type)) {
return resolveRtcCallLastContent(message, conversationType)
return resolveRtcCallLastContent(message, conversationType);
}
return summarizeMessageContent(message)
return summarizeMessageContent(message);
}

View File

@@ -1,12 +1,12 @@
import type { MessageDO, SettingDO } from '../home/types'
import type { MessageDO, SettingDO } from '../home/types';
import { toRaw } from 'vue'
import { toRaw } from 'vue';
import { getCurrentUserId } from '#/views/im/utils/auth'
import { getCurrentUserId } from '#/views/im/utils/auth';
import { ImConversationType } from './constants'
import { ImConversationType } from './constants';
export const DB_SCHEMA_VERSION = 2
export const DB_SCHEMA_VERSION = 2;
export type DbStoreName =
| 'channels'
@@ -18,9 +18,9 @@ export type DbStoreName =
| 'groupRequests'
| 'groups'
| 'messages'
| 'settings'
| 'settings';
export type DbTransaction = IDBTransaction
export type DbTransaction = IDBTransaction;
/** IM 本地存储 key */
export const StorageKeys = {
@@ -28,7 +28,7 @@ export const StorageKeys = {
/** 侧边栏宽度,三个 Tab 共用一份记忆 */
asideWidth: 'im:aside',
/** 会话列表置顶折叠展开态 */
conversationPinnedExpanded: 'im:conversation:pinnedExpanded'
conversationPinnedExpanded: 'im:conversation:pinnedExpanded',
},
settings: {
/** 私聊消息拉取游标 */
@@ -48,44 +48,44 @@ export const StorageKeys = {
/** 加群申请增量拉取游标 */
groupRequestPullCursor: 'groupRequestPullCursor',
/** 会话读位置增量拉取游标 */
conversationReadPullCursor: 'conversationReadPullCursor'
}
} as const
conversationReadPullCursor: 'conversationReadPullCursor',
},
} as const;
let currentDb: IDBDatabase | null = null
let currentUserId: null | number = null
let currentSession = 0
let currentDb: IDBDatabase | null = null;
let currentUserId: null | number = null;
let currentSession = 0;
/** 校验当前 IM IndexedDB session 仍有效 */
export function isCurrentDbSession(session: number): boolean {
return session === currentSession
return session === currentSession;
}
/** 获取当前 IM IndexedDB session */
export function getDbSession(): number {
return currentSession
return currentSession;
}
/** 拼接当前用户 IM DB 名称 */
function getDbName(userId: number): string {
return `im:${userId}`
return `im:${userId}`;
}
/** 包装 IndexedDB request */
function requestToPromise<T = unknown>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.addEventListener('success', () => resolve(request.result))
request.addEventListener('error', () => reject(request.error))
})
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => reject(request.error));
});
}
/** 等待事务完成 */
function transactionDone(transaction: DbTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.addEventListener('complete', () => resolve())
transaction.addEventListener('error', () => reject(transaction.error))
transaction.addEventListener('abort', () => reject(transaction.error))
})
transaction.addEventListener('complete', () => resolve());
transaction.addEventListener('error', () => reject(transaction.error));
transaction.addEventListener('abort', () => reject(transaction.error));
});
}
/** 创建索引 */
@@ -93,155 +93,182 @@ function createIndex(
store: IDBObjectStore,
name: string,
keyPath: string | string[],
options?: IDBIndexParameters
options?: IDBIndexParameters,
) {
if (!store.indexNames.contains(name)) {
store.createIndex(name, keyPath, options)
store.createIndex(name, keyPath, options);
}
}
/** 初始化 schema */
function upgradeSchema(db: IDBDatabase) {
if (!db.objectStoreNames.contains('conversations')) {
const store = db.createObjectStore('conversations', { keyPath: 'clientConversationId' })
createIndex(store, 'lastSendTime', 'lastSendTime')
const store = db.createObjectStore('conversations', {
keyPath: 'clientConversationId',
});
createIndex(store, 'lastSendTime', 'lastSendTime');
}
if (!db.objectStoreNames.contains('conversationReads')) {
const store = db.createObjectStore('conversationReads', { keyPath: 'clientConversationId' })
createIndex(store, 'conversationType+targetId', ['conversationType', 'targetId'], {
unique: true
})
const store = db.createObjectStore('conversationReads', {
keyPath: 'clientConversationId',
});
createIndex(
store,
'conversationType+targetId',
['conversationType', 'targetId'],
{
unique: true,
},
);
}
if (!db.objectStoreNames.contains('messages')) {
const store = db.createObjectStore('messages', { keyPath: 'messageKey' })
createIndex(store, 'clientConversationId', 'clientConversationId')
createIndex(store, 'clientConversationId+sendTime', ['clientConversationId', 'sendTime'])
createIndex(store, 'clientMessageId', 'clientMessageId', { unique: true })
const store = db.createObjectStore('messages', { keyPath: 'messageKey' });
createIndex(store, 'clientConversationId', 'clientConversationId');
createIndex(store, 'clientConversationId+sendTime', [
'clientConversationId',
'sendTime',
]);
createIndex(store, 'clientMessageId', 'clientMessageId', { unique: true });
}
if (!db.objectStoreNames.contains('friends')) {
const store = db.createObjectStore('friends', { keyPath: 'id' })
createIndex(store, 'friendUserId', 'friendUserId', { unique: true })
createIndex(store, 'status', 'status')
const store = db.createObjectStore('friends', { keyPath: 'id' });
createIndex(store, 'friendUserId', 'friendUserId', { unique: true });
createIndex(store, 'status', 'status');
}
if (!db.objectStoreNames.contains('friendRequests')) {
const store = db.createObjectStore('friendRequests', { keyPath: 'id' })
createIndex(store, 'status', 'status')
createIndex(store, 'createTime', 'createTime')
const store = db.createObjectStore('friendRequests', { keyPath: 'id' });
createIndex(store, 'status', 'status');
createIndex(store, 'createTime', 'createTime');
}
if (!db.objectStoreNames.contains('groups')) {
const store = db.createObjectStore('groups', { keyPath: 'id' })
createIndex(store, 'name', 'name')
createIndex(store, 'status', 'status')
const store = db.createObjectStore('groups', { keyPath: 'id' });
createIndex(store, 'name', 'name');
createIndex(store, 'status', 'status');
}
if (!db.objectStoreNames.contains('groupMembers')) {
const store = db.createObjectStore('groupMembers', { keyPath: 'id' })
createIndex(store, 'groupId', 'groupId')
createIndex(store, 'groupId+userId', ['groupId', 'userId'], { unique: true })
const store = db.createObjectStore('groupMembers', { keyPath: 'id' });
createIndex(store, 'groupId', 'groupId');
createIndex(store, 'groupId+userId', ['groupId', 'userId'], {
unique: true,
});
}
if (!db.objectStoreNames.contains('groupRequests')) {
const store = db.createObjectStore('groupRequests', { keyPath: 'id' })
createIndex(store, 'status', 'status')
createIndex(store, 'createTime', 'createTime')
const store = db.createObjectStore('groupRequests', { keyPath: 'id' });
createIndex(store, 'status', 'status');
createIndex(store, 'createTime', 'createTime');
}
if (!db.objectStoreNames.contains('channels')) {
const store = db.createObjectStore('channels', { keyPath: 'id' })
createIndex(store, 'status', 'status')
createIndex(store, 'sort', 'sort')
const store = db.createObjectStore('channels', { keyPath: 'id' });
createIndex(store, 'status', 'status');
createIndex(store, 'sort', 'sort');
}
if (!db.objectStoreNames.contains('settings')) {
db.createObjectStore('settings', { keyPath: 'key' })
db.createObjectStore('settings', { keyPath: 'key' });
}
}
/** 打开 IM IndexedDB */
function openDb(name: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, DB_SCHEMA_VERSION)
const request = indexedDB.open(name, DB_SCHEMA_VERSION);
// 创建或升级对象仓库
request.addEventListener('upgradeneeded', () => upgradeSchema(request.result))
request.addEventListener('upgradeneeded', () =>
upgradeSchema(request.result),
);
// 返回可复用连接
request.addEventListener('success', () => resolve(request.result))
request.addEventListener('error', () => reject(request.error))
})
request.addEventListener('success', () => resolve(request.result));
request.addEventListener('error', () => reject(request.error));
});
}
/** 初始化当前用户 IM DB */
export async function initDb(): Promise<void> {
const userId = getCurrentUserId()
const userId = getCurrentUserId();
if (!Number.isFinite(userId) || userId <= 0) {
throw new Error('当前用户不存在,无法初始化 IM DB')
throw new Error('当前用户不存在,无法初始化 IM DB');
}
if (currentDb && currentUserId === userId) {
return
return;
}
currentDb?.close()
currentSession++
currentUserId = userId
currentDb = await openDb(getDbName(userId))
currentDb?.close();
currentSession++;
currentUserId = userId;
currentDb = await openDb(getDbName(userId));
}
/** 关闭当前 IM DB 连接 */
function closeDbConnection() {
currentDb?.close()
currentDb = null
currentUserId = null
currentDb?.close();
currentDb = null;
currentUserId = null;
}
/** 获取当前 IM DB */
function getRawDb(): IDBDatabase {
if (!currentDb) {
throw new Error('IM DB 未初始化')
throw new Error('IM DB 未初始化');
}
return currentDb
return currentDb;
}
/** 校验单次写入 session */
function guardSession(session: number) {
if (!isCurrentDbSession(session)) {
throw new Error('IM DB session 已失效')
throw new Error('IM DB session 已失效');
}
}
/** 克隆可入库对象 */
function toDbValue<T>(value: T): T {
return cloneDbValue(value) as T
return cloneDbValue(value) as T;
}
/** 转换为 IndexedDB 可克隆对象 */
function cloneDbValue(value: unknown): unknown {
const raw = toRaw(value)
const raw = toRaw(value);
if (Array.isArray(raw)) {
return raw.map((item) => cloneDbValue(item))
return raw.map((item) => cloneDbValue(item));
}
if (!raw || typeof raw !== 'object') {
return raw
return raw;
}
const prototype = Object.getPrototypeOf(raw)
const prototype = Object.getPrototypeOf(raw);
if (prototype !== Object.prototype && prototype !== null) {
return raw
return raw;
}
return Object.fromEntries(
Object.entries(raw as Record<string, unknown>).map(([key, item]) => [key, cloneDbValue(item)])
)
Object.entries(raw as Record<string, unknown>).map(([key, item]) => [
key,
cloneDbValue(item),
]),
);
}
class DbClient {
/** 清空 store 记录 */
async clearStore(storeName: DbStoreName, tx?: DbTransaction): Promise<void> {
if (tx) {
await requestToPromise(tx.objectStore(storeName).clear())
return
await requestToPromise(tx.objectStore(storeName).clear());
return;
}
await this.transaction([storeName], 'readwrite', (tx) => this.clearStore(storeName, tx))
await this.transaction([storeName], 'readwrite', (tx) =>
this.clearStore(storeName, tx),
);
}
/** 删除记录 */
async delete(storeName: DbStoreName, key: IDBValidKey, tx?: DbTransaction): Promise<void> {
async delete(
storeName: DbStoreName,
key: IDBValidKey,
tx?: DbTransaction,
): Promise<void> {
if (tx) {
await requestToPromise(tx.objectStore(storeName).delete(key))
return
await requestToPromise(tx.objectStore(storeName).delete(key));
return;
}
await this.transaction([storeName], 'readwrite', (tx) => this.delete(storeName, key, tx))
await this.transaction([storeName], 'readwrite', (tx) =>
this.delete(storeName, key, tx),
);
}
/** 按索引删除记录 */
@@ -249,50 +276,54 @@ class DbClient {
storeName: DbStoreName,
indexName: string,
query: IDBKeyRange | IDBValidKey,
tx?: DbTransaction
tx?: DbTransaction,
): Promise<void> {
if (!tx) {
await this.transaction([storeName], 'readwrite', (tx) =>
this.deleteByIndex(storeName, indexName, query, tx)
)
return
this.deleteByIndex(storeName, indexName, query, tx),
);
return;
}
const index = tx.objectStore(storeName).index(indexName)
const index = tx.objectStore(storeName).index(indexName);
await new Promise<void>((resolve, reject) => {
const request = index.openCursor(query)
request.addEventListener('error', () => reject(request.error))
const request = index.openCursor(query);
request.addEventListener('error', () => reject(request.error));
request.addEventListener('success', () => {
const cursor = request.result
const cursor = request.result;
if (!cursor) {
resolve()
return
resolve();
return;
}
cursor.delete()
cursor.continue()
})
})
cursor.delete();
cursor.continue();
});
});
}
/** 获取单条记录 */
async get<T>(
storeName: DbStoreName,
key: IDBValidKey,
tx?: DbTransaction
tx?: DbTransaction,
): Promise<T | undefined> {
if (tx) {
return requestToPromise<T | undefined>(tx.objectStore(storeName).get(key))
return requestToPromise<T | undefined>(
tx.objectStore(storeName).get(key),
);
}
return this.transaction<T | undefined>([storeName], 'readonly', (tx) =>
this.get<T>(storeName, key, tx)
)
this.get<T>(storeName, key, tx),
);
}
/** 获取 store 全量记录 */
async getAll<T>(storeName: DbStoreName, tx?: DbTransaction): Promise<T[]> {
if (tx) {
return requestToPromise<T[]>(tx.objectStore(storeName).getAll())
return requestToPromise<T[]>(tx.objectStore(storeName).getAll());
}
return this.transaction<T[]>([storeName], 'readonly', (tx) => this.getAll<T>(storeName, tx))
return this.transaction<T[]>([storeName], 'readonly', (tx) =>
this.getAll<T>(storeName, tx),
);
}
/** 按索引获取记录列表 */
@@ -300,14 +331,16 @@ class DbClient {
storeName: DbStoreName,
indexName: string,
query?: IDBKeyRange | IDBValidKey,
tx?: DbTransaction
tx?: DbTransaction,
): Promise<T[]> {
if (tx) {
return requestToPromise<T[]>(tx.objectStore(storeName).index(indexName).getAll(query))
return requestToPromise<T[]>(
tx.objectStore(storeName).index(indexName).getAll(query),
);
}
return this.transaction<T[]>([storeName], 'readonly', (tx) =>
this.getAllByIndex<T>(storeName, indexName, query, tx)
)
this.getAllByIndex<T>(storeName, indexName, query, tx),
);
}
/** 按唯一索引获取单条记录 */
@@ -315,200 +348,224 @@ class DbClient {
storeName: DbStoreName,
indexName: string,
query: IDBKeyRange | IDBValidKey,
tx?: DbTransaction
tx?: DbTransaction,
): Promise<T | undefined> {
if (tx) {
return requestToPromise<T | undefined>(tx.objectStore(storeName).index(indexName).get(query))
return requestToPromise<T | undefined>(
tx.objectStore(storeName).index(indexName).get(query),
);
}
return this.transaction<T | undefined>([storeName], 'readonly', (tx) =>
this.getByIndex<T>(storeName, indexName, query, tx)
)
this.getByIndex<T>(storeName, indexName, query, tx),
);
}
/** 按会话分页获取消息 */
async getMessageListByConversation(
clientConversationId: string,
options?: { beforeSendTime?: number; limit?: number },
tx?: DbTransaction
tx?: DbTransaction,
): Promise<MessageDO[]> {
const limit = options?.limit ?? 50
const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER
const limit = options?.limit ?? 50;
const upper = options?.beforeSendTime ?? Number.MAX_SAFE_INTEGER;
const range = IDBKeyRange.bound(
[clientConversationId, 0],
[clientConversationId, upper],
false,
true
)
true,
);
const read = async (tx: DbTransaction): Promise<MessageDO[]> => {
const index = tx.objectStore('messages').index('clientConversationId+sendTime')
const out: MessageDO[] = []
const index = tx
.objectStore('messages')
.index('clientConversationId+sendTime');
const out: MessageDO[] = [];
await new Promise<void>((resolve, reject) => {
// 从新到旧读取一页
const request = index.openCursor(range, 'prev')
request.addEventListener('error', () => reject(request.error))
const request = index.openCursor(range, 'prev');
request.addEventListener('error', () => reject(request.error));
request.addEventListener('success', () => {
const cursor = request.result
const cursor = request.result;
if (!cursor || out.length >= limit) {
resolve()
return
resolve();
return;
}
out.push(cursor.value as MessageDO)
cursor.continue()
})
})
out.push(cursor.value as MessageDO);
cursor.continue();
});
});
// 气泡渲染需要按时间升序
return out.toReversed()
}
return out.toReversed();
};
if (tx) {
return read(tx)
return read(tx);
}
return this.transaction<MessageDO[]>(['messages'], 'readonly', read)
return this.transaction<MessageDO[]>(['messages'], 'readonly', read);
}
/** 读取设置 */
async getSetting<T>(key: string, tx?: DbTransaction): Promise<T | undefined> {
const item = await this.get<SettingDO<T>>('settings', key, tx)
return item?.value
const item = await this.get<SettingDO<T>>('settings', key, tx);
return item?.value;
}
/** 写入记录 */
async put<T>(storeName: DbStoreName, value: T, tx?: DbTransaction): Promise<void> {
async put<T>(
storeName: DbStoreName,
value: T,
tx?: DbTransaction,
): Promise<void> {
if (tx) {
await requestToPromise(tx.objectStore(storeName).put(toDbValue(value)))
return
await requestToPromise(tx.objectStore(storeName).put(toDbValue(value)));
return;
}
await this.transaction([storeName], 'readwrite', (tx) => this.put(storeName, value, tx))
await this.transaction([storeName], 'readwrite', (tx) =>
this.put(storeName, value, tx),
);
}
/** 写入设置 */
async setSetting<T>(key: string, value: T, tx?: DbTransaction): Promise<void> {
await this.put<SettingDO<T>>('settings', { key, value, updateTime: Date.now() }, tx)
async setSetting<T>(
key: string,
value: T,
tx?: DbTransaction,
): Promise<void> {
await this.put<SettingDO<T>>(
'settings',
{ key, value, updateTime: Date.now() },
tx,
);
}
/** 执行事务 */
async transaction<T>(
storeNames: DbStoreName[],
mode: IDBTransactionMode,
runner: (tx: DbTransaction) => Promise<T>
runner: (tx: DbTransaction) => Promise<T>,
): Promise<T> {
// 开启事务前校验 session
const session = getDbSession()
guardSession(session)
const tx = getRawDb().transaction(storeNames, mode)
const done = transactionDone(tx)
let result: T
const session = getDbSession();
guardSession(session);
const tx = getRawDb().transaction(storeNames, mode);
const done = transactionDone(tx);
let result: T;
try {
// 事务内只执行 IndexedDB request 链
result = await runner(tx)
result = await runner(tx);
} catch (error) {
try {
tx.abort()
tx.abort();
} catch {}
await done.catch(() => undefined)
throw error
await done.catch(() => undefined);
throw error;
}
// commit 后再次校验 session
await done
guardSession(session)
return result
await done;
guardSession(session);
return result;
}
}
const dbClient = new DbClient()
const dbClient = new DbClient();
/** 获取当前 IM DB client */
export function getDb(): DbClient {
return dbClient
return dbClient;
}
/** 当前用户会话主键 */
export function getClientConversationId(type: number, targetId: number): string {
return `${type}:${targetId}`
export function getClientConversationId(
type: number,
targetId: number,
): string {
return `${type}:${targetId}`;
}
/** 解析当前用户会话主键 */
export function parseClientConversationId(
clientConversationId: string
): null | { targetId: number; type: number; } {
const [typeText, targetIdText] = clientConversationId.split(':')
const type = Number(typeText)
const targetId = Number(targetIdText)
clientConversationId: string,
): null | { targetId: number; type: number } {
const [typeText, targetIdText] = clientConversationId.split(':');
const type = Number(typeText);
const targetId = Number(targetIdText);
if (!Number.isFinite(type) || !Number.isFinite(targetId) || targetId <= 0) {
return null
return null;
}
return { type, targetId }
return { type, targetId };
}
/** 服务端消息主键 */
export function getServerMessageKey(conversationType: number, id: number): string {
return `${conversationType}:${id}`
export function getServerMessageKey(
conversationType: number,
id: number,
): string {
return `${conversationType}:${id}`;
}
/** 客户端临时消息主键 */
export function getClientMessageKey(clientMessageId: string): string {
return `client:${clientMessageId}`
return `client:${clientMessageId}`;
}
/** 解析本地消息主键 */
export function parseMessageKey(
messageKey: string
messageKey: string,
):
| null
| { clientMessageId: string; kind: 'client'; }
| { conversationType: number; id: number; kind: 'server'; } {
| { clientMessageId: string; kind: 'client' }
| { conversationType: number; id: number; kind: 'server' } {
if (!messageKey) {
return null
return null;
}
if (messageKey.startsWith('client:')) {
const clientMessageId = messageKey.slice('client:'.length)
return clientMessageId ? { kind: 'client', clientMessageId } : null
const clientMessageId = messageKey.slice('client:'.length);
return clientMessageId ? { kind: 'client', clientMessageId } : null;
}
const [conversationTypeText, idText] = messageKey.split(':')
const conversationType = Number(conversationTypeText)
const id = Number(idText)
const [conversationTypeText, idText] = messageKey.split(':');
const conversationType = Number(conversationTypeText);
const id = Number(idText);
if (!Number.isFinite(conversationType) || !Number.isFinite(id) || id <= 0) {
return null
return null;
}
return { kind: 'server', conversationType, id }
return { kind: 'server', conversationType, id };
}
/** 更新消息拉取游标 */
export async function setMessageMaxId(
conversationType: number,
maxId: number | undefined,
tx?: DbTransaction
tx?: DbTransaction,
): Promise<void> {
if (!maxId) {
return
return;
}
let key: string
let key: string;
switch (conversationType) {
case ImConversationType.CHANNEL: {
key = StorageKeys.settings.channelMessageMaxId
break
key = StorageKeys.settings.channelMessageMaxId;
break;
}
case ImConversationType.GROUP: {
key = StorageKeys.settings.groupMessageMaxId
break
key = StorageKeys.settings.groupMessageMaxId;
break;
}
case ImConversationType.PRIVATE: {
key = StorageKeys.settings.privateMessageMaxId
break
key = StorageKeys.settings.privateMessageMaxId;
break;
}
default: {
throw new Error(`未知 IM 会话类型:${conversationType}`)
throw new Error(`未知 IM 会话类型:${conversationType}`);
}
}
const db = getDb()
const current = (await db.getSetting<number>(key, tx)) || 0
const db = getDb();
const current = (await db.getSetting<number>(key, tx)) || 0;
if (maxId > current) {
await db.setSetting(key, maxId, tx)
await db.setSetting(key, maxId, tx);
}
}
/** 停止当前 IM DB session */
export async function stopRequests(): Promise<void> {
currentSession++
currentSession++;
const [
{ useMessageStoreWithOut },
{ useConversationStoreWithOut },
@@ -517,7 +574,7 @@ export async function stopRequests(): Promise<void> {
{ useChannelStoreWithOut },
{ useGroupRequestStoreWithOut },
{ useFaceStoreWithOut },
{ useRtcStore }
{ useRtcStore },
] = await Promise.all([
import('../home/store/messageStore'),
import('../home/store/conversationStore'),
@@ -526,16 +583,16 @@ export async function stopRequests(): Promise<void> {
import('../home/store/channelStore'),
import('../home/store/groupRequestStore'),
import('../home/store/faceStore'),
import('../home/store/rtcStore')
])
useMessageStoreWithOut().clear()
useConversationStoreWithOut().clear()
useFriendStoreWithOut().clear()
useGroupStoreWithOut().clear()
useChannelStoreWithOut().clear()
useGroupRequestStoreWithOut().clear()
useFaceStoreWithOut().clear()
useRtcStore().reset()
useRtcStore().clearGroupCallCache()
closeDbConnection()
import('../home/store/rtcStore'),
]);
useMessageStoreWithOut().clear();
useConversationStoreWithOut().clear();
useFriendStoreWithOut().clear();
useGroupStoreWithOut().clear();
useChannelStoreWithOut().clear();
useGroupRequestStoreWithOut().clear();
useFaceStoreWithOut().clear();
useRtcStore().reset();
useRtcStore().clearGroupCallCache();
closeDbConnection();
}

View File

@@ -5,17 +5,134 @@
* Unicode emoji 选中后由调用方插入到输入框走 TEXT 通道,不走 FACE 内容类型
*/
export const IM_EMOJI_LIST: string[] = [
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
'😋', '😎', '😍', '😘', '😗', '😙', '😚', '🙂', '🤗', '🤩',
'🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮',
'🤐', '😯', '😪', '😫', '😴', '😌', '😛', '😜', '😝', '🤤',
'😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '☹️', '🙁', '😖',
'😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩', '🤯',
'😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '😡', '😠',
'🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '😇', '🤠', '🥳',
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉',
'👆', '👇', '✋', '🤚', '🖐', '🖖', '👋', '🤝', '🙏', '💪',
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '💕', '💖',
'🎉', '🎊', '🎁', '🎂', '🍰', '🌹', '🌷', '🌸', '🎵', '🎶',
'⭐', '🌟', '✨', '💫', '🔥', '💯', '✅', '❌', '⚠️', '❓'
]
'😀',
'😁',
'😂',
'🤣',
'😃',
'😄',
'😅',
'😆',
'😉',
'😊',
'😋',
'😎',
'😍',
'😘',
'😗',
'😙',
'😚',
'🙂',
'🤗',
'🤩',
'🤔',
'🤨',
'😐',
'😑',
'😶',
'🙄',
'😏',
'😣',
'😥',
'😮',
'🤐',
'😯',
'😪',
'😫',
'😴',
'😌',
'😛',
'😜',
'😝',
'🤤',
'😒',
'😓',
'😔',
'😕',
'🙃',
'🤑',
'😲',
'☹️',
'🙁',
'😖',
'😞',
'😟',
'😤',
'😢',
'😭',
'😦',
'😧',
'😨',
'😩',
'🤯',
'😬',
'😰',
'😱',
'🥵',
'🥶',
'😳',
'🤪',
'😵',
'😡',
'😠',
'🤬',
'😷',
'🤒',
'🤕',
'🤢',
'🤮',
'🤧',
'😇',
'🤠',
'🥳',
'👍',
'👎',
'👌',
'✌️',
'🤞',
'🤟',
'🤘',
'🤙',
'👈',
'👉',
'👆',
'👇',
'✋',
'🤚',
'🖐',
'🖖',
'👋',
'🤝',
'🙏',
'💪',
'❤️',
'🧡',
'💛',
'💚',
'💙',
'💜',
'🖤',
'💔',
'💕',
'💖',
'🎉',
'🎊',
'🎁',
'🎂',
'🍰',
'🌹',
'🌷',
'🌸',
'🎵',
'🎶',
'⭐',
'🌟',
'✨',
'💫',
'🔥',
'💯',
'✅',
'❌',
'⚠️',
'❓',
];

View File

@@ -1,52 +1,54 @@
import type { FriendLite } from '../home/types'
import type { FriendLite } from '../home/types';
import { loadImage } from './image'
import { getAvatarBgColor, getAvatarText } from './user'
import { loadImage } from './image';
import { getAvatarBgColor, getAvatarText } from './user';
/** 默认群名生成:所选好友前 4 个名字拼接,超过补「等 N 人」;为空兜底「群聊」 */
export function buildDefaultGroupName(members: FriendLite[]): string {
if (members.length === 0) {
return '群聊'
return '群聊';
}
const names = members.slice(0, 4).map((m) => m.displayName || m.nickname || '')
const head = names.filter(Boolean).join('、')
const names = members
.slice(0, 4)
.map((m) => m.displayName || m.nickname || '');
const head = names.filter(Boolean).join('、');
if (members.length > 4) {
// members 只含被选好友,+1 把创建者也计入实际成员数
return `${head}${members.length + 1}`
return `${head}${members.length + 1}`;
}
return head || '群聊'
return head || '群聊';
}
/** 群头像单格的输入:头像 URL + 名字 ,至少给一个,二者都缺时走灰底空格 */
export interface GroupAvatarMember {
/** 头像 URL缺失或加载失败时按 name 画色卡 */
avatar?: string
avatar?: string;
/** 显示名(昵称 / 备注);色卡文字 + 底色 hash 来源 */
name?: string
name?: string;
}
/** 群头像拼接的可选参数 */
export interface BuildGroupAvatarOptions {
/** 输出画布边长(像素);默认 64 */
targetSize?: number
targetSize?: number;
/** 单格之间的间隔(像素);默认 1 */
divider?: number
divider?: number;
/** 画布底色;默认透明,让上下留白透出宿主容器底色 */
background?: string
background?: string;
}
/** 单格在画布上的位置 + 尺寸 */
interface CellRect {
x: number
y: number
w: number
h: number
x: number;
y: number;
w: number;
h: number;
}
/** 单格的最终绘制内容 */
type Cell =
| { bg: string; kind: 'color'; text: string; }
| { img: HTMLImageElement; kind: 'image'; }
| { bg: string; kind: 'color'; text: string }
| { img: HTMLImageElement; kind: 'image' };
/**
* 把群成员头像拼成一张方形群头像 dataURL按 1 ~ 9 张走九宫格变体布局
@@ -57,108 +59,108 @@ type Cell =
*/
export async function buildGroupAvatar(
members: GroupAvatarMember[],
options: BuildGroupAvatarOptions = {}
options: BuildGroupAvatarOptions = {},
): Promise<string> {
const targetSize = options.targetSize ?? 64
const divider = options.divider ?? 1
const background = options.background
const targetSize = options.targetSize ?? 64;
const divider = options.divider ?? 1;
const background = options.background;
const top = members.slice(0, 9)
const top = members.slice(0, 9);
if (top.length === 0) {
return ''
return '';
}
const cells = await Promise.all(top.map((m) => resolveCell(m)))
const rects = computeCellRects(cells.length, targetSize, divider)
const cells = await Promise.all(top.map((m) => resolveCell(m)));
const rects = computeCellRects(cells.length, targetSize, divider);
const canvas = document.createElement('canvas')
canvas.width = targetSize
canvas.height = targetSize
const ctx = canvas.getContext('2d')
const canvas = document.createElement('canvas');
canvas.width = targetSize;
canvas.height = targetSize;
const ctx = canvas.getContext('2d');
if (!ctx) {
return ''
return '';
}
if (background) {
ctx.fillStyle = background
ctx.fillRect(0, 0, targetSize, targetSize)
ctx.fillStyle = background;
ctx.fillRect(0, 0, targetSize, targetSize);
}
cells.forEach((cell, idx) => {
const rect = rects[idx]
const rect = rects[idx];
if (!rect) {
return
return;
}
drawCell(ctx, rect, cell)
})
return canvas.toDataURL('image/png')
drawCell(ctx, rect, cell);
});
return canvas.toDataURL('image/png');
}
/** 群头像 dataURL 缓存上限:每条约 5 ~ 30KB200 条软封顶约 5MB 常驻 */
const MERGED_AVATAR_CACHE_MAX = 200
const mergedAvatarCache = new Map<string, string>()
const MERGED_AVATAR_CACHE_MAX = 200;
const mergedAvatarCache = new Map<string, string>();
/** 取群头像 dataURL 缓存;命中时按 LRU 提到末尾 */
export function getCachedGroupAvatar(key: string): string | undefined {
const cached = mergedAvatarCache.get(key)
const cached = mergedAvatarCache.get(key);
if (cached === undefined) {
return undefined
return undefined;
}
mergedAvatarCache.delete(key)
mergedAvatarCache.set(key, cached)
return cached
mergedAvatarCache.delete(key);
mergedAvatarCache.set(key, cached);
return cached;
}
/** 写群头像 dataURL 缓存超上限时丢弃最早一条Map 迭代顺序 = 插入顺序,配合 get 的提升即 LRU */
export function setCachedGroupAvatar(key: string, value: string): void {
if (mergedAvatarCache.has(key)) {
mergedAvatarCache.delete(key)
mergedAvatarCache.delete(key);
} else if (mergedAvatarCache.size >= MERGED_AVATAR_CACHE_MAX) {
const oldest = mergedAvatarCache.keys().next().value
const oldest = mergedAvatarCache.keys().next().value;
if (oldest !== undefined) {
mergedAvatarCache.delete(oldest)
mergedAvatarCache.delete(oldest);
}
}
mergedAvatarCache.set(key, value)
mergedAvatarCache.set(key, value);
}
/** 单成员 → Cell有 avatar 且加载成功走 image否则走 color */
async function resolveCell(member: GroupAvatarMember): Promise<Cell> {
if (member.avatar) {
const img = await loadImage(member.avatar)
const img = await loadImage(member.avatar);
if (img) {
return { kind: 'image', img }
return { kind: 'image', img };
}
}
return {
kind: 'color',
text: getAvatarText(member.name),
bg: getAvatarBgColor(member.name)
}
bg: getAvatarBgColor(member.name),
};
}
/** 把单个 Cell 画到 ctx 上指定格子里image 走 cover 裁剪保比例color 走 fillRect + 居中文字 */
function drawCell(
ctx: CanvasRenderingContext2D,
rect: CellRect,
cell: Cell
cell: Cell,
): void {
const { x, y, w, h } = rect
const { x, y, w, h } = rect;
if (cell.kind === 'image') {
drawImageCover(ctx, cell.img, x, y, w, h)
return
drawImageCover(ctx, cell.img, x, y, w, h);
return;
}
ctx.fillStyle = cell.bg
ctx.fillRect(x, y, w, h)
ctx.fillStyle = cell.bg;
ctx.fillRect(x, y, w, h);
if (!cell.text) {
return
return;
}
// 字号按短边算;单字 0.42、双字 0.34
const baseSize = Math.min(w, h)
const fontSize = Math.floor(baseSize * (cell.text.length > 1 ? 0.34 : 0.42))
ctx.fillStyle = '#FFFFFF'
ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(cell.text, x + w / 2, y + h / 2)
const baseSize = Math.min(w, h);
const fontSize = Math.floor(baseSize * (cell.text.length > 1 ? 0.34 : 0.42));
ctx.fillStyle = '#FFFFFF';
ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(cell.text, x + w / 2, y + h / 2);
}
/** cover 裁剪:从源图中心裁出与目标矩形同比例的子区域,画到目标矩形(保持比例不变形) */
@@ -168,22 +170,22 @@ function drawImageCover(
dx: number,
dy: number,
dw: number,
dh: number
dh: number,
): void {
const srcAspect = img.width / img.height
const dstAspect = dw / dh
let sx = 0
let sy = 0
let sw = img.width
let sh = img.height
const srcAspect = img.width / img.height;
const dstAspect = dw / dh;
let sx = 0;
let sy = 0;
let sw = img.width;
let sh = img.height;
if (srcAspect > dstAspect) {
sw = sh * dstAspect
sx = (img.width - sw) / 2
sw = sh * dstAspect;
sx = (img.width - sw) / 2;
} else if (srcAspect < dstAspect) {
sh = sw / dstAspect
sy = (img.height - sh) / 2
sh = sw / dstAspect;
sy = (img.height - sh) / 2;
}
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh)
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
}
/**
@@ -194,96 +196,154 @@ function drawImageCover(
* - 5~6 张3 列 2 行正方形单格 + 垂直居中
* - 7~9 张3 列 3 行正方形单格
*/
function computeCellRects(count: number, target: number, divider: number): CellRect[] {
function computeCellRects(
count: number,
target: number,
divider: number,
): CellRect[] {
if (count <= 0) {
return []
return [];
}
if (count === 1) {
return [{ x: 0, y: 0, w: target, h: target }]
return [{ x: 0, y: 0, w: target, h: target }];
}
if (count <= 4) {
return computeCellRectsSmall(count, target, divider)
return computeCellRectsSmall(count, target, divider);
}
if (count <= 6) {
return computeCellRectsMedium(count, target, divider)
return computeCellRectsMedium(count, target, divider);
}
return computeCellRectsLarge(count, target, divider)
return computeCellRectsLarge(count, target, divider);
}
/** 2~4 张2 列 2 行正方形 */
function computeCellRectsSmall(count: number, target: number, divider: number): CellRect[] {
const s = (target - 3 * divider) / 2
const step = s + divider
const rects: CellRect[] = []
function computeCellRectsSmall(
count: number,
target: number,
divider: number,
): CellRect[] {
const s = (target - 3 * divider) / 2;
const step = s + divider;
const rects: CellRect[] = [];
switch (count) {
case 2: {
// 居中 1 行(留上下空白)
const y = target / 4
rects.push({ x: divider, y, w: s, h: s }, { x: divider + step, y, w: s, h: s })
break
const y = target / 4;
rects.push(
{ x: divider, y, w: s, h: s },
{ x: divider + step, y, w: s, h: s },
);
break;
}
case 3: {
// 上 1 居中 + 下 2
rects.push({ x: target / 4, y: divider, w: s, h: s })
const y1 = divider + step
rects.push({ x: divider, y: y1, w: s, h: s }, { x: divider + step, y: y1, w: s, h: s })
break
rects.push({ x: target / 4, y: divider, w: s, h: s });
const y1 = divider + step;
rects.push(
{ x: divider, y: y1, w: s, h: s },
{ x: divider + step, y: y1, w: s, h: s },
);
break;
}
case 4: {
// 2×2 满铺
rects.push({ x: divider, y: divider, w: s, h: s }, { x: divider + step, y: divider, w: s, h: s })
const y1 = divider + step
rects.push({ x: divider, y: y1, w: s, h: s }, { x: divider + step, y: y1, w: s, h: s })
break
rects.push(
{ x: divider, y: divider, w: s, h: s },
{ x: divider + step, y: divider, w: s, h: s },
);
const y1 = divider + step;
rects.push(
{ x: divider, y: y1, w: s, h: s },
{ x: divider + step, y: y1, w: s, h: s },
);
break;
}
}
return rects
return rects;
}
/** 5~6 张3 列 2 行正方形单格 + 垂直居中;上下留白由透明背景透出 GroupAvatar 容器底色 */
function computeCellRectsMedium(count: number, target: number, divider: number): CellRect[] {
const s = (target - 4 * divider) / 3
const step = s + divider
const xs = [divider, divider + step, divider + 2 * step] as const
function computeCellRectsMedium(
count: number,
target: number,
divider: number,
): CellRect[] {
const s = (target - 4 * divider) / 3;
const step = s + divider;
const xs = [divider, divider + step, divider + 2 * step] as const;
// 2 行高 + 1 行间 div
const totalH = 2 * s + divider
const y0 = (target - totalH) / 2
const y1 = y0 + step
const rects: CellRect[] = []
const totalH = 2 * s + divider;
const y0 = (target - totalH) / 2;
const y1 = y0 + step;
const rects: CellRect[] = [];
if (count === 5) {
// 上 2 居中(左右对称留白) + 下 3 左右贴边
const upX0 = (target - 2 * s - divider) / 2
rects.push({ x: upX0, y: y0, w: s, h: s }, { x: upX0 + step, y: y0, w: s, h: s }, { x: xs[0], y: y1, w: s, h: s }, { x: xs[1], y: y1, w: s, h: s }, { x: xs[2], y: y1, w: s, h: s })
return rects
const upX0 = (target - 2 * s - divider) / 2;
rects.push(
{ x: upX0, y: y0, w: s, h: s },
{ x: upX0 + step, y: y0, w: s, h: s },
{ x: xs[0], y: y1, w: s, h: s },
{ x: xs[1], y: y1, w: s, h: s },
{ x: xs[2], y: y1, w: s, h: s },
);
return rects;
}
// count === 6上 3 + 下 3 满铺
rects.push({ x: xs[0], y: y0, w: s, h: s }, { x: xs[1], y: y0, w: s, h: s }, { x: xs[2], y: y0, w: s, h: s }, { x: xs[0], y: y1, w: s, h: s }, { x: xs[1], y: y1, w: s, h: s }, { x: xs[2], y: y1, w: s, h: s })
return rects
rects.push(
{ x: xs[0], y: y0, w: s, h: s },
{ x: xs[1], y: y0, w: s, h: s },
{ x: xs[2], y: y0, w: s, h: s },
{ x: xs[0], y: y1, w: s, h: s },
{ x: xs[1], y: y1, w: s, h: s },
{ x: xs[2], y: y1, w: s, h: s },
);
return rects;
}
/** 7~9 张3 列 3 行正方形单格 */
function computeCellRectsLarge(count: number, target: number, divider: number): CellRect[] {
const s = (target - 4 * divider) / 3
const step = s + divider
const xs = [divider, divider + step, divider + 2 * step] as const
const ys = [divider, divider + step, divider + 2 * step] as const
const rects: CellRect[] = []
function computeCellRectsLarge(
count: number,
target: number,
divider: number,
): CellRect[] {
const s = (target - 4 * divider) / 3;
const step = s + divider;
const xs = [divider, divider + step, divider + 2 * step] as const;
const ys = [divider, divider + step, divider + 2 * step] as const;
const rects: CellRect[] = [];
if (count === 7) {
// 上 1 居中 + 中 3 + 下 3
rects.push({ x: xs[1], y: ys[0], w: s, h: s }, { x: xs[0], y: ys[1], w: s, h: s }, { x: xs[1], y: ys[1], w: s, h: s }, { x: xs[2], y: ys[1], w: s, h: s }, { x: xs[0], y: ys[2], w: s, h: s }, { x: xs[1], y: ys[2], w: s, h: s }, { x: xs[2], y: ys[2], w: s, h: s })
return rects
rects.push(
{ x: xs[1], y: ys[0], w: s, h: s },
{ x: xs[0], y: ys[1], w: s, h: s },
{ x: xs[1], y: ys[1], w: s, h: s },
{ x: xs[2], y: ys[1], w: s, h: s },
{ x: xs[0], y: ys[2], w: s, h: s },
{ x: xs[1], y: ys[2], w: s, h: s },
{ x: xs[2], y: ys[2], w: s, h: s },
);
return rects;
}
if (count === 8) {
// 上 2 居中 + 中 3 + 下 3
const upX0 = (target - 2 * s - divider) / 2
rects.push({ x: upX0, y: ys[0], w: s, h: s }, { x: upX0 + step, y: ys[0], w: s, h: s }, { x: xs[0], y: ys[1], w: s, h: s }, { x: xs[1], y: ys[1], w: s, h: s }, { x: xs[2], y: ys[1], w: s, h: s }, { x: xs[0], y: ys[2], w: s, h: s }, { x: xs[1], y: ys[2], w: s, h: s }, { x: xs[2], y: ys[2], w: s, h: s })
return rects
const upX0 = (target - 2 * s - divider) / 2;
rects.push(
{ x: upX0, y: ys[0], w: s, h: s },
{ x: upX0 + step, y: ys[0], w: s, h: s },
{ x: xs[0], y: ys[1], w: s, h: s },
{ x: xs[1], y: ys[1], w: s, h: s },
{ x: xs[2], y: ys[1], w: s, h: s },
{ x: xs[0], y: ys[2], w: s, h: s },
{ x: xs[1], y: ys[2], w: s, h: s },
{ x: xs[2], y: ys[2], w: s, h: s },
);
return rects;
}
// count === 93×3 满铺
for (const y of ys) {
for (const x of xs) {
rects.push({ x, y, w: s, h: s })
rects.push({ x, y, w: s, h: s });
}
}
return rects
return rects;
}

View File

@@ -6,23 +6,24 @@
// ====================================================================
/** 默认占位尺寸probe 失败 / 解码异常时兜底,避免 width/height 为 0 让消息渲染塌掉 */
const DEFAULT_FALLBACK_SIZE = { width: 200, height: 200 } as const
const DEFAULT_FALLBACK_SIZE = { width: 200, height: 200 } as const;
/** 加载远程 URL 到 HTMLImageElement失败返回 nullcanvas 绘制要求 crossOrigin=anonymous默认开 */
export function loadImage(
src: string,
options: { crossOrigin?: 'anonymous' | 'use-credentials' | null } = {}
options: { crossOrigin?: 'anonymous' | 'use-credentials' | null } = {},
): Promise<HTMLImageElement | null> {
const crossOrigin = options.crossOrigin === undefined ? 'anonymous' : options.crossOrigin
const crossOrigin =
options.crossOrigin === undefined ? 'anonymous' : options.crossOrigin;
return new Promise((resolve) => {
const img = new Image()
const img = new Image();
if (crossOrigin) {
img.crossOrigin = crossOrigin
img.crossOrigin = crossOrigin;
}
img.addEventListener('load', () => resolve(img))
img.addEventListener('error', () => resolve(null))
img.src = src
})
img.addEventListener('load', () => resolve(img));
img.addEventListener('error', () => resolve(null));
img.src = src;
});
}
/**
@@ -32,26 +33,28 @@ export function loadImage(
* - 远程 URL直接走 <img>.src 触发浏览器加载(受 CORS 影响;只读尺寸不需要画 canvas跨域也能拿到
* - 解码失败 / 不是图片:返回 200×200 兜底
*/
export function probeImageSize(source: File | string): Promise<{ height: number; width: number; }> {
const isFile = source instanceof File
const src = isFile ? URL.createObjectURL(source) : source
export function probeImageSize(
source: File | string,
): Promise<{ height: number; width: number }> {
const isFile = source instanceof File;
const src = isFile ? URL.createObjectURL(source) : source;
return new Promise((resolve) => {
const img = new Image()
const img = new Image();
img.addEventListener('load', () => {
if (isFile) {
URL.revokeObjectURL(src)
URL.revokeObjectURL(src);
}
resolve({
width: img.naturalWidth || DEFAULT_FALLBACK_SIZE.width,
height: img.naturalHeight || DEFAULT_FALLBACK_SIZE.height
})
})
height: img.naturalHeight || DEFAULT_FALLBACK_SIZE.height,
});
});
img.addEventListener('error', () => {
if (isFile) {
URL.revokeObjectURL(src)
URL.revokeObjectURL(src);
}
resolve({ ...DEFAULT_FALLBACK_SIZE })
})
img.src = src
})
resolve({ ...DEFAULT_FALLBACK_SIZE });
});
img.src = src;
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { getDb } from './db'
import { getDb } from './db';
/**
* IM 状态事件补偿(增量拉取)通用编排
@@ -10,28 +10,28 @@ import { getDb } from './db'
/** 增量拉取游标:上次拉到的位置 */
export interface PullCursor {
lastUpdateTime?: number
lastId?: number
lastUpdateTime?: number;
lastId?: number;
}
/** 可作为游标的拉取记录:服务端按 update_time + id 返回,客户端取最后一条推进游标 */
interface PullRecord {
id: number
updateTime?: number
id: number;
updateTime?: number;
}
/** 单次拉取条数(与后端 limit 上限对齐) */
const PULL_PAGE_SIZE = 100
const PULL_PAGE_SIZE = 100;
/** 单轮最多翻页数,兜底防御异常游标导致的死循环 */
const PULL_MAX_PAGES = 100
const PULL_MAX_PAGES = 100;
/** 状态事件拉取回扫窗口,覆盖同秒内旧行更新和客户端 / 服务端时钟精度差 */
const PULL_OVERLAP_MS = 5000
const PULL_OVERLAP_MS = 5000;
/** 消息类 minId 拉取的单轮翻页上限,兜底防御异常游标死翻;消息量可能远大于状态事件,放宽到 1000 */
const MIN_ID_PULL_MAX_PAGES = 1000
const MIN_ID_PULL_MAX_PAGES = 1000;
/** 读取某模块的拉取游标;无则返回空游标(首次拉全量) */
export async function getPullCursor(key: string): Promise<PullCursor> {
return (await getDb().getSetting<PullCursor>(key)) ?? {}
return (await getDb().getSetting<PullCursor>(key)) ?? {};
}
/**
@@ -44,62 +44,72 @@ export async function getPullCursor(key: string): Promise<PullCursor> {
*/
export async function runIncrementalPull<T extends PullRecord>(
cursorKey: string,
fetchPage: (params: { lastId?: number; lastUpdateTime?: number; limit: number }) => Promise<T[]>,
fetchPage: (params: {
lastId?: number;
lastUpdateTime?: number;
limit: number;
}) => Promise<T[]>,
apply: (records: T[]) => boolean | Promise<boolean>,
isActive?: () => boolean
isActive?: () => boolean,
): Promise<void> {
const storedCursor = await getPullCursor(cursorKey)
const highWater = { ...storedCursor }
const storedCursor = await getPullCursor(cursorKey);
const highWater = { ...storedCursor };
let cursor =
storedCursor.lastUpdateTime == null
storedCursor.lastUpdateTime === null
? {}
: { lastUpdateTime: Math.max(0, storedCursor.lastUpdateTime - PULL_OVERLAP_MS), lastId: 0 }
: {
lastUpdateTime: Math.max(
0,
storedCursor.lastUpdateTime - PULL_OVERLAP_MS,
),
lastId: 0,
};
for (let page = 0; page < PULL_MAX_PAGES; page++) {
if (isActive && !isActive()) {
return
return;
}
const list = await fetchPage({
lastUpdateTime: cursor.lastUpdateTime,
lastId: cursor.lastId,
limit: PULL_PAGE_SIZE
})
limit: PULL_PAGE_SIZE,
});
if (isActive && !isActive()) {
return
return;
}
if (list.length > 0) {
// apply 未完全落地(返回 false时直接终止游标只能跟着已落地的数据走否则会跳过本页记录
if ((await apply(list)) === false) {
return
return;
}
if (isActive && !isActive()) {
return
return;
}
// 推进游标到本页最后一条并持久化:下次从这里接着拉
const last = list[list.length - 1]
const last = list[list.length - 1];
if (!last) {
return
return;
}
if (last.updateTime == null) {
return
if (last.updateTime === null) {
return;
}
cursor = { lastUpdateTime: last.updateTime, lastId: last.id }
cursor = { lastUpdateTime: last.updateTime, lastId: last.id };
if (
highWater.lastUpdateTime == null ||
highWater.lastUpdateTime === null ||
cursor.lastUpdateTime > highWater.lastUpdateTime ||
(cursor.lastUpdateTime === highWater.lastUpdateTime &&
cursor.lastId > (highWater.lastId ?? 0))
) {
highWater.lastUpdateTime = cursor.lastUpdateTime
highWater.lastId = cursor.lastId
await getDb().setSetting(cursorKey, highWater)
highWater.lastUpdateTime = cursor.lastUpdateTime;
highWater.lastId = cursor.lastId;
await getDb().setSetting(cursorKey, highWater);
}
}
// 不满一页 = 没有更多变更
if (list.length < PULL_PAGE_SIZE) {
return
return;
}
}
console.warn(`[IM pull] ${cursorKey} 达到单轮翻页上限,提前结束本轮补偿`)
console.warn(`[IM pull] ${cursorKey} 达到单轮翻页上限,提前结束本轮补偿`);
}
/**
@@ -116,40 +126,45 @@ export async function runIncrementalPull<T extends PullRecord>(
* @param maxPages 单轮翻页上限
*/
export async function runMinIdPull<T extends { id?: number }>(options: {
applyPage: (records: T[], nextMinId?: number) => Promise<boolean> | Promise<void>
fetchPage: (params: { minId: number; size: number }) => Promise<T[]>
initialMinId: number
isActive?: () => boolean
maxPages?: number
pageSize: number
applyPage: (
records: T[],
nextMinId?: number,
) => Promise<boolean> | Promise<void>;
fetchPage: (params: { minId: number; size: number }) => Promise<T[]>;
initialMinId: number;
isActive?: () => boolean;
maxPages?: number;
pageSize: number;
}): Promise<void> {
const { initialMinId, pageSize, fetchPage, applyPage, isActive } = options
const maxPages = options.maxPages ?? MIN_ID_PULL_MAX_PAGES
let minId = initialMinId || 0
const { initialMinId, pageSize, fetchPage, applyPage, isActive } = options;
const maxPages = options.maxPages ?? MIN_ID_PULL_MAX_PAGES;
let minId = initialMinId || 0;
for (let page = 0; page < maxPages; page++) {
if (isActive && !isActive()) {
return
return;
}
const list = await fetchPage({ minId, size: pageSize })
const list = await fetchPage({ minId, size: pageSize });
// 拉取期间取消 / 切账号:丢弃本批不入库,也不再翻页
if (isActive && !isActive()) {
return
return;
}
if (!list || list.length === 0) {
return
return;
}
// 本批最大消息 id 作为下次游标;无有效 id 则本批 apply 后停(无法继续翻页)
const validIds = list.map((record) => record.id).filter((id): id is number => id != null)
const nextMinId = validIds.length > 0 ? Math.max(...validIds) : undefined
const validIds = list
.map((record) => record.id)
.filter((id): id is number => id !== null);
const nextMinId = validIds.length > 0 ? Math.max(...validIds) : undefined;
// applyPage 返回 false本页未落地如入库失败不推进游标并终止避免漏消息
if ((await applyPage(list, nextMinId)) === false) {
return
return;
}
// 无有效 id或游标没前进后端契约是 id > minId理论不会出现防御死翻
if (nextMinId == null || nextMinId <= minId) {
return
if (nextMinId === null || nextMinId <= minId) {
return;
}
minId = nextMinId
minId = nextMinId;
}
console.warn('[IM pull] runMinIdPull 达到单轮翻页上限,提前结束本轮')
console.warn('[IM pull] runMinIdPull 达到单轮翻页上限,提前结束本轮');
}

View File

@@ -1,12 +1,28 @@
import dayjs from 'dayjs'
import dayjs from 'dayjs';
// ====================================================================
// IM 时间格式化
// ====================================================================
// 4 类场景,文案规则各有差异;统一走 dayjs避免 raw Date 散点写法
const WEEKDAY_NAMES_FULL = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
const WEEKDAY_NAMES_SHORT = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const WEEKDAY_NAMES_FULL = [
'星期日',
'星期一',
'星期二',
'星期三',
'星期四',
'星期五',
'星期六',
];
const WEEKDAY_NAMES_SHORT = [
'周日',
'周一',
'周二',
'周三',
'周四',
'周五',
'周六',
];
/**
* 消息列表的时间分隔条
@@ -18,22 +34,22 @@ const WEEKDAY_NAMES_SHORT = ['周日', '周一', '周二', '周三', '周四', '
*/
export function formatTimeTip(timestamp: number): string {
if (!timestamp) {
return ''
return '';
}
const target = dayjs(timestamp)
const now = dayjs()
const time = target.format('HH:mm')
const target = dayjs(timestamp);
const now = dayjs();
const time = target.format('HH:mm');
if (target.isSame(now, 'day')) {
return time
return time;
}
if (target.isSame(now.subtract(1, 'day'), 'day')) {
return `昨天 ${time}`
return `昨天 ${time}`;
}
const diffDays = now.startOf('day').diff(target.startOf('day'), 'day')
const diffDays = now.startOf('day').diff(target.startOf('day'), 'day');
if (diffDays >= 2 && diffDays <= 6) {
return `${WEEKDAY_NAMES_SHORT[target.day()]} ${time}`
return `${WEEKDAY_NAMES_SHORT[target.day()]} ${time}`;
}
return target.format('MM-DD HH:mm')
return target.format('MM-DD HH:mm');
}
/**
@@ -47,22 +63,24 @@ export function formatTimeTip(timestamp: number): string {
*/
export function formatConversationTime(timestamp: number): string {
if (!timestamp) {
return ''
return '';
}
const target = dayjs(timestamp)
const now = dayjs()
const target = dayjs(timestamp);
const now = dayjs();
if (target.isSame(now, 'day')) {
return target.format('HH:mm')
return target.format('HH:mm');
}
if (target.isSame(now.subtract(1, 'day'), 'day')) {
return `昨天 ${target.format('HH:mm')}`
return `昨天 ${target.format('HH:mm')}`;
}
// 用 startOf('day') 兜底跨时间点的差值,避免接近凌晨时取到 1.x → diff 算成 1 漏掉昨天分支
const diffDays = now.startOf('day').diff(target.startOf('day'), 'day')
const diffDays = now.startOf('day').diff(target.startOf('day'), 'day');
if (diffDays >= 2 && diffDays <= 6) {
return WEEKDAY_NAMES_FULL[target.day()] || ''
return WEEKDAY_NAMES_FULL[target.day()] || '';
}
return target.year() === now.year() ? target.format('MM/DD') : target.format('YYYY/MM/DD')
return target.year() === now.year()
? target.format('MM/DD')
: target.format('YYYY/MM/DD');
}
/**
@@ -73,56 +91,58 @@ export function formatConversationTime(timestamp: number): string {
*/
export function formatHistoryTime(timestamp: number): string {
if (!timestamp) {
return ''
return '';
}
const target = dayjs(timestamp)
const target = dayjs(timestamp);
return target.year() === dayjs().year()
? target.format('M月D日 HH:mm')
: target.format('YYYY年M月D日 HH:mm')
: target.format('YYYY年M月D日 HH:mm');
}
/** 合并消息详情列表里每行的时间MM-DD HH:mm */
export function formatMergeItemTime(timestamp: number): string {
if (!timestamp) {
return ''
return '';
}
return dayjs(timestamp).format('MM-DD HH:mm')
return dayjs(timestamp).format('MM-DD HH:mm');
}
/** RTC 通话时长(秒)→ "00:06" / "1:23:45" */
export function formatCallDuration(seconds: number | undefined): string {
const total = Math.max(0, Math.floor(seconds || 0))
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
const pad = (n: number) => String(n).padStart(2, '0')
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
const total = Math.max(0, Math.floor(seconds || 0));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
}
/** 秒数格式化为 mm:ss */
export function formatSeconds(seconds?: number): string {
const value = Math.max(0, Number(seconds || 0))
const minute = Math.floor(value / 60)
const second = Math.floor(value % 60)
return `${minute.toString().padStart(2, '0')}:${second.toString().padStart(2, '0')}`
const value = Math.max(0, Number(seconds || 0));
const minute = Math.floor(value / 60);
const second = Math.floor(value % 60);
return `${minute.toString().padStart(2, '0')}:${second.toString().padStart(2, '0')}`;
}
/** 格式化日期 */
export function formatDate(
date?: Date | number | string,
format = 'YYYY-MM-DD HH:mm:ss'
format = 'YYYY-MM-DD HH:mm:ss',
) {
return dayjs(date).format(format)
return dayjs(date).format(format);
}
/** 接通到结束的通话时长;任一时间缺失返回 '-' */
export function resolveCallDuration(
acceptTime: Date | string | undefined,
endTime: Date | string | undefined
endTime: Date | string | undefined,
): string {
if (!acceptTime || !endTime) {
return '-'
return '-';
}
const seconds = Math.floor((new Date(endTime).getTime() - new Date(acceptTime).getTime()) / 1000)
return seconds > 0 ? formatCallDuration(seconds) : '-'
const seconds = Math.floor(
(new Date(endTime).getTime() - new Date(acceptTime).getTime()) / 1000,
);
return seconds > 0 ? formatCallDuration(seconds) : '-';
}

View File

@@ -1,3 +1,4 @@
// oxlint-disable unicorn/number-literal-case
// ====================================================================
// IM 用户展示 utility
// ====================================================================
@@ -9,34 +10,34 @@
// 命名约定:显示名相关函数一律使用 displayName与 friend.displayName / member.displayUserName 字段对齐
// ====================================================================
import type { Conversation, Friend, Group, User } from '../home/types'
import type { Conversation, Friend, Group, User } from '../home/types';
import type { MentionCandidate } from './message';
import { CommonStatusEnum } from '@vben/constants'
import { useUserStore } from '@vben/stores'
import { CommonStatusEnum } from '@vben/constants';
import { useUserStore } from '@vben/stores';
import { getCurrentUserId } from '#/views/im/utils/auth'
import { getCurrentUserId } from '#/views/im/utils/auth';
import { useConversationStore } from '../home/store/conversationStore'
import { useFriendStore } from '../home/store/friendStore'
import { useGroupStore } from '../home/store/groupStore'
import { useImUiStore } from '../home/store/uiStore'
import { useConversationStore } from '../home/store/conversationStore';
import { useFriendStore } from '../home/store/friendStore';
import { useGroupStore } from '../home/store/groupStore';
import { useImUiStore } from '../home/store/uiStore';
import {
IM_AT_ALL_NICKNAME,
IM_AT_ALL_USER_ID,
ImConversationType,
ImFriendAddSource
} from './constants'
import { type MentionCandidate } from './message'
ImFriendAddSource,
} from './constants';
const SystemUserSexEnum = {
FEMALE: 2,
MALE: 1,
UNKNOWN: 0
} as const
UNKNOWN: 0,
} as const;
// 候选缺失场景的稳定空数组;让 textMentions computed 在非 TEXT / 非群聊 / 无 @ 时返回同一引用,
// MessageBubble 的 textSegments 才不会跟着无谓重算
const EMPTY_MENTIONS: MentionCandidate[] = []
const EMPTY_MENTIONS: MentionCandidate[] = [];
/**
* 是否历史退群群joinStatus 为 DISABLE已退群 / 被移除)
@@ -44,7 +45,7 @@ const EMPTY_MENTIONS: MentionCandidate[] = []
* /im/group/list 会带回历史退群群(供展示历史消息的群名 / 头像);这类群应禁止发送、隐藏群操作入口、不可被转发 / 推荐选中
*/
export function isGroupQuit(group?: Group | null): boolean {
return group?.joinStatus === CommonStatusEnum.DISABLE
return group?.joinStatus === CommonStatusEnum.DISABLE;
}
/**
@@ -52,8 +53,10 @@ export function isGroupQuit(group?: Group | null): boolean {
*
* displayName 是「我对这个人的私人称呼」属于我的数据删好友DISABLE也保留删了再加回来时备注自然延续历史消息里仍以备注辨识
*/
export function getFriendDisplayName(friend: Pick<Friend, 'displayName' | 'nickname'>): string {
return friend.displayName || friend.nickname
export function getFriendDisplayName(
friend: Pick<Friend, 'displayName' | 'nickname'>,
): string {
return friend.displayName || friend.nickname;
}
/**
@@ -63,14 +66,16 @@ export function getFriendDisplayName(friend: Pick<Friend, 'displayName' | 'nickn
*/
export function getMemberDisplayName(
member: { displayUserName?: string; nickname: string },
friend?: null | Pick<Friend, 'displayName'>
friend?: null | Pick<Friend, 'displayName'>,
): string {
return friend?.displayName || member.displayUserName || member.nickname
return friend?.displayName || member.displayUserName || member.nickname;
}
/** 群显示名当前用户对该群的备注groupRemark > 群名name */
export function getGroupDisplayName(group: Pick<Group, 'groupRemark' | 'name'>): string {
return group.groupRemark || group.name
export function getGroupDisplayName(
group: Pick<Group, 'groupRemark' | 'name'>,
): string {
return group.groupRemark || group.name;
}
/**
@@ -85,30 +90,30 @@ export function getGroupDisplayName(group: Pick<Group, 'groupRemark' | 'name'>):
export function tryGetSenderDisplayName(
senderId: number,
conversationType: number,
conversationTargetId: number
conversationTargetId: number,
): string | undefined {
if (conversationType === ImConversationType.GROUP) {
const group = useGroupStore().getGroup(conversationTargetId)
const member = group?.members?.find((m) => m.userId === senderId)
const group = useGroupStore().getGroup(conversationTargetId);
const member = group?.members?.find((m) => m.userId === senderId);
if (member) {
const friend = useFriendStore().getFriend(senderId)
return getMemberDisplayName(member, friend)
const friend = useFriendStore().getFriend(senderId);
return getMemberDisplayName(member, friend);
}
if (senderId === getCurrentUserId()) {
return useUserStore().userInfo?.nickname || undefined
return useUserStore().userInfo?.nickname || undefined;
}
return undefined
return undefined;
}
// PRIVATE / 未知会话类型self 走 userStore对方走 friend
if (senderId === getCurrentUserId()) {
return useUserStore().userInfo?.nickname || undefined
return useUserStore().userInfo?.nickname || undefined;
}
if (conversationType === ImConversationType.PRIVATE) {
const friend = useFriendStore().getFriend(senderId)
return friend ? getFriendDisplayName(friend) : undefined
const friend = useFriendStore().getFriend(senderId);
return friend ? getFriendDisplayName(friend) : undefined;
}
return undefined
return undefined;
}
/**
@@ -123,21 +128,25 @@ export function getSenderDisplayName(
senderId: number,
conversationType: number,
conversationTargetId: number,
fallbackName?: string
fallbackName?: string,
): string {
const real = tryGetSenderDisplayName(senderId, conversationType, conversationTargetId)
const real = tryGetSenderDisplayName(
senderId,
conversationType,
conversationTargetId,
);
if (real) {
return real
return real;
}
if (fallbackName) {
return fallbackName
return fallbackName;
}
// self 在 GROUP members 没加载时,至少用真实昵称兜底渲染(比 String(senderId) 友好);兜底拉成员由 conversationStore 触发,回来后 try 版本能命中真名自然刷新
const userStore = useUserStore()
const userStore = useUserStore();
if (senderId === getCurrentUserId()) {
return userStore.userInfo?.nickname || String(senderId)
return userStore.userInfo?.nickname || String(senderId);
}
return String(senderId)
return String(senderId);
}
/**
@@ -152,36 +161,36 @@ export function getSenderDisplayName(
export function getSenderRealNickname(
senderId: number,
conversationType: number,
conversationTargetId: number
conversationTargetId: number,
): string {
const userStore = useUserStore()
const selfUserId = getCurrentUserId()
const userStore = useUserStore();
const selfUserId = getCurrentUserId();
// 群聊先走 member.nicknameself 也是 member异常时再走 self / senderId 兜底
if (conversationType === ImConversationType.GROUP) {
const group = useGroupStore().getGroup(conversationTargetId)
const member = group?.members?.find((m) => m.userId === senderId)
const group = useGroupStore().getGroup(conversationTargetId);
const member = group?.members?.find((m) => m.userId === senderId);
if (member?.nickname) {
return member.nickname
return member.nickname;
}
if (senderId === selfUserId) {
return userStore.userInfo?.nickname || String(senderId)
return userStore.userInfo?.nickname || String(senderId);
}
return String(senderId)
return String(senderId);
}
if (conversationType === ImConversationType.PRIVATE) {
if (senderId === selfUserId) {
return userStore.userInfo?.nickname || String(senderId)
return userStore.userInfo?.nickname || String(senderId);
}
const friend = useFriendStore().getFriend(senderId)
return friend?.nickname || String(senderId)
const friend = useFriendStore().getFriend(senderId);
return friend?.nickname || String(senderId);
}
if (senderId === selfUserId) {
return userStore.userInfo?.nickname || String(senderId)
return userStore.userInfo?.nickname || String(senderId);
}
return String(senderId)
return String(senderId);
}
/**
@@ -195,20 +204,20 @@ export function getSenderRealNickname(
export function getSenderAvatar(
senderId: number,
conversationType: number,
conversationTargetId: number
conversationTargetId: number,
): string {
const userStore = useUserStore()
const userStore = useUserStore();
if (senderId === getCurrentUserId()) {
return userStore.userInfo?.avatar || ''
return userStore.userInfo?.avatar || '';
}
if (conversationType === ImConversationType.GROUP) {
const group = useGroupStore().getGroup(conversationTargetId)
const member = group?.members?.find((m) => m.userId === senderId)
const group = useGroupStore().getGroup(conversationTargetId);
const member = group?.members?.find((m) => m.userId === senderId);
if (member?.avatar) {
return member.avatar
return member.avatar;
}
}
return useFriendStore().getFriend(senderId)?.avatar || ''
return useFriendStore().getFriend(senderId)?.avatar || '';
}
/**
@@ -219,63 +228,68 @@ export function getSenderAvatar(
*/
export function getMentionCandidates(
atUserIds: number[] | undefined,
conversation: null | Pick<Conversation, 'targetId' | 'type'> | undefined
conversation: null | Pick<Conversation, 'targetId' | 'type'> | undefined,
): MentionCandidate[] {
if (!atUserIds || atUserIds.length === 0) {
return EMPTY_MENTIONS
return EMPTY_MENTIONS;
}
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return EMPTY_MENTIONS
return EMPTY_MENTIONS;
}
// 群成员预建 Map避免每个 atUserId 走一次 array find@全体成员场景下成员数 × atUserIds 是 N²
const members = useGroupStore().getGroup(conversation.targetId)?.members || []
const memberById = new Map(members.map((m) => [m.userId, m]))
const friendStore = useFriendStore()
const candidates: MentionCandidate[] = []
const seen = new Set<string>()
const members =
useGroupStore().getGroup(conversation.targetId)?.members || [];
const memberById = new Map(members.map((m) => [m.userId, m]));
const friendStore = useFriendStore();
const candidates: MentionCandidate[] = [];
const seen = new Set<string>();
for (const userId of atUserIds) {
// @全体成员是虚拟伪成员userId = -1 在 group.members 里查不到,注入字面量「所有人」候选
if (userId === IM_AT_ALL_USER_ID) {
const key = `${IM_AT_ALL_USER_ID}#${IM_AT_ALL_NICKNAME}`
const key = `${IM_AT_ALL_USER_ID}#${IM_AT_ALL_NICKNAME}`;
if (!seen.has(key)) {
seen.add(key)
seen.add(key);
candidates.push({
userId: IM_AT_ALL_USER_ID,
name: IM_AT_ALL_NICKNAME,
displayName: IM_AT_ALL_NICKNAME
})
displayName: IM_AT_ALL_NICKNAME,
});
}
continue
continue;
}
const member = memberById.get(userId)
const friend = friendStore.getFriend(userId)
const nickname = (member?.nickname || friend?.nickname || '').trim()
const member = memberById.get(userId);
const friend = friendStore.getFriend(userId);
const nickname = (member?.nickname || friend?.nickname || '').trim();
if (!nickname) {
continue
continue;
}
for (const literal of [nickname, friend?.displayName, member?.displayUserName]) {
const trimmed = (literal || '').trim()
for (const literal of [
nickname,
friend?.displayName,
member?.displayUserName,
]) {
const trimmed = (literal || '').trim();
if (!trimmed) {
continue
continue;
}
const key = `${userId}#${trimmed}`
const key = `${userId}#${trimmed}`;
if (seen.has(key)) {
continue
continue;
}
seen.add(key)
candidates.push({ userId, name: trimmed, displayName: nickname })
seen.add(key);
candidates.push({ userId, name: trimmed, displayName: nickname });
}
}
const nameCount = new Map<string, number>()
const nameCount = new Map<string, number>();
for (const candidate of candidates) {
nameCount.set(candidate.name, (nameCount.get(candidate.name) ?? 0) + 1)
nameCount.set(candidate.name, (nameCount.get(candidate.name) ?? 0) + 1);
}
for (const candidate of candidates) {
if ((nameCount.get(candidate.name) ?? 0) > 1) {
candidate.ambiguous = true
candidate.ambiguous = true;
}
}
return candidates
return candidates;
}
/**
@@ -288,80 +302,95 @@ export function getMentionCandidates(
export function openMentionUserInfoCardAtEvent(
userId: number,
event: MouseEvent,
fallbackName?: string
fallbackName?: string,
): void {
if (userId === IM_AT_ALL_USER_ID) {
return
return;
}
const conversation = useConversationStore().activeConversation
const isGroup = conversation?.type === ImConversationType.GROUP
const group = isGroup && conversation ? useGroupStore().getGroup(conversation.targetId) : undefined
const member = group?.members?.find((m) => m.userId === userId)
const friend = useFriendStore().getFriend(userId)
const conversation = useConversationStore().activeConversation;
const isGroup = conversation?.type === ImConversationType.GROUP;
const group =
isGroup && conversation
? useGroupStore().getGroup(conversation.targetId)
: undefined;
const member = group?.members?.find((m) => m.userId === userId);
const friend = useFriendStore().getFriend(userId);
const user: User = {
id: userId,
nickname: friend?.nickname || member?.nickname || fallbackName || String(userId),
avatar: getSenderAvatar(userId, conversation?.type ?? 0, conversation?.targetId ?? 0)
}
nickname:
friend?.nickname || member?.nickname || fallbackName || String(userId),
avatar: getSenderAvatar(
userId,
conversation?.type ?? 0,
conversation?.targetId ?? 0,
),
};
useImUiStore().openUserInfoCardAtEvent(
user,
event,
isGroup ? ImFriendAddSource.GROUP : ImFriendAddSource.SEARCH,
isGroup ? group?.name || '' : ''
)
isGroup ? group?.name || '' : '',
);
}
/** 性别图标UNKNOWN / null / undefined 一律不展示,对齐微信留白 */
export function getGenderIcon(sex?: number): string {
if (sex === SystemUserSexEnum.MALE) {
return 'mdi:human-male'
return 'mdi:human-male';
}
if (sex === SystemUserSexEnum.FEMALE) {
return 'mdi:human-female'
return 'mdi:human-female';
}
return ''
return '';
}
/** 性别图标主题色:男蓝、女粉 */
export function getGenderColor(sex?: number): string {
if (sex === SystemUserSexEnum.MALE) {
return '#5b97f5'
return '#5b97f5';
}
if (sex === SystemUserSexEnum.FEMALE) {
return '#f56c92'
return '#f56c92';
}
return ''
return '';
}
/** 头像色卡底色调色板(参考微信) */
const AVATAR_BG_COLORS = ['#07C160', '#1A95FF', '#FA9D3B', '#9163E0', '#F76760', '#1ABC9C']
const AVATAR_BG_COLORS = [
'#07C160',
'#1A95FF',
'#FA9D3B',
'#9163E0',
'#F76760',
'#1ABC9C',
];
/** 头像色卡文字:中文取首字、英文取前 2 字母大写、其他取首字大写、空名返回空串 */
export function getAvatarText(name?: string): string {
const trimmed = name?.trim()
const trimmed = name?.trim();
if (!trimmed) {
return ''
return '';
}
const first = trimmed.charAt(0)
const code = first.codePointAt(0) ?? 0
if (code >= 0x4E_00 && code <= 0x9F_A5) {
return first
const first = trimmed.charAt(0);
const code = first.codePointAt(0) ?? 0;
if (code >= 0x4e_00 && code <= 0x9f_a5) {
return first;
}
const letters = trimmed.match(/[A-Za-z]/g)
const letters = trimmed.match(/[A-Za-z]/g);
if (!letters || letters.length === 0) {
return first.toUpperCase()
return first.toUpperCase();
}
return letters.slice(0, 2).join('').toUpperCase()
return letters.slice(0, 2).join('').toUpperCase();
}
/** 头像色卡底色:按 name charCode 之和取调色板色,空名走默认灰 */
export function getAvatarBgColor(name?: string): string {
if (!name) {
return '#909399'
return '#909399';
}
let hash = 0
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash += name.codePointAt(i) ?? 0
hash += name.codePointAt(i) ?? 0;
}
return AVATAR_BG_COLORS[hash % AVATAR_BG_COLORS.length] || '#909399'
return AVATAR_BG_COLORS[hash % AVATAR_BG_COLORS.length] || '#909399';
}