diff --git a/src/api/im/channel/material/index.ts b/src/api/im/channel/material/index.ts new file mode 100644 index 000000000..5023f0481 --- /dev/null +++ b/src/api/im/channel/material/index.ts @@ -0,0 +1,18 @@ +import request from '@/config/axios' + +// 用户端能看到的频道素材详情 +export interface ImChannelMaterialRespVO { + id: number + channelId: number + type: number + title: string + coverUrl?: string + summary?: string + content?: string + url?: string +} + +// 获取频道素材详情;用于客户端点击图文卡片渲染详情页 +export const getChannelMaterial = (id: number) => { + return request.get({ url: '/im/channel/material/get?id=' + id }) +} diff --git a/src/api/im/message/channel/index.ts b/src/api/im/message/channel/index.ts index 9a69fb3b2..b57242258 100644 --- a/src/api/im/message/channel/index.ts +++ b/src/api/im/message/channel/index.ts @@ -9,18 +9,6 @@ export interface ImChannelMessageRespVO { sendTime: string } -// 用户端能看到的频道素材详情 -export interface ImChannelMaterialRespVO { - id: number - channelId: number - type: number - title: string - coverUrl?: string - summary?: string - content?: string - url?: string -} - // 拉取当前用户应收的频道消息(离线增量);按 minId 游标分页 export const pullChannelMessages = (params: { minId: number; size?: number }) => { return request.get({ @@ -28,9 +16,3 @@ export const pullChannelMessages = (params: { minId: number; size?: number }) => params }) } - -// 获取频道素材详情;用于客户端点击图文卡片渲染详情页 -// TODO @AI:这个地址,也要改把。 -export const getChannelMaterial = (id: number) => { - return request.get({ url: '/im/channel/material/get?id=' + id }) -} diff --git a/src/views/im/home/index.vue b/src/views/im/home/index.vue index 138e2ac7e..94485666f 100644 --- a/src/views/im/home/index.vue +++ b/src/views/im/home/index.vue @@ -81,12 +81,13 @@ onMounted(async () => { // 1.1 整段 loading=true 阻断 saveConversations 抖动写盘 + WebSocket 普通消息进缓冲,避免 connect 到 pullOnce 之间收到的实时消息推进 maxId 导致 pull 跳过断线积压消息 conversationStore.loading = true try { - // 1.2 四个 store 并发从 IDB 读取本地缓存(loadConversations / loadDrafts 返回 void;load{Friends,Groups} 返回是否命中缓存) - const [, hasCachedFriends, hasCachedGroups] = await Promise.all([ + // 1.2 五个 store 并发从 IDB 读取本地缓存(loadConversations / loadDrafts 返回 void;load{Friends,Groups,Channels} 返回是否命中缓存) + const [, hasCachedFriends, hasCachedGroups, , hasCachedChannels] = await Promise.all([ conversationStore.loadConversations(), friendStore.loadFriends(), groupStore.loadGroups(), - draftStore.loadDrafts() + draftStore.loadDrafts(), + channelStore.loadChannels() ]) // 2.1 有缓存:异步背景刷新,失败仅记日志(IDB 数据已经够撑首屏,pullOnce 也能正常入库) @@ -103,10 +104,11 @@ onMounted(async () => { } else { requiredFetches.push(groupStore.fetchGroups()) } - // TODO @AI:这里的“// 频道列表无 IDB 缓存;首屏后台异步拉一次,失败不阻塞 pull”;是不是要和上面的 2.1 2.2 综合起来?不然孤独的,有点奇怪? - // 频道列表无 IDB 缓存;首屏后台异步拉一次,失败不阻塞 pull - void channelStore.fetchChannels().catch((e) => console.warn('[IM] 拉取频道列表失败', e)) - // 2.3 TODO @AI:这里加个注释,会不会有间隔感一点? + if (hasCachedChannels) { + void channelStore.fetchChannels().catch((e) => console.warn('[IM] 后台刷频道列表失败', e)) + } else { + requiredFetches.push(channelStore.fetchChannels()) + } if (requiredFetches.length > 0) { await Promise.all(requiredFetches) } diff --git a/src/views/im/home/pages/conversation/components/message/MaterialBubble.vue b/src/views/im/home/pages/conversation/components/message/MaterialBubble.vue new file mode 100644 index 000000000..6f077569f --- /dev/null +++ b/src/views/im/home/pages/conversation/components/message/MaterialBubble.vue @@ -0,0 +1,243 @@ + + + + + + + diff --git a/src/views/im/home/store/channelStore.ts b/src/views/im/home/store/channelStore.ts new file mode 100644 index 000000000..6844427b5 --- /dev/null +++ b/src/views/im/home/store/channelStore.ts @@ -0,0 +1,101 @@ +import { defineStore, acceptHMRUpdate } from 'pinia' +import { store } from '@/store' +import { getEnabledChannelList, type ImManagerChannelVO } from '@/api/im/manager/channel' +import { useConversationStore } from './conversationStore' +import { ImConversationType } from '../../utils/constants' +import { getCurrentUserId, imStorage, setQuietly, StorageKeys } from '../../utils/storage' + +/** + * IM 频道 Store + * + * 负责:缓存当前用户能看到的频道精简列表(含 id / code / name / avatar), + * 供会话列表 / 卡片渲染时反查频道名称 + 头像,避免显示「频道 1」这种占位 + */ +export const useChannelStore = defineStore('imChannelStore', { + state: () => ({ + channels: [] as ImManagerChannelVO[], + loaded: false + }), + + getters: { + getChannel(state): (id: number) => ImManagerChannelVO | undefined { + return (id: number) => state.channels.find((c) => c.id === id) + } + }, + + actions: { + // ==================== 本地缓存 ==================== + + /** 从 IDB 恢复频道列表;命中返回 true 让首屏立刻有真实名 / 头像 */ + async loadChannels(): Promise { + const userId = getCurrentUserId() + if (!userId) { + return false + } + try { + const cached = await imStorage.getItem(StorageKeys.channels(userId)) + if (!cached || cached.length === 0) { + return false + } + this.channels = cached + return true + } catch (e) { + console.warn('[IM channelStore] 本地频道缓存读取失败', e) + return false + } + }, + + /** 整桶持久化频道列表(量级小,不维护增量) */ + saveChannels(): void { + const userId = getCurrentUserId() + if (!userId) { + return + } + setQuietly(StorageKeys.channels(userId), this.channels, '[IM channelStore] 本地频道缓存写入失败') + }, + + // ==================== 远端拉取 ==================== + + /** 拉取启用的频道精简列表;成功后回填会话列表已有的频道 name / avatar,覆盖 IDB 旧占位 */ + async fetchChannels(force = false) { + if (this.loaded && !force) { + return + } + try { + this.channels = (await getEnabledChannelList()) || [] + this.loaded = true + this.syncConversationMetadata() + this.saveChannels() + } catch (e) { + console.warn('[IM channelStore] fetchChannels 失败', e) + } + }, + + /** 用最新的频道信息覆盖已有 CHANNEL 会话的 name / avatar;conversationStore 持久化的旧占位被刷掉 */ + syncConversationMetadata() { + const conversationStore = useConversationStore() + const indexed = new Map(this.channels.map((c) => [c.id, c])) + conversationStore.conversations.forEach((conversation) => { + if (conversation.type !== ImConversationType.CHANNEL) { + return + } + const channel = indexed.get(conversation.targetId) + if (!channel) { + return + } + if (channel.name) { + conversation.name = channel.name + } + if (channel.avatar) { + conversation.avatar = channel.avatar + } + }) + } + } +}) + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useChannelStore, import.meta.hot)) +} + +export const useImChannelStore = () => useChannelStore(store) diff --git a/src/views/im/manager/channel/channel/ChannelForm.vue b/src/views/im/manager/channel/channel/ChannelForm.vue new file mode 100644 index 000000000..f8b345a46 --- /dev/null +++ b/src/views/im/manager/channel/channel/ChannelForm.vue @@ -0,0 +1,129 @@ + + + diff --git a/src/views/im/manager/channel/channel/index.vue b/src/views/im/manager/channel/channel/index.vue new file mode 100644 index 000000000..287394555 --- /dev/null +++ b/src/views/im/manager/channel/channel/index.vue @@ -0,0 +1,182 @@ + + + diff --git a/src/views/im/manager/channel/material/index.vue b/src/views/im/manager/channel/material/index.vue new file mode 100644 index 000000000..4a5a4b3c3 --- /dev/null +++ b/src/views/im/manager/channel/material/index.vue @@ -0,0 +1,180 @@ + + + diff --git a/src/views/im/manager/channel/message/ChannelMessageSendForm.vue b/src/views/im/manager/channel/message/ChannelMessageSendForm.vue new file mode 100644 index 000000000..24a15d144 --- /dev/null +++ b/src/views/im/manager/channel/message/ChannelMessageSendForm.vue @@ -0,0 +1,144 @@ + + + diff --git a/src/views/im/manager/channel/message/index.vue b/src/views/im/manager/channel/message/index.vue new file mode 100644 index 000000000..8d1d52586 --- /dev/null +++ b/src/views/im/manager/channel/message/index.vue @@ -0,0 +1,159 @@ + + + diff --git a/src/views/im/utils/storage.ts b/src/views/im/utils/storage.ts index 3307737fc..7536d468c 100644 --- a/src/views/im/utils/storage.ts +++ b/src/views/im/utils/storage.ts @@ -56,6 +56,8 @@ export const StorageKeys = { 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}`,