You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

393 lines
13 KiB

/**
* WebSocket 房间连接服务(SockJS + STOMP)
*/
import SockJS from 'sockjs-client'
import { Client } from '@stomp/stompjs'
import { getToken } from '@/utils/auth'
const WS_BASE = process.env.VUE_APP_BASE_API || '/dev-api'
/**
* 创建房间 WebSocket 连接
* @param {Object} options
* @param {string|number} options.roomId - 房间 ID
* @param {Function} options.onMembers - 收到成员列表回调 (members) => {}
* @param {Function} options.onMemberJoined - 成员加入回调 (member) => {}
* @param {Function} options.onMemberLeft - 成员离开回调 (member, sessionId) => {}
* @param {Function} options.onChatMessage - 群聊消息回调 (msg) => {}
* @param {Function} options.onPrivateChat - 私聊消息回调 (msg) => {}
* @param {Function} options.onChatHistory - 群聊历史回调 (messages) => {}
* @param {Function} options.onPrivateChatHistory - 私聊历史回调 (targetUserId, messages) => {}
* @param {Function} options.onSyncRouteVisibility - 航线显隐同步回调 (routeId, visible, senderUserId) => {}
* @param {Function} options.onSyncWaypoints - 航点变更同步回调 (routeId, senderUserId) => {}
* @param {Function} options.onSyncPlatformIcons - 平台图标变更同步回调 (senderUserId) => {}
* @param {Function} options.onSyncRoomDrawings - 空域图形变更同步回调 (senderUserId) => {}
* @param {Function} options.onSyncPlatformStyles - 探测区/威力区样式变更同步回调 (senderUserId) => {}
* @param {Function} options.onRoomState - 新加入时收到的房间状态 (visibleRouteIds: number[]) => {}
* @param {Function} options.onObjectEditLock - 对象被某成员编辑锁定 (msg: { objectType, objectId, editor }) => {}
* @param {Function} options.onObjectEditUnlock - 对象编辑解锁 (msg: { objectType, objectId, sessionId }) => {}
* @param {Function} options.onUserActivities - 房间用户当前操作快照 (activities: object[]) => {}
* @param {Function} options.onConnected - 连接成功回调
* @param {Function} options.onDisconnected - 断开回调
* @param {Function} options.onError - 错误回调
* @param {string} [options.deviceId] - 设备标识
*/
export function createRoomWebSocket(options) {
const {
roomId,
onMembers,
onMemberJoined,
onMemberLeft,
onChatMessage,
onPrivateChat,
onChatHistory,
onPrivateChatHistory,
onSyncRouteVisibility,
onSyncWaypoints,
onSyncPlatformIcons,
onSyncRoomDrawings,
onSyncPlatformStyles,
onRoomState,
onObjectEditLock,
onObjectEditUnlock,
onUserActivities,
onConnected,
onDisconnected,
onError,
deviceId = 'web-' + Math.random().toString(36).slice(2, 10)
} = options
let client = null
let roomSubscription = null
let privateSubscription = null
let heartbeatTimer = null
let reconnectAttempts = 0
const maxReconnectAttempts = 10
const reconnectDelay = 2000
function getWsUrl() {
const token = getToken()
const base = window.location.origin + WS_BASE
const sep = base.includes('?') ? '&' : '?'
return base + '/ws' + (token ? sep + 'token=' + encodeURIComponent(token) : '')
}
function sendJoin() {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'JOIN', deviceId })
})
}
}
function sendLeave() {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'LEAVE' })
})
}
}
function sendPing() {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'PING' })
})
}
}
/**
* @param {string|{content?: string, messageType?: string, imageUrl?: string}} contentOrPayload - 纯文本或含图片字段的对象
*/
function sendChat(contentOrPayload) {
if (!client || !client.connected) return
const payload =
typeof contentOrPayload === 'string'
? { type: 'CHAT', content: contentOrPayload }
: {
type: 'CHAT',
content: (contentOrPayload && contentOrPayload.content) || '',
...(contentOrPayload && contentOrPayload.messageType
? { messageType: contentOrPayload.messageType }
: {}),
...(contentOrPayload && contentOrPayload.imageUrl
? { imageUrl: contentOrPayload.imageUrl }
: {})
}
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify(payload)
})
}
/**
* @param {string|{content?: string, messageType?: string, imageUrl?: string}} contentOrPayload
*/
function sendPrivateChat(targetUserId, targetUserName, contentOrPayload) {
if (!client || !client.connected) return
const isStr = typeof contentOrPayload === 'string'
const payload = {
type: 'PRIVATE_CHAT',
targetUserId,
targetUserName,
content: isStr ? contentOrPayload : (contentOrPayload && contentOrPayload.content) || '',
...(isStr
? {}
: {
...(contentOrPayload && contentOrPayload.messageType
? { messageType: contentOrPayload.messageType }
: {}),
...(contentOrPayload && contentOrPayload.imageUrl
? { imageUrl: contentOrPayload.imageUrl }
: {})
})
}
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify(payload)
})
}
function sendPrivateChatHistoryRequest(targetUserId) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'PRIVATE_CHAT_HISTORY_REQUEST', targetUserId })
})
}
}
function sendSyncRouteVisibility(routeId, visible) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'SYNC_ROUTE_VISIBILITY', routeId, visible })
})
}
}
function sendSyncWaypoints(routeId) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'SYNC_WAYPOINTS', routeId })
})
}
}
function sendSyncPlatformIcons() {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'SYNC_PLATFORM_ICONS' })
})
}
}
function sendSyncRoomDrawings() {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'SYNC_ROOM_DRAWINGS' })
})
}
}
function sendSyncPlatformStyles() {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'SYNC_PLATFORM_STYLES' })
})
}
}
/** 发送:当前成员锁定某对象进入编辑 */
function sendObjectEditLock(objectType, objectId, objectName) {
if (!client || !client.connected) return
const payload = { type: 'OBJECT_EDIT_LOCK', objectType, objectId }
if (objectName != null && String(objectName).trim() !== '') {
payload.objectName = String(objectName)
}
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify(payload)
})
}
/** 发送:当前地图选中(航线等),供「当前操作」展示 */
function sendUserSelection(selection) {
if (!client || !client.connected) return
const s = selection || {}
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({
type: 'USER_SELECTION',
summary: s.summary != null ? String(s.summary) : '',
selectedCount: s.selectedCount != null ? Number(s.selectedCount) : 0,
items: Array.isArray(s.items) ? s.items : []
})
})
}
/** 发送:当前成员解锁某对象(结束编辑) */
function sendObjectEditUnlock(objectType, objectId) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'OBJECT_EDIT_UNLOCK', objectType, objectId })
})
}
}
function startHeartbeat() {
stopHeartbeat()
heartbeatTimer = setInterval(sendPing, 30000)
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
}
function handleRoomMessage(message) {
try {
const body = JSON.parse(message.body)
const type = body.type
if (type === 'MEMBER_LIST' && body.members) {
onMembers && onMembers(body.members)
} else if (type === 'MEMBER_JOINED' && body.member) {
onMemberJoined && onMemberJoined(body.member)
} else if (type === 'MEMBER_LEFT' && body.member) {
onMemberLeft && onMemberLeft(body.member, body.sessionId)
} else if (type === 'CHAT' && body.sender) {
onChatMessage && onChatMessage(body)
} else if (type === 'SYNC_ROUTE_VISIBILITY' && body.routeId != null) {
onSyncRouteVisibility && onSyncRouteVisibility(body.routeId, !!body.visible, body.senderSessionId)
} else if (type === 'SYNC_WAYPOINTS' && body.routeId != null) {
onSyncWaypoints && onSyncWaypoints(body.routeId, body.senderSessionId)
} else if (type === 'SYNC_PLATFORM_ICONS') {
onSyncPlatformIcons && onSyncPlatformIcons(body.senderSessionId)
} else if (type === 'SYNC_ROOM_DRAWINGS') {
onSyncRoomDrawings && onSyncRoomDrawings(body.senderSessionId)
} else if (type === 'SYNC_PLATFORM_STYLES') {
onSyncPlatformStyles && onSyncPlatformStyles(body.senderSessionId)
} else if (type === 'OBJECT_EDIT_LOCK' && body.objectType != null && body.objectId != null && body.editor) {
onObjectEditLock && onObjectEditLock(body)
} else if (type === 'OBJECT_EDIT_UNLOCK' && body.objectType != null && body.objectId != null) {
onObjectEditUnlock && onObjectEditUnlock(body)
} else if (type === 'USER_ACTIVITIES' && Array.isArray(body.activities)) {
onUserActivities && onUserActivities(body.activities)
}
} catch (e) {
console.warn('[WebSocket] parse message error:', e)
}
}
function handlePrivateMessage(message) {
try {
const body = JSON.parse(message.body)
const type = body.type
if (type === 'PRIVATE_CHAT' && body.sender) {
onPrivateChat && onPrivateChat(body)
} else if (type === 'CHAT_HISTORY' && Array.isArray(body.messages)) {
onChatHistory && onChatHistory(body.messages)
} else if (type === 'PRIVATE_CHAT_HISTORY' && body.targetUserId != null && Array.isArray(body.messages)) {
onPrivateChatHistory && onPrivateChatHistory(body.targetUserId, body.messages)
} else if (type === 'ROOM_STATE' && Array.isArray(body.visibleRouteIds)) {
onRoomState && onRoomState(body.visibleRouteIds)
} else if (type === 'USER_ACTIVITIES' && Array.isArray(body.activities)) {
onUserActivities && onUserActivities(body.activities)
}
} catch (e) {
console.warn('[WebSocket] parse private message error:', e)
}
}
function connect() {
const token = getToken()
if (!token) {
onError && onError(new Error('未登录'))
return
}
const sock = new SockJS(getWsUrl())
client = new Client({
webSocketFactory: () => sock,
reconnectDelay: 0,
heartbeatIncoming: 0,
heartbeatOutgoing: 0,
onConnect: () => {
reconnectAttempts = 0
roomSubscription = client.subscribe('/topic/room/' + roomId, handleRoomMessage)
privateSubscription = client.subscribe('/user/queue/private', handlePrivateMessage)
sendJoin()
startHeartbeat()
onConnected && onConnected()
},
onStompError: (frame) => {
console.warn('[WebSocket] STOMP error:', frame)
onError && onError(new Error(frame.headers?.message || '连接错误'))
},
onWebSocketClose: () => {
stopHeartbeat()
roomSubscription = null
privateSubscription = null
onDisconnected && onDisconnected()
}
})
client.activate()
}
function disconnect() {
stopHeartbeat()
sendLeave()
if (roomSubscription) {
roomSubscription.unsubscribe()
roomSubscription = null
}
if (privateSubscription) {
privateSubscription.unsubscribe()
privateSubscription = null
}
if (client) {
client.deactivate()
client = null
}
}
function tryReconnect() {
if (reconnectAttempts >= maxReconnectAttempts) return
reconnectAttempts++
setTimeout(() => {
disconnect()
connect()
}, reconnectDelay)
}
connect()
return {
disconnect,
reconnect: connect,
sendChat,
sendPrivateChat,
sendPrivateChatHistoryRequest,
sendSyncRouteVisibility,
sendSyncWaypoints,
sendSyncPlatformIcons,
sendSyncRoomDrawings,
sendSyncPlatformStyles,
sendObjectEditLock,
sendObjectEditUnlock,
sendUserSelection,
get connected() {
return client && client.connected
}
}
}