Browse Source

编辑/选中状态

mh
menghao 1 month ago
parent
commit
14783654fa
  1. 86
      ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
  2. 2
      ruoyi-ui/.env.development
  3. 60
      ruoyi-ui/src/utils/websocket.js
  4. 107
      ruoyi-ui/src/views/childRoom/RightPanel.vue
  5. 70
      ruoyi-ui/src/views/childRoom/index.vue
  6. 2
      ruoyi-ui/vue.config.js

86
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";
/**
* 处理房间消息JOINLEAVEPINGCHATPRIVATE_CHAT
* 处理房间消息JOINLEAVEPINGCHATPRIVATE_CHATOBJECT_VIEWOBJECT_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<String, Object> 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<String, Object> viewer = new HashMap<>();
viewer.put("userId", loginUser.getUserId());
viewer.put("userName", loginUser.getUsername());
viewer.put("nickName", loginUser.getUser().getNickName());
viewer.put("sessionId", sessionId);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> editor = new HashMap<>();
editor.put("userId", loginUser.getUserId());
editor.put("userName", loginUser.getUsername());
editor.put("nickName", loginUser.getUser().getNickName());
editor.put("sessionId", sessionId);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> parsePayload(String payload) {
try {

2
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

60
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
}

107
ruoyi-ui/src/views/childRoom/RightPanel.vue

@ -59,6 +59,12 @@
<div class="tree-item-info">
<div class="tree-item-name">{{ route.name }}</div>
<div class="tree-item-meta">{{ route.points }}{{ $t('rightPanel.points') }}</div>
<div v-if="routeViewingByOther(route.id)" class="route-viewing-tag">
{{ routeViewingName(route.id) }} 正在查看
</div>
<div v-if="routeLockedByOther(route.id)" class="route-locked-tag">
<i class="el-icon-lock"></i> {{ routeLockedByName(route.id) }} 正在编辑
</div>
</div>
<el-tag
v-if="route.conflict"
@ -71,12 +77,28 @@
<div class="tree-item-actions">
<i class="el-icon-view" title="显示/隐藏" @click.stop="handleToggleRouteVisibility(route)"></i>
<i
v-if="!routeLockedByOther(route.id)"
:class="routeLocked[route.id] ? 'el-icon-lock' : 'el-icon-unlock'"
:title="routeLocked[route.id] ? '解锁' : '上锁'"
@click.stop="$emit('toggle-route-lock', route)"
></i>
<i class="el-icon-edit" title="编辑" @click.stop="handleOpenRouteDialog(route)"></i>
<i class="el-icon-delete" title="删除" @click.stop="$emit('delete-route', route)"></i>
<i
v-else
class="el-icon-lock route-locked-by-other-icon"
title="被他人锁定,无法操作"
></i>
<i
class="el-icon-edit"
:title="routeLockedByOther(route.id) ? '被他人锁定,无法编辑' : '编辑'"
:class="{ 'action-disabled': routeLockedByOther(route.id) }"
@click.stop="!routeLockedByOther(route.id) && handleOpenRouteDialog(route)"
></i>
<i
class="el-icon-delete"
:class="{ 'action-disabled': routeLockedByOther(route.id) }"
title="删除"
@click.stop="!routeLockedByOther(route.id) && $emit('delete-route', route)"
></i>
</div>
</div>
<!-- 航点列表 -->
@ -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;

70
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) {

2
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 源码路径

Loading…
Cancel
Save