diff --git a/src/assets/audio/im/message-tip.mp3 b/src/assets/audio/im/message-tip.mp3 new file mode 100644 index 000000000..5f317dcd4 Binary files /dev/null and b/src/assets/audio/im/message-tip.mp3 differ diff --git a/src/router/modules/remaining.ts b/src/router/modules/remaining.ts index 78b4dc98e..9c2c1837d 100644 --- a/src/router/modules/remaining.ts +++ b/src/router/modules/remaining.ts @@ -751,20 +751,20 @@ const remainingRouter: AppRouteRecordRaw[] = [ // 统一 /im 分组:下分 home(聊天壳)+ manager(Layout 管理壳) path: '/im', name: 'Im', - redirect: '/im/home/chat', + redirect: '/im/home/conversation', meta: { hidden: false, title: 'IM 即时通讯' }, children: [ { path: 'home', component: () => import('@/views/im/home/Index.vue'), name: 'ImHome', - redirect: '/im/home/chat', + redirect: '/im/home/conversation', meta: { hidden: true, title: '聊天' }, children: [ { - path: 'chat', - component: () => import('@/views/im/home/pages/chat/MessagePage.vue'), - name: 'ImHomeChat', + path: 'conversation', + component: () => import('@/views/im/home/pages/conversation/MessagePage.vue'), + name: 'ImHomeConversation', meta: { hidden: true, title: '消息' } }, { diff --git a/src/utils/formatTime.ts b/src/utils/formatTime.ts index 99eb428c3..f476ecc3d 100644 --- a/src/utils/formatTime.ts +++ b/src/utils/formatTime.ts @@ -190,6 +190,18 @@ export function formatPast2(ms: number): string { } } +/** + * 将秒数格式化为 mm:ss,适用于音视频时长、倒计时等 + * + * @param seconds 秒数 + */ +export function formatSeconds(seconds: number): string { + const s = Math.max(0, Math.floor(seconds || 0)) + const mm = Math.floor(s / 60).toString().padStart(2, '0') + const ss = (s % 60).toString().padStart(2, '0') + return `${mm}:${ss}` +} + /** * element plus 的时间 Formatter 实现,使用 YYYY-MM-DD HH:mm:ss 格式 * diff --git a/src/views/im/home/types/index.ts b/src/views/im/home/types/index.ts new file mode 100644 index 000000000..a774f1f6f --- /dev/null +++ b/src/views/im/home/types/index.ts @@ -0,0 +1,90 @@ +// ==================== 本地会话 / 消息结构 ==================== + +// 会话数据结构(前端自有结构,后端无对应实体) +export interface Conversation { + // ========== 核心标识 ========== + targetId: number // 会话目标编号:私聊=对方 userId;群聊=groupId + type: number // 会话类型,对齐 ImConversationType + + // ========== 展示字段 ========== + showName: string // 展示名称 + showImage: string // 头像 + lastContent: string // 会话列表展示的最后一条消息摘要 + lastSendTime: number // 最后一条消息时间,用于排序 + unreadCount: number // 未读数 + messages: Message[] // 消息列表 + senderNickName?: string // 最后一条消息的发送者昵称(群聊列表前缀展示用) + + // ========== UI 状态 ========== + deleted?: boolean // 是否已删除(软删标记,持久化时过滤) + top?: boolean // 是否置顶(排序时优先) + muted?: boolean // 是否免打扰(不展示未读徽标 + 不响提示音) + atMe?: boolean // 群聊:是否有人 @我 + atAll?: boolean // 群聊:是否有人 @全体成员 + lastReadCount?: number // 群回执:当前会话最近一条需回执消息的已读人数 + lastTimeTip?: number // 最后一条"时间分隔线"的时间戳,判断是否需要插入下一条 TIP_TIME +} + +// 消息数据结构 +export interface Message { + // ========== 后端字段(对齐 ImPrivateMessageDTO / ImGroupMessageDTO) ========== + id: number // 服务端消息编号,发送中为 0 + clientMessageId: string // 客户端消息编号,本地生成用于合并去重 + type: number // 消息类型,对齐 ImMessageType + content: string // 消息内容,JSON 字符串 + status: number // 消息状态,对齐 ImMessageStatus + sendTime: number // 发送时间(前端转毫秒时间戳;后端为 LocalDateTime 字符串) + senderId: number // 发送人编号 + atUserIds?: number[] // 群 @ 目标用户列表 + receiverUserIds?: number[] // 群定向接收用户列表 + receiptStatus?: number // 群回执状态,对齐 ImGroupReceiptStatus(仅群消息) + readCount?: number // 群回执已读人数(仅群消息) + + // ========== 前端扩展字段 ========== + senderNickName: string // 发送人昵称(前端从 friendStore / groupStore 补全) + targetId: number // 会话目标编号(私聊=receiverId / 群聊=groupId),与 Conversation.targetId 一致 + selfSend: boolean // 是否自己发送(前端按 senderId 计算) +} + +// localStorage 存储结构:按用户 ID 分桶,保存所有会话元数据 +export interface ConversationsData { + privateMessageMaxId: number // 私聊消息最大编号 + groupMessageMaxId: number // 群聊消息最大编号 + conversations: Conversation[] // 会话列表 +} + +// ==================== WebSocket 帧 / 事件 ==================== + +// 后端 WebSocket 统一帧结构:{ type, content } +export interface WebSocketFrame { + type: string // 帧类型,对齐 ImWebSocketMessageType + content: string // 帧内容(JSON 字符串) +} + +// 私聊消息 DTO(对齐后端 ImPrivateMessageDTO) +export interface ImPrivateMessageDTO { + id: number // 消息编号 + clientMessageId: string // 客户端消息编号 + senderId: number // 发送人编号 + receiverId: number // 接收人编号 + type: number // 消息类型 + content: string // 消息内容 + status: number // 消息状态 + sendTime: string // 发送时间 +} + +// 群聊消息 DTO(对齐后端 ImGroupMessageDTO) +export interface ImGroupMessageDTO { + id: number // 消息编号 + clientMessageId: string // 客户端消息编号 + senderId: number // 发送人编号 + groupId: number // 群编号 + type: number // 消息类型 + content: string // 消息内容 + status: number // 消息状态 + sendTime: string // 发送时间 + atUserIds?: number[] // 群 @ 目标用户列表 + receiverUserIds?: number[] // 群定向接收用户列表 + readCount?: number // 群回执已读人数(type = RECEIPT 时使用) + receiptStatus?: number // 群回执状态(type = RECEIPT 时使用) +} diff --git a/src/views/im/utils/constants.ts b/src/views/im/utils/constants.ts index d20b7cbad..993cc5e44 100644 --- a/src/views/im/utils/constants.ts +++ b/src/views/im/utils/constants.ts @@ -47,13 +47,13 @@ export const ImMessageStatus = { } as const /** IM 会话类型枚举 */ -export const ImChatType = { +export const ImConversationType = { PRIVATE: 1, // 私聊 GROUP: 2 // 群聊 } as const -/** IM WebSocket 外层事件类型(对齐后端 ImPrivateMessageDTO.TYPE / ImGroupMessageDTO.TYPE) */ -export const ImWsEventType = { +/** IM WebSocket 外层帧类型(对齐后端 ImPrivateMessageDTO.TYPE / ImGroupMessageDTO.TYPE) */ +export const ImWebSocketMessageType = { PRIVATE_MESSAGE: 'im-private-message', // 私聊通道 GROUP_MESSAGE: 'im-group-message' // 群聊通道 } as const @@ -70,3 +70,6 @@ export const PRIVATE_MESSAGE_PULL_SIZE = 100 /** 每次拉取群聊消息的最大条数(后端上限 1000,前端取保守值 100) */ export const GROUP_MESSAGE_PULL_SIZE = 100 + +/** 会话之间插入"时间分隔线"的阈值:10 分钟 */ +export const TIME_TIP_GAP_MS = 10 * 60 * 1000 diff --git a/src/views/im/utils/message.ts b/src/views/im/utils/message.ts new file mode 100644 index 000000000..6b6136361 --- /dev/null +++ b/src/views/im/utils/message.ts @@ -0,0 +1,122 @@ +import { generateUUID } from '@/utils' + +// ==================================================================== +// IM 消息 content 编解码 & 展示工具 +// ==================================================================== +// 约定:消息的 content 字段统一存 JSON 字符串,字段名、结构对齐后端 +// cn.iocoder.yudao.module.im.service.websocket.dto.message.* 下的 DTO。 +// 各类消息 payload interface 字段对齐后端;解析统一用 parseMessage, +// 序列化直接 JSON.stringify(payload)。 +// ==================================================================== + +// ==================== 客户端 ID ==================== + +/** 生成客户端消息 ID(纯 UUID),用于前端去重 & ACK 回写 */ +export const generateClientMessageId = (): string => { + return generateUUID() +} + +// ==================== 消息 payload ==================== + +/** 文本消息 payload(对齐后端 TextMessage) */ +export interface TextMessage { + content: string +} + +/** 图片消息 payload(对齐后端 ImageMessage) */ +export interface ImageMessage { + url: string + /** 缩略图 URL */ + thumbnailUrl?: string + /** 图片宽度 */ + width?: number + /** 图片高度 */ + height?: number + /** 文件大小(字节) */ + size?: number +} + +/** 语音消息 payload(对齐后端 AudioMessage;ImMessageType 保留 VOICE 命名) */ +export interface AudioMessage { + url: string + /** 时长(秒) */ + duration: number + /** 文件大小(字节) */ + size?: number +} + +/** 文件消息 payload(对齐后端 FileMessage) */ +export interface FileMessage { + url: string + name: string + size: number + /** MIME 类型 */ + type?: string +} + +/** 视频消息 payload(对齐后端 VideoMessage;暂未接入渲染) */ +export interface VideoMessage { + url: string + /** 封面 URL */ + coverUrl?: string + /** 时长(秒) */ + duration?: number + width?: number + height?: number + /** 文件大小(字节) */ + size?: number +} + +/** 解析消息 content(JSON 字符串)为指定 payload,非法 JSON 返回 null */ +export const parseMessage = (content: string): T | null => { + try { + return JSON.parse(content) as T + } catch { + return null + } +} + +/** 序列化消息 payload 为 content JSON 字符串;与 parseMessage 对称 */ +export const serializeMessage = (payload: T): string => JSON.stringify(payload) + +// ==================== 撤回提示文案 ==================== + +/** + * 生成本地「撤回提示消息」的展示内容 + * 对齐后端 ImMessageType.TIP_TEXT(21) 语义:用灰色系统文案展示。 + */ +export const buildRecallTip = (senderName: string, selfSend: boolean): string => { + return selfSend ? '你撤回了一条消息' : `${senderName || '对方'} 撤回了一条消息` +} + +// ==================== 新消息提示音 ==================== + +import tipAudioUrl from '@/assets/audio/im/message-tip.mp3' + +/** + * 新消息提示音,带 1 秒节流:短时间内多条消息只响一次,避免疲劳轰炸。 + * + * 浏览器自动播放策略要求页面有过用户交互;若无法播放会静默失败。 + */ +let __lastPlayAudioTipAt = 0 +let __tipAudio: HTMLAudioElement | null = null +export const playAudioTip = () => { + const now = Date.now() + if (now - __lastPlayAudioTipAt < 1000) { + return + } + __lastPlayAudioTipAt = now + try { + if (!__tipAudio) { + __tipAudio = new Audio(tipAudioUrl) + __tipAudio.preload = 'auto' + } + __tipAudio.currentTime = 0 + const playPromise = __tipAudio.play() + if (playPromise && typeof playPromise.catch === 'function') { + playPromise.catch((e) => console.debug('[IM] playAudioTip 失败', e)) + } + } catch (e) { + console.debug('[IM] playAudioTip 失败', e) + } +} diff --git a/src/views/im/utils/storage.ts b/src/views/im/utils/storage.ts new file mode 100644 index 000000000..69ee3efa1 --- /dev/null +++ b/src/views/im/utils/storage.ts @@ -0,0 +1,6 @@ +// localStorage key 统一在此生成。im: 前缀避免与其他模块冲突。 +// 当前数据量(会话 / 消息)直接用 localStorage 满足,不需要 IndexedDB。 +export const StorageKeys = { + conversations: (userId: number | string) => `im:conversations:${userId}`, + asideWidth: (page: 'friend' | 'group') => `im:aside:${page}` +} as const