✨ feat(im): 优化 im 前端的工具类
parent
5f16cd74e0
commit
68d3ad10d4
Binary file not shown.
|
|
@ -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: '消息' }
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 格式
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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 时使用)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(对齐后端 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 = <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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue