chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,451 @@
|
||||
// message-service.uts
|
||||
// 实时消息中枢:统一接管 WebSocket 连接、消息分发与会话管理。
|
||||
//
|
||||
// 职责:
|
||||
// 1. 在用户已登录且已拿到后端 appConf.socketUrl 后,自动注入 token 建立连接;
|
||||
// 2. 监听 wsManager 的 open/message/close/error/reconnect 事件;
|
||||
// 3. 将服务器下发的消息按 header.type 分发为应用级事件(uni.$emit),
|
||||
// 各页面(聊天/通知/系统)只需监听对应事件即可,无需关心底层 WS;
|
||||
// 4. 维护内存中的会话列表与消息列表,提供排序、未读、置顶、免打扰等会话管理;
|
||||
// 5. 提供 sendChat() 便捷发送单聊/群聊消息。
|
||||
//
|
||||
// 注意:连接鉴权采用与后端约定一致的 Authorization 头(Bearer token),
|
||||
// 由 wsManager.connect 透传到 uni.connectSocket 的 header 中。
|
||||
|
||||
import { wsManager } from './websocket.uts'
|
||||
import {
|
||||
IMessageBase,
|
||||
IMessageHeader,
|
||||
IMessagePayload,
|
||||
IMessagePreview,
|
||||
IMessageSession,
|
||||
IMessageServiceCallbacks,
|
||||
IChatMessage,
|
||||
IConnectOptions
|
||||
} from './socket-types.uts'
|
||||
import { getUserToken } from '@/stores/user.uts'
|
||||
import userState from '@/stores/user.uts'
|
||||
|
||||
class MessageService {
|
||||
private initialized = false
|
||||
private connected = false
|
||||
private token = ''
|
||||
|
||||
// 会话管理状态
|
||||
private callbacks : IMessageServiceCallbacks = {}
|
||||
private sessions : Map<string, IMessageSession> = new Map()
|
||||
private messages : Map<string, IMessageBase[]> = new Map() // session_id -> messages[]
|
||||
private currentUserId = userState.uid
|
||||
|
||||
// 注册 WS 事件监听(幂等,只执行一次)
|
||||
init() : void {
|
||||
if (this.initialized == true) {
|
||||
return
|
||||
}
|
||||
this.initialized = true
|
||||
wsManager.on({
|
||||
onOpen: (res : any) => {
|
||||
this.connected = true
|
||||
console.log('[MessageService] WS 已连接')
|
||||
uni.$emit('onWsConnected', res)
|
||||
},
|
||||
onMessage: (msg : IMessageBase) => {
|
||||
// 1) 维护会话与消息列表
|
||||
this.handleWebSocketMessage(msg)
|
||||
// 2) 应用级事件分发
|
||||
this.dispatch(msg)
|
||||
},
|
||||
onClose: (res : any) => {
|
||||
this.connected = false
|
||||
console.log('[MessageService] WS 已关闭')
|
||||
uni.$emit('onWsDisconnected', res)
|
||||
},
|
||||
onError: (err : any) => {
|
||||
console.error('[MessageService] WS 错误', err)
|
||||
uni.$emit('onWsError', err)
|
||||
},
|
||||
onReconnect: (res : any) => {
|
||||
console.log('[MessageService] WS 重连中', res)
|
||||
uni.$emit('onWsReconnect', res)
|
||||
},
|
||||
onReconnectFailed: (res : any) => {
|
||||
console.error('[MessageService] WS 重连失败', res)
|
||||
uni.$emit('onWsReconnectFailed', res)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 已登录时按需建立 WS 连接(幂等)。
|
||||
* @param socketUrl 后端 appConf.socketUrl,为空则跳过
|
||||
*/
|
||||
connectIfNeeded(socketUrl : string | null) : void {
|
||||
this.init()
|
||||
if (socketUrl == null || socketUrl.length == 0) {
|
||||
console.warn('[MessageService] 未配置 socketUrl,跳过 WS 连接')
|
||||
return
|
||||
}
|
||||
const tokenResult = getUserToken()
|
||||
if (tokenResult == null || tokenResult.accessToken == null || tokenResult.accessToken.length == 0) {
|
||||
console.warn('[MessageService] 未登录或 token 为空,跳过 WS 连接')
|
||||
return
|
||||
}
|
||||
if (wsManager.isConnected() == true) {
|
||||
return
|
||||
}
|
||||
this.token = tokenResult.accessToken!
|
||||
const options : IConnectOptions = {
|
||||
reconnectInterval: 3000,
|
||||
heartbeatInterval: 20000,
|
||||
maxReconnectCount: 5,
|
||||
header: {
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
}
|
||||
}
|
||||
wsManager.connect(socketUrl, options)
|
||||
}
|
||||
|
||||
// 主动断开
|
||||
disconnect() : void {
|
||||
wsManager.close(1000, 'user logout')
|
||||
this.connected = false
|
||||
}
|
||||
|
||||
isConnected() : boolean {
|
||||
return this.connected
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天消息(单聊/群聊)。
|
||||
* 真实送达以服务器回执为准,本方法仅保证消息已提交至 WS。
|
||||
*/
|
||||
sendChat(receiver : number, sessionId : string, contentType : string, content : any) : void {
|
||||
const header : IMessageHeader = {
|
||||
id: `c_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
|
||||
type: 'chat',
|
||||
target: 'single',
|
||||
seqno: 1,
|
||||
timestamp: Date.now(),
|
||||
source: 'client',
|
||||
version: '1.0.0',
|
||||
token: this.token
|
||||
}
|
||||
const msg : IMessageBase = {
|
||||
header: header,
|
||||
payload: {
|
||||
uid: userState.uid,
|
||||
receiver: receiver,
|
||||
session_id: sessionId,
|
||||
type: contentType,
|
||||
status: 'sending',
|
||||
content: content
|
||||
} as any
|
||||
}
|
||||
wsManager.send(msg)
|
||||
}
|
||||
|
||||
// 按消息类型分发为应用级事件
|
||||
private dispatch(msg : IMessageBase) : void {
|
||||
const type = msg.header.type
|
||||
switch (type) {
|
||||
case 'chat':
|
||||
uni.$emit('onChatMessage', msg)
|
||||
break
|
||||
case 'notify':
|
||||
uni.$emit('onWsNotify', msg)
|
||||
break
|
||||
case 'status':
|
||||
uni.$emit('onWsStatus', msg)
|
||||
break
|
||||
case 'system':
|
||||
uni.$emit('onWsSystem', msg)
|
||||
break
|
||||
case 'pong':
|
||||
// 心跳回包,wsManager 内部已处理,无需外发
|
||||
break
|
||||
default:
|
||||
uni.$emit('onWsMessage', msg)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 消息,维护会话与消息列表
|
||||
*/
|
||||
private handleWebSocketMessage(message : IMessageBase) : void {
|
||||
if (message == null) {
|
||||
return
|
||||
}
|
||||
const { header, payload } = message
|
||||
const session_id = this.getSessionId(header.target, payload)
|
||||
// 1. 更新会话信息
|
||||
this.updateSession(session_id, message)
|
||||
// 2. 添加到消息列表
|
||||
this.addMessageToSession(session_id, message)
|
||||
// 3. 触发 UI 更新
|
||||
uni.$emit('session_updated', session_id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话ID
|
||||
*/
|
||||
private getSessionId(target : string, payload : IMessagePayload) : string {
|
||||
const sessionPayload = payload as IChatMessage
|
||||
const sessionId = sessionPayload.session_id ?? ''
|
||||
switch (target) {
|
||||
case 'single':
|
||||
return 'single' + sessionId
|
||||
case 'group':
|
||||
return 'group' + sessionId
|
||||
case 'room':
|
||||
return 'room' + sessionId
|
||||
case 'system':
|
||||
default:
|
||||
return 'system' + sessionId
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话名称
|
||||
*/
|
||||
private getSessionName(payload : IMessagePayload, type : string) : string {
|
||||
const chatPayload = payload as IChatMessage
|
||||
switch (type) {
|
||||
case 'single':
|
||||
return chatPayload.nickname ?? '未知用户'
|
||||
case 'group':
|
||||
return '群聊'
|
||||
case 'room':
|
||||
return chatPayload.nickname ?? '房间'
|
||||
case 'system':
|
||||
return '系统消息'
|
||||
default:
|
||||
return '会话'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建会话
|
||||
*/
|
||||
private createSession(session_id : string, message : IMessageBase) : IMessageSession {
|
||||
const { header, payload } = message
|
||||
const session_type = header.target
|
||||
const chatPayload = payload as IChatMessage
|
||||
return {
|
||||
session_id,
|
||||
session_type,
|
||||
session_name: this.getSessionName(payload, session_type),
|
||||
session_avatar: chatPayload.avatar ?? 'https://cdn.example.com/default_avatar.png',
|
||||
last_message: this.createMessagePreview(message),
|
||||
unread_count: 1,
|
||||
is_pinned: false,
|
||||
is_muted: false,
|
||||
last_active_time: message.header.timestamp,
|
||||
created_time: message.header.timestamp,
|
||||
status: 'normal',
|
||||
ext: {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建消息预览
|
||||
*/
|
||||
private createMessagePreview(message : IMessageBase) : IMessagePreview {
|
||||
const { header, payload } = message
|
||||
const chatPayload = payload as IChatMessage
|
||||
return {
|
||||
id: header.id,
|
||||
type: chatPayload.type ?? 'text',
|
||||
content_preview: this.formatContentPreview(payload),
|
||||
timestamp: header.timestamp,
|
||||
msg_status: chatPayload.status ?? 'delivered',
|
||||
is_mentioned: this.isMentioned(payload),
|
||||
is_important: this.isImportant(payload),
|
||||
ext: chatPayload.ext ?? {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否@我
|
||||
*/
|
||||
private isMentioned(payload : IMessagePayload) : boolean {
|
||||
const mentionPayload = payload as IChatMessage
|
||||
if (mentionPayload.at_user == null)
|
||||
return false
|
||||
else if (mentionPayload.at_user == 'all')
|
||||
return true
|
||||
else if ((mentionPayload.at_user as number[]).includes(this.currentUserId))
|
||||
return true
|
||||
else
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否重要消息
|
||||
*/
|
||||
private isImportant(payload : IMessagePayload) : boolean {
|
||||
const chatPayload = payload as IChatMessage
|
||||
// 红包、转账、重要通知等
|
||||
const importantTypes = ['red_packet', 'transfer', 'system_notify']
|
||||
return importantTypes.includes(chatPayload.type ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化消息预览内容
|
||||
*/
|
||||
private formatContentPreview(payload : IMessagePayload) : string {
|
||||
const chatPayload = payload as IChatMessage
|
||||
if (chatPayload.content == null) return '[消息]'
|
||||
const type = chatPayload.type ?? 'text'
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return '[文本消息]'
|
||||
case 'image':
|
||||
return '[图片]'
|
||||
case 'audio':
|
||||
return '[语音]'
|
||||
case 'video':
|
||||
return '[视频]'
|
||||
case 'file':
|
||||
return '[文件]'
|
||||
case 'location':
|
||||
return '[位置]'
|
||||
case 'emoji':
|
||||
return '[表情]'
|
||||
case 'sticker':
|
||||
return '[贴纸]'
|
||||
case 'red_packet':
|
||||
return '[红包]'
|
||||
case 'recall':
|
||||
return '[消息已撤回]'
|
||||
case 'system':
|
||||
return '[系统消息]'
|
||||
default:
|
||||
return '[消息]'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话信息
|
||||
*/
|
||||
private updateSession(session_id : string, message : IMessageBase) : void {
|
||||
const { header, payload } = message
|
||||
const chatPayload = payload as IChatMessage
|
||||
// 获取或创建会话
|
||||
let session = this.sessions.get(session_id)
|
||||
if (session == null) {
|
||||
session = this.createSession(session_id, message)
|
||||
}
|
||||
// 更新最后消息
|
||||
session.last_message = this.createMessagePreview(message)
|
||||
session.last_active_time = header.timestamp
|
||||
// 增加未读数(如果不是自己发送的消息)
|
||||
if (chatPayload.uid != this.currentUserId) {
|
||||
session.unread_count++
|
||||
}
|
||||
this.sessions.set(session_id, session)
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加消息到会话
|
||||
*/
|
||||
private addMessageToSession(session_id : string, message : IMessageBase) : void {
|
||||
if (!this.messages.has(session_id)) {
|
||||
this.messages.set(session_id, [])
|
||||
}
|
||||
const sessionMessages = this.messages.get(session_id)!
|
||||
sessionMessages.push(message)
|
||||
// 保持消息按时间排序
|
||||
sessionMessages.sort((a, b) => a.header.timestamp - b.header.timestamp)
|
||||
// 限制每会话最多保存100条消息
|
||||
if (sessionMessages.length > 100) {
|
||||
sessionMessages.splice(0, sessionMessages.length - 100)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排序后的会话列表
|
||||
*/
|
||||
getSortedSessions() : IMessageSession[] {
|
||||
const sessions : IMessageSession[] = []
|
||||
this.sessions.forEach((value, key) => {
|
||||
sessions.push(value)
|
||||
})
|
||||
// 排序规则:
|
||||
// 1. 置顶的在前
|
||||
// 2. 未读消息数多的在前
|
||||
// 3. 未读且被@的在前
|
||||
// 4. 最后活跃时间新的在前
|
||||
sessions.sort((a, b) => {
|
||||
// 置顶排序
|
||||
if (a.is_pinned != b.is_pinned) {
|
||||
return a.is_pinned ? -1 : 1
|
||||
}
|
||||
// 未读数排序
|
||||
if (a.unread_count != b.unread_count) {
|
||||
return b.unread_count - a.unread_count
|
||||
}
|
||||
// @消息排序
|
||||
const aHasMention = a.last_message?.is_mentioned ?? false
|
||||
const bHasMention = b.last_message?.is_mentioned ?? false
|
||||
if (aHasMention != bHasMention) {
|
||||
return aHasMention ? -1 : 1
|
||||
}
|
||||
// 最后活跃时间排序
|
||||
return b.last_active_time - a.last_active_time
|
||||
})
|
||||
return sessions
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话消息列表
|
||||
*/
|
||||
getSessionMessages(session_id : string) : any[] {
|
||||
return (this.messages.get(session_id) as any[] | null) ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空会话未读数
|
||||
*/
|
||||
clearUnread(session_id : string) : void {
|
||||
const session = this.sessions.get(session_id)
|
||||
if (session != null) {
|
||||
session.unread_count = 0
|
||||
uni.$emit('session_updated', session_id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 置顶/取消置顶
|
||||
*/
|
||||
togglePin(session_id : string, is_pinned : boolean = true) : void {
|
||||
const session = this.sessions.get(session_id)
|
||||
if (session != null) {
|
||||
session.is_pinned = is_pinned
|
||||
uni.$emit('session_updated', session_id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 免打扰/取消免打扰
|
||||
*/
|
||||
toggleMute(session_id : string, is_muted : boolean = true) : void {
|
||||
const session = this.sessions.get(session_id)
|
||||
if (session != null) {
|
||||
session.is_muted = is_muted
|
||||
uni.$emit('session_updated', session_id)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册消息回调
|
||||
*/
|
||||
on(callbacks : IMessageServiceCallbacks) : void {
|
||||
if (callbacks.onNewMessage != null) this.callbacks.onNewMessage = callbacks.onNewMessage
|
||||
if (callbacks.onMessageStatusChange != null) this.callbacks.onMessageStatusChange = callbacks.onMessageStatusChange
|
||||
}
|
||||
}
|
||||
|
||||
export { IMessageSession }
|
||||
export const messageService = new MessageService()
|
||||
export const messageManager = messageService
|
||||
export default messageService
|
||||
@@ -0,0 +1,453 @@
|
||||
|
||||
export type ContentType =
|
||||
| 'text' // 文本
|
||||
| 'image' // 图片
|
||||
| 'audio' // 音频
|
||||
| 'video' // 视频
|
||||
| 'file' // 文件
|
||||
| 'location' // 位置
|
||||
| 'emoji' // 表情
|
||||
| 'sticker' // 贴纸
|
||||
| 'card' // 卡片
|
||||
| 'system' // 系统消息
|
||||
| 'notify' // 通知消息
|
||||
| 'status' // 状态消息
|
||||
| 'game' // 游戏消息
|
||||
| 'room' // 房间消息
|
||||
| 'user' // 用户消息
|
||||
| 'error' // 错误消息
|
||||
| 'red_packet' // 红包
|
||||
| 'transfer' // 转账
|
||||
| 'recall' // 撤回
|
||||
|
||||
export type MessageStatus =
|
||||
| 'sending' // 发送中
|
||||
| 'sent' // 已发送
|
||||
| 'delivereadBase' //已送达
|
||||
| 'read' // 已读
|
||||
| 'failed' // 失败
|
||||
| 'recalled'; // 已撤回
|
||||
|
||||
// ==================== 1. 文本消息 ====================
|
||||
export type IHeartbeatMessage = {
|
||||
uid : number; // 用户ID
|
||||
nickname ?: string; // 昵称(可选,用于显示)
|
||||
avatar ?: string; // 头像URL(可选,用于显示)
|
||||
session_id ?: string; // 会话ID uid | group_id | room _id
|
||||
status : MessageStatus;
|
||||
rtt ?: number; // 往返延迟(毫秒,响应时返回)
|
||||
type ?: 'text';
|
||||
content ?: string | IMessageContent
|
||||
at_all ?: boolean;
|
||||
at_user ?: null | 'all' | number[];
|
||||
ext ?: any; // 扩展字段
|
||||
}
|
||||
|
||||
// ==================== 2. 图片消息 ====================
|
||||
type IImageField = {
|
||||
// 图片消息特有字段
|
||||
file_url : string;
|
||||
file_name ?: string;
|
||||
file_size ?: number;
|
||||
file_mime ?: string;
|
||||
file_width ?: number;
|
||||
file_height ?: number;
|
||||
thumbnail_url ?: string;
|
||||
thumbnail_size ?: number;
|
||||
at_user ?: number[];
|
||||
}
|
||||
|
||||
// ==================== 3. 音频消息 ====================
|
||||
type IVoiceField = {
|
||||
// 音频消息特有字段
|
||||
file_url : string;
|
||||
file_name ?: string;
|
||||
file_size ?: number;
|
||||
file_mime ?: string;
|
||||
file_duration ?: number;
|
||||
thumbnail_url ?: string;
|
||||
}
|
||||
|
||||
// ==================== 4. 视频消息 ====================
|
||||
type IVideoField = {
|
||||
// 音频消息特有字段
|
||||
file_url : string;
|
||||
file_name ?: string;
|
||||
file_size ?: number;
|
||||
file_mime ?: string;
|
||||
file_duration ?: number;
|
||||
thumbnail_url ?: string;
|
||||
}
|
||||
|
||||
// ==================== 5. 文件消息 ====================
|
||||
type IFileField = {
|
||||
// 文件消息特有字段
|
||||
file_url : string;
|
||||
file_name : string;
|
||||
file_size : number;
|
||||
file_mime ?: string;
|
||||
}
|
||||
|
||||
// ==================== 6. 位置消息 ====================
|
||||
type ILocationField = {
|
||||
// 位置消息特有字段
|
||||
latitude : number;
|
||||
longitude : number;
|
||||
address ?: string;
|
||||
}
|
||||
|
||||
// ==================== 7. 表情消息 ====================
|
||||
type IEmojiField = {
|
||||
|
||||
// 表情消息特有字段
|
||||
emoji_code : string;
|
||||
}
|
||||
|
||||
// ==================== 8. 贴纸消息 ====================
|
||||
type IStickerField = {
|
||||
// 贴纸消息特有字段
|
||||
sticker_id : string;
|
||||
sticker_url : string;
|
||||
}
|
||||
|
||||
// ==================== 9. 卡片消息 ====================
|
||||
type ICardField = {
|
||||
// 卡片消息特有字段
|
||||
card_title : string;
|
||||
card_desc ?: string;
|
||||
card_image ?: string;
|
||||
card_url ?: string;
|
||||
card_data ?: any;
|
||||
}
|
||||
|
||||
// ==================== 10. 系统消息 ====================
|
||||
type ISystemField = {
|
||||
// 系统消息特有字段
|
||||
content : string;
|
||||
sub_type ?: string;
|
||||
error_code ?: number;
|
||||
error_msg ?: string;
|
||||
error_detail ?: string;
|
||||
}
|
||||
|
||||
export type IMessageContent =
|
||||
string
|
||||
| IImageField
|
||||
| IVoiceField
|
||||
| IVideoField
|
||||
| IFileField
|
||||
| ILocationField
|
||||
| IEmojiField
|
||||
| IStickerField
|
||||
| ICardField
|
||||
| ISystemField
|
||||
|
||||
export interface IChatMessage {
|
||||
uid : number;
|
||||
nickname ?: string; // 昵称(可选,用于显示)
|
||||
avatar ?: string; // 头像URL(可选,用于显示)
|
||||
|
||||
session_id ?: string; // 会话ID uid | group_id | room _id
|
||||
receiver : number // 接收用户UID
|
||||
|
||||
type : ContentType;
|
||||
status : MessageStatus;
|
||||
msg_id ?: string;
|
||||
ext ?: any;
|
||||
// 文本消息特有字段
|
||||
content : IMessageContent;
|
||||
at_user ?: 'all' | number[];
|
||||
}
|
||||
|
||||
// ==================== 11. 通知消息 ====================
|
||||
export type NotifyType = 'system' | 'friend' | 'group' | 'game';
|
||||
export type ActionType = 'accept' | 'reject' | 'ignore';
|
||||
|
||||
export interface INotifyMessage {
|
||||
// 基础字段
|
||||
uid : number;
|
||||
nickname ?: string; // 昵称(可选,用于显示)
|
||||
avatar ?: string; // 头像URL(可选,用于显示)
|
||||
type : 'notify';
|
||||
status : MessageStatus;
|
||||
msg_id ?: string;
|
||||
receiver : number
|
||||
at_user ?: number[];
|
||||
|
||||
session_id ?: string;
|
||||
ext ?: any;
|
||||
|
||||
content ?: string | IMessageContent
|
||||
// 通知消息特有字段
|
||||
notify_type : NotifyType;
|
||||
notify_title ?: string;
|
||||
notify_content : string;
|
||||
notify_icon ?: string;
|
||||
need_confirm ?: boolean;
|
||||
action ?: ActionType;
|
||||
}
|
||||
|
||||
// ==================== 12. 状态消息 ====================
|
||||
export interface IStatusMessage {
|
||||
uid : number;
|
||||
nickname ?: string; // 昵称(可选,用于显示)
|
||||
avatar ?: string; // 头像URL(可选,用于显示)
|
||||
|
||||
session_id ?: string; // 会话ID uid | group_id | room _id
|
||||
receiver : number // 接收用户UID
|
||||
|
||||
content ?: string | IMessageContent
|
||||
type : 'status';
|
||||
status : MessageStatus;
|
||||
msg_id ?: string;
|
||||
at_user ?: number[];
|
||||
ext ?: any;
|
||||
|
||||
// 状态消息特有字段
|
||||
status_type : 'online' | 'offline' | 'typing' | 'recording';
|
||||
status_data ?: any;
|
||||
}
|
||||
|
||||
// ==================== 13. 游戏消息 ====================
|
||||
export type GameType = 'chess' | 'poker' | 'mahjong' | 'custom';
|
||||
export type GameAction = 'start' | 'move' | 'end' | 'ready' | 'leave';
|
||||
export type GameState = 'waiting' | 'playing' | 'finished';
|
||||
|
||||
export interface IGameMessage {
|
||||
uid : number;
|
||||
nickname ?: string; // 昵称(可选,用于显示)
|
||||
avatar ?: string; // 头像URL(可选,用于显示)
|
||||
|
||||
session_id ?: string; // 会话ID uid | group_id | room _id
|
||||
receiver : number // 接收用户UID
|
||||
|
||||
content ?: string | IMessageContent
|
||||
type : 'game';
|
||||
status : MessageStatus;
|
||||
msg_id ?: string;
|
||||
at_user ?: number[];
|
||||
ext ?: any;
|
||||
|
||||
// 游戏消息特有字段
|
||||
game_type : GameType;
|
||||
game_action : GameAction;
|
||||
game_data ?: any;
|
||||
game_status ?: GameState;
|
||||
current_player ?: string;
|
||||
game_score ?: number;
|
||||
}
|
||||
|
||||
// ==================== 14. 房间消息 ====================
|
||||
export type RoomType = 'game' | 'chat';
|
||||
export type RoomStatus = 'waiting' | 'playing' | 'finished' | 'closed';
|
||||
|
||||
export interface IRoomMember {
|
||||
member_id : string;
|
||||
nickname ?: string;
|
||||
avatar ?: string;
|
||||
role ?: 'owner' | 'admin' | 'member';
|
||||
join_time ?: number;
|
||||
online ?: boolean;
|
||||
}
|
||||
|
||||
export interface IRoomMessage {
|
||||
uid : number;
|
||||
nickname ?: string; // 昵称(可选,用于显示)
|
||||
avatar ?: string; // 头像URL(可选,用于显示)
|
||||
|
||||
session_id ?: string; // 会话ID uid | group_id | room _id
|
||||
receiver : number // 接收用户UID
|
||||
|
||||
content ?: string | IMessageContent
|
||||
type : 'room';
|
||||
status : MessageStatus;
|
||||
msg_id ?: string;
|
||||
at_user ?: number[];
|
||||
ext ?: any;
|
||||
|
||||
// 房间消息特有字段
|
||||
room_name ?: string;
|
||||
room_type : RoomType;
|
||||
room_status ?: RoomStatus;
|
||||
room_members ?: IRoomMember[];
|
||||
room_max_members ?: number;
|
||||
room_description ?: string;
|
||||
room_created_time ?: number;
|
||||
}
|
||||
|
||||
// ==================== 15. 用户消息 ====================
|
||||
export type UserStatus = 'online' | 'offline' | 'away' | 'busy';
|
||||
|
||||
export interface IUserMessage {
|
||||
uid : number;
|
||||
nickname ?: string; // 昵称(可选,用于显示)
|
||||
avatar ?: string; // 头像URL(可选,用于显示)
|
||||
|
||||
session_id ?: string; // 会话ID uid | group_id | room _id
|
||||
receiver : number // 接收用户UID
|
||||
|
||||
content ?: string | IMessageContent
|
||||
type : 'user';
|
||||
status : MessageStatus;
|
||||
msg_id ?: string;
|
||||
at_user ?: number[];
|
||||
ext ?: any;
|
||||
|
||||
user_status ?: UserStatus;
|
||||
signature ?: string;
|
||||
user_data ?: any;
|
||||
}
|
||||
|
||||
// ==================== 16. 错误消息 ====================
|
||||
export interface IErrorMessage {
|
||||
uid : number;
|
||||
nickname ?: string; // 昵称(可选,用于显示)
|
||||
avatar ?: string; // 头像URL(可选,用于显示)
|
||||
|
||||
session_id ?: string; // 会话ID uid | group_id | room _id
|
||||
receiver : number; // 接收用户UID
|
||||
|
||||
type? : 'error';
|
||||
content ?: string | IMessageContent
|
||||
status? : MessageStatus;
|
||||
msg_id ?: string;
|
||||
at_user ?: number[];
|
||||
ext ?: any;
|
||||
|
||||
// 错误消息特有字段
|
||||
error_code ?: number;
|
||||
error_msg ?: string;
|
||||
error_detail ?: string;
|
||||
error_data ?: any;
|
||||
}
|
||||
|
||||
// ==================== 17. 联合类型组织 ====================
|
||||
// 聊天消息(用户之间的消息)
|
||||
|
||||
|
||||
// 完整的消息载荷类型
|
||||
export type IMessagePayload =
|
||||
IHeartbeatMessage
|
||||
| IChatMessage // 聊天消息
|
||||
| INotifyMessage // 通知消息
|
||||
| IStatusMessage // 状态消息
|
||||
| IGameMessage // 游戏消息
|
||||
| IRoomMessage // 房间消息
|
||||
| IUserMessage // 用户消息
|
||||
| IErrorMessage; // 错误消息
|
||||
|
||||
|
||||
|
||||
|
||||
export type IMessageHeader = {
|
||||
// 消息唯一ID(雪花算法生成,必填)
|
||||
id : string;
|
||||
// 消息类型(必填)
|
||||
type : 'ping' | 'pong' | 'chat' | 'notify' | 'status' | 'game' | 'system';
|
||||
// 目标会话类型(single/group/room/system,选填)
|
||||
target : "single" | "group" | "room" | "system";
|
||||
// 客户端序列号(用于去重和顺序保证,必填)
|
||||
seqno : number;
|
||||
// 时间戳(毫秒,必填)
|
||||
timestamp : number;
|
||||
// 消息来源(client/server,选填)
|
||||
source ?: string;
|
||||
version : "1.0.0" | string
|
||||
token ?: string
|
||||
platform ?: 'ios' | 'android' | 'web'; // 平台类型
|
||||
device_id ?: string; // 设备ID
|
||||
network_type ?: 'wifi' | '4g' | '5g' | 'unknown'; // 网络类型
|
||||
|
||||
}
|
||||
|
||||
export type IMessageBase = {
|
||||
// 消息头
|
||||
header : IMessageHeader;
|
||||
// 消息体
|
||||
payload : IMessagePayload;
|
||||
}
|
||||
|
||||
export type IMessageSession = {
|
||||
// 会话唯一标识(user_id/group_id/room_id/system)
|
||||
session_id : string;
|
||||
// 会话类型(single/group/room/system)
|
||||
session_type : 'single' | 'group' | 'room' | 'system';
|
||||
// 会话名称
|
||||
session_name : string;
|
||||
// 会话头像/封面
|
||||
session_avatar : string;
|
||||
// 最后一条消息
|
||||
last_message : IMessagePreview | null;
|
||||
// 未读消息数
|
||||
unread_count : number;
|
||||
// 是否置顶
|
||||
is_pinned : boolean;
|
||||
// 是否免打扰
|
||||
is_muted : boolean;
|
||||
// 最后活跃时间(毫秒时间戳)
|
||||
last_active_time : number;
|
||||
// 创建时间
|
||||
created_time : number;
|
||||
// 会话状态(normal/archived/deleted)
|
||||
status : 'normal' | 'archived' | 'deleted';
|
||||
// 扩展字段
|
||||
ext ?: any;
|
||||
}
|
||||
|
||||
export type IMessagePreview = {
|
||||
// 消息ID
|
||||
id : string;
|
||||
// 消息类型
|
||||
type : string;
|
||||
// 消息内容预览
|
||||
content_preview : string;
|
||||
// 发送者(单聊时显示对方昵称)
|
||||
sender_name ?: string;
|
||||
// 发送者头像
|
||||
sender_avatar ?: string;
|
||||
// 消息时间
|
||||
timestamp : number;
|
||||
// 消息状态
|
||||
msg_status : string;
|
||||
// 是否@我
|
||||
is_mentioned : boolean;
|
||||
// 是否为重要消息
|
||||
is_important : boolean;
|
||||
// 消息扩展信息
|
||||
ext ?: any;
|
||||
}
|
||||
|
||||
export type IWebSocketPayload = string | UTSJSONObject | ArrayBuffer | IMessageBase
|
||||
|
||||
export type IConnectOptions = {
|
||||
reconnectInterval ?: number
|
||||
heartbeatInterval ?: number
|
||||
maxReconnectCount ?: number
|
||||
protocols ?: Array<string> | null
|
||||
header ?: UTSJSONObject | null
|
||||
heartbeatTimeout ?: number
|
||||
connectTimeout ?: number
|
||||
debug ?: boolean
|
||||
}
|
||||
|
||||
export type IWebSocketCallbacks = {
|
||||
onOpen ?: ((res : any) => void) | null
|
||||
onMessage ?: ((res : IMessageBase) => void) | null
|
||||
onError ?: ((res : any) => void) | null
|
||||
onClose ?: ((res : any) => void) | null
|
||||
onReconnect ?: ((res : any) => void) | null
|
||||
onReconnectFailed ?: ((res : any) => void) | null
|
||||
onHeartbeatTimeout ?: ((res : any) => void) | null
|
||||
}
|
||||
|
||||
export type IMessageQueueItem = {
|
||||
data : IMessageBase | IWebSocketPayload
|
||||
success : (() => void) | null
|
||||
fail : ((err : any) => void) | null
|
||||
}
|
||||
|
||||
export type IMessageServiceCallbacks = {
|
||||
onNewMessage ?: ((msg : IMessageBase) => void) | null
|
||||
//onConversationUpdate ?: ((conv : IConversation) => void) | null
|
||||
onMessageStatusChange ?: ((msgId : string, status : string) => void) | null
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
|
||||
|
||||
|
||||
// 消息类型 文本 图片 音频 视频 文件 // 位置 表情 // 贴纸// 卡片 系统消息
|
||||
export type ContentType = 'text' | 'image' | 'audio' | 'video' | 'file' | 'location' | 'emoji' | 'sticker' | 'card' | 'system'
|
||||
|
||||
// 消息状态 发送中 已发送 已送达 已读 失败 已撤回
|
||||
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'read' | 'failed' | 'recalled'
|
||||
|
||||
|
||||
export interface IPayloadBase {
|
||||
msg_id : string; // 消息ID
|
||||
seq_no : number; // 序列号
|
||||
timestamp : number; // 时间戳
|
||||
uid : number; // 发送者ID
|
||||
msg_status : MessageStatus; // 消息状态
|
||||
target_msg_id ?: string; // 目标消息ID
|
||||
to_user_id ?: string; // 会话路由
|
||||
group_id ?: string;
|
||||
room_id ?: string;
|
||||
session_id ?: string;
|
||||
ext ?: any; // 扩展字段
|
||||
}
|
||||
export interface IHeartbeatMessage extends IPayloadBase {
|
||||
msg_id : string; // 消息ID
|
||||
seq_no : number; // 序列号
|
||||
timestamp : number; // 时间戳
|
||||
uid : number; // 发送者ID
|
||||
}
|
||||
// ==================== 3. 聊天消息类型 ====================
|
||||
|
||||
// 文本消息
|
||||
export interface ITextMessage extends IPayloadBase {
|
||||
content_type : ContentType
|
||||
content : string;
|
||||
at_all ?: boolean;
|
||||
at_users ?: number[];
|
||||
sub_type ?: string;
|
||||
}
|
||||
|
||||
// 图片消息
|
||||
export interface IImageMessage extends IPayloadBase {
|
||||
content_type : ContentType
|
||||
file_url : string;
|
||||
file_name ?: string;
|
||||
file_size ?: number;
|
||||
file_mime ?: string;
|
||||
file_width ?: number;
|
||||
file_height ?: number;
|
||||
thumbnail_url ?: string;
|
||||
thumbnail_size ?: number;
|
||||
at_all ?: boolean;
|
||||
at_users ?: number[];
|
||||
}
|
||||
|
||||
// 音频消息
|
||||
export interface IAudioMessage extends IPayloadBase {
|
||||
content_type : ContentType
|
||||
file_url : string;
|
||||
file_name ?: string;
|
||||
file_size ?: number;
|
||||
file_mime ?: string;
|
||||
file_duration ?: number;
|
||||
thumbnail_url ?: string;
|
||||
}
|
||||
|
||||
// 视频消息
|
||||
export interface IVideoMessage extends IPayloadBase {
|
||||
content_type : ContentType
|
||||
file_url : string;
|
||||
file_name ?: string;
|
||||
file_size ?: number;
|
||||
file_mime ?: string;
|
||||
file_duration ?: number;
|
||||
file_width ?: number;
|
||||
file_height ?: number;
|
||||
thumbnail_url ?: string;
|
||||
thumbnail_size ?: number;
|
||||
}
|
||||
|
||||
// 文件消息
|
||||
export interface IFileMessage extends IPayloadBase {
|
||||
content_type : ContentType
|
||||
file_url : string;
|
||||
file_name : string;
|
||||
file_size : number;
|
||||
file_mime ?: string;
|
||||
}
|
||||
|
||||
// 位置消息
|
||||
export interface ILocationMessage extends IPayloadBase {
|
||||
content_type : ContentType
|
||||
latitude : number;
|
||||
longitude : number;
|
||||
address ?: string;
|
||||
}
|
||||
|
||||
// 表情消息
|
||||
export interface IEmojiMessage extends IPayloadBase {
|
||||
content_type : ContentType
|
||||
emoji_code : string;
|
||||
}
|
||||
|
||||
// 贴纸消息
|
||||
export interface IStickerMessage extends IPayloadBase {
|
||||
content_type : ContentType
|
||||
sticker_id : string;
|
||||
sticker_url : string;
|
||||
}
|
||||
|
||||
// 卡片消息
|
||||
export interface ICardMessage extends IPayloadBase {
|
||||
content_type : ContentType
|
||||
card_title : string;
|
||||
card_desc ?: string;
|
||||
card_image ?: string;
|
||||
card_url ?: string;
|
||||
card_data ?: any;
|
||||
}
|
||||
|
||||
// 系统消息
|
||||
export interface ISystemMessage extends IPayloadBase {
|
||||
content_type : ContentType
|
||||
content : string;
|
||||
sub_type ?: string;
|
||||
error_code ?: number;
|
||||
error_msg ?: string;
|
||||
error_detail ?: string;
|
||||
}
|
||||
|
||||
|
||||
// ==================== 4. 通知消息类型 ====================
|
||||
|
||||
export type NotifyType = 'system' | 'friend' | 'group' | 'game';
|
||||
export type ActionType = 'accept' | 'reject' | 'ignore';
|
||||
|
||||
|
||||
export interface INotifyMessage extends IPayloadBase {
|
||||
notify_type : NotifyType;
|
||||
notify_title ?: string;
|
||||
notify_content : string;
|
||||
notify_icon ?: string;
|
||||
need_confirm ?: boolean;
|
||||
action ?: ActionType;
|
||||
}
|
||||
|
||||
// ==================== 5. 状态消息类型 ====================
|
||||
|
||||
export interface IStatusMessage extends IPayloadBase {
|
||||
|
||||
status_type : 'online' | 'offline' | 'typing' | 'recording';
|
||||
status_data ?: any;
|
||||
}
|
||||
|
||||
|
||||
// ==================== 6. 游戏消息类型 ====================
|
||||
|
||||
export type GameType = 'chess' | 'poker' | 'mahjong' | 'custom';
|
||||
export type GameAction = 'start' | 'move' | 'end' | 'ready' | 'leave';
|
||||
export type GameState = 'waiting' | 'playing' | 'finished';
|
||||
|
||||
export interface IGameMessage extends IPayloadBase {
|
||||
|
||||
game_type : GameType;
|
||||
game_action : GameAction;
|
||||
game_data ?: any;
|
||||
game_status ?: GameState;
|
||||
current_player ?: string;
|
||||
game_score ?: number;
|
||||
}
|
||||
|
||||
// ==================== 7. 房间消息类型 ====================
|
||||
|
||||
export type RoomType = 'game' | 'chat';
|
||||
export type RoomStatus = 'waiting' | 'playing' | 'finished' | 'closed';
|
||||
|
||||
export interface IRoomMember {
|
||||
member_id : string;
|
||||
nickname ?: string;
|
||||
avatar ?: string;
|
||||
role ?: 'owner' | 'admin' | 'member';
|
||||
join_time ?: number;
|
||||
online ?: boolean;
|
||||
}
|
||||
|
||||
export interface IRoomMessage extends IPayloadBase {
|
||||
|
||||
room_name ?: string;
|
||||
room_type : RoomType;
|
||||
room_status ?: RoomStatus;
|
||||
room_members ?: IRoomMember[];
|
||||
room_max_members ?: number;
|
||||
room_description ?: string;
|
||||
room_created_time ?: number;
|
||||
}
|
||||
// ==================== 8. 用户消息类型 ====================
|
||||
|
||||
export type UserStatus = 'online' | 'offline' | 'away' | 'busy';
|
||||
|
||||
export interface IUserMessage extends IPayloadBase {
|
||||
|
||||
nickname ?: string;
|
||||
avatar ?: string;
|
||||
user_status ?: UserStatus;
|
||||
signature ?: string;
|
||||
user_data ?: any;
|
||||
}
|
||||
|
||||
// ==================== 9. 错误消息类型 ====================
|
||||
|
||||
export interface IErrorMessage extends IPayloadBase {
|
||||
|
||||
error_code : number;
|
||||
error_msg : string;
|
||||
error_detail ?: string;
|
||||
error_data ?: any;
|
||||
}
|
||||
|
||||
// ==================== 10. 联合类型组织 ====================
|
||||
|
||||
// 聊天消息(用户之间的消息)
|
||||
export type IChatMessage = ITextMessage | IImageMessage | IAudioMessage | IVideoMessage | IFileMessage | ILocationMessage | IEmojiMessage | IStickerMessage | ICardMessage | ISystemMessage;
|
||||
|
||||
// 通知消息
|
||||
export type INotifyMessageUnion = INotifyMessage;
|
||||
|
||||
// 系统状态消息
|
||||
export type IStatusMessageUnion = IStatusMessage;
|
||||
|
||||
// 游戏消息
|
||||
export type IGameMessageUnion = IGameMessage;
|
||||
|
||||
// 房间消息
|
||||
export type IRoomMessageUnion = IRoomMessage;
|
||||
|
||||
// 用户消息
|
||||
export type IUserMessageUnion = IUserMessage;
|
||||
|
||||
// 错误消息
|
||||
export type IErrorMessageUnion = IErrorMessage;
|
||||
|
||||
// 完整的消息载荷类型 // 聊天消息 通知消息 状态消息 游戏消息 房间消息 用户消息 错误消息
|
||||
type IMessagePayload = IHeartbeatMessage | IChatMessage | INotifyMessage | IStatusMessage | IGameMessage | IRoomMessage | IUserMessage | IErrorMessage;
|
||||
export default IMessagePayload;
|
||||
@@ -0,0 +1,424 @@
|
||||
|
||||
import {
|
||||
IMessageBase,
|
||||
IConnectOptions,
|
||||
IWebSocketCallbacks,
|
||||
IMessageQueueItem,
|
||||
IChatMessage,
|
||||
IHeartbeatMessage,
|
||||
IWebSocketPayload
|
||||
} from './socket-types.uts'
|
||||
|
||||
import userState from '@/stores/user.uts'
|
||||
|
||||
class WebSocketManager {
|
||||
private static instance : WebSocketManager | null = null
|
||||
private socketTask : SocketTask | null = null
|
||||
private url = ''
|
||||
private protocols : Array<string> = []
|
||||
private header : UTSJSONObject | null = null
|
||||
private connected = false
|
||||
private connecting = false
|
||||
private manualClose = false
|
||||
private reconnectTimer : number | null = null
|
||||
private heartbeatTimer : number | null = null
|
||||
private lastHeartbeatAt = 0
|
||||
private waitingHeartbeat = false
|
||||
private reconnectCount = 0
|
||||
private reconnectInterval = 3000
|
||||
private heartbeatInterval = 20000
|
||||
private heartbeatTimeout = 10000
|
||||
private connectTimeout = 10000
|
||||
private maxReconnectCount = 5
|
||||
private debug = false
|
||||
private callbacks : IWebSocketCallbacks = {}
|
||||
private messageQueue : Array<IMessageQueueItem> = []
|
||||
|
||||
static getInstance() : WebSocketManager {
|
||||
if (WebSocketManager.instance == null) {
|
||||
WebSocketManager.instance = new WebSocketManager()
|
||||
}
|
||||
return WebSocketManager.instance
|
||||
}
|
||||
|
||||
connect(url : string, options : IConnectOptions | null = null) : void {
|
||||
this.log('WebSocket connect')
|
||||
if (url.length == 0) {
|
||||
this.log('WebSocket url 不能为空')
|
||||
return
|
||||
}
|
||||
const urlChanged = this.url.length > 0 && this.url != url
|
||||
this.url = url
|
||||
this.applyOptions(options)
|
||||
if (urlChanged == true) {
|
||||
this.releaseSocket()
|
||||
}
|
||||
if (this.connected == true || this.connecting == true) {
|
||||
return
|
||||
}
|
||||
this.manualClose = false
|
||||
this.connecting = true
|
||||
this.clearReconnectTimer()
|
||||
this.createSocketTask()
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用配置
|
||||
*/
|
||||
private applyOptions(options : IConnectOptions | null) : void {
|
||||
if (options == null) {
|
||||
return
|
||||
}
|
||||
if (options.reconnectInterval != null) this.reconnectInterval = options.reconnectInterval
|
||||
if (options.heartbeatInterval != null) this.heartbeatInterval = options.heartbeatInterval
|
||||
if (options.maxReconnectCount != null) this.maxReconnectCount = options.maxReconnectCount
|
||||
if (options.protocols != null) this.protocols = options.protocols
|
||||
if (options.header != null) this.header = options.header
|
||||
if (options.heartbeatTimeout != null) this.heartbeatTimeout = options.heartbeatTimeout
|
||||
if (options.connectTimeout != null) this.connectTimeout = options.connectTimeout
|
||||
if (options.debug != null) this.debug = options.debug
|
||||
}
|
||||
|
||||
private createSocketTask() : void {
|
||||
this.releaseSocket()
|
||||
let finished = false
|
||||
const finishConnect = () : void => {
|
||||
if (finished == true) {
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
this.connecting = false
|
||||
}
|
||||
|
||||
const connectTimeoutTimer = setTimeout(() => {
|
||||
finishConnect()
|
||||
this.handleError({ errMsg: 'WebSocket 连接超时' })
|
||||
this.scheduleReconnect('connect timeout')
|
||||
}, this.connectTimeout) as number
|
||||
|
||||
try {
|
||||
this.socketTask = uni.connectSocket({
|
||||
url: this.url,
|
||||
header: this.header,
|
||||
protocols: this.protocols
|
||||
})
|
||||
|
||||
const currentTask = this.socketTask
|
||||
if (currentTask == null) {
|
||||
clearTimeout(connectTimeoutTimer)
|
||||
finishConnect()
|
||||
return
|
||||
}
|
||||
|
||||
currentTask.onOpen((res : any) => {
|
||||
clearTimeout(connectTimeoutTimer)
|
||||
finishConnect()
|
||||
this.connected = true
|
||||
this.reconnectCount = 0
|
||||
this.waitingHeartbeat = false
|
||||
this.startHeartbeat()
|
||||
this.flushMessageQueue()
|
||||
if (this.callbacks.onOpen != null) {
|
||||
this.callbacks.onOpen(res)
|
||||
}
|
||||
uni.$emit('onWsOpen', res)
|
||||
})
|
||||
|
||||
currentTask.onMessage((res : OnSocketMessageCallbackResult) => {
|
||||
console.log(res)
|
||||
this.handleMessage(res)
|
||||
})
|
||||
|
||||
currentTask.onError((err : any) => {
|
||||
clearTimeout(connectTimeoutTimer)
|
||||
finishConnect()
|
||||
uni.$emit('onWsError', err)
|
||||
this.handleError(err)
|
||||
})
|
||||
|
||||
currentTask.onClose((res : any) => {
|
||||
clearTimeout(connectTimeoutTimer)
|
||||
finishConnect()
|
||||
uni.$emit('onWsClose', res)
|
||||
this.handleClose(res)
|
||||
})
|
||||
} catch (error) {
|
||||
clearTimeout(connectTimeoutTimer)
|
||||
finishConnect()
|
||||
this.handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(res : OnSocketMessageCallbackResult) : void {
|
||||
const dataStr = res.data as string
|
||||
// #ifndef APP
|
||||
const rawData = JSON.parse(dataStr) as IMessageBase
|
||||
// #endif
|
||||
// #ifdef APP
|
||||
const rawJson = JSON.parseObject(dataStr)
|
||||
console.log("OnMessage:", rawJson)
|
||||
const rawData = rawJson?.parse<IMessageBase>()
|
||||
// #endif
|
||||
//if (rawData.type == "ping")
|
||||
console.log("OnMessage:", rawData)
|
||||
if (rawData != null && rawData.header.type == "pong") {
|
||||
this.waitingHeartbeat = false
|
||||
this.lastHeartbeatAt = Date.now()
|
||||
return
|
||||
}
|
||||
if (rawData != null && this.callbacks.onMessage != null) {
|
||||
uni.$emit('onWsMessage', rawData)
|
||||
this.callbacks.onMessage(rawData)
|
||||
}
|
||||
}
|
||||
|
||||
send(data : IWebSocketPayload, success : (() => void) | null = null, fail : ((err : any) => void) | null = null) : void {
|
||||
let payload : string | IMessageBase | UTSJSONObject | null = null
|
||||
try {
|
||||
payload = JSON.stringify(data)
|
||||
} catch (error) {
|
||||
payload = null
|
||||
}
|
||||
if (payload == null) {
|
||||
if (fail != null) {
|
||||
fail({ errMsg: 'WebSocket 消息序列化失败' })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (this.socketTask == null || this.connected == false) {
|
||||
this.enqueueMessage(data, success, fail)
|
||||
return
|
||||
}
|
||||
this.socketTask.send({
|
||||
data: payload,
|
||||
success: () => {
|
||||
if (success != null) {
|
||||
success()
|
||||
}
|
||||
},
|
||||
fail: (err : any) => {
|
||||
this.enqueueMessage(data, success, fail)
|
||||
if (fail != null) {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
close(code : number = 1000, reason : string = 'manual close') : void {
|
||||
this.manualClose = true
|
||||
this.stopHeartbeat()
|
||||
this.clearReconnectTimer()
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
if (this.socketTask != null) {
|
||||
try {
|
||||
this.socketTask.close({ code, reason })
|
||||
} catch (error) {
|
||||
this.log('关闭 WebSocket 失败', error)
|
||||
}
|
||||
}
|
||||
this.releaseSocket()
|
||||
}
|
||||
|
||||
on(callbacks : IWebSocketCallbacks) : void {
|
||||
if (callbacks.onOpen != null) this.callbacks.onOpen = callbacks.onOpen
|
||||
if (callbacks.onMessage != null) this.callbacks.onMessage = callbacks.onMessage
|
||||
if (callbacks.onError != null) this.callbacks.onError = callbacks.onError
|
||||
if (callbacks.onClose != null) this.callbacks.onClose = callbacks.onClose
|
||||
if (callbacks.onReconnect != null) this.callbacks.onReconnect = callbacks.onReconnect
|
||||
if (callbacks.onReconnectFailed != null) this.callbacks.onReconnectFailed = callbacks.onReconnectFailed
|
||||
if (callbacks.onHeartbeatTimeout != null) this.callbacks.onHeartbeatTimeout = callbacks.onHeartbeatTimeout
|
||||
}
|
||||
|
||||
isConnected() : boolean {
|
||||
return this.connected
|
||||
}
|
||||
|
||||
getReconnectCount() : number {
|
||||
return this.reconnectCount
|
||||
}
|
||||
|
||||
getStats() : UTSJSONObject {
|
||||
return {
|
||||
url: this.url,
|
||||
connected: this.connected,
|
||||
connecting: this.connecting,
|
||||
reconnectCount: this.reconnectCount,
|
||||
queueSize: this.messageQueue.length,
|
||||
lastHeartbeatAt: this.lastHeartbeatAt
|
||||
} as UTSJSONObject
|
||||
}
|
||||
|
||||
removeAllListeners() : void {
|
||||
this.callbacks = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始心跳
|
||||
*/
|
||||
private startHeartbeat() : void {
|
||||
this.stopHeartbeat()
|
||||
if (this.heartbeatInterval <= 0) return
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.connected == false) {
|
||||
this.stopHeartbeat()
|
||||
return
|
||||
}
|
||||
if (this.waitingHeartbeat == true) {
|
||||
const expired = Date.now() - this.lastHeartbeatAt > this.heartbeatTimeout
|
||||
if (expired == true) {
|
||||
if (this.callbacks.onHeartbeatTimeout != null) {
|
||||
this.callbacks.onHeartbeatTimeout({ errMsg: 'WebSocket 心跳超时' })
|
||||
}
|
||||
this.scheduleReconnect('heartbeat timeout')
|
||||
return
|
||||
}
|
||||
}
|
||||
this.waitingHeartbeat = true
|
||||
this.lastHeartbeatAt = Date.now()
|
||||
const heartbeatUserId = userState.uid
|
||||
const heartData : IMessageBase = {
|
||||
header: {
|
||||
id: "heartbeat_1700000000000",
|
||||
type: "ping",
|
||||
seqno: 1,
|
||||
timestamp: Date.now(),
|
||||
source: "client",
|
||||
target: "system",
|
||||
version: "1.0.0",
|
||||
platform: 'android',
|
||||
device_id: "device_id",
|
||||
network_type: 'wifi',
|
||||
},
|
||||
payload: {
|
||||
uid: heartbeatUserId,
|
||||
status: 'sent',
|
||||
type: 'text',
|
||||
content: 'ping'
|
||||
}
|
||||
}
|
||||
this.send(heartData)
|
||||
}, this.heartbeatInterval) as number
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止心跳
|
||||
*/
|
||||
private stopHeartbeat() : void {
|
||||
const timer = this.heartbeatTimer
|
||||
if (timer != null) {
|
||||
clearInterval(timer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
this.waitingHeartbeat = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 将消息入队
|
||||
* @param data IWebSocketPayload
|
||||
* @param success (() => void) | null
|
||||
* @param fail fail : ((err : any) => void) | null
|
||||
* @return { void }
|
||||
*/
|
||||
private enqueueMessage(data : IWebSocketPayload, success : (() => void) | null, fail : ((err : any) => void) | null) : void {
|
||||
this.messageQueue.push({ data, success, fail })
|
||||
if (this.messageQueue.length > 100) {
|
||||
this.messageQueue.splice(0, this.messageQueue.length - 100)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新消息队列
|
||||
* @return {void}
|
||||
*/
|
||||
private flushMessageQueue() : void {
|
||||
if (this.messageQueue.length == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const queue = this.messageQueue.slice()
|
||||
this.messageQueue = []
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const item = queue[i]
|
||||
this.send(item.data, item.success, item.fail)
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(err : any) : void {
|
||||
if (this.callbacks.onError != null) {
|
||||
this.callbacks.onError(err)
|
||||
}
|
||||
this.scheduleReconnect('socket error')
|
||||
}
|
||||
|
||||
private handleClose(res : any) : void {
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.stopHeartbeat()
|
||||
this.releaseSocket()
|
||||
if (this.callbacks.onClose != null) {
|
||||
this.callbacks.onClose(res)
|
||||
}
|
||||
if (this.manualClose == false) {
|
||||
this.scheduleReconnect('socket close')
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(reason : string) : void {
|
||||
if (this.manualClose == true || this.url.length == 0) {
|
||||
return
|
||||
}
|
||||
if (this.reconnectTimer != null) {
|
||||
return
|
||||
}
|
||||
if (this.reconnectCount >= this.maxReconnectCount) {
|
||||
if (this.callbacks.onReconnectFailed != null) {
|
||||
this.callbacks.onReconnectFailed({ errMsg: reason, reconnectCount: this.reconnectCount })
|
||||
}
|
||||
return
|
||||
}
|
||||
this.connected = false
|
||||
this.connecting = false
|
||||
this.stopHeartbeat()
|
||||
this.releaseSocket()
|
||||
this.reconnectCount = this.reconnectCount + 1
|
||||
if (this.callbacks.onReconnect != null) {
|
||||
this.callbacks.onReconnect({ reason, reconnectCount: this.reconnectCount })
|
||||
}
|
||||
const delay = this.reconnectInterval * this.reconnectCount
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect(this.url)
|
||||
}, delay) as number
|
||||
}
|
||||
|
||||
private clearReconnectTimer() : void {
|
||||
const timer = this.reconnectTimer
|
||||
if (timer != null) {
|
||||
clearTimeout(timer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private releaseSocket() : void {
|
||||
this.socketTask = null
|
||||
}
|
||||
|
||||
private log(message : string, detail : any | null = null) : void {
|
||||
if (this.debug == false) {
|
||||
return
|
||||
}
|
||||
|
||||
if (detail != null) {
|
||||
console.log('[WebSocketManager]', message, detail)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[WebSocketManager]', message)
|
||||
}
|
||||
}
|
||||
|
||||
export type { IConnectOptions, IWebSocketCallbacks }
|
||||
export const wsManager = WebSocketManager.getInstance()
|
||||
export default WebSocketManager
|
||||
Reference in New Issue
Block a user