Compare commits

...

5 Commits

Author SHA1 Message Date
menghao 347d791d39 编辑/选中状态2.0 1 month ago
menghao 3592696855 Merge branch 'ctw' of http://124.70.32.114:3100/woka/cesium-map-object into mh 1 month ago
menghao 14783654fa 编辑/选中状态 1 month ago
cuitw 16a8b85051 联网的实时渲染 1 month ago
cuitw 40b9cbc1c8 聊天室 1 month ago
  1. 204
      ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
  2. 109
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomChatService.java
  3. 5
      ruoyi-common/src/main/java/com/ruoyi/common/constant/CacheConstants.java
  4. 14
      ruoyi-common/src/main/java/com/ruoyi/common/utils/ServletUtils.java
  5. 3
      ruoyi-framework/src/main/java/com/ruoyi/framework/security/handle/AuthenticationEntryPointImpl.java
  6. 30
      ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/TokenService.java
  7. 2
      ruoyi-ui/.env.development
  8. 6
      ruoyi-ui/src/assets/styles/ruoyi.scss
  9. 13
      ruoyi-ui/src/lang/en.js
  10. 13
      ruoyi-ui/src/lang/zh.js
  11. 19
      ruoyi-ui/src/utils/request.js
  12. 177
      ruoyi-ui/src/utils/websocket.js
  13. 181
      ruoyi-ui/src/views/cesiumMap/index.vue
  14. 85
      ruoyi-ui/src/views/childRoom/RightPanel.vue
  15. 430
      ruoyi-ui/src/views/childRoom/index.vue
  16. 209
      ruoyi-ui/src/views/dialogs/KTimeSetDialog.vue
  17. 488
      ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue
  18. 174
      ruoyi-ui/src/views/dialogs/PlatformEditDialog.vue
  19. 243
      ruoyi-ui/src/views/dialogs/RouteEditDialog.vue
  20. 2
      ruoyi-ui/vue.config.js

204
ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java

@ -18,6 +18,7 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.domain.Rooms;
import com.ruoyi.system.service.IRoomsService;
import com.ruoyi.websocket.dto.RoomMemberDTO;
import com.ruoyi.websocket.service.RoomChatService;
import com.ruoyi.websocket.service.RoomWebSocketService;
/**
@ -35,18 +36,36 @@ public class RoomWebSocketController {
private RoomWebSocketService roomWebSocketService;
@Autowired
private RoomChatService roomChatService;
@Autowired
private IRoomsService roomsService;
private static final String TYPE_JOIN = "JOIN";
private static final String TYPE_LEAVE = "LEAVE";
private static final String TYPE_PING = "PING";
private static final String TYPE_CHAT = "CHAT";
private static final String TYPE_PRIVATE_CHAT = "PRIVATE_CHAT";
private static final String TYPE_CHAT_HISTORY = "CHAT_HISTORY";
private static final String TYPE_PRIVATE_CHAT_HISTORY = "PRIVATE_CHAT_HISTORY";
private static final String TYPE_PRIVATE_CHAT_HISTORY_REQUEST = "PRIVATE_CHAT_HISTORY_REQUEST";
private static final String TYPE_MEMBER_JOINED = "MEMBER_JOINED";
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_SYNC_ROUTE_VISIBILITY = "SYNC_ROUTE_VISIBILITY";
private static final String TYPE_SYNC_WAYPOINTS = "SYNC_WAYPOINTS";
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";
/**
* 处理房间消息JOINLEAVEPING
* 处理房间消息JOINLEAVEPINGCHATPRIVATE_CHATSYNC_*
* 处理房间消息JOINLEAVEPINGCHATPRIVATE_CHATOBJECT_VIEWOBJECT_EDIT_LOCK
*/
@MessageMapping("/room/{roomId}")
public void handleRoomMessage(@DestinationVariable Long roomId, @Payload String payload,
@ -70,9 +89,63 @@ public class RoomWebSocketController {
handleLeave(roomId, sessionId, loginUser);
} else if (TYPE_PING.equals(type)) {
handlePing(roomId, sessionId, loginUser);
} else if (TYPE_CHAT.equals(type)) {
handleChat(roomId, sessionId, loginUser, body);
} else if (TYPE_PRIVATE_CHAT.equals(type)) {
handlePrivateChat(roomId, sessionId, loginUser, body);
} else if (TYPE_PRIVATE_CHAT_HISTORY_REQUEST.equals(type)) {
handlePrivateChatHistoryRequest(roomId, loginUser, body);
} else if (TYPE_SYNC_ROUTE_VISIBILITY.equals(type)) {
handleSyncRouteVisibility(roomId, body, sessionId);
} else if (TYPE_SYNC_WAYPOINTS.equals(type)) {
handleSyncWaypoints(roomId, body, sessionId);
} else if (TYPE_SYNC_PLATFORM_ICONS.equals(type)) {
handleSyncPlatformIcons(roomId, body, sessionId);
} else if (TYPE_SYNC_ROOM_DRAWINGS.equals(type)) {
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<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 {
@ -100,6 +173,12 @@ public class RoomWebSocketController {
String topic = "/topic/room/" + roomId;
messagingTemplate.convertAndSend(topic, memberListMsg);
List<Object> chatHistory = roomChatService.getGroupChatHistory(roomId);
Map<String, Object> chatHistoryMsg = new HashMap<>();
chatHistoryMsg.put("type", TYPE_CHAT_HISTORY);
chatHistoryMsg.put("messages", chatHistory);
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", chatHistoryMsg);
}
private void handleLeave(Long roomId, String sessionId, LoginUser loginUser) {
@ -123,6 +202,129 @@ public class RoomWebSocketController {
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", msg);
}
/** 群聊:广播给房间内所有人 */
private void handleChat(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) {
String content = body != null && body.containsKey("content") ? String.valueOf(body.get("content")) : "";
if (content.isEmpty()) return;
Map<String, Object> sender = new HashMap<>();
sender.put("userId", loginUser.getUserId());
sender.put("userName", loginUser.getUsername());
sender.put("nickName", loginUser.getUser().getNickName());
sender.put("avatar", loginUser.getUser().getAvatar());
sender.put("sessionId", sessionId);
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_CHAT);
msg.put("sender", sender);
msg.put("content", content);
msg.put("timestamp", System.currentTimeMillis());
roomChatService.saveGroupChat(roomId, loginUser.getUserId(), msg);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
/** 私聊:发送给指定用户(仅限同房间成员) */
private void handlePrivateChat(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) {
Object targetUserNameObj = body != null ? body.get("targetUserName") : null;
Object targetUserIdObj = body != null ? body.get("targetUserId") : null;
String content = body != null && body.containsKey("content") ? String.valueOf(body.get("content")) : "";
if (targetUserNameObj == null || content.isEmpty()) return;
String targetUserName = String.valueOf(targetUserNameObj);
if (targetUserName.equals(loginUser.getUsername())) return;
List<RoomMemberDTO> members = roomWebSocketService.getRoomMembers(roomId);
boolean targetInRoom = members.stream().anyMatch(m -> targetUserName.equals(m.getUserName()));
if (!targetInRoom) return;
Map<String, Object> sender = new HashMap<>();
sender.put("userId", loginUser.getUserId());
sender.put("userName", loginUser.getUsername());
sender.put("nickName", loginUser.getUser().getNickName());
sender.put("avatar", loginUser.getUser().getAvatar());
sender.put("sessionId", sessionId);
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_PRIVATE_CHAT);
msg.put("sender", sender);
msg.put("targetUserId", targetUserIdObj);
msg.put("targetUserName", targetUserName);
msg.put("content", content);
msg.put("timestamp", System.currentTimeMillis());
Long targetUserId = targetUserIdObj instanceof Number ? ((Number) targetUserIdObj).longValue() : null;
if (targetUserId != null) {
roomChatService.savePrivateChat(loginUser.getUserId(), targetUserId, msg);
}
messagingTemplate.convertAndSendToUser(targetUserName, "/queue/private", msg);
}
/** 私聊历史请求:返回与指定用户的聊天记录 */
private void handlePrivateChatHistoryRequest(Long roomId, LoginUser loginUser, Map<String, Object> body) {
Object targetUserIdObj = body != null ? body.get("targetUserId") : null;
if (targetUserIdObj == null) return;
Long targetUserId = targetUserIdObj instanceof Number ? ((Number) targetUserIdObj).longValue() : null;
if (targetUserId == null) return;
List<RoomMemberDTO> members = roomWebSocketService.getRoomMembers(roomId);
boolean targetInRoom = members.stream().anyMatch(m -> targetUserId.equals(m.getUserId()));
if (!targetInRoom) return;
List<Object> history = roomChatService.getPrivateChatHistory(loginUser.getUserId(), targetUserId);
Map<String, Object> resp = new HashMap<>();
resp.put("type", TYPE_PRIVATE_CHAT_HISTORY);
resp.put("targetUserId", targetUserId);
resp.put("messages", history);
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", resp);
}
/** 广播航线显隐变更,供其他设备实时同步 */
private void handleSyncRouteVisibility(Long roomId, Map<String, Object> body, String sessionId) {
if (body == null || !body.containsKey("routeId")) return;
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_SYNC_ROUTE_VISIBILITY);
msg.put("routeId", body.get("routeId"));
msg.put("visible", body.get("visible") != null && Boolean.TRUE.equals(body.get("visible")));
msg.put("senderSessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
/** 广播航点变更,供其他设备实时同步 */
private void handleSyncWaypoints(Long roomId, Map<String, Object> body, String sessionId) {
if (body == null || !body.containsKey("routeId")) return;
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_SYNC_WAYPOINTS);
msg.put("routeId", body.get("routeId"));
msg.put("senderSessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
/** 广播平台图标变更,供其他设备实时同步 */
private void handleSyncPlatformIcons(Long roomId, Map<String, Object> body, String sessionId) {
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_SYNC_PLATFORM_ICONS);
msg.put("senderSessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
/** 广播空域图形变更,供其他设备实时同步 */
private void handleSyncRoomDrawings(Long roomId, Map<String, Object> body, String sessionId) {
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_SYNC_ROOM_DRAWINGS);
msg.put("senderSessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
/** 广播探测区/威力区样式变更,供其他设备实时同步 */
private void handleSyncPlatformStyles(Long roomId, Map<String, Object> body, String sessionId) {
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_SYNC_PLATFORM_STYLES);
msg.put("senderSessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
private RoomMemberDTO buildMember(LoginUser loginUser, String sessionId, Long roomId, Map<String, Object> body) {
RoomMemberDTO dto = new RoomMemberDTO();
dto.setUserId(loginUser.getUserId());

109
ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomChatService.java

@ -0,0 +1,109 @@
package com.ruoyi.websocket.service;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import com.alibaba.fastjson2.JSON;
/**
* 房间聊天 Redis 持久化服务
* 使用 Sorted Set 存储score 为时间戳超过 30 天自动清理
*
* @author ruoyi
*/
@Service
public class RoomChatService {
private static final String ROOM_CHAT_PREFIX = "room:";
private static final String ROOM_CHAT_USER_SUFFIX = ":user:";
private static final String ROOM_CHAT_SUFFIX = ":chat";
private static final String ROOM_CHAT_USERS_SUFFIX = ":chat:users";
private static final String PRIVATE_CHAT_PREFIX = "chat:private:";
private static final long EXPIRE_DAYS = 30;
private static final long EXPIRE_MS = EXPIRE_DAYS * 24 * 60 * 60 * 1000L;
@Autowired
@Qualifier("stringObjectRedisTemplate")
private RedisTemplate<String, Object> redisTemplate;
private String roomChatKey(Long roomId, Long userId) {
return ROOM_CHAT_PREFIX + roomId + ROOM_CHAT_USER_SUFFIX + userId + ROOM_CHAT_SUFFIX;
}
private String roomChatUsersKey(Long roomId) {
return ROOM_CHAT_PREFIX + roomId + ROOM_CHAT_USERS_SUFFIX;
}
private String privateChatKey(Long userId1, Long userId2) {
long a = userId1 != null ? userId1 : 0;
long b = userId2 != null ? userId2 : 0;
return PRIVATE_CHAT_PREFIX + Math.min(a, b) + ":" + Math.max(a, b);
}
/**
* 保存群聊消息 roomId + userId key 存储
*/
public void saveGroupChat(Long roomId, Long senderUserId, Object msg) {
if (senderUserId == null) return;
String key = roomChatKey(roomId, senderUserId);
long now = System.currentTimeMillis();
String member = msg instanceof String ? (String) msg : JSON.toJSONString(msg);
redisTemplate.opsForZSet().add(key, member, now);
redisTemplate.opsForZSet().removeRangeByScore(key, Double.NEGATIVE_INFINITY, now - EXPIRE_MS);
redisTemplate.opsForSet().add(roomChatUsersKey(roomId), String.valueOf(senderUserId));
}
/**
* 获取群聊历史最近 30 天内按时间升序合并所有用户
*/
public List<Object> getGroupChatHistory(Long roomId) {
String usersKey = roomChatUsersKey(roomId);
Set<Object> userIds = redisTemplate.opsForSet().members(usersKey);
if (userIds == null || userIds.isEmpty()) return new ArrayList<>();
long min = System.currentTimeMillis() - EXPIRE_MS;
List<Object[]> merged = new ArrayList<>();
for (Object uid : userIds) {
String key = roomChatKey(roomId, Long.valueOf(String.valueOf(uid)));
Set<ZSetOperations.TypedTuple<Object>> tuples =
redisTemplate.opsForZSet().rangeByScoreWithScores(key, min, Double.POSITIVE_INFINITY);
if (tuples != null) {
for (ZSetOperations.TypedTuple<Object> t : tuples) {
merged.add(new Object[] { t.getValue(), t.getScore() != null ? t.getScore() : 0.0 });
}
}
}
merged.sort((a, b) -> Double.compare((Double) a[1], (Double) b[1]));
List<Object> result = new ArrayList<>();
for (Object[] arr : merged) {
result.add(arr[0]);
}
return result;
}
/**
* 保存私聊消息
*/
public void savePrivateChat(Long userId1, Long userId2, Object msg) {
String key = privateChatKey(userId1, userId2);
long now = System.currentTimeMillis();
String member = msg instanceof String ? (String) msg : JSON.toJSONString(msg);
redisTemplate.opsForZSet().add(key, member, now);
redisTemplate.opsForZSet().removeRangeByScore(key, Double.NEGATIVE_INFINITY, now - EXPIRE_MS);
}
/**
* 获取私聊历史最近 30 天内按时间升序
*/
public List<Object> getPrivateChatHistory(Long userId1, Long userId2) {
String key = privateChatKey(userId1, userId2);
long min = System.currentTimeMillis() - EXPIRE_MS;
Set<Object> set = redisTemplate.opsForZSet().rangeByScore(key, min, Double.POSITIVE_INFINITY);
return set != null ? new ArrayList<>(set) : new ArrayList<>();
}
}

5
ruoyi-common/src/main/java/com/ruoyi/common/constant/CacheConstants.java

@ -13,6 +13,11 @@ public class CacheConstants
public static final String LOGIN_TOKEN_KEY = "login_tokens:";
/**
* 用户ID -> 当前活跃 token 映射用于单设备登录新登录顶掉旧会话
*/
public static final String LOGIN_USER_ID_TOKEN_KEY = "login_tokens:user:";
/**
* 验证码 redis key
*/
public static final String CAPTCHA_CODE_KEY = "captcha_codes:";

14
ruoyi-common/src/main/java/com/ruoyi/common/utils/ServletUtils.java

@ -138,9 +138,21 @@ public class ServletUtils
*/
public static void renderString(HttpServletResponse response, String string)
{
renderString(response, string, 200);
}
/**
* 将字符串渲染到客户端可指定 HTTP 状态码用于 401 等认证失败场景
*
* @param response 渲染对象
* @param string 待渲染的字符串
* @param status HTTP 状态码
*/
public static void renderString(HttpServletResponse response, String string, int status)
{
try
{
response.setStatus(200);
response.setStatus(status);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().print(string);

3
ruoyi-framework/src/main/java/com/ruoyi/framework/security/handle/AuthenticationEntryPointImpl.java

@ -29,6 +29,7 @@ public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, S
{
int code = HttpStatus.UNAUTHORIZED;
String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
// 使用 HTTP 401 状态码,便于前端 axios error 拦截器识别并触发重新登录(含被顶掉场景)
ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)), HttpStatus.UNAUTHORIZED);
}
}

30
ruoyi-framework/src/main/java/com/ruoyi/framework/web/service/TokenService.java

@ -94,30 +94,51 @@ public class TokenService
}
/**
* 删除用户身份信息
* 删除用户身份信息同时清除用户- token 映射便于单设备登录
*/
public void delLoginUser(String token)
{
if (StringUtils.isNotEmpty(token))
{
String userKey = getTokenKey(token);
LoginUser loginUser = redisCache.getCacheObject(userKey);
if (loginUser != null && loginUser.getUserId() != null) {
redisCache.deleteObject(CacheConstants.LOGIN_USER_ID_TOKEN_KEY + loginUser.getUserId());
}
redisCache.deleteObject(userKey);
}
}
/**
* 创建令牌
* 创建令牌单设备登录新登录会顶掉该账号在其他设备的旧会话
*
* @param loginUser 用户信息
* @return 令牌
*/
public String createToken(LoginUser loginUser)
{
// 踢掉该账号在其他设备的旧会话
Long userId = loginUser.getUserId();
if (userId != null) {
String userTokenKey = CacheConstants.LOGIN_USER_ID_TOKEN_KEY + userId;
String oldToken = redisCache.getCacheObject(userTokenKey);
if (StringUtils.isNotEmpty(oldToken)) {
redisCache.deleteObject(getTokenKey(oldToken));
log.info("用户[{}]新设备登录,已踢掉旧会话 token={}", loginUser.getUsername(), oldToken);
}
}
String token = IdUtils.fastUUID();
loginUser.setToken(token);
setUserAgent(loginUser);
refreshToken(loginUser);
// 记录该用户当前活跃的 token(用于下次登录时踢掉)
if (userId != null) {
String userTokenKey = CacheConstants.LOGIN_USER_ID_TOKEN_KEY + userId;
redisCache.setCacheObject(userTokenKey, token, expireTime, TimeUnit.MINUTES);
}
Map<String, Object> claims = new HashMap<>();
claims.put(Constants.LOGIN_USER_KEY, token);
claims.put(Constants.JWT_USERNAME, loginUser.getUsername());
@ -152,6 +173,11 @@ public class TokenService
// 根据uuid将loginUser缓存
String userKey = getTokenKey(loginUser.getToken());
redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
// 同步刷新用户- token 映射的过期时间
if (loginUser.getUserId() != null) {
String userTokenKey = CacheConstants.LOGIN_USER_ID_TOKEN_KEY + loginUser.getUserId();
redisCache.setCacheObject(userTokenKey, loginUser.getToken(), expireTime, TimeUnit.MINUTES);
}
}
/**

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

6
ruoyi-ui/src/assets/styles/ruoyi.scss

@ -81,6 +81,12 @@
margin-top: 6vh !important;
}
/* 全局弹窗:取消背景变暗,点击遮罩不影响地图操作(遮罩透明且不拦截鼠标事件) */
.v-modal {
background-color: transparent !important;
pointer-events: none !important;
}
.el-dialog__wrapper.scrollbar .el-dialog .el-dialog__body {
overflow: auto;
overflow-x: hidden;

13
ruoyi-ui/src/lang/en.js

@ -51,12 +51,7 @@ export default {
powerZone: 'Power Zone',
threatZone: 'Threat Zone'
},
tools: {
routeCalculation: 'Route Calculation',
conflictDisplay: 'Conflict Display',
dataMaterials: 'Data Materials',
coordinateConversion: 'Coordinate Conversion'
},
options: {
settings: 'Settings',
pageLayout: 'Page Layout',
@ -150,7 +145,11 @@ export default {
rollbackConfirmText: 'Are you sure you want to rollback to the selected operation?',
rollbackWarning: 'This operation will undo this operation and all subsequent changes, cannot be recovered!',
confirmRollback: 'Confirm Rollback',
groupChat: 'Group Chat - Online Members Communication',
groupChat: 'Group Chat',
privateChat: 'Private Chat',
selectMember: 'Select',
selectMemberToChat: 'Select a member to start private chat',
selectMemberFirst: 'Please select a member first',
onlineCount: ' people online',
inputMessage: 'Please enter message...',
send: 'Send',

13
ruoyi-ui/src/lang/zh.js

@ -51,12 +51,7 @@ export default {
powerZone: '威力区',
threatZone: '威胁区'
},
tools: {
routeCalculation: '航线计算',
conflictDisplay: '冲突显示',
dataMaterials: '数据资料',
coordinateConversion: '坐标换算'
},
options: {
settings: '设置',
pageLayout: '页面布局',
@ -150,7 +145,11 @@ export default {
rollbackConfirmText: '确定要回滚到所选操作吗?',
rollbackWarning: '此操作将撤销该操作及其后的所有更改,不可恢复!',
confirmRollback: '确定回滚',
groupChat: '群聊 - 在线成员交流',
groupChat: '群聊',
privateChat: '私聊',
selectMember: '选择对象',
selectMemberToChat: '选择成员开始私聊',
selectMemberFirst: '请先选择要私聊的成员',
onlineCount: '人在线',
inputMessage: '请输入消息...',
send: '发送',

19
ruoyi-ui/src/utils/request.js

@ -85,7 +85,7 @@ service.interceptors.response.use(res => {
if (code === 401) {
if (!isRelogin.show) {
isRelogin.show = true
MessageBox.confirm('登录状态已过期,您可以继续留在该页面,或者重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => {
MessageBox.confirm('登录状态已过期或您的账号已在其他设备登录,请重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => {
isRelogin.show = false
store.dispatch('LogOut').then(() => {
location.href = '/index'
@ -109,6 +109,23 @@ service.interceptors.response.use(res => {
}
},
error => {
// HTTP 401:token 失效或被顶掉,触发重新登录(与 success 中 res.data.code===401 一致)
const status = error.response && error.response.status
const code = error.response && error.response.data && error.response.data.code
if (status === 401 || code === 401) {
if (!isRelogin.show) {
isRelogin.show = true
MessageBox.confirm('登录状态已过期或您的账号已在其他设备登录,请重新登录', '系统提示', { confirmButtonText: '重新登录', cancelButtonText: '取消', type: 'warning' }).then(() => {
isRelogin.show = false
store.dispatch('LogOut').then(() => {
location.href = '/index'
})
}).catch(() => {
isRelogin.show = false
})
}
return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
}
console.log('err' + error)
let { message } = error
if (message == "Network Error") {

177
ruoyi-ui/src/utils/websocket.js

@ -14,6 +14,17 @@ const WS_BASE = process.env.VUE_APP_BASE_API || '/dev-api'
* @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.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 - 错误回调
@ -25,6 +36,17 @@ export function createRoomWebSocket(options) {
onMembers,
onMemberJoined,
onMemberLeft,
onChatMessage,
onPrivateChat,
onChatHistory,
onPrivateChatHistory,
onSyncRouteVisibility,
onSyncWaypoints,
onSyncPlatformIcons,
onSyncRoomDrawings,
onSyncPlatformStyles,
onObjectEditLock,
onObjectEditUnlock,
onConnected,
onDisconnected,
onError,
@ -32,7 +54,8 @@ export function createRoomWebSocket(options) {
} = options
let client = null
let subscription = null
let roomSubscription = null
let privateSubscription = null
let heartbeatTimer = null
let reconnectAttempts = 0
const maxReconnectAttempts = 10
@ -72,6 +95,98 @@ export function createRoomWebSocket(options) {
}
}
function sendChat(content) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'CHAT', content })
})
}
}
function sendPrivateChat(targetUserId, targetUserName, content) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'PRIVATE_CHAT', targetUserId, targetUserName, content })
})
}
}
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) {
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)
@ -84,7 +199,7 @@ export function createRoomWebSocket(options) {
}
}
function handleMessage(message) {
function handleRoomMessage(message) {
try {
const body = JSON.parse(message.body)
const type = body.type
@ -94,12 +209,44 @@ export function createRoomWebSocket(options) {
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)
}
} 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)
}
} catch (e) {
console.warn('[WebSocket] parse private message error:', e)
}
}
function connect() {
const token = getToken()
if (!token) {
@ -115,7 +262,8 @@ export function createRoomWebSocket(options) {
heartbeatOutgoing: 0,
onConnect: () => {
reconnectAttempts = 0
subscription = client.subscribe('/topic/room/' + roomId, handleMessage)
roomSubscription = client.subscribe('/topic/room/' + roomId, handleRoomMessage)
privateSubscription = client.subscribe('/user/queue/private', handlePrivateMessage)
sendJoin()
startHeartbeat()
onConnected && onConnected()
@ -126,7 +274,8 @@ export function createRoomWebSocket(options) {
},
onWebSocketClose: () => {
stopHeartbeat()
subscription = null
roomSubscription = null
privateSubscription = null
onDisconnected && onDisconnected()
}
})
@ -136,9 +285,13 @@ export function createRoomWebSocket(options) {
function disconnect() {
stopHeartbeat()
sendLeave()
if (subscription) {
subscription.unsubscribe()
subscription = null
if (roomSubscription) {
roomSubscription.unsubscribe()
roomSubscription = null
}
if (privateSubscription) {
privateSubscription.unsubscribe()
privateSubscription = null
}
if (client) {
client.deactivate()
@ -160,6 +313,16 @@ export function createRoomWebSocket(options) {
return {
disconnect,
reconnect: connect,
sendChat,
sendPrivateChat,
sendPrivateChatHistoryRequest,
sendSyncRouteVisibility,
sendSyncWaypoints,
sendSyncPlatformIcons,
sendSyncRoomDrawings,
sendSyncPlatformStyles,
sendObjectEditLock,
sendObjectEditUnlock,
get connected() {
return client && client.connected
}

181
ruoyi-ui/src/views/cesiumMap/index.vue

@ -361,6 +361,11 @@ export default {
type: Object,
default: () => ({})
},
/** 被其他成员编辑锁定的航线 ID 列表(禁止拖拽航点等) */
routeLockedByOtherIds: {
type: Array,
default: () => []
},
/** 推演时间轴:相对 K 的分钟数(用于导弹按时间轴显示/隐藏与位置插值) */
deductionTimeMinutes: {
type: Number,
@ -3598,6 +3603,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 = {
@ -5762,34 +5769,45 @@ export default {
addLineEntity(positions, pointEntities = []) {
this.entityCounter++
const id = `line_${this.entityCounter}`
const entity = this.viewer.entities.add({
id: id,
name: `测距 ${this.entityCounter}`,
polyline: {
positions: positions,
width: this.defaultStyles.line.width,
material: Cesium.Color.fromCssColorString(this.defaultStyles.line.color),
arcType: Cesium.ArcType.NONE
}
})
const entityData = {
id,
type: 'line',
points: positions.map(p => this.cartesianToLatLng(p)),
positions: positions,
entity: entity,
entity: null,
pointEntities: pointEntities, //
color: this.defaultStyles.line.color,
width: this.defaultStyles.line.width,
label: `测距 ${this.entityCounter}`,
bearingType: 'true' //
}
// 使 CallbackProperty
const positionsProp = pointEntities.length > 0
? new Cesium.CallbackProperty(() => {
return entityData.pointEntities.map(pe => {
const pos = pe.position.getValue(Cesium.JulianDate.now())
return pos || Cesium.Cartesian3.ZERO
})
}, false)
: positions
const entity = this.viewer.entities.add({
id: id,
name: `测距 ${this.entityCounter}`,
polyline: {
positions: positionsProp,
width: this.defaultStyles.line.width,
material: Cesium.Color.fromCssColorString(this.defaultStyles.line.color),
arcType: Cesium.ArcType.NONE
}
})
entityData.entity = entity
this.allEntities.push(entityData)
this.updateLineSegmentLabels(entityData)
entity.clickHandler = (e) => {
this.selectEntity(entityData)
e.stopPropagation()
}
this.notifyDrawingEntitiesChanged()
return entityData
},
addPolygonEntity(positions) {
@ -6614,7 +6632,7 @@ export default {
platformColor: this.editPlatformForm.iconColor || '#000000'
};
savePlatformStyle(styleData).then(() => {
// console.log("");
this.$emit('platform-style-saved');
}).catch(err => {
console.error("样式保存失败", err);
});
@ -6935,7 +6953,7 @@ export default {
powerZoneOpacity: existing.powerZoneOpacity,
powerZoneVisible: existing.powerZoneVisible
}
savePlatformStyle(styleData).catch(() => {})
savePlatformStyle(styleData).then(() => this.$emit('platform-style-saved')).catch(() => {})
}).catch(() => {})
}
},
@ -7009,7 +7027,7 @@ export default {
powerZoneOpacity: existing.powerZoneOpacity,
powerZoneVisible: existing.powerZoneVisible
}
savePlatformStyle(styleData).then(doDrawAndClose).catch(() => doDrawAndClose())
savePlatformStyle(styleData).then(() => { this.$emit('platform-style-saved'); doDrawAndClose(); }).catch(() => doDrawAndClose())
}).catch(() => doDrawAndClose())
} else {
doDrawAndClose()
@ -7063,7 +7081,7 @@ export default {
powerZoneOpacity: opacity,
powerZoneVisible: visible
}
savePlatformStyle(styleData).catch(() => {})
savePlatformStyle(styleData).then(() => this.$emit('platform-style-saved')).catch(() => {})
}).catch(() => {})
}
},
@ -7139,7 +7157,7 @@ export default {
powerZoneOpacity: opacity,
powerZoneVisible: visible
}
savePlatformStyle(styleData).then(doDrawAndClose).catch(() => doDrawAndClose())
savePlatformStyle(styleData).then(() => { this.$emit('platform-style-saved'); doDrawAndClose(); }).catch(() => doDrawAndClose())
}).catch(() => doDrawAndClose())
} else {
doDrawAndClose()
@ -7176,7 +7194,7 @@ export default {
if (currentRoomId && Number(platformId) > 0) {
getPlatformStyle({ roomId: currentRoomId, routeId, platformId }).then(res => {
const existing = res.data || {}
savePlatformStyle({ ...existing, roomId: currentRoomId, routeId, platformId, detectionZoneVisible: nextVisible }).catch(() => {})
savePlatformStyle({ ...existing, roomId: currentRoomId, routeId, platformId, detectionZoneVisible: nextVisible }).then(() => this.$emit('platform-style-saved')).catch(() => {})
}).catch(() => {})
}
return
@ -7201,7 +7219,7 @@ export default {
if (roomId && platformId) {
getPlatformStyle({ roomId, routeId: 0, platformId }).then(res => {
const existing = res.data || {}
savePlatformStyle({ ...existing, roomId: String(roomId), routeId: 0, platformId, detectionZoneVisible: nextVisible }).catch(() => {})
savePlatformStyle({ ...existing, roomId: String(roomId), routeId: 0, platformId, detectionZoneVisible: nextVisible }).then(() => this.$emit('platform-style-saved')).catch(() => {})
}).catch(() => {})
}
}
@ -7238,7 +7256,7 @@ export default {
if (currentRoomId && Number(platformId) > 0) {
getPlatformStyle({ roomId: currentRoomId, routeId, platformId }).then(res => {
const existing = res.data || {}
savePlatformStyle({ ...existing, roomId: currentRoomId, routeId, platformId, powerZoneVisible: nextVisible }).catch(() => {})
savePlatformStyle({ ...existing, roomId: currentRoomId, routeId, platformId, powerZoneVisible: nextVisible }).then(() => this.$emit('platform-style-saved')).catch(() => {})
}).catch(() => {})
}
return
@ -7264,7 +7282,7 @@ export default {
if (roomId && platformId) {
getPlatformStyle({ roomId, routeId: 0, platformId }).then(res => {
const existing = res.data || {}
savePlatformStyle({ ...existing, roomId: String(roomId), routeId: 0, platformId, powerZoneVisible: nextVisible }).catch(() => {})
savePlatformStyle({ ...existing, roomId: String(roomId), routeId: 0, platformId, powerZoneVisible: nextVisible }).then(() => this.$emit('platform-style-saved')).catch(() => {})
}).catch(() => {})
}
}
@ -7686,9 +7704,9 @@ export default {
});
},
// ================== / ==================
/** 需要持久化到方案的空域图形类型(不含平台图标、航线、测距点线) */
/** 需要持久化到方案的空域图形类型(含测距;不含平台图标、航线) */
getDrawingEntityTypes() {
return ['polygon', 'rectangle', 'circle', 'sector', 'arrow', 'text', 'image', 'powerZone']
return ['line', 'polygon', 'rectangle', 'circle', 'sector', 'arrow', 'text', 'image', 'powerZone']
},
/** 空域/威力区图形增删时通知父组件,用于自动保存到房间(从房间加载时不触发) */
notifyDrawingEntitiesChanged() {
@ -7703,7 +7721,11 @@ export default {
case 'point':
data = { lat: entity.lat, lng: entity.lng }; break
case 'line':
data = { points: entity.points || [] }; break
data = {
points: entity.points || [],
width: entity.width != null ? entity.width : 3,
bearingType: entity.bearingType || 'true'
}; break
case 'polygon':
data = {
points: entity.points || [],
@ -7813,6 +7835,10 @@ export default {
if (!entity && item.id) entity = this.viewer.entities.getById(item.id)
if (entity) this.viewer.entities.remove(entity)
if (item.type === 'powerZone' && item.centerEntity) this.viewer.entities.remove(item.centerEntity)
if (item.type === 'line') {
if (item.pointEntities) item.pointEntities.forEach(pe => { try { this.viewer.entities.remove(pe) } catch (_) {} })
if (item.segmentLabelEntities) item.segmentLabelEntities.forEach(le => { try { this.viewer.entities.remove(le) } catch (_) {} })
}
} catch (e) { console.warn('clearDrawingEntities:', e) }
})
this.allEntities = this.allEntities.filter(item => !types.includes(item.type))
@ -7937,25 +7963,67 @@ export default {
break
case 'line': {
const linePositions = entityData.data.points.map(p => Cesium.Cartesian3.fromDegrees(p.lng, p.lat))
entity = this.viewer.entities.add({
polyline: {
positions: linePositions,
width: 3,
material: Cesium.Color.fromCssColorString(color),
arcType: Cesium.ArcType.NONE
}
})
const lineWidth = entityData.data.width != null ? entityData.data.width : 3
const pointEntities = []
for (let i = 0; i < linePositions.length; i++) {
const pos = linePositions[i]
this.entityCounter++
const pointId = `point_${this.entityCounter}`
const isStartPoint = i === 0
const pointEntity = this.viewer.entities.add({
id: pointId,
position: pos,
point: {
pixelSize: this.defaultStyles.point.size,
color: Cesium.Color.fromCssColorString(this.defaultStyles.point.color),
outlineColor: Cesium.Color.WHITE,
outlineWidth: 2
},
label: {
text: isStartPoint ? '起点' : '',
font: '14px Microsoft YaHei, sans-serif',
fillColor: Cesium.Color.BLACK,
backgroundColor: Cesium.Color.WHITE.withAlpha(0.8),
showBackground: true,
horizontalOrigin: Cesium.HorizontalOrigin.LEFT,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
pixelOffset: new Cesium.Cartesian2(15, 0),
disableDepthTestDistance: Number.POSITIVE_INFINITY
}
})
pointEntities.push(pointEntity)
}
const lineEntityData = {
id: entity.id,
id: `line_import_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
type: 'line',
label: entityData.label || '测距',
color: color,
entity,
entity: null,
points: entityData.data.points,
positions: linePositions,
pointEntities: [],
pointEntities,
width: lineWidth,
bearingType: entityData.data.bearingType || 'true'
}
const positionsProp = pointEntities.length > 0
? new Cesium.CallbackProperty(() => {
return lineEntityData.pointEntities.map(pe => {
const p = pe.position.getValue(Cesium.JulianDate.now())
return p || Cesium.Cartesian3.ZERO
})
}, false)
: linePositions
entity = this.viewer.entities.add({
id: lineEntityData.id,
name: lineEntityData.label,
polyline: {
positions: positionsProp,
width: lineWidth,
material: Cesium.Color.fromCssColorString(color),
arcType: Cesium.ArcType.NONE
}
})
lineEntityData.entity = entity
this.allEntities.push(lineEntityData)
this.updateLineSegmentLabels(lineEntityData)
return
@ -8523,62 +8591,33 @@ export default {
}
}
}, Cesium.ScreenSpaceEventType.LEFT_DOWN)
//
// 线 CallbackProperty
this.pointMovementHandler.setInputAction((movement) => {
if (isMoving && selectedPoint && selectedLineEntity) {
const newPosition = this.getClickPosition(movement.endPosition)
if (newPosition) {
//
// 线 CallbackProperty /
selectedPoint.position = newPosition
// Cesium
const newPositions = [...selectedLineEntity.positions]
newPositions[pointIndex] = newPosition
// 线
this.viewer.entities.remove(selectedLineEntity.entity)
// 线
const entitiesToRemove = []
this.viewer.entities.values.forEach(e => {
if (e.id && e.id === selectedLineEntity.id) {
entitiesToRemove.push(e)
}
})
entitiesToRemove.forEach(e => {
this.viewer.entities.remove(e)
})
// 线
const newEntity = this.viewer.entities.add({
id: selectedLineEntity.id,
name: selectedLineEntity.label,
polyline: {
positions: newPositions,
width: selectedLineEntity.width,
material: Cesium.Color.fromCssColorString(selectedLineEntity.color),
arcType: Cesium.ArcType.NONE
}
})
// 线
selectedLineEntity.entity = newEntity
selectedLineEntity.positions = newPositions
//
// positions points updateLineSegmentLabels 使
selectedLineEntity.positions[pointIndex] = newPosition
selectedLineEntity.points[pointIndex] = this.cartesianToLatLng(newPosition)
//
this.updateLineSegmentLabels(selectedLineEntity)
//
this.viewer.scene.requestRender()
}
}
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
//
this.pointMovementHandler.setInputAction(() => {
// 线
if (isMoving && selectedLineEntity && selectedLineEntity.type === 'line') {
this.notifyDrawingEntitiesChanged()
}
//
if (originalCameraController !== null) {
this.viewer.scene.screenSpaceCameraController.enableInputs = originalCameraController
originalCameraController = null
}
isMoving = false
selectedPoint = null
selectedLineEntity = null

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

@ -59,6 +59,9 @@
<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="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 +74,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 +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
@ -383,12 +412,11 @@ export default {
})
this.$emit('select-plan', { id: null })
} else {
// 线
// 线 activeRouteIds
// 线广
this.activeRouteIds.forEach(activeId => {
const activeRoute = this.routes.find(r => r.id === activeId);
if (activeRoute) {
this.$emit('toggle-route-visibility', activeRoute);
this.$emit('toggle-route-visibility', activeRoute, { fromPlanSwitch: true });
}
});
//
@ -504,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,
@ -742,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;

430
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"
@ -35,7 +36,8 @@
@platform-icon-updated="onPlatformIconUpdated"
@platform-icon-removed="onPlatformIconRemoved"
@viewer-ready="onViewerReady"
@drawing-entities-changed="onDrawingEntitiesChanged" />
@drawing-entities-changed="onDrawingEntitiesChanged"
@platform-style-saved="onPlatformStyleSaved" />
<div v-show="!screenshotMode" class="map-overlay-text">
<!-- <i class="el-icon-location-outline text-3xl mb-2 block"></i> -->
<!-- <p>二维GIS地图区域</p>
@ -75,25 +77,13 @@
</div>
</el-dialog>
<!-- 设定/修改 K 时弹窗房主或管理员可随时打开并修改 -->
<el-dialog title="设定 / 修改 K 时" :visible.sync="showKTimeSetDialog" width="420px" :append-to-body="true">
<el-form label-width="90px">
<el-form-item label="K 时(基准)">
<el-date-picker
v-model="kTimeForm.dateTime"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="选择日期和时间"
style="width: 100%"
/>
</el-form-item>
<p class="k-time-tip">航线的任务时间将以此 K 时为基准进行加减航点表时间为相对 K 的分钟数房主/管理员可随时再次点击作战时间修改 K </p>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="showKTimeSetDialog = false"> </el-button>
<el-button type="primary" @click="saveKTime"> </el-button>
</div>
</el-dialog>
<!-- 设定/修改 K 时弹窗 4T 一致可拖动记录位置不阻挡地图 -->
<k-time-set-dialog
v-model="showKTimeSetDialog"
:date-time.sync="kTimeForm.dateTime"
:room-id="currentRoomId"
@save="saveKTime"
/>
</div>
<!-- 顶部导航栏 -->
<top-header
@ -187,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"
@ -205,7 +197,7 @@
@open-waypoint-dialog="openWaypointDialog"
@add-waypoint="addWaypoint"
@cancel-route="cancelRoute"
@toggle-route-visibility="toggleRouteVisibility"
@toggle-route-visibility="(route, opts) => toggleRouteVisibility(route, opts)"
@toggle-route-lock="handleToggleRouteLockFromPanel"
@view-conflict="viewConflict"
@resolve-conflict="resolveConflict"
@ -312,23 +304,32 @@
</el-dialog>
</div>
<!-- 在线成员弹窗 -->
<!-- 在线成员弹窗 4T 一致可拖动记录位置不阻挡地图 -->
<online-members-dialog
v-model="showOnlineMembers"
:online-members="wsOnlineMembers"
:room-id="currentRoomId"
:chat-messages="chatMessages"
:private-chat-messages="privateChatMessages"
:send-chat="sendChat"
:send-private-chat="sendPrivateChat"
:send-private-chat-history-request="sendPrivateChatHistoryRequest"
:current-user-id="currentUserId"
/>
<!-- 平台编辑弹窗 -->
<!-- 平台编辑弹窗 4T 一致可拖动记录位置不阻挡地图 -->
<platform-edit-dialog
v-model="showPlatformDialog"
:platform="selectedPlatform"
:room-id="currentRoomId"
@save="updatePlatform"
/>
<!-- 航线编辑弹窗 -->
<!-- 航线编辑弹窗 4T 一致可拖动记录位置不阻挡地图 -->
<route-edit-dialog
v-model="showRouteDialog"
:route="selectedRoute"
:room-id="currentRoomId"
@save="updateRoute"
/>
@ -431,6 +432,7 @@ import WaypointEditDialog from '@/views/dialogs/WaypointEditDialog'
import ScaleDialog from '@/views/dialogs/ScaleDialog'
import ExternalParamsDialog from '@/views/dialogs/ExternalParamsDialog'
import PageLayoutDialog from '@/views/dialogs/PageLayoutDialog'
import KTimeSetDialog from '@/views/dialogs/KTimeSetDialog'
import LeftMenu from './LeftMenu'
import RightPanel from './RightPanel'
import BottomLeftPanel from './BottomLeftPanel'
@ -457,6 +459,7 @@ export default {
ScaleDialog,
ExternalParamsDialog,
PageLayoutDialog,
KTimeSetDialog,
LeftMenu,
RightPanel,
BottomLeftPanel,
@ -511,11 +514,17 @@ export default {
screenshotDataUrl: '',
screenshotFileName: '',
// 线 WebSocket 广
routeLockedBy: {}, // routeId -> { userId, userName, nickName, sessionId }
routeEditLockedId: null, // 线 ID
//
roomCode: 'JTF-7-ALPHA',
onlineCount: 0,
wsOnlineMembers: [],
wsConnection: null,
chatMessages: [],
privateChatMessages: {},
combatTime: 'K+00:00:00', //
astroTime: '',
roomDetail: null,
@ -645,9 +654,20 @@ export default {
//
userAvatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
/** 当前连接的 WebSocket sessionId 集合(用于过滤自己发出的同步消息,避免重复应用) */
mySyncSessionIds: [],
};
},
watch: {
'$route.query.roomId': {
handler(newRoomId) {
if (newRoomId != null && String(newRoomId) !== String(this.currentRoomId)) {
this.currentRoomId = newRoomId;
this.connectRoomWebSocket();
if (newRoomId) this.getRoomDetail();
}
}
},
timeProgress: {
handler() {
this.updateTimeFromProgress();
@ -666,9 +686,21 @@ 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: {
currentUserId() {
const id = this.$store.getters.id;
return id != null ? Number(id) : null;
},
isRoomOwner() {
if (!this.roomDetail || this.roomDetail.ownerId == null) return false;
const myId = this.$store.getters.id;
@ -703,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() {
@ -743,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);
@ -825,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) {
@ -854,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;
@ -972,6 +1037,7 @@ export default {
this.selectedRouteDetails = { ...this.selectedRouteDetails, waypoints: sortedWaypoints };
}
this.$message.success('已添加航点');
this.wsConnection?.sendSyncWaypoints?.(routeId);
} catch (e) {
this.$message.error(e.msg || e.message || '添加航点失败');
console.error(e);
@ -980,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;
@ -1077,6 +1147,7 @@ export default {
}
}
this.$message.success(isHold ? '已设为普通航点' : '已设为盘旋航点');
this.wsConnection?.sendSyncWaypoints?.(routeId);
} catch (e) {
this.$message.error(e.msg || e.message || '切换失败');
console.error(e);
@ -1115,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) {
@ -1182,6 +1257,7 @@ export default {
}
this.$message.success('航点位置已更新');
this.$nextTick(() => this.updateDeductionPositions());
this.wsConnection?.sendSyncWaypoints?.(routeId);
// Redis
if (this.currentRoomId && routeForPlatform && waypoints.length > 0) {
this.updateMissilePositionsAfterRouteEdit(this.currentRoomId, routeId, routeForPlatform.platformId != null ? routeForPlatform.platformId : 0, waypoints);
@ -1199,6 +1275,42 @@ export default {
showOnlineMembersDialog() {
this.showOnlineMembers = true;
},
sendChat(content) {
if (this.wsConnection && this.wsConnection.sendChat) {
this.wsConnection.sendChat(content);
}
},
sendPrivateChatHistoryRequest(targetUserId) {
if (this.wsConnection && this.wsConnection.sendPrivateChatHistoryRequest) {
this.wsConnection.sendPrivateChatHistoryRequest(targetUserId);
}
},
normalizeChatMessages(messages) {
if (!messages || !Array.isArray(messages)) return [];
return messages.map(m => {
if (typeof m === 'string') {
try {
return JSON.parse(m);
} catch (e) {
return null;
}
}
return m;
}).filter(Boolean);
},
sendPrivateChat(targetUserId, targetUserName, content) {
if (!this.wsConnection || !this.wsConnection.sendPrivateChat) return;
this.wsConnection.sendPrivateChat(targetUserId, targetUserName, content);
const sender = {
userId: this.currentUserId,
userName: this.$store.getters.name,
nickName: this.$store.getters.nickName || this.$store.getters.name,
avatar: this.$store.getters.avatar || ''
};
const msg = { type: 'PRIVATE_CHAT', sender, targetUserId, targetUserName, content, timestamp: Date.now() };
const list = this.privateChatMessages[targetUserId] || [];
this.$set(this.privateChatMessages, targetUserId, [...list, msg]);
},
connectRoomWebSocket() {
if (!this.currentRoomId) return;
this.disconnectRoomWebSocket();
@ -1208,6 +1320,8 @@ export default {
onMembers: (members) => {
this.wsOnlineMembers = members.map(m => ({
id: m.sessionId || m.userId,
userId: m.userId,
userName: m.userName,
name: m.nickName || m.userName,
role: m.role === 'owner' ? '房主' : (m.role === 'admin' ? '管理员' : '成员'),
status: '在线',
@ -1215,11 +1329,15 @@ export default {
avatar: m.avatar ? (m.avatar.startsWith('http') ? m.avatar : (baseUrl + m.avatar)) : ''
}));
this.onlineCount = this.wsOnlineMembers.length;
const myId = this.$store.getters.id;
this.mySyncSessionIds = (members || []).filter(m => myId != null && String(m.userId) === String(myId)).map(m => m.sessionId).filter(Boolean);
},
onMemberJoined: (member) => {
const baseUrl = (process.env.VUE_APP_BACKEND_URL || (window.location.origin + (process.env.VUE_APP_BASE_API || '')));
const m = {
id: member.sessionId || member.userId,
userId: member.userId,
userName: member.userName,
name: member.nickName || member.userName,
role: member.role === 'owner' ? '房主' : (member.role === 'admin' ? '管理员' : '成员'),
status: '在线',
@ -1230,15 +1348,83 @@ export default {
this.wsOnlineMembers = [...this.wsOnlineMembers, m];
this.onlineCount = this.wsOnlineMembers.length;
}
const myId = this.$store.getters.id;
if (myId != null && String(member.userId) === String(myId) && member.sessionId) {
if (!this.mySyncSessionIds.includes(member.sessionId)) {
this.mySyncSessionIds = [...this.mySyncSessionIds, member.sessionId];
}
}
},
onMemberLeft: (member, sessionId) => {
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];
},
onPrivateChat: (msg) => {
const senderId = msg.sender && msg.sender.userId;
if (!senderId) return;
const list = this.privateChatMessages[senderId] || [];
this.$set(this.privateChatMessages, senderId, [...list, msg]);
},
onChatHistory: (messages) => {
const history = this.normalizeChatMessages(messages);
const maxTs = history.length ? Math.max(...history.map(m => m.timestamp || 0)) : 0;
const newer = this.chatMessages.filter(m => (m.timestamp || 0) > maxTs);
this.chatMessages = [...history, ...newer];
},
onPrivateChatHistory: (targetUserId, messages) => {
const history = this.normalizeChatMessages(messages);
const existing = this.privateChatMessages[targetUserId] || [];
const maxTs = history.length ? Math.max(...history.map(m => m.timestamp || 0)) : 0;
const newer = existing.filter(m => (m.timestamp || 0) > maxTs);
this.$set(this.privateChatMessages, targetUserId, [...history, ...newer]);
},
onSyncRouteVisibility: (routeId, visible, senderSessionId) => {
if (this.isMySyncSession(senderSessionId)) return;
this.applySyncRouteVisibility(routeId, visible);
},
onSyncWaypoints: (routeId, senderSessionId) => {
if (this.isMySyncSession(senderSessionId)) return;
this.applySyncWaypoints(routeId);
},
onSyncPlatformIcons: (senderSessionId) => {
if (this.isMySyncSession(senderSessionId)) return;
this.applySyncPlatformIcons();
},
onSyncRoomDrawings: (senderSessionId) => {
if (this.isMySyncSession(senderSessionId)) return;
this.applySyncRoomDrawings();
},
onSyncPlatformStyles: (senderSessionId) => {
if (this.isMySyncSession(senderSessionId)) return;
this.applySyncPlatformStyles();
},
onConnected: () => {},
onDisconnected: () => {
this.onlineCount = 0;
this.wsOnlineMembers = [];
this.mySyncSessionIds = [];
this.chatMessages = [];
this.privateChatMessages = {};
this.routeLockedBy = {};
},
onError: (err) => {
console.warn('[WebSocket]', err);
@ -1251,7 +1437,153 @@ export default {
this.wsConnection = null;
}
this.wsOnlineMembers = [];
this.mySyncSessionIds = [];
this.onlineCount = 0;
this.chatMessages = [];
this.privateChatMessages = {};
},
/** 判断是否为当前连接发出的同步消息(避免自己发的消息再应用一次) */
isMySyncSession(senderSessionId) {
if (!senderSessionId) return false;
return Array.isArray(this.mySyncSessionIds) && this.mySyncSessionIds.includes(senderSessionId);
},
/** 收到其他设备的航线显隐同步:直接应用变更,不经过 selectRoute 的 toggle 逻辑,避免互相干扰 */
async applySyncRouteVisibility(routeId, visible) {
const route = this.routes.find(r => r.id === routeId);
if (!route) return;
if (visible) {
if (this.activeRouteIds.includes(routeId)) return;
try {
const res = await getRoutes(route.id);
if (res.code !== 200 || !res.data) return;
const waypoints = res.data.waypoints || [];
this.activeRouteIds = [...this.activeRouteIds, route.id];
this.selectedRouteId = res.data.id;
this.selectedRouteDetails = {
id: res.data.id,
name: res.data.callSign,
waypoints: waypoints,
platformId: route.platformId,
platform: route.platform,
attributes: route.attributes
};
const routeIndex = this.routes.findIndex(r => r.id === route.id);
if (routeIndex > -1) {
this.$set(this.routes, routeIndex, { ...this.routes[routeIndex], waypoints });
}
if (waypoints.length > 0 && this.$refs.cesiumMap) {
const roomId = this.currentRoomId;
if (roomId && route.platformId) {
try {
const styleRes = await getPlatformStyle({ roomId, routeId: route.id, platformId: route.platformId });
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(route.id, styleRes.data);
} catch (_) {}
}
this.$refs.cesiumMap.renderRouteWaypoints(waypoints, route.id, route.platformId, route.platform, this.parseRouteStyle(route.attributes));
}
} catch (_) {}
} else {
const idx = this.activeRouteIds.indexOf(routeId);
if (idx > -1) {
this.activeRouteIds = this.activeRouteIds.filter(id => id !== routeId);
if (this.$refs.cesiumMap) this.$refs.cesiumMap.removeRouteById(routeId);
if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) {
if (this.activeRouteIds.length > 0) {
const lastId = this.activeRouteIds[this.activeRouteIds.length - 1];
const res = await getRoutes(lastId);
if (res.code === 200 && res.data) {
const fromList = this.routes.find(r => r.id === lastId);
this.selectedRouteId = res.data.id;
this.selectedRouteDetails = {
id: res.data.id,
name: res.data.callSign,
waypoints: res.data.waypoints || [],
platformId: fromList?.platformId,
platform: fromList?.platform,
attributes: fromList?.attributes
};
}
} else {
this.selectedRouteId = null;
this.selectedRouteDetails = null;
}
}
}
}
},
/** 收到其他设备的航点变更同步:拉取最新数据并重绘 */
async applySyncWaypoints(routeId) {
try {
const res = await getRoutes(routeId);
if (res.code !== 200 || !res.data) return;
const waypoints = res.data.waypoints || [];
const route = this.routes.find(r => r.id === routeId);
if (route) {
this.$set(route, 'waypoints', waypoints);
}
if (this.selectedRouteId === routeId && this.selectedRouteDetails) {
this.selectedRouteDetails = { ...this.selectedRouteDetails, waypoints };
}
if (this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap && waypoints.length > 0) {
const r = this.routes.find(rr => rr.id === routeId);
if (r) {
this.$refs.cesiumMap.removeRouteById(routeId);
this.$refs.cesiumMap.renderRouteWaypoints(waypoints, routeId, r.platformId, r.platform, this.parseRouteStyle(r.attributes));
this.$nextTick(() => this.updateDeductionPositions());
}
}
} catch (e) {
console.warn('applySyncWaypoints failed', e);
}
},
/** 收到其他设备的平台图标变更同步:拉取最新数据并重绘 */
applySyncPlatformIcons() {
const rId = this.currentRoomId;
if (!rId || !this.$refs.cesiumMap || typeof this.$refs.cesiumMap.loadRoomPlatformIcons !== 'function') return;
listRoomPlatformIcons(rId).then(res => {
if (res.code === 200 && res.data) this.$refs.cesiumMap.loadRoomPlatformIcons(rId, res.data);
}).catch(() => {});
},
/** 收到其他设备的空域图形变更同步:拉取最新房间数据并重绘 */
applySyncRoomDrawings() {
if (!this.currentRoomId || !this.$refs.cesiumMap || typeof this.$refs.cesiumMap.loadFrontendDrawings !== 'function') return;
getRooms(this.currentRoomId).then(res => {
if (res.code === 200 && res.data) {
this.roomDetail = this.roomDetail ? { ...this.roomDetail, frontendDrawings: res.data.frontendDrawings } : { ...res.data };
if (res.data.frontendDrawings) {
this.$refs.cesiumMap.loadFrontendDrawings(res.data.frontendDrawings);
} else {
this.$refs.cesiumMap.clearDrawingEntities();
}
}
}).catch(() => {});
},
/** 收到其他设备的探测区/威力区样式变更同步:刷新平台图标与航线样式 */
async applySyncPlatformStyles() {
const rId = this.currentRoomId;
if (!rId || !this.$refs.cesiumMap) return;
if (typeof this.$refs.cesiumMap.loadRoomPlatformIcons === 'function') {
try {
const res = await listRoomPlatformIcons(rId);
if (res.code === 200 && res.data) this.$refs.cesiumMap.loadRoomPlatformIcons(rId, res.data);
} catch (_) {}
}
for (const routeId of this.activeRouteIds || []) {
const route = this.routes.find(r => r.id === routeId);
if (!route || !route.platformId) continue;
try {
const styleRes = await getPlatformStyle({ roomId: rId, routeId, platformId: route.platformId });
if (styleRes.data && this.$refs.cesiumMap) {
this.$refs.cesiumMap.setPlatformStyle(routeId, styleRes.data);
if (styleRes.data.detectionZoneVisible !== false && styleRes.data.detectionZoneRadius != null && Number(styleRes.data.detectionZoneRadius) > 0) {
this.$refs.cesiumMap.ensureDetectionZoneForRoute(routeId, styleRes.data.detectionZoneRadius, styleRes.data.detectionZoneColor || 'rgba(0, 150, 255, 0.35)', styleRes.data.detectionZoneOpacity);
}
if (styleRes.data.powerZoneVisible !== false && styleRes.data.powerZoneRadius != null && Number(styleRes.data.powerZoneRadius) > 0) {
this.$refs.cesiumMap.ensurePowerZoneForRoute(routeId, styleRes.data.powerZoneRadius, styleRes.data.powerZoneAngle ?? 120, styleRes.data.powerZoneColor || 'rgba(255, 0, 0, 0.3)', styleRes.data.powerZoneOpacity);
}
}
} catch (_) {}
}
},
//
openPlatformDialog(platform) {
@ -1361,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 {
@ -1425,6 +1766,7 @@ export default {
this.selectedRouteDetails.attributes = updatedRoute.attributes;
}
this.$message.success(updatedRoute.waypoints && updatedRoute.waypoints.length > 0 ? '航线与航点已保存' : '航线更新成功');
this.wsConnection?.sendSyncWaypoints?.(updatedRoute.id);
const routeStyle = updatedRoute.routeStyle || this.parseRouteStyle(updatedRoute.attributes);
if (this.$refs.cesiumMap && this.activeRouteIds.includes(updatedRoute.id)) {
const route = this.routes.find(r => r.id === updatedRoute.id);
@ -1471,6 +1813,10 @@ export default {
}
},
async handleDeleteRoute(route) {
if (this.isRouteLockedByOther(route.id)) {
this.$message.warning('该航线正被其他成员编辑,无法删除');
return;
}
try {
//
await this.$confirm(`确定要彻底删除航线 "${route.name}" 吗?`, '提示', {
@ -1703,6 +2049,7 @@ export default {
if (platformToRemove && this.$refs.cesiumMap) {
if (platformToRemove.serverId) {
await delRoomPlatformIcon(platformToRemove.serverId).catch(() => {});
this.wsConnection?.sendSyncPlatformIcons?.();
if (typeof this.$refs.cesiumMap.removePlatformIconByServerId === 'function') {
this.$refs.cesiumMap.removePlatformIconByServerId(platformToRemove.serverId);
}
@ -1727,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;
@ -1734,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);
@ -1792,6 +2148,7 @@ export default {
this.showWaypointDialog = false;
this.$message.success('航点信息已持久化至数据库');
this.$nextTick(() => this.updateDeductionPositions());
this.wsConnection?.sendSyncWaypoints?.(this.selectedRouteDetails.id);
// Redis
if (roomId && sd.waypoints && sd.waypoints.length > 0) {
this.updateMissilePositionsAfterRouteEdit(roomId, sd.id, sd.platformId != null ? sd.platformId : 0, sd.waypoints);
@ -1883,6 +2240,7 @@ export default {
const frontendDrawingsStr = JSON.stringify(drawingsData);
await updateRooms({ id: this.currentRoomId, frontendDrawings: frontendDrawingsStr });
if (this.roomDetail) this.roomDetail.frontendDrawings = frontendDrawingsStr;
this.wsConnection?.sendSyncRoomDrawings?.();
},
/** 将任意日期字符串格式化为 yyyy-MM-dd HH:mm:ss,供日期选择器使用 */
formatKTimeForPicker(val) {
@ -2332,6 +2690,7 @@ export default {
entityData.serverId = res.data.id
entityData.roomId = this.currentRoomId
this.$message.success('平台图标已保存到当前房间')
this.wsConnection?.sendSyncPlatformIcons?.()
}
} catch (e) {
console.warn('Parse platform drag data or save failed', e)
@ -2350,13 +2709,21 @@ export default {
lat: entityData.lat,
heading: entityData.heading != null ? entityData.heading : 0,
iconScale: entityData.iconScale != null ? entityData.iconScale : 1
}).then(() => {}).catch(() => {})
}).then(() => {
this.wsConnection?.sendSyncPlatformIcons?.()
}).catch(() => {})
}, 500)
},
/** 平台图标从地图删除时同步删除服务端记录 */
onPlatformIconRemoved({ serverId }) {
if (!serverId) return
delRoomPlatformIcon(serverId).then(() => {}).catch(() => {})
delRoomPlatformIcon(serverId).then(() => {
this.wsConnection?.sendSyncPlatformIcons?.()
}).catch(() => {})
},
/** 探测区/威力区样式保存后通知其他设备同步 */
onPlatformStyleSaved() {
this.wsConnection?.sendSyncPlatformStyles?.()
},
handleScaleUnitChange(unit) {
@ -3214,6 +3581,7 @@ export default {
}
}
this.$message.success('已添加盘旋航点');
this.wsConnection?.sendSyncWaypoints?.(routeId);
} catch (e) {
this.$message.error(e.msg || '添加盘旋失败');
console.error(e);
@ -3303,6 +3671,7 @@ export default {
this.selectedRouteDetails = null;
}
}
this.wsConnection?.sendSyncRouteVisibility?.(route.id, false);
this.$message.info(`已取消航线: ${route.name}`);
return;
}
@ -3349,6 +3718,7 @@ export default {
} else {
this.$message.warning('该航线暂无坐标数据,无法在地图展示');
}
this.wsConnection?.sendSyncRouteVisibility?.(route.id, true);
}
} catch (error) {
console.error("获取航线详情失败:", error);
@ -3463,16 +3833,17 @@ export default {
this.$message.success(nextLocked ? '航线已上锁,无法修改' : '航线已解锁,可以编辑');
},
toggleRouteVisibility(route) {
async toggleRouteVisibility(route, opts) {
const index = this.activeRouteIds.indexOf(route.id);
const fromPlanSwitch = opts && opts.fromPlanSwitch;
if (index > -1) {
// 线
// 使 Vue
this.activeRouteIds = this.activeRouteIds.filter(id => id !== route.id);
if (this.$refs.cesiumMap) {
this.$refs.cesiumMap.removeRouteById(route.id);
}
if (!fromPlanSwitch) this.wsConnection?.sendSyncRouteVisibility?.(route.id, false);
if (this.selectedRouteDetails && this.selectedRouteDetails.id === route.id) {
if (this.activeRouteIds.length > 0) {
const lastId = this.activeRouteIds[this.activeRouteIds.length - 1];
@ -3499,7 +3870,8 @@ export default {
}
} else {
// 线
this.selectRoute(route);
await this.selectRoute(route);
if (!fromPlanSwitch) this.wsConnection?.sendSyncRouteVisibility?.(route.id, true);
}
},

209
ruoyi-ui/src/views/dialogs/KTimeSetDialog.vue

@ -0,0 +1,209 @@
<template>
<!-- 功能与 4T 一致透明遮罩可拖动记录位置不阻挡地图风格保持原样 -->
<div v-if="value" class="k-time-set-dialog">
<div class="panel-container" :style="panelStyle">
<div class="dialog-header" @mousedown="onDragStart">
<h3>设定 / 修改 K </h3>
<div class="close-btn" @mousedown.stop @click="closeDialog">×</div>
</div>
<div class="dialog-body">
<el-form label-width="90px">
<el-form-item label="K 时(基准)">
<el-date-picker
:value="dateTime"
@input="$emit('update:dateTime', $event)"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="选择日期和时间"
style="width: 100%"
/>
</el-form-item>
</el-form>
<div class="dialog-footer">
<el-button @click="closeDialog"> </el-button>
<el-button type="primary" @click="handleSave"> </el-button>
</div>
</div>
</div>
</div>
</template>
<script>
const STORAGE_KEY_PREFIX = 'kTimeSetPanel_'
export default {
name: 'KTimeSetDialog',
props: {
value: { type: Boolean, default: false },
dateTime: { type: String, default: null },
roomId: { type: [String, Number], default: null }
},
data() {
return {
panelLeft: null,
panelTop: null,
panelWidth: 420,
panelHeight: 250,
isDragging: false,
dragStartX: 0,
dragStartY: 0
}
},
computed: {
panelStyle() {
const left = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20
const top = this.panelTop != null ? this.panelTop : (window.innerHeight - this.panelHeight) / 2 - 40
return {
left: `${left}px`,
top: `${top}px`,
width: `${this.panelWidth}px`,
height: `${this.panelHeight}px`
}
}
},
watch: {
value(val) {
if (val) this.loadPosition()
}
},
methods: {
closeDialog() {
this.$emit('input', false)
},
handleSave() {
this.$emit('save')
},
getStorageKey() {
return STORAGE_KEY_PREFIX + (this.roomId || 'default')
},
loadPosition() {
try {
const raw = localStorage.getItem(this.getStorageKey())
if (raw) {
const d = JSON.parse(raw)
if (d.panelPosition) {
const left = Number(d.panelPosition.left)
const top = Number(d.panelPosition.top)
if (!isNaN(left) && left >= 0) this.panelLeft = Math.min(left, window.innerWidth - this.panelWidth)
if (!isNaN(top) && top >= 0) this.panelTop = Math.min(top, window.innerHeight - this.panelHeight)
}
}
} catch (e) {
console.warn('加载 K 时弹窗位置失败:', e)
}
},
savePosition() {
try {
const payload = {}
if (this.panelLeft != null && this.panelTop != null) {
payload.panelPosition = { left: this.panelLeft, top: this.panelTop }
}
localStorage.setItem(this.getStorageKey(), JSON.stringify(payload))
} catch (e) {
console.warn('保存 K 时弹窗位置失败:', e)
}
},
onDragStart(e) {
e.preventDefault()
this.isDragging = true
const currentLeft = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20
const currentTop = this.panelTop != null ? this.panelTop : (window.innerHeight - this.panelHeight) / 2 - 40
this.dragStartX = e.clientX - currentLeft
this.dragStartY = e.clientY - currentTop
document.addEventListener('mousemove', this.onDragMove)
document.addEventListener('mouseup', this.onDragEnd)
},
onDragMove(e) {
if (!this.isDragging) return
e.preventDefault()
let left = e.clientX - this.dragStartX
let top = e.clientY - this.dragStartY
left = Math.max(0, Math.min(window.innerWidth - this.panelWidth, left))
top = Math.max(0, Math.min(window.innerHeight - this.panelHeight, top))
this.panelLeft = left
this.panelTop = top
},
onDragEnd() {
this.isDragging = false
document.removeEventListener('mousemove', this.onDragMove)
document.removeEventListener('mouseup', this.onDragEnd)
this.savePosition()
}
}
}
</script>
<style scoped>
/* 透明遮罩、不阻挡地图 */
.k-time-set-dialog {
position: fixed;
inset: 0;
z-index: 1000;
background: transparent;
pointer-events: none;
}
/* 原 el-dialog 风格 */
.panel-container {
position: fixed;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
overflow: hidden;
z-index: 1001;
pointer-events: auto;
display: flex;
flex-direction: column;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background-color: #f5f7fa;
border-bottom: 1px solid #ebeef5;
cursor: move;
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.close-btn {
font-size: 20px;
color: #909399;
cursor: pointer;
transition: color 0.3s;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #606266;
background: #f5f5f5;
border-radius: 50%;
}
.dialog-body {
padding: 20px 25px;
flex: 1;
overflow: hidden;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 10px 0 0 0;
margin-top: 10px;
border-top: 1px solid #ebeef5;
}
</style>

488
ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue

@ -1,13 +1,10 @@
<template>
<!-- 4T 弹窗一致透明遮罩可拖动记录位置不阻挡地图 -->
<div v-if="value" class="online-members-dialog">
<!-- 遮罩层 -->
<div class="dialog-overlay" @click="closeDialog"></div>
<!-- 弹窗内容由全局 dialogDrag 插件支持拖动 -->
<div class="dialog-content">
<div class="dialog-header">
<div class="panel-container" :style="panelStyle">
<div class="dialog-header" @mousedown="onDragStart">
<h3>{{ $t('onlineMembersDialog.title') }}</h3>
<div class="close-btn" @click="closeDialog">×</div>
<div class="close-btn" @mousedown.stop @click="closeDialog">×</div>
</div>
<div class="dialog-body">
@ -119,25 +116,65 @@
<el-tab-pane :label="$t('onlineMembersDialog.chatRoom')" name="chat">
<div class="chat-room">
<div class="chat-header">
<h4>{{ $t('onlineMembersDialog.groupChat') }}</h4>
<el-radio-group v-model="chatMode" size="mini">
<el-radio-button label="group">{{ $t('onlineMembersDialog.groupChat') }}</el-radio-button>
<el-radio-button label="private">{{ $t('onlineMembersDialog.privateChat') }}</el-radio-button>
</el-radio-group>
<span class="online-count">{{ displayOnlineMembers.length }} {{ $t('onlineMembersDialog.onlineCount') }}</span>
</div>
<!-- 私聊选择对象 -->
<div v-if="chatMode === 'private'" class="private-target">
<span class="target-label">{{ $t('onlineMembersDialog.selectMember') }}</span>
<el-select v-model="privateChatTarget" :placeholder="$t('onlineMembersDialog.selectMemberToChat')" size="small" filterable class="target-select">
<el-option
v-for="m in chatableMembers"
:key="m.userId"
:label="m.name"
:value="m.userId"
>
<span>{{ m.name }}</span>
<span class="target-role">({{ m.role }})</span>
</el-option>
</el-select>
</div>
<!-- 聊天内容区域 -->
<div class="chat-content" ref="chatContent">
<div
v-for="(message, index) in chatMessages"
:key="index"
:class="['chat-message', message.sender === '我' ? 'self-message' : 'other-message']"
>
<div class="message-avatar">
<el-avatar :size="32" :src="message.avatar">{{ message.sender.charAt(0) }}</el-avatar>
<template v-if="chatMode === 'group'">
<div
v-for="(message, index) in displayChatMessages"
:key="'g-' + index"
:class="['chat-message', isMessageSelf(message) ? 'self-message' : 'other-message']"
>
<div class="message-avatar">
<el-avatar :size="32" :src="getMessageAvatar(message)">{{ getMessageSenderName(message).charAt(0) }}</el-avatar>
</div>
<div class="message-content">
<div class="message-sender">{{ getMessageSenderName(message) }}</div>
<div class="message-text">{{ message.content }}</div>
<div class="message-time">{{ formatMessageTime(message.timestamp) }}</div>
</div>
</div>
<div class="message-content">
<div class="message-sender">{{ message.sender }}</div>
<div class="message-text">{{ message.content }}</div>
<div class="message-time">{{ message.time }}</div>
</template>
<template v-else-if="chatMode === 'private' && privateChatTarget">
<div
v-for="(message, index) in displayPrivateMessages"
:key="'p-' + index"
:class="['chat-message', isPrivateMessageSelf(message) ? 'self-message' : 'other-message']"
>
<div class="message-avatar">
<el-avatar :size="32" :src="getPrivateMessageAvatar(message)">{{ getPrivateMessageSenderName(message).charAt(0) }}</el-avatar>
</div>
<div class="message-content">
<div class="message-sender">{{ getPrivateMessageSenderName(message) }}</div>
<div class="message-text">{{ message.content }}</div>
<div class="message-time">{{ formatMessageTime(message.timestamp) }}</div>
</div>
</div>
</template>
<div v-else-if="chatMode === 'private' && !privateChatTarget" class="chat-empty">
{{ $t('onlineMembersDialog.selectMemberToChat') }}
</div>
</div>
@ -145,22 +182,26 @@
<div class="chat-input">
<el-input
v-model="newMessage"
:placeholder="$t('onlineMembersDialog.inputMessage')"
:placeholder="chatMode === 'private' && !privateChatTarget ? $t('onlineMembersDialog.selectMemberFirst') : $t('onlineMembersDialog.inputMessage')"
:disabled="chatMode === 'private' && !privateChatTarget"
@keyup.enter="sendMessage"
clearable
></el-input>
<el-button type="primary" @click="sendMessage" class="send-btn">{{ $t('onlineMembersDialog.send') }}</el-button>
<el-button type="primary" @click="sendMessage" class="send-btn" :disabled="chatMode === 'private' && !privateChatTarget">{{ $t('onlineMembersDialog.send') }}</el-button>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 移除了底部的dialog-footer关闭按钮 -->
<!-- 右下角拖拽调整大小 -->
<div class="resize-handle" @mousedown="onResizeStart" title="拖动调整大小"></div>
</div>
</div>
</template>
<script>
const STORAGE_KEY_PREFIX = 'onlineMembersPanel_'
export default {
name: 'OnlineMembersDialog',
props: {
@ -171,11 +212,51 @@ export default {
onlineMembers: {
type: Array,
default: () => []
},
roomId: {
type: [String, Number],
default: null
},
chatMessages: {
type: Array,
default: () => []
},
privateChatMessages: {
type: Object,
default: () => ({})
},
sendChat: {
type: Function,
default: null
},
sendPrivateChat: {
type: Function,
default: null
},
sendPrivateChatHistoryRequest: {
type: Function,
default: null
},
currentUserId: {
type: [Number, String],
default: null
}
},
data() {
return {
activeTab: 'members',
panelLeft: null,
panelTop: null,
panelWidth: 700,
panelHeight: 520,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
isResizing: false,
resizeStartX: 0,
resizeStartY: 0,
resizeStartW: 0,
resizeStartH: 0,
showRollbackDialog: false,
// 线 props.onlineMembers 使 mock
@ -243,39 +324,136 @@ export default {
}
],
//
newMessage: '',
chatMessages: [
{
sender: '张三',
content: '各位注意,J-20参数已更新,速度调整为850km/h',
time: 'K+00:45:30',
avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
},
{
sender: '李四',
content: '收到,Alpha进场航线已选中,等待进一步指令',
time: 'K+00:42:20',
avatar: 'https://cube.elemecdn.com/1/88/03b0d39583f48206768a7534e55bcpng.png'
},
{
sender: '王五',
content: 'Beta巡逻航线已添加WP5航点,坐标确认完毕',
time: 'K+00:38:50',
avatar: 'https://cube.elemecdn.com/2/88/03b0d39583f48206768a7534e55bcpng.png'
}
]
chatMode: 'group',
privateChatTarget: null
};
},
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 list.filter(m => m.userId != null && Number(m.userId) !== cur);
},
displayChatMessages() {
return this.chatMessages || [];
},
displayPrivateMessages() {
if (!this.privateChatTarget) return [];
return this.privateChatMessages[this.privateChatTarget] || [];
},
selectedPrivateMember() {
if (!this.privateChatTarget) return null;
return this.chatableMembers.find(m => Number(m.userId) === Number(this.privateChatTarget));
},
panelStyle() {
const left = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20;
const top = this.panelTop != null ? this.panelTop : (window.innerHeight - this.panelHeight) / 2 - 40;
return {
left: `${left}px`,
top: `${top}px`,
width: `${this.panelWidth}px`,
height: `${this.panelHeight}px`
};
}
},
methods: {
closeDialog() {
this.$emit('input', false);
},
getStorageKey() {
return STORAGE_KEY_PREFIX + (this.roomId || 'default');
},
loadPosition() {
try {
const key = this.getStorageKey();
const raw = localStorage.getItem(key);
if (raw) {
const d = JSON.parse(raw);
if (d.panelPosition) {
const left = Number(d.panelPosition.left);
const top = Number(d.panelPosition.top);
if (!isNaN(left) && left >= 0) this.panelLeft = Math.min(left, window.innerWidth - this.panelWidth);
if (!isNaN(top) && top >= 0) this.panelTop = Math.min(top, window.innerHeight - this.panelHeight);
}
if (d.panelSize) {
const w = Number(d.panelSize.width);
const h = Number(d.panelSize.height);
if (!isNaN(w) && w >= 400 && w <= 1000) this.panelWidth = w;
if (!isNaN(h) && h >= 300 && h <= window.innerHeight - 60) this.panelHeight = h;
}
}
} catch (e) {
console.warn('加载在线成员弹窗位置失败:', e);
}
},
savePosition() {
try {
const payload = { panelSize: { width: this.panelWidth, height: this.panelHeight } };
if (this.panelLeft != null && this.panelTop != null) {
payload.panelPosition = { left: this.panelLeft, top: this.panelTop };
}
localStorage.setItem(this.getStorageKey(), JSON.stringify(payload));
} catch (e) {
console.warn('保存在线成员弹窗位置失败:', e);
}
},
onDragStart(e) {
e.preventDefault();
this.isDragging = true;
const currentLeft = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20;
const currentTop = this.panelTop != null ? this.panelTop : (window.innerHeight - this.panelHeight) / 2 - 40;
this.dragStartX = e.clientX - currentLeft;
this.dragStartY = e.clientY - currentTop;
document.addEventListener('mousemove', this.onDragMove);
document.addEventListener('mouseup', this.onDragEnd);
},
onDragMove(e) {
if (!this.isDragging) return;
e.preventDefault();
let left = e.clientX - this.dragStartX;
let top = e.clientY - this.dragStartY;
left = Math.max(0, Math.min(window.innerWidth - this.panelWidth, left));
top = Math.max(0, Math.min(window.innerHeight - this.panelHeight, top));
this.panelLeft = left;
this.panelTop = top;
},
onDragEnd() {
this.isDragging = false;
document.removeEventListener('mousemove', this.onDragMove);
document.removeEventListener('mouseup', this.onDragEnd);
this.savePosition();
},
onResizeStart(e) {
e.preventDefault();
e.stopPropagation();
this.isResizing = true;
this.resizeStartX = e.clientX;
this.resizeStartY = e.clientY;
this.resizeStartW = this.panelWidth;
this.resizeStartH = this.panelHeight;
document.addEventListener('mousemove', this.onResizeMove);
document.addEventListener('mouseup', this.onResizeEnd);
},
onResizeMove(e) {
if (!this.isResizing) return;
e.preventDefault();
const dx = e.clientX - this.resizeStartX;
const dy = e.clientY - this.resizeStartY;
this.panelWidth = Math.max(400, Math.min(1000, this.resizeStartW + dx));
this.panelHeight = Math.max(300, Math.min(window.innerHeight - 60, this.resizeStartH + dy));
},
onResizeEnd() {
this.isResizing = false;
document.removeEventListener('mousemove', this.onResizeMove);
document.removeEventListener('mouseup', this.onResizeEnd);
this.savePosition();
},
showRollbackConfirm() {
this.showRollbackDialog = true;
@ -289,76 +467,90 @@ export default {
//
sendMessage() {
if (!this.newMessage.trim()) {
const text = this.newMessage.trim();
if (!text) {
this.$message.warning(this.$t('onlineMembersDialog.pleaseInputMessage'));
return;
}
// K+
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
const seconds = now.getSeconds().toString().padStart(2, '0');
const currentTime = `K+${hours}:${minutes}:${seconds}`;
//
this.chatMessages.push({
sender: '我',
content: this.newMessage.trim(),
time: currentTime,
avatar: 'https://cube.elemecdn.com/5/88/03b0d39583f48206768a7534e55bcpng.png'
});
//
if (this.chatMode === 'group') {
if (this.sendChat) this.sendChat(text);
} else {
const target = this.selectedPrivateMember;
if (!target || !this.sendPrivateChat) return;
this.sendPrivateChat(target.userId, target.userName, text);
}
this.newMessage = '';
//
this.$nextTick(() => {
const chatContent = this.$refs.chatContent;
chatContent.scrollTop = chatContent.scrollHeight;
});
//
setTimeout(() => {
const randomMember = this.onlineMembers[Math.floor(Math.random() * this.onlineMembers.length)];
const replies = [
`收到你的消息,${randomMember.role}已确认`,
`已处理:${randomMember.name}正在执行相关操作`,
`明白,${randomMember.role}这边已准备就绪`
];
const randomReply = replies[Math.floor(Math.random() * replies.length)];
const now2 = new Date();
const hours2 = now2.getHours().toString().padStart(2, '0');
const minutes2 = now2.getMinutes().toString().padStart(2, '0');
const seconds2 = now2.getSeconds().toString().padStart(2, '0');
const replyTime = `K+${hours2}:${minutes2}:${seconds2}`;
this.chatMessages.push({
sender: randomMember.name,
content: randomReply,
time: replyTime,
avatar: randomMember.avatar
});
//
this.$nextTick(() => {
const chatContent = this.$refs.chatContent;
chatContent.scrollTop = chatContent.scrollHeight;
});
}, 1000);
this.$nextTick(() => this.scrollChatToBottom());
},
formatMessageTime(ts) {
if (!ts) return '';
const d = new Date(typeof ts === 'number' ? ts : parseInt(ts, 10));
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0') + ':' + d.getSeconds().toString().padStart(2, '0');
},
isMessageSelf(msg) {
return msg.sender && Number(msg.sender.userId) === Number(this.currentUserId);
},
getMessageAvatar(msg) {
const av = (msg.sender && msg.sender.avatar) || '';
return this.resolveAvatarUrl(av);
},
getMessageSenderName(msg) {
if (!msg.sender) return '';
return msg.sender.nickName || msg.sender.userName || '';
},
isPrivateMessageSelf(msg) {
return msg.sender && Number(msg.sender.userId) === Number(this.currentUserId);
},
getPrivateMessageAvatar(msg) {
const av = (msg.sender && msg.sender.avatar) || '';
return this.resolveAvatarUrl(av);
},
getPrivateMessageSenderName(msg) {
if (!msg.sender) return '';
return msg.sender.nickName || msg.sender.userName || '';
},
scrollChatToBottom() {
const el = this.$refs.chatContent;
if (el) el.scrollTop = el.scrollHeight;
},
resolveAvatarUrl(av) {
if (!av) return '';
if (av.startsWith('http')) return av;
const base = process.env.VUE_APP_BACKEND_URL || (window.location.origin + (process.env.VUE_APP_BASE_API || '/dev-api'));
return base + av;
}
},
//
watch: {
value(val) {
if (val) this.loadPosition();
},
activeTab(newVal) {
if (newVal === 'chat') {
this.$nextTick(() => {
const chatContent = this.$refs.chatContent;
if (chatContent) {
chatContent.scrollTop = chatContent.scrollHeight;
}
});
this.$nextTick(() => this.scrollChatToBottom());
}
},
chatMessages: {
handler() {
this.$nextTick(() => this.scrollChatToBottom());
},
deep: true
},
displayPrivateMessages: {
handler() {
this.$nextTick(() => this.scrollChatToBottom());
},
deep: true
},
chatableMembers: {
handler(members) {
if (this.privateChatTarget && !members.some(m => Number(m.userId) === Number(this.privateChatTarget))) {
this.privateChatTarget = null;
}
}
},
privateChatTarget(val) {
if (val && this.sendPrivateChatHistoryRequest) {
this.sendPrivateChatHistoryRequest(val);
}
}
}
@ -366,38 +558,25 @@ export default {
</script>
<style scoped>
/* 与 4T 弹窗一致:透明遮罩、不阻挡地图点击 */
.online-members-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
pointer-events: none;
}
.dialog-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
cursor: pointer;
}
.dialog-content {
position: relative;
.panel-container {
position: fixed;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 700px;
max-height: 90vh;
overflow-y: auto;
overflow: hidden;
z-index: 1001;
pointer-events: auto;
display: flex;
flex-direction: column;
animation: dialog-fade-in 0.3s ease;
}
@ -448,7 +627,25 @@ export default {
.dialog-body {
padding: 20px;
/* 调整内边距 让内容和顶部更协调 */
flex: 1;
overflow-y: auto;
min-height: 0;
}
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
user-select: none;
z-index: 10;
background: linear-gradient(to top left, transparent 50%, rgba(0, 138, 255, 0.2) 50%);
}
.resize-handle:hover {
background: linear-gradient(to top left, transparent 50%, rgba(0, 138, 255, 0.4) 50%);
}
/* 在线成员样式 */
@ -651,6 +848,37 @@ export default {
color: #333;
}
.private-target {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.target-label {
font-size: 13px;
color: #666;
margin-right: 8px;
white-space: nowrap;
}
.target-select {
flex: 1;
min-width: 120px;
}
.target-role {
font-size: 12px;
color: #999;
margin-left: 4px;
}
.chat-empty {
color: #999;
font-size: 14px;
text-align: center;
padding: 40px 0;
}
.online-count {
font-size: 12px;
color: #008aff;

174
ruoyi-ui/src/views/dialogs/PlatformEditDialog.vue

@ -1,10 +1,10 @@
<template>
<!-- 4T 一致透明遮罩可拖动记录位置不阻挡地图 -->
<div v-if="value" class="platform-edit-dialog">
<div class="dialog-overlay" @click="closeDialog"></div>
<div class="dialog-content">
<div class="dialog-header">
<div class="panel-container" :style="panelStyle">
<div class="dialog-header" @mousedown="onDragStart">
<h3>平台配置编辑</h3>
<div class="close-btn" @click="closeDialog">×</div>
<div class="close-btn" @mousedown.stop @click="closeDialog">×</div>
</div>
<div class="dialog-body">
@ -104,6 +104,7 @@
<el-button size="medium" @click="closeDialog" style="width: 120px; border-radius: 20px;"> </el-button>
<el-button size="medium" type="primary" @click="savePlatform" style="width: 120px; border-radius: 20px;"> </el-button>
</div>
<div class="resize-handle" @mousedown="onResizeStart" title="拖动调整大小"></div>
</div>
</div>
</template>
@ -112,14 +113,29 @@
import { getToken } from "@/utils/auth";
import { updateLib } from "@/api/system/lib";
const STORAGE_KEY_PREFIX = 'platformEditPanel_'
export default {
name: 'PlatformEditDialog',
props: {
value: { type: Boolean, default: false },
platform: { type: Object, default: () => ({}) }
platform: { type: Object, default: () => ({}) },
roomId: { type: [String, Number], default: null }
},
data() {
return {
panelLeft: null,
panelTop: null,
panelWidth: 550,
panelHeight: 520,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
isResizing: false,
resizeStartX: 0,
resizeStartY: 0,
resizeStartW: 0,
resizeStartH: 0,
//
uploadImgUrl: process.env.VUE_APP_BASE_API + "/common/upload",
headers: { Authorization: "Bearer " + getToken() },
@ -145,9 +161,22 @@ export default {
}
};
},
computed: {
panelStyle() {
const left = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20;
const top = this.panelTop != null ? this.panelTop : (window.innerHeight - this.panelHeight) / 2 - 40;
return {
left: `${left}px`,
top: `${top}px`,
width: `${this.panelWidth}px`,
height: `${this.panelHeight}px`
};
}
},
watch: {
value(newVal) {
if (newVal) {
this.loadPosition();
console.log("子组件接收到的原始数据:", this.platform);
this.$nextTick(() => {
this.initFormData();
@ -156,6 +185,93 @@ export default {
}
},
methods: {
getStorageKey() {
return STORAGE_KEY_PREFIX + (this.roomId || 'default');
},
loadPosition() {
try {
const raw = localStorage.getItem(this.getStorageKey());
if (raw) {
const d = JSON.parse(raw);
if (d.panelPosition) {
const left = Number(d.panelPosition.left);
const top = Number(d.panelPosition.top);
if (!isNaN(left) && left >= 0) this.panelLeft = Math.min(left, window.innerWidth - this.panelWidth);
if (!isNaN(top) && top >= 0) this.panelTop = Math.min(top, window.innerHeight - this.panelHeight);
}
if (d.panelSize) {
const w = Number(d.panelSize.width);
const h = Number(d.panelSize.height);
if (!isNaN(w) && w >= 400 && w <= 900) this.panelWidth = w;
if (!isNaN(h) && h >= 400 && h <= window.innerHeight - 60) this.panelHeight = h;
}
}
} catch (e) {
console.warn('加载平台编辑弹窗位置失败:', e);
}
},
savePosition() {
try {
const payload = { panelSize: { width: this.panelWidth, height: this.panelHeight } };
if (this.panelLeft != null && this.panelTop != null) {
payload.panelPosition = { left: this.panelLeft, top: this.panelTop };
}
localStorage.setItem(this.getStorageKey(), JSON.stringify(payload));
} catch (e) {
console.warn('保存平台编辑弹窗位置失败:', e);
}
},
onDragStart(e) {
e.preventDefault();
this.isDragging = true;
const currentLeft = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20;
const currentTop = this.panelTop != null ? this.panelTop : (window.innerHeight - this.panelHeight) / 2 - 40;
this.dragStartX = e.clientX - currentLeft;
this.dragStartY = e.clientY - currentTop;
document.addEventListener('mousemove', this.onDragMove);
document.addEventListener('mouseup', this.onDragEnd);
},
onDragMove(e) {
if (!this.isDragging) return;
e.preventDefault();
let left = e.clientX - this.dragStartX;
let top = e.clientY - this.dragStartY;
left = Math.max(0, Math.min(window.innerWidth - this.panelWidth, left));
top = Math.max(0, Math.min(window.innerHeight - this.panelHeight, top));
this.panelLeft = left;
this.panelTop = top;
},
onDragEnd() {
this.isDragging = false;
document.removeEventListener('mousemove', this.onDragMove);
document.removeEventListener('mouseup', this.onDragEnd);
this.savePosition();
},
onResizeStart(e) {
e.preventDefault();
e.stopPropagation();
this.isResizing = true;
this.resizeStartX = e.clientX;
this.resizeStartY = e.clientY;
this.resizeStartW = this.panelWidth;
this.resizeStartH = this.panelHeight;
document.addEventListener('mousemove', this.onResizeMove);
document.addEventListener('mouseup', this.onResizeEnd);
},
onResizeMove(e) {
if (!this.isResizing) return;
e.preventDefault();
const dx = e.clientX - this.resizeStartX;
const dy = e.clientY - this.resizeStartY;
this.panelWidth = Math.max(400, Math.min(900, this.resizeStartW + dx));
this.panelHeight = Math.max(400, Math.min(window.innerHeight - 60, this.resizeStartH + dy));
},
onResizeEnd() {
this.isResizing = false;
document.removeEventListener('mousemove', this.onResizeMove);
document.removeEventListener('mouseup', this.onResizeEnd);
this.savePosition();
},
// JSON
initFormData() {
//
@ -265,39 +381,25 @@ export default {
</script>
<style scoped>
/* 1. 弹窗核心容器:增加圆角,优化阴影 */
/* 与 4T 一致:透明遮罩、不阻挡地图 */
.platform-edit-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
background: transparent;
pointer-events: none;
}
.dialog-content {
position: relative;
.panel-container {
position: fixed;
background: white;
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
width: 550px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 2001;
pointer-events: auto;
animation: dialog-fade-in 0.3s ease;
}
@ -306,13 +408,13 @@ export default {
to { opacity: 1; transform: translateY(0); }
}
/* 页眉页脚:彻底删掉分割线 */
.dialog-header {
padding: 20px 25px 5px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f2f2f2;
cursor: move;
}
.dialog-header h3 {
@ -459,4 +561,20 @@ export default {
padding: 10px 0 25px;
border-top: none;
}
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
user-select: none;
z-index: 10;
background: linear-gradient(to top left, transparent 50%, rgba(24, 144, 255, 0.2) 50%);
}
.resize-handle:hover {
background: linear-gradient(to top left, transparent 50%, rgba(24, 144, 255, 0.4) 50%);
}
</style>

243
ruoyi-ui/src/views/dialogs/RouteEditDialog.vue

@ -1,12 +1,12 @@
<template>
<el-dialog
title="编辑航线"
:visible.sync="visible"
:width="activeTab === 'waypoints' ? '920px' : '560px'"
:close-on-click-modal="false"
append-to-body
custom-class="blue-dialog route-edit-dialog"
>
<!-- 4T 一致透明遮罩可拖动记录位置不阻挡地图 -->
<div v-if="visible" class="route-edit-dialog-wrap">
<div class="panel-container" :style="panelStyle">
<div class="dialog-header" @mousedown="onDragStart">
<h3>编辑航线</h3>
<div class="close-btn" @mousedown.stop @click="visible = false">×</div>
</div>
<div class="dialog-body-inner">
<div class="route-edit-tab-bar">
<button type="button" class="tab-bar-item" :class="{ active: activeTab === 'basic' }" @click="activeTab = 'basic'">基础</button>
<button type="button" class="tab-bar-item" :class="{ active: activeTab === 'platform' }" @click="activeTab = 'platform'">平台</button>
@ -277,24 +277,42 @@
</div>
</div>
<span slot="footer" class="dialog-footer">
<div class="dialog-footer">
<el-button size="mini" @click="visible = false"> </el-button>
<el-button type="primary" size="mini" class="blue-btn" @click="handleSave"> </el-button>
</span>
</el-dialog>
</div>
</div>
<div class="resize-handle" @mousedown="onResizeStart" title="拖动调整大小"></div>
</div>
</div>
</template>
<script>
import { listLib } from '@/api/system/lib'
const STORAGE_KEY_PREFIX = 'routeEditPanel_'
export default {
name: 'RouteEditDialog',
props: {
value: { type: Boolean, default: false },
route: { type: Object, default: () => ({}) }
route: { type: Object, default: () => ({}) },
roomId: { type: [String, Number], default: null }
},
data() {
return {
panelLeft: null,
panelTop: null,
panelWidth: 560,
panelHeight: 540,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
isResizing: false,
resizeStartX: 0,
resizeStartY: 0,
resizeStartW: 0,
resizeStartH: 0,
activeTab: 'basic',
platformCategory: 'Air',
platformLoading: false,
@ -328,6 +346,18 @@ export default {
visible: {
get() { return this.value },
set(val) { this.$emit('input', val) }
},
panelStyle() {
const w = this.panelWidth
const h = this.panelHeight
const left = this.panelLeft != null ? this.panelLeft : (window.innerWidth - w) / 2 - 20
const top = this.panelTop != null ? this.panelTop : (window.innerHeight - h) / 2 - 40
return {
left: `${left}px`,
top: `${top}px`,
width: `${w}px`,
height: `${h}px`
}
}
},
watch: {
@ -349,17 +379,106 @@ export default {
deep: true
},
visible(val) {
if (val && this.activeTab === 'platform') {
this.loadPlatforms()
if (val) {
this.loadPosition()
if (this.activeTab === 'platform') this.loadPlatforms()
}
},
activeTab(val) {
if (val === 'platform' && this.visible) {
this.loadPlatforms()
}
if (val === 'platform' && this.visible) this.loadPlatforms()
if (val === 'waypoints' && this.panelWidth < 920) this.panelWidth = 920
}
},
methods: {
getStorageKey() {
return STORAGE_KEY_PREFIX + (this.roomId || 'default')
},
loadPosition() {
try {
const raw = localStorage.getItem(this.getStorageKey())
if (raw) {
const d = JSON.parse(raw)
if (d.panelPosition) {
const left = Number(d.panelPosition.left)
const top = Number(d.panelPosition.top)
if (!isNaN(left) && left >= 0) this.panelLeft = Math.min(left, window.innerWidth - this.panelWidth)
if (!isNaN(top) && top >= 0) this.panelTop = Math.min(top, window.innerHeight - this.panelHeight)
}
if (d.panelSize) {
const w = Number(d.panelSize.width)
const h = Number(d.panelSize.height)
if (!isNaN(w) && w >= 400 && w <= 1200) this.panelWidth = w
if (!isNaN(h) && h >= 400 && h <= window.innerHeight - 60) this.panelHeight = h
}
}
} catch (e) {
console.warn('加载航线编辑弹窗位置失败:', e)
}
},
savePosition() {
try {
const payload = { panelSize: { width: this.panelWidth, height: this.panelHeight } }
if (this.panelLeft != null && this.panelTop != null) {
payload.panelPosition = { left: this.panelLeft, top: this.panelTop }
}
localStorage.setItem(this.getStorageKey(), JSON.stringify(payload))
} catch (e) {
console.warn('保存航线编辑弹窗位置失败:', e)
}
},
onDragStart(e) {
e.preventDefault()
this.isDragging = true
const w = this.panelWidth
const h = this.panelHeight
const currentLeft = this.panelLeft != null ? this.panelLeft : (window.innerWidth - w) / 2 - 20
const currentTop = this.panelTop != null ? this.panelTop : (window.innerHeight - h) / 2 - 40
this.dragStartX = e.clientX - currentLeft
this.dragStartY = e.clientY - currentTop
document.addEventListener('mousemove', this.onDragMove)
document.addEventListener('mouseup', this.onDragEnd)
},
onDragMove(e) {
if (!this.isDragging) return
e.preventDefault()
let left = e.clientX - this.dragStartX
let top = e.clientY - this.dragStartY
left = Math.max(0, Math.min(window.innerWidth - this.panelWidth, left))
top = Math.max(0, Math.min(window.innerHeight - this.panelHeight, top))
this.panelLeft = left
this.panelTop = top
},
onDragEnd() {
this.isDragging = false
document.removeEventListener('mousemove', this.onDragMove)
document.removeEventListener('mouseup', this.onDragEnd)
this.savePosition()
},
onResizeStart(e) {
e.preventDefault()
e.stopPropagation()
this.isResizing = true
this.resizeStartX = e.clientX
this.resizeStartY = e.clientY
this.resizeStartW = this.panelWidth
this.resizeStartH = this.panelHeight
document.addEventListener('mousemove', this.onResizeMove)
document.addEventListener('mouseup', this.onResizeEnd)
},
onResizeMove(e) {
if (!this.isResizing) return
e.preventDefault()
const dx = e.clientX - this.resizeStartX
const dy = e.clientY - this.resizeStartY
this.panelWidth = Math.max(560, Math.min(1200, this.resizeStartW + dx))
this.panelHeight = Math.max(400, Math.min(window.innerHeight - 60, this.resizeStartH + dy))
},
onResizeEnd() {
this.isResizing = false
document.removeEventListener('mousemove', this.onResizeMove)
document.removeEventListener('mouseup', this.onResizeEnd)
this.savePosition()
},
loadPlatforms() {
this.platformLoading = true
listLib().then(res => {
@ -532,24 +651,80 @@ export default {
</script>
<style>
/* 航线编辑标题:加粗加黑 */
.route-edit-dialog .el-dialog__title { font-weight: 700; color: #303133; }
.route-edit-dialog .el-dialog__header { padding: 14px 20px 12px; }
/* 弹窗固定于视口正中间,不随页面滚动 */
.route-edit-dialog.el-dialog {
position: fixed !important;
top: 50% !important;
left: 50% !important;
margin: 0 !important;
transform: translate(-50%, -50%);
}
/* 弹窗内容区固定高度,切换基础/平台时大小不变 */
.route-edit-dialog .el-dialog__body {
padding-top: 12px;
height: 420px;
min-height: 420px;
/* 与 4T 一致:透明遮罩、不阻挡地图 */
.route-edit-dialog-wrap {
position: fixed;
inset: 0;
z-index: 2000;
background: transparent;
pointer-events: none;
}
.route-edit-dialog-wrap .panel-container {
position: fixed;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
overflow: hidden;
box-sizing: border-box;
z-index: 2001;
pointer-events: auto;
display: flex;
flex-direction: column;
}
.route-edit-dialog-wrap .dialog-header {
padding: 14px 20px 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #ebeef5;
cursor: move;
}
.route-edit-dialog-wrap .dialog-header h3 {
margin: 0;
font-weight: 700;
color: #303133;
font-size: 16px;
}
.route-edit-dialog-wrap .close-btn {
font-size: 20px;
color: #909399;
cursor: pointer;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.route-edit-dialog-wrap .close-btn:hover {
color: #606266;
background: #f5f5f5;
border-radius: 50%;
}
.route-edit-dialog-wrap .dialog-body-inner {
padding: 12px 20px 16px;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.route-edit-dialog-wrap .dialog-footer {
padding: 10px 0 0;
margin-top: 8px;
border-top: 1px solid #ebeef5;
}
.route-edit-dialog-wrap .resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
user-select: none;
z-index: 10;
background: linear-gradient(to top left, transparent 50%, rgba(22, 93, 255, 0.2) 50%);
}
.route-edit-dialog-wrap .resize-handle:hover {
background: linear-gradient(to top left, transparent 50%, rgba(22, 93, 255, 0.4) 50%);
}
</style>
<style scoped>

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