✨ feat(im): 重构优化 store 方案
parent
e30e30ea51
commit
2785e2bea6
|
|
@ -13,6 +13,9 @@ import { parseMessage, buildRecallTip, type TextMessage } from '../../utils/mess
|
||||||
import type { Conversation, Message, ConversationsData } from '../types'
|
import type { Conversation, Message, ConversationsData } from '../types'
|
||||||
|
|
||||||
const AT_ALL_FLAG = -1 // @全体成员 的特殊 userId 标识:atUserIds 中包含 -1 表示 @all
|
const AT_ALL_FLAG = -1 // @全体成员 的特殊 userId 标识:atUserIds 中包含 -1 表示 @all
|
||||||
|
// 单会话持久化消息数上限:localStorage 整体配额一般 5~10MB,全量序列化容易撑爆。
|
||||||
|
// 内存里保留完整历史,落盘只截最近 N 条;用户重启后历史不够再向后端拉。
|
||||||
|
const MAX_PERSISTED_MESSAGES_PER_CONVERSATION = 100
|
||||||
|
|
||||||
/** 获取当前登录用户编号 */
|
/** 获取当前登录用户编号 */
|
||||||
function getCurrentUserId(): number {
|
function getCurrentUserId(): number {
|
||||||
|
|
@ -113,11 +116,17 @@ export const useConversationStore = defineStore('imConversationStore', {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO @AI:这个方案,可能存不下太多数据,需要调整!
|
// TODO @AI:可能要调整存储方案;
|
||||||
|
// 落盘前对每个会话的 messages 做尾部截断,避免长会话把 localStorage 撑爆
|
||||||
const storageData: ConversationsData = {
|
const storageData: ConversationsData = {
|
||||||
privateMessageMaxId: this.privateMessageMaxId,
|
privateMessageMaxId: this.privateMessageMaxId,
|
||||||
groupMessageMaxId: this.groupMessageMaxId,
|
groupMessageMaxId: this.groupMessageMaxId,
|
||||||
conversations: this.conversations.filter((c) => !c.deleted)
|
conversations: this.conversations
|
||||||
|
.filter((c) => !c.deleted)
|
||||||
|
.map((c) => ({
|
||||||
|
...c,
|
||||||
|
messages: c.messages.slice(-MAX_PERSISTED_MESSAGES_PER_CONVERSATION)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(currentConversationsKey(), JSON.stringify(storageData))
|
localStorage.setItem(currentConversationsKey(), JSON.stringify(storageData))
|
||||||
|
|
|
||||||
|
|
@ -101,8 +101,6 @@ export const useFriendStore = defineStore('imFriendStore', {
|
||||||
|
|
||||||
/** 本地合并 / 新增某个好友(WebSocket 事件 & 手动刷新都用) */
|
/** 本地合并 / 新增某个好友(WebSocket 事件 & 手动刷新都用) */
|
||||||
upsertFriend(friend: Friend) {
|
upsertFriend(friend: Friend) {
|
||||||
// TODO DONE @AI:index
|
|
||||||
// TODO DONE @AI:注释
|
|
||||||
// 按 friendUserId 查已有记录下标:>=0 命中则覆盖合并,<0 则追加
|
// 按 friendUserId 查已有记录下标:>=0 命中则覆盖合并,<0 则追加
|
||||||
const index = this.friends.findIndex((f) => f.friendUserId === friend.friendUserId)
|
const index = this.friends.findIndex((f) => f.friendUserId === friend.friendUserId)
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
|
|
@ -128,14 +126,12 @@ export const useFriendStore = defineStore('imFriendStore', {
|
||||||
|
|
||||||
/** 本地标记删除(WebSocket FRIEND_DEL 事件触发;同时级联清私聊会话) */
|
/** 本地标记删除(WebSocket FRIEND_DEL 事件触发;同时级联清私聊会话) */
|
||||||
removeFriend(friendUserId: number) {
|
removeFriend(friendUserId: number) {
|
||||||
// TODO DONE @AI:变量叫 friend
|
// 软删:保留记录但置为 DISABLE,避免后续误判"陌生人"
|
||||||
// 标记墓碑:保留记录但置为 DISABLE,避免后续误判"陌生人"
|
|
||||||
const friend = this.getFriend(friendUserId)
|
const friend = this.getFriend(friendUserId)
|
||||||
if (friend) {
|
if (friend) {
|
||||||
friend.status = CommonStatusEnum.DISABLE
|
friend.status = CommonStatusEnum.DISABLE
|
||||||
friend.deleteTime = new Date().toISOString()
|
friend.deleteTime = Date.now()
|
||||||
}
|
}
|
||||||
// TODO DONE @AI:注释
|
|
||||||
// 级联清理:把对应的私聊会话也软删,避免会话列表里留着已删好友
|
// 级联清理:把对应的私聊会话也软删,避免会话列表里留着已删好友
|
||||||
const conversationStore = useConversationStore()
|
const conversationStore = useConversationStore()
|
||||||
conversationStore.removePrivateConversation(friendUserId)
|
conversationStore.removePrivateConversation(friendUserId)
|
||||||
|
|
@ -144,7 +140,6 @@ export const useFriendStore = defineStore('imFriendStore', {
|
||||||
/** 切换免打扰 */
|
/** 切换免打扰 */
|
||||||
async setMuted(friendUserId: number, muted: boolean) {
|
async setMuted(friendUserId: number, muted: boolean) {
|
||||||
await apiUpdateFriend({ friendUserId, muted })
|
await apiUpdateFriend({ friendUserId, muted })
|
||||||
// TODO DONE @AI:变量叫 friend
|
|
||||||
const friend = this.getFriend(friendUserId)
|
const friend = this.getFriend(friendUserId)
|
||||||
if (friend) {
|
if (friend) {
|
||||||
friend.muted = muted
|
friend.muted = muted
|
||||||
|
|
@ -167,8 +162,8 @@ function toFriend(vo: ImFriendRespVO): Friend {
|
||||||
avatar: vo.avatar,
|
avatar: vo.avatar,
|
||||||
muted: !!vo.muted,
|
muted: !!vo.muted,
|
||||||
status: vo.status,
|
status: vo.status,
|
||||||
addTime: vo.addTime,
|
addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined,
|
||||||
deleteTime: vo.deleteTime
|
deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,11 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
getGroup: (state) => (id: number): Group | undefined => {
|
getGroup:
|
||||||
return state.groups.find((g) => g.id === id)
|
(state) =>
|
||||||
}
|
(id: number): Group | undefined => {
|
||||||
|
return state.groups.find((g) => g.id === id)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
|
|
@ -40,7 +42,6 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
if (this.loaded && !force) {
|
if (this.loaded && !force) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// TODO DONE @AI:注释下
|
|
||||||
// 拉取当前登录用户加入的所有群(不带成员;成员按需再走 loadGroupMembers)
|
// 拉取当前登录用户加入的所有群(不带成员;成员按需再走 loadGroupMembers)
|
||||||
const list = await apiGetMyGroupList()
|
const list = await apiGetMyGroupList()
|
||||||
this.groups = (list || []).map(toGroup)
|
this.groups = (list || []).map(toGroup)
|
||||||
|
|
@ -62,7 +63,6 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// TODO DONE @AI:group
|
|
||||||
this.upsertGroup(toGroup(data))
|
this.upsertGroup(toGroup(data))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[IM groupStore] loadGroupInfo 失败', e)
|
console.warn('[IM groupStore] loadGroupInfo 失败', e)
|
||||||
|
|
@ -71,17 +71,15 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
|
|
||||||
/** 按群拉取成员(带缓存,force=true 强制刷新) */
|
/** 按群拉取成员(带缓存,force=true 强制刷新) */
|
||||||
async loadGroupMembers(groupId: number, force = false): Promise<GroupMember[]> {
|
async loadGroupMembers(groupId: number, force = false): Promise<GroupMember[]> {
|
||||||
// TODO DONE @AI:group
|
|
||||||
// 命中缓存:群已加载且成员列表已就绪,直接返回(force=true 时强制刷)
|
// 命中缓存:群已加载且成员列表已就绪,直接返回(force=true 时强制刷)
|
||||||
const group = this.getGroup(groupId)
|
const group = this.getGroup(groupId)
|
||||||
if (group && group.members && !force) {
|
if (group && group.members && !force) {
|
||||||
return group.members
|
return group.members
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO DONE @AI:注释;
|
|
||||||
// 拉取该群所有成员(聚合自 AdminUser,含 nickname / avatar / displayUserName)
|
// 拉取该群所有成员(聚合自 AdminUser,含 nickname / avatar / displayUserName)
|
||||||
const list = await apiGetGroupMemberList(groupId)
|
const list = await apiGetGroupMemberList(groupId)
|
||||||
const members = (list || []).map((m) => toGroupMember(m, groupId))
|
const members = (list || []).map((member) => toGroupMember(member, groupId))
|
||||||
// 成员列表可能在群列表之前触发,此时需要占位一个 group
|
// 成员列表可能在群列表之前触发,此时需要占位一个 group
|
||||||
if (!group) {
|
if (!group) {
|
||||||
this.upsertGroup({
|
this.upsertGroup({
|
||||||
|
|
@ -98,7 +96,6 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
},
|
},
|
||||||
|
|
||||||
upsertGroup(group: Group) {
|
upsertGroup(group: Group) {
|
||||||
// TODO DONE @AI:index
|
|
||||||
// 按 id 查已有记录下标:>=0 命中则覆盖合并,<0 则追加
|
// 按 id 查已有记录下标:>=0 命中则覆盖合并,<0 则追加
|
||||||
const index = this.groups.findIndex((g) => g.id === group.id)
|
const index = this.groups.findIndex((g) => g.id === group.id)
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
|
|
@ -117,7 +114,6 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
|
|
||||||
/** 本地移除(WebSocket GROUP_DEL 事件触发;同时级联清群聊会话) */
|
/** 本地移除(WebSocket GROUP_DEL 事件触发;同时级联清群聊会话) */
|
||||||
removeGroup(id: number) {
|
removeGroup(id: number) {
|
||||||
// TODO DONE @AI:注释
|
|
||||||
// 直接从本地列表里移除(群解散是硬删,不留墓碑,区别于好友的软删)
|
// 直接从本地列表里移除(群解散是硬删,不留墓碑,区别于好友的软删)
|
||||||
this.groups = this.groups.filter((g) => g.id !== id)
|
this.groups = this.groups.filter((g) => g.id !== id)
|
||||||
// 级联清理:对应群聊会话也软删,避免会话列表里留着已解散的群
|
// 级联清理:对应群聊会话也软删,避免会话列表里留着已解散的群
|
||||||
|
|
@ -127,7 +123,6 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
|
|
||||||
/** 切换免打扰(仅本地状态;后端 /im/group/update 接入 muted 字段后再补) */
|
/** 切换免打扰(仅本地状态;后端 /im/group/update 接入 muted 字段后再补) */
|
||||||
setMuted(id: number, muted: boolean) {
|
setMuted(id: number, muted: boolean) {
|
||||||
// TODO DONE @AI:注释
|
|
||||||
// 在本地 group 上直接打 muted 标记;conversationStore 的会话级 muted 由 ConversationItem 单独 setMuted 写
|
// 在本地 group 上直接打 muted 标记;conversationStore 的会话级 muted 由 ConversationItem 单独 setMuted 写
|
||||||
const group = this.getGroup(id)
|
const group = this.getGroup(id)
|
||||||
if (group) {
|
if (group) {
|
||||||
|
|
@ -135,6 +130,7 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 切换用户时清空 */
|
||||||
clear() {
|
clear() {
|
||||||
this.groups = []
|
this.groups = []
|
||||||
this.loaded = false
|
this.loaded = false
|
||||||
|
|
@ -145,23 +141,23 @@ export const useGroupStore = defineStore('imGroupStore', {
|
||||||
function toGroup(vo: ImGroupRespVO): Group {
|
function toGroup(vo: ImGroupRespVO): Group {
|
||||||
return {
|
return {
|
||||||
id: vo.id,
|
id: vo.id,
|
||||||
name: vo.name || '',
|
name: vo.name,
|
||||||
avatar: vo.avatar,
|
avatar: vo.avatar,
|
||||||
notice: vo.notice,
|
notice: vo.notice,
|
||||||
ownerUserId: vo.ownerUserId
|
ownerUserId: vo.ownerUserId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toGroupMember(m: ImGroupMemberRespVO, groupId: number): GroupMember {
|
function toGroupMember(member: ImGroupMemberRespVO, groupId: number): GroupMember {
|
||||||
return {
|
return {
|
||||||
id: m.id,
|
id: member.id,
|
||||||
userId: m.userId,
|
userId: member.userId,
|
||||||
groupId,
|
groupId,
|
||||||
nickname: m.nickname || String(m.userId),
|
nickname: member.nickname || String(member.userId),
|
||||||
avatar: m.avatar,
|
avatar: member.avatar,
|
||||||
displayUserName: m.displayUserName,
|
displayUserName: member.displayUserName,
|
||||||
displayGroupName: m.displayGroupName,
|
displayGroupName: member.displayGroupName,
|
||||||
status: m.status
|
status: member.status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
import type { UserInfo } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IM 全局 UI store
|
||||||
|
*
|
||||||
|
* 收纳标准:触发点 N 个、挂载点想保持 1 个的浮层状态。
|
||||||
|
* 任意位置都可能 open,但 DOM 上只想留一份实例 → 走 store 派发,
|
||||||
|
* 由 `Index.vue` 挂一个订阅组件统一渲染。
|
||||||
|
*/
|
||||||
|
export const useImUiStore = defineStore('imUiStore', () => {
|
||||||
|
// ==================== 用户名片 UserInfoCard ====================
|
||||||
|
// 用户名片悬浮卡:头像 / 昵称等触发点遍布会话、群成员、@ 选择器等列表,
|
||||||
|
const userInfoCard = reactive({
|
||||||
|
show: false,
|
||||||
|
user: null as UserInfo | null,
|
||||||
|
position: { x: 0, y: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 打开用户名片 */
|
||||||
|
function openUserInfoCard(user: UserInfo, position: { x: number; y: number }) {
|
||||||
|
const viewportWidth = document.documentElement.clientWidth
|
||||||
|
const viewportHeight = document.documentElement.clientHeight
|
||||||
|
userInfoCard.user = user
|
||||||
|
userInfoCard.position.x = Math.min(position.x, viewportWidth - 350)
|
||||||
|
userInfoCard.position.y = Math.min(position.y, viewportHeight - 220)
|
||||||
|
userInfoCard.show = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭用户名片 */
|
||||||
|
function closeUserInfoCard() {
|
||||||
|
userInfoCard.show = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 右键菜单 ContextMenu ====================
|
||||||
|
// 右键菜单虽然是一个组件挂在主壳上,但其触发时机分散在各列表
|
||||||
|
interface ContextMenuItem {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextMenu = reactive({
|
||||||
|
show: false,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
items: [] as ContextMenuItem[],
|
||||||
|
/** 选中回调:每次 open 时由调用方传入 */
|
||||||
|
onSelect: null as ((item: ContextMenuItem) => void) | null
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 打开右键菜单 */
|
||||||
|
function openContextMenu(
|
||||||
|
position: { x: number; y: number },
|
||||||
|
items: ContextMenuItem[],
|
||||||
|
onSelect: (item: ContextMenuItem) => void
|
||||||
|
) {
|
||||||
|
contextMenu.position = position
|
||||||
|
contextMenu.items = items
|
||||||
|
contextMenu.onSelect = onSelect
|
||||||
|
contextMenu.show = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭右键菜单 */
|
||||||
|
function closeContextMenu() {
|
||||||
|
contextMenu.show = false
|
||||||
|
contextMenu.onSelect = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userInfoCard,
|
||||||
|
openUserInfoCard,
|
||||||
|
closeUserInfoCard,
|
||||||
|
|
||||||
|
contextMenu,
|
||||||
|
openContextMenu,
|
||||||
|
closeContextMenu
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -133,6 +133,18 @@ export interface Friend {
|
||||||
avatar?: string // 好友头像
|
avatar?: string // 好友头像
|
||||||
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音)
|
||||||
status?: number // 好友状态,对齐 CommonStatusEnum(DISABLE = 已删除/墓碑)
|
status?: number // 好友状态,对齐 CommonStatusEnum(DISABLE = 已删除/墓碑)
|
||||||
addTime?: string // 添加好友时间
|
addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 toFriend 转换)
|
||||||
deleteTime?: string // 删除好友时间
|
deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 toFriend 转换)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 用户名片 ====================
|
||||||
|
|
||||||
|
// 用户精简信息(对齐后端 UserSimpleRespVO,名片 / 头像 hover 等场景共用)
|
||||||
|
export interface UserInfo {
|
||||||
|
id: number
|
||||||
|
nickname?: string
|
||||||
|
avatar?: string
|
||||||
|
sex?: number
|
||||||
|
deptId?: number
|
||||||
|
deptName?: string
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue