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
parent
664904bd06
commit
763e11eb78
|
|
@ -7,3 +7,4 @@ pnpm-debug
|
|||
auto-*.d.ts
|
||||
.idea
|
||||
.history
|
||||
output/
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ watch(
|
|||
if (!signature) {
|
||||
mergeToken++
|
||||
mergedUrl.value = ''
|
||||
groupStore.loadGroupMembers(groupId)
|
||||
groupStore.loadGroupMemberList(groupId)
|
||||
return
|
||||
}
|
||||
const targetSize = getTargetSize(size)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]>(() =>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 || ''
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -224,8 +224,8 @@ export const useMessageSender = () => {
|
|||
return
|
||||
}
|
||||
// 本地标记已读:未读数清零 + 消息状态更新为 READ(UI 立刻响应)
|
||||
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>(
|
||||
|
|
|
|||
|
|
@ -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 返回 void;load{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.fullPath,IM 子路由切换(消息 / 通讯录)也能重新加上前缀
|
||||
*/
|
||||
watch(
|
||||
[() => conversationStore.getTotalUnread, () => route.fullPath],
|
||||
[() => conversationStore.getTotalUnreadCount, () => route.fullPath],
|
||||
([count]) => {
|
||||
nextTick(() => {
|
||||
const base = appStore.getTitle
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
])
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
/** 群创建成功:跳到新群会话 + 关掉本侧抽屉,让用户专注新群 */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 已 trim,plain 为空时 store 内部按 clearDraft 处理
|
||||
// reply 透传当前快照:setDraft 是整对象替换,不读旧 reply 会让用户每敲一个键就把引用条擦掉
|
||||
// collectFromEditor 已 trim,plain 为空时 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 + 当前会话草稿(包含 reply);syncEditorState 后 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -896,7 +896,7 @@ function handleReply() {
|
|||
if (!conversation) {
|
||||
return
|
||||
}
|
||||
conversationStore.setReplyDraft(conversation, buildQuoteFromMessage(props.message))
|
||||
conversationStore.setConversationReplyDraft(conversation, buildQuoteFromMessage(props.message))
|
||||
}
|
||||
|
||||
/** 转发当前消息:打开 ForwardDialog(单条模式;mode=single 即原样转) */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('群已创建,但消息转发失败,请稍后在群里重试')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
/** 置顶会话:单独切片,给折叠头计数 + 折叠区渲染用 */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,18 +28,18 @@ export const useFaceStore = defineStore('imFace', () => {
|
|||
/** 个人表情包列表(用户长按「添加到表情」/ 上传产生) */
|
||||
const faceUserItems = ref<ImFaceUserItemVO[]>([])
|
||||
|
||||
/** reset() 时递增;旧账号那次还没返回的请求 resolve 后比对一致才写 store,防跨账号数据泄漏 */
|
||||
/** clear() 时递增;旧账号请求返回后不写入新账号内存 */
|
||||
let storeEpoch = 0
|
||||
|
||||
/**
|
||||
* 系统表情包拉取 promise;ensureFacePacks 内 cache:
|
||||
* 系统表情包拉取 promise;ensureFacePackList 内 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
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 成功后置位;loadFriends(IDB)不置位,否则后台 SWR 刷新会被缓存命中跳过
|
||||
// 仅 fetchFriendList 成功后置位;loadFriendData(IDB)不置位,否则后台 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 事件 dispatcher(1201-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.friendUserId(payload 里固定是 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++) */
|
||||
|
|
|
|||
|
|
@ -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', {
|
|||
/**
|
||||
* 各群下未处理申请数的 Map;O(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 + 排到头部
|
||||
* 校验 handleResult:HTTP 在途时若已收到 1505 / 1506,returnedRequest 可能已是已处理状态,不能再塞回未处理列表
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 成功后置位;loadGroups(IDB)不置位,否则后台 SWR 刷新会被缓存命中跳过
|
||||
// 仅 fetchGroupList 成功后置位;loadGroupList(IDB)不置位,否则后台 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
|
||||
})
|
||||
// 持久化到 IDB(fire-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)
|
||||
},
|
||||
|
||||
/** 本地更新群成员的 displayUserName(GROUP_MEMBER_NICKNAME_UPDATE 事件);不命中则等 fetchGroupMembers 兜底 */
|
||||
updateMemberDisplayUserName(groupId: number, userId: number, displayUserName: string) {
|
||||
/** 本地更新群成员的 displayUserName(GROUP_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)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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] || []
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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_UPDATE:多端同步成员个人设置变更(silent / groupRemark)
|
||||
*
|
||||
* payload 携带变更字段,按非 null 字段直接局部更新;省一次 fetchGroupMembers 接口
|
||||
* payload 携带变更字段,按非 null 字段直接局部更新;省一次 fetchGroupMemberList 接口
|
||||
*/
|
||||
handleGroupMemberSettingUpdate(websocketMessage: ImGroupMessageDTO) {
|
||||
// content 解析失败由外层 dispatchGroupFrame 的 try-catch 兜底(含 websocketMessage 打印),不重复 catch
|
||||
|
|
|
|||
|
|
@ -146,10 +146,12 @@ export interface Group {
|
|||
silent?: boolean // 是否免打扰。从当前用户的 GroupMember 回填
|
||||
groupRemark?: string // 群备注。从当前用户的 GroupMember 回填(当前用户对该群的自定义名)
|
||||
members?: GroupMember[] // 群成员缓存(按需懒加载)
|
||||
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMembers / fetchGroupMembers 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 fetchGroupMembers(force=false) 命中缓存时误判整群已加载
|
||||
membersLoaded?: boolean // members 是否"完整加载"——只有整群 loadGroupMemberList / fetchGroupMemberList 命中时为 true;fetchGroupMember 单成员补齐不置位,避免 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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 统一在此生成
|
||||
*
|
||||
* - 好友 / 群相关业务数据走 imStorage(IndexedDB),key 都按 userId 分桶
|
||||
* - 轻量 UI 状态(侧边栏宽度)仍走 localStorage:体积小、跨 Tab 同步天然,没必要走 IndexedDB
|
||||
*
|
||||
* 所有业务 key 都注入 userId:多账号切换按用户隔离避免数据互串;账号切换时只清 in-memory、IDB 数据保留——回切旧账号能秒开,不浪费已下载好友 / 群 / 成员快照
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue