refactor(im): 统一本地存储入口和 store 命名

- 删除 utils/storage.ts,getCurrentUserId 移到 utils/user.ts
- StorageKeys 移到 utils/db.ts,按 localStorage / settings 分组
- db 客户端新增 clearStore;整桶 store 改为 clearStore + 循环 put 单事务
- 业务 store action / getter 统一改为 verbXxxList / verbXxx 风格
- draft API 加 Conversation 前缀;FriendStore loadFriends 改名 loadFriendData
- 卸载 localforage 依赖
pull/881/head
YunaiV 2026-05-28 19:49:54 +08:00
parent 664904bd06
commit 763e11eb78
47 changed files with 641 additions and 566 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ pnpm-debug
auto-*.d.ts
.idea
.history
output/

View File

@ -57,7 +57,6 @@
"jsencrypt": "^3.3.2",
"jsoneditor": "^10.1.3",
"livekit-client": "^2.18.9",
"localforage": "^1.10.0",
"lodash-es": "^4.17.21",
"markdown-it": "^14.1.0",
"markmap-common": "^0.16.0",

View File

@ -104,9 +104,6 @@ importers:
livekit-client:
specifier: ^2.18.9
version: 2.18.9(@types/dom-mediacapture-record@1.0.22)
localforage:
specifier: ^1.10.0
version: 1.10.0
lodash-es:
specifier: ^4.17.21
version: 4.17.21
@ -3552,9 +3549,6 @@ packages:
resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==}
engines: {node: '>= 4'}
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==, tarball: https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz}
immer@9.0.21:
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
@ -3785,9 +3779,6 @@ packages:
lezer-feel@1.4.0:
resolution: {integrity: sha512-kNxG7O38gwpuYy+C3JCRxQNTCE2qu9uTuH5dE3EGVnRhIQMe6rPDz0S8t3urLEOsMud6HI795m6zX2ujfUaqTw==, tarball: https://registry.npmmirror.com/lezer-feel/-/lezer-feel-1.4.0.tgz}
lie@3.1.1:
resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==, tarball: https://registry.npmmirror.com/lie/-/lie-3.1.1.tgz}
lilconfig@3.1.2:
resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==}
engines: {node: '>=14'}
@ -3820,9 +3811,6 @@ packages:
resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==}
engines: {node: '>=14'}
localforage@1.10.0:
resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==, tarball: https://registry.npmmirror.com/localforage/-/localforage-1.10.0.tgz}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@ -8993,8 +8981,6 @@ snapshots:
ignore@6.0.2: {}
immediate@3.0.6: {}
immer@9.0.21: {}
immutable@5.0.3: {}
@ -9188,10 +9174,6 @@ snapshots:
'@lezer/lr': 1.4.2
min-dash: 4.2.2
lie@3.1.1:
dependencies:
immediate: 3.0.6
lilconfig@3.1.2: {}
lines-and-columns@1.2.4: {}
@ -9244,10 +9226,6 @@ snapshots:
mlly: 1.7.3
pkg-types: 1.2.1
localforage@1.10.0:
dependencies:
lie: 3.1.1
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0

View File

@ -30,7 +30,7 @@ const props = withDefaults(
defaultWidth?: number //
minWidth?: number //
maxWidth?: number //
storageKey: string // localStorage key StorageKeys.asideWidth Tab
storageKey: string // localStorage key StorageKeys.localStorage.asideWidth
}>(),
{
defaultWidth: 260,

View File

@ -76,7 +76,7 @@ const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const uiStore = useImUiStore()
const totalUnread = computed(() => conversationStore.getTotalUnread) // Tab
const totalUnread = computed(() => conversationStore.getTotalUnreadCount) // Tab
const unhandledRequestCount = computed(() => friendStore.getUnhandledRequestCount) // Tab =
const tabs = [

View File

@ -61,7 +61,7 @@
</div>
<!-- 已是好友显示已添加否则显示添加点击进入 apply 步骤 -->
<el-button
v-if="!friendStore.isFriend(user.id)"
v-if="!friendStore.isActiveFriend(user.id)"
type="primary"
size="small"
@click="enterApply(user)"
@ -134,7 +134,7 @@ import { useUserStore } from '@/store/modules/user'
import UserAvatar from '../user/UserAvatar.vue'
import { useFriendStore } from '../../store/friendStore'
import { getCurrentUserId } from '../../../utils/storage'
import { getCurrentUserId } from '../../../utils/user'
import { ImFriendAddSource } from '../../../utils/constants'
import { getGenderColor, getGenderIcon } from '../../../utils/user'
import { getSimpleUserListByNickname, type UserVO } from '@/api/system/user'
@ -264,15 +264,15 @@ async function handleSubmitApply() {
}
submitting.value = true
try {
const requestId = await friendStore.applyFriend({
const requestId = await friendStore.applyFriendRequest({
toUserId: targetUser.value.id,
applyContent: applyContent.value.trim() || undefined,
displayName: displayName.value.trim() || undefined,
addSource: addSource.value
})
// silent loadFriendInfo WS FRIEND_ADD
// silent fetchFriendInfo WS FRIEND_ADD
if (requestId === null) {
await friendStore.loadFriendInfo(targetUser.value.id)
await friendStore.fetchFriendInfo(targetUser.value.id)
}
message.success(requestId ? '申请已发送,等待对方验证' : '已添加为好友')
visible.value = false

View File

@ -113,7 +113,7 @@ watch(
if (!signature) {
mergeToken++
mergedUrl.value = ''
groupStore.loadGroupMembers(groupId)
groupStore.loadGroupMemberList(groupId)
return
}
const targetSize = getTargetSize(size)

View File

@ -68,7 +68,7 @@ defineExpose({
})
/** 全量好友:直接复用 friendStore Lite 视图(带拼音字段供分桶用) */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendsLite)
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
/** 完成按钮可点:至少有 1 个非 locked 勾选locked 是入口锁定项,不算"用户主动选择" */
const canSubmit = computed(() => selectedIds.value.length > 0)
@ -115,7 +115,7 @@ async function handleOk() {
if (!group?.id) {
throw new Error('创建群失败:未返回群编号')
}
// upsert groupStore fetchGroups VO
// upsert groupStore fetchGroupList VO
groupStore.upsertGroup({
id: group.id,
name: group.name,

View File

@ -80,7 +80,7 @@ const members = ref<GroupMemberLite[]>([])
* 是否已加群基于"自己确实在成员列表里"判断
* - 缓存未命中直接 false陌生群
* - 命中且 members 已拉精准查 self.userId 在不在
* - 命中但 members 未拉fetchGroups 接口语义即我加入的群命中视为 member拉成员后会自动收敛
* - 命中但 members 未拉fetchGroupList 接口语义即我加入的群命中视为 member拉成员后会自动收敛
*/
const isMember = computed(() => {
if (!props.group?.id) {
@ -117,7 +117,7 @@ watch(
if (!id || !member) {
return
}
const list = await groupStore.fetchGroupMembers(id)
const list = await groupStore.fetchGroupMemberList(id)
if (props.group?.id !== id) {
return
}

View File

@ -84,7 +84,7 @@ const members = computed<GroupMemberLite[]>(() => {
})
/** 全量好友:直接复用 friendStore Lite 视图 */
const friends = computed(() => friendStore.getActiveFriendsLite)
const friends = computed(() => friendStore.getActiveFriendLiteList)
/** 已在群里的好友 id传给 Panel 的 disabledIds 置灰 + 不计入已选 */
const disabledIds = computed<number[]>(() =>

View File

@ -273,7 +273,7 @@ async function handleAgree(item: ImGroupRequestRespVO) {
if (actingId.value !== null) return
actingId.value = item.id
try {
await groupRequestStore.agreeRequest(item.id)
await groupRequestStore.agreeGroupRequest(item.id)
updateLocalResult(item.id, ImGroupRequestHandleResult.AGREED)
message.success('已同意')
} finally {
@ -293,7 +293,7 @@ async function handleRefuse(item: ImGroupRequestRespVO) {
}
actingId.value = item.id
try {
await groupRequestStore.refuseRequest(item.id, handleContent || undefined)
await groupRequestStore.refuseGroupRequest(item.id, handleContent || undefined)
updateLocalResult(item.id, ImGroupRequestHandleResult.REFUSED)
message.success('已拒绝')
} finally {

View File

@ -77,7 +77,7 @@ import {
ImConversationType
} from '@/views/im/utils/constants'
import { RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS } from '@/views/im/utils/config'
import { getCurrentUserId } from '@/views/im/utils/storage'
import { getCurrentUserId } from '@/views/im/utils/user'
import { getSenderAvatar, getSenderDisplayName } from '@/views/im/utils/user'
import { Track } from 'livekit-client'
import RtcCallInviting from './RtcCallInviting.vue'

View File

@ -28,7 +28,7 @@
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { useGroupStore } from '../../store/groupStore'
import { getCurrentUserId } from '@/views/im/utils/storage'
import { getCurrentUserId } from '@/views/im/utils/user'
import GroupMemberPickerPanel from '../picker/GroupMemberPickerPanel.vue'
import type { GroupMemberLite } from '../group/GroupMember.vue'

View File

@ -70,7 +70,7 @@ import { useRtcStore } from '../../store/rtcStore'
import { useGroupCallMembers } from '../../composables/useGroupCallMembers'
import { joinCall, getActiveCall } from '@/api/im/rtc'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import { getCurrentUserId } from '@/views/im/utils/storage'
import { getCurrentUserId } from '@/views/im/utils/user'
const props = defineProps<{
groupId: number

View File

@ -164,7 +164,7 @@ const headerTitle = computed(() => {
/** 候选会话:从 store 拿排序后的列表hide 由 Panel 接 hideKeys 过滤) */
const candidateConversations = computed<Conversation[]>(
() => conversationStore.getSortedConversations
() => conversationStore.getSortedConversationList
)
/** 隐藏 key不能把名片推回名片本身的会话用户名片避免自推、群名片避免推回该群 */
@ -177,7 +177,7 @@ const hideKeys = computed<string[]>(() => {
})
/** 好友视图候选列表:直接复用 friendStore Lite 视图 */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendsLite)
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
/** 把选中的 emoji 拼到留言末尾FacePicker 自身负责关闭面板 */
function handleEmojiSelect(emoji: string) {
@ -224,7 +224,7 @@ async function handleSend() {
const results = await Promise.all(tasks)
const failedNames = results.filter((r) => !r.ok).map((r) => r.conversation.name || '未命名会话')
// ""
conversationStore.pushRecentForwardConversationKeys(targets.map((c) => getConversationKey(c)))
conversationStore.pushRecentForwardConversationKeyList(targets.map((c) => getConversationKey(c)))
if (failedNames.length === 0) {
message.success('已转发')
} else if (failedNames.length === targets.length) {
@ -264,7 +264,7 @@ async function handleCreateGroupAndSend() {
if (!group?.id) {
throw new Error('创建群失败:未返回群编号')
}
// upsert groupStore fetchGroups
// upsert groupStore fetchGroupList
groupStore.upsertGroup({
id: group.id,
name: group.name,
@ -294,7 +294,7 @@ async function handleCreateGroupAndSend() {
if (leaveText) {
await send(leaveText, { conversation: newConversation })
}
conversationStore.pushRecentForwardConversationKeys([getConversationKey(newConversation)])
conversationStore.pushRecentForwardConversationKeyList([getConversationKey(newConversation)])
message.success('已创建群聊并发送')
visible.value = false
} finally {

View File

@ -319,7 +319,7 @@ async function saveRemark() {
if (next === (props.displayName || '')) {
return
}
await friendStore.setDisplayName(userId, next)
await friendStore.setFriendDisplayName(userId, next)
message.success('已更新备注')
emit('saved', next)
}

View File

@ -3,7 +3,7 @@
用户名片浮层
- 仅承担"浮层定位 + 关闭逻辑(点遮罩 / Esc"名片视觉走 <UserInfo> contact 详情共用一份组件
- 触发useImUiStore.openUserInfoCard(user, position)本组件订阅 store全局只挂一份实例
- 关系态由 isSelf / isFriend 派生 relation prop 透到 UserInfo删除 / 加好友 / 备注落库都在 UserInfo 内闭环
- 关系态由 isSelf / isActiveFriend 派生 relation prop 透到 UserInfo删除 / 加好友 / 备注落库都在 UserInfo 内闭环
-->
<teleport to="body">
<div v-if="card.show" class="fixed inset-0 z-9998" @click.self="handleClose">
@ -54,11 +54,11 @@ const isSelf = computed(() => {
const myId = Number(userStore.getUser?.id) || 0
return !!user.value?.id && user.value.id === myId
})
const isFriend = computed(() => {
const isActiveFriend = computed(() => {
if (!user.value?.id || isSelf.value) {
return false
}
return friendStore.isFriend(user.value.id)
return friendStore.isActiveFriend(user.value.id)
})
const relation = computed<UserInfoRelation>(() => {
if (!user.value) {
@ -67,14 +67,14 @@ const relation = computed<UserInfoRelation>(() => {
if (isSelf.value) {
return 'self'
}
if (isFriend.value) {
if (isActiveFriend.value) {
return 'friend'
}
return 'stranger'
})
const remark = computed(() => {
if (!isFriend.value || !user.value?.id) {
if (!isActiveFriend.value || !user.value?.id) {
return undefined
}
return friendStore.getFriend(user.value.id)?.displayName || ''

View File

@ -32,7 +32,7 @@ import {
} from '../../utils/config'
import { buildChannelConversationStub } from '../../utils/channel'
import { generateClientMessageId, getPrivateMessagePeerId } from '../../utils/message'
import { getCurrentUserId } from '../../utils/storage'
import { getCurrentUserId } from '../../utils/user'
import type { Message } from '../types'
/**
@ -42,7 +42,7 @@ import type { Message } from '../types'
* 1. + 使 `minId` privateMessageMaxId / groupMessageMaxId
* 2. size minId
* 3. conversationStore.loading=true
* - conversationStore localStorage
* - conversationStore
* - websocketStore WS
* 4. WebSocket
*/
@ -250,7 +250,7 @@ export const useMessagePuller = () => {
}
}
// 游标推进到本批最大 id与后端返回顺序无关无有效 id 直接 break 避免死翻同一批
// 游标推进到本批最大消息编号
const validIds = list.map((message) => message.id).filter((id): id is number => id != null)
if (validIds.length === 0) {
await messageStore.applyPulledMessageList(pulledMessages, conversationType)

View File

@ -224,8 +224,8 @@ export const useMessageSender = () => {
return
}
// 本地标记已读:未读数清零 + 消息状态更新为 READUI 立刻响应)
conversationStore.markConversationAsRead(conversation.type, conversation.targetId)
messageStore.markConversationMessagesRead(conversation)
conversationStore.markConversationRead(conversation.type, conversation.targetId)
messageStore.markConversationMessageListRead(conversation)
const maxMessageId = messageStore
.getMessages(getClientConversationId(conversation.type, conversation.targetId))
.reduce<number>(

View File

@ -45,8 +45,7 @@ import { useMessagePuller } from './composables/useMessagePuller'
import { useMessageSender } from './composables/useMessageSender'
import { useVoicePlayer } from './composables/useVoicePlayer'
import { ImConversationType } from '../utils/constants'
import { StorageKeys } from '../utils/storage'
import { initDb, stopRequests } from '../utils/db'
import { initDb, stopRequests, StorageKeys } from '../utils/db'
import type { Conversation } from './types'
import ToolBar from './components/ToolBar.vue'
import UserInfoCard from './components/user/UserInfoCard.vue'
@ -73,44 +72,44 @@ const voicePlayer = useVoicePlayer()
/** 初始化:先吃本地缓存让首屏立即渲染,再远端刷新最新数据,最后建实时通信拉离线消息 */
onMounted(async () => {
// 0.1 IDB /
void faceStore.ensureFacePacks().catch((e) => console.warn('[IM] 后台预拉表情包失败', e))
// 0.2 / store
void groupRequestStore
.fetchUnhandledList()
.catch((e) => console.warn('[IM] 拉取未处理加群申请失败', e))
void faceStore.ensureFacePackList().catch((e) => console.warn('[IM] 后台预拉表情包失败', e))
// 1.1 loading=true + WebSocket connect pullOnce maxId pull 线
conversationStore.loading = true
try {
// 1.2 IM DB
await initDb()
// 1.3 store IDB loadConversations / loadMessageCursors voidload{Friends,Groups,Channels}
// 1.3 store IDB
const [, , hasCachedFriends, hasCachedGroups, hasCachedChannels] = await Promise.all([
conversationStore.loadConversations(),
messageStore.loadMessageCursors(),
friendStore.loadFriends(),
groupStore.loadGroups(),
channelStore.loadChannels()
conversationStore.loadConversationList(),
messageStore.loadMessageCursorList(),
friendStore.loadFriendData(),
groupStore.loadGroupList(),
channelStore.loadChannelList(),
groupRequestStore.loadGroupRequestList()
])
// 1.4
void groupRequestStore
.fetchUnhandledGroupRequestList()
.catch((e) => console.warn('[IM] 拉取未处理加群申请失败', e))
// 2.1 IDB pullOnce
// 2.2 / await + onMounted
// pullOnce senderId IDB fetch Promise.all RTT
const requiredFetches: Promise<unknown>[] = []
if (hasCachedFriends) {
void friendStore.fetchFriends().catch((e) => console.warn('[IM] 后台刷好友失败', e))
void friendStore.fetchFriendList().catch((e) => console.warn('[IM] 后台刷好友失败', e))
} else {
requiredFetches.push(friendStore.fetchFriends())
requiredFetches.push(friendStore.fetchFriendList())
}
if (hasCachedGroups) {
void groupStore.fetchGroups().catch((e) => console.warn('[IM] 后台刷群列表失败', e))
void groupStore.fetchGroupList().catch((e) => console.warn('[IM] 后台刷群列表失败', e))
} else {
requiredFetches.push(groupStore.fetchGroups())
requiredFetches.push(groupStore.fetchGroupList())
}
if (hasCachedChannels) {
void channelStore.fetchChannels().catch((e) => console.warn('[IM] 后台刷频道列表失败', e))
void channelStore.fetchChannelList().catch((e) => console.warn('[IM] 后台刷频道列表失败', e))
} else {
requiredFetches.push(channelStore.fetchChannels())
requiredFetches.push(channelStore.fetchChannelList())
}
if (requiredFetches.length > 0) {
await Promise.all(requiredFetches)
@ -121,7 +120,7 @@ onMounted(async () => {
await pullOnce()
// 4.
const sorted = conversationStore.getSortedConversations
const sorted = conversationStore.getSortedConversationList
const firstVisible = pickFirstVisibleConversation(sorted)
if (firstVisible && !conversationStore.activeConversation) {
conversationStore.setActiveConversation(firstVisible)
@ -143,7 +142,8 @@ function pickFirstVisibleConversation(sorted: Conversation[]): Conversation | un
if (sorted.length === 0) {
return undefined
}
const pinnedExpanded = localStorage.getItem(StorageKeys.conversationPinnedExpanded) === 'true'
const pinnedExpanded =
localStorage.getItem(StorageKeys.localStorage.conversationPinnedExpanded) === 'true'
if (pinnedExpanded) {
return sorted[0]
}
@ -152,16 +152,16 @@ function pickFirstVisibleConversation(sorted: Conversation[]): Conversation | un
/** 标签关闭前 flush 草稿队列debounce 默认 trail-edge 触发,最后一次输入可能还压在队列里 */
function onBeforeUnload() {
conversationStore.flushDraftSave()
conversationStore.flushConversationDraftSave()
}
window.addEventListener('beforeunload', onBeforeUnload)
/** 离开 IM 主壳:取消在飞的 pull(防止旧响应写新 session+ 主动断 WebSocket + flush 草稿 + 表情缓存 reset + 解绑 unload + 停语音 */
/** 离开 IM 主壳:取消在飞的 pull + 主动断 WebSocket + flush 草稿 + 清空表情缓存 + 解绑 unload + 停语音 */
onUnmounted(() => {
cancelPull()
webSocketStore.disconnect()
conversationStore.flushDraftSave()
faceStore.reset()
conversationStore.flushConversationDraftSave()
faceStore.clear()
// audio
voicePlayer.stop()
window.removeEventListener('beforeunload', onBeforeUnload)
@ -200,7 +200,7 @@ watch(
* 一并监听 route.fullPathIM 子路由切换消息 / 通讯录也能重新加上前缀
*/
watch(
[() => conversationStore.getTotalUnread, () => route.fullPath],
[() => conversationStore.getTotalUnreadCount, () => route.fullPath],
([count]) => {
nextTick(() => {
const base = appStore.getTitle

View File

@ -88,7 +88,7 @@ import { ElMessageBox } from 'element-plus'
import UserAvatar from '../../components/user/UserAvatar.vue'
import UserInfo from '../../components/user/UserInfo.vue'
import { useFriendStore } from '../../store/friendStore'
import { getCurrentUserId } from '../../../utils/storage'
import { getCurrentUserId } from '../../../utils/user'
import { ImFriendRequestHandleResult } from '../../../utils/constants'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import type { FriendRequest, User } from '../../types'

View File

@ -79,7 +79,7 @@ import { computed, ref } from 'vue'
import Icon from '@/components/Icon/src/Icon.vue'
import UserAvatar from '../../components/user/UserAvatar.vue'
import { useFriendStore } from '../../store/friendStore'
import { getCurrentUserId } from '../../../utils/storage'
import { getCurrentUserId } from '../../../utils/user'
import { DICT_TYPE, getDictLabel } from '@/utils/dict'
import type { FriendRequest } from '../../types'
@ -124,7 +124,7 @@ async function handleLoadMore() {
}
loadingMore.value = true
try {
await friendStore.loadMoreFriendRequests()
await friendStore.loadMoreFriendRequestList()
} finally {
loadingMore.value = false
}

View File

@ -6,7 +6,7 @@
- 本页仅做选中分发 + 数据源转换 + 跨组件事件落 store
-->
<div class="flex flex-1 h-full min-w-0 bg-[var(--el-bg-color)]">
<ResizableAside :default-width="260" :storage-key="StorageKeys.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(--el-border-color-lighter)]"
@ -104,7 +104,7 @@ import { useGroupStore } from '../../store/groupStore'
import { getFriendDisplayName, getGroupDisplayName } from '../../../utils/user'
import type { FriendLite, FriendRequest, Group, GroupLite, User } from '../../types'
import { ImConversationType } from '../../../utils/constants'
import { StorageKeys } from '../../../utils/storage'
import { StorageKeys } from '../../../utils/db'
defineOptions({ name: 'ImContactPage' })
@ -129,14 +129,14 @@ const currentRequest = computed<FriendRequest>(() => {
if (!req) {
return {} as FriendRequest
}
return friendStore.findFriendRequest(req.id) || req
return friendStore.getFriendRequest(req.id) || req
})
/** 我相关的申请列表(用 friendStore 里的实时副本,便于通知到达后自动刷新) */
const friendRequests = computed<FriendRequest[]>(() => friendStore.friendRequests)
/** 好友列表的展示快照:附带后端算好的拼音,给 FriendList 做字母分桶 / 拼音搜索 */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendsLite)
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
const groups = computed<GroupLite[]>(() =>
groupStore.groups.map((group: Group) => ({
@ -217,9 +217,9 @@ const friendUser = computed<User | null>(() => {
onMounted(async () => {
await Promise.all([
friendStore.fetchFriends(),
friendStore.fetchFriendRequests(),
groupStore.fetchGroups()
friendStore.fetchFriendList(),
friendStore.fetchFriendRequestList(),
groupStore.fetchGroupList()
])
})

View File

@ -617,10 +617,10 @@ function onMutedChange(value: boolean | string | number) {
}
const next = !!value
const { type, targetId } = props.conversation
conversationStore.setSilent(type, targetId, next)
groupStore.setSilent(targetId, next).catch((error) => {
console.error('[IM ConversationGroupSide] setSilent 失败', { targetId }, error)
conversationStore.setSilent(type, targetId, !next)
conversationStore.setConversationSilent(type, targetId, next)
groupStore.setGroupSilent(targetId, next).catch((error) => {
console.error('[IM ConversationGroupSide] setGroupSilent 失败', { targetId }, error)
conversationStore.setConversationSilent(type, targetId, !next)
})
}
@ -629,7 +629,7 @@ function onTopChange(value: boolean | string | number) {
if (!props.conversation) {
return
}
conversationStore.setTop(props.conversation.type, props.conversation.targetId, !!value)
conversationStore.setConversationTop(props.conversation.type, props.conversation.targetId, !!value)
}
// ==================== ====================
@ -697,7 +697,7 @@ async function handleQuit() {
await quitGroup(groupId)
// self.member DISABLE GroupInfo isMember + store
if (myId.value) {
groupStore.updateMemberStatus(groupId, myId.value, CommonStatusEnum.DISABLE)
groupStore.updateGroupMemberStatus(groupId, myId.value, CommonStatusEnum.DISABLE)
}
conversationStore.removeConversation(ImConversationType.GROUP, groupId)
groupStore.removeGroup(groupId)

View File

@ -143,7 +143,7 @@ const draft = computed(() => {
if (isActive.value) {
return undefined
}
return conversationStore.getDraft(props.conversation)
return conversationStore.getConversationDraft(props.conversation)
})
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)
@ -216,7 +216,7 @@ const requestText = computed(() => {
if (!isGroup.value) {
return ''
}
const count = groupRequestStore.unhandledCountMap.get(props.conversation.targetId) ?? 0
const count = groupRequestStore.getUnhandledGroupRequestCountMap.get(props.conversation.targetId) ?? 0
return count > 0 ? `[${count}条进群申请]` : ''
})
@ -227,7 +227,7 @@ function handleClick() {
/** 切换置顶 */
function handleTop() {
conversationStore.setTop(
conversationStore.setConversationTop(
props.conversation.type,
props.conversation.targetId,
!props.conversation.top
@ -238,14 +238,14 @@ function handleTop() {
function handleMuted() {
const next = !props.conversation.silent
const { type, targetId } = props.conversation
conversationStore.setSilent(type, targetId, next)
conversationStore.setConversationSilent(type, targetId, next)
const sync =
type === ImConversationType.PRIVATE
? friendStore.setSilent(targetId, next)
: groupStore.setSilent(targetId, next)
? friendStore.setFriendSilent(targetId, next)
: groupStore.setGroupSilent(targetId, next)
sync.catch((e) => {
console.error('[IM] 切换免打扰失败', e)
conversationStore.setSilent(type, targetId, !next)
conversationStore.setConversationSilent(type, targetId, !next)
})
}

View File

@ -207,7 +207,7 @@ async function handleSaveDisplayName() {
if (!props.friend) {
return
}
await friendStore.setDisplayName(props.friend.friendUserId, editDisplayName.value)
await friendStore.setFriendDisplayName(props.friend.friendUserId, editDisplayName.value)
displayNamePopoverVisible.value = false
message.success('保存成功')
}
@ -221,13 +221,13 @@ function handleMutedChange(value: boolean | string | number) {
}
const next = !!value
const { type, targetId } = props.conversation
conversationStore.setSilent(type, targetId, next)
conversationStore.setConversationSilent(type, targetId, next)
if (type !== ImConversationType.PRIVATE) {
return
}
friendStore.setSilent(targetId, next).catch((error) => {
friendStore.setFriendSilent(targetId, next).catch((error) => {
console.error('[IM ConversationPrivateSide] 切换免打扰失败', { targetId }, error)
conversationStore.setSilent(type, targetId, !next)
conversationStore.setConversationSilent(type, targetId, !next)
})
}
@ -236,7 +236,7 @@ function handleTopChange(value: boolean | string | number) {
if (!props.conversation) {
return
}
conversationStore.setTop(props.conversation.type, props.conversation.targetId, !!value)
conversationStore.setConversationTop(props.conversation.type, props.conversation.targetId, !!value)
}
/** 群创建成功:跳到新群会话 + 关掉本侧抽屉,让用户专注新群 */

View File

@ -314,8 +314,8 @@ watch(
document.addEventListener('click', handleDocumentClick)
if (isFullMode.value) {
// home onMounted ensureXxx promise
void faceStore.ensureFacePacks()
void faceStore.ensureFaceUserItems()
void faceStore.ensureFacePackList()
void faceStore.ensureFaceUserItemList()
}
} else {
document.removeEventListener('click', handleDocumentClick)

View File

@ -46,7 +46,7 @@
:quote="replyTarget"
closable
class="mx-3 mb-1.5"
@close="clearReplyDraft"
@close="clearConversationReplyDraft"
/>
<!--
@ -261,11 +261,11 @@ function syncDraftToStore(editor: HTMLDivElement) {
if (!conversation) {
return
}
// collectFromEditor trimplain store clearDraft
// reply setDraft reply
// collectFromEditor trimplain store clearConversationDraft
// reply setConversationDraft reply
const { text } = collectFromEditor(editor)
const existing = conversationStore.getDraft(conversation)
conversationStore.setDraft(conversation, {
const existing = conversationStore.getConversationDraft(conversation)
conversationStore.setConversationDraft(conversation, {
html: editor.innerHTML,
plain: text,
reply: existing?.reply
@ -279,7 +279,7 @@ function restoreDraftToEditor() {
return
}
const conversation = conversationStore.activeConversation
const draft = conversation ? conversationStore.getDraft(conversation) : undefined
const draft = conversation ? conversationStore.getConversationDraft(conversation) : undefined
editor.innerHTML = draft?.html || ''
applyEditorUiState(editor)
// focus
@ -365,7 +365,7 @@ function collectFromEditor(root: HTMLElement): { text: string; atUserIds: number
* 3. 二次防御collectFromEditor trim可能比 syncEditorState 更严格例如全 ZWSP仍空就 return
* 4. 清空 + 同步状态先清 innerHTML syncEditorState placeholder / canSend 一起回归
* 顺序很重要先清后 sync否则 sync 看到旧内容会误判
* 5. 上送atUserIds 非空才传避免发空数组quote clearDraft 前抓取,确保引用条立即消失
* 5. 上送atUserIds 非空才传避免发空数组quote clearConversationDraft 前抓取确保引用条立即消失
*/
async function handleSend(options?: { receipt?: boolean }) {
const editor = editorRef.value
@ -376,12 +376,12 @@ async function handleSend(options?: { receipt?: boolean }) {
if (!text) {
return
}
// 1. quote editor + 稿( reply);syncEditorState plain / reply ,
// store , clearDraft debounce , [稿]
// 1. quote editor + 稿 replysyncEditorState plain / reply
// store clearConversationDraft debounce [稿]
const replyQuote = replyTarget.value
editor.innerHTML = ''
if (conversationStore.activeConversation) {
conversationStore.clearDraft(conversationStore.activeConversation)
conversationStore.clearConversationDraft(conversationStore.activeConversation)
}
syncEditorState()
// 2.
@ -572,23 +572,23 @@ const replyTarget = computed<QuoteMessage | undefined>(() => {
if (!conversation) {
return undefined
}
return conversationStore.getDraft(conversation)?.reply
return conversationStore.getConversationDraft(conversation)?.reply
})
/** 清掉当前 reply 但保留正文草稿:点 × 关闭 / 发送即将进行时调 */
function clearReplyDraft() {
function clearConversationReplyDraft() {
const conversation = conversationStore.activeConversation
if (!conversation) {
return
}
conversationStore.clearReplyDraft(conversation)
conversationStore.clearConversationReplyDraft(conversation)
}
/** 取走当前 reply 快照(抓一次清一次),媒体上传链路在动手前统一调它拿 quote */
function consumeReply(): QuoteMessage | undefined {
const quote = replyTarget.value
if (quote) {
clearReplyDraft()
clearConversationReplyDraft()
}
return quote
}

View File

@ -77,7 +77,7 @@ const canManage = computed(
)
/** 当前群未处理申请数;从 store 派生 */
const pendingCount = computed(() => groupRequestStore.getUnhandledCountByGroupId(props.groupId))
const pendingCount = computed(() => groupRequestStore.getUnhandledGroupRequestCount(props.groupId))
</script>
<style scoped>

View File

@ -896,7 +896,7 @@ function handleReply() {
if (!conversation) {
return
}
conversationStore.setReplyDraft(conversation, buildQuoteFromMessage(props.message))
conversationStore.setConversationReplyDraft(conversation, buildQuoteFromMessage(props.message))
}
/** 转发当前消息:打开 ForwardDialog单条模式mode=single 即原样转) */

View File

@ -327,7 +327,7 @@ const showNotFriendBanner = computed(() => {
if (!conversation || conversation.type !== ImConversationType.PRIVATE) {
return false
}
return !friendStore.isFriend(conversation.targetId)
return !friendStore.isActiveFriend(conversation.targetId)
})
/** 点击「对方还不是你的朋友」胶囊:打开 UserInfoCard引导用户重新添加 */
@ -436,13 +436,13 @@ async function ensureGroupData(groupId: number) {
})
// IDB /
await groupStore.loadGroupMembers(groupId).catch((error) => {
console.warn('[IM MessagePanel] loadGroupMembers 失败', { groupId }, error)
await groupStore.loadGroupMemberList(groupId).catch((error) => {
console.warn('[IM MessagePanel] loadGroupMemberList 失败', { groupId }, error)
return null
})
// in-memory
groupStore.fetchGroupMembers(groupId, true).catch((error) => {
console.warn('[IM MessagePanel] fetchGroupMembers 失败', { groupId }, error)
groupStore.fetchGroupMemberList(groupId, true).catch((error) => {
console.warn('[IM MessagePanel] fetchGroupMemberList 失败', { groupId }, error)
})
}
@ -453,7 +453,7 @@ function reloadGroupData() {
return
}
groupStore.fetchGroupInfo(conversation.targetId)
groupStore.fetchGroupMembers(conversation.targetId, true)
groupStore.fetchGroupMemberList(conversation.targetId, true)
}
/** 历史消息抽屉 ref「聊天历史」icon / 抽屉「查找聊天内容」入口都调 open() 触发 */
@ -721,7 +721,7 @@ watch(
// /
sideVisible.value = false
scrollToBottom()
// / fetchFriends
// / fetchFriendList
if (targetId && type === ImConversationType.GROUP) {
ensureGroupData(targetId)
}

View File

@ -227,13 +227,13 @@ const confirmButtonText = computed(() =>
/** 候选会话:从 store 拿排序后的列表(转发回原会话也允许,与微信一致);公众号 / 频道单向消息不接受转发,从候选里剔除 */
const candidateConversations = computed<Conversation[]>(() =>
conversationStore.getSortedConversations.filter(
conversationStore.getSortedConversationList.filter(
(conversation) => conversation.type !== ImConversationType.CHANNEL
)
)
/** 好友视图候选列表:直接复用 friendStore Lite 视图 */
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendsLite)
const friends = computed<FriendLite[]>(() => friendStore.getActiveFriendLiteList)
/** 切到好友视图:清掉之前在会话视图输入的留言,避免在不可见输入框里把留言静默发到新群 */
function handleSwitchToContact() {
@ -341,7 +341,7 @@ async function handleSend() {
const results = await Promise.all(tasks)
const failedNames = results.filter((r) => !r.ok).map((r) => r.target.name || '未命名会话')
// ""
conversationStore.pushRecentForwardConversationKeys(targets.map((c) => getConversationKey(c)))
conversationStore.pushRecentForwardConversationKeyList(targets.map((c) => getConversationKey(c)))
if (failedNames.length === 0) {
message.success('已转发')
} else if (failedNames.length === targets.length) {
@ -387,7 +387,7 @@ async function handleCreateGroupAndSend() {
if (!group?.id) {
throw new Error('创建群失败:未返回群编号')
}
// upsert groupStore fetchGroups
// upsert groupStore fetchGroupList
groupStore.upsertGroup({
id: group.id,
name: group.name,
@ -411,7 +411,7 @@ async function handleCreateGroupAndSend() {
if (leaveText) {
await send(leaveText, { conversation: newConversation })
}
conversationStore.pushRecentForwardConversationKeys([getConversationKey(newConversation)])
conversationStore.pushRecentForwardConversationKeyList([getConversationKey(newConversation)])
message.success('已创建群聊并转发')
} else {
message.warning('群已创建,但消息转发失败,请稍后在群里重试')

View File

@ -2,7 +2,7 @@
<!-- 消息 Tab左侧会话列表 + 右侧聊天面板 -->
<div class="flex flex-1 min-w-0 h-full">
<!-- 左侧会话列表可拖拽宽度 -->
<ResizableAside :default-width="260" :storage-key="StorageKeys.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(--el-border-color-lighter)]"
@ -101,8 +101,8 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { useConversationStore } from '../../store/conversationStore'
import { useGroupStore } from '../../store/groupStore'
import { useImUiStore } from '../../store/uiStore'
import { StorageKeys } from '../../../utils/storage'
import { ImConversationType } from '../../../utils/constants'
import { StorageKeys } from '../../../utils/db'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import type { Conversation } from '../../types'
import ResizableAside from '../../components/ResizableAside.vue'
@ -121,7 +121,7 @@ const uiStore = useImUiStore()
const keyword = ref('')
const sortedConversations = computed(() => conversationStore.getSortedConversations)
const sortedConversations = computed(() => conversationStore.getSortedConversationList)
/** 顶部搜索框过滤会话:只按 name 模糊匹配,避免命中 lastContent 等次要字段干扰 */
const filteredConversations = computed(() =>
@ -135,13 +135,16 @@ const PINNED_FOLD_THRESHOLD = 3
/** 置顶折叠展开态localStorage 持久化,刷新后保留用户上次的选择,对齐微信 */
const pinnedExpanded = ref(
localStorage.getItem(StorageKeys.conversationPinnedExpanded) === 'true'
localStorage.getItem(StorageKeys.localStorage.conversationPinnedExpanded) === 'true'
)
/** toggle + 写盘 */
function togglePinnedExpanded() {
pinnedExpanded.value = !pinnedExpanded.value
localStorage.setItem(StorageKeys.conversationPinnedExpanded, String(pinnedExpanded.value))
localStorage.setItem(
StorageKeys.localStorage.conversationPinnedExpanded,
String(pinnedExpanded.value)
)
}
/** 置顶会话:单独切片,给折叠头计数 + 折叠区渲染用 */

View File

@ -3,7 +3,8 @@ import { store } from '@/store'
import { getSimpleChannelList, type ImManagerChannelVO } from '@/api/im/manager/channel'
import { useConversationStore } from './conversationStore'
import { ImConversationType } from '../../utils/constants'
import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage'
import { getDb } from '../../utils/db'
import type { ChannelDO } from '../types'
/**
* IM Store
@ -26,14 +27,10 @@ export const useChannelStore = defineStore('imChannelStore', {
actions: {
// ==================== 本地缓存 ====================
/** 从 IDB 恢复频道列表;命中返回 true 让首屏立刻有真实名 / 头像 */
async loadChannels(): Promise<boolean> {
const userId = getCurrentUserId()
if (!userId) {
return false
}
/** 从 IndexedDB 恢复频道列表 */
async loadChannelList(): Promise<boolean> {
try {
const cached = await imStorage.getItem<ImManagerChannelVO[]>(StorageKeys.channels(userId))
const cached = await getDb().getAll<ChannelDO>('channels')
if (!cached || cached.length === 0) {
return false
}
@ -47,17 +44,21 @@ export const useChannelStore = defineStore('imChannelStore', {
/** 保存频道列表 */
saveChannelList(): void {
const userId = getCurrentUserId()
if (!userId) {
return
}
setQuietly(StorageKeys.channels(userId), this.channels, '[IM channelStore] 本地频道缓存写入失败')
void getDb()
.transaction(['channels'], 'readwrite', async (tx) => {
const db = getDb()
await db.clearStore('channels', tx)
for (const channel of this.channels) {
await db.put('channels', channel, tx)
}
})
.catch((e) => console.warn('[IM channelStore] 本地频道缓存写入失败', e))
},
// ==================== 远端拉取 ====================
/** 拉取启用的频道精简列表;成功后回填会话列表已有的频道 name / avatar覆盖 IDB 旧占位 */
async fetchChannels(force = false) {
async fetchChannelList(force = false) {
if (this.loaded && !force) {
return
}
@ -67,7 +68,7 @@ export const useChannelStore = defineStore('imChannelStore', {
this.syncChannelConversationMetadata()
this.saveChannelList()
} catch (e) {
console.warn('[IM channelStore] fetchChannels 失败', e)
console.warn('[IM channelStore] fetchChannelList 失败', e)
}
},

View File

@ -4,8 +4,8 @@ import { store } from '@/store'
import { CONVERSATION_RECENT_FORWARD_MAX } from '../../utils/config'
import { ImConversationType } from '../../utils/constants'
import { getClientConversationId, getDb, type DbTransaction } from '../../utils/db'
import { getCurrentUserId } from '../../utils/storage'
import { getClientConversationId, getDb, StorageKeys, type DbTransaction } from '../../utils/db'
import { getCurrentUserId } from '../../utils/user'
import { useMessageStore } from './messageStore'
import type { Conversation, ConversationDO } from '../types'
@ -36,20 +36,7 @@ function toConversationDO(conversation: Conversation): ConversationDO {
silent: conversation.silent,
atMe: conversation.atMe,
atAll: conversation.atAll,
draft: draft
? {
html: draft.html,
plain: draft.plain,
reply: draft.reply
? {
messageId: draft.reply.messageId,
senderId: draft.reply.senderId,
type: draft.reply.type,
content: draft.reply.content
}
: undefined
}
: undefined,
draft: draft ? { ...draft, reply: draft.reply ? { ...draft.reply } : undefined } : undefined,
clientConversationId: getClientConversationId(conversation.type, conversation.targetId)
}
}
@ -70,7 +57,7 @@ export const useConversationStore = defineStore('imConversationStore', {
getters: {
/** 排序后的会话列表 */
getSortedConversations(state): Conversation[] {
getSortedConversationList(state): Conversation[] {
return [...state.conversations]
.filter((conversation) => !conversation.deleted)
.sort((a, b) => {
@ -84,7 +71,7 @@ export const useConversationStore = defineStore('imConversationStore', {
},
/** 未读总数 */
getTotalUnread(state): number {
getTotalUnreadCount(state): number {
return state.conversations
.filter((conversation) => !conversation.deleted && !conversation.silent)
.reduce((sum, conversation) => sum + (conversation.unreadCount || 0), 0)
@ -101,7 +88,7 @@ export const useConversationStore = defineStore('imConversationStore', {
actions: {
/** 加载会话 */
async loadConversations() {
async loadConversationList() {
// 1. 清理旧账号内存
const userId = getCurrentUserId()
if (!userId) {
@ -116,7 +103,7 @@ export const useConversationStore = defineStore('imConversationStore', {
const db = getDb()
const [conversations, recent] = await Promise.all([
db.getAll<ConversationDO>('conversations'),
db.getSetting<string[]>('recentForwardConversationKeys')
db.getSetting<string[]>(StorageKeys.settings.recentForwardConversationKeys)
])
this.conversations = conversations.map(fromConversationDO)
if (Array.isArray(recent)) {
@ -136,7 +123,7 @@ export const useConversationStore = defineStore('imConversationStore', {
/** 清空会话内存 */
clear() {
saveDraftConversationsDebounced.cancel()
saveDraftConversationListDebounced.cancel()
pendingDraftConversations.clear()
this.conversations = []
this.activeConversation = null
@ -144,7 +131,7 @@ export const useConversationStore = defineStore('imConversationStore', {
},
/** 执行会话记录持久化 */
async persistConversationRecords(
async saveConversationRecord(
target: Conversation | Conversation[] | null | undefined,
tx?: DbTransaction
): Promise<void> {
@ -173,7 +160,7 @@ export const useConversationStore = defineStore('imConversationStore', {
if (!conversation) {
return
}
void this.persistConversationRecords(conversation, tx).catch((e) =>
void this.saveConversationRecord(conversation, tx).catch((e) =>
console.warn('[IM conversationStore] 会话写入失败', e)
)
},
@ -183,7 +170,7 @@ export const useConversationStore = defineStore('imConversationStore', {
if (this.loading && !tx) {
return
}
void this.persistConversationRecords(conversations || this.conversations, tx).catch((e) =>
void this.saveConversationRecord(conversations || this.conversations, tx).catch((e) =>
console.warn('[IM conversationStore] 会话写入失败', e)
)
},
@ -263,7 +250,7 @@ export const useConversationStore = defineStore('imConversationStore', {
conversation.atMe = false
conversation.atAll = false
// 2. 懒加载消息并保存会话摘要
void useMessageStore().ensureConversationMessagesLoaded(conversation)
void useMessageStore().ensureConversationMessageListLoaded(conversation)
this.saveConversation(conversation)
},
@ -292,7 +279,7 @@ export const useConversationStore = defineStore('imConversationStore', {
},
/** 设置置顶 */
setTop(type: number, targetId: number, top: boolean) {
setConversationTop(type: number, targetId: number, top: boolean) {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
return
@ -302,7 +289,7 @@ export const useConversationStore = defineStore('imConversationStore', {
},
/** 设置免打扰 */
setSilent(type: number, targetId: number, silent: boolean) {
setConversationSilent(type: number, targetId: number, silent: boolean) {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
return
@ -323,8 +310,8 @@ export const useConversationStore = defineStore('imConversationStore', {
}
conversation.deleted = true
// 2. 删除会话关联的消息和草稿
useMessageStore().deleteConversationMessages(type, targetId)
this.clearDraft(conversation)
useMessageStore().deleteConversationMessageList(type, targetId)
this.clearConversationDraft(conversation)
this.saveConversation(conversation)
},
@ -339,7 +326,7 @@ export const useConversationStore = defineStore('imConversationStore', {
},
/** 标记会话已读 */
markConversationAsRead(type: number, targetId: number) {
markConversationRead(type: number, targetId: number) {
const conversation = this.getConversation(type, targetId)
if (!conversation) {
return
@ -356,7 +343,7 @@ export const useConversationStore = defineStore('imConversationStore', {
// ==================== 最近转发 ====================
/** 推送最近转发会话 */
pushRecentForwardConversationKeys(keys: string[]) {
pushRecentForwardConversationKeyList(keys: string[]) {
if (!keys || keys.length === 0) {
return
}
@ -365,7 +352,7 @@ export const useConversationStore = defineStore('imConversationStore', {
0,
CONVERSATION_RECENT_FORWARD_MAX
)
this.saveRecentForwardConversationKeys()
this.saveRecentForwardConversationKeyList()
},
/** 移除最近转发会话 */
@ -375,14 +362,14 @@ export const useConversationStore = defineStore('imConversationStore', {
return
}
this.recentForwardConversationKeys.splice(index, 1)
this.saveRecentForwardConversationKeys()
this.saveRecentForwardConversationKeyList()
},
/** 保存最近转发会话 */
saveRecentForwardConversationKeys() {
saveRecentForwardConversationKeyList() {
void getDb()
.setSetting(
'recentForwardConversationKeys',
StorageKeys.settings.recentForwardConversationKeys,
this.recentForwardConversationKeys.slice(0, CONVERSATION_RECENT_FORWARD_MAX)
)
.catch((e) => console.warn('[IM conversationStore] 最近转发列表写入失败', e))
@ -427,17 +414,17 @@ export const useConversationStore = defineStore('imConversationStore', {
// ==================== 草稿 ====================
/** 获取草稿 */
getDraft(conversation: { type: number; targetId: number }): Conversation['draft'] | undefined {
getConversationDraft(conversation: { type: number; targetId: number }): Conversation['draft'] | undefined {
return this.getConversation(conversation.type, conversation.targetId)?.draft
},
/** 设置草稿 */
setDraft(
setConversationDraft(
conversation: { type: number; targetId: number },
snapshot: NonNullable<Conversation['draft']>
): void {
if (!snapshot.plain.trim() && !snapshot.reply) {
this.clearDraft(conversation)
this.clearConversationDraft(conversation)
return
}
const target = this.getConversation(conversation.type, conversation.targetId)
@ -445,29 +432,29 @@ export const useConversationStore = defineStore('imConversationStore', {
return
}
target.draft = snapshot
this.scheduleDraftSave(target)
this.scheduleConversationDraftSave(target)
},
/** 清除草稿 */
clearDraft(conversation: { type: number; targetId: number }): void {
clearConversationDraft(conversation: { type: number; targetId: number }): void {
const target = this.getConversation(conversation.type, conversation.targetId)
if (!target?.draft) {
return
}
target.draft = undefined
this.scheduleDraftSave(target)
this.scheduleConversationDraftSave(target)
},
/** 设置回复草稿 */
setReplyDraft(
setConversationReplyDraft(
conversation: { type: number; targetId: number },
quote: NonNullable<Conversation['draft']>['reply']
) {
if (!quote) {
return
}
const existing = this.getDraft(conversation)
this.setDraft(conversation, {
const existing = this.getConversationDraft(conversation)
this.setConversationDraft(conversation, {
html: existing?.html ?? '',
plain: existing?.plain ?? '',
reply: quote
@ -475,23 +462,23 @@ export const useConversationStore = defineStore('imConversationStore', {
},
/** 清除回复草稿 */
clearReplyDraft(conversation: { type: number; targetId: number }): void {
const existing = this.getDraft(conversation)
clearConversationReplyDraft(conversation: { type: number; targetId: number }): void {
const existing = this.getConversationDraft(conversation)
if (!existing?.reply) {
return
}
this.setDraft(conversation, { ...existing, reply: undefined })
this.setConversationDraft(conversation, { ...existing, reply: undefined })
},
/** 调度草稿保存 */
scheduleDraftSave(conversation: Conversation): void {
scheduleConversationDraftSave(conversation: Conversation): void {
pendingDraftConversations.add(conversation)
saveDraftConversationsDebounced()
saveDraftConversationListDebounced()
},
/** 立即保存草稿 */
flushDraftSave(): void {
saveDraftConversationsDebounced.flush()
flushConversationDraftSave(): void {
saveDraftConversationListDebounced.flush()
}
}
})
@ -499,14 +486,14 @@ export const useConversationStore = defineStore('imConversationStore', {
export const useConversationStoreWithOut = () => useConversationStore(store)
/** 合并草稿写入 */
const saveDraftConversationsDebounced = debounce(() => {
const saveDraftConversationListDebounced = debounce(() => {
const conversations = Array.from(pendingDraftConversations)
pendingDraftConversations.clear()
if (conversations.length === 0) {
return
}
void useConversationStoreWithOut()
.persistConversationRecords(conversations)
.saveConversationRecord(conversations)
.catch((e) => console.warn('[IM conversationStore] 草稿写入失败', e))
}, PERSIST_DRAFT_DEBOUNCE_MS)

View File

@ -28,18 +28,18 @@ export const useFaceStore = defineStore('imFace', () => {
/** 个人表情包列表(用户长按「添加到表情」/ 上传产生) */
const faceUserItems = ref<ImFaceUserItemVO[]>([])
/** reset() 时递增;旧账号那次还没返回的请求 resolve 后比对一致才写 store防跨账号数据泄漏 */
/** clear() 时递增;旧账号请求返回后不写入新账号内存 */
let storeEpoch = 0
/**
* promiseensureFacePacks cache
* promiseensureFacePackList cache
* - null =
* - resolve = await
* - reject null
*/
let facePacksPromise: Promise<void> | null = null
/** 按需拉取系统表情包(已拉过则直接复用 cached promise */
async function ensureFacePacks(): Promise<void> {
async function ensureFacePackList(): Promise<void> {
if (!facePacksPromise) {
const requestEpoch = storeEpoch
facePacksPromise = apiGetFacePackList()
@ -63,7 +63,7 @@ export const useFaceStore = defineStore('imFace', () => {
/** 个人表情拉取 promise语义同上 */
let faceUserItemsPromise: Promise<void> | null = null
/** 按需拉取个人表情(已拉过则直接复用 cached promise */
async function ensureFaceUserItems(): Promise<void> {
async function ensureFaceUserItemList(): Promise<void> {
if (!faceUserItemsPromise) {
const requestEpoch = storeEpoch
faceUserItemsPromise = apiGetFaceUserItemList()
@ -95,7 +95,7 @@ export const useFaceStore = defineStore('imFace', () => {
if (!id) {
return false
}
// reset 已切账号:旧请求拿到的 id 不能再 unshift 进新账号内存
// 已切账号时跳过旧请求结果
if (requestEpoch !== storeEpoch) {
return false
}
@ -117,7 +117,7 @@ export const useFaceStore = defineStore('imFace', () => {
const requestEpoch = storeEpoch
try {
await apiDeleteFaceUserItem(id)
// reset 已切账号:不要再 filter 新账号列表
// 已切账号时跳过旧请求结果
if (requestEpoch !== storeEpoch) {
return false
}
@ -129,8 +129,8 @@ export const useFaceStore = defineStore('imFace', () => {
}
}
/** 切账号 / 退出 IM 时清空缓存,避免下个用户看到上一用户的个人表情 */
function reset(): void {
/** 清空表情缓存 */
function clear(): void {
facePacks.value = []
faceUserItems.value = []
facePacksPromise = null
@ -141,11 +141,11 @@ export const useFaceStore = defineStore('imFace', () => {
return {
facePacks,
faceUserItems,
ensureFacePacks,
ensureFaceUserItems,
ensureFacePackList,
ensureFaceUserItemList,
addFaceUserItem,
removeFaceUserItem,
reset
clear
}
})

View File

@ -23,9 +23,10 @@ import {
import { useConversationStore } from './conversationStore'
import { ImConversationType, ImFriendRequestHandleResult } from '../../utils/constants'
import { FRIEND_REQUEST_PAGE_SIZE } from '../../utils/config'
import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage'
import { getDb } from '../../utils/db'
import { getCurrentUserId } from '../../utils/user'
import { getFriendDisplayName } from '../../utils/user'
import type { Friend, FriendLite, FriendRequest } from '../types'
import type { Friend, FriendDO, FriendLite, FriendRequest, FriendRequestDO } from '../types'
/** 当前正在进行的好友列表拉取;多 dispatcher 同时触发时复用同一 Promise避免雪崩重拉 */
let pendingFetchFriends: Promise<void> | null = null
@ -68,7 +69,7 @@ export interface FriendNotificationPayload {
export const useFriendStore = defineStore('imFriendStore', {
state: () => ({
friends: [] as Friend[],
// 仅 fetchFriends 成功后置位loadFriendsIDB不置位否则后台 SWR 刷新会被缓存命中跳过
// 仅 fetchFriendList 成功后置位loadFriendDataIDB不置位否则后台 SWR 刷新会被缓存命中跳过
loaded: false,
/** 我相关的好友申请列表(含我发起的 + 别人加我的;后端按 id 倒序游标分页) */
friendRequests: [] as FriendRequest[],
@ -81,9 +82,9 @@ export const useFriendStore = defineStore('imFriendStore', {
* friendUserId Friend O(1) friends Pinia friends
*
* senderId / getFriend
* find N × M = O(N×M) O(1)fetchFriends / upsertFriend
* find N × M = O(N×M) O(1)fetchFriendList / upsertFriend
*/
friendMap: (state): Map<number, Friend> => {
getFriendMap: (state): Map<number, Friend> => {
const map = new Map<number, Friend>()
for (const friend of state.friends) {
map.set(friend.friendUserId, friend)
@ -92,15 +93,15 @@ export const useFriendStore = defineStore('imFriendStore', {
},
/** 按 friendUserId 找好友(含已软删的 DISABLE 记录,调用方自行判定) */
getFriend(): (friendUserId: number) => Friend | undefined {
return (friendUserId: number) => this.friendMap.get(friendUserId)
return (friendUserId: number) => this.getFriendMap.get(friendUserId)
},
/** 当前生效的好友列表(过滤掉 DISABLE 软删记录) */
getActiveFriends: (state): Friend[] => {
getActiveFriendList: (state): Friend[] => {
return state.friends.filter((friend) => friend.status !== CommonStatusEnum.DISABLE)
},
/** 当前生效好友的 Lite 视图PickerPanel / 选人弹窗共用,自带拼音字段供分桶 / 搜索) */
getActiveFriendsLite(): FriendLite[] {
return this.getActiveFriends.map((friend: Friend) => ({
getActiveFriendLiteList(): FriendLite[] {
return this.getActiveFriendList.map((friend: Friend) => ({
id: friend.friendUserId,
nickname: friend.nickname,
nicknamePinyin: friend.nicknamePinyin,
@ -110,14 +111,14 @@ export const useFriendStore = defineStore('imFriendStore', {
}))
},
/** 判断对方是否是当前用户的有效好友(存在 + 非 DISABLE */
isFriend() {
isActiveFriend() {
return (friendUserId: number): boolean => {
const entry = this.getFriend(friendUserId)
return !!entry && entry.status !== CommonStatusEnum.DISABLE
}
},
/** 我的黑名单blocked=true 且 ENABLE */
getBlockedFriends: (state): Friend[] => {
getBlockedFriendList: (state): Friend[] => {
return state.friends.filter(
(friend) => friend.status !== CommonStatusEnum.DISABLE && friend.blocked === true
)
@ -136,38 +137,81 @@ export const useFriendStore = defineStore('imFriendStore', {
actions: {
// ==================== 本地缓存 ====================
/** 从 IDB 恢复好友列表 */
async loadFriends(): Promise<boolean> {
const userId = getCurrentUserId()
if (!userId) {
return false
}
/** 从 IndexedDB 恢复好友和好友申请 */
async loadFriendData(): Promise<boolean> {
try {
const cached = await imStorage.getItem<Friend[]>(StorageKeys.friends(userId))
if (!cached || cached.length === 0) {
return false
const [friends, friendRequests] = await Promise.all([
getDb().getAll<FriendDO>('friends'),
getDb().getAll<FriendRequestDO>('friendRequests')
])
if (friends.length > 0) {
this.friends = friends
}
this.friends = cached
return true
if (friendRequests.length > 0) {
this.friendRequests = friendRequests.sort(
(requestA, requestB) => requestB.id - requestA.id
)
this.hasMoreFriendRequests = friendRequests.length >= FRIEND_REQUEST_PAGE_SIZE
}
return friends.length > 0
} catch (e) {
console.warn('[IM friendStore] 本地好友缓存读取失败', e)
return false
}
},
/** 整桶持久化好友列表(量级有限,不维护增量) */
saveFriends(): void {
const userId = getCurrentUserId()
if (!userId) {
/** 保存好友列表 */
saveFriendList(): void {
void getDb()
.transaction(['friends'], 'readwrite', async (tx) => {
const db = getDb()
await db.clearStore('friends', tx)
for (const friend of this.friends) {
if (friend.id) {
await db.put('friends', friend, tx)
}
}
})
.catch((e) => console.warn('[IM friendStore] 本地好友缓存写入失败', e))
},
/** 保存单个好友 */
saveFriend(friend: Friend | undefined): void {
if (!friend?.id) {
return
}
setQuietly(StorageKeys.friends(userId), this.friends, '[IM friendStore] 本地好友缓存写入失败')
void getDb()
.put('friends', friend)
.catch((e) => console.warn('[IM friendStore] 本地好友写入失败', e))
},
/** 保存好友申请列表 */
saveFriendRequestList(): void {
void getDb()
.transaction(['friendRequests'], 'readwrite', async (tx) => {
const db = getDb()
await db.clearStore('friendRequests', tx)
for (const request of this.friendRequests) {
await db.put('friendRequests', request, tx)
}
})
.catch((e) => console.warn('[IM friendStore] 本地好友申请缓存写入失败', e))
},
/** 保存单条好友申请 */
saveFriendRequest(request: FriendRequest | undefined): void {
if (!request) {
return
}
void getDb()
.put('friendRequests', request)
.catch((e) => console.warn('[IM friendStore] 本地好友申请写入失败', e))
},
// ==================== 远端拉取 ====================
/** 从后端拉取并覆盖本地列表(含 DISABLE 历史好友给已删对话兜底);只同步 ENABLE 的会话信息DISABLE 的不动 —— cascade 清会话由 WS dispatcher 按 payload.clear 处理,避免 fetchFriends 覆盖用户「不清空聊天记录」的选择 */
async fetchFriends(force = false) {
/** 从后端拉取并覆盖本地列表(含 DISABLE 历史好友给已删对话兜底);只同步 ENABLE 的会话信息DISABLE 的不动 —— cascade 清会话由 WS dispatcher 按 payload.clear 处理,避免 fetchFriendList 覆盖用户「不清空聊天记录」的选择 */
async fetchFriendList(force = false) {
if (this.loaded && !force) {
return
}
@ -194,7 +238,7 @@ export const useFriendStore = defineStore('imFriendStore', {
silent: friend.silent
})
}
this.saveFriends()
this.saveFriendList()
})
.finally(() => {
if (requestEpoch === storeEpoch) {
@ -205,7 +249,7 @@ export const useFriendStore = defineStore('imFriendStore', {
},
/** 按 friendUserId 获取详情并合并到本地(保证 nickname / avatar 最新) */
async loadFriendInfo(friendUserId: number) {
async fetchFriendInfo(friendUserId: number) {
const requestEpoch = storeEpoch
try {
const data = await apiGetFriend(friendUserId)
@ -218,14 +262,14 @@ export const useFriendStore = defineStore('imFriendStore', {
}
this.upsertFriend(convertFriend(data))
} catch (e) {
console.warn('[IM friendStore] loadFriendInfo 失败', e)
console.warn('[IM friendStore] fetchFriendInfo 失败', e)
}
},
// ==================== 申请-审批 ====================
/** 发起好友申请:成功后等待对方同意(不直接落地为好友) */
async applyFriend(reqVO: ImFriendRequestApplyReqVO): Promise<number | null> {
async applyFriendRequest(reqVO: ImFriendRequestApplyReqVO): Promise<number | null> {
return await apiApplyFriendRequest(reqVO)
},
@ -247,20 +291,21 @@ export const useFriendStore = defineStore('imFriendStore', {
result: number,
handleContent?: string
): Promise<void> {
const request = this.findFriendRequest(requestId)
const request = this.getFriendRequest(requestId)
if (request) {
request.handleResult = result
if (handleContent !== undefined) {
request.handleContent = handleContent
}
request.handleTime = Date.now()
this.saveFriendRequest(request)
return
}
await this.loadFriendRequest(requestId)
await this.fetchFriendRequest(requestId)
},
/** 拉取「我相关」的好友申请列表首页(页面打开 / 收到 FRIEND_REQUEST_RECEIVED 时刷新pending 期间复用同一 Promise */
async fetchFriendRequests() {
async fetchFriendRequestList() {
if (pendingFetchRequests) {
return pendingFetchRequests
}
@ -274,6 +319,7 @@ export const useFriendStore = defineStore('imFriendStore', {
this.friendRequests = items
// 不足一页即没有更多;满页可能还有,等 loadMore 拉到 0 条再确定
this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE
this.saveFriendRequestList()
})
.finally(() => {
if (requestEpoch === storeEpoch) {
@ -284,13 +330,13 @@ export const useFriendStore = defineStore('imFriendStore', {
},
/** 加载更多申请(按本地最旧 requestId 游标分页);无更多 / pending 中直接返回 */
async loadMoreFriendRequests() {
async loadMoreFriendRequestList() {
if (!this.hasMoreFriendRequests || pendingLoadMoreRequests || pendingFetchRequests) {
return
}
const oldest = this.friendRequests[this.friendRequests.length - 1]
if (!oldest) {
return this.fetchFriendRequests()
return this.fetchFriendRequestList()
}
const requestEpoch = storeEpoch
pendingLoadMoreRequests = apiGetMyFriendRequestList(FRIEND_REQUEST_PAGE_SIZE, oldest.id)
@ -301,6 +347,7 @@ export const useFriendStore = defineStore('imFriendStore', {
const items = (list || []).map(convertFriendRequest)
this.friendRequests.push(...items)
this.hasMoreFriendRequests = items.length >= FRIEND_REQUEST_PAGE_SIZE
this.saveFriendRequestList()
})
.finally(() => {
if (requestEpoch === storeEpoch) {
@ -311,12 +358,12 @@ export const useFriendStore = defineStore('imFriendStore', {
},
/** 按 id 查申请记录;列表是按 id 倒序的小列表O(n) find 即可,不再维护 Map 索引 */
findFriendRequest(requestId: number): FriendRequest | undefined {
getFriendRequest(requestId: number): FriendRequest | undefined {
return this.friendRequests.find((request) => request.id === requestId)
},
/** 按 id 从后端单查并 upsert 到本地dispatcher 兜底用,避免全量重拉);后端带越权过滤 */
async loadFriendRequest(requestId: number) {
async fetchFriendRequest(requestId: number) {
const requestEpoch = storeEpoch
const data = await apiGetMyFriendRequest(requestId)
if (!data) {
@ -327,9 +374,10 @@ export const useFriendStore = defineStore('imFriendStore', {
return
}
const next = convertFriendRequest(data)
const existing = this.findFriendRequest(requestId)
const existing = this.getFriendRequest(requestId)
if (existing) {
Object.assign(existing, next)
this.saveFriendRequest(existing)
return
}
// 比本地最旧 id 还老:不入列表,让 loadMore 自然带回,避免破坏 id 倒序 / 后续 loadMore 重复 push
@ -344,6 +392,7 @@ export const useFriendStore = defineStore('imFriendStore', {
} else {
this.friendRequests.splice(insertIndex, 0, next)
}
this.saveFriendRequest(next)
},
// ==================== 好友关系操作 ====================
@ -359,7 +408,7 @@ export const useFriendStore = defineStore('imFriendStore', {
},
/** 切换免打扰:同步会话的 silent 字段,避免会话列表 silent 图标等 1210 推到才更新 */
async setSilent(friendUserId: number, silent: boolean) {
async setFriendSilent(friendUserId: number, silent: boolean) {
const requestEpoch = storeEpoch
await apiUpdateFriend({ friendUserId, silent })
if (requestEpoch !== storeEpoch) {
@ -370,12 +419,12 @@ export const useFriendStore = defineStore('imFriendStore', {
friend.silent = silent
const conversationStore = useConversationStore()
conversationStore.updateConversation(ImConversationType.PRIVATE, friendUserId, { silent })
this.saveFriends()
this.saveFriend(friend)
}
},
/** 切换联系人置顶 */
async setPinned(friendUserId: number, pinned: boolean) {
async setFriendPinned(friendUserId: number, pinned: boolean) {
const requestEpoch = storeEpoch
await apiUpdateFriend({ friendUserId, pinned })
if (requestEpoch !== storeEpoch) {
@ -384,7 +433,7 @@ export const useFriendStore = defineStore('imFriendStore', {
const friend = this.getFriend(friendUserId)
if (friend) {
friend.pinned = pinned
this.saveFriends()
this.saveFriend(friend)
}
},
@ -398,7 +447,7 @@ export const useFriendStore = defineStore('imFriendStore', {
const friend = this.getFriend(friendUserId)
if (friend) {
friend.blocked = true
this.saveFriends()
this.saveFriend(friend)
}
},
@ -412,12 +461,12 @@ export const useFriendStore = defineStore('imFriendStore', {
const friend = this.getFriend(friendUserId)
if (friend) {
friend.blocked = false
this.saveFriends()
this.saveFriend(friend)
}
},
/** 修改好友展示备注(仅自己可见) */
async setDisplayName(friendUserId: number, displayName: string) {
async setFriendDisplayName(friendUserId: number, displayName: string) {
const requestEpoch = storeEpoch
const value = displayName.trim()
// 后端 displayName 语义null/undefined = 不改,"" = 清空,所以这里直接传 value可能是空串
@ -432,7 +481,7 @@ export const useFriendStore = defineStore('imFriendStore', {
conversationStore.updateConversation(ImConversationType.PRIVATE, friendUserId, {
name: getFriendDisplayName(friend)
})
this.saveFriends()
this.saveFriend(friend)
}
},
@ -458,7 +507,7 @@ export const useFriendStore = defineStore('imFriendStore', {
avatar: friend.avatar,
silent: friend.silent
})
this.saveFriends()
this.saveFriend(merged)
},
/** 本地标记删除WebSocket FRIEND_DELETE 事件触发clear=true 时级联清相关数据如私聊会话) */
@ -473,7 +522,7 @@ export const useFriendStore = defineStore('imFriendStore', {
const conversationStore = useConversationStore()
conversationStore.removePrivateConversation(friendUserId)
}
this.saveFriends()
this.saveFriend(friend)
},
// ==================== WebSocket 事件 dispatcher1201-1210 段) ====================
@ -484,7 +533,7 @@ export const useFriendStore = defineStore('imFriendStore', {
const existingIndex = this.friendRequests.findIndex((item) => item.id === payload.requestId)
if (existingIndex >= 0) {
const existing = this.friendRequests.splice(existingIndex, 1)[0]
this.friendRequests.unshift({
const next = {
...existing,
fromUserId: payload.operatorUserId,
toUserId: currentUserId,
@ -494,10 +543,12 @@ export const useFriendStore = defineStore('imFriendStore', {
createTime: Date.now(),
fromNickname: payload.fromNickname,
fromAvatar: payload.fromAvatar
})
}
this.friendRequests.unshift(next)
this.saveFriendRequest(next)
return
}
this.friendRequests.unshift({
const next = {
id: payload.requestId!,
fromUserId: payload.operatorUserId,
toUserId: currentUserId,
@ -507,7 +558,9 @@ export const useFriendStore = defineStore('imFriendStore', {
createTime: Date.now(),
fromNickname: payload.fromNickname,
fromAvatar: payload.fromAvatar
})
}
this.friendRequests.unshift(next)
this.saveFriendRequest(next)
},
/** FRIEND_REQUEST_APPROVED(1201):我的申请被同意;按 requestId 更新状态FRIEND_ADD 会另外推) */
@ -530,7 +583,7 @@ export const useFriendStore = defineStore('imFriendStore', {
* payload.friendUserIdpayload toUserId
*/
applyFriendAddNotification(_payload: FriendNotificationPayload, peerUserId: number) {
void this.loadFriendInfo(peerUserId)
void this.fetchFriendInfo(peerUserId)
},
/**
@ -546,7 +599,7 @@ export const useFriendStore = defineStore('imFriendStore', {
const friend = this.getFriend(payload.friendUserId)
if (friend) {
friend.blocked = true
this.saveFriends()
this.saveFriend(friend)
}
},
@ -555,13 +608,13 @@ export const useFriendStore = defineStore('imFriendStore', {
const friend = this.getFriend(payload.friendUserId)
if (friend) {
friend.blocked = false
this.saveFriends()
this.saveFriend(friend)
}
},
/** FRIEND_INFO_UPDATED(1209):好友资料变更(昵称 / 头像);重拉详情 */
applyFriendInfoUpdatedNotification(payload: FriendNotificationPayload) {
void this.loadFriendInfo(payload.friendUserId)
void this.fetchFriendInfo(payload.friendUserId)
},
/** FRIEND_UPDATE(1210):批量更新(备注 / 免打扰 / 联系人置顶);多端同步 */
@ -584,7 +637,7 @@ export const useFriendStore = defineStore('imFriendStore', {
name: getFriendDisplayName(friend),
silent: friend.silent
})
this.saveFriends()
this.saveFriend(friend)
},
/** 清空好友内存状态并废弃未返回请求pending Promise 置空 + storeEpoch++ */

View File

@ -9,6 +9,8 @@ import {
type ImGroupRequestRespVO
} from '@/api/im/group/request'
import { ImGroupRequestHandleResult } from '@/views/im/utils/constants'
import { getDb } from '../../utils/db'
import type { GroupRequestDO } from '../types'
/**
* IM Store
@ -17,17 +19,17 @@ import { ImGroupRequestHandleResult } from '@/views/im/utils/constants'
* / Drawer count ImGroupRespVO pendingRequestCount
*
*
* - IM fetchUnhandledList
* - WebSocket 1503 fetchOne(requestId) + push unhandledList
* - IM fetchUnhandledGroupRequestList
* - WebSocket 1503 addGroupRequestById(requestId) + push unhandledList
* - WebSocket 1505 / 1506 requestId unhandledList
* - WebSocket 1517 GROUP_ADMIN_ADD admin fetchUnhandledList
* - WebSocket 1517 GROUP_ADMIN_ADD admin fetchUnhandledGroupRequestList
* - agree / refuse requestId
*/
export const useGroupRequestStore = defineStore('imGroupRequestStore', {
state: () => ({
/** 我管理的所有群下未处理申请列表(按 id 倒序) */
unhandledList: [] as ImGroupRequestRespVO[],
/** fetchUnhandledList 是否成功执行过;避免横幅显示 0 然后跳数字的闪烁 */
/** fetchUnhandledGroupRequestList 是否成功执行过;避免横幅显示 0 然后跳数字的闪烁 */
loaded: false
}),
@ -35,7 +37,7 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', {
/**
* MapO(N) ConversationItem N N×M filter
*/
unhandledCountMap(state): Map<number, number> {
getUnhandledGroupRequestCountMap(state): 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)
@ -43,22 +45,60 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', {
return map
},
/** 指定群下的未处理申请数 */
getUnhandledCountByGroupId(): (groupId: number) => number {
return (groupId: number) => this.unhandledCountMap.get(groupId) ?? 0
getUnhandledGroupRequestCount(): (groupId: number) => number {
return (groupId: number) => this.getUnhandledGroupRequestCountMap.get(groupId) ?? 0
},
/** 指定群下的未处理申请列表 */
getUnhandledListByGroupId:
getUnhandledGroupRequestListByGroupId:
(state) =>
(groupId: number): ImGroupRequestRespVO[] =>
state.unhandledList.filter((r) => r.groupId === groupId)
},
actions: {
/** 从 IndexedDB 恢复加群申请 */
async loadGroupRequestList(): Promise<boolean> {
try {
const cached = await getDb().getAll<GroupRequestDO>('groupRequests')
if (!cached || cached.length === 0) {
return false
}
this.unhandledList = cached
.filter((request) => request.handleResult === ImGroupRequestHandleResult.UNHANDLED)
.sort((requestA, requestB) => requestB.id - requestA.id)
return true
} catch (e) {
console.warn('[IM groupRequestStore] 本地加群申请缓存读取失败', e)
return false
}
},
/** 保存加群申请列表 */
saveGroupRequestList(): void {
void getDb()
.transaction(['groupRequests'], 'readwrite', async (tx) => {
const db = getDb()
await db.clearStore('groupRequests', tx)
for (const request of this.unhandledList) {
await db.put('groupRequests', request, tx)
}
})
.catch((e) => console.warn('[IM groupRequestStore] 本地加群申请缓存写入失败', e))
},
/** 保存单条加群申请 */
saveGroupRequest(request: ImGroupRequestRespVO): void {
void getDb()
.put('groupRequests', request)
.catch((e) => console.warn('[IM groupRequestStore] 本地加群申请写入失败', e))
},
/** 拉取我管理的所有群下未处理申请;进 IM 后 / 升级 admin 后 / WS 推送有冲突时调用 */
async fetchUnhandledList() {
async fetchUnhandledGroupRequestList() {
const list = await apiGetUnhandledRequestList()
this.unhandledList = list || []
this.loaded = true
this.saveGroupRequestList()
},
/**
@ -67,33 +107,37 @@ export const useGroupRequestStore = defineStore('imGroupRequestStore', {
* group_id, user_id requestId applyContent / inviterUserId fetch +
* handleResultHTTP 1505 / 1506returnedRequest
*/
async addByRequestId(requestId: number) {
async addGroupRequestById(requestId: number) {
const request = await apiGetMyGroupRequest(requestId)
if (!request || request.handleResult !== ImGroupRequestHandleResult.UNHANDLED) {
return
}
this.unhandledList = [request, ...this.unhandledList.filter((r) => r.id !== requestId)]
this.saveGroupRequest(request)
},
/** WS 收到 1505 / 1506 或本端处理完一条:按 requestId 从列表移除 */
removeByRequestId(requestId: number) {
removeGroupRequestById(requestId: number) {
this.unhandledList = this.unhandledList.filter((r) => r.id !== requestId)
void getDb()
.delete('groupRequests', requestId)
.catch((e) => console.warn('[IM groupRequestStore] 本地加群申请删除失败', e))
},
/** 同意申请;本端处理后立即从列表移除,避免被反复点击 */
async agreeRequest(requestId: number) {
async agreeGroupRequest(requestId: number) {
await apiAgreeGroupRequest(requestId)
this.removeByRequestId(requestId)
this.removeGroupRequestById(requestId)
},
/** 拒绝申请 */
async refuseRequest(requestId: number, handleContent?: string) {
async refuseGroupRequest(requestId: number, handleContent?: string) {
await apiRefuseGroupRequest(requestId, handleContent)
this.removeByRequestId(requestId)
this.removeGroupRequestById(requestId)
},
/** 退出 IM / 切账号时清理 */
reset() {
/** 清空加群申请内存 */
clear() {
this.unhandledList = []
this.loaded = false
}

View File

@ -21,21 +21,16 @@ import {
ImMessageType
} from '../../utils/constants'
import { CommonStatusEnum } from '@/utils/constants'
import {
getCurrentUserId,
imStorage,
removeQuietly,
setQuietly,
StorageKeys
} from '../../utils/storage'
import { getDb } from '../../utils/db'
import { getCurrentUserId } from '../../utils/user'
import { getGroupDisplayName } from '../../utils/user'
import { type GroupNotificationPayload } from '../../utils/message'
import type { Group, GroupMember, Message } from '../types'
import type { Group, GroupDO, GroupMember, GroupMemberDO, Message } from '../types'
/**
* fetchGroupMembers groupId Promise
* fetchGroupMemberList groupId Promise
*
* key userId A B IIFE saveGroupMembers A B IDB
* key userId A B IIFE saveGroupMemberList A B IDB
*/
const pendingMemberFetches = new Map<string, Promise<GroupMember[]>>()
const pendingMemberKey = (userId: number, groupId: number) => `${userId}:${groupId}`
@ -50,6 +45,12 @@ const pendingSingleMemberFetches = new Map<string, Promise<GroupMember | null>>(
const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: number) =>
`${userId}:${groupId}:${memberUserId}`
/** 构建群 IndexedDB 记录 */
function buildGroupDO(group: Group): GroupDO {
const { members: _members, membersLoaded: _membersLoaded, ...record } = group
return record
}
/** 判断当前用户是否在 payload.memberUserIds 里GROUP_CREATE / INVITE / KICK 自判用) */
function isSelfInPayloadMembers(payload: GroupNotificationPayload): boolean {
const selfUserId = getCurrentUserId()
@ -67,7 +68,7 @@ function isSelfInPayloadMembers(payload: GroupNotificationPayload): boolean {
export const useGroupStore = defineStore('imGroupStore', {
state: () => ({
groups: [] as Group[],
// 仅 fetchGroups 成功后置位loadGroupsIDB不置位否则后台 SWR 刷新会被缓存命中跳过
// 仅 fetchGroupList 成功后置位loadGroupListIDB不置位否则后台 SWR 刷新会被缓存命中跳过
loaded: false
}),
@ -89,14 +90,10 @@ export const useGroupStore = defineStore('imGroupStore', {
actions: {
// ==================== 本地缓存 ====================
/** 从 IDB 恢复群列表(不带 members返回是否命中缓存 */
async loadGroups(): Promise<boolean> {
const userId = getCurrentUserId()
if (!userId) {
return false
}
/** 从 IndexedDB 恢复群列表 */
async loadGroupList(): Promise<boolean> {
try {
const cached = await imStorage.getItem<Group[]>(StorageKeys.groups(userId))
const cached = await getDb().getAll<GroupDO>('groups')
if (!cached || cached.length === 0) {
return false
}
@ -108,36 +105,39 @@ export const useGroupStore = defineStore('imGroupStore', {
}
},
/** 整桶持久化群列表;剥离 members 字段,成员另走 groupMembers:${groupId} 分桶 */
saveGroups(): void {
const userId = getCurrentUserId()
if (!userId) {
return
}
const groupsWithoutMembers = this.groups.map(({ members, ...rest }) => rest)
setQuietly(
StorageKeys.groups(userId),
groupsWithoutMembers,
'[IM groupStore] 本地群缓存写入失败'
)
/** 保存群列表 */
saveGroupList(): void {
void getDb()
.transaction(['groups'], 'readwrite', async (tx) => {
const db = getDb()
await db.clearStore('groups', tx)
for (const group of this.groups) {
await db.put('groups', buildGroupDO(group), tx)
}
})
.catch((e) => console.warn('[IM groupStore] 本地群缓存写入失败', e))
},
/** 从 IDB 恢复指定群成员;命中返回成员数组,未命中返回 null */
async loadGroupMembers(groupId: number): Promise<GroupMember[] | null> {
const userId = getCurrentUserId()
if (!userId) {
return null
/** 保存单个群 */
saveGroup(group: Group | undefined): void {
if (!group) {
return
}
// in-memory 已"完整"加载fetchGroupMembers 跑过或上次冷启动从 IDB 整桶恢复过):直接复用;
void getDb()
.put('groups', buildGroupDO(group))
.catch((e) => console.warn('[IM groupStore] 本地群写入失败', e))
},
/** 从 IndexedDB 恢复指定群成员 */
async loadGroupMemberList(groupId: number): Promise<GroupMember[] | null> {
// in-memory 已"完整"加载fetchGroupMemberList 跑过或上次冷启动从 IDB 整桶恢复过):直接复用;
// 单成员补齐fetchGroupMember写进的 partial members 不在此返回缓存——其 membersLoaded=false
const cachedGroup = this.getGroup(groupId)
if (cachedGroup?.members && cachedGroup.membersLoaded) {
return cachedGroup.members
}
try {
const cached = await imStorage.getItem<GroupMember[]>(
StorageKeys.groupMembers(userId, groupId)
)
const cached = await getDb().getAllByIndex<GroupMemberDO>('groupMembers', 'groupId', groupId)
if (!cached || cached.length === 0) {
return null
}
@ -145,7 +145,7 @@ export const useGroupStore = defineStore('imGroupStore', {
const group = this.getGroup(groupId)
if (!group) {
// group 还没就位:仅 in-memory 占位name='' 表示未知),不调 upsertGroup —— 避免把假名灌进 conversation.name + groups IDB 桶;
// 后续,等 fetchGroups 浅合并时,被真名覆盖
// 后续,等 fetchGroupList 浅合并时,被真名覆盖
this.groups.push({
id: groupId,
name: '',
@ -165,31 +165,35 @@ export const useGroupStore = defineStore('imGroupStore', {
}
},
/** 整桶持久化指定群成员 */
saveGroupMembers(groupId: number): void {
const userId = getCurrentUserId()
if (!userId) {
return
}
/** 保存指定群成员 */
saveGroupMemberList(groupId: number): void {
const members = this.getGroup(groupId)?.members
if (!members) {
return
}
setQuietly(
StorageKeys.groupMembers(userId, groupId),
members,
`[IM groupStore] 本地群成员缓存写入失败 (groupId=${groupId})`
)
void getDb()
.transaction(['groupMembers'], 'readwrite', async (tx) => {
const db = getDb()
await db.deleteByIndex('groupMembers', 'groupId', groupId, tx)
for (const member of members) {
if (member.id) {
await db.put('groupMembers', member, tx)
}
}
})
.catch((e) =>
console.warn(`[IM groupStore] 本地群成员缓存写入失败 (groupId=${groupId})`, e)
)
},
// ==================== 远端拉取 ====================
/** 拉取群列表;同步刷新对应群聊会话的展示名 / 头像 + 落 IDB */
async fetchGroups(force = false) {
async fetchGroupList(force = false) {
if (this.loaded && !force) {
return
}
// 拉取当前登录用户加入的所有群(不带成员;成员按需再走 fetchGroupMembers
// 拉取当前登录用户加入的所有群(不带成员;成员按需再走 fetchGroupMemberList
const list = await apiGetMyGroupList()
const fresh = (list || []).map(convertGroup)
// 合并而非全量替换silent / groupRemark / 成员缓存这些字段不在 ImGroupRespVO 里,得从旧 group 保留
@ -217,7 +221,7 @@ export const useGroupStore = defineStore('imGroupStore', {
silent: group.silent
})
}
this.saveGroups()
this.saveGroupList()
},
/** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert常用于 GROUP_UPDATE 推送后或手动 reload */
@ -234,7 +238,7 @@ export const useGroupStore = defineStore('imGroupStore', {
},
/** 按群拉取成员in-memory 缓存 + 并发去重force=true 强刷)+ 落 IDB */
fetchGroupMembers(groupId: number, force = false): Promise<GroupMember[]> {
fetchGroupMemberList(groupId: number, force = false): Promise<GroupMember[]> {
// in-memory "完整"加载过才命中——单成员补齐写入的 partial members 不在此返回membersLoaded=false
const cached = this.getGroup(groupId)
if (cached && cached.members && cached.membersLoaded && !force) {
@ -264,13 +268,13 @@ export const useGroupStore = defineStore('imGroupStore', {
const silent = !!meRaw?.silent
const groupRemark = meRaw?.groupRemark || ''
// 必须 await 之后重新 getGroup避免 fetchGroups 已并发写入真实 group 的 race
// 必须 await 之后重新 getGroup避免 fetchGroupList 已并发写入真实 group 的 race
const group = this.getGroup(groupId)
const isPlaceholder = !group
let groupFieldsChanged = false
if (!group) {
// group 还没就位:仅 in-memory push 占位name='' 表示未知),不调 upsertGroup——避免把假名灌进 conversation.name + groups IDB 桶;
// 后续,等 fetchGroups 浅合并时,被真名覆盖
// 后续,等 fetchGroupList 浅合并时,被真名覆盖
this.groups.push({
id: groupId,
name: '',
@ -298,9 +302,9 @@ export const useGroupStore = defineStore('imGroupStore', {
}
// groups 桶仅在 user-per-group 字段实际变化时写——避免一次批量进群引发多次整桶重写
this.saveGroupMembers(groupId)
this.saveGroupMemberList(groupId)
if (!isPlaceholder && groupFieldsChanged) {
this.saveGroups()
this.saveGroup(group)
}
return members
// 无论成功 / 失败都要从单飞表清掉,否则后续同 group 请求永远拿到这个 stale Promise
@ -314,7 +318,7 @@ export const useGroupStore = defineStore('imGroupStore', {
/**
* (groupId, memberUserId) deriveLastSenderDisplayName
*
* fetchGroupMembers me silent / groupRemark me
* fetchGroupMemberList me silent / groupRemark me
* upsert group.members IDB displayUserName
*/
fetchGroupMember(groupId: number, memberUserId: number): Promise<GroupMember | null> {
@ -341,11 +345,11 @@ export const useGroupStore = defineStore('imGroupStore', {
}
const member = convertGroupMember(data, groupId)
// 把这一条 upsert 进 group.members 仅供 in-memory 渲染兜底group 还没就位则用 placeholder
// 注意:不写 IDB——成员桶语义是"全量",存"1 人桶"会污染下次冷启动的 loadGroupMembers
// 注意:不写 IDB——成员桶语义是"全量",存"1 人桶"会污染下次冷启动的 loadGroupMemberList
const group = this.getGroup(groupId)
if (!group) {
// memberCount 不设:后续 fetchGroups 合并 `existing.memberCount ?? fresh.memberCount` 时,
// 占位值会顶替真实值fresh 不带 memberCount等 fetchGroupMembers 跑过才能拿到真实数
// memberCount 不设:后续 fetchGroupList 合并 `existing.memberCount ?? fresh.memberCount` 时,
// 占位值会顶替真实值fresh 不带 memberCount等 fetchGroupMemberList 跑过才能拿到真实数
this.groups.push({
id: groupId,
name: '',
@ -384,7 +388,7 @@ export const useGroupStore = defineStore('imGroupStore', {
silent: merged.silent
})
// 持久化到 IDBfire-and-forget
this.saveGroups()
this.saveGroup(merged)
},
/** 本地移除(由 WebSocket GROUP_DEL 事件触发) */
@ -393,19 +397,17 @@ export const useGroupStore = defineStore('imGroupStore', {
this.groups = this.groups.filter((g) => g.id !== id)
const conversationStore = useConversationStore()
conversationStore.removeGroupConversation(id)
this.saveGroups()
// 群解散后顺手删 IDB 里该群的成员桶——这桶仅靠 groupId 索引,不删会一直留在 IDB 占空间
const userId = getCurrentUserId()
if (userId) {
removeQuietly(
StorageKeys.groupMembers(userId, id),
`[IM groupStore] 群成员缓存删除失败 (groupId=${id})`
)
}
void getDb()
.transaction(['groups', 'groupMembers'], 'readwrite', async (tx) => {
const db = getDb()
await db.delete('groups', id, tx)
await db.deleteByIndex('groupMembers', 'groupId', id, tx)
})
.catch((e) => console.warn(`[IM groupStore] 群缓存删除失败 (groupId=${id})`, e))
},
/** 切换免打扰:推后端 + 落本地 + 同步会话列表的 silent避免 silent 图标 / 总未读 / 提示音判断与设置漂移;和 friendStore.setSilent 对齐 */
async setSilent(id: number, silent: boolean) {
/** 切换免打扰:推后端 + 落本地 + 同步会话列表的 silent避免 silent 图标 / 总未读 / 提示音判断与设置漂移;和 friendStore.setFriendSilent 对齐 */
async setGroupSilent(id: number, silent: boolean) {
await apiUpdateGroupMember({ groupId: id, silent })
const group = this.getGroup(id)
if (!group) {
@ -414,11 +416,11 @@ export const useGroupStore = defineStore('imGroupStore', {
group.silent = silent
const conversationStore = useConversationStore()
conversationStore.updateConversation(ImConversationType.GROUP, id, { silent })
this.saveGroups()
this.saveGroup(group)
},
/** 批量更新群成员角色;本地不命中则忽略,等 fetchGroupMembers 兜底 */
updateMembersRole(groupId: number, userIds: number[], role: number) {
/** 批量更新群成员角色;本地不命中则忽略,等 fetchGroupMemberList 兜底 */
updateGroupMemberRoleList(groupId: number, userIds: number[], role: number) {
const group = this.getGroup(groupId)
if (!group?.members?.length) {
return
@ -436,11 +438,12 @@ export const useGroupStore = defineStore('imGroupStore', {
// 有变化才整组替换,让响应式只在真有更新时通知下游
if (changed) {
group.members = newMembers
this.saveGroupMemberList(groupId)
}
},
/** 群主转让:群表 ownerUserId 改为新值;旧群主 role → NORMAL新群主 role → OWNER */
transferOwner(groupId: number, oldOwnerId: number, newOwnerId: number) {
transferGroupOwner(groupId: number, oldOwnerId: number, newOwnerId: number) {
const group = this.getGroup(groupId)
if (!group) {
return
@ -448,13 +451,13 @@ export const useGroupStore = defineStore('imGroupStore', {
if (group.ownerUserId !== newOwnerId) {
group.ownerUserId = newOwnerId
}
this.updateMembersRole(groupId, [oldOwnerId], ImGroupMemberRole.NORMAL)
this.updateMembersRole(groupId, [newOwnerId], ImGroupMemberRole.OWNER)
this.saveGroups()
this.updateGroupMemberRoleList(groupId, [oldOwnerId], ImGroupMemberRole.NORMAL)
this.updateGroupMemberRoleList(groupId, [newOwnerId], ImGroupMemberRole.OWNER)
this.saveGroup(group)
},
/** 本地剔除群成员GROUP_MEMBER_QUIT / KICK 事件);不命中则等 fetchGroupMembers 兜底 */
removeMembersLocal(groupId: number, userIds: number[]) {
/** 本地剔除群成员GROUP_MEMBER_QUIT / KICK 事件);不命中则等 fetchGroupMemberList 兜底 */
removeLocalGroupMemberList(groupId: number, userIds: number[]) {
const group = this.getGroup(groupId)
if (!group?.members?.length || !userIds.length) {
return
@ -466,32 +469,32 @@ export const useGroupStore = defineStore('imGroupStore', {
}
group.members = next
group.memberCount = next.length
this.saveGroupMembers(groupId)
this.saveGroupMemberList(groupId)
},
/** 本地更新群成员的 status自己退群 / 被踢的本地预置;让 isMember 立即收敛到 stranger不依赖 removeGroup 的整群移除) */
updateMemberStatus(groupId: number, userId: number, status: number) {
updateGroupMemberStatus(groupId: number, userId: number, status: number) {
const group = this.getGroup(groupId)
const member = group?.members?.find((m) => m.userId === userId)
if (!member || member.status === status) {
return
}
member.status = status
this.saveGroupMembers(groupId)
this.saveGroupMemberList(groupId)
},
/** 本地更新群成员的 displayUserNameGROUP_MEMBER_NICKNAME_UPDATE 事件);不命中则等 fetchGroupMembers 兜底 */
updateMemberDisplayUserName(groupId: number, userId: number, displayUserName: string) {
/** 本地更新群成员的 displayUserNameGROUP_MEMBER_NICKNAME_UPDATE 事件);不命中则等 fetchGroupMemberList 兜底 */
updateGroupMemberDisplayUserName(groupId: number, userId: number, displayUserName: string) {
const group = this.getGroup(groupId)
const member = group?.members?.find((m) => m.userId === userId)
if (!member || member.displayUserName === displayUserName) {
return
}
member.displayUserName = displayUserName
this.saveGroupMembers(groupId)
this.saveGroupMemberList(groupId)
},
/** 局部更新群字段name / notice / avatar 等);未命中本地缓存时静默忽略,等 fetchGroups 兜底;新值跟旧值都相同时跳过响应式 + IDB 写 */
/** 局部更新群字段name / notice / avatar 等);未命中本地缓存时静默忽略,等 fetchGroupList 兜底;新值跟旧值都相同时跳过响应式 + IDB 写 */
updateGroupFields(groupId: number, fields: Partial<Group>) {
const group = this.getGroup(groupId)
if (!group) {
@ -508,14 +511,14 @@ export const useGroupStore = defineStore('imGroupStore', {
avatar: group.avatar,
silent: group.silent
})
this.saveGroups()
this.saveGroup(group)
},
/**
* GROUP_* 广 type action
*
* WebSocket + useMessagePuller 线 pull messageStore.insertMessage
* store fetchGroups
* store fetchGroupList
*/
applyGroupNotification(groupId: number, type: number, content?: string) {
if (!groupId) {
@ -564,16 +567,16 @@ export const useGroupStore = defineStore('imGroupStore', {
this.applyGroupMemberNicknameUpdateNotification(groupId, payload)
break
case ImMessageType.GROUP_ADMIN_ADD:
this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.ADMIN)
this.updateGroupMemberRoleList(groupId, payload.memberUserIds || [], ImGroupMemberRole.ADMIN)
// 自己被加为管理员,原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
if (isSelfInPayloadMembers(payload)) {
useGroupRequestStore()
.fetchUnhandledList()
.fetchUnhandledGroupRequestList()
.catch(() => undefined)
}
break
case ImMessageType.GROUP_ADMIN_REMOVE:
this.updateMembersRole(groupId, payload.memberUserIds || [], ImGroupMemberRole.NORMAL)
this.updateGroupMemberRoleList(groupId, payload.memberUserIds || [], ImGroupMemberRole.NORMAL)
break
case ImMessageType.GROUP_OWNER_TRANSFER:
this.applyGroupOwnerTransferNotification(groupId, payload)
@ -612,9 +615,9 @@ export const useGroupStore = defineStore('imGroupStore', {
if (selfIsOperator && this.getGroup(groupId)) {
return
}
// 先 await fetchGroupInfo 把群 upsert 进 state.groups否则 fetchGroupMembers 的「不是我加入的群」guard 会兜空
// 先 await fetchGroupInfo 把群 upsert 进 state.groups否则 fetchGroupMemberList 的「不是我加入的群」guard 会兜空
await this.fetchGroupInfo(groupId)
this.fetchGroupMembers(groupId, true).catch(() => undefined)
this.fetchGroupMemberList(groupId, true).catch(() => undefined)
},
/** 群名变更:按 newName 局部更新本地群名 */
@ -642,31 +645,31 @@ export const useGroupStore = defineStore('imGroupStore', {
/** 成员加入:被邀请者本端 group 未就位先 fetchGroupInfo 初次拉取;所有人都刷成员列表(新成员 nickname / avatar 不在 payload */
async applyGroupMemberInviteNotification(groupId: number, payload: GroupNotificationPayload) {
// 自己刚被拉进来:必须 await fetchGroupInfo 让群入 state.groups否则 fetchGroupMembers 的 guard 会兜空
// 自己刚被拉进来:必须 await fetchGroupInfo 让群入 state.groups否则 fetchGroupMemberList 的 guard 会兜空
if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) {
await this.fetchGroupInfo(groupId)
}
this.fetchGroupMembers(groupId, true).catch(() => undefined)
this.fetchGroupMemberList(groupId, true).catch(() => undefined)
},
/** 自由进群:进群者本端 group 未就位先 fetchGroupInfo 初次拉取;所有人都刷成员列表 */
async applyGroupMemberEnterNotification(groupId: number, payload: GroupNotificationPayload) {
const selfUserId = getCurrentUserId()
// 自己自由进群:必须 await fetchGroupInfo 让群入 state.groups否则 fetchGroupMembers 的 guard 会兜空
// 自己自由进群:必须 await fetchGroupInfo 让群入 state.groups否则 fetchGroupMemberList 的 guard 会兜空
if (selfUserId && payload.entrantUserId === selfUserId && !this.getGroup(groupId)) {
await this.fetchGroupInfo(groupId)
}
this.fetchGroupMembers(groupId, true).catch(() => undefined)
this.fetchGroupMemberList(groupId, true).catch(() => undefined)
},
/** 成员退群:退群者本人先把 self.status 置 DISABLE 再 removeGroup保留状态语义 + 维持 groups 列表干净);其他成员从本地列表移除 quitter */
applyGroupMemberQuitNotification(groupId: number, payload: GroupNotificationPayload) {
const selfUserId = getCurrentUserId()
if (selfUserId && payload.operatorUserId === selfUserId) {
this.updateMemberStatus(groupId, selfUserId, CommonStatusEnum.DISABLE)
this.updateGroupMemberStatus(groupId, selfUserId, CommonStatusEnum.DISABLE)
this.removeGroup(groupId)
} else if (payload.operatorUserId) {
this.removeMembersLocal(groupId, [payload.operatorUserId])
this.removeLocalGroupMemberList(groupId, [payload.operatorUserId])
}
},
@ -676,18 +679,18 @@ export const useGroupStore = defineStore('imGroupStore', {
const selfUserId = getCurrentUserId()
if (isSelfInPayloadMembers(payload)) {
if (selfUserId) {
this.updateMemberStatus(groupId, selfUserId, CommonStatusEnum.DISABLE)
this.updateGroupMemberStatus(groupId, selfUserId, CommonStatusEnum.DISABLE)
}
this.removeGroup(groupId)
} else if (memberIds.length) {
this.removeMembersLocal(groupId, memberIds)
this.removeLocalGroupMemberList(groupId, memberIds)
}
},
/** 成员昵称变更:按 operatorUserId 局部更新对应 member.displayUserName */
applyGroupMemberNicknameUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
if (payload.operatorUserId) {
this.updateMemberDisplayUserName(
this.updateGroupMemberDisplayUserName(
groupId,
payload.operatorUserId,
payload.displayUserName ?? ''
@ -698,13 +701,13 @@ export const useGroupStore = defineStore('imGroupStore', {
/** 群主转让:旧群主 → NORMAL新群主 → OWNER新群主自己侧重新拉申请列表 */
applyGroupOwnerTransferNotification(groupId: number, payload: GroupNotificationPayload) {
if (payload.operatorUserId && payload.newOwnerUserId) {
this.transferOwner(groupId, payload.operatorUserId, payload.newOwnerUserId)
this.transferGroupOwner(groupId, payload.operatorUserId, payload.newOwnerUserId)
}
// 自己接管群主:原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
const selfUserId = getCurrentUserId()
if (selfUserId && payload.newOwnerUserId === selfUserId) {
useGroupRequestStore()
.fetchUnhandledList()
.fetchUnhandledGroupRequestList()
.catch(() => undefined)
}
},
@ -740,7 +743,7 @@ export const useGroupStore = defineStore('imGroupStore', {
receiverUserIds: message.receiverUserIds ? [...message.receiverUserIds] : []
}
]
this.saveGroups()
this.saveGroup(group)
},
/** 群消息取消置顶:按 messageId 从本地置顶列表中移除 */
@ -757,7 +760,7 @@ export const useGroupStore = defineStore('imGroupStore', {
return
}
group.pinnedMessages = newPinnedMessages
this.saveGroups()
this.saveGroup(group)
},
/** 单成员禁言:更新目标成员的 muteEndTime */
@ -766,7 +769,7 @@ export const useGroupStore = defineStore('imGroupStore', {
const member = group?.members?.find((m) => m.userId === payload.mutedUserId)
if (member && payload.muteEndTime) {
member.muteEndTime = payload.muteEndTime
this.saveGroupMembers(groupId)
this.saveGroupMemberList(groupId)
}
},
@ -776,7 +779,7 @@ export const useGroupStore = defineStore('imGroupStore', {
const member = group?.members?.find((m) => m.userId === payload.mutedUserId)
if (member) {
member.muteEndTime = undefined
this.saveGroupMembers(groupId)
this.saveGroupMemberList(groupId)
}
},

View File

@ -16,6 +16,7 @@ import {
getServerMessageKey,
parseClientConversationId,
setMessageMaxId,
StorageKeys,
type DbTransaction
} from '../../utils/db'
import {
@ -24,7 +25,7 @@ import {
revokeBlobUrlsInContent
} from '../../utils/message'
import { resolveConversationLastContent } from '../../utils/conversation'
import { getCurrentUserId } from '../../utils/storage'
import { getCurrentUserId } from '../../utils/user'
import { tryGetSenderDisplayName } from '../../utils/user'
import { useGroupStore } from './groupStore'
import { useConversationStore } from './conversationStore'
@ -42,6 +43,10 @@ interface MessageConversationInfo {
silent?: boolean
}
interface PersistMessageRecordOptions {
mergeClientRecord?: boolean
}
/** 拉取消息批量处理项 */
export type PulledMessage =
| {
@ -132,7 +137,7 @@ function deriveLastSenderDisplayName(
const group = groupStore.getGroup(conversation.targetId)
const fetchPromise = group?.membersLoaded
? groupStore.fetchGroupMember(conversation.targetId, senderId)
: groupStore.fetchGroupMembers(conversation.targetId)
: groupStore.fetchGroupMemberList(conversation.targetId)
fetchPromise.catch((e) =>
console.warn(
'[IM messageStore] 兜底拉群成员失败',
@ -263,12 +268,12 @@ export const useMessageStore = defineStore('imMessageStore', {
},
/** 从 settings 加载消息游标 */
async loadMessageCursors() {
async loadMessageCursorList() {
const db = getDb()
const [privateMaxId, groupMaxId, channelMaxId] = await Promise.all([
db.getSetting<number>('privateMessageMaxId'),
db.getSetting<number>('groupMessageMaxId'),
db.getSetting<number>('channelMessageMaxId')
db.getSetting<number>(StorageKeys.settings.privateMessageMaxId),
db.getSetting<number>(StorageKeys.settings.groupMessageMaxId),
db.getSetting<number>(StorageKeys.settings.channelMessageMaxId)
])
this.privateMessageMaxId = privateMaxId || 0
this.groupMessageMaxId = groupMaxId || 0
@ -276,7 +281,7 @@ export const useMessageStore = defineStore('imMessageStore', {
},
/** 更新内存游标 */
updateMessageMaxId(conversationType: number, messageId?: number) {
updateMessageCursor(conversationType: number, messageId?: number) {
if (!messageId) {
return
}
@ -311,7 +316,7 @@ export const useMessageStore = defineStore('imMessageStore', {
},
/** 加载当前会话最近消息 */
async loadMoreMessages(
async loadMoreMessageList(
clientConversationId: string,
beforeSendTime?: number,
limit = 50
@ -340,13 +345,13 @@ export const useMessageStore = defineStore('imMessageStore', {
},
/** 确保会话消息已加载 */
async ensureConversationMessagesLoaded(conversation: Conversation) {
async ensureConversationMessageListLoaded(conversation: Conversation) {
const key = getMessageCacheKey(conversation.type, conversation.targetId)
if (this.messagesByConversation[key]) {
this.touchConversationMessageCache(key)
return
}
await this.loadMoreMessages(key)
await this.loadMoreMessageList(key)
},
/** 获取内存消息数组 */
@ -360,11 +365,16 @@ export const useMessageStore = defineStore('imMessageStore', {
},
/** 持久化消息记录 */
async persistMessageRecord(message: Message, conversationType: number, tx?: DbTransaction) {
async saveMessageRecord(
message: Message,
conversationType: number,
tx?: DbTransaction,
options?: PersistMessageRecordOptions
) {
const db = getDb()
const next = buildMessageDO(message, conversationType)
// ack 后服务端 key 替换 client key
if (message.id && message.clientMessageId) {
// 服务端 key 替换 client key
if (options?.mergeClientRecord && message.id && message.clientMessageId) {
const existing = await db.getByIndex<MessageDO>(
'messages',
'clientMessageId',
@ -379,8 +389,8 @@ export const useMessageStore = defineStore('imMessageStore', {
},
/** 保存消息游标 */
async saveMessageMaxId(conversationType: number, messageId?: number, tx?: DbTransaction) {
this.updateMessageMaxId(conversationType, messageId)
async saveMessageCursor(conversationType: number, messageId?: number, tx?: DbTransaction) {
this.updateMessageCursor(conversationType, messageId)
await setMessageMaxId(conversationType, messageId, tx)
},
@ -423,14 +433,21 @@ export const useMessageStore = defineStore('imMessageStore', {
) {
if (pulledMessages.length === 0) {
// 1. 空批次只推进游标
await this.saveMessageMaxId(conversationType, maxMessageId)
await this.saveMessageCursor(conversationType, maxMessageId)
return
}
const conversationStore = useConversationStore()
const persistedMessages = new Map<string, { message: Message; conversationType: number }>()
const persistedMessages = new Map<
string,
{ message: Message; conversationType: number; mergeClientRecord?: boolean }
>()
const changedConversations = new Map<string, Conversation>()
const addChanged = (conversation: Conversation, message: Message) => {
const addChanged = (
conversation: Conversation,
message: Message,
options?: PersistMessageRecordOptions
) => {
const clientConversationId = getClientConversationId(
conversation.type,
conversation.targetId
@ -438,7 +455,8 @@ export const useMessageStore = defineStore('imMessageStore', {
changedConversations.set(clientConversationId, conversation)
persistedMessages.set(getMessageKey(message, conversation.type), {
message,
conversationType: conversation.type
conversationType: conversation.type,
mergeClientRecord: options?.mergeClientRecord
})
}
@ -458,6 +476,7 @@ export const useMessageStore = defineStore('imMessageStore', {
}
const { conversationInfo } = pulledMessage
const hasServerClientMessageId = !!pulledMessage.message.clientMessageId
const message = ensureClientMessageId(pulledMessage.message)
// 1.2 群通知先同步群资料
if (
@ -482,8 +501,10 @@ export const useMessageStore = defineStore('imMessageStore', {
recomputeConversationLast(conversation, messages)
syncConversationAtFlags(conversation, message)
}
this.updateMessageMaxId(conversationInfo.type, message.id)
addChanged(conversation, messages[existingIndex])
this.updateMessageCursor(conversationInfo.type, message.id)
addChanged(conversation, messages[existingIndex], {
mergeClientRecord: hasServerClientMessageId
})
continue
}
@ -515,21 +536,25 @@ export const useMessageStore = defineStore('imMessageStore', {
}
}
messages.splice(insertIndex, 0, message)
this.updateMessageMaxId(conversationInfo.type, message.id)
addChanged(conversation, message)
this.updateMessageCursor(conversationInfo.type, message.id)
addChanged(conversation, message, {
mergeClientRecord: hasServerClientMessageId && !!message.id
})
}
// 2. 更新内存游标
this.updateMessageMaxId(conversationType, maxMessageId)
this.updateMessageCursor(conversationType, maxMessageId)
// 3. 单事务写入消息、会话摘要和游标
await getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
// 3.1 写入本批变更消息
for (const item of persistedMessages.values()) {
await this.persistMessageRecord(item.message, item.conversationType, tx)
await this.saveMessageRecord(item.message, item.conversationType, tx, {
mergeClientRecord: item.mergeClientRecord
})
}
// 3.2 写入本批变更会话
await conversationStore.persistConversationRecords([...changedConversations.values()], tx)
await conversationStore.saveConversationRecord([...changedConversations.values()], tx)
// 3.3 写入本批游标
await setMessageMaxId(conversationType, maxMessageId, tx)
})
@ -543,6 +568,7 @@ export const useMessageStore = defineStore('imMessageStore', {
options?: { saveMaxId?: boolean }
) {
const conversationStore = useConversationStore()
const hasIncomingClientMessageId = !!messageInfo.clientMessageId
const message = ensureClientMessageId(messageInfo)
// 1. 先处理消息带来的群资料变更
if (conversationInfo.type === ImConversationType.GROUP && isGroupNotification(message.type)) {
@ -564,11 +590,13 @@ export const useMessageStore = defineStore('imMessageStore', {
recomputeConversationLast(conversation, messages)
syncConversationAtFlags(conversation, message)
}
this.updateMessageMaxId(conversationInfo.type, message.id)
this.updateMessageCursor(conversationInfo.type, message.id)
void getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
await this.persistMessageRecord(messages[existingIndex], conversationInfo.type, tx)
await conversationStore.persistConversationRecords(conversation, tx)
await this.saveMessageRecord(messages[existingIndex], conversationInfo.type, tx, {
mergeClientRecord: hasIncomingClientMessageId
})
await conversationStore.saveConversationRecord(conversation, tx)
if (options?.saveMaxId !== false) {
await setMessageMaxId(conversationInfo.type, message.id, tx)
}
@ -606,12 +634,14 @@ export const useMessageStore = defineStore('imMessageStore', {
}
}
messages.splice(insertIndex, 0, message)
this.updateMessageMaxId(conversationInfo.type, message.id)
this.updateMessageCursor(conversationInfo.type, message.id)
// 6. 单事务写入消息、会话摘要和游标
void getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
await this.persistMessageRecord(message, conversationInfo.type, tx)
await conversationStore.persistConversationRecords(conversation, tx)
await this.saveMessageRecord(message, conversationInfo.type, tx, {
mergeClientRecord: hasIncomingClientMessageId && !!message.id
})
await conversationStore.saveConversationRecord(conversation, tx)
if (options?.saveMaxId !== false) {
await setMessageMaxId(conversationInfo.type, message.id, tx)
}
@ -668,12 +698,14 @@ export const useMessageStore = defineStore('imMessageStore', {
if (messages[messages.length - 1] === message) {
recomputeConversationLast(conversation, messages)
}
this.updateMessageMaxId(conversationType, message.id)
this.updateMessageCursor(conversationType, message.id)
// 3. 单事务写入消息、会话摘要和游标
await getDb()
.transaction(['messages', 'conversations', 'settings'], 'readwrite', async (tx) => {
await this.persistMessageRecord(message, conversationType, tx)
await conversationStore.persistConversationRecords(conversation, tx)
await this.saveMessageRecord(message, conversationType, tx, {
mergeClientRecord: true
})
await conversationStore.saveConversationRecord(conversation, tx)
await setMessageMaxId(conversationType, message.id, tx)
})
.catch((e) => console.error('[IM messageStore] ack 写入失败', e))
@ -723,7 +755,7 @@ export const useMessageStore = defineStore('imMessageStore', {
if (!changed) {
return
}
this.persistMessageRecord(changed.message, conversationType).catch((e) =>
this.saveMessageRecord(changed.message, conversationType).catch((e) =>
console.error('[IM messageStore] 撤回消息写入失败', e)
)
conversationStore.saveConversation(changed.conversation)
@ -773,7 +805,7 @@ export const useMessageStore = defineStore('imMessageStore', {
void getDb()
.transaction(['messages'], 'readwrite', async (tx) => {
for (const message of changed) {
await this.persistMessageRecord(message, options.conversationType, tx)
await this.saveMessageRecord(message, options.conversationType, tx)
}
})
.catch((e) => console.warn('[IM messageStore] 回执写入失败', e))
@ -789,7 +821,7 @@ export const useMessageStore = defineStore('imMessageStore', {
const fresh = earlierMessages
.map(ensureClientMessageId)
.filter((message) => message.id && !existingIds.has(message.id))
.sort((a, b) => (a.id || 0) - (b.id || 0))
.sort((messageA, messageB) => (messageA.id || 0) - (messageB.id || 0))
if (fresh.length === 0) {
return
}
@ -798,7 +830,7 @@ export const useMessageStore = defineStore('imMessageStore', {
void getDb()
.transaction(['messages'], 'readwrite', async (tx) => {
for (const message of fresh) {
await this.persistMessageRecord(message, conversationType, tx)
await this.saveMessageRecord(message, conversationType, tx)
}
})
.catch((e) => console.warn('[IM messageStore] 历史消息写入失败', e))
@ -840,20 +872,29 @@ export const useMessageStore = defineStore('imMessageStore', {
},
/** 当前会话标记已读 */
markConversationMessagesRead(conversation: Conversation) {
markConversationMessageListRead(conversation: Conversation) {
const messages = this.getMessageList(conversation.type, conversation.targetId)
const changed: Message[] = []
messages.forEach((message) => {
if (!message.selfSend && message.status === ImMessageStatus.UNREAD) {
message.status = ImMessageStatus.READ
this.persistMessageRecord(message, conversation.type).catch((e) =>
console.warn('[IM messageStore] 已读状态写入失败', e)
)
changed.push(message)
}
})
if (changed.length === 0) {
return
}
void getDb()
.transaction(['messages'], 'readwrite', async (tx) => {
for (const message of changed) {
await this.saveMessageRecord(message, conversation.type, tx)
}
})
.catch((e) => console.warn('[IM messageStore] 已读状态写入失败', e))
},
/** 删除会话全部消息 */
deleteConversationMessages(conversationType: number, targetId: number) {
deleteConversationMessageList(conversationType: number, targetId: number) {
// 1. 清理内存消息和媒体资源
const clientConversationId = getClientConversationId(conversationType, targetId)
const messages = this.messagesByConversation[clientConversationId] || []

View File

@ -10,7 +10,7 @@ import {
type ImRtcParticipantStatusValue,
type ImRtcCallStageValue
} from '../../utils/constants'
import { getCurrentUserId } from '../../utils/storage'
import { getCurrentUserId } from '../../utils/user'
import { useFriendStore } from './friendStore'
import { useGroupStore } from './groupStore'

View File

@ -506,7 +506,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
// 未知对端(陌生人加好友前先收到消息等场景):异步补拉一次,下次再渲染就有 name/avatar
const friend = friendStore.getFriend(peerId)
if (!friend) {
friendStore.loadFriendInfo(peerId).catch(() => undefined)
friendStore.fetchFriendInfo(peerId).catch(() => undefined)
}
// 会话标题永远跟「对端」走(不管谁发的消息);这里只算一次给 insertMessage 用
const peerDisplayName = friend ? getFriendDisplayName(friend) : ''
@ -544,9 +544,9 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
if (isActive) {
// 聊天窗口打开 = 实际看到了:本端清未读;私聊已读开启时再上报后端,让对方 UI 立刻切到"已读"
// 已读位置直接用刚到的消息 id这条就是当前会话最大 id
conversationStore.markConversationAsRead(ImConversationType.PRIVATE, peerId)
conversationStore.markConversationRead(ImConversationType.PRIVATE, peerId)
if (conversation) {
useMessageStore().markConversationMessagesRead(conversation)
useMessageStore().markConversationMessageListRead(conversation)
}
if (MESSAGE_PRIVATE_READ_ENABLED) {
apiReadPrivateMessages(peerId, websocketMessage.id).catch((e) => {
@ -566,7 +566,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
return
}
const conversationStore = useConversationStore()
conversationStore.markConversationAsRead(
conversationStore.markConversationRead(
ImConversationType.PRIVATE,
websocketMessage.receiverId
)
@ -672,12 +672,12 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
conversationStore.activeConversation?.targetId === websocketMessage.groupId
if (isActive) {
// 群已读上报需要带 messageId群消息以"读到第几条"的游标为准,区别于私聊只标 receiverId群已读关闭时仅本地清零
conversationStore.markConversationAsRead(
conversationStore.markConversationRead(
ImConversationType.GROUP,
websocketMessage.groupId
)
if (conversation) {
useMessageStore().markConversationMessagesRead(conversation)
useMessageStore().markConversationMessageListRead(conversation)
}
if (MESSAGE_GROUP_READ_ENABLED) {
apiReadGroupMessages(websocketMessage.groupId, websocketMessage.id).catch((e) => {
@ -699,7 +699,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
return
}
const conversationStore = useConversationStore()
conversationStore.markConversationAsRead(ImConversationType.GROUP, websocketMessage.groupId)
conversationStore.markConversationRead(ImConversationType.GROUP, websocketMessage.groupId)
},
/** 群聊 RECEIPT更新某条群消息的 readCount / receiptStatus群已读关闭时兜底忽略 */
@ -796,11 +796,11 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
const groupRequestStore = useGroupRequestStore()
switch (websocketMessage.type) {
case ImMessageType.GROUP_REQUEST_RECEIVED:
groupRequestStore.addByRequestId(payload.requestId).catch(() => undefined)
groupRequestStore.addGroupRequestById(payload.requestId).catch(() => undefined)
break
case ImMessageType.GROUP_REQUEST_APPROVED:
case ImMessageType.GROUP_REQUEST_REJECTED:
groupRequestStore.removeByRequestId(payload.requestId)
groupRequestStore.removeGroupRequestById(payload.requestId)
break
default:
break
@ -812,7 +812,7 @@ export const useImWebSocketStore = defineStore('imWebSocketStore', {
/**
* GROUP_MEMBER_SETTING_UPDATEsilent / groupRemark
*
* payload null fetchGroupMembers
* payload null fetchGroupMemberList
*/
handleGroupMemberSettingUpdate(websocketMessage: ImGroupMessageDTO) {
// content 解析失败由外层 dispatchGroupFrame 的 try-catch 兜底(含 websocketMessage 打印),不重复 catch

View File

@ -146,10 +146,12 @@ export interface Group {
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
members?: GroupMember[] // 群成员缓存(按需懒加载)
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMembers / fetchGroupMembers 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMembers(force=false) 命中缓存时误判整群已加载
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 truefetchGroupMember 单成员补齐不置位,避免 fetchGroupMemberList(force=false) 命中缓存时误判整群已加载
memberCount?: number // 成员总数
}
export type GroupDO = Omit<Group, 'members' | 'membersLoaded'>
// 群成员实体(前端内部结构)
export interface GroupMember {
// ========== 后端字段(对齐 ImGroupMemberRespVO ==========
@ -167,6 +169,8 @@ export interface GroupMember {
isOwner?: boolean // 是否群主(前端从 Group.ownerUserId 计算)
}
export type GroupMemberDO = GroupMember
// ==================== 好友 ====================
// 好友实体(前端内部结构)
@ -188,6 +192,8 @@ export interface Friend {
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 convertFriend 转换)
}
export type FriendDO = Friend
/**
* ImFriendRequestRespVO
*/
@ -210,6 +216,12 @@ export interface FriendRequest {
toAvatar?: string // 接收方头像
}
export type FriendRequestDO = FriendRequest
export type GroupRequestDO = import('@/api/im/group/request').ImGroupRequestRespVO
export type ChannelDO = import('@/api/im/manager/channel').ImManagerChannelVO
// ==================== 用户名片 ====================
// 用户精简信息(对齐后端 UserSimpleRespVO名片 / 头像 hover 等场景共用)
@ -227,7 +239,7 @@ export interface User {
/**
* Friend
* - id friendUserId click / Friend.id
* - status === DISABLE friendStore.getActiveFriends / getActiveFriendsLite
* - status === DISABLE friendStore.getActiveFriendList / getActiveFriendLiteList
*/
export interface FriendLite {
id: number

View File

@ -1,6 +1,6 @@
import { toRaw } from 'vue'
import { getCurrentUserId } from './storage'
import { getCurrentUserId } from './user'
import { ImConversationType } from './constants'
import type { MessageDO, SettingDO } from '../home/types'
@ -19,6 +19,26 @@ export type DbStoreName =
export type DbTransaction = IDBTransaction
/** IM 本地存储 key */
export const StorageKeys = {
localStorage: {
/** 侧边栏宽度,三个 Tab 共用一份记忆 */
asideWidth: 'im:aside',
/** 会话列表置顶折叠展开态 */
conversationPinnedExpanded: 'im:conversation:pinnedExpanded'
},
settings: {
/** 私聊消息拉取游标 */
privateMessageMaxId: 'privateMessageMaxId',
/** 群聊消息拉取游标 */
groupMessageMaxId: 'groupMessageMaxId',
/** 频道消息拉取游标 */
channelMessageMaxId: 'channelMessageMaxId',
/** 最近转发会话 key 列表 */
recentForwardConversationKeys: 'recentForwardConversationKeys'
}
} as const
let currentDb: IDBDatabase | null = null
let currentUserId: number | null = null
let currentSession = 0
@ -243,6 +263,15 @@ class DbClient {
)
}
/** 清空 store 记录 */
async clearStore(storeName: DbStoreName, tx?: DbTransaction): Promise<void> {
if (tx) {
await requestToPromise(tx.objectStore(storeName).clear())
return
}
await this.transaction([storeName], 'readwrite', (tx) => this.clearStore(storeName, tx))
}
/** 按索引删除记录 */
async deleteByIndex(
storeName: DbStoreName,
@ -422,13 +451,13 @@ export async function setMessageMaxId(
let key: string
switch (conversationType) {
case ImConversationType.PRIVATE:
key = 'privateMessageMaxId'
key = StorageKeys.settings.privateMessageMaxId
break
case ImConversationType.GROUP:
key = 'groupMessageMaxId'
key = StorageKeys.settings.groupMessageMaxId
break
case ImConversationType.CHANNEL:
key = 'channelMessageMaxId'
key = StorageKeys.settings.channelMessageMaxId
break
default:
throw new Error(`未知 IM 会话类型:${conversationType}`)
@ -442,6 +471,7 @@ export async function setMessageMaxId(
/** 停止当前 IM DB session */
export async function stopRequests(): Promise<void> {
currentSession++
const [
{ useMessageStoreWithOut },
{ useConversationStoreWithOut },
@ -459,13 +489,12 @@ export async function stopRequests(): Promise<void> {
import('../home/store/groupRequestStore'),
import('../home/store/faceStore')
])
currentSession++
useMessageStoreWithOut().clear()
useConversationStoreWithOut().clear()
useFriendStoreWithOut().clear()
useGroupStoreWithOut().clear()
useChannelStoreWithOut().clear()
useGroupRequestStoreWithOut().reset()
useFaceStoreWithOut().reset()
useGroupRequestStoreWithOut().clear()
useFaceStoreWithOut().clear()
closeDbConnection()
}

View File

@ -6,7 +6,7 @@ import {
ImMessageType,
type ImConversationTypeValue
} from './constants'
import { getCurrentUserId } from './storage'
import { getCurrentUserId } from './user'
import { formatCallDuration } from './time'
import { useFriendStore } from '../home/store/friendStore'
import { useGroupStore } from '../home/store/groupStore'

View File

@ -1,83 +0,0 @@
import localforage from 'localforage'
import { toRaw } from 'vue'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
/**
* IM localforage IndexedDB WebSQL / localStorage
*/
export const imStorage = localforage.createInstance({
name: 'im',
storeName: 'conversation',
description: 'IM 本地缓存'
})
/**
* key
*
* - / imStorageIndexedDBkey userId
* - UI localStorage Tab IndexedDB
*
* key userId in-memoryIDB / /
*/
export const StorageKeys = {
/** 好友列表整桶(含 DISABLE 软删记录);好友量级有限,不维护增量 */
friends: (userId: number | string) => `friends:${userId}`,
/** 群列表整桶(不含 members剥离到独立 key保证整桶写不带成员爆量 */
groups: (userId: number | string) => `groups:${userId}`,
/** 频道列表整桶;频道量级很小,整桶整写够用 */
channels: (userId: number | string) => `channels:${userId}`,
/** 单群成员,按 groupId 分桶——单群可上百-千级,跟懒加载粒度对齐;群解散时物理删 */
groupMembers: (userId: number | string, groupId: number) => `groupMembers:${userId}:${groupId}`,
/** 侧边栏宽度localStorage三个 Tab 共用一份记忆,对齐微信(拖一次到处一致)。 */
asideWidth: 'im:aside',
/** 会话列表置顶折叠展开态localStorage轻量 UI 偏好。 */
conversationPinnedExpanded: 'im:conversation:pinnedExpanded'
} as const
/** 取当前登录用户编号;返回 0 表示未登录,调用方一律早 return 不写无主 key */
export function getCurrentUserId(): number {
const { wsCache } = useCache()
const user = wsCache.get(CACHE_KEY.USER)?.user
return Number(user?.id) || 0
}
/** IDB 写入fire-and-forget */
export function setQuietly(key: string, value: unknown, errorLabel: string): void {
const raw = toStorageValue(value)
void imStorage.setItem(key, raw).catch((e) => console.warn(errorLabel, e))
}
export function removeQuietly(key: string, errorLabel: string): void {
void imStorage.removeItem(key).catch((e) => console.warn(errorLabel, e))
}
/** 转换为 IndexedDB 可存储的数据 */
function toStorageValue<T>(value: T, seen = new WeakMap<object, unknown>()): T {
const raw = value && typeof value === 'object' ? toRaw(value) : value
if (!raw || typeof raw !== 'object') {
return raw
}
if (raw instanceof Date || raw instanceof Blob || raw instanceof ArrayBuffer) {
return raw
}
if (seen.has(raw)) {
return seen.get(raw) as T
}
if (Array.isArray(raw)) {
const array: unknown[] = []
seen.set(raw, array)
raw.forEach((item) => array.push(toStorageValue(item, seen)))
return array as T
}
const out: Record<string, unknown> = {}
seen.set(raw, out)
Object.entries(raw).forEach(([key, item]) => {
if (typeof item === 'function' || typeof item === 'symbol') {
return
}
out[key] = toStorageValue(item, seen)
})
return out as T
}

View File

@ -11,6 +11,7 @@
import { countBy } from 'lodash-es'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
import { useUserStore } from '@/store/modules/user'
import { SystemUserSexEnum } from '@/utils/constants'
import {
@ -19,7 +20,6 @@ import {
IM_AT_ALL_NICKNAME,
IM_AT_ALL_USER_ID
} from './constants'
import { getCurrentUserId } from './storage'
import { type MentionCandidate } from './message'
import { useConversationStore } from '../home/store/conversationStore'
import { useFriendStore } from '../home/store/friendStore'
@ -31,6 +31,13 @@ import type { Conversation, Friend, Group, User } from '../home/types'
// MessageBubble 的 textSegments 才不会跟着无谓重算
const EMPTY_MENTIONS: MentionCandidate[] = []
/** 取当前登录用户编号 */
export function getCurrentUserId(): number {
const { wsCache } = useCache()
const user = wsCache.get(CACHE_KEY.USER)?.user
return Number(user?.id) || 0
}
/**
* >
*
@ -60,7 +67,7 @@ export function getGroupDisplayName(group: Pick<Group, 'name' | 'groupRemark'>):
/**
* undefined
*
* "是否真名" conversationStore lastSenderDisplayName fetchGroupMembers
* "是否真名" conversationStore lastSenderDisplayName fetchGroupMemberList
*
* GROUP member displayUserName / / member sender
* - self userStore.nickname fetch self 退 GROUP_MEMBER_QUIT 403