From 14783654fa61ec17d4e4eeb60d6f98a7b81fd3ea Mon Sep 17 00:00:00 2001 From: menghao <1584479611@qq.com> Date: Wed, 4 Mar 2026 10:45:16 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=96=E8=BE=91/=E9=80=89=E4=B8=AD=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/RoomWebSocketController.java | 86 ++++++++++++++++- ruoyi-ui/.env.development | 2 +- ruoyi-ui/src/utils/websocket.js | 60 ++++++++++++ ruoyi-ui/src/views/childRoom/RightPanel.vue | 107 ++++++++++++++++++++- ruoyi-ui/src/views/childRoom/index.vue | 70 ++++++++++++++ ruoyi-ui/vue.config.js | 2 +- 6 files changed, 321 insertions(+), 6 deletions(-) diff --git a/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java b/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java index 2d75553..c5f93fc 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java @@ -53,9 +53,17 @@ public class RoomWebSocketController { private static final String TYPE_MEMBER_LEFT = "MEMBER_LEFT"; private static final String TYPE_MEMBER_LIST = "MEMBER_LIST"; private static final String TYPE_PONG = "PONG"; + /** 对象选中/查看:某成员正在查看某条航线,广播给房间内其他人 */ + private static final String TYPE_OBJECT_VIEW = "OBJECT_VIEW"; + /** 取消对象查看 */ + private static final String TYPE_OBJECT_VIEW_CLEAR = "OBJECT_VIEW_CLEAR"; + /** 对象编辑锁定:某成员进入编辑,其他人看到锁定 */ + private static final String TYPE_OBJECT_EDIT_LOCK = "OBJECT_EDIT_LOCK"; + /** 对象编辑解锁 */ + private static final String TYPE_OBJECT_EDIT_UNLOCK = "OBJECT_EDIT_UNLOCK"; /** - * 处理房间消息:JOIN、LEAVE、PING、CHAT、PRIVATE_CHAT + * 处理房间消息:JOIN、LEAVE、PING、CHAT、PRIVATE_CHAT、OBJECT_VIEW、OBJECT_EDIT_LOCK */ @MessageMapping("/room/{roomId}") public void handleRoomMessage(@DestinationVariable Long roomId, @Payload String payload, @@ -85,9 +93,85 @@ public class RoomWebSocketController { handlePrivateChat(roomId, sessionId, loginUser, body); } else if (TYPE_PRIVATE_CHAT_HISTORY_REQUEST.equals(type)) { handlePrivateChatHistoryRequest(roomId, loginUser, body); + } else if (TYPE_OBJECT_VIEW.equals(type)) { + handleObjectView(roomId, sessionId, loginUser, body); + } else if (TYPE_OBJECT_VIEW_CLEAR.equals(type)) { + handleObjectViewClear(roomId, sessionId, loginUser, body); + } else if (TYPE_OBJECT_EDIT_LOCK.equals(type)) { + handleObjectEditLock(roomId, sessionId, loginUser, body); + } else if (TYPE_OBJECT_EDIT_UNLOCK.equals(type)) { + handleObjectEditUnlock(roomId, sessionId, loginUser, body); } } + /** 广播:某成员正在查看某对象(如航线) */ + private void handleObjectView(Long roomId, String sessionId, LoginUser loginUser, Map body) { + String objectType = body != null ? String.valueOf(body.get("objectType")) : null; + Object objectIdObj = body != null ? body.get("objectId") : null; + if (objectType == null || objectIdObj == null) return; + + Map viewer = new HashMap<>(); + viewer.put("userId", loginUser.getUserId()); + viewer.put("userName", loginUser.getUsername()); + viewer.put("nickName", loginUser.getUser().getNickName()); + viewer.put("sessionId", sessionId); + + Map msg = new HashMap<>(); + msg.put("type", TYPE_OBJECT_VIEW); + msg.put("objectType", objectType); + msg.put("objectId", objectIdObj); + msg.put("viewer", viewer); + messagingTemplate.convertAndSend("/topic/room/" + roomId, msg); + } + + /** 广播:某成员取消查看某对象 */ + private void handleObjectViewClear(Long roomId, String sessionId, LoginUser loginUser, Map body) { + String objectType = body != null ? String.valueOf(body.get("objectType")) : null; + Object objectIdObj = body != null ? body.get("objectId") : null; + if (objectType == null || objectIdObj == null) return; + + Map msg = new HashMap<>(); + msg.put("type", TYPE_OBJECT_VIEW_CLEAR); + msg.put("objectType", objectType); + msg.put("objectId", objectIdObj); + msg.put("sessionId", sessionId); + messagingTemplate.convertAndSend("/topic/room/" + roomId, msg); + } + + /** 广播:某成员锁定某对象进入编辑 */ + private void handleObjectEditLock(Long roomId, String sessionId, LoginUser loginUser, Map body) { + String objectType = body != null ? String.valueOf(body.get("objectType")) : null; + Object objectIdObj = body != null ? body.get("objectId") : null; + if (objectType == null || objectIdObj == null) return; + + Map editor = new HashMap<>(); + editor.put("userId", loginUser.getUserId()); + editor.put("userName", loginUser.getUsername()); + editor.put("nickName", loginUser.getUser().getNickName()); + editor.put("sessionId", sessionId); + + Map msg = new HashMap<>(); + msg.put("type", TYPE_OBJECT_EDIT_LOCK); + msg.put("objectType", objectType); + msg.put("objectId", objectIdObj); + msg.put("editor", editor); + messagingTemplate.convertAndSend("/topic/room/" + roomId, msg); + } + + /** 广播:某成员解锁某对象(结束编辑) */ + private void handleObjectEditUnlock(Long roomId, String sessionId, LoginUser loginUser, Map body) { + String objectType = body != null ? String.valueOf(body.get("objectType")) : null; + Object objectIdObj = body != null ? body.get("objectId") : null; + if (objectType == null || objectIdObj == null) return; + + Map msg = new HashMap<>(); + msg.put("type", TYPE_OBJECT_EDIT_UNLOCK); + msg.put("objectType", objectType); + msg.put("objectId", objectIdObj); + msg.put("sessionId", sessionId); + messagingTemplate.convertAndSend("/topic/room/" + roomId, msg); + } + @SuppressWarnings("unchecked") private Map parsePayload(String payload) { try { diff --git a/ruoyi-ui/.env.development b/ruoyi-ui/.env.development index b32bd7f..0e7127a 100644 --- a/ruoyi-ui/.env.development +++ b/ruoyi-ui/.env.development @@ -8,7 +8,7 @@ ENV = 'development' VUE_APP_BASE_API = '/dev-api' # 访问地址(绕过 /dev-api 代理,用于解决静态资源/图片访问 401 认证问题) -VUE_APP_BACKEND_URL = 'http://192.168.1.104:8080' +VUE_APP_BACKEND_URL = 'http://127.0.0.1:8080' # 路由懒加载 VUE_CLI_BABEL_TRANSPILE_MODULES = true diff --git a/ruoyi-ui/src/utils/websocket.js b/ruoyi-ui/src/utils/websocket.js index bc097c0..dc814fc 100644 --- a/ruoyi-ui/src/utils/websocket.js +++ b/ruoyi-ui/src/utils/websocket.js @@ -18,6 +18,10 @@ const WS_BASE = process.env.VUE_APP_BASE_API || '/dev-api' * @param {Function} options.onPrivateChat - 私聊消息回调 (msg) => {} * @param {Function} options.onChatHistory - 群聊历史回调 (messages) => {} * @param {Function} options.onPrivateChatHistory - 私聊历史回调 (targetUserId, messages) => {} + * @param {Function} options.onObjectViewing - 对象被某成员查看 (msg: { objectType, objectId, viewer }) => {} + * @param {Function} options.onObjectViewClear - 取消对象查看 (msg: { objectType, objectId, sessionId }) => {} + * @param {Function} options.onObjectEditLock - 对象被某成员编辑锁定 (msg: { objectType, objectId, editor }) => {} + * @param {Function} options.onObjectEditUnlock - 对象编辑解锁 (msg: { objectType, objectId, sessionId }) => {} * @param {Function} options.onConnected - 连接成功回调 * @param {Function} options.onDisconnected - 断开回调 * @param {Function} options.onError - 错误回调 @@ -33,6 +37,10 @@ export function createRoomWebSocket(options) { onPrivateChat, onChatHistory, onPrivateChatHistory, + onObjectViewing, + onObjectViewClear, + onObjectEditLock, + onObjectEditUnlock, onConnected, onDisconnected, onError, @@ -108,6 +116,46 @@ export function createRoomWebSocket(options) { } } + /** 发送:当前成员正在查看某对象(如航线) */ + function sendObjectView(objectType, objectId) { + if (client && client.connected) { + client.publish({ + destination: '/app/room/' + roomId, + body: JSON.stringify({ type: 'OBJECT_VIEW', objectType, objectId }) + }) + } + } + + /** 发送:取消查看某对象 */ + function sendObjectViewClear(objectType, objectId) { + if (client && client.connected) { + client.publish({ + destination: '/app/room/' + roomId, + body: JSON.stringify({ type: 'OBJECT_VIEW_CLEAR', objectType, objectId }) + }) + } + } + + /** 发送:当前成员锁定某对象进入编辑 */ + function sendObjectEditLock(objectType, objectId) { + if (client && client.connected) { + client.publish({ + destination: '/app/room/' + roomId, + body: JSON.stringify({ type: 'OBJECT_EDIT_LOCK', objectType, objectId }) + }) + } + } + + /** 发送:当前成员解锁某对象(结束编辑) */ + 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) @@ -132,6 +180,14 @@ export function createRoomWebSocket(options) { onMemberLeft && onMemberLeft(body.member, body.sessionId) } else if (type === 'CHAT' && body.sender) { onChatMessage && onChatMessage(body) + } else if (type === 'OBJECT_VIEW' && body.objectType != null && body.objectId != null && body.viewer) { + onObjectViewing && onObjectViewing(body) + } else if (type === 'OBJECT_VIEW_CLEAR' && body.objectType != null && body.objectId != null) { + onObjectViewClear && onObjectViewClear(body) + } 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) } } catch (e) { console.warn('[WebSocket] parse message error:', e) @@ -223,6 +279,10 @@ export function createRoomWebSocket(options) { sendChat, sendPrivateChat, sendPrivateChatHistoryRequest, + sendObjectView, + sendObjectViewClear, + sendObjectEditLock, + sendObjectEditUnlock, get connected() { return client && client.connected } diff --git a/ruoyi-ui/src/views/childRoom/RightPanel.vue b/ruoyi-ui/src/views/childRoom/RightPanel.vue index cb80ba7..3f65034 100644 --- a/ruoyi-ui/src/views/childRoom/RightPanel.vue +++ b/ruoyi-ui/src/views/childRoom/RightPanel.vue @@ -59,6 +59,12 @@
{{ route.name }}
{{ route.points }}{{ $t('rightPanel.points') }}
+
+ {{ routeViewingName(route.id) }} 正在查看 +
+
+ {{ routeLockedByName(route.id) }} 正在编辑 +
- - + + + @@ -296,6 +318,21 @@ export default { type: Object, default: () => ({}) }, + /** 协作:谁正在查看该航线 routeId -> { userId, userName, nickName, sessionId } */ + routeViewingBy: { + type: Object, + default: () => ({}) + }, + /** 协作:谁正在编辑(锁定)该航线 routeId -> { userId, userName, nickName, sessionId } */ + routeLockedBy: { + type: Object, + default: () => ({}) + }, + /** 当前用户 ID,用于判断“正在查看/锁定”是否为自己 */ + currentUserId: { + type: [Number, String], + default: null + }, selectedPlanId: { type: [String, Number], default: null @@ -504,9 +541,35 @@ export default { getRouteClasses(routeId) { return { - active: this.activeRouteIds.includes(routeId) + active: this.activeRouteIds.includes(routeId), + 'viewing-by-other': this.routeViewingByOther(routeId), + 'locked-by-other': this.routeLockedByOther(routeId) } }, + /** 是否有其他成员正在查看该航线(非自己) */ + routeViewingByOther(routeId) { + const v = this.routeViewingBy[routeId] + if (!v) return false + const myId = this.currentUserId != null ? Number(this.currentUserId) : null + const uid = v.userId != null ? Number(v.userId) : null + return myId !== uid + }, + routeViewingName(routeId) { + const v = this.routeViewingBy[routeId] + return (v && (v.nickName || v.userName)) || '' + }, + /** 是否有其他成员正在编辑(锁定)该航线 */ + routeLockedByOther(routeId) { + const e = this.routeLockedBy[routeId] + if (!e) return false + const myId = this.currentUserId != null ? Number(this.currentUserId) : null + const uid = e.userId != null ? Number(e.userId) : null + return myId !== uid + }, + routeLockedByName(routeId) { + const e = this.routeLockedBy[routeId] + return (e && (e.nickName || e.userName)) || '' + }, handleOpenWaypointDialog(point,index,total) { this.$emit('open-waypoint-dialog', { ...point, @@ -742,6 +805,44 @@ export default { color: #999; } +.route-viewing-tag { + font-size: 11px; + color: #008aff; + margin-top: 2px; +} + +.route-locked-tag { + font-size: 11px; + color: #909399; + margin-top: 2px; +} + +.route-locked-tag .el-icon-lock { + margin-right: 2px; + font-size: 12px; +} + +.tree-item.route-item.locked-by-other .tree-item-header { + background: rgba(240, 242, 245, 0.95) !important; + border-left: 3px solid #c0c4cc; +} + +.tree-item-actions i.action-disabled { + color: #c0c4cc; + cursor: not-allowed; + opacity: 0.7; +} + +.tree-item-actions i.action-disabled:hover { + background: transparent; + transform: none; +} + +.route-locked-by-other-icon { + color: #909399; + cursor: default; +} + .tree-item-actions { display: flex; gap: 8px; diff --git a/ruoyi-ui/src/views/childRoom/index.vue b/ruoyi-ui/src/views/childRoom/index.vue index 6d4dbc5..18ef6e7 100644 --- a/ruoyi-ui/src/views/childRoom/index.vue +++ b/ruoyi-ui/src/views/childRoom/index.vue @@ -175,6 +175,9 @@ :routes="routes" :active-route-ids="activeRouteIds" :route-locked="routeLocked" + :route-viewing-by="routeViewingBy" + :route-locked-by="routeLockedBy" + :current-user-id="currentUserId" :selected-route-details="selectedRouteDetails" :conflicts="conflicts" :conflict-count="conflictCount" @@ -510,6 +513,11 @@ export default { screenshotDataUrl: '', screenshotFileName: '', + // 协作:谁正在查看/编辑哪条航线(来自 WebSocket 广播) + routeViewingBy: {}, // routeId -> { userId, userName, nickName, sessionId } + routeLockedBy: {}, // routeId -> { userId, userName, nickName, sessionId } + routeEditLockedId: null, // 本端当前编辑锁定的航线 ID,关闭弹窗时发送解锁 + // 作战信息 roomCode: 'JTF-7-ALPHA', onlineCount: 0, @@ -667,6 +675,22 @@ export default { this.timeProgress = progress; }); } + }, + /** 选中航线变化时:广播“正在查看”或取消查看,供其他成员显示 */ + selectedRouteId: { + handler(newId, oldId) { + if (!this.wsConnection || !this.wsConnection.sendObjectViewClear || !this.wsConnection.sendObjectView) return; + if (oldId != null) this.wsConnection.sendObjectViewClear('route', oldId); + if (newId != null) this.wsConnection.sendObjectView('route', newId); + } + }, + /** 航线编辑弹窗关闭时:发送编辑解锁,让其他成员可编辑 */ + showRouteDialog(visible) { + if (visible) return; + if (this.routeEditLockedId != null && this.wsConnection && this.wsConnection.sendObjectEditUnlock) { + this.wsConnection.sendObjectEditUnlock('route', this.routeEditLockedId); + this.routeEditLockedId = null; + } } }, computed: { @@ -833,6 +857,16 @@ export default { // 处理从地图点击传来的航线编辑请求 async handleOpenRouteEdit(routeId) { console.log(`>>> [父组件接收] 航线 ID: ${routeId}`); + const lockedBy = this.routeLockedBy[routeId]; + if (lockedBy) { + const myId = this.currentUserId != null ? Number(this.currentUserId) : null; + const uid = lockedBy.userId != null ? Number(lockedBy.userId) : null; + if (myId !== uid) { + const name = lockedBy.nickName || lockedBy.userName || '其他成员'; + this.$message.warning(`${name} 正在编辑该航线,请稍后再试`); + return; + } + } try { const response = await getRoutes(routeId); if (response.code === 200 && response.data) { @@ -1279,6 +1313,35 @@ export default { onMemberLeft: (member, sessionId) => { this.wsOnlineMembers = this.wsOnlineMembers.filter(m => m.id !== sessionId && m.id !== member.sessionId); this.onlineCount = this.wsOnlineMembers.length; + // 该成员离开后清除其查看/编辑状态 + Object.keys(this.routeViewingBy).forEach(rid => { + if (this.routeViewingBy[rid] && this.routeViewingBy[rid].sessionId === sessionId) { + this.$delete(this.routeViewingBy, rid); + } + }); + Object.keys(this.routeLockedBy).forEach(rid => { + if (this.routeLockedBy[rid] && this.routeLockedBy[rid].sessionId === sessionId) { + this.$delete(this.routeLockedBy, rid); + } + }); + }, + onObjectViewing: (msg) => { + if (msg.objectType !== 'route' || msg.objectId == null) return; + this.$set(this.routeViewingBy, msg.objectId, msg.viewer); + }, + onObjectViewClear: (msg) => { + if (msg.objectType !== 'route' || msg.objectId == null) return; + const cur = this.routeViewingBy[msg.objectId]; + if (cur && cur.sessionId === msg.sessionId) this.$delete(this.routeViewingBy, msg.objectId); + }, + onObjectEditLock: (msg) => { + if (msg.objectType !== 'route' || msg.objectId == null) return; + this.$set(this.routeLockedBy, msg.objectId, msg.editor); + }, + onObjectEditUnlock: (msg) => { + if (msg.objectType !== 'route' || msg.objectId == null) return; + const cur = this.routeLockedBy[msg.objectId]; + if (cur && cur.sessionId === msg.sessionId) this.$delete(this.routeLockedBy, msg.objectId); }, onChatMessage: (msg) => { this.chatMessages = [...this.chatMessages, msg]; @@ -1308,6 +1371,8 @@ export default { this.wsOnlineMembers = []; this.chatMessages = []; this.privateChatMessages = {}; + this.routeViewingBy = {}; + this.routeLockedBy = {}; }, onError: (err) => { console.warn('[WebSocket]', err); @@ -1432,6 +1497,11 @@ export default { openRouteDialog(route) { this.selectedRoute = route; this.showRouteDialog = true; + // 进入编辑即锁定该航线,广播给其他成员 + if (this.wsConnection && this.wsConnection.sendObjectEditLock && route && route.id != null) { + this.wsConnection.sendObjectEditLock('route', route.id); + this.routeEditLockedId = route.id; + } }, // 更新航线数据(含航点表编辑后的批量持久化) async updateRoute(updatedRoute) { diff --git a/ruoyi-ui/vue.config.js b/ruoyi-ui/vue.config.js index b80f647..7ab699a 100644 --- a/ruoyi-ui/vue.config.js +++ b/ruoyi-ui/vue.config.js @@ -15,7 +15,7 @@ const CompressionPlugin = require('compression-webpack-plugin') const CopyWebpackPlugin = require('copy-webpack-plugin') const name = process.env.VUE_APP_TITLE || '若依管理系统' // 网页标题 -const baseUrl = 'http://192.168.1.104:8080' // 后端接口 +const baseUrl = 'http://127.0.0.1:8080' // 后端接口 const port = process.env.port || process.env.npm_config_port || 80 // 端口 // 定义 Cesium 源码路径