Compare commits

...

8 Commits

  1. 33
      ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
  2. 61
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomRoomStateService.java
  3. 2
      ruoyi-ui/.env.development
  4. 4
      ruoyi-ui/src/utils/websocket.js
  5. 30
      ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
  6. 1317
      ruoyi-ui/src/views/cesiumMap/index.vue
  7. 10
      ruoyi-ui/src/views/childRoom/RightPanel.vue
  8. 316
      ruoyi-ui/src/views/childRoom/index.vue
  9. 2
      ruoyi-ui/vue.config.js

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

@ -1,8 +1,10 @@
package com.ruoyi.websocket.controller;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.DestinationVariable;
@ -19,6 +21,7 @@ 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.RoomRoomStateService;
import com.ruoyi.websocket.service.RoomWebSocketService;
/**
@ -41,6 +44,9 @@ public class RoomWebSocketController {
@Autowired
private IRoomsService roomsService;
@Autowired
private RoomRoomStateService roomRoomStateService;
private static final String TYPE_JOIN = "JOIN";
private static final String TYPE_LEAVE = "LEAVE";
private static final String TYPE_PING = "PING";
@ -58,6 +64,8 @@ public class RoomWebSocketController {
private static final String TYPE_SYNC_PLATFORM_ICONS = "SYNC_PLATFORM_ICONS";
private static final String TYPE_SYNC_ROOM_DRAWINGS = "SYNC_ROOM_DRAWINGS";
private static final String TYPE_SYNC_PLATFORM_STYLES = "SYNC_PLATFORM_STYLES";
/** 新用户加入时下发的房间状态(可见航线等) */
private static final String TYPE_ROOM_STATE = "ROOM_STATE";
/** 对象编辑锁定:某成员进入编辑,其他人看到锁定 */
private static final String TYPE_OBJECT_EDIT_LOCK = "OBJECT_EDIT_LOCK";
/** 对象编辑解锁 */
@ -179,6 +187,14 @@ public class RoomWebSocketController {
chatHistoryMsg.put("type", TYPE_CHAT_HISTORY);
chatHistoryMsg.put("messages", chatHistory);
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", chatHistoryMsg);
Set<Long> visibleRouteIds = roomRoomStateService.getVisibleRouteIds(roomId);
if (visibleRouteIds != null && !visibleRouteIds.isEmpty()) {
Map<String, Object> roomStateMsg = new HashMap<>();
roomStateMsg.put("type", TYPE_ROOM_STATE);
roomStateMsg.put("visibleRouteIds", new ArrayList<>(visibleRouteIds));
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", roomStateMsg);
}
}
private void handleLeave(Long roomId, String sessionId, LoginUser loginUser) {
@ -280,13 +296,26 @@ public class RoomWebSocketController {
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", resp);
}
/** 广播航线显隐变更,供其他设备实时同步 */
/** 广播航线显隐变更,供其他设备实时同步;并持久化到 Redis 供新加入用户同步 */
private void handleSyncRouteVisibility(Long roomId, Map<String, Object> body, String sessionId) {
if (body == null || !body.containsKey("routeId")) return;
Object routeIdObj = body.get("routeId");
boolean visible = body.get("visible") != null && Boolean.TRUE.equals(body.get("visible"));
Long routeId = null;
if (routeIdObj instanceof Number) {
routeId = ((Number) routeIdObj).longValue();
} else if (routeIdObj != null) {
try {
routeId = Long.parseLong(routeIdObj.toString());
} catch (NumberFormatException ignored) {}
}
if (routeId != null) {
roomRoomStateService.updateRouteVisibility(roomId, routeId, visible);
}
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("visible", visible);
msg.put("senderSessionId", sessionId);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}

61
ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomRoomStateService.java

@ -0,0 +1,61 @@
package com.ruoyi.websocket.service;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.TimeUnit;
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;
/**
* 房间状态服务持久化房间内可见航线等状态供新加入用户同步
*/
@Service
public class RoomRoomStateService {
private static final String ROOM_VISIBLE_ROUTES_PREFIX = "room:";
private static final String ROOM_VISIBLE_ROUTES_SUFFIX = ":visibleRoutes";
private static final int EXPIRE_HOURS = 24;
@Autowired
@Qualifier("stringObjectRedisTemplate")
private RedisTemplate<String, Object> redisTemplate;
private String visibleRoutesKey(Long roomId) {
return ROOM_VISIBLE_ROUTES_PREFIX + roomId + ROOM_VISIBLE_ROUTES_SUFFIX;
}
/** 更新航线可见状态 */
public void updateRouteVisibility(Long roomId, Long routeId, boolean visible) {
if (roomId == null || routeId == null) return;
String key = visibleRoutesKey(roomId);
if (visible) {
redisTemplate.opsForSet().add(key, routeId);
} else {
redisTemplate.opsForSet().remove(key, routeId);
}
redisTemplate.expire(key, EXPIRE_HOURS, TimeUnit.HOURS);
}
/** 获取房间内当前可见的航线 ID 集合 */
@SuppressWarnings("unchecked")
public Set<Long> getVisibleRouteIds(Long roomId) {
if (roomId == null) return new HashSet<>();
String key = visibleRoutesKey(roomId);
Set<Object> raw = redisTemplate.opsForSet().members(key);
Set<Long> result = new HashSet<>();
if (raw != null) {
for (Object o : raw) {
if (o instanceof Number) {
result.add(((Number) o).longValue());
} else if (o != null) {
try {
result.add(Long.parseLong(o.toString()));
} catch (NumberFormatException ignored) {}
}
}
}
return result;
}
}

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://127.0.0.1:8080'
VUE_APP_BACKEND_URL = 'http://192.168.1.107:8080'
# 路由懒加载
VUE_CLI_BABEL_TRANSPILE_MODULES = true

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

@ -23,6 +23,7 @@ const WS_BASE = process.env.VUE_APP_BASE_API || '/dev-api'
* @param {Function} options.onSyncPlatformIcons - 平台图标变更同步回调 (senderUserId) => {}
* @param {Function} options.onSyncRoomDrawings - 空域图形变更同步回调 (senderUserId) => {}
* @param {Function} options.onSyncPlatformStyles - 探测区/威力区样式变更同步回调 (senderUserId) => {}
* @param {Function} options.onRoomState - 新加入时收到的房间状态 (visibleRouteIds: number[]) => {}
* @param {Function} options.onObjectEditLock - 对象被某成员编辑锁定 (msg: { objectType, objectId, editor }) => {}
* @param {Function} options.onObjectEditUnlock - 对象编辑解锁 (msg: { objectType, objectId, sessionId }) => {}
* @param {Function} options.onConnected - 连接成功回调
@ -45,6 +46,7 @@ export function createRoomWebSocket(options) {
onSyncPlatformIcons,
onSyncRoomDrawings,
onSyncPlatformStyles,
onRoomState,
onObjectEditLock,
onObjectEditUnlock,
onConnected,
@ -241,6 +243,8 @@ export function createRoomWebSocket(options) {
onChatHistory && onChatHistory(body.messages)
} else if (type === 'PRIVATE_CHAT_HISTORY' && body.targetUserId != null && Array.isArray(body.messages)) {
onPrivateChatHistory && onPrivateChatHistory(body.targetUserId, body.messages)
} else if (type === 'ROOM_STATE' && Array.isArray(body.visibleRouteIds)) {
onRoomState && onRoomState(body.visibleRouteIds)
}
} catch (e) {
console.warn('[WebSocket] parse private message error:', e)

30
ruoyi-ui/src/views/cesiumMap/ContextMenu.vue

@ -172,6 +172,25 @@
</div>
</div>
<!-- 空域图形调整位置 -->
<div class="menu-section" v-if="entityData.type === 'polygon' || entityData.type === 'rectangle' || entityData.type === 'circle' || entityData.type === 'sector' || entityData.type === 'arrow'">
<div class="menu-title">位置</div>
<div class="menu-item" @click="handleAdjustPosition">
<span class="menu-icon">📍</span>
<span>调整位置</span>
</div>
</div>
<!-- 空域图形命名多边形矩形圆形扇形 -->
<div class="menu-section" v-if="entityData.type === 'polygon' || entityData.type === 'rectangle' || entityData.type === 'circle' || entityData.type === 'sector'">
<div class="menu-title">命名</div>
<div class="menu-item" @click="handleEditAirspaceName">
<span class="menu-icon">📝</span>
<span>命名</span>
<span class="menu-value">{{ entityData.name || '(未命名)' }}</span>
</div>
</div>
<!-- 多边形特有选项 -->
<div class="menu-section" v-if="entityData.type === 'polygon' || entityData.type === 'rectangle' || entityData.type === 'circle' || entityData.type === 'sector' || entityData.type === 'powerZone'">
<div class="menu-title">填充属性</div>
@ -442,6 +461,17 @@ export default {
this.$emit('delete')
},
handleAdjustPosition() {
this.$emit('adjust-airspace-position')
},
handleEditAirspaceName() {
const name = prompt('请输入图形名称:', this.entityData.name || '')
if (name !== null) {
this.$emit('update-property', 'name', name.trim())
}
},
handleDetectionZonePlatform() {
this.$emit('detection-zone')
},

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

File diff suppressed because it is too large

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

@ -412,17 +412,9 @@ export default {
})
this.$emit('select-plan', { id: null })
} else {
// 线广
this.activeRouteIds.forEach(activeId => {
const activeRoute = this.routes.find(r => r.id === activeId);
if (activeRoute) {
this.$emit('toggle-route-visibility', activeRoute, { fromPlanSwitch: true });
}
});
//
// 线线
this.expandedPlans = [];
this.expandedRoutes = [];
// --- ---
this.expandedPlans.push(planId)
const plan = this.plans.find(p => p.id === planId)
if (plan) {

316
ruoyi-ui/src/views/childRoom/index.vue

@ -623,6 +623,8 @@ export default {
plans: [],
activeRightTab: 'plan',
activeRouteIds: [], // 线ID
/** 新加入时收到的房间可见航线 ID,若 routes 未加载则暂存,getList 完成后应用 */
roomStatePendingVisibleRouteIds: [],
/** 航线上锁状态:routeId -> true 上锁,与地图右键及右侧列表锁图标同步 */
routeLocked: {},
// runConflictCheck 线
@ -1417,6 +1419,9 @@ export default {
if (this.isMySyncSession(senderSessionId)) return;
this.applySyncPlatformStyles();
},
onRoomState: (visibleRouteIds) => {
this.applyRoomStateVisibleRoutes(visibleRouteIds);
},
onConnected: () => {},
onDisconnected: () => {
this.onlineCount = 0;
@ -1436,12 +1441,31 @@ export default {
this.wsConnection.disconnect();
this.wsConnection = null;
}
this.roomStatePendingVisibleRouteIds = [];
this.wsOnlineMembers = [];
this.mySyncSessionIds = [];
this.onlineCount = 0;
this.chatMessages = [];
this.privateChatMessages = {};
},
/** 应用房间状态中的可见航线(新加入用户同步;若 routes 未加载则暂存) */
async applyRoomStateVisibleRoutes(visibleRouteIds) {
if (!visibleRouteIds || !Array.isArray(visibleRouteIds) || visibleRouteIds.length === 0) return;
if (!this.routes || this.routes.length === 0) {
this.roomStatePendingVisibleRouteIds = visibleRouteIds;
return;
}
for (const routeId of visibleRouteIds) {
await this.applySyncRouteVisibility(routeId, true);
}
},
/** 应用暂存的房间可见航线(getList 完成后调用) */
async applyRoomStatePending() {
if (this.roomStatePendingVisibleRouteIds.length === 0) return;
const pending = [...this.roomStatePendingVisibleRouteIds];
this.roomStatePendingVisibleRouteIds = [];
await this.applyRoomStateVisibleRoutes(pending);
},
/** 判断是否为当前连接发出的同步消息(避免自己发的消息再应用一次) */
isMySyncSession(senderSessionId) {
if (!senderSessionId) return false;
@ -1480,6 +1504,7 @@ export default {
} catch (_) {}
}
this.$refs.cesiumMap.renderRouteWaypoints(waypoints, route.id, route.platformId, route.platform, this.parseRouteStyle(route.attributes));
this.$nextTick(() => this.updateDeductionPositions());
}
} catch (_) {}
} else {
@ -1920,6 +1945,7 @@ export default {
}).catch(() => {});
}
}
this.$nextTick(() => this.applyRoomStatePending());
}
} catch (error) {
console.error("数据加载失败:", error);
@ -2117,7 +2143,9 @@ export default {
if (updatedWaypoint.labelColor != null) payload.labelColor = updatedWaypoint.labelColor;
const response = await updateWaypoints(payload);
if (response.code === 200) {
const index = this.selectedRouteDetails.waypoints.findIndex(p => p.id === updatedWaypoint.id);
const roomId = this.currentRoomId;
const sd = this.selectedRouteDetails;
const index = sd.waypoints.findIndex(p => p.id === updatedWaypoint.id);
if (index !== -1) {
// payload startTime
this.selectedRouteDetails.waypoints.splice(index, 1, { ...updatedWaypoint, ...payload });
@ -2128,8 +2156,6 @@ export default {
if (idxInList !== -1) routeInList.waypoints.splice(idxInList, 1, merged);
}
if (this.$refs.cesiumMap) {
const roomId = this.currentRoomId;
const sd = this.selectedRouteDetails;
if (roomId && sd.platformId) {
try {
const styleRes = await getPlatformStyle({ roomId, routeId: sd.id, platformId: sd.platformId });
@ -3189,8 +3215,9 @@ export default {
/**
* 按速度与计划时间构建航线时间轴含飞行段盘旋段与提前到达则等待的等待段
* pathData 可选{ path, segmentEndIndices, holdArcRanges } getRoutePathWithSegmentIndices 提供用于输出 hold
* holdRadiusByLegIndex 可选{ [legIndex]: number }为盘旋段指定半径用于推演时落点精准在切点
*/
buildRouteTimeline(waypoints, globalMin, globalMax, pathData) {
buildRouteTimeline(waypoints, globalMin, globalMax, pathData, holdRadiusByLegIndex) {
const warnings = [];
if (!waypoints || waypoints.length === 0) return { segments: [], warnings };
const points = waypoints.map((wp, idx) => ({
@ -3224,6 +3251,7 @@ export default {
const effectiveTime = [points[0].minutes];
const segments = [];
const lateArrivalLegs = []; //
const holdDelayConflicts = []; //
const path = pathData && pathData.path;
const segmentEndIndices = pathData && pathData.segmentEndIndices;
const holdArcRanges = pathData && pathData.holdArcRanges || {};
@ -3239,20 +3267,53 @@ export default {
const speedKmh = points[i].speed || 800;
const travelToEntryMin = (distToEntry / 1000) * (60 / speedKmh);
const arrivalEntry = effectiveTime[i] + travelToEntryMin;
const holdEndTime = points[i + 1].minutes;
const holdEndTime = points[i + 1].minutes; // K+10
const exitPos = holdPathSlice.length ? holdPathSlice[holdPathSlice.length - 1] : (toEntrySlice.length ? toEntrySlice[toEntrySlice.length - 1] : { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt });
let loopEndIdx = 1;
for (let k = 1; k < Math.min(holdPathSlice.length, 120); k++) {
if (this.segmentDistance(holdPathSlice[0], holdPathSlice[k]) < 80) { loopEndIdx = k; break; }
}
const holdClosedLoopPath = holdPathSlice.slice(0, loopEndIdx + 1);
const holdLoopLength = this.pathSliceDistance(holdClosedLoopPath) || 1;
let exitIdxOnLoop = 0;
let minD = 1e9;
for (let k = 0; k <= loopEndIdx; k++) {
const d = this.segmentDistance(holdPathSlice[k], exitPos);
if (d < minD) { minD = d; exitIdxOnLoop = k; }
}
const holdExitDistanceOnLoop = this.pathSliceDistance(holdPathSlice.slice(0, exitIdxOnLoop + 1));
const speedMpMin = (speedKmh * 1000) / 60;
const requiredDistAtK10 = (holdEndTime - arrivalEntry) * speedMpMin;
let n = Math.ceil((requiredDistAtK10 - holdExitDistanceOnLoop) / holdLoopLength);
if (n < 0 || !Number.isFinite(n)) n = 0;
const segmentEndTime = arrivalEntry + (holdExitDistanceOnLoop + n * holdLoopLength) / speedMpMin;
if (segmentEndTime > holdEndTime) {
const delaySec = Math.round((segmentEndTime - holdEndTime) * 60);
const holdWp = waypoints[i + 1];
warnings.push(`盘旋「${holdWp.name || 'WP' + (i + 2)}」:到设定时间时未在切出点,继续盘旋至切出点,实际切出将延迟 ${delaySec} 秒。`);
holdDelayConflicts.push({
legIndex: i,
holdCenter: holdWp ? { lng: parseFloat(holdWp.lng), lat: parseFloat(holdWp.lat), alt: Number(holdWp.alt) || 0 } : null,
setExitTime: holdEndTime,
actualExitTime: segmentEndTime,
delayMinutes: segmentEndTime - holdEndTime,
delaySeconds: delaySec,
fromName: waypoints[i].name,
toName: (waypoints[i + 1] && waypoints[i + 1].name) ? waypoints[i + 1].name : `盘旋${i + 2}`
});
}
const distExitToNext = this.pathSliceDistance(toNextSlice);
const travelExitMin = (distExitToNext / 1000) * (60 / speedKmh);
const arrivalNext = holdEndTime + travelExitMin;
const arrivalNext = segmentEndTime + travelExitMin;
effectiveTime[i + 1] = holdEndTime;
if (i + 2 < points.length) effectiveTime[i + 2] = arrivalNext;
const posCur = { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt };
const entryPos = toEntrySlice.length ? toEntrySlice[toEntrySlice.length - 1] : posCur;
const exitPos = holdPathSlice.length ? holdPathSlice[holdPathSlice.length - 1] : entryPos;
const holdDurationMin = holdEndTime - arrivalEntry;
const holdWp = waypoints[i + 1];
const holdParams = this.parseHoldParams(holdWp);
const holdCenter = holdWp ? { lng: parseFloat(holdWp.lng), lat: parseFloat(holdWp.lat), alt: Number(holdWp.alt) || 0 } : null;
const holdRadius = holdParams && holdParams.radius != null ? holdParams.radius : null;
const overrideR = holdRadiusByLegIndex && holdRadiusByLegIndex[i] != null ? holdRadiusByLegIndex[i] : null;
const holdRadius = (overrideR != null && Number.isFinite(overrideR)) ? overrideR : (holdParams && holdParams.radius != null ? holdParams.radius : null);
const holdClockwise = holdParams && holdParams.clockwise !== false;
const holdCircumference = holdRadius != null ? 2 * Math.PI * holdRadius : null;
const holdEntryAngle = holdCenter && entryPos && holdRadius != null
@ -3261,13 +3322,15 @@ export default {
segments.push({ startTime: effectiveTime[i], endTime: arrivalEntry, startPos: posCur, endPos: entryPos, type: 'fly', legIndex: i, pathSlice: toEntrySlice });
segments.push({
startTime: arrivalEntry,
endTime: holdEndTime,
endTime: segmentEndTime,
startPos: entryPos,
endPos: exitPos,
type: 'hold',
legIndex: i,
holdPath: holdPathSlice,
holdDurationMin,
holdClosedLoopPath,
holdLoopLength,
holdExitDistanceOnLoop,
speedKmh: points[i].speed || 800,
holdCenter,
holdRadius,
@ -3275,7 +3338,7 @@ export default {
holdClockwise,
holdEntryAngle
});
segments.push({ startTime: holdEndTime, endTime: arrivalNext, startPos: exitPos, endPos: toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos, type: 'fly', legIndex: i, pathSlice: toNextSlice });
segments.push({ startTime: segmentEndTime, endTime: arrivalNext, startPos: exitPos, endPos: toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos, type: 'fly', legIndex: i, pathSlice: toNextSlice });
i++;
continue;
}
@ -3321,7 +3384,7 @@ export default {
earlyArrivalLegs.push({ legIndex: i, scheduled, actualArrival, fromName: waypoints[i].name, toName: waypoints[i + 1].name });
}
}
return { segments, warnings, earlyArrivalLegs, lateArrivalLegs };
return { segments, warnings, earlyArrivalLegs, lateArrivalLegs, holdDelayConflicts };
},
/** 从路径片段中按比例 t(0~1)取点:按弧长比例插值 */
@ -3373,24 +3436,14 @@ export default {
return s.startPos;
}
if (s.type === 'hold' && s.holdPath && s.holdPath.length) {
const durationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime);
const speedKmh = s.speedKmh != null ? s.speedKmh : 800;
const totalHoldDistM = (speedKmh * (durationMin / 60)) * 1000;
if (s.holdCircumference != null && s.holdCircumference > 0 && s.holdCenter && s.holdRadius != null) {
const currentDistM = t * totalHoldDistM;
const distOnLap = currentDistM % s.holdCircumference;
const angleRad = (distOnLap / s.holdCircumference) * (2 * Math.PI);
const signedAngle = s.holdClockwise ? -angleRad : angleRad;
const entryAngle = s.holdEntryAngle != null ? s.holdEntryAngle : 0;
const angle = entryAngle + signedAngle;
return this.positionOnCircle(s.holdCenter.lng, s.holdCenter.lat, s.holdCenter.alt, s.holdRadius, angle);
// K
if (s.holdClosedLoopPath && s.holdClosedLoopPath.length >= 2 && s.holdLoopLength > 0 && s.speedKmh != null) {
const distM = (minutesFromK - s.startTime) * (s.speedKmh * 1000 / 60);
const distOnLoop = ((distM % s.holdLoopLength) + s.holdLoopLength) % s.holdLoopLength;
const tPath = distOnLoop / s.holdLoopLength;
return this.getPositionAlongPathSlice(s.holdClosedLoopPath, tPath);
}
const holdPathLen = this.pathSliceDistance(s.holdPath);
if (holdPathLen <= 0) return this.getPositionAlongPathSlice(s.holdPath, t);
const currentDistM = t * totalHoldDistM;
const positionOnLap = currentDistM % holdPathLen;
const tLap = holdPathLen > 0 ? positionOnLap / holdPathLen : 0;
return this.getPositionAlongPathSlice(s.holdPath, tLap);
return this.getPositionAlongPathSlice(s.holdPath, t);
}
if (s.type === 'fly' && s.pathSlice && s.pathSlice.length) {
return this.getPositionAlongPathSlice(s.pathSlice, t);
@ -3411,17 +3464,125 @@ export default {
return last.endPos;
},
/** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧/盘旋弧路径运动;返回 { position, nextPosition, previousPosition, warnings, earlyArrivalLegs, currentSegment },currentSegment 含 speedKmh 用于标牌 */
getPositionAtMinutesFromK(waypoints, minutesFromK, globalMin, globalMax) {
/** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧/盘旋弧路径运动;盘旋半径由系统根据 k+10 落点反算,使平滑落在切点。routeId 可选,传入时会把计算半径同步给地图以实时渲染盘旋轨迹与切点进入。返回 { position, nextPosition, previousPosition, warnings, earlyArrivalLegs, currentSegment } */
getPositionAtMinutesFromK(waypoints, minutesFromK, globalMin, globalMax, routeId) {
if (!waypoints || waypoints.length === 0) return { position: null, nextPosition: null, previousPosition: null, warnings: [], earlyArrivalLegs: [], currentSegment: null };
const cesiumMap = this.$refs.cesiumMap;
let pathData = null;
if (this.$refs.cesiumMap && this.$refs.cesiumMap.getRoutePathWithSegmentIndices) {
const ret = this.$refs.cesiumMap.getRoutePathWithSegmentIndices(waypoints);
if (cesiumMap && cesiumMap.getRoutePathWithSegmentIndices) {
const ret = cesiumMap.getRoutePathWithSegmentIndices(waypoints);
if (ret.path && ret.path.length > 0 && ret.segmentEndIndices && ret.segmentEndIndices.length > 0) {
pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} };
}
}
const { segments, warnings, earlyArrivalLegs } = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData);
let { segments, warnings, earlyArrivalLegs } = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData);
const holdRadiusByLegIndex = {};
const holdEllipseParamsByLegIndex = {};
if (cesiumMap && segments && pathData) {
for (let idx = 0; idx < segments.length; idx++) {
const s = segments[idx];
if (s.type !== 'hold' || s.holdCenter == null) continue;
const i = s.legIndex;
const holdDurationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime);
const speedKmh = s.speedKmh != null ? s.speedKmh : 800;
const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000;
const prevWp = waypoints[i];
const holdWp = waypoints[i + 1];
const nextWp = (i + 2) < waypoints.length ? waypoints[i + 2] : holdWp;
if (!prevWp || !holdWp) continue;
const centerCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(holdWp.lng), parseFloat(holdWp.lat), Number(holdWp.alt) || 0);
const prevCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(prevWp.lng), parseFloat(prevWp.lat), Number(prevWp.alt) || 0);
const nextCartesian = nextWp ? Cesium.Cartesian3.fromDegrees(parseFloat(nextWp.lng), parseFloat(nextWp.lat), Number(nextWp.alt) || 0) : centerCartesian;
const clockwise = s.holdClockwise !== false;
const isEllipse = (waypoints[i + 1] && (waypoints[i + 1].pointType || waypoints[i + 1].point_type) === 'hold_ellipse') || s.holdRadius == null;
if (isEllipse && cesiumMap.computeEllipseParamsForDuration) {
const holdParams = this.parseHoldParams(holdWp);
const headingDeg = holdParams && holdParams.headingDeg != null ? holdParams.headingDeg : 0;
const a0 = holdParams && (holdParams.semiMajor != null || holdParams.semiMajorAxis != null) ? (holdParams.semiMajor ?? holdParams.semiMajorAxis) : 500;
const b0 = holdParams && (holdParams.semiMinor != null || holdParams.semiMinorAxis != null) ? (holdParams.semiMinor ?? holdParams.semiMinorAxis) : 300;
const out = cesiumMap.computeEllipseParamsForDuration(centerCartesian, prevCartesian, nextCartesian, clockwise, totalHoldDistM, headingDeg, a0, b0);
if (out && out.semiMajor != null && out.semiMinor != null) holdEllipseParamsByLegIndex[i] = { semiMajor: out.semiMajor, semiMinor: out.semiMinor, headingDeg: headingDeg };
} else if (!isEllipse && cesiumMap.computeHoldRadiusForDuration) {
const R = cesiumMap.computeHoldRadiusForDuration(centerCartesian, prevCartesian, nextCartesian, clockwise, totalHoldDistM);
if (R != null && Number.isFinite(R)) holdRadiusByLegIndex[i] = R;
}
}
const hasCircle = Object.keys(holdRadiusByLegIndex).length > 0;
const hasEllipse = Object.keys(holdEllipseParamsByLegIndex).length > 0;
if (hasCircle || hasEllipse) {
let pathData2 = null;
let segments2 = null;
for (let iter = 0; iter < 2; iter++) {
const ret2 = cesiumMap.getRoutePathWithSegmentIndices(waypoints, { holdRadiusByLegIndex, holdEllipseParamsByLegIndex });
if (!ret2.path || ret2.path.length === 0 || !ret2.segmentEndIndices || ret2.segmentEndIndices.length === 0) break;
pathData2 = { path: ret2.path, segmentEndIndices: ret2.segmentEndIndices, holdArcRanges: ret2.holdArcRanges || {} };
const out = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData2, holdRadiusByLegIndex);
segments2 = out.segments;
let changed = false;
if (hasCircle) {
const nextRadii = {};
for (let idx = 0; idx < segments2.length; idx++) {
const s = segments2[idx];
if (s.type !== 'hold' || s.holdRadius == null || s.holdCenter == null) continue;
const i = s.legIndex;
const holdDurationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime);
const speedKmh = s.speedKmh != null ? s.speedKmh : 800;
const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000;
const prevWp = waypoints[i];
const holdWp = waypoints[i + 1];
const nextWp = (i + 2) < waypoints.length ? waypoints[i + 2] : holdWp;
if (!prevWp || !holdWp) continue;
const centerCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(holdWp.lng), parseFloat(holdWp.lat), Number(holdWp.alt) || 0);
const prevCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(prevWp.lng), parseFloat(prevWp.lat), Number(prevWp.alt) || 0);
const nextCartesian = nextWp ? Cesium.Cartesian3.fromDegrees(parseFloat(nextWp.lng), parseFloat(nextWp.lat), Number(nextWp.alt) || 0) : centerCartesian;
const Rnew = cesiumMap.computeHoldRadiusForDuration(centerCartesian, prevCartesian, nextCartesian, s.holdClockwise !== false, totalHoldDistM);
if (Rnew != null && Number.isFinite(Rnew)) {
if (holdRadiusByLegIndex[i] == null || Math.abs(Rnew - holdRadiusByLegIndex[i]) > 1) changed = true;
nextRadii[i] = Rnew;
}
}
Object.assign(holdRadiusByLegIndex, nextRadii);
}
if (hasEllipse) {
for (let idx = 0; idx < segments2.length; idx++) {
const s = segments2[idx];
if (s.type !== 'hold' || s.holdRadius != null || s.holdCenter == null) continue;
const i = s.legIndex;
const holdWp = waypoints[i + 1];
const holdParams = this.parseHoldParams(holdWp);
const holdDurationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime);
const speedKmh = s.speedKmh != null ? s.speedKmh : 800;
const totalHoldDistM = speedKmh * (holdDurationMin / 60) * 1000;
const prevWp = waypoints[i];
const nextWp = (i + 2) < waypoints.length ? waypoints[i + 2] : holdWp;
if (!prevWp || !holdWp || !cesiumMap.computeEllipseParamsForDuration) continue;
const centerCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(holdWp.lng), parseFloat(holdWp.lat), Number(holdWp.alt) || 0);
const prevCartesian = Cesium.Cartesian3.fromDegrees(parseFloat(prevWp.lng), parseFloat(prevWp.lat), Number(prevWp.alt) || 0);
const nextCartesian = nextWp ? Cesium.Cartesian3.fromDegrees(parseFloat(nextWp.lng), parseFloat(nextWp.lat), Number(nextWp.alt) || 0) : centerCartesian;
const headingDeg = holdParams && holdParams.headingDeg != null ? holdParams.headingDeg : 0;
const a0 = holdParams && (holdParams.semiMajor != null || holdParams.semiMajorAxis != null) ? (holdParams.semiMajor ?? holdParams.semiMajorAxis) : 500;
const b0 = holdParams && (holdParams.semiMinor != null || holdParams.semiMinorAxis != null) ? (holdParams.semiMinor ?? holdParams.semiMinorAxis) : 300;
const out = cesiumMap.computeEllipseParamsForDuration(centerCartesian, prevCartesian, nextCartesian, s.holdClockwise !== false, totalHoldDistM, headingDeg, a0, b0);
if (out && out.semiMajor != null) {
const old = holdEllipseParamsByLegIndex[i];
if (!old || Math.abs(out.semiMajor - old.semiMajor) > 1) changed = true;
holdEllipseParamsByLegIndex[i] = { semiMajor: out.semiMajor, semiMinor: out.semiMinor, headingDeg: headingDeg };
}
}
}
if (!changed || iter === 1) break;
}
if (pathData2) pathData = pathData2;
if (segments2) segments = segments2;
if (routeId != null) {
if (cesiumMap.setRouteHoldRadii) cesiumMap.setRouteHoldRadii(routeId, holdRadiusByLegIndex);
if (cesiumMap.setRouteHoldEllipseParams) cesiumMap.setRouteHoldEllipseParams(routeId, holdEllipseParamsByLegIndex);
}
} else if (routeId != null) {
if (cesiumMap.setRouteHoldRadii) cesiumMap.setRouteHoldRadii(routeId, {});
if (cesiumMap.setRouteHoldEllipseParams) cesiumMap.setRouteHoldEllipseParams(routeId, {});
}
}
const path = pathData ? pathData.path : null;
const segmentEndIndices = pathData ? pathData.segmentEndIndices : null;
const position = this.getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices);
@ -3470,7 +3631,7 @@ export default {
this.activeRouteIds.forEach(routeId => {
const route = this.routes.find(r => r.id === routeId);
if (!route || !route.waypoints || route.waypoints.length === 0) return;
const { position, nextPosition, previousPosition, warnings, earlyArrivalLegs, currentSegment } = this.getPositionAtMinutesFromK(route.waypoints, minutesFromK, minMinutes, maxMinutes);
const { position, nextPosition, previousPosition, warnings, earlyArrivalLegs, currentSegment } = this.getPositionAtMinutesFromK(route.waypoints, minutesFromK, minMinutes, maxMinutes, routeId);
if (warnings && warnings.length) allWarnings.push(...warnings);
if (position) {
const directionPoint = nextPosition || previousPosition;
@ -3613,25 +3774,73 @@ export default {
const minutes = absMin % 60;
return `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
},
selectPlan(plan) {
async selectPlan(plan) {
if (plan && plan.id) {
this.selectedPlanId = plan.id;
this.selectedPlanDetails = plan;
} else {
this.selectedPlanId = null;
this.selectedPlanDetails = null;
this.selectedRouteId = null;
this.selectedRouteDetails = null;
return;
}
//
this.selectedRouteId = null;
this.selectedRouteDetails = null;
this.activeRouteIds = [];
// /线 routeId
if (this.$refs.cesiumMap) {
[...this.activeRouteIds].forEach(routeId => {
this.$refs.cesiumMap.removeRouteById(routeId);
});
}
// /线
if (this.$refs.cesiumMap && this.$refs.cesiumMap.clearAllWaypoints) {
this.$refs.cesiumMap.clearAllWaypoints();
// activeRouteIds线
const planRouteIds = this.activeRouteIds.filter(id => {
const r = this.routes.find(x => x.id === id);
return r && r.scenarioId === plan.id;
});
if (planRouteIds.length > 0) {
for (const routeId of planRouteIds) {
const route = this.routes.find(r => r.id === routeId);
if (!route) continue;
try {
const res = await getRoutes(route.id);
if (res.code !== 200 || !res.data) continue;
const waypoints = res.data.waypoints || [];
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));
}
this.$nextTick(() => this.updateDeductionPositions());
} catch (_) {}
}
const firstId = planRouteIds[0];
const firstRoute = this.routes.find(r => r.id === firstId);
if (firstRoute && firstRoute.waypoints) {
this.selectedRouteId = firstRoute.id;
this.selectedRouteDetails = {
id: firstRoute.id,
name: firstRoute.callSign || firstRoute.name,
waypoints: firstRoute.waypoints,
platformId: firstRoute.platformId,
platform: firstRoute.platform,
attributes: firstRoute.attributes
};
}
} else {
this.selectedRouteId = null;
this.selectedRouteDetails = null;
}
console.log(`>>> [切换成功] 已进入方案: ${plan && plan.name},地图已清空,列表已展开。`);
console.log(`>>> [切换成功] 已进入方案: ${plan.name},已恢复显示 ${planRouteIds.length} 条航线`);
},
/** 切换航线:实现多选/开关逻辑 */
async selectRoute(route) {
@ -3714,6 +3923,7 @@ export default {
} catch (_) {}
}
this.$refs.cesiumMap.renderRouteWaypoints(waypoints, route.id, route.platformId, route.platform, this.parseRouteStyle(route.attributes));
this.$nextTick(() => this.updateDeductionPositions());
}
} else {
this.$message.warning('该航线暂无坐标数据,无法在地图展示');
@ -3892,7 +4102,7 @@ export default {
pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} };
}
}
const { earlyArrivalLegs, lateArrivalLegs } = this.buildRouteTimeline(route.waypoints, minMinutes, maxMinutes, pathData);
const { earlyArrivalLegs, lateArrivalLegs, holdDelayConflicts } = this.buildRouteTimeline(route.waypoints, minMinutes, maxMinutes, pathData);
const routeName = route.name || `航线${route.id}`;
(earlyArrivalLegs || []).forEach(leg => {
@ -3918,6 +4128,20 @@ export default {
severity: 'high'
});
});
(holdDelayConflicts || []).forEach(conf => {
list.push({
id: id++,
title: '盘旋时间不足',
routeName,
fromWaypoint: conf.fromName,
toWaypoint: conf.toName,
time: this.minutesToStartTime(conf.setExitTime),
position: conf.holdCenter ? `经度 ${conf.holdCenter.lng.toFixed(5)}°, 纬度 ${conf.holdCenter.lat.toFixed(5)}°` : undefined,
suggestion: `警告:设定的盘旋时间不足以支撑战斗机完成最后一圈,实际切出将延迟 ${conf.delaySeconds} 秒。`,
severity: 'high',
holdCenter: conf.holdCenter
});
});
});
this.conflicts = list;

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://127.0.0.1:8080' // 后端接口
const baseUrl = 'http://192.168.1.107:8080' // 后端接口
const port = process.env.port || process.env.npm_config_port || 80 // 端口
// 定义 Cesium 源码路径

Loading…
Cancel
Save