fix: lint
This commit is contained in:
@@ -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. 首拉失败:手动复位 loading(pullOnce 没跑到,它的 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<{
|
||||
/** full:emoji + 个人表情 + 系统包(聊天主输入用);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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
|
||||
@@ -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%));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 + 序列化 content;merge 模式下一次构造,预览 / 发送共用 */
|
||||
@@ -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:建群并转发 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
: '';
|
||||
}
|
||||
|
||||
/** 移除置顶:调后端 API,loading 期间禁止重复点;后端广播 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)]"
|
||||
/>
|
||||
|
||||
@@ -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>>() // 申请列表弹窗 ref:handleOpen 调 open({ groupId }) 触发
|
||||
const requestListDialogRef = ref<InstanceType<typeof GroupRequestListDialog>>(); // 申请列表弹窗 ref:handleOpen 调 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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<{
|
||||
/** 消息 content(JSON 字符串) */
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 segments:sender 名段挂可点击 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;
|
||||
}
|
||||
// 合并到 messageStore:prependMessageList 内部去重 + 升序合并 + 落 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 conversation:dialog 的 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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 注入后调用
|
||||
|
||||
// ==================== 多选模式 ====================
|
||||
// 模块级单例 state(composable);本组件仅做切会话退出 + 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_THRESHOLD(80px)做容差:用户稍微往上翻几行就视作"不在底部",
|
||||
// 否则一直 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
|
||||
|
||||
@@ -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 直接命中「全部已读」分支;
|
||||
// 否则只更新 readCount,receiptStatus 维持不变(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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>>() // 发起群聊弹窗 ref:handleOpenCreateGroup 调 open() 打开
|
||||
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>(); // 发起群聊弹窗 ref:handleOpenCreateGroup 调 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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
/**
|
||||
* 系统表情包拉取 promise;ensureFacePackList 内 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
@@ -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', {
|
||||
* 各群下未处理申请数的 Map;O(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
@@ -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(已加入已有通话场景)→ RUNNING;CREATED → 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 移除;同时标记 leftUserIds(pending 占位渲染排除) */
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
@@ -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-100);status=SENDING 期间持续更新;ack 后置 undefined
|
||||
targetId: number; // 会话目标编号(私聊=对端 userId / 群聊=groupId),与 Conversation.targetId 一致
|
||||
selfSend: boolean; // 是否自己发送(前端按 senderId 计算)
|
||||
uploadProgress?: number; // 媒体消息上传进度(0-100);status=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 // 当前登录用户在该群的成员状态(参见 CommonStatusEnum:0 在群 / 1 已退群);历史退群群仍返回,供展示历史消息的群名 / 头像
|
||||
id: number; // 群编号
|
||||
name: string; // 群名称
|
||||
avatar?: string; // 群头像
|
||||
notice?: string; // 群公告
|
||||
ownerUserId?: number; // 群主用户编号
|
||||
pinnedMessages?: Message[]; // 群置顶消息列表
|
||||
mutedAll?: boolean; // 是否全群禁言
|
||||
banned?: boolean; // 是否被管理员封禁
|
||||
joinApproval?: boolean; // 进群是否需群主 / 管理员审批
|
||||
joinStatus?: number; // 当前登录用户在该群的成员状态(参见 CommonStatusEnum:0 在群 / 1 已退群);历史退群群仍返回,供展示历史消息的群名 / 头像
|
||||
|
||||
// ========== 前端扩展字段(user-per-group 维度) ==========
|
||||
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
|
||||
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
|
||||
members?: GroupMember[] // 群成员缓存(按需懒加载)
|
||||
infoLoaded?: boolean // 群详情是否已加载,本轮会话内存标记,不持久化
|
||||
activeCallLoaded?: boolean // 群活跃通话是否已探测,本轮会话内存标记,不持久化
|
||||
activeCallExpired?: boolean // 群活跃通话探测是否已过期
|
||||
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 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 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 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 // 好友状态,对齐 CommonStatusEnum(DISABLE = 已删除,软删保留记录)
|
||||
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; // 好友状态,对齐 CommonStatusEnum(DISABLE = 已删除,软删保留记录)
|
||||
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; // 进群是否需群主 / 管理员审批
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: '请选择处理结果',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
/** 弹窗选中回调 */
|
||||
|
||||
@@ -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: '请选择处理结果',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: '解散',
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '请选择会话类型',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 || '',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
/** 语音单文件上限(MB):60s @ 64kbps 约 0.5MB,5MB 已远超录制可能产出的大小 */
|
||||
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_MINUTES(5 分钟)严格,对齐微信 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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -5,17 +5,134 @@
|
||||
* Unicode emoji 选中后由调用方插入到输入框走 TEXT 通道,不走 FACE 内容类型
|
||||
*/
|
||||
export const IM_EMOJI_LIST: string[] = [
|
||||
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
|
||||
'😋', '😎', '😍', '😘', '😗', '😙', '😚', '🙂', '🤗', '🤩',
|
||||
'🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮',
|
||||
'🤐', '😯', '😪', '😫', '😴', '😌', '😛', '😜', '😝', '🤤',
|
||||
'😒', '😓', '😔', '😕', '🙃', '🤑', '😲', '☹️', '🙁', '😖',
|
||||
'😞', '😟', '😤', '😢', '😭', '😦', '😧', '😨', '😩', '🤯',
|
||||
'😬', '😰', '😱', '🥵', '🥶', '😳', '🤪', '😵', '😡', '😠',
|
||||
'🤬', '😷', '🤒', '🤕', '🤢', '🤮', '🤧', '😇', '🤠', '🥳',
|
||||
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉',
|
||||
'👆', '👇', '✋', '🤚', '🖐', '🖖', '👋', '🤝', '🙏', '💪',
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '💔', '💕', '💖',
|
||||
'🎉', '🎊', '🎁', '🎂', '🍰', '🌹', '🌷', '🌸', '🎵', '🎶',
|
||||
'⭐', '🌟', '✨', '💫', '🔥', '💯', '✅', '❌', '⚠️', '❓'
|
||||
]
|
||||
'😀',
|
||||
'😁',
|
||||
'😂',
|
||||
'🤣',
|
||||
'😃',
|
||||
'😄',
|
||||
'😅',
|
||||
'😆',
|
||||
'😉',
|
||||
'😊',
|
||||
'😋',
|
||||
'😎',
|
||||
'😍',
|
||||
'😘',
|
||||
'😗',
|
||||
'😙',
|
||||
'😚',
|
||||
'🙂',
|
||||
'🤗',
|
||||
'🤩',
|
||||
'🤔',
|
||||
'🤨',
|
||||
'😐',
|
||||
'😑',
|
||||
'😶',
|
||||
'🙄',
|
||||
'😏',
|
||||
'😣',
|
||||
'😥',
|
||||
'😮',
|
||||
'🤐',
|
||||
'😯',
|
||||
'😪',
|
||||
'😫',
|
||||
'😴',
|
||||
'😌',
|
||||
'😛',
|
||||
'😜',
|
||||
'😝',
|
||||
'🤤',
|
||||
'😒',
|
||||
'😓',
|
||||
'😔',
|
||||
'😕',
|
||||
'🙃',
|
||||
'🤑',
|
||||
'😲',
|
||||
'☹️',
|
||||
'🙁',
|
||||
'😖',
|
||||
'😞',
|
||||
'😟',
|
||||
'😤',
|
||||
'😢',
|
||||
'😭',
|
||||
'😦',
|
||||
'😧',
|
||||
'😨',
|
||||
'😩',
|
||||
'🤯',
|
||||
'😬',
|
||||
'😰',
|
||||
'😱',
|
||||
'🥵',
|
||||
'🥶',
|
||||
'😳',
|
||||
'🤪',
|
||||
'😵',
|
||||
'😡',
|
||||
'😠',
|
||||
'🤬',
|
||||
'😷',
|
||||
'🤒',
|
||||
'🤕',
|
||||
'🤢',
|
||||
'🤮',
|
||||
'🤧',
|
||||
'😇',
|
||||
'🤠',
|
||||
'🥳',
|
||||
'👍',
|
||||
'👎',
|
||||
'👌',
|
||||
'✌️',
|
||||
'🤞',
|
||||
'🤟',
|
||||
'🤘',
|
||||
'🤙',
|
||||
'👈',
|
||||
'👉',
|
||||
'👆',
|
||||
'👇',
|
||||
'✋',
|
||||
'🤚',
|
||||
'🖐',
|
||||
'🖖',
|
||||
'👋',
|
||||
'🤝',
|
||||
'🙏',
|
||||
'💪',
|
||||
'❤️',
|
||||
'🧡',
|
||||
'💛',
|
||||
'💚',
|
||||
'💙',
|
||||
'💜',
|
||||
'🖤',
|
||||
'💔',
|
||||
'💕',
|
||||
'💖',
|
||||
'🎉',
|
||||
'🎊',
|
||||
'🎁',
|
||||
'🎂',
|
||||
'🍰',
|
||||
'🌹',
|
||||
'🌷',
|
||||
'🌸',
|
||||
'🎵',
|
||||
'🎶',
|
||||
'⭐',
|
||||
'🌟',
|
||||
'✨',
|
||||
'💫',
|
||||
'🔥',
|
||||
'💯',
|
||||
'✅',
|
||||
'❌',
|
||||
'⚠️',
|
||||
'❓',
|
||||
];
|
||||
|
||||
@@ -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 ~ 30KB,200 条软封顶约 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 === 9:3×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;
|
||||
}
|
||||
|
||||
@@ -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,失败返回 null;canvas 绘制要求 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
@@ -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 达到单轮翻页上限,提前结束本轮');
|
||||
}
|
||||
|
||||
@@ -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) : '-';
|
||||
}
|
||||
|
||||
@@ -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.nickname(self 也是 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';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user