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

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -77,7 +77,7 @@ import {
ImConversationType ImConversationType
} from '@/views/im/utils/constants' } from '@/views/im/utils/constants'
import { RTC_NO_ANSWER_CALL_CHECK_INTERVAL_MS } from '@/views/im/utils/config' 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 { getSenderAvatar, getSenderDisplayName } from '@/views/im/utils/user'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import RtcCallInviting from './RtcCallInviting.vue' import RtcCallInviting from './RtcCallInviting.vue'

View File

@ -28,7 +28,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useGroupStore } from '../../store/groupStore' 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 GroupMemberPickerPanel from '../picker/GroupMemberPickerPanel.vue'
import type { GroupMemberLite } from '../group/GroupMember.vue' import type { GroupMemberLite } from '../group/GroupMember.vue'

View File

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

View File

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

View File

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

View File

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

View File

@ -32,7 +32,7 @@ import {
} from '../../utils/config' } from '../../utils/config'
import { buildChannelConversationStub } from '../../utils/channel' import { buildChannelConversationStub } from '../../utils/channel'
import { generateClientMessageId, getPrivateMessagePeerId } from '../../utils/message' import { generateClientMessageId, getPrivateMessagePeerId } from '../../utils/message'
import { getCurrentUserId } from '../../utils/storage' import { getCurrentUserId } from '../../utils/user'
import type { Message } from '../types' import type { Message } from '../types'
/** /**
@ -42,7 +42,7 @@ import type { Message } from '../types'
* 1. + 使 `minId` privateMessageMaxId / groupMessageMaxId * 1. + 使 `minId` privateMessageMaxId / groupMessageMaxId
* 2. size minId * 2. size minId
* 3. conversationStore.loading=true * 3. conversationStore.loading=true
* - conversationStore localStorage * - conversationStore
* - websocketStore WS * - websocketStore WS
* 4. WebSocket * 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) const validIds = list.map((message) => message.id).filter((id): id is number => id != null)
if (validIds.length === 0) { if (validIds.length === 0) {
await messageStore.applyPulledMessageList(pulledMessages, conversationType) await messageStore.applyPulledMessageList(pulledMessages, conversationType)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { toRaw } from 'vue' import { toRaw } from 'vue'
import { getCurrentUserId } from './storage' import { getCurrentUserId } from './user'
import { ImConversationType } from './constants' import { ImConversationType } from './constants'
import type { MessageDO, SettingDO } from '../home/types' import type { MessageDO, SettingDO } from '../home/types'
@ -19,6 +19,26 @@ export type DbStoreName =
export type DbTransaction = IDBTransaction 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 currentDb: IDBDatabase | null = null
let currentUserId: number | null = null let currentUserId: number | null = null
let currentSession = 0 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( async deleteByIndex(
storeName: DbStoreName, storeName: DbStoreName,
@ -422,13 +451,13 @@ export async function setMessageMaxId(
let key: string let key: string
switch (conversationType) { switch (conversationType) {
case ImConversationType.PRIVATE: case ImConversationType.PRIVATE:
key = 'privateMessageMaxId' key = StorageKeys.settings.privateMessageMaxId
break break
case ImConversationType.GROUP: case ImConversationType.GROUP:
key = 'groupMessageMaxId' key = StorageKeys.settings.groupMessageMaxId
break break
case ImConversationType.CHANNEL: case ImConversationType.CHANNEL:
key = 'channelMessageMaxId' key = StorageKeys.settings.channelMessageMaxId
break break
default: default:
throw new Error(`未知 IM 会话类型:${conversationType}`) throw new Error(`未知 IM 会话类型:${conversationType}`)
@ -442,6 +471,7 @@ export async function setMessageMaxId(
/** 停止当前 IM DB session */ /** 停止当前 IM DB session */
export async function stopRequests(): Promise<void> { export async function stopRequests(): Promise<void> {
currentSession++
const [ const [
{ useMessageStoreWithOut }, { useMessageStoreWithOut },
{ useConversationStoreWithOut }, { useConversationStoreWithOut },
@ -459,13 +489,12 @@ export async function stopRequests(): Promise<void> {
import('../home/store/groupRequestStore'), import('../home/store/groupRequestStore'),
import('../home/store/faceStore') import('../home/store/faceStore')
]) ])
currentSession++
useMessageStoreWithOut().clear() useMessageStoreWithOut().clear()
useConversationStoreWithOut().clear() useConversationStoreWithOut().clear()
useFriendStoreWithOut().clear() useFriendStoreWithOut().clear()
useGroupStoreWithOut().clear() useGroupStoreWithOut().clear()
useChannelStoreWithOut().clear() useChannelStoreWithOut().clear()
useGroupRequestStoreWithOut().reset() useGroupRequestStoreWithOut().clear()
useFaceStoreWithOut().reset() useFaceStoreWithOut().clear()
closeDbConnection() closeDbConnection()
} }

View File

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

View File

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

View File

@ -11,6 +11,7 @@
import { countBy } from 'lodash-es' import { countBy } from 'lodash-es'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { SystemUserSexEnum } from '@/utils/constants' import { SystemUserSexEnum } from '@/utils/constants'
import { import {
@ -19,7 +20,6 @@ import {
IM_AT_ALL_NICKNAME, IM_AT_ALL_NICKNAME,
IM_AT_ALL_USER_ID IM_AT_ALL_USER_ID
} from './constants' } from './constants'
import { getCurrentUserId } from './storage'
import { type MentionCandidate } from './message' import { type MentionCandidate } from './message'
import { useConversationStore } from '../home/store/conversationStore' import { useConversationStore } from '../home/store/conversationStore'
import { useFriendStore } from '../home/store/friendStore' import { useFriendStore } from '../home/store/friendStore'
@ -31,6 +31,13 @@ import type { Conversation, Friend, Group, User } from '../home/types'
// MessageBubble 的 textSegments 才不会跟着无谓重算 // MessageBubble 的 textSegments 才不会跟着无谓重算
const EMPTY_MENTIONS: MentionCandidate[] = [] 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 * undefined
* *
* "是否真名" conversationStore lastSenderDisplayName fetchGroupMembers * "是否真名" conversationStore lastSenderDisplayName fetchGroupMemberList
* *
* GROUP member displayUserName / / member sender * GROUP member displayUserName / / member sender
* - self userStore.nickname fetch self 退 GROUP_MEMBER_QUIT 403 * - self userStore.nickname fetch self 退 GROUP_MEMBER_QUIT 403