diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java index f9c29ef..6c1ca78 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoutesController.java @@ -23,6 +23,7 @@ import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.system.domain.dto.PlatformStyleDTO; import com.alibaba.fastjson2.JSON; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.beans.factory.annotation.Qualifier; /** * 实体部署与航线Controller @@ -40,6 +41,10 @@ public class RoutesController extends BaseController @Autowired private RedisTemplate redisTemplate; + @Autowired + @Qualifier("fourTRedisTemplate") + private RedisTemplate fourTRedisTemplate; + /** * 保存平台样式到 Redis */ @@ -74,6 +79,45 @@ public class RoutesController extends BaseController } /** + * 保存4T数据到 Redis(按房间存储) + */ + @PreAuthorize("@ss.hasPermi('system:routes:edit')") + @PostMapping("/save4TData") + public AjaxResult save4TData(@RequestBody java.util.Map params) + { + Object roomId = params.get("roomId"); + Object data = params.get("data"); + if (roomId == null || data == null) { + return AjaxResult.error("参数不完整"); + } + String key = "room:" + String.valueOf(roomId) + ":4t"; + fourTRedisTemplate.opsForValue().set(key, data.toString()); + return success(); + } + + /** + * 从 Redis 获取4T数据 + */ + @PreAuthorize("@ss.hasPermi('system:routes:query')") + @GetMapping("/get4TData") + public AjaxResult get4TData(Long roomId) + { + if (roomId == null) { + return AjaxResult.error("房间ID不能为空"); + } + String key = "room:" + String.valueOf(roomId) + ":4t"; + String val = fourTRedisTemplate.opsForValue().get(key); + if (val != null && !val.isEmpty()) { + try { + return success(JSON.parseObject(val)); + } catch (Exception e) { + return success(val); + } + } + return success(); + } + + /** * 查询实体部署与航线列表 */ @PreAuthorize("@ss.hasPermi('system:routes:list')") diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java index c8c49c9..3760365 100644 --- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java +++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/CacheController.java @@ -9,6 +9,7 @@ import java.util.Properties; import java.util.Set; import java.util.TreeSet; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.access.prepost.PreAuthorize; @@ -32,6 +33,7 @@ import com.ruoyi.system.domain.SysCache; public class CacheController { @Autowired + @Qualifier("stringRedisTemplate") private RedisTemplate redisTemplate; private final static List caches = new ArrayList(); diff --git a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java index 113521b..8a372c3 100644 --- a/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java +++ b/ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java @@ -63,6 +63,23 @@ public class RedisConfig extends CachingConfigurerSupport return template; } + /** + * 纯字符串 RedisTemplate,用于存储 4T 等 JSON 字符串,避免 FastJson 序列化问题 + * 命名为 fourTRedisTemplate 避免与 Spring Boot 自带的 stringRedisTemplate 冲突 + */ + @Bean("fourTRedisTemplate") + public RedisTemplate fourTRedisTemplate(RedisConnectionFactory connectionFactory) + { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new StringRedisSerializer()); + template.afterPropertiesSet(); + return template; + } + @Bean public DefaultRedisScript limitScript() { diff --git a/ruoyi-ui/src/api/system/routes.js b/ruoyi-ui/src/api/system/routes.js index cf6ad48..ae406dd 100644 --- a/ruoyi-ui/src/api/system/routes.js +++ b/ruoyi-ui/src/api/system/routes.js @@ -60,3 +60,22 @@ export function getPlatformStyle(query) { params: query }) } + +// 保存4T数据到Redis(禁用防重复提交,因拖拽/调整大小可能快速连续触发保存) +export function save4TData(data) { + return request({ + url: '/system/routes/save4TData', + method: 'post', + data, + headers: { repeatSubmit: false } + }) +} + +// 从Redis获取4T数据 +export function get4TData(params) { + return request({ + url: '/system/routes/get4TData', + method: 'get', + params + }) +} diff --git a/ruoyi-ui/src/views/cesiumMap/index.vue b/ruoyi-ui/src/views/cesiumMap/index.vue index 7362d25..cf80d3a 100644 --- a/ruoyi-ui/src/views/cesiumMap/index.vue +++ b/ruoyi-ui/src/views/cesiumMap/index.vue @@ -473,6 +473,8 @@ export default { waypointDragging: null, waypointDragPending: null, WAYPOINT_DRAG_THRESHOLD_PX: 8, + /** 虚拟点(entry/exit)拖拽时的实时预览:{ routeId, dbId, position },拖拽时用 position 作为航点中心重算弧线 */ + waypointDragPreview: null, /** 拖拽航点前相机 enableInputs 状态,松开时恢复 */ waypointDragCameraInputsEnabled: undefined, lastClickWasDrag: false, @@ -1840,6 +1842,7 @@ export default { //正式航线渲染函数 renderRouteWaypoints(waypoints, routeId = 'default', platformId, platform, style) { if (!waypoints || waypoints.length < 1) return; + this.waypointDragPreview = null; // 清理旧线 const lineId = `route-line-${routeId}`; const existingLine = this.viewer.entities.getById(lineId); @@ -1909,11 +1912,12 @@ export default { }); if (!this._routeWaypointIdsByRoute) this._routeWaypointIdsByRoute = {}; this._routeWaypointIdsByRoute[routeId] = waypoints.map((wp) => wp.id); - // 判断航点 i 是否为“转弯半径”航点(将用弧线两端两个点替代中心点) + // 判断航点 i 是否为“转弯半径”航点(将用弧线两端两个点替代中心点);非首尾默认 45° 坡度 const isTurnWaypointWithArc = (i) => { if (i < 1 || i >= waypoints.length - 1) return false; const wp = waypoints[i]; - if (this.getWaypointRadius(wp) <= 0) return false; + const effectiveAngle = this.getEffectiveTurnAngle(wp, i, waypoints.length); + if (this.getWaypointRadius({ ...wp, turnAngle: effectiveAngle }) <= 0) return false; const nextPos = originalPositions[i + 1]; let nextLogical = nextPos; if (this.isHoldWaypoint(waypoints[i + 1])) { @@ -1924,7 +1928,7 @@ export default { } return !!nextLogical; }; - // 遍历并绘制航点标记:转弯半径处也画中心点+标签(与普通航点一致);盘旋处在圆心画点+标签 + // 遍历并绘制航点标记:盘旋处在圆心画点+标签;有转弯半径时只画 entry/exit 两个虚拟点(见下方连线逻辑),不画原航点中心 waypoints.forEach((wp, index) => { const pos = originalPositions[index]; if (this.isHoldWaypoint(wp)) { @@ -1956,6 +1960,8 @@ export default { }); return; } + // 有转弯半径的航点不显示中心点,只显示下方连线逻辑里添加的 entry/exit 两个虚拟点,点击虚拟点弹窗仍控制原航点(dbId) + if (isTurnWaypointWithArc(index)) return; this.viewer.entities.add({ id: `wp_${routeId}_${wp.id}`, name: wp.name || `WP${index + 1}`, @@ -2250,7 +2256,8 @@ export default { }); lastPos = exit; } else { - const radius = this.getWaypointRadius(wp); + const effectiveAngle = this.getEffectiveTurnAngle(wp, i, waypoints.length); + const radius = this.getWaypointRadius({ ...wp, turnAngle: effectiveAngle }); let nextLogical = nextPos; if (nextPos && i + 1 < waypoints.length && this.isHoldWaypoint(waypoints[i + 1])) { const holdParams = this.parseHoldParams(waypoints[i + 1]); @@ -2259,22 +2266,42 @@ export default { : this.getEllipseEntryPoint(originalPositions[i + 1], currPos, holdParams.semiMajor ?? 500, holdParams.semiMinor ?? 300, ((holdParams.headingDeg || 0) * Math.PI) / 180); } if (i < waypoints.length - 1 && radius > 0 && nextLogical) { - const arcPoints = this.computeArcPositions(lastPos, currPos, nextLogical, radius); + const lastPosCloned = Cesium.Cartesian3.clone(lastPos); + const currPosCloned = Cesium.Cartesian3.clone(currPos); + const nextLogicalCloned = Cesium.Cartesian3.clone(nextLogical); + const routeIdCloned = routeId; + const dbIdCloned = wp.id; + const that = this; + const getArcPoints = () => { + const center = (that.waypointDragPreview && that.waypointDragPreview.routeId === routeIdCloned && that.waypointDragPreview.dbId === dbIdCloned) + ? that.waypointDragPreview.position : currPosCloned; + return that.computeArcPositions(lastPosCloned, center, nextLogicalCloned, radius); + }; + // 弧线用 CallbackProperty,拖拽虚拟点时实时重算实现动态预览 this.viewer.entities.add({ id: `arc-line-${routeId}-${i}`, - polyline: { positions: arcPoints, width: lineWidth, material: lineMaterial, arcType: Cesium.ArcType.NONE, zIndex: 20 }, + show: false, + polyline: { + positions: new Cesium.CallbackProperty(getArcPoints, false), + width: lineWidth, + material: lineMaterial, + arcType: Cesium.ArcType.NONE, + zIndex: 20 + }, properties: { routeId: routeId } }); - // 转弯半径两侧航点与航线整体航点风格一致,点击均打开原航点编辑(dbId 为当前 wp.id) + // 转弯半径两侧虚拟点也用 CallbackProperty,拖拽时随弧线实时更新 const wpName = wp.name || `WP${i + 1}`; - const arcEntry = arcPoints[0]; - const arcExit = arcPoints[arcPoints.length - 1]; - [arcEntry, arcExit].forEach((pos, idx) => { + [0, 1].forEach((idx) => { const suffix = idx === 0 ? '_entry' : '_exit'; + const getPos = () => { + const pts = getArcPoints(); + return idx === 0 ? pts[0] : pts[pts.length - 1]; + }; this.viewer.entities.add({ id: `wp_${routeId}_${wp.id}${suffix}`, name: wpName, - position: pos, + position: new Cesium.CallbackProperty(getPos, false), properties: { isMissionWaypoint: true, routeId: routeId, @@ -2298,6 +2325,7 @@ export default { } }); }); + const arcPoints = getArcPoints(); finalPathPositions.push(...arcPoints); lastPos = arcPoints[arcPoints.length - 1]; } else { @@ -2326,14 +2354,14 @@ export default { } } }, - /** 从各航点/弧线/盘旋实体取当前位置,供主航线折线实时连线(拖拽时动态跟随) */ + /** 从各航点/弧线/盘旋实体取当前位置,供主航线折线实时连线(拖拽时动态跟随)。转弯处优先用弧线,不经过航点中心,只展示转弯半径 */ getRouteLinePositionsFromWaypointEntities(routeId) { const ids = this._routeWaypointIdsByRoute && this._routeWaypointIdsByRoute[routeId]; if (!ids || !ids.length || !this.viewer) return null; const now = Cesium.JulianDate.now(); const positions = []; for (let i = 0; i < ids.length; i++) { - // 盘旋段优先用整圆弧线,不取圆心点,避免主折线出现穿过圆心的直线 + // 盘旋段优先用整圆弧线,不取圆心点 const holdEnt = this.viewer.entities.getById(`hold-line-${routeId}-${i}`); if (holdEnt && holdEnt.polyline && holdEnt.polyline.positions) { const arr = holdEnt.polyline.positions.getValue(now); @@ -2342,14 +2370,7 @@ export default { continue; } } - const ent = this.viewer.entities.getById(`wp_${routeId}_${ids[i]}`); - if (ent && ent.position) { - const pos = ent.position.getValue(now); - if (pos) { - positions.push(Cesium.Cartesian3.clone(pos)); - continue; - } - } + // 转弯处优先取弧线,避免主航线显示“原直线”(中心点连线),只显示转弯半径弧 const arcEnt = this.viewer.entities.getById(`arc-line-${routeId}-${i}`); if (arcEnt && arcEnt.polyline && arcEnt.polyline.positions) { const arr = arcEnt.polyline.positions.getValue(now); @@ -2358,10 +2379,23 @@ export default { continue; } } + const ent = this.viewer.entities.getById(`wp_${routeId}_${ids[i]}`); + if (ent && ent.position) { + const pos = ent.position.getValue(now); + if (pos) { + positions.push(Cesium.Cartesian3.clone(pos)); + continue; + } + } } return positions.length > 0 ? positions : null; }, + /** 渲染/路径用:非首尾航点默认转弯坡度 45°,首尾为 0 */ + getEffectiveTurnAngle(wp, index, waypointsLength) { + if (index === 0 || index === waypointsLength - 1) return wp.turnAngle != null ? wp.turnAngle : 0; + return wp.turnAngle != null ? wp.turnAngle : 45; + }, // 计算单个点的转弯半径 getWaypointRadius(wp) { const speed = wp.speed || 800; @@ -2733,7 +2767,8 @@ export default { ? this.getCircleEntryPoint(originalPositions[i + 1], currPos, holdParams.radius) : this.getEllipseEntryPoint(originalPositions[i + 1], currPos, holdParams.semiMajor ?? 500, holdParams.semiMinor ?? 300, ((holdParams.headingDeg || 0) * Math.PI) / 180); } - const radius = this.getWaypointRadius(wp); + const effectiveAngle = this.getEffectiveTurnAngle(wp, i, waypoints.length); + const radius = this.getWaypointRadius({ ...wp, turnAngle: effectiveAngle }); if (i < waypoints.length - 1 && radius > 0 && nextLogical) { const arcPoints = this.computeArcPositions(lastPos, currPos, nextLogical, radius); arcPoints.forEach(p => path.push(toLngLatAlt(p))); @@ -3187,7 +3222,12 @@ export default { } const pos = this.getClickPositionWithHeight(movement.endPosition, this.waypointDragging.originalAlt); if (pos) { - this.waypointDragging.entity.position = pos; + const entityId = this.waypointDragging.entity.id || ''; + if (entityId.endsWith('_entry') || entityId.endsWith('_exit')) { + this.waypointDragPreview = { routeId: this.waypointDragging.routeId, dbId: this.waypointDragging.dbId, position: pos }; + } else { + this.waypointDragging.entity.position = pos; + } if (this.viewer.scene.requestRender) this.viewer.scene.requestRender(); } } @@ -3196,7 +3236,12 @@ export default { if (this.waypointDragging) { const pos = this.getClickPositionWithHeight(movement.endPosition, this.waypointDragging.originalAlt); if (pos) { - this.waypointDragging.entity.position = pos; + const entityId = this.waypointDragging.entity.id || ''; + if (entityId.endsWith('_entry') || entityId.endsWith('_exit')) { + this.waypointDragPreview = { routeId: this.waypointDragging.routeId, dbId: this.waypointDragging.dbId, position: pos }; + } else { + this.waypointDragging.entity.position = pos; + } if (this.viewer.scene.requestRender) this.viewer.scene.requestRender(); } return; @@ -3229,7 +3274,15 @@ export default { const entity = this.waypointDragging.entity; const routeId = this.waypointDragging.routeId; const dbId = this.waypointDragging.dbId; - const pos = entity.position.getValue(Cesium.JulianDate.now()); + const entityId = entity.id || ''; + let pos; + if (entityId.endsWith('_entry') || entityId.endsWith('_exit')) { + pos = this.waypointDragPreview && this.waypointDragPreview.routeId === routeId && this.waypointDragPreview.dbId === dbId + ? this.waypointDragPreview.position : entity.position.getValue(Cesium.JulianDate.now()); + this.waypointDragPreview = null; + } else { + pos = entity.position.getValue(Cesium.JulianDate.now()); + } const ll = this.cartesianToLatLngAlt(pos); if (ll) { this.$emit('waypoint-position-changed', { dbId, routeId, lat: ll.lat, lng: ll.lng, alt: ll.alt }); @@ -3237,6 +3290,7 @@ export default { this.lastClickWasDrag = true; this.waypointDragging = null; } + this.waypointDragPreview = null; this.waypointDragPending = null; }, Cesium.ScreenSpaceEventType.LEFT_UP); } catch (error) { diff --git a/ruoyi-ui/src/views/childRoom/index.vue b/ruoyi-ui/src/views/childRoom/index.vue index 9a32bba..49e9ae1 100644 --- a/ruoyi-ui/src/views/childRoom/index.vue +++ b/ruoyi-ui/src/views/childRoom/index.vue @@ -367,6 +367,13 @@ @confirm="handleImportConfirm" /> + + + 0) { const defaultMap = (this.defaultMenuItems || []).reduce((m, it) => { m[it.id] = it; return m }, {}) + const savedIds = new Set(arr.map(i => i.id)) + // 合并缺失的默认项(如新增的4T),按 defaultMenuItems 顺序插入 + const defaultOrder = (this.defaultMenuItems || []).map(d => d.id) + defaultOrder.forEach(defId => { + if (!savedIds.has(defId) && defaultMap[defId]) { + const insertAfterId = defaultOrder[defaultOrder.indexOf(defId) - 1] + const refIdx = insertAfterId ? arr.findIndex(i => i.id === insertAfterId) : -1 + const insertIdx = refIdx >= 0 ? refIdx + 1 : 0 + arr.splice(insertIdx, 0, { ...defaultMap[defId] }) + savedIds.add(defId) + } + }) this.menuItems = arr.map(item => { const def = defaultMap[item.id] if (def) return { ...item, name: def.name, icon: def.icon, action: def.action } @@ -2399,21 +2423,25 @@ export default { this.handleMenuAction(item.action) } - // 点击方案、平台、冲突等菜单项时,停止地图绘制状态 - if (item.id === 'file' || item.id === 'start' || item.id === 'insert') { + // 点击方案、平台、冲突、4T等菜单项时,停止地图绘制状态 + if (item.id === 'file' || item.id === 'start' || item.id === 'insert' || item.id === '4t') { this.drawDom = false; this.airspaceDrawDom = false; } // 点击左侧的方案、冲突、平台时,切换右侧面板内容 if (item.id === 'file') { - // 如果当前已经是方案标签页,则关闭右侧面板 + // 方案:切换右侧面板,并显示4T悬浮窗 + this.show4TPanel = true; if (this.activeRightTab === 'plan' && !this.isRightPanelHidden) { this.isRightPanelHidden = true; } else { this.activeRightTab = 'plan'; this.isRightPanelHidden = false; } + } else if (item.id === '4t') { + // 4T:切换4T悬浮窗显示 + this.show4TPanel = !this.show4TPanel; } else if (item.id === 'start') { // 如果当前已经是冲突标签页,则关闭右侧面板 if (this.activeRightTab === 'conflict' && !this.isRightPanelHidden) {