From 2785e2bea6630adb98d911441b63f71549264a5d Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 25 Apr 2026 16:45:31 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20store=20=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/im/home/store/conversationStore.ts | 13 +++- src/views/im/home/store/friendStore.ts | 13 +--- src/views/im/home/store/groupStore.ts | 36 ++++----- src/views/im/home/store/uiStore.ts | 80 ++++++++++++++++++++ src/views/im/home/types/index.ts | 16 +++- 5 files changed, 125 insertions(+), 33 deletions(-) create mode 100644 src/views/im/home/store/uiStore.ts diff --git a/src/views/im/home/store/conversationStore.ts b/src/views/im/home/store/conversationStore.ts index 8886f34a7..35e467156 100644 --- a/src/views/im/home/store/conversationStore.ts +++ b/src/views/im/home/store/conversationStore.ts @@ -13,6 +13,9 @@ import { parseMessage, buildRecallTip, type TextMessage } from '../../utils/mess import type { Conversation, Message, ConversationsData } from '../types' const AT_ALL_FLAG = -1 // @全体成员 的特殊 userId 标识:atUserIds 中包含 -1 表示 @all +// 单会话持久化消息数上限:localStorage 整体配额一般 5~10MB,全量序列化容易撑爆。 +// 内存里保留完整历史,落盘只截最近 N 条;用户重启后历史不够再向后端拉。 +const MAX_PERSISTED_MESSAGES_PER_CONVERSATION = 100 /** 获取当前登录用户编号 */ function getCurrentUserId(): number { @@ -113,11 +116,17 @@ export const useConversationStore = defineStore('imConversationStore', { return } - // TODO @AI:这个方案,可能存不下太多数据,需要调整! + // TODO @AI:可能要调整存储方案; + // 落盘前对每个会话的 messages 做尾部截断,避免长会话把 localStorage 撑爆 const storageData: ConversationsData = { privateMessageMaxId: this.privateMessageMaxId, 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 { localStorage.setItem(currentConversationsKey(), JSON.stringify(storageData)) diff --git a/src/views/im/home/store/friendStore.ts b/src/views/im/home/store/friendStore.ts index 1868f317b..6b2962a9a 100644 --- a/src/views/im/home/store/friendStore.ts +++ b/src/views/im/home/store/friendStore.ts @@ -101,8 +101,6 @@ export const useFriendStore = defineStore('imFriendStore', { /** 本地合并 / 新增某个好友(WebSocket 事件 & 手动刷新都用) */ upsertFriend(friend: Friend) { - // TODO DONE @AI:index - // TODO DONE @AI:注释 // 按 friendUserId 查已有记录下标:>=0 命中则覆盖合并,<0 则追加 const index = this.friends.findIndex((f) => f.friendUserId === friend.friendUserId) if (index >= 0) { @@ -128,14 +126,12 @@ export const useFriendStore = defineStore('imFriendStore', { /** 本地标记删除(WebSocket FRIEND_DEL 事件触发;同时级联清私聊会话) */ removeFriend(friendUserId: number) { - // TODO DONE @AI:变量叫 friend - // 标记墓碑:保留记录但置为 DISABLE,避免后续误判"陌生人" + // 软删:保留记录但置为 DISABLE,避免后续误判"陌生人" const friend = this.getFriend(friendUserId) if (friend) { friend.status = CommonStatusEnum.DISABLE - friend.deleteTime = new Date().toISOString() + friend.deleteTime = Date.now() } - // TODO DONE @AI:注释 // 级联清理:把对应的私聊会话也软删,避免会话列表里留着已删好友 const conversationStore = useConversationStore() conversationStore.removePrivateConversation(friendUserId) @@ -144,7 +140,6 @@ export const useFriendStore = defineStore('imFriendStore', { /** 切换免打扰 */ async setMuted(friendUserId: number, muted: boolean) { await apiUpdateFriend({ friendUserId, muted }) - // TODO DONE @AI:变量叫 friend const friend = this.getFriend(friendUserId) if (friend) { friend.muted = muted @@ -167,8 +162,8 @@ function toFriend(vo: ImFriendRespVO): Friend { avatar: vo.avatar, muted: !!vo.muted, status: vo.status, - addTime: vo.addTime, - deleteTime: vo.deleteTime + addTime: vo.addTime ? new Date(vo.addTime).getTime() : undefined, + deleteTime: vo.deleteTime ? new Date(vo.deleteTime).getTime() : undefined } } diff --git a/src/views/im/home/store/groupStore.ts b/src/views/im/home/store/groupStore.ts index b8a73aa1d..78e2262ca 100644 --- a/src/views/im/home/store/groupStore.ts +++ b/src/views/im/home/store/groupStore.ts @@ -29,9 +29,11 @@ export const useGroupStore = defineStore('imGroupStore', { }), getters: { - getGroup: (state) => (id: number): Group | undefined => { - return state.groups.find((g) => g.id === id) - } + getGroup: + (state) => + (id: number): Group | undefined => { + return state.groups.find((g) => g.id === id) + } }, actions: { @@ -40,7 +42,6 @@ export const useGroupStore = defineStore('imGroupStore', { if (this.loaded && !force) { return } - // TODO DONE @AI:注释下 // 拉取当前登录用户加入的所有群(不带成员;成员按需再走 loadGroupMembers) const list = await apiGetMyGroupList() this.groups = (list || []).map(toGroup) @@ -62,7 +63,6 @@ export const useGroupStore = defineStore('imGroupStore', { if (!data) { return } - // TODO DONE @AI:group this.upsertGroup(toGroup(data)) } catch (e) { console.warn('[IM groupStore] loadGroupInfo 失败', e) @@ -71,17 +71,15 @@ export const useGroupStore = defineStore('imGroupStore', { /** 按群拉取成员(带缓存,force=true 强制刷新) */ async loadGroupMembers(groupId: number, force = false): Promise { - // TODO DONE @AI:group // 命中缓存:群已加载且成员列表已就绪,直接返回(force=true 时强制刷) const group = this.getGroup(groupId) if (group && group.members && !force) { return group.members } - // TODO DONE @AI:注释; // 拉取该群所有成员(聚合自 AdminUser,含 nickname / avatar / displayUserName) const list = await apiGetGroupMemberList(groupId) - const members = (list || []).map((m) => toGroupMember(m, groupId)) + const members = (list || []).map((member) => toGroupMember(member, groupId)) // 成员列表可能在群列表之前触发,此时需要占位一个 group if (!group) { this.upsertGroup({ @@ -98,7 +96,6 @@ export const useGroupStore = defineStore('imGroupStore', { }, upsertGroup(group: Group) { - // TODO DONE @AI:index // 按 id 查已有记录下标:>=0 命中则覆盖合并,<0 则追加 const index = this.groups.findIndex((g) => g.id === group.id) if (index >= 0) { @@ -117,7 +114,6 @@ export const useGroupStore = defineStore('imGroupStore', { /** 本地移除(WebSocket GROUP_DEL 事件触发;同时级联清群聊会话) */ removeGroup(id: number) { - // TODO DONE @AI:注释 // 直接从本地列表里移除(群解散是硬删,不留墓碑,区别于好友的软删) this.groups = this.groups.filter((g) => g.id !== id) // 级联清理:对应群聊会话也软删,避免会话列表里留着已解散的群 @@ -127,7 +123,6 @@ export const useGroupStore = defineStore('imGroupStore', { /** 切换免打扰(仅本地状态;后端 /im/group/update 接入 muted 字段后再补) */ setMuted(id: number, muted: boolean) { - // TODO DONE @AI:注释 // 在本地 group 上直接打 muted 标记;conversationStore 的会话级 muted 由 ConversationItem 单独 setMuted 写 const group = this.getGroup(id) if (group) { @@ -135,6 +130,7 @@ export const useGroupStore = defineStore('imGroupStore', { } }, + /** 切换用户时清空 */ clear() { this.groups = [] this.loaded = false @@ -145,23 +141,23 @@ export const useGroupStore = defineStore('imGroupStore', { function toGroup(vo: ImGroupRespVO): Group { return { id: vo.id, - name: vo.name || '', + name: vo.name, avatar: vo.avatar, notice: vo.notice, ownerUserId: vo.ownerUserId } } -function toGroupMember(m: ImGroupMemberRespVO, groupId: number): GroupMember { +function toGroupMember(member: ImGroupMemberRespVO, groupId: number): GroupMember { return { - id: m.id, - userId: m.userId, + id: member.id, + userId: member.userId, groupId, - nickname: m.nickname || String(m.userId), - avatar: m.avatar, - displayUserName: m.displayUserName, - displayGroupName: m.displayGroupName, - status: m.status + nickname: member.nickname || String(member.userId), + avatar: member.avatar, + displayUserName: member.displayUserName, + displayGroupName: member.displayGroupName, + status: member.status } } diff --git a/src/views/im/home/store/uiStore.ts b/src/views/im/home/store/uiStore.ts new file mode 100644 index 000000000..a78741684 --- /dev/null +++ b/src/views/im/home/store/uiStore.ts @@ -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 + } +}) diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts index e5b34bd09..66c65d909 100644 --- a/src/views/im/home/types/index.ts +++ b/src/views/im/home/types/index.ts @@ -133,6 +133,18 @@ export interface Friend { avatar?: string // 好友头像 muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) status?: number // 好友状态,对齐 CommonStatusEnum(DISABLE = 已删除/墓碑) - addTime?: string // 添加好友时间 - deleteTime?: string // 删除好友时间 + addTime?: number // 添加好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 toFriend 转换) + deleteTime?: number // 删除好友时间(毫秒时间戳;后端为 LocalDateTime 字符串,在 toFriend 转换) } + +// ==================== 用户名片 ==================== + +// 用户精简信息(对齐后端 UserSimpleRespVO,名片 / 头像 hover 等场景共用) +export interface UserInfo { + id: number + nickname?: string + avatar?: string + sex?: number + deptId?: number + deptName?: string +} \ No newline at end of file