From 68d3ad10d44ea2e7b89a22d8af7b8bbfa6a50acd Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 24 Apr 2026 21:36:09 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(im):=20=E4=BC=98=E5=8C=96=20im?= =?UTF-8?q?=20=E5=89=8D=E7=AB=AF=E7=9A=84=E5=B7=A5=E5=85=B7=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/audio/im/message-tip.mp3 | Bin 0 -> 13059 bytes src/router/modules/remaining.ts | 10 +-- src/utils/formatTime.ts | 12 +++ src/views/im/home/types/index.ts | 90 ++++++++++++++++++++ src/views/im/utils/constants.ts | 9 +- src/views/im/utils/message.ts | 122 ++++++++++++++++++++++++++++ src/views/im/utils/storage.ts | 6 ++ 7 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 src/assets/audio/im/message-tip.mp3 create mode 100644 src/views/im/home/types/index.ts create mode 100644 src/views/im/utils/message.ts create mode 100644 src/views/im/utils/storage.ts diff --git a/src/assets/audio/im/message-tip.mp3 b/src/assets/audio/im/message-tip.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..5f317dcd466c5430bb0b7a7c1bee9ea8e3f6c430 GIT binary patch literal 13059 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%0D#7~VnCN+4A3wP^3G94Y}5tr zb#cajtFme7R{byRPu%^jf@LD|+5@V!(R<+6P-@bA91EqfrH@R(c`?e}J*TLpG w*jxwy 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