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 9bac0e8..1a41837 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 @@ -58,9 +58,14 @@ public class RoomWebSocketController { private static final String TYPE_SYNC_PLATFORM_ICONS = "SYNC_PLATFORM_ICONS"; private static final String TYPE_SYNC_ROOM_DRAWINGS = "SYNC_ROOM_DRAWINGS"; private static final String TYPE_SYNC_PLATFORM_STYLES = "SYNC_PLATFORM_STYLES"; + /** 对象编辑锁定:某成员进入编辑,其他人看到锁定 */ + 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、SYNC_* + * 处理房间消息:JOIN、LEAVE、PING、CHAT、PRIVATE_CHAT、OBJECT_VIEW、OBJECT_EDIT_LOCK */ @MessageMapping("/room/{roomId}") public void handleRoomMessage(@DestinationVariable Long roomId, @Payload String payload, @@ -100,9 +105,47 @@ public class RoomWebSocketController { handleSyncRoomDrawings(roomId, body, sessionId); } else if (TYPE_SYNC_PLATFORM_STYLES.equals(type)) { handleSyncPlatformStyles(roomId, body, sessionId); + } 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 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/src/utils/websocket.js b/ruoyi-ui/src/utils/websocket.js index 9f37b15..c65e40c 100644 --- a/ruoyi-ui/src/utils/websocket.js +++ b/ruoyi-ui/src/utils/websocket.js @@ -23,6 +23,8 @@ const WS_BASE = process.env.VUE_APP_BASE_API || '/dev-api' * @param {Function} options.onSyncPlatformIcons - 平台图标变更同步回调 (senderUserId) => {} * @param {Function} options.onSyncRoomDrawings - 空域图形变更同步回调 (senderUserId) => {} * @param {Function} options.onSyncPlatformStyles - 探测区/威力区样式变更同步回调 (senderUserId) => {} + * @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 - 错误回调 @@ -43,6 +45,8 @@ export function createRoomWebSocket(options) { onSyncPlatformIcons, onSyncRoomDrawings, onSyncPlatformStyles, + onObjectEditLock, + onObjectEditUnlock, onConnected, onDisconnected, onError, @@ -163,6 +167,26 @@ export function createRoomWebSocket(options) { } } + /** 发送:当前成员锁定某对象进入编辑 */ + 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) @@ -197,6 +221,10 @@ export function createRoomWebSocket(options) { 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) } } catch (e) { console.warn('[WebSocket] parse message error:', e) @@ -293,6 +321,8 @@ export function createRoomWebSocket(options) { sendSyncPlatformIcons, sendSyncRoomDrawings, sendSyncPlatformStyles, + sendObjectEditLock, + sendObjectEditUnlock, get connected() { return client && client.connected } diff --git a/ruoyi-ui/src/views/cesiumMap/index.vue b/ruoyi-ui/src/views/cesiumMap/index.vue index 2e56db5..7e35d8f 100644 --- a/ruoyi-ui/src/views/cesiumMap/index.vue +++ b/ruoyi-ui/src/views/cesiumMap/index.vue @@ -367,6 +367,11 @@ export default { type: Object, default: () => ({}) }, + /** 被其他成员编辑锁定的航线 ID 列表(禁止拖拽航点等) */ + routeLockedByOtherIds: { + type: Array, + default: () => [] + }, /** 推演时间轴:相对 K 的分钟数(用于导弹按时间轴显示/隐藏与位置插值) */ deductionTimeMinutes: { type: Number, @@ -3616,6 +3621,8 @@ export default { const dbId = props.dbId && (props.dbId.getValue ? props.dbId.getValue() : props.dbId); if (routeId == null || dbId == null) return; if (this.routeLocked[routeId]) return; + const rid = Number(routeId); + if (Array.isArray(this.routeLockedByOtherIds) && this.routeLockedByOtherIds.indexOf(rid) !== -1) return; const entity = pickedObject.id; const carto = Cesium.Cartographic.fromCartesian(entity.position.getValue(Cesium.JulianDate.now())); this.waypointDragPending = { diff --git a/ruoyi-ui/src/views/childRoom/RightPanel.vue b/ruoyi-ui/src/views/childRoom/RightPanel.vue index 228e8ea..c9cd3f3 100644 --- a/ruoyi-ui/src/views/childRoom/RightPanel.vue +++ b/ruoyi-ui/src/views/childRoom/RightPanel.vue @@ -59,6 +59,9 @@
{{ route.name }}
{{ route.points }}{{ $t('rightPanel.points') }}
+
+ {{ routeLockedByName(route.id) }} 正在编辑 +
- - + + + @@ -296,6 +315,16 @@ export default { 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 @@ -503,9 +532,22 @@ export default { getRouteClasses(routeId) { return { - active: this.activeRouteIds.includes(routeId) + active: this.activeRouteIds.includes(routeId), + 'locked-by-other': this.routeLockedByOther(routeId) } }, + /** 是否有其他成员正在编辑(锁定)该航线 */ + 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, @@ -741,6 +783,38 @@ export default { color: #999; } +.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 1e7db10..9585a92 100644 --- a/ruoyi-ui/src/views/childRoom/index.vue +++ b/ruoyi-ui/src/views/childRoom/index.vue @@ -15,6 +15,7 @@ :coordinateFormat="coordinateFormat" :bottomPanelVisible="bottomPanelVisible" :route-locked="routeLocked" + :route-locked-by-other-ids="routeLockedByOtherRouteIds" :deduction-time-minutes="deductionMinutesFromK" :room-id="currentRoomId" @draw-complete="handleMapDrawComplete" @@ -176,6 +177,8 @@ :routes="routes" :active-route-ids="activeRouteIds" :route-locked="routeLocked" + :route-locked-by="routeLockedBy" + :current-user-id="currentUserId" :selected-route-details="selectedRouteDetails" :conflicts="conflicts" :conflict-count="conflictCount" @@ -511,6 +514,10 @@ export default { screenshotDataUrl: '', screenshotFileName: '', + // 协作:谁正在编辑(锁定)哪条航线(来自 WebSocket 广播) + routeLockedBy: {}, // routeId -> { userId, userName, nickName, sessionId } + routeEditLockedId: null, // 本端当前编辑锁定的航线 ID,关闭弹窗时发送解锁 + // 作战信息 roomCode: 'JTF-7-ALPHA', onlineCount: 0, @@ -679,6 +686,14 @@ export default { this.timeProgress = progress; }); } + }, + /** 航线编辑弹窗关闭时:发送编辑解锁,让其他成员可编辑 */ + showRouteDialog(visible) { + if (visible) return; + if (this.routeEditLockedId != null && this.wsConnection && this.wsConnection.sendObjectEditUnlock) { + this.wsConnection.sendObjectEditUnlock('route', this.routeEditLockedId); + this.routeEditLockedId = null; + } } }, computed: { @@ -720,6 +735,17 @@ export default { if (!this.addHoldContext) return ''; if (this.addHoldContext.mode === 'drawing') return '在最后两航点之间插入盘旋,保存航线时生效。'; return `在 ${this.addHoldContext.fromName} 与 ${this.addHoldContext.toName} 之间添加盘旋,到计划时间后沿切线飞往下一航点(原「下一格」航点将被移除)。`; + }, + /** 被其他成员编辑锁定的航线 ID 列表,供地图禁止拖拽等 */ + routeLockedByOtherRouteIds() { + const myId = this.currentUserId; + if (myId == null) return Object.keys(this.routeLockedBy).map(id => Number(id)); + const ids = []; + Object.keys(this.routeLockedBy).forEach(rid => { + const editor = this.routeLockedBy[rid]; + if (editor && Number(editor.userId) !== Number(myId)) ids.push(Number(rid)); + }); + return ids; } }, mounted() { @@ -760,6 +786,10 @@ export default { }, // 处理从地图点击传来的编辑请求(支持 wpId 或 waypointIndex,如右键盘旋弧时仅有 waypointIndex) async handleOpenWaypointEdit(wpId, routeId, waypointIndex) { + if (routeId != null && this.isRouteLockedByOther(routeId)) { + this.$message.warning('该航线正被其他成员编辑,无法修改航点'); + return; + } if (waypointIndex != null && (wpId == null || wpId === undefined)) { try { const response = await getRoutes(routeId); @@ -842,9 +872,23 @@ export default { }); }, + /** 该航线是否被其他成员编辑锁定(非自己) */ + isRouteLockedByOther(routeId) { + const lockedBy = this.routeLockedBy[routeId]; + if (!lockedBy) return false; + const myId = this.currentUserId != null ? Number(this.currentUserId) : null; + const uid = lockedBy.userId != null ? Number(lockedBy.userId) : null; + return myId !== uid; + }, // 处理从地图点击传来的航线编辑请求 async handleOpenRouteEdit(routeId) { console.log(`>>> [父组件接收] 航线 ID: ${routeId}`); + if (this.isRouteLockedByOther(routeId)) { + const lockedBy = this.routeLockedBy[routeId]; + const name = lockedBy && (lockedBy.nickName || lockedBy.userName) || '其他成员'; + this.$message.warning(`${name} 正在编辑该航线,请稍后再试`); + return; + } try { const response = await getRoutes(routeId); if (response.code === 200 && response.data) { @@ -871,6 +915,10 @@ export default { /** 右键航点“向前/向后增加航点”:进入放置模式,传入 waypoints 给地图预览 */ handleAddWaypointAt({ routeId, waypointIndex, mode }) { + if (this.isRouteLockedByOther(routeId)) { + this.$message.warning('该航线正被其他成员编辑,无法添加航点'); + return; + } if (this.routeLocked[routeId]) { this.$message.info('该航线已上锁,请先解锁'); return; @@ -998,6 +1046,10 @@ export default { /** 右键航点“切换盘旋航点”:普通航点设为盘旋(圆形默认),盘旋航点设为普通;支持首尾航点 */ async handleToggleWaypointHold({ routeId, dbId, waypointIndex }) { + if (this.isRouteLockedByOther(routeId)) { + this.$message.warning('该航线正被其他成员编辑,无法修改'); + return; + } if (this.routeLocked[routeId]) { this.$message.info('该航线已上锁,请先解锁'); return; @@ -1134,6 +1186,10 @@ export default { /** 地图上拖拽航点结束:将新位置写回数据库并刷新显示 */ async handleWaypointPositionChanged({ dbId, routeId, lat, lng, alt }) { + if (this.isRouteLockedByOther(routeId)) { + this.$message.warning('该航线正被其他成员编辑,无法拖拽修改'); + return; + } let waypoints = null; let route = null; if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) { @@ -1303,6 +1359,21 @@ export default { this.wsOnlineMembers = this.wsOnlineMembers.filter(m => m.id !== sessionId && m.id !== member.sessionId); this.onlineCount = this.wsOnlineMembers.length; this.mySyncSessionIds = this.mySyncSessionIds.filter(s => s !== sessionId && s !== member.sessionId); + // 该成员离开后清除其编辑锁定状态 + Object.keys(this.routeLockedBy).forEach(rid => { + if (this.routeLockedBy[rid] && this.routeLockedBy[rid].sessionId === sessionId) { + this.$delete(this.routeLockedBy, rid); + } + }); + }, + 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]; @@ -1353,6 +1424,7 @@ export default { this.mySyncSessionIds = []; this.chatMessages = []; this.privateChatMessages = {}; + this.routeLockedBy = {}; }, onError: (err) => { console.warn('[WebSocket]', err); @@ -1621,9 +1693,18 @@ 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) { + if (this.isRouteLockedByOther(updatedRoute.id)) { + this.$message.warning('该航线正被其他成员编辑,无法保存'); + return; + } const index = this.routes.findIndex(r => r.id === updatedRoute.id); if (index === -1) return; try { @@ -1732,6 +1813,10 @@ export default { } }, async handleDeleteRoute(route) { + if (this.isRouteLockedByOther(route.id)) { + this.$message.warning('该航线正被其他成员编辑,无法删除'); + return; + } try { // 二次确认,防止误删 await this.$confirm(`确定要彻底删除航线 "${route.name}" 吗?`, '提示', { @@ -1989,6 +2074,10 @@ export default { // 航点编辑弹窗相关方法 openWaypointDialog(data) { console.log(">>> [父组件接收] 编辑航点详情:", data); + if (data && data.routeId != null && this.isRouteLockedByOther(data.routeId)) { + this.$message.warning('该航线正被其他成员编辑,无法修改'); + return; + } // 将整个对象赋值给弹窗绑定的变量 this.selectedWaypoint = data; this.showWaypointDialog = true; @@ -1996,6 +2085,11 @@ export default { /** 航点编辑保存:更新数据库并同步地图显示 */ async updateWaypoint(updatedWaypoint) { if (!this.selectedRouteDetails || !this.selectedRouteDetails.waypoints) return; + const routeId = updatedWaypoint.routeId != null ? updatedWaypoint.routeId : this.selectedRouteDetails.id; + if (this.isRouteLockedByOther(routeId)) { + this.$message.warning('该航线正被其他成员编辑,无法保存'); + return; + } try { if (this.$refs.cesiumMap && updatedWaypoint.turnAngle > 0) { updatedWaypoint.turnRadius = this.$refs.cesiumMap.getWaypointRadius(updatedWaypoint); diff --git a/ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue b/ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue index 879ed38..f729a6a 100644 --- a/ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue +++ b/ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue @@ -331,11 +331,14 @@ export default { }, computed: { displayOnlineMembers() { - return (this.onlineMembers && this.onlineMembers.length > 0) ? this.onlineMembers : this._mockOnlineMembers; + const list = (this.onlineMembers && this.onlineMembers.length > 0) ? this.onlineMembers : this._mockOnlineMembers; + return Array.isArray(list) ? list : []; }, chatableMembers() { + const list = this.displayOnlineMembers; + if (!Array.isArray(list)) return []; const cur = this.currentUserId != null ? Number(this.currentUserId) : null; - return this.displayOnlineMembers.filter(m => m.userId != null && Number(m.userId) !== cur); + return list.filter(m => m.userId != null && Number(m.userId) !== cur); }, displayChatMessages() { return this.chatMessages || []; diff --git a/ruoyi-ui/vue.config.js b/ruoyi-ui/vue.config.js index 12927c0..7ab699a 100644 --- a/ruoyi-ui/vue.config.js +++ b/ruoyi-ui/vue.config.js @@ -14,7 +14,7 @@ const CompressionPlugin = require('compression-webpack-plugin') // 引入 CopyWebpackPlugin 用于复制 Cesium 静态资源 const CopyWebpackPlugin = require('copy-webpack-plugin') -const name = process.env.VUE_APP_TITLE || '网络化任务规划系统' // 网页标题 +const name = process.env.VUE_APP_TITLE || '若依管理系统' // 网页标题 const baseUrl = 'http://127.0.0.1:8080' // 后端接口 const port = process.env.port || process.env.npm_config_port || 80 // 端口