feat(im): 优化 im 前端的工具类

im
YunaiV 2026-04-24 21:36:09 +08:00
parent 5f16cd74e0
commit 68d3ad10d4
7 changed files with 241 additions and 8 deletions

Binary file not shown.

View File

@ -751,20 +751,20 @@ const remainingRouter: AppRouteRecordRaw[] = [
// 统一 /im 分组:下分 home聊天壳+ managerLayout 管理壳)
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: '消息' }
},
{

View File

@ -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
*

View File

@ -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 时使用)
}

View File

@ -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

View File

@ -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<T>
// 序列化直接 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对齐后端 AudioMessageImMessageType 保留 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
}
/** 解析消息 contentJSON 字符串)为指定 payload非法 JSON 返回 null */
export const parseMessage = <T>(content: string): T | null => {
try {
return JSON.parse(content) as T
} catch {
return null
}
}
/** 序列化消息 payload 为 content JSON 字符串;与 parseMessage 对称 */
export const serializeMessage = <T>(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)
}
}

View File

@ -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