From 4ad9920e88aacb570b303dca728966bb90060e9d Mon Sep 17 00:00:00 2001 From: sd <1504629600@qq.com> Date: Fri, 6 Feb 2026 15:20:17 +0800 Subject: [PATCH 01/19] =?UTF-8?q?=E6=88=91=E7=9A=84=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=EF=BC=8C=E4=BD=A0=E4=BB=AC=E5=88=AB=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-ui/.env.development | 2 +- ruoyi-ui/vue.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ruoyi-ui/.env.development b/ruoyi-ui/.env.development index ebae234..4d098ba 100644 --- a/ruoyi-ui/.env.development +++ b/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.50.145:8080' +VUE_APP_BACKEND_URL = 'http://localhost:8080' # 路由懒加载 VUE_CLI_BABEL_TRANSPILE_MODULES = true diff --git a/ruoyi-ui/vue.config.js b/ruoyi-ui/vue.config.js index fa4cddd..daef08e 100644 --- a/ruoyi-ui/vue.config.js +++ b/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.50.145:8080' // 后端接口 +const baseUrl = 'http://localhost:8080' // 后端接口 const port = process.env.port || process.env.npm_config_port || 80 // 端口 // 定义 Cesium 源码路径 From 5212736bf9d583e7909c51f12f55985181903532 Mon Sep 17 00:00:00 2001 From: sd <1504629600@qq.com> Date: Fri, 6 Feb 2026 16:58:39 +0800 Subject: [PATCH 02/19] =?UTF-8?q?=E7=9C=9F=E6=96=B9=E4=BD=8D=E7=A3=81?= =?UTF-8?q?=E6=96=B9=E4=BD=8D=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-ui/src/views/cesiumMap/index.vue | 109 ++++++++++++++++++++++++--------- 1 file changed, 80 insertions(+), 29 deletions(-) diff --git a/ruoyi-ui/src/views/cesiumMap/index.vue b/ruoyi-ui/src/views/cesiumMap/index.vue index 54dded0..56e973c 100644 --- a/ruoyi-ui/src/views/cesiumMap/index.vue +++ b/ruoyi-ui/src/views/cesiumMap/index.vue @@ -965,15 +965,28 @@ export default { const bearing = bearingType === 'magnetic' ? this.calculateMagneticBearing(currentSegmentPositions) : this.calculateTrueBearing(currentSegmentPositions); - // 显示小框提示 - this.hoverTooltip = { - visible: true, - content: `累计长度:${cumulativeLength.toFixed(2)} 米\n${bearingType === 'magnetic' ? '磁方位' : '真方位'}:${bearing.toFixed(2)}°`, - position: { - x: movement.endPosition.x + 10, - y: movement.endPosition.y - 10 - } - }; + // 根据工具模式决定显示格式 + if (this.toolMode === 'ranging') { + // 测距模式:使用千米,简化格式 + this.hoverTooltip = { + visible: true, + content: `${(cumulativeLength / 1000).toFixed(1)}km ,${bearingType === 'magnetic' ? '磁' : '真'}:${bearing.toFixed(1)}°`, + position: { + x: movement.endPosition.x + 10, + y: movement.endPosition.y - 10 + } + }; + } else { + // 空域模式:使用米,完整格式 + this.hoverTooltip = { + visible: true, + content: `累计长度:${cumulativeLength.toFixed(2)} 米\n${bearingType === 'magnetic' ? '磁方位' : '真方位'}:${bearing.toFixed(2)}°`, + position: { + x: movement.endPosition.x + 10, + y: movement.endPosition.y - 10 + } + }; + } } else { // 如果没有找到对应的段,隐藏信息 this.hoverTooltip.visible = false; @@ -1252,15 +1265,28 @@ export default { const length = this.calculateLineLength(tempPositions); // 默认为真方位,因为绘制过程中还没有bearingType属性 const bearing = this.calculateTrueBearing(tempPositions); - // 更新小框提示,显示实时长度和真方位角 - this.hoverTooltip = { - visible: true, - content: `长度:${length.toFixed(2)} 米\n真方位:${bearing.toFixed(2)}°`, - position: { - x: movement.endPosition.x + 10, - y: movement.endPosition.y - 10 - } - }; + // 根据工具模式决定显示格式 + if (this.toolMode === 'ranging') { + // 测距模式:使用千米,简化格式 + this.hoverTooltip = { + visible: true, + content: `${(length / 1000).toFixed(1)}km ,真:${bearing.toFixed(1)}°`, + position: { + x: movement.endPosition.x + 10, + y: movement.endPosition.y - 10 + } + }; + } else { + // 空域模式:使用米,完整格式 + this.hoverTooltip = { + visible: true, + content: `长度:${length.toFixed(2)} 米\n真方位:${bearing.toFixed(2)}°`, + position: { + x: movement.endPosition.x + 10, + y: movement.endPosition.y - 10 + } + }; + } } else { // 如果没有点,隐藏提示 this.hoverTooltip.visible = false; @@ -2692,20 +2718,45 @@ export default { let declination; if (lngDeg >= 73 && lngDeg <= 135 && latDeg >= 18 && latDeg <= 53) { - // 中国地区简化磁偏角模型 - // 基于2020年数据,每年约变化0.1度 + // 中国地区简化磁偏角模型(基于WMM2025模型数据) + // 注意:中国大部分地区磁偏角偏西(负值),只有新疆、西藏等少数地区偏东(正值) + // 数据来源:NOAA WMM-2025模型 const year = new Date().getFullYear(); - const yearOffset = (year - 2020) * 0.1; + const yearOffset = (year - 2025) * 0.04; if (lngDeg < 100) { - // 西部地区 - declination = 2.0 - yearOffset; + // 西部地区(新疆、西藏等) + if (latDeg > 40) { + // 西北地区(新疆北部) + declination = 3.0 - yearOffset; + } else if (latDeg > 32) { + // 西南地区(四川、重庆等) + declination = -2.0 - yearOffset; + } else { + // 西藏地区 + declination = 1.0 - yearOffset; + } } else if (lngDeg < 115) { - // 中部地区 - declination = 1.0 - yearOffset; + // 中部地区(华北、华中) + if (latDeg > 38) { + // 华北地区(北京等) + declination = -7.5 - yearOffset; + } else if (latDeg > 32) { + // 华中北部 + declination = -5.0 - yearOffset; + } else { + // 华中南部 + declination = -3.5 - yearOffset; + } } else { - // 东部地区 - declination = 0.5 - yearOffset; + // 东部地区(华东、华南) + if (latDeg > 35) { + // 华东地区(上海等) + declination = -5.5 - yearOffset; + } else { + // 华南地区(广州、深圳等) + declination = -2.5 - yearOffset; + } } } else { // 其他地区默认磁偏角为0 @@ -2732,8 +2783,8 @@ export default { // 计算磁偏角 const declination = this.calculateMagneticDeclination(startLat, startLng); - // 计算磁方位角(真方位角加上磁偏角) - let magneticBearing = trueBearing + declination; + // 计算磁方位角(真方位角减去磁偏角) + let magneticBearing = trueBearing - declination; // 调整到0-360范围 return (magneticBearing + 360) % 360; From 9114c295ba4506ae6848f4528ea3b68ba3d07cfe Mon Sep 17 00:00:00 2001 From: ctw <1051735452@qq.com> Date: Fri, 6 Feb 2026 17:07:59 +0800 Subject: [PATCH 03/19] =?UTF-8?q?=E7=9B=98=E6=97=8B=E7=9A=84=E4=B8=80?= =?UTF-8?q?=E7=A7=8D=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-druid.yml | 2 +- .../com/ruoyi/system/domain/RouteWaypoints.java | 26 + .../mapper/system/RouteWaypointsMapper.xml | 12 +- ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue | 3 + ruoyi-ui/src/views/cesiumMap/index.vue | 1020 ++++++++++++++++++-- ruoyi-ui/src/views/childRoom/RightPanel.vue | 5 + ruoyi-ui/src/views/childRoom/index.vue | 431 ++++++++- ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue | 83 +- 8 files changed, 1471 insertions(+), 111 deletions(-) diff --git a/ruoyi-admin/src/main/resources/application-druid.yml b/ruoyi-admin/src/main/resources/application-druid.yml index 037db5c..c40f2aa 100644 --- a/ruoyi-admin/src/main/resources/application-druid.yml +++ b/ruoyi-admin/src/main/resources/application-druid.yml @@ -8,7 +8,7 @@ spring: master: url: jdbc:mysql://localhost:3306/ry?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 username: root - password: 123456 + password: A20040303ctw! # 从库数据源 slave: # 从数据源开关/默认关闭 diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java index 45bc77e..4d83b21 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/RouteWaypoints.java @@ -55,6 +55,14 @@ public class RouteWaypoints extends BaseEntity @Excel(name = "转弯角度 (用于计算转弯半径)") private Double turnAngle; + /** 航点类型: normal-普通, hold_circle-圆形盘旋, hold_ellipse-椭圆盘旋 */ + @Excel(name = "航点类型") + private String pointType; + + /** 盘旋参数JSON: 圆(radius,clockwise) 椭圆(semiMajor,semiMinor,headingDeg,clockwise) */ + @Excel(name = "盘旋参数") + private String holdParams; + public void setId(Long id) { this.id = id; @@ -155,6 +163,22 @@ public class RouteWaypoints extends BaseEntity return turnAngle; } + public void setPointType(String pointType) { + this.pointType = pointType; + } + + public String getPointType() { + return pointType; + } + + public void setHoldParams(String holdParams) { + this.holdParams = holdParams; + } + + public String getHoldParams() { + return holdParams; + } + @Override public String toString() { return new ToStringBuilder(this,ToStringStyle.MULTI_LINE_STYLE) @@ -168,6 +192,8 @@ public class RouteWaypoints extends BaseEntity .append("speed", getSpeed()) .append("startTime", getStartTime()) .append("turnAngle", getTurnAngle()) + .append("pointType", getPointType()) + .append("holdParams", getHoldParams()) .toString(); } diff --git a/ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml b/ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml index 7ccc118..b5d6df5 100644 --- a/ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml +++ b/ruoyi-system/src/main/resources/mapper/system/RouteWaypointsMapper.xml @@ -15,10 +15,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + - select id, route_id, name, seq, lat, lng, alt, speed, start_time, turn_angle from route_waypoints + select id, route_id, name, seq, lat, lng, alt, speed, start_time, turn_angle, point_type, hold_params from route_waypoints @@ -57,6 +61,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" speed, start_time, turn_angle, + point_type, + hold_params, #{routeId}, @@ -68,6 +74,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{speed}, #{startTime}, #{turnAngle}, + #{pointType}, + #{holdParams}, @@ -83,6 +91,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" speed = #{speed}, start_time = #{startTime}, turn_angle = #{turnAngle}, + point_type = #{pointType}, + hold_params = #{holdParams}, where id = #{id} diff --git a/ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue b/ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue index 58d3b46..dcced8d 100644 --- a/ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue +++ b/ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue @@ -45,6 +45,9 @@ export default { { id: 'polygon', name: '面', icon: 'el-icon-house' }, { id: 'rectangle', name: '矩形', icon: 'jx' }, { id: 'circle', name: '圆形', icon: 'circle' }, + { id: 'ellipse', name: '椭圆', icon: 'el-icon-oval' }, + { id: 'hold_circle', name: '圆形盘旋', icon: 'circle' }, + { id: 'hold_ellipse', name: '椭圆盘旋', icon: 'el-icon-oval' }, { id: 'sector', name: '扇形', icon: 'sx' }, { id: 'arrow', name: '箭头', icon: 'el-icon-right' }, { id: 'text', name: '文本', icon: 'el-icon-document' }, diff --git a/ruoyi-ui/src/views/cesiumMap/index.vue b/ruoyi-ui/src/views/cesiumMap/index.vue index 15f1d19..20b8e6c 100644 --- a/ruoyi-ui/src/views/cesiumMap/index.vue +++ b/ruoyi-ui/src/views/cesiumMap/index.vue @@ -130,6 +130,10 @@ export default { drawingPointEntities: [], // 存储线绘制时的点实体 drawingStartPoint: null, isDrawing: false, + missionHoldParamsByIndex: {}, + missionPendingHold: null, + missionInitialHoldParams: null, + tempHoldEntity: null, activeCursorPosition: null, // 实时鼠标位置 // 实体管理 allEntities: [], // 所有绘制的实体 @@ -156,6 +160,9 @@ export default { polygon: { color: '#0000FF', opacity: 0.5, width: 2 }, rectangle: { color: '#FFA500', opacity: 0.3, width: 2 }, circle: { color: '#800080', opacity: 0.4, width: 2 }, + ellipse: { color: '#008080', opacity: 0.4, width: 2 }, + hold_circle: { color: '#00BFFF', width: 2 }, + hold_ellipse: { color: '#00BFFF', width: 2 }, sector: { color: '#FF6347', opacity: 0.5, width: 2 }, arrow: { color: '#FF0000', width: 6 }, text: { color: '#000000', font: '48px Microsoft YaHei, PingFang SC, sans-serif', backgroundColor: 'rgba(255, 255, 255, 0.8)' }, @@ -375,6 +382,9 @@ export default { startMissionRouteDrawing() { this.stopDrawing(); // 停止其他可能存在的绘制 this.drawingPoints = []; + this.missionHoldParamsByIndex = {}; + this.missionPendingHold = null; + this.missionInitialHoldParams = null; let activeCursorPosition = null; this.isDrawing = true; this.viewer.canvas.style.cursor = 'crosshair'; @@ -392,7 +402,24 @@ export default { const position = this.getClickPosition(click.position); if (!position) return; this.drawingPoints.push(position); + if (this.missionInitialHoldParams && this.drawingPoints.length === 2) { + this.missionPendingHold = { + beforeIndex: 1, + center: Cesium.Cartesian3.clone(this.drawingPoints[0]), + params: this.missionInitialHoldParams + }; + this.missionInitialHoldParams = null; + if (this.tempHoldEntity) { try { this.viewer.entities.remove(this.tempHoldEntity); } catch (e) {} this.tempHoldEntity = null; } + this.tempHoldEntity = this.viewer.entities.add({ + id: 'temp_hold_preview', + name: 'HOLD', + position: this.missionPendingHold.center, + point: { pixelSize: 10, color: Cesium.Color.ORANGE, outlineColor: Cesium.Color.WHITE, outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY }, + label: { text: 'HOLD', font: '12px MicroSoft YaHei', fillColor: Cesium.Color.ORANGE, outlineColor: Cesium.Color.BLACK, outlineWidth: 2 } + }); + } const wpIndex = this.drawingPoints.length; + this.$emit('drawing-points-update', this.drawingPoints.length); // 绘制业务航点 this.viewer.entities.add({ id: `temp_wp_${wpIndex}`, @@ -420,15 +447,22 @@ export default { style: Cesium.LabelStyle.FILL_AND_OUTLINE } }); - // 第一次点击后,创建动态黑白斑马线 + // 第一次点击后,创建动态黑白斑马线(若有盘旋则在连线中插入盘旋点) if (this.drawingPoints.length === 1) { this.tempPreviewEntity = this.viewer.entities.add({ polyline: { positions: new Cesium.CallbackProperty(() => { - if (this.drawingPoints.length > 0 && activeCursorPosition) { - return [...this.drawingPoints, activeCursorPosition]; + let positions = this.drawingPoints; + if (this.missionPendingHold && this.drawingPoints.length > 0) { + const idx = this.missionPendingHold.beforeIndex; + positions = [ + ...this.drawingPoints.slice(0, idx + 1), + this.missionPendingHold.center, + ...this.drawingPoints.slice(idx + 1) + ]; } - return this.drawingPoints; + if (positions.length > 0 && activeCursorPosition) return [...positions, activeCursorPosition]; + return positions; }, false), width: 4, // 黑白斑马材质 @@ -445,18 +479,36 @@ export default { // 右键点击逻辑(结束绘制、抛出数据、恢复右键) this.drawingHandler.setInputAction(() => { if (this.drawingPoints.length > 1) { - // 转换坐标并传回给 childRoom/index.vue - const latLngPoints = this.drawingPoints.map((p, index) => { - const coords = this.cartesianToLatLng(p); - return { - id: index + 1, - name: `WP${index + 1}`, + const latLngPoints = []; + let insertIdx = 0; + for (let i = 0; i < this.drawingPoints.length; i++) { + const coords = this.cartesianToLatLng(this.drawingPoints[i]); + const meta = this.missionHoldParamsByIndex[i]; + latLngPoints.push({ + id: insertIdx + 1, + name: meta ? 'HOLD' : `WP${insertIdx + 1}`, lat: coords.lat, lng: coords.lng, alt: 5000, - speed: 800 - }; - }); + speed: 800, + ...(meta && { pointType: meta.radius != null ? 'hold_circle' : 'hold_ellipse', holdParams: JSON.stringify(meta) }) + }); + insertIdx++; + if (this.missionPendingHold && this.missionPendingHold.beforeIndex === i) { + const holdCoords = this.cartesianToLatLng(this.missionPendingHold.center); + latLngPoints.push({ + id: insertIdx + 1, + name: 'HOLD', + lat: holdCoords.lat, + lng: holdCoords.lng, + alt: 5000, + speed: 800, + pointType: this.missionPendingHold.params.radius != null ? 'hold_circle' : 'hold_ellipse', + holdParams: JSON.stringify(this.missionPendingHold.params) + }); + insertIdx++; + } + } this.$emit('draw-complete', latLngPoints); } else { this.$message.info('点数不足,航线已取消'); @@ -468,6 +520,43 @@ export default { }, 200); }, Cesium.ScreenSpaceEventType.RIGHT_CLICK); }, + + /** 航线绘制过程中:在最后两航点之间插入盘旋(由父组件在弹窗确认后调用)。插入后“最后一个点”会被移除,下一点击即为盘旋后的下一航点。 */ + insertHoldBetweenLastTwo(holdParams) { + if (!this.drawingPoints || this.drawingPoints.length < 2) return; + const last = this.drawingPoints[this.drawingPoints.length - 1]; + this.drawingPoints.pop(); + const removedWpId = `temp_wp_${this.drawingPoints.length + 1}`; + const lastPointEntity = this.viewer.entities.getById(removedWpId); + if (lastPointEntity) this.viewer.entities.remove(lastPointEntity); + this.missionPendingHold = { + beforeIndex: this.drawingPoints.length - 1, + center: Cesium.Cartesian3.clone(last), + params: holdParams + }; + if (this.tempHoldEntity) { + try { this.viewer.entities.remove(this.tempHoldEntity); } catch (e) {} + this.tempHoldEntity = null; + } + this.tempHoldEntity = this.viewer.entities.add({ + id: 'temp_hold_preview', + name: 'HOLD', + position: this.missionPendingHold.center, + point: { pixelSize: 10, color: Cesium.Color.ORANGE, outlineColor: Cesium.Color.WHITE, outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY }, + label: { text: 'HOLD', font: '12px MicroSoft YaHei', fillColor: Cesium.Color.ORANGE, outlineColor: Cesium.Color.BLACK, outlineWidth: 2 } + }); + this.$emit('drawing-points-update', this.drawingPoints.length); + }, + + getMissionDrawingPointsCount() { + return (this.drawingPoints && this.drawingPoints.length) || 0; + }, + + /** 方式一:先画盘旋再画航线。在开始航线绘制后、用户点击第一个点作为盘旋中心,第二个点后自动插入该盘旋 */ + setInitialHoldParams(holdParams) { + this.missionInitialHoldParams = holdParams; + }, + // 格式化平台图标 URL(与 RouteEditDialog / RightPanel 一致) formatPlatformIconUrl(url) { if (!url) return ''; @@ -502,6 +591,11 @@ export default { if (existingArc) { this.viewer.entities.remove(existingArc); } + const holdId = `hold-line-${routeId}-${index}`; + const existingHold = this.viewer.entities.getById(holdId); + if (existingHold) { + this.viewer.entities.remove(existingHold); + } }); const wpStyle = (style && style.waypoint) ? style.waypoint : {}; const lineStyle = (style && style.line) ? style.line : {}; @@ -573,39 +667,75 @@ export default { } }); } - // 绘制连线 + // 绘制连线(含盘旋弧) if (waypoints.length > 1) { - let finalPathPositions = []; - for (let i = 0; i < waypoints.length; i++) { + const finalPathPositions = [originalPositions[0]]; + let lastPos = originalPositions[0]; + for (let i = 1; i < waypoints.length; i++) { const currPos = originalPositions[i]; - const radius = this.getWaypointRadius(waypoints[i]); - // 如果不是首尾点且有半径,我们就画红色的弧线 - if (i > 0 && i < waypoints.length - 1 && radius > 0) { - const prevPos = originalPositions[i - 1]; - const nextPos = originalPositions[i + 1]; - const arcPoints = this.computeArcPositions(prevPos, currPos, nextPos, radius); - // 绘制红色实线弧 - this.viewer.entities.add({ - id: `arc-line-${routeId}-${i}`, - polyline: { - positions: arcPoints, - width: 8, - material: Cesium.Color.RED, - clampToGround: true, - zIndex: 20 - }, - properties: {routeId: routeId} - }); - console.log(`>>> 航点 ${waypoints[i].name} 已渲染红色转弯弧,半径: ${radius}`); - } - if (i === 0 || i === waypoints.length - 1 || radius <= 0) { - finalPathPositions.push(currPos); + const wp = waypoints[i]; + const nextPos = i + 1 < waypoints.length ? originalPositions[i + 1] : null; + if (this.isHoldWaypoint(wp)) { + const params = this.parseHoldParams(wp); + const radius = params && params.radius != null ? params.radius : 500; + const semiMajor = params && (params.semiMajor != null || params.semiMajorAxis != null) ? (params.semiMajor ?? params.semiMajorAxis) : 500; + const semiMinor = params && (params.semiMinor != null || params.semiMinorAxis != null) ? (params.semiMinor ?? params.semiMinorAxis) : 300; + const headingRad = ((params && params.headingDeg != null ? params.headingDeg : 0) * Math.PI) / 180; + const clockwise = params && params.clockwise !== false; + const entry = params && params.radius != null + ? this.getCircleEntryPoint(currPos, lastPos, radius) + : this.getEllipseEntryPoint(currPos, lastPos, semiMajor, semiMinor, headingRad); + const exit = params && params.radius != null + ? this.getCircleTangentExitPoint(currPos, nextPos || currPos, radius, clockwise) + : this.getEllipseTangentExitPoint(currPos, nextPos || currPos, semiMajor, semiMinor, headingRad, clockwise); + finalPathPositions.push(entry); + let arcPoints; + if (params && params.radius != null) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(currPos); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const toEntry = Cesium.Cartesian3.subtract(entry, currPos, new Cesium.Cartesian3()); + const entryAngle = Math.atan2(Cesium.Cartesian3.dot(toEntry, east), Cesium.Cartesian3.dot(toEntry, north)); + arcPoints = this.getCircleFullCircle(currPos, radius, entryAngle, clockwise, 48); + } else { + const enuE = Cesium.Transforms.eastNorthUpToFixedFrame(currPos); + const eastE = Cesium.Matrix4.getColumn(enuE, 0, new Cesium.Cartesian3()); + const northE = Cesium.Matrix4.getColumn(enuE, 1, new Cesium.Cartesian3()); + const toEntryE = Cesium.Cartesian3.subtract(entry, currPos, new Cesium.Cartesian3()); + const thetaE = Math.atan2(Cesium.Cartesian3.dot(toEntryE, eastE), Cesium.Cartesian3.dot(toEntryE, northE)); + const entryLocalAngle = thetaE - headingRad; + arcPoints = this.getEllipseFullCircle(currPos, semiMajor, semiMinor, headingRad, entryLocalAngle, clockwise, 48); + } + for (let k = 1; k < arcPoints.length; k++) finalPathPositions.push(arcPoints[k]); + finalPathPositions.push(exit); + this.viewer.entities.add({ + id: `hold-line-${routeId}-${i}`, + polyline: { positions: [entry, ...arcPoints.slice(1), exit], width: 8, material: Cesium.Color.ORANGE, clampToGround: true, zIndex: 20 }, + properties: { routeId: routeId } + }); + lastPos = exit; } else { - const prevPos = originalPositions[i - 1]; - const nextPos = originalPositions[i + 1]; - // 计算弧线点集 - const arcPoints = this.computeArcPositions(prevPos, currPos, nextPos, radius); - finalPathPositions.push(...arcPoints); + const radius = this.getWaypointRadius(wp); + let nextLogical = nextPos; + if (nextPos && i + 1 < waypoints.length && this.isHoldWaypoint(waypoints[i + 1])) { + const holdParams = this.parseHoldParams(waypoints[i + 1]); + nextLogical = holdParams && holdParams.radius != null + ? 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); + } + if (i < waypoints.length - 1 && radius > 0 && nextLogical) { + const arcPoints = this.computeArcPositions(lastPos, currPos, nextLogical, radius); + this.viewer.entities.add({ + id: `arc-line-${routeId}-${i}`, + polyline: { positions: arcPoints, width: 8, material: Cesium.Color.RED, clampToGround: true, zIndex: 20 }, + properties: { routeId: routeId } + }); + finalPathPositions.push(...arcPoints); + lastPos = arcPoints[arcPoints.length - 1]; + } else { + finalPathPositions.push(currPos); + lastPos = currPos; + } } } const lineWidth = lineStyle.width != null ? lineStyle.width : 4; @@ -648,6 +778,133 @@ export default { return (v_mps * v_mps) / (g * Math.tan(radians)); }, + isHoldWaypoint(wp) { + const t = (wp && wp.pointType) || (wp && wp.point_type) || 'normal'; + return t === 'hold_circle' || t === 'hold_ellipse'; + }, + parseHoldParams(wp) { + const raw = (wp && wp.holdParams) || (wp && wp.hold_params); + if (!raw) return null; + try { + const p = typeof raw === 'string' ? JSON.parse(raw) : raw; + return { + radius: p.radius, + semiMajor: p.semiMajor ?? p.semiMajorAxis, + semiMinor: p.semiMinor ?? p.semiMinorAxis, + headingDeg: p.headingDeg ?? 0, + clockwise: p.clockwise !== false + }; + } catch (e) { + return null; + } + }, + + /** 圆上从 entry 到 exit 的弧段(按顺时针/逆时针),采样点数 */ + getCircleArcEntryToExit(centerCartesian, radiusMeters, entryCartesian, exitCartesian, clockwise, numPoints) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const toEntry = Cesium.Cartesian3.subtract(entryCartesian, centerCartesian, new Cesium.Cartesian3()); + const toExit = Cesium.Cartesian3.subtract(exitCartesian, centerCartesian, new Cesium.Cartesian3()); + let entryAngle = Math.atan2(Cesium.Cartesian3.dot(toEntry, east), Cesium.Cartesian3.dot(toEntry, north)); + let exitAngle = Math.atan2(Cesium.Cartesian3.dot(toExit, east), Cesium.Cartesian3.dot(toExit, north)); + let diff = exitAngle - entryAngle; + const sign = clockwise ? -1 : 1; + if (sign * diff <= 0) diff += sign * 2 * Math.PI; + const points = []; + for (let i = 0; i <= numPoints; i++) { + const t = i / numPoints; + const angle = entryAngle + sign * t * Math.abs(diff); + const offset = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(angle) * radiusMeters, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(angle) * radiusMeters, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + points.push(Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3())); + } + return points; + }, + + /** 圆上整圈(360°)采样,从 startAngleRad 起按顺时针/逆时针,用于盘旋段渲染为整圆 */ + getCircleFullCircle(centerCartesian, radiusMeters, startAngleRad, clockwise, numPoints) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const sign = clockwise ? -1 : 1; + const points = []; + for (let i = 0; i <= numPoints; i++) { + const t = i / numPoints; + const angle = startAngleRad + sign * t * 2 * Math.PI; + const offset = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(angle) * radiusMeters, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(angle) * radiusMeters, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + points.push(Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3())); + } + return points; + }, + + /** 椭圆上从 entry 到 exit 的弧段(按顺时针/逆时针) */ + getEllipseArcEntryToExit(centerCartesian, semiMajorM, semiMinorM, headingRad, entryCartesian, exitCartesian, clockwise, numPoints) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const toLocalAngle = (cart) => { + const toP = Cesium.Cartesian3.subtract(cart, centerCartesian, new Cesium.Cartesian3()); + const e = Cesium.Cartesian3.dot(toP, east); + const n = Cesium.Cartesian3.dot(toP, north); + const theta = Math.atan2(e, n); + const local = theta - headingRad; + return local; + }; + let entryT = toLocalAngle(entryCartesian); + let exitT = toLocalAngle(exitCartesian); + let diff = exitT - entryT; + const sign = clockwise ? -1 : 1; + if (sign * diff <= 0) diff += sign * 2 * Math.PI; + const c = Math.cos(headingRad); + const s = Math.sin(headingRad); + const points = []; + for (let i = 0; i <= numPoints; i++) { + const t = i / numPoints; + const angle = entryT + sign * t * Math.abs(diff); + const localE = semiMajorM * Math.cos(angle) * c - semiMinorM * Math.sin(angle) * s; + const localN = semiMajorM * Math.cos(angle) * s + semiMinorM * Math.sin(angle) * c; + const offset = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, localN, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, localE, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + points.push(Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3())); + } + return points; + }, + + /** 椭圆上整圈(360°)采样,从 startLocalAngle 起按顺时针/逆时针,用于盘旋段渲染为整椭圆 */ + getEllipseFullCircle(centerCartesian, semiMajorM, semiMinorM, headingRad, startLocalAngle, clockwise, numPoints) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const sign = clockwise ? -1 : 1; + const c = Math.cos(headingRad); + const s = Math.sin(headingRad); + const points = []; + for (let i = 0; i <= numPoints; i++) { + const t = i / numPoints; + const angle = startLocalAngle + sign * t * 2 * Math.PI; + const localE = semiMajorM * Math.cos(angle) * c - semiMinorM * Math.sin(angle) * s; + const localN = semiMajorM * Math.cos(angle) * s + semiMinorM * Math.sin(angle) * c; + const offset = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, localN, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, localE, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + points.push(Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3())); + } + return points; + }, + // 计算转弯处的圆弧点集 computeArcPositions(p1, p2, p3, radius) { const v1 = Cesium.Cartesian3.subtract(p1, p2, new Cesium.Cartesian3()); @@ -676,13 +933,142 @@ export default { return arc; }, + /** 圆:中心( Cartesian3 )、半径(米)、顺时针、采样数 → 世界坐标点数组(从进入点沿圆到出口点需外部指定起止角或由 entry/exit 截取) */ + computeCirclePositions(centerCartesian, radiusMeters, clockwise, numPoints) { + if (!this.viewer || !centerCartesian || radiusMeters <= 0) return []; + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const points = []; + const sign = clockwise ? -1 : 1; + for (let i = 0; i <= numPoints; i++) { + const t = i / numPoints; + const angle = sign * t * 2 * Math.PI; + const offset = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(angle) * radiusMeters, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(angle) * radiusMeters, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + points.push(Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3())); + } + return points; + }, + + /** 椭圆:中心、半长轴/半短轴(米)、长轴方位(弧度)、顺时针、采样数 → 世界坐标点数组 */ + computeEllipsePositions(centerCartesian, semiMajorM, semiMinorM, headingRad, clockwise, numPoints) { + if (!this.viewer || !centerCartesian || semiMajorM <= 0 || semiMinorM <= 0) return []; + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const c = Math.cos(headingRad); + const s = Math.sin(headingRad); + const points = []; + const sign = clockwise ? -1 : 1; + for (let i = 0; i <= numPoints; i++) { + const t = i / numPoints; + const angle = sign * t * 2 * Math.PI; + const localE = semiMajorM * Math.cos(angle) * c - semiMinorM * Math.sin(angle) * s; + const localN = semiMajorM * Math.cos(angle) * s + semiMinorM * Math.sin(angle) * c; + const offset = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, localN, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, localE, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + points.push(Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3())); + } + return points; + }, + + /** 圆上进入点:从 prev 飞向 center 时与圆的交点(靠近 prev 的那侧) */ + getCircleEntryPoint(centerCartesian, prevPointCartesian, radiusMeters) { + const toCenter = Cesium.Cartesian3.subtract(centerCartesian, prevPointCartesian, new Cesium.Cartesian3()); + const dist = Cesium.Cartesian3.magnitude(toCenter); + if (dist < 1e-6) return centerCartesian; + if (radiusMeters >= dist) return Cesium.Cartesian3.clone(prevPointCartesian); + const unit = Cesium.Cartesian3.normalize(toCenter, new Cesium.Cartesian3()); + return Cesium.Cartesian3.add(prevPointCartesian, Cesium.Cartesian3.multiplyByScalar(unit, dist - radiusMeters, new Cesium.Cartesian3()), new Cesium.Cartesian3()); + }, + + /** 圆上切线出口点:从圆飞往 next 时在圆上的切点(选顺时针/逆时针中朝向 next 的那一侧) */ + getCircleTangentExitPoint(centerCartesian, nextPointCartesian, radiusMeters, clockwise) { + const toNext = Cesium.Cartesian3.subtract(nextPointCartesian, centerCartesian, new Cesium.Cartesian3()); + const d = Cesium.Cartesian3.magnitude(toNext); + if (d < 1e-6) return centerCartesian; + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const e = Cesium.Cartesian3.dot(toNext, east); + const n = Cesium.Cartesian3.dot(toNext, north); + const theta = Math.atan2(e, n); + const alpha = Math.acos(Math.min(1, radiusMeters / d)); + const sign = clockwise ? 1 : -1; + const exitAngle = theta + sign * alpha; + const offset = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(exitAngle) * radiusMeters, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(exitAngle) * radiusMeters, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3()); + }, + + /** 椭圆上进入点:从 prev 指向 center 的方向与椭圆边的交点(靠近 prev 的一侧) */ + getEllipseEntryPoint(centerCartesian, prevPointCartesian, semiMajorM, semiMinorM, headingRad) { + const toPrev = Cesium.Cartesian3.subtract(prevPointCartesian, centerCartesian, new Cesium.Cartesian3()); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const de = Cesium.Cartesian3.dot(toPrev, east); + const dn = Cesium.Cartesian3.dot(toPrev, north); + const c = Math.cos(-headingRad); + const s = Math.sin(-headingRad); + const dx = de * c - dn * s; + const dy = de * s + dn * c; + const n = Math.sqrt((dx * dx) / (semiMajorM * semiMajorM) + (dy * dy) / (semiMinorM * semiMinorM)); + if (n < 1e-6) return centerCartesian; + const k = 1 / n; + const lx = k * dx; + const ly = k * dy; + const backE = lx * Math.cos(headingRad) - ly * Math.sin(headingRad); + const backN = lx * Math.sin(headingRad) + ly * Math.cos(headingRad); + const offset = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, backN, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, backE, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3()); + }, + + /** 椭圆上“出口点”:沿长轴方向近似为下一航点方向的切点(取椭圆上最靠近 next 的点再沿法向微调为切向出口) */ + getEllipseTangentExitPoint(centerCartesian, nextPointCartesian, semiMajorM, semiMinorM, headingRad, clockwise) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(centerCartesian); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const toNext = Cesium.Cartesian3.subtract(nextPointCartesian, centerCartesian, new Cesium.Cartesian3()); + const e = Cesium.Cartesian3.dot(toNext, east); + const n = Cesium.Cartesian3.dot(toNext, north); + const theta = Math.atan2(e, n); + const localAngle = theta - headingRad; + const cosA = Math.cos(localAngle); + const sinA = Math.sin(localAngle); + const r = (semiMajorM * semiMinorM) / Math.sqrt((semiMinorM * cosA) ** 2 + (semiMajorM * sinA) ** 2); + const sign = clockwise ? -1 : 1; + const exitLocal = sign * Math.atan2(semiMinorM * sinA, semiMajorM * cosA); + const localE = semiMajorM * Math.cos(exitLocal) * Math.cos(headingRad) - semiMinorM * Math.sin(exitLocal) * Math.sin(headingRad); + const localN = semiMajorM * Math.cos(exitLocal) * Math.sin(headingRad) + semiMinorM * Math.sin(exitLocal) * Math.cos(headingRad); + const offset = Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, localN, new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, localE, new Cesium.Cartesian3()), + new Cesium.Cartesian3() + ); + return Cesium.Cartesian3.add(centerCartesian, offset, new Cesium.Cartesian3()); + }, + /** - * 获取与地图绘制一致的带转弯弧的路径(用于推演时图标沿弧线运动)。 - * @param {Array} waypoints - 航点列表,需含 lng, lat, alt, speed, turnAngle - * @returns {{ path: Array<{lng,lat,alt}>, segmentEndIndices: number[] }} path 为路径点;segmentEndIndices[i] 为第 i 段(航点 i -> i+1)在 path 中的结束下标 + * 获取与地图绘制一致的带转弯弧与盘旋弧的路径(用于推演时图标沿弧线运动)。 + * @returns {{ path, segmentEndIndices, holdArcRanges: { [legIndex]: { start, end } } }} */ getRoutePathWithSegmentIndices(waypoints) { - if (!waypoints || waypoints.length === 0) return { path: [], segmentEndIndices: [] }; + if (!waypoints || waypoints.length === 0) return { path: [], segmentEndIndices: [], holdArcRanges: {} }; const ellipsoid = this.viewer.scene.globe.ellipsoid; const toLngLatAlt = (cartesian) => { const carto = Cesium.Cartographic.fromCartesian(cartesian, ellipsoid); @@ -695,22 +1081,72 @@ export default { const originalPositions = waypoints.map(wp => Cesium.Cartesian3.fromDegrees(parseFloat(wp.lng), parseFloat(wp.lat), Number(wp.alt) || 0) ); - const path = []; + const path = [toLngLatAlt(originalPositions[0])]; const segmentEndIndices = []; - for (let i = 0; i < waypoints.length; i++) { + const holdArcRanges = {}; + let lastPos = originalPositions[0]; + for (let i = 1; i < waypoints.length; i++) { const currPos = originalPositions[i]; - const radius = this.getWaypointRadius(waypoints[i]); - if (i === 0 || i === waypoints.length - 1 || radius <= 0) { - path.push(toLngLatAlt(currPos)); + const wp = waypoints[i]; + const nextPos = i + 1 < waypoints.length ? originalPositions[i + 1] : null; + if (this.isHoldWaypoint(wp)) { + const params = this.parseHoldParams(wp); + const radius = params && params.radius != null ? params.radius : 500; + const semiMajor = params && (params.semiMajor != null || params.semiMajorAxis != null) ? (params.semiMajor ?? params.semiMajorAxis) : 500; + const semiMinor = params && (params.semiMinor != null || params.semiMinorAxis != null) ? (params.semiMinor ?? params.semiMinorAxis) : 300; + const headingRad = ((params && params.headingDeg != null ? params.headingDeg : 0) * Math.PI) / 180; + const clockwise = params && params.clockwise !== false; + const entry = params && params.radius != null + ? this.getCircleEntryPoint(currPos, lastPos, radius) + : this.getEllipseEntryPoint(currPos, lastPos, semiMajor, semiMinor, headingRad); + const exit = params && params.radius != null + ? this.getCircleTangentExitPoint(currPos, nextPos || currPos, radius, clockwise) + : this.getEllipseTangentExitPoint(currPos, nextPos || currPos, semiMajor, semiMinor, headingRad, clockwise); + path.push(toLngLatAlt(entry)); + const arcStartIdx = path.length - 1; + let arcPoints; + if (params && params.radius != null) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(currPos); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const toEntry = Cesium.Cartesian3.subtract(entry, currPos, new Cesium.Cartesian3()); + const entryAngle = Math.atan2(Cesium.Cartesian3.dot(toEntry, east), Cesium.Cartesian3.dot(toEntry, north)); + arcPoints = this.getCircleFullCircle(currPos, radius, entryAngle, clockwise, 48); + } else { + const enuE = Cesium.Transforms.eastNorthUpToFixedFrame(currPos); + const eastE = Cesium.Matrix4.getColumn(enuE, 0, new Cesium.Cartesian3()); + const northE = Cesium.Matrix4.getColumn(enuE, 1, new Cesium.Cartesian3()); + const toEntryE = Cesium.Cartesian3.subtract(entry, currPos, new Cesium.Cartesian3()); + const thetaE = Math.atan2(Cesium.Cartesian3.dot(toEntryE, eastE), Cesium.Cartesian3.dot(toEntryE, northE)); + const entryLocalAngle = thetaE - headingRad; + arcPoints = this.getEllipseFullCircle(currPos, semiMajor, semiMinor, headingRad, entryLocalAngle, clockwise, 48); + } + for (let k = 1; k < arcPoints.length; k++) path.push(toLngLatAlt(arcPoints[k])); + path.push(toLngLatAlt(exit)); + holdArcRanges[i - 1] = { start: arcStartIdx, end: path.length - 1 }; + segmentEndIndices[i - 1] = path.length - 1; + lastPos = exit; } else { - const prevPos = originalPositions[i - 1]; - const nextPos = originalPositions[i + 1]; - const arcPoints = this.computeArcPositions(prevPos, currPos, nextPos, radius); - arcPoints.forEach(p => path.push(toLngLatAlt(p))); + let nextLogical = nextPos; + if (nextPos && i + 1 < waypoints.length && this.isHoldWaypoint(waypoints[i + 1])) { + const holdParams = this.parseHoldParams(waypoints[i + 1]); + nextLogical = holdParams && holdParams.radius != null + ? 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); + if (i < waypoints.length - 1 && radius > 0 && nextLogical) { + const arcPoints = this.computeArcPositions(lastPos, currPos, nextLogical, radius); + arcPoints.forEach(p => path.push(toLngLatAlt(p))); + lastPos = arcPoints[arcPoints.length - 1]; + } else { + path.push(toLngLatAlt(currPos)); + lastPos = currPos; + } + segmentEndIndices[i - 1] = path.length - 1; } - if (i >= 1) segmentEndIndices[i - 1] = path.length - 1; } - return { path, segmentEndIndices }; + return { path, segmentEndIndices, holdArcRanges }; }, removeRouteById(routeId) { @@ -1133,6 +1569,15 @@ export default { case 'circle': this.startCircleDrawing() break + case 'ellipse': + this.startEllipseDrawing() + break + case 'hold_circle': + this.startHoldCircleDrawing() + break + case 'hold_ellipse': + this.startHoldEllipseDrawing() + break case 'sector': this.startSectorDrawing() break @@ -1163,11 +1608,19 @@ export default { const entity = allEntities[i]; if (entity.id && ( entity.id.toString().startsWith('temp_wp_') || - entity.id.toString().includes('temp-preview') + entity.id.toString().includes('temp-preview') || + entity.id === 'temp_hold_preview' )) { this.viewer.entities.remove(entity); } } + if (this.tempHoldEntity) { + try { this.viewer.entities.remove(this.tempHoldEntity); } catch (e) {} + this.tempHoldEntity = null; + } + this.missionPendingHold = null; + this.missionInitialHoldParams = null; + this.missionHoldParamsByIndex = {}; //清理已知的预览实体 if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); @@ -1773,6 +2226,365 @@ export default { this.drawingPoints = []; this.activeCursorPosition = null; }, + // 绘制椭圆:三点(圆心、长轴端点、短轴端点) + startEllipseDrawing() { + this.drawingPoints = []; + this.activeCursorPosition = null; + if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } + if (this.tempPreviewEntity) { this.viewer.entities.remove(this.tempPreviewEntity); this.tempPreviewEntity = null; } + this.drawingHandler.removeInputAction(Cesium.ScreenSpaceEventType.MOUSE_MOVE); + this.drawingHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK); + this.drawingHandler.removeInputAction(Cesium.ScreenSpaceEventType.RIGHT_CLICK); + this.drawingHandler.setInputAction((movement) => { + const newPosition = this.getClickPosition(movement.endPosition); + if (newPosition) this.activeCursorPosition = newPosition; + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + this.drawingHandler.setInputAction((click) => { + const position = this.getClickPosition(click.position); + if (!position) return; + this.drawingPoints.push(position); + if (this.drawingPoints.length === 1) { + this.activeCursorPosition = position; + this.tempEntity = this.viewer.entities.add({ + position: position, + ellipse: { + semiMajorAxis: new Cesium.CallbackProperty(() => { + let a = 100; + let b = 50; + if (this.drawingPoints.length >= 2 && this.drawingPoints[0] && this.drawingPoints[1]) { + a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.drawingPoints[1]), 50); + } else if (this.activeCursorPosition && this.drawingPoints.length === 1) { + a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.activeCursorPosition), 50); + } + if (this.drawingPoints.length >= 3 && this.drawingPoints[0] && this.drawingPoints[1] && this.drawingPoints[2]) { + b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.drawingPoints[2]), 20); + } else if (this.drawingPoints.length >= 2 && this.activeCursorPosition) { + b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.activeCursorPosition), 20); + } + return Math.max(a, b); + }, false), + semiMinorAxis: new Cesium.CallbackProperty(() => { + let a = 100; + let b = 50; + if (this.drawingPoints.length >= 2 && this.drawingPoints[0] && this.drawingPoints[1]) { + a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.drawingPoints[1]), 50); + } else if (this.activeCursorPosition && this.drawingPoints.length === 1) { + a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.activeCursorPosition), 50); + } + if (this.drawingPoints.length >= 3 && this.drawingPoints[0] && this.drawingPoints[1] && this.drawingPoints[2]) { + b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.drawingPoints[2]), 20); + } else if (this.drawingPoints.length >= 2 && this.activeCursorPosition) { + b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.activeCursorPosition), 20); + } + return Math.min(a, b); + }, false), + rotation: new Cesium.CallbackProperty(() => { + if (this.drawingPoints.length >= 2 && this.drawingPoints[0] && this.drawingPoints[1]) { + let heading = this.ellipseHeadingFromCenterAndMajor(this.drawingPoints[0], this.drawingPoints[1]); + let a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.drawingPoints[1]), 50); + let b = 50; + if (this.drawingPoints.length >= 3 && this.drawingPoints[2]) { + b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.drawingPoints[2]), 20); + } else if (this.activeCursorPosition) { + b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.activeCursorPosition), 20); + } + if (b > a) heading += Math.PI / 2; + return heading; + } + return 0; + }, false), + material: Cesium.Color.fromCssColorString(this.defaultStyles.ellipse.color).withAlpha(0.5), + outline: true, + outlineColor: Cesium.Color.fromCssColorString(this.defaultStyles.ellipse.color), + outlineWidth: 2 + } + }); + } else if (this.drawingPoints.length === 2) { + this.activeCursorPosition = this.drawingPoints[1]; + } else if (this.drawingPoints.length === 3) { + this.activeCursorPosition = null; + if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } + this.finishEllipseDrawing(); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + this.drawingHandler.setInputAction(() => this.cancelDrawing(), Cesium.ScreenSpaceEventType.RIGHT_CLICK); + }, + ellipseHeadingFromCenterAndMajor(center, majorEnd) { + const ellipsoid = this.viewer.scene.globe.ellipsoid; + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(center); + const toMajor = Cesium.Cartesian3.subtract(majorEnd, center, new Cesium.Cartesian3()); + const e = Cesium.Cartesian3.dot(toMajor, Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3())); + const n = Cesium.Cartesian3.dot(toMajor, Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3())); + return Math.atan2(e, n); + }, + ellipseSemiMinorFromThreePoints(center, majorEnd, thirdPoint) { + const ellipsoid = this.viewer.scene.globe.ellipsoid; + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(center); + const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3()); + const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3()); + const toMajor = Cesium.Cartesian3.subtract(majorEnd, center, new Cesium.Cartesian3()); + const heading = Math.atan2(Cesium.Cartesian3.dot(toMajor, east), Cesium.Cartesian3.dot(toMajor, north)); + const toThird = Cesium.Cartesian3.subtract(thirdPoint, center, new Cesium.Cartesian3()); + const e3 = Cesium.Cartesian3.dot(toThird, east); + const n3 = Cesium.Cartesian3.dot(toThird, north); + const minorComponent = -e3 * Math.sin(heading) + n3 * Math.cos(heading); + return Math.abs(minorComponent); + }, + finishEllipseDrawing() { + const center = this.drawingPoints[0]; + const majorEnd = this.drawingPoints[1]; + const third = this.drawingPoints[2]; + let a = Math.max(Cesium.Cartesian3.distance(center, majorEnd), 50); + let b = Math.max(this.ellipseSemiMinorFromThreePoints(center, majorEnd, third), 20); + let heading = this.ellipseHeadingFromCenterAndMajor(center, majorEnd); + if (b > a) { + [a, b] = [b, a]; + heading += Math.PI / 2; + } + const semiMajor = a; + const semiMinor = b; + this.entityCounter++; + const id = `ellipse_${this.entityCounter}`; + const entity = this.viewer.entities.add({ + id, + name: `椭圆 ${this.entityCounter}`, + position: center, + ellipse: { + semiMajorAxis: semiMajor, + semiMinorAxis: semiMinor, + rotation: heading, + material: Cesium.Color.fromCssColorString(this.defaultStyles.ellipse.color).withAlpha(this.defaultStyles.ellipse.opacity), + outline: true, + outlineColor: Cesium.Color.fromCssColorString(this.defaultStyles.ellipse.color), + outlineWidth: this.defaultStyles.ellipse.width + } + }); + const entityData = { + id, + type: 'ellipse', + points: this.drawingPoints.map(p => this.cartesianToLatLng(p)), + positions: [...this.drawingPoints], + entity, + semiMajorAxis: semiMajor, + semiMinorAxis: semiMinor, + headingDeg: (heading * 180 / Math.PI), + color: this.defaultStyles.ellipse.color, + opacity: this.defaultStyles.ellipse.opacity, + width: this.defaultStyles.ellipse.width, + label: `椭圆 ${this.entityCounter}` + }; + this.allEntities.push(entityData); + this.measurementResult = { type: 'ellipse', semiMajorAxis: semiMajor, semiMinorAxis: semiMinor, area: Math.PI * semiMajor * semiMinor }; + this.drawingPoints = []; + this.activeCursorPosition = null; + }, + // 圆形盘旋:仅边框、不填充,交互与圆一致(圆心 + 边缘点) + startHoldCircleDrawing() { + this.drawingPoints = []; + this.activeCursorPosition = null; + if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } + if (this.tempPreviewEntity) { this.viewer.entities.remove(this.tempPreviewEntity); this.tempPreviewEntity = null; } + const initialRadius = 100; + this.drawingHandler.setInputAction((movement) => { + const newPosition = this.getClickPosition(movement.endPosition); + if (newPosition) this.activeCursorPosition = newPosition; + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + this.drawingHandler.setInputAction((click) => { + const position = this.getClickPosition(click.position); + if (!position) return; + if (this.drawingPoints.length === 0) { + this.drawingPoints.push(position); + this.activeCursorPosition = position; + this.tempEntity = this.viewer.entities.add({ + position: position, + ellipse: { + semiMajorAxis: new Cesium.CallbackProperty(() => { + if (this.activeCursorPosition && this.drawingPoints[0]) { + const d = Cesium.Cartesian3.distance(this.drawingPoints[0], this.activeCursorPosition); + return isFinite(d) && d > 0 ? d : initialRadius; + } + return initialRadius; + }, false), + semiMinorAxis: new Cesium.CallbackProperty(() => { + if (this.activeCursorPosition && this.drawingPoints[0]) { + const d = Cesium.Cartesian3.distance(this.drawingPoints[0], this.activeCursorPosition); + return isFinite(d) && d > 0 ? d : initialRadius; + } + return initialRadius; + }, false), + material: Cesium.Color.TRANSPARENT, + outline: true, + outlineColor: Cesium.Color.fromCssColorString(this.defaultStyles.hold_circle.color), + outlineWidth: this.defaultStyles.hold_circle.width || 2 + } + }); + } else if (this.drawingPoints.length === 1) { + this.drawingPoints.push(position); + this.activeCursorPosition = null; + this.finishHoldCircleDrawing(position); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + this.drawingHandler.setInputAction(() => this.cancelDrawing(), Cesium.ScreenSpaceEventType.RIGHT_CLICK); + }, + finishHoldCircleDrawing(edgePosition) { + const centerPoint = this.drawingPoints[0]; + const radius = Cesium.Cartesian3.distance(centerPoint, edgePosition); + if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } + this.entityCounter++; + const id = `hold_circle_${this.entityCounter}`; + const finalEntity = this.viewer.entities.add({ + id, + position: centerPoint, + ellipse: { + semiMajorAxis: radius, + semiMinorAxis: radius, + material: Cesium.Color.TRANSPARENT, + outline: true, + outlineColor: Cesium.Color.fromCssColorString(this.defaultStyles.hold_circle.color), + outlineWidth: this.defaultStyles.hold_circle.width || 2 + } + }); + const entityData = { + id, + type: 'hold_circle', + points: [centerPoint, edgePosition].map(p => this.cartesianToLatLng(p)), + positions: [centerPoint, edgePosition], + entity: finalEntity, + color: this.defaultStyles.hold_circle.color, + width: this.defaultStyles.hold_circle.width || 2, + radius, + label: `圆形盘旋 ${this.entityCounter}` + }; + this.allEntities.push(entityData); + this.measurementResult = { type: 'hold_circle', radius, area: Math.PI * radius * radius }; + this.drawingPoints = []; + this.activeCursorPosition = null; + }, + // 椭圆盘旋:仅边框、不填充,交互与椭圆一致(圆心、长轴端点、短轴端点) + startHoldEllipseDrawing() { + this.drawingPoints = []; + this.activeCursorPosition = null; + if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } + if (this.tempPreviewEntity) { this.viewer.entities.remove(this.tempPreviewEntity); this.tempPreviewEntity = null; } + this.drawingHandler.setInputAction((movement) => { + const newPosition = this.getClickPosition(movement.endPosition); + if (newPosition) this.activeCursorPosition = newPosition; + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + this.drawingHandler.setInputAction((click) => { + const position = this.getClickPosition(click.position); + if (!position) return; + this.drawingPoints.push(position); + if (this.drawingPoints.length === 1) { + this.activeCursorPosition = position; + this.tempEntity = this.viewer.entities.add({ + position: position, + ellipse: { + semiMajorAxis: new Cesium.CallbackProperty(() => { + let a = 100, b = 50; + if (this.drawingPoints.length >= 2 && this.drawingPoints[0] && this.drawingPoints[1]) { + a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.drawingPoints[1]), 50); + } else if (this.activeCursorPosition && this.drawingPoints.length === 1) { + a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.activeCursorPosition), 50); + } + if (this.drawingPoints.length >= 3 && this.drawingPoints[0] && this.drawingPoints[1] && this.drawingPoints[2]) { + b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.drawingPoints[2]), 20); + } else if (this.drawingPoints.length >= 2 && this.activeCursorPosition) { + b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.activeCursorPosition), 20); + } + return Math.max(a, b); + }, false), + semiMinorAxis: new Cesium.CallbackProperty(() => { + let a = 100, b = 50; + if (this.drawingPoints.length >= 2 && this.drawingPoints[0] && this.drawingPoints[1]) { + a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.drawingPoints[1]), 50); + } else if (this.activeCursorPosition && this.drawingPoints.length === 1) { + a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.activeCursorPosition), 50); + } + if (this.drawingPoints.length >= 3 && this.drawingPoints[0] && this.drawingPoints[1] && this.drawingPoints[2]) { + b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.drawingPoints[2]), 20); + } else if (this.drawingPoints.length >= 2 && this.activeCursorPosition) { + b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.activeCursorPosition), 20); + } + return Math.min(a, b); + }, false), + rotation: new Cesium.CallbackProperty(() => { + if (this.drawingPoints.length >= 2 && this.drawingPoints[0] && this.drawingPoints[1]) { + let heading = this.ellipseHeadingFromCenterAndMajor(this.drawingPoints[0], this.drawingPoints[1]); + let a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.drawingPoints[1]), 50); + let b = 50; + if (this.drawingPoints.length >= 3 && this.drawingPoints[2]) { + b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.drawingPoints[2]), 20); + } else if (this.activeCursorPosition) { + b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.activeCursorPosition), 20); + } + if (b > a) heading += Math.PI / 2; + return heading; + } + return 0; + }, false), + material: Cesium.Color.TRANSPARENT, + outline: true, + outlineColor: Cesium.Color.fromCssColorString(this.defaultStyles.hold_ellipse.color), + outlineWidth: this.defaultStyles.hold_ellipse.width || 2 + } + }); + } else if (this.drawingPoints.length === 2) { + this.activeCursorPosition = this.drawingPoints[1]; + } else if (this.drawingPoints.length === 3) { + this.activeCursorPosition = null; + if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } + this.finishHoldEllipseDrawing(); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + this.drawingHandler.setInputAction(() => this.cancelDrawing(), Cesium.ScreenSpaceEventType.RIGHT_CLICK); + }, + finishHoldEllipseDrawing() { + const center = this.drawingPoints[0]; + const majorEnd = this.drawingPoints[1]; + const third = this.drawingPoints[2]; + let a = Math.max(Cesium.Cartesian3.distance(center, majorEnd), 50); + let b = Math.max(this.ellipseSemiMinorFromThreePoints(center, majorEnd, third), 20); + let heading = this.ellipseHeadingFromCenterAndMajor(center, majorEnd); + if (b > a) { + [a, b] = [b, a]; + heading += Math.PI / 2; + } + const semiMajor = a; + const semiMinor = b; + this.entityCounter++; + const id = `hold_ellipse_${this.entityCounter}`; + const entity = this.viewer.entities.add({ + id, + name: `椭圆盘旋 ${this.entityCounter}`, + position: center, + ellipse: { + semiMajorAxis: semiMajor, + semiMinorAxis: semiMinor, + rotation: heading, + material: Cesium.Color.TRANSPARENT, + outline: true, + outlineColor: Cesium.Color.fromCssColorString(this.defaultStyles.hold_ellipse.color), + outlineWidth: this.defaultStyles.hold_ellipse.width || 2 + } + }); + const entityData = { + id, + type: 'hold_ellipse', + points: this.drawingPoints.map(p => this.cartesianToLatLng(p)), + positions: [...this.drawingPoints], + entity, + semiMajorAxis: semiMajor, + semiMinorAxis: semiMinor, + headingDeg: (heading * 180 / Math.PI), + color: this.defaultStyles.hold_ellipse.color, + width: this.defaultStyles.hold_ellipse.width || 2, + label: `椭圆盘旋 ${this.entityCounter}` + }; + this.allEntities.push(entityData); + this.measurementResult = { type: 'hold_ellipse', semiMajorAxis: semiMajor, semiMinorAxis: semiMinor, area: Math.PI * semiMajor * semiMinor }; + this.drawingPoints = []; + this.activeCursorPosition = null; + }, // 绘制扇形 startSectorDrawing() { // 重置绘制状态 @@ -2658,6 +3470,21 @@ export default { entity.ellipse.outlineWidth = data.width } break + case 'ellipse': + if (entity.ellipse) { + entity.ellipse.material = Cesium.Color.fromCssColorString(data.color).withAlpha(data.opacity) + entity.ellipse.outlineColor = Cesium.Color.fromCssColorString(data.color) + entity.ellipse.outlineWidth = data.width + } + break + case 'hold_circle': + case 'hold_ellipse': + if (entity.ellipse) { + entity.ellipse.material = Cesium.Color.TRANSPARENT + entity.ellipse.outlineColor = Cesium.Color.fromCssColorString(data.color) + entity.ellipse.outlineWidth = data.width || 2 + } + break case 'sector': if (entity.polygon) { entity.polygon.material = Cesium.Color.fromCssColorString(data.color).withAlpha(data.opacity) @@ -2802,6 +3629,9 @@ export default { polygon: '面', rectangle: '矩形', circle: '圆形', + ellipse: '椭圆', + hold_circle: '圆形盘旋', + hold_ellipse: '椭圆盘旋', sector: '扇形', arrow: '箭头', text: '文本', @@ -2825,6 +3655,14 @@ export default { points: entity.points } : entity.type === 'rectangle' ? { coordinates: entity.coordinates + } : entity.type === 'ellipse' || entity.type === 'hold_ellipse' ? { + center: entity.points && entity.points[0] ? entity.points[0] : (entity.positions && entity.positions[0] ? this.cartesianToLatLng(entity.positions[0]) : null), + semiMajorAxis: entity.semiMajorAxis, + semiMinorAxis: entity.semiMinorAxis, + headingDeg: entity.headingDeg + } : (entity.type === 'circle' || entity.type === 'hold_circle') ? { + center: entity.points && entity.points[0] ? entity.points[0] : (entity.positions && entity.positions[0] ? this.cartesianToLatLng(entity.positions[0]) : null), + radius: entity.radius } : { center: entity.center, radius: entity.radius @@ -2985,6 +3823,70 @@ export default { } }) break + case 'hold_circle': { + const hcRadius = entityData.data.radius || 1000 + if (hcRadius <= 0) { + this.$message.error('圆形盘旋半径必须大于0') + return + } + const hcCenter = entityData.data.center + entity = this.viewer.entities.add({ + position: Cesium.Cartesian3.fromDegrees(hcCenter.lng, hcCenter.lat), + ellipse: { + semiMinorAxis: hcRadius, + semiMajorAxis: hcRadius, + material: Cesium.Color.TRANSPARENT, + outline: true, + outlineColor: Cesium.Color.fromCssColorString(color), + outlineWidth: entityData.data.width != null ? entityData.data.width : 2 + }, + label: { + text: entityData.label || '圆形盘旋', + font: '14px sans-serif', + fillColor: Cesium.Color.WHITE, + outlineColor: Cesium.Color.BLACK, + outlineWidth: 2, + style: Cesium.LabelStyle.FILL_AND_OUTLINE, + verticalOrigin: Cesium.VerticalOrigin.BOTTOM, + pixelOffset: new Cesium.Cartesian2(0, -10) + } + }) + break + } + case 'hold_ellipse': { + const heCenter = entityData.data.center + if (!heCenter) { + this.$message.error('椭圆盘旋缺少中心点') + return + } + let heA = entityData.data.semiMajorAxis || 1000 + let heB = entityData.data.semiMinorAxis || 500 + if (heB > heA) { [heA, heB] = [heB, heA] } + const heHeading = entityData.data.headingDeg != null ? (entityData.data.headingDeg * Math.PI / 180) : 0 + entity = this.viewer.entities.add({ + position: Cesium.Cartesian3.fromDegrees(heCenter.lng, heCenter.lat), + ellipse: { + semiMajorAxis: heA, + semiMinorAxis: heB, + rotation: heHeading, + material: Cesium.Color.TRANSPARENT, + outline: true, + outlineColor: Cesium.Color.fromCssColorString(color), + outlineWidth: entityData.data.width != null ? entityData.data.width : 2 + }, + label: { + text: entityData.label || '椭圆盘旋', + font: '14px sans-serif', + fillColor: Cesium.Color.WHITE, + outlineColor: Cesium.Color.BLACK, + outlineWidth: 2, + style: Cesium.LabelStyle.FILL_AND_OUTLINE, + verticalOrigin: Cesium.VerticalOrigin.BOTTOM, + pixelOffset: new Cesium.Cartesian2(0, -10) + } + }) + break + } } if (entity) { this.allEntities.push({ diff --git a/ruoyi-ui/src/views/childRoom/RightPanel.vue b/ruoyi-ui/src/views/childRoom/RightPanel.vue index 3187dc1..1f3a46b 100644 --- a/ruoyi-ui/src/views/childRoom/RightPanel.vue +++ b/ruoyi-ui/src/views/childRoom/RightPanel.vue @@ -42,6 +42,7 @@
+
@@ -452,6 +453,10 @@ export default { this.$emit('create-route', plan) }, + handleCreateRouteWithHold(plan) { + this.$emit('create-route-with-hold', plan) + }, + handleDeletePlan(plan) { this.$confirm(`是否确认删除方案 "${plan.name}"?这将同时删除该方案下的所有航线。`, "警告", { confirmButtonText: "确定", diff --git a/ruoyi-ui/src/views/childRoom/index.vue b/ruoyi-ui/src/views/childRoom/index.vue index 9163d51..ae32850 100644 --- a/ruoyi-ui/src/views/childRoom/index.vue +++ b/ruoyi-ui/src/views/childRoom/index.vue @@ -8,14 +8,19 @@ :tool-mode="drawDom ? 'ranging' : (airspaceDrawDom ? 'airspace' : 'airspace')" :scaleConfig="scaleConfig" @draw-complete="handleMapDrawComplete" + @drawing-points-update="missionDrawingPointsCount = $event" @open-waypoint-dialog="handleOpenWaypointEdit" + @open-route-dialog="handleOpenRouteEdit" @scale-click="handleScaleClick" /> - @open-route-dialog="handleOpenRouteEdit" />

二维GIS地图区域

支持标绘/航线/空域/实时态势

+
+ 已 {{ missionDrawingPointsCount }} 个航点,右键结束 + 插入盘旋 +
-
+
- {{ deductionWarnings[0] }} + {{ deductionWarnings[0] || '存在航段将提前到达下一航点。' }} 等 {{ deductionWarnings.length }} 条 + 在此添加盘旋
+ +
{{ addHoldDialogTip }}
+ + + + 圆形 + 椭圆 + + + + + + + + + 顺时针 + 逆时针 + + + + + + + + 取消 + 确定添加 + +
@@ -333,7 +382,7 @@ import BottomLeftPanel from './BottomLeftPanel' import TopHeader from './TopHeader' import { listScenario,addScenario,delScenario} from "@/api/system/scenario"; import { listRoutes, getRoutes, addRoutes, updateRoutes, delRoutes } from "@/api/system/routes"; -import { updateWaypoints } from "@/api/system/waypoints"; +import { updateWaypoints, addWaypoints, delWaypoints } from "@/api/system/waypoints"; import { listLib,addLib,delLib} from "@/api/system/lib"; import { getRooms, updateRooms } from "@/api/system/rooms"; import { getMenuConfig, saveMenuConfig } from "@/api/system/userMenuConfig"; @@ -518,6 +567,12 @@ export default { currentTime: 'K+00:00:00', deductionMinutesFromK: 0, deductionWarnings: [], + deductionEarlyArrivalByRoute: {}, // routeId -> earlyArrivalLegs + showAddHoldDialog: false, + addHoldContext: null, // { routeId, routeName, legIndex, fromName, toName } + addHoldForm: { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: null }, + missionDrawingActive: false, + missionDrawingPointsCount: 0, isPlaying: false, playbackSpeed: 1, playbackInterval: null, @@ -569,6 +624,21 @@ export default { kTimeDisplay() { if (!this.roomDetail || !this.roomDetail.kAnchorTime) return ''; return this.formatKTimeForPicker(this.roomDetail.kAnchorTime) || ''; + }, + hasEarlyArrivalLegs() { + return this.activeRouteIds.some(rid => (this.deductionEarlyArrivalByRoute[rid] || []).length > 0); + }, + addHoldDialogTitle() { + if (!this.addHoldContext) return '添加盘旋'; + if (this.addHoldContext.mode === 'drawing') return '在最后两航点之间插入盘旋'; + if (this.addHoldContext.mode === 'drawing_first') return '先画盘旋再画航线'; + return '在此航段添加盘旋'; + }, + addHoldDialogTip() { + if (!this.addHoldContext) return ''; + if (this.addHoldContext.mode === 'drawing') return '在最后两航点之间插入盘旋,保存航线时生效。'; + if (this.addHoldContext.mode === 'drawing_first') return '设置盘旋参数后,将进入航线绘制:第一个点击为盘旋中心,第二个点击为下一航点。'; + return `在 ${this.addHoldContext.fromName} 与 ${this.addHoldContext.toName} 之间添加盘旋,到计划时间后沿切线飞往下一航点(原「下一格」航点将被移除)。`; } }, mounted() { @@ -835,6 +905,8 @@ export default { if (route && route.waypoints && route.waypoints.length > 0) { this.$refs.cesiumMap.removeRouteById(updatedRoute.id); this.$refs.cesiumMap.renderRouteWaypoints(route.waypoints, updatedRoute.id, route.platformId, route.platform, routeStyle); + // 切换平台后按当前推演时间把图标更新到对应位置,避免图标回到起点 + this.$nextTick(() => this.updateDeductionPositions()); } } } else { @@ -856,6 +928,8 @@ export default { } // 进入 Cesium 绘图模式 if (this.$refs.cesiumMap) { + this.missionDrawingActive = true; + this.missionDrawingPointsCount = 0; this.$refs.cesiumMap.startMissionRouteDrawing(); this.$message.success(`${plan.name}开启航线规划`); } @@ -944,6 +1018,7 @@ export default { } }, handleMapDrawComplete(points) { + this.missionDrawingActive = false; if (!points || points.length < 2) { this.$message.error('航点太少,无法生成航线'); this.drawDom = false; @@ -952,6 +1027,19 @@ export default { this.tempMapPoints = points; // 暂存坐标点 this.showNameDialog = true; // 弹出对话框起名 }, + + openAddHoldDuringDrawing() { + this.addHoldContext = { mode: 'drawing' }; + this.addHoldForm = { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: 60 }; + this.showAddHoldDialog = true; + }, + + openAddHoldThenCreateRoute(plan) { + if (!plan || !plan.id) return; + this.addHoldContext = { mode: 'drawing_first', plan }; + this.addHoldForm = { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: 60 }; + this.showAddHoldDialog = true; + }, /** 弹窗点击“确定”:正式将数据保存到后端数据库 */ async confirmSaveNewRoute() { // 1. 严格校验 @@ -965,15 +1053,17 @@ export default { return; } - // 2. 构造数据 + // 2. 构造数据(含盘旋航点的 pointType、holdParams) const finalWaypoints = this.tempMapPoints.map((p, index) => ({ - name: `WP${index + 1}`, + name: p.name || `WP${index + 1}`, lat: p.lat, lng: p.lng, - alt: 5000.0, - speed: 800.0, - startTime: 'K+00:00:00', - turnAngle: 0.0 + alt: p.alt != null ? p.alt : 5000.0, + speed: p.speed != null ? p.speed : 800.0, + startTime: p.startTime || 'K+00:00:00', + turnAngle: p.turnAngle != null ? p.turnAngle : 0.0, + ...(p.pointType && { pointType: p.pointType }), + ...(p.holdParams != null && { holdParams: typeof p.holdParams === 'string' ? p.holdParams : JSON.stringify(p.holdParams) }) })); const routeData = { @@ -1068,26 +1158,33 @@ export default { : 'K+00:00:00', turnAngle: updatedWaypoint.turnAngle }; + if (updatedWaypoint.pointType != null) payload.pointType = updatedWaypoint.pointType; + if (updatedWaypoint.holdParams != null) payload.holdParams = updatedWaypoint.holdParams; const response = await updateWaypoints(payload); if (response.code === 200) { const index = this.selectedRouteDetails.waypoints.findIndex(p => p.id === updatedWaypoint.id); if (index !== -1) { // 更新本地数据(用已提交的 payload 保证 startTime 等与数据库一致) this.selectedRouteDetails.waypoints.splice(index, 1, { ...updatedWaypoint, ...payload }); - // 通知地图组件同步更新 + const merged = { ...updatedWaypoint, ...payload }; + const routeInList = this.routes.find(r => r.id === this.selectedRouteDetails.id); + if (routeInList && routeInList.waypoints) { + const idxInList = routeInList.waypoints.findIndex(p => p.id === updatedWaypoint.id); + if (idxInList !== -1) routeInList.waypoints.splice(idxInList, 1, merged); + } if (this.$refs.cesiumMap) { - // 更新航点图标 this.$refs.cesiumMap.updateWaypointGraphicById(updatedWaypoint.id, updatedWaypoint.name); - // 重新触发航线渲染 this.$refs.cesiumMap.renderRouteWaypoints( this.selectedRouteDetails.waypoints, this.selectedRouteDetails.id, this.selectedRouteDetails.platformId, - this.selectedRouteDetails.platform + this.selectedRouteDetails.platform, + this.parseRouteStyle(this.selectedRouteDetails.attributes) ); } this.showWaypointDialog = false; this.$message.success('航点信息已持久化至数据库'); + this.$nextTick(() => this.updateDeductionPositions()); } } else { // 如果 code 不是 200,手动抛出错误进入catch @@ -1114,7 +1211,7 @@ export default { return this.activeRouteIds && this.activeRouteIds.length > 0; }, updateCombatTime() { - // 有推演航线时:作战时间 = 推演时间轴当前时间(由 updateTimeFromProgress 同步) + // 作战时间始终与推演时间轴同步:有航线时用 deductionMinutesFromK,无航线时用 currentTime(由时间轴 updateTimeFromProgress 计算) if (this.hasDeductionRange()) { const sign = this.deductionMinutesFromK >= 0 ? '+' : '-'; const absMin = Math.abs(Math.floor(this.deductionMinutesFromK)); @@ -1123,8 +1220,8 @@ export default { this.combatTime = `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`; return; } - // 无推演航线时:保持进入房间时的固定作战时间,不随真实时间变化 - this.combatTime = 'K+00:00:00'; + // 无展示航线时:作战时间仍随推演时间轴变化,用 currentTime + this.combatTime = this.currentTime || 'K+00:00:00'; }, /** 将作战时间字符串(如 K+01:30:00)解析为相对 K 的分钟数 */ combatTimeToMinutes(str) { @@ -1737,10 +1834,8 @@ export default { const hours = Math.floor(absMin / 60); const minutes = absMin % 60; this.currentTime = `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`; - // 右上角作战时间与推演时间轴同步 - if (this.hasDeductionRange()) { - this.combatTime = this.currentTime; - } + // 右上角作战时间随时与推演时间轴同步(无论是否展示航线) + this.combatTime = this.currentTime; this.updateDeductionPositions(); }, @@ -1775,6 +1870,62 @@ export default { const min = parseInt(m[3], 10); return sign * (h * 60 + min); }, + /** 将相对 K 的分钟数转为 startTime 字符串(如 K+01:00、K-00:30) */ + minutesToStartTime(minutes) { + const m = Math.floor(Number(minutes)); + if (!Number.isFinite(m)) return 'K+00:00'; + const sign = m >= 0 ? '+' : '-'; + const abs = Math.abs(m); + const h = Math.floor(abs / 60); + const min = abs % 60; + return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`; + }, + + isHoldWaypoint(wp) { + const t = (wp && wp.pointType) || (wp && wp.point_type) || 'normal'; + return t === 'hold_circle' || t === 'hold_ellipse'; + }, + parseHoldParams(wp) { + const raw = (wp && wp.holdParams) || (wp && wp.hold_params); + if (!raw) return null; + try { + const p = typeof raw === 'string' ? JSON.parse(raw) : raw; + return { radius: p.radius, semiMajor: p.semiMajor ?? p.semiMajorAxis, semiMinor: p.semiMinor ?? p.semiMinorAxis, headingDeg: p.headingDeg ?? 0, clockwise: p.clockwise !== false }; + } catch (e) { + return null; + } + }, + + /** 路径片段总距离(米) */ + pathSliceDistance(pathSlice) { + if (!pathSlice || pathSlice.length < 2) return 0; + let d = 0; + for (let i = 1; i < pathSlice.length; i++) d += this.segmentDistance(pathSlice[i - 1], pathSlice[i]); + return d; + }, + + /** 圆周上按角度取点:圆心 lng/lat/alt,半径米。angleRad 为从北顺时针的角度弧度,0=北 */ + positionOnCircle(centerLng, centerLat, centerAlt, radiusM, angleRad) { + const R = 6371000; + const latRad = (centerLat * Math.PI) / 180; + const dNorth = radiusM * Math.cos(angleRad); + const dEast = radiusM * Math.sin(angleRad); + const dLat = (dNorth / R) * (180 / Math.PI); + const dLng = (dEast / (R * Math.cos(latRad))) * (180 / Math.PI); + return { + lng: centerLng + dLng, + lat: centerLat + dLat, + alt: centerAlt != null ? centerAlt : 0 + }; + }, + /** 从圆心到某点的角度(弧度,从北顺时针),用于盘旋入口基准角 */ + angleFromCenterToPoint(centerLng, centerLat, pointLng, pointLat) { + const R = 6371000; + const latRad = (centerLat * Math.PI) / 180; + const dNorth = (pointLat - centerLat) * (R * Math.PI / 180); + const dEast = (pointLng - centerLng) * (R * Math.cos(latRad) * Math.PI / 180); + return Math.atan2(dEast, dNorth); + }, /** 两航点间近似距离(米),含高度差 */ segmentDistance(wp1, wp2) { @@ -1791,26 +1942,28 @@ export default { }, /** - * 按速度与计划时间构建航线时间轴:含飞行段与“提前到达则等待”的等待段,并做航段校验。 - * 若所有航点相对 K 时相同,则在 [globalMin, globalMax] 内按航点顺序均匀分布,避免闪现。 + * 按速度与计划时间构建航线时间轴:含飞行段、盘旋段与“提前到达则等待”的等待段。 + * pathData 可选:{ path, segmentEndIndices, holdArcRanges },由 getRoutePathWithSegmentIndices 提供,用于输出 hold 段。 */ - buildRouteTimeline(waypoints, globalMin, globalMax) { + buildRouteTimeline(waypoints, globalMin, globalMax, pathData) { const warnings = []; if (!waypoints || waypoints.length === 0) return { segments: [], warnings }; - const points = waypoints.map(wp => ({ + const points = waypoints.map((wp, idx) => ({ lng: parseFloat(wp.lng), lat: parseFloat(wp.lat), alt: Number(wp.alt) || 0, minutes: this.waypointStartTimeToMinutes(wp.startTime), - speed: Number(wp.speed) || 800 + speed: Number(wp.speed) || 800, + isHold: this.isHoldWaypoint(wp) })); + const hasHold = points.some(p => p.isHold); const allSame = points.every(p => p.minutes === points[0].minutes); - if (allSame && points.length > 1) { + if (allSame && points.length > 1 && !hasHold) { const span = Math.max(globalMax - globalMin, 1); points.forEach((p, i) => { p.minutes = globalMin + (span * i) / (points.length - 1); }); - } else { + } else if (!hasHold) { points.sort((a, b) => a.minutes - b.minutes); } if (points.length === 1) { @@ -1823,7 +1976,61 @@ export default { } const effectiveTime = [points[0].minutes]; const segments = []; + const path = pathData && pathData.path; + const segmentEndIndices = pathData && pathData.segmentEndIndices; + const holdArcRanges = pathData && pathData.holdArcRanges || {}; for (let i = 0; i < points.length - 1; i++) { + if (this.isHoldWaypoint(waypoints[i + 1]) && path && segmentEndIndices && holdArcRanges[i]) { + const range = holdArcRanges[i]; + const startIdx = i === 0 ? 0 : segmentEndIndices[i - 1] + 1; + const toEntrySlice = path.slice(startIdx, range.start + 1); + const holdPathSlice = path.slice(range.start, range.end + 1); + const exitIdx = segmentEndIndices[i]; + const toNextSlice = path.slice(exitIdx, (segmentEndIndices[i + 1] != null ? segmentEndIndices[i + 1] : path.length - 1) + 1); + const distToEntry = this.pathSliceDistance(toEntrySlice); + const speedKmh = points[i].speed || 800; + const travelToEntryMin = (distToEntry / 1000) * (60 / speedKmh); + const arrivalEntry = effectiveTime[i] + travelToEntryMin; + const holdEndTime = points[i + 1].minutes; + const distExitToNext = this.pathSliceDistance(toNextSlice); + const travelExitMin = (distExitToNext / 1000) * (60 / speedKmh); + const arrivalNext = holdEndTime + 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 holdClockwise = holdParams && holdParams.clockwise !== false; + const holdCircumference = holdRadius != null ? 2 * Math.PI * holdRadius : null; + const holdEntryAngle = holdCenter && entryPos && holdRadius != null + ? this.angleFromCenterToPoint(holdCenter.lng, holdCenter.lat, entryPos.lng, entryPos.lat) + : null; + segments.push({ startTime: effectiveTime[i], endTime: arrivalEntry, startPos: posCur, endPos: entryPos, type: 'fly', legIndex: i, pathSlice: toEntrySlice }); + segments.push({ + startTime: arrivalEntry, + endTime: holdEndTime, + startPos: entryPos, + endPos: exitPos, + type: 'hold', + legIndex: i, + holdPath: holdPathSlice, + holdDurationMin, + speedKmh: points[i].speed || 800, + holdCenter, + holdRadius, + holdCircumference, + holdClockwise, + holdEntryAngle + }); + segments.push({ startTime: holdEndTime, endTime: arrivalNext, startPos: exitPos, endPos: toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos, type: 'fly', legIndex: i, pathSlice: toNextSlice }); + i++; + continue; + } const dist = this.segmentDistance(points[i], points[i + 1]); const speedKmh = points[i].speed || 800; const travelMin = (dist / 1000) * (60 / speedKmh); @@ -1847,7 +2054,19 @@ export default { segments.push({ startTime: actualArrival, endTime: effectiveTime[i + 1], startPos: posNext, endPos: posNext, type: 'wait', legIndex: i }); } } - return { segments, warnings }; + const earlyArrivalLegs = []; + for (let i = 0; i < points.length - 1; i++) { + if (this.isHoldWaypoint(waypoints[i + 1])) continue; + const dist = this.segmentDistance(points[i], points[i + 1]); + const speedKmh = points[i].speed || 800; + const travelMin = (dist / 1000) * (60 / speedKmh); + const actualArrival = effectiveTime[i] + travelMin; + const scheduled = points[i + 1].minutes; + if (travelMin > 0 && scheduled - points[i].minutes > 0 && actualArrival < scheduled - 0.5) { + earlyArrivalLegs.push({ legIndex: i, scheduled, actualArrival, fromName: waypoints[i].name, toName: waypoints[i + 1].name }); + } + } + return { segments, warnings, earlyArrivalLegs }; }, /** 从路径片段中按比例 t(0~1)取点:按弧长比例插值 */ @@ -1875,31 +2094,52 @@ export default { }; }, - /** 从时间轴中取当前推演时间对应的位置;若有 path/segmentEndIndices 则沿带转弯弧的路径插值 */ + /** 从时间轴中取当前推演时间对应的位置;支持 fly/wait/hold,hold 沿 holdPath 弧长插值 */ getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices) { if (!segments || segments.length === 0) return null; if (minutesFromK <= segments[0].startTime) return segments[0].startPos; const last = segments[segments.length - 1]; if (minutesFromK >= last.endTime) { - // 若最后一段是等待且有弧线路径,终点取弧线终点 if (last.type === 'wait' && path && segmentEndIndices && last.legIndex != null && last.legIndex < segmentEndIndices.length && path[segmentEndIndices[last.legIndex]]) { return path[segmentEndIndices[last.legIndex]]; } + if (last.type === 'hold' && last.holdPath && last.holdPath.length) return last.holdPath[last.holdPath.length - 1]; return last.endPos; } for (let i = 0; i < segments.length; i++) { const s = segments[i]; if (minutesFromK < s.endTime) { - const t = (minutesFromK - s.startTime) / (s.endTime - s.startTime); + const t = Math.max(0, Math.min(1, (minutesFromK - s.startTime) / (s.endTime - s.startTime))); if (s.type === 'wait') { - // 有弧线路径时在弧线终点等待,不跳到航点 if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) { const endIdx = segmentEndIndices[s.legIndex]; if (path[endIdx]) return path[endIdx]; } 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); + } + 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); + } + if (s.type === 'fly' && s.pathSlice && s.pathSlice.length) { + return this.getPositionAlongPathSlice(s.pathSlice, t); + } if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) { const startIdx = s.legIndex === 0 ? 0 : segmentEndIndices[s.legIndex - 1]; const endIdx = segmentEndIndices[s.legIndex]; @@ -1916,24 +2156,24 @@ export default { return last.endPos; }, - /** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧路径运动;返回 { position, nextPosition, previousPosition, warnings },用于计算机头朝向 */ + /** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧/盘旋弧路径运动;返回 { position, nextPosition, previousPosition, warnings } */ getPositionAtMinutesFromK(waypoints, minutesFromK, globalMin, globalMax) { if (!waypoints || waypoints.length === 0) return { position: null, nextPosition: null, previousPosition: null, warnings: [] }; - const { segments, warnings } = this.buildRouteTimeline(waypoints, globalMin, globalMax); - let path = null; - let segmentEndIndices = null; + let pathData = null; if (this.$refs.cesiumMap && this.$refs.cesiumMap.getRoutePathWithSegmentIndices) { const ret = this.$refs.cesiumMap.getRoutePathWithSegmentIndices(waypoints); if (ret.path && ret.path.length > 0 && ret.segmentEndIndices && ret.segmentEndIndices.length > 0) { - path = ret.path; - segmentEndIndices = ret.segmentEndIndices; + pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} }; } } + const { segments, warnings, earlyArrivalLegs } = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData); + const path = pathData ? pathData.path : null; + const segmentEndIndices = pathData ? pathData.segmentEndIndices : null; const position = this.getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices); const stepMin = 1 / 60; const nextPosition = this.getPositionFromTimeline(segments, minutesFromK + stepMin, path, segmentEndIndices); const previousPosition = this.getPositionFromTimeline(segments, minutesFromK - stepMin, path, segmentEndIndices); - return { position, nextPosition, previousPosition, warnings }; + return { position, nextPosition, previousPosition, warnings, earlyArrivalLegs: earlyArrivalLegs || [] }; }, /** 仅根据当前展示的航线(activeRouteIds)更新平台图标位置,并汇总航段提示 */ @@ -1945,13 +2185,120 @@ 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 } = this.getPositionAtMinutesFromK(route.waypoints, minutesFromK, minMinutes, maxMinutes); + const { position, nextPosition, previousPosition, warnings, earlyArrivalLegs } = this.getPositionAtMinutesFromK(route.waypoints, minutesFromK, minMinutes, maxMinutes); if (warnings && warnings.length) allWarnings.push(...warnings); if (position) this.$refs.cesiumMap.updatePlatformPosition(routeId, position, nextPosition || previousPosition); + this.deductionEarlyArrivalByRoute[routeId] = earlyArrivalLegs || []; }); this.deductionWarnings = [...new Set(allWarnings)]; }, + openAddHoldFromFirstEarly() { + for (const routeId of this.activeRouteIds) { + const legs = this.deductionEarlyArrivalByRoute[routeId] || []; + if (legs.length === 0) continue; + const route = this.routes.find(r => r.id === routeId); + const leg = legs[0]; + const waypoints = route && route.waypoints ? route.waypoints : []; + const nextWp = waypoints[leg.legIndex + 1]; + this.addHoldContext = { + routeId, + routeName: route ? route.name : '', + legIndex: leg.legIndex, + fromName: leg.fromName, + toName: leg.toName + }; + const defaultStart = nextWp && nextWp.startTime ? nextWp.startTime : 'K+01:00'; + this.addHoldForm.startTime = defaultStart; + this.addHoldForm.startTimeMinutes = this.waypointStartTimeToMinutes(defaultStart); + this.showAddHoldDialog = true; + return; + } + }, + + async saveAddHold() { + if (!this.addHoldContext) return; + if (this.addHoldContext.mode === 'drawing') { + const holdParams = this.addHoldForm.holdType === 'hold_circle' + ? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise } + : { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise }; + if (this.$refs.cesiumMap && this.$refs.cesiumMap.insertHoldBetweenLastTwo) { + this.$refs.cesiumMap.insertHoldBetweenLastTwo(holdParams); + } + this.showAddHoldDialog = false; + this.addHoldContext = null; + this.$message.success('已插入盘旋,继续点选航点后右键结束保存'); + return; + } + if (this.addHoldContext.mode === 'drawing_first') { + const holdParams = this.addHoldForm.holdType === 'hold_circle' + ? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise } + : { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise }; + this.showAddHoldDialog = false; + const plan = this.addHoldContext.plan; + this.addHoldContext = null; + this.createRoute(plan); + this.$nextTick(() => { + if (this.$refs.cesiumMap && this.$refs.cesiumMap.setInitialHoldParams) { + this.$refs.cesiumMap.setInitialHoldParams(holdParams); + } + }); + this.$message.success('请先点击盘旋中心,再点击下一航点,然后继续绘制或右键结束'); + return; + } + const { routeId, legIndex } = this.addHoldContext; + const route = this.routes.find(r => r.id === routeId); + if (!route || !route.waypoints || route.waypoints.length < 2) { + this.$message.warning('航线数据异常'); + return; + } + const waypoints = route.waypoints; + const prevWp = waypoints[legIndex]; + const nextWp = waypoints[legIndex + 1]; + const newSeq = (prevWp.seq != null ? Number(prevWp.seq) : legIndex + 1) + 1; + const baseSeq = prevWp.seq != null ? Number(prevWp.seq) : legIndex + 1; + const holdParams = this.addHoldForm.holdType === 'hold_circle' + ? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise } + : { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise }; + const startTime = this.addHoldForm.startTimeMinutes !== '' && this.addHoldForm.startTimeMinutes != null && !Number.isNaN(Number(this.addHoldForm.startTimeMinutes)) + ? this.minutesToStartTime(Number(this.addHoldForm.startTimeMinutes)) + : (nextWp.startTime || 'K+01:00'); + try { + await addWaypoints({ + routeId, + name: 'HOLD', + seq: newSeq, + lat: nextWp.lat, + lng: nextWp.lng, + alt: nextWp.alt != null ? nextWp.alt : prevWp.alt, + speed: prevWp.speed || 800, + startTime, + pointType: this.addHoldForm.holdType, + holdParams: JSON.stringify(holdParams) + }); + await delWaypoints(nextWp.id); + for (let i = legIndex + 2; i < waypoints.length; i++) { + const w = waypoints[i]; + if (w.id) { + await updateWaypoints({ ...w, seq: baseSeq + (i - legIndex) }); + } + } + this.showAddHoldDialog = false; + this.addHoldContext = null; + await this.getList(); + const updated = this.routes.find(r => r.id === routeId); + if (updated && updated.waypoints && this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap) { + this.$refs.cesiumMap.removeRouteById(routeId); + this.$refs.cesiumMap.renderRouteWaypoints(updated.waypoints, routeId, updated.platformId, updated.platform, this.parseRouteStyle(updated.attributes)); + this.$nextTick(() => this.updateDeductionPositions()); + } + this.$message.success('已添加盘旋航点'); + } catch (e) { + this.$message.error(e.msg || '添加盘旋失败'); + console.error(e); + } + }, + // 时间控制(保留用于底部时间轴) play() { this.$message.success('推演开始'); diff --git a/ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue b/ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue index d03dd53..5741f17 100644 --- a/ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue +++ b/ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue @@ -34,7 +34,7 @@ > - + + 0 && index === total - 1); + const pt = (this.waypoint.pointType || this.waypoint.point_type) || 'normal'; + let holdRadius = 500, holdSemiMajor = 500, holdSemiMinor = 300, holdHeadingDeg = 0, holdClockwise = true; + try { + const raw = this.waypoint.holdParams || this.waypoint.hold_params; + if (raw) { + const p = typeof raw === 'string' ? JSON.parse(raw) : raw; + holdRadius = p.radius != null ? p.radius : 500; + holdSemiMajor = p.semiMajor ?? p.semiMajorAxis ?? 500; + holdSemiMinor = p.semiMinor ?? p.semiMinorAxis ?? 300; + holdHeadingDeg = p.headingDeg ?? 0; + holdClockwise = p.clockwise !== false; + } + } catch (e) {} this.formData = { name: this.waypoint.name || '', alt: this.waypoint.alt !== undefined && this.waypoint.alt !== null ? Number(this.waypoint.alt) : 0, @@ -150,7 +203,13 @@ export default { currentIndex: index, totalPoints: total, isBankDisabled: locked, - turnAngle: locked ? 0 : (Number(this.waypoint.turnAngle) || 0) + turnAngle: locked ? 0 : (Number(this.waypoint.turnAngle) || 0), + pointType: pt, + holdRadius, + holdSemiMajor, + holdSemiMinor, + holdHeadingDeg, + holdClockwise }; this.$nextTick(() => { @@ -167,11 +226,19 @@ export default { if (valid) { const { minutesFromK, ...rest } = this.formData; const startTimeStr = this.minutesToStartTime(minutesFromK); - this.$emit('save', { - ...this.waypoint, - ...rest, - startTime: startTimeStr - }); + const payload = { ...this.waypoint, ...rest, startTime: startTimeStr }; + if (this.formData.pointType && this.formData.pointType !== 'normal') { + payload.pointType = this.formData.pointType; + payload.holdParams = this.formData.pointType === 'hold_circle' + ? JSON.stringify({ radius: this.formData.holdRadius, clockwise: this.formData.holdClockwise }) + : JSON.stringify({ + semiMajor: this.formData.holdSemiMajor, + semiMinor: this.formData.holdSemiMinor, + headingDeg: this.formData.holdHeadingDeg, + clockwise: this.formData.holdClockwise + }); + } + this.$emit('save', payload); this.closeDialog(); } }); From cb769b8cd1c4706416b4d3de5170335d781b42fa Mon Sep 17 00:00:00 2001 From: ctw <1051735452@qq.com> Date: Sun, 8 Feb 2026 09:48:00 +0800 Subject: [PATCH 04/19] =?UTF-8?q?=E7=9B=98=E6=97=8B=E7=9A=84=E4=B8=80?= =?UTF-8?q?=E7=A7=8D=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue | 3 - ruoyi-ui/src/views/cesiumMap/index.vue | 374 ------------------------ ruoyi-ui/src/views/childRoom/RightPanel.vue | 5 - ruoyi-ui/src/views/childRoom/index.vue | 65 ++-- 4 files changed, 18 insertions(+), 429 deletions(-) diff --git a/ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue b/ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue index dcced8d..58d3b46 100644 --- a/ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue +++ b/ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue @@ -45,9 +45,6 @@ export default { { id: 'polygon', name: '面', icon: 'el-icon-house' }, { id: 'rectangle', name: '矩形', icon: 'jx' }, { id: 'circle', name: '圆形', icon: 'circle' }, - { id: 'ellipse', name: '椭圆', icon: 'el-icon-oval' }, - { id: 'hold_circle', name: '圆形盘旋', icon: 'circle' }, - { id: 'hold_ellipse', name: '椭圆盘旋', icon: 'el-icon-oval' }, { id: 'sector', name: '扇形', icon: 'sx' }, { id: 'arrow', name: '箭头', icon: 'el-icon-right' }, { id: 'text', name: '文本', icon: 'el-icon-document' }, diff --git a/ruoyi-ui/src/views/cesiumMap/index.vue b/ruoyi-ui/src/views/cesiumMap/index.vue index 20b8e6c..552d41d 100644 --- a/ruoyi-ui/src/views/cesiumMap/index.vue +++ b/ruoyi-ui/src/views/cesiumMap/index.vue @@ -132,7 +132,6 @@ export default { isDrawing: false, missionHoldParamsByIndex: {}, missionPendingHold: null, - missionInitialHoldParams: null, tempHoldEntity: null, activeCursorPosition: null, // 实时鼠标位置 // 实体管理 @@ -160,9 +159,6 @@ export default { polygon: { color: '#0000FF', opacity: 0.5, width: 2 }, rectangle: { color: '#FFA500', opacity: 0.3, width: 2 }, circle: { color: '#800080', opacity: 0.4, width: 2 }, - ellipse: { color: '#008080', opacity: 0.4, width: 2 }, - hold_circle: { color: '#00BFFF', width: 2 }, - hold_ellipse: { color: '#00BFFF', width: 2 }, sector: { color: '#FF6347', opacity: 0.5, width: 2 }, arrow: { color: '#FF0000', width: 6 }, text: { color: '#000000', font: '48px Microsoft YaHei, PingFang SC, sans-serif', backgroundColor: 'rgba(255, 255, 255, 0.8)' }, @@ -384,7 +380,6 @@ export default { this.drawingPoints = []; this.missionHoldParamsByIndex = {}; this.missionPendingHold = null; - this.missionInitialHoldParams = null; let activeCursorPosition = null; this.isDrawing = true; this.viewer.canvas.style.cursor = 'crosshair'; @@ -402,22 +397,6 @@ export default { const position = this.getClickPosition(click.position); if (!position) return; this.drawingPoints.push(position); - if (this.missionInitialHoldParams && this.drawingPoints.length === 2) { - this.missionPendingHold = { - beforeIndex: 1, - center: Cesium.Cartesian3.clone(this.drawingPoints[0]), - params: this.missionInitialHoldParams - }; - this.missionInitialHoldParams = null; - if (this.tempHoldEntity) { try { this.viewer.entities.remove(this.tempHoldEntity); } catch (e) {} this.tempHoldEntity = null; } - this.tempHoldEntity = this.viewer.entities.add({ - id: 'temp_hold_preview', - name: 'HOLD', - position: this.missionPendingHold.center, - point: { pixelSize: 10, color: Cesium.Color.ORANGE, outlineColor: Cesium.Color.WHITE, outlineWidth: 2, disableDepthTestDistance: Number.POSITIVE_INFINITY }, - label: { text: 'HOLD', font: '12px MicroSoft YaHei', fillColor: Cesium.Color.ORANGE, outlineColor: Cesium.Color.BLACK, outlineWidth: 2 } - }); - } const wpIndex = this.drawingPoints.length; this.$emit('drawing-points-update', this.drawingPoints.length); // 绘制业务航点 @@ -552,11 +531,6 @@ export default { return (this.drawingPoints && this.drawingPoints.length) || 0; }, - /** 方式一:先画盘旋再画航线。在开始航线绘制后、用户点击第一个点作为盘旋中心,第二个点后自动插入该盘旋 */ - setInitialHoldParams(holdParams) { - this.missionInitialHoldParams = holdParams; - }, - // 格式化平台图标 URL(与 RouteEditDialog / RightPanel 一致) formatPlatformIconUrl(url) { if (!url) return ''; @@ -1569,15 +1543,6 @@ export default { case 'circle': this.startCircleDrawing() break - case 'ellipse': - this.startEllipseDrawing() - break - case 'hold_circle': - this.startHoldCircleDrawing() - break - case 'hold_ellipse': - this.startHoldEllipseDrawing() - break case 'sector': this.startSectorDrawing() break @@ -1619,7 +1584,6 @@ export default { this.tempHoldEntity = null; } this.missionPendingHold = null; - this.missionInitialHoldParams = null; this.missionHoldParamsByIndex = {}; //清理已知的预览实体 if (this.tempEntity) { @@ -2226,89 +2190,6 @@ export default { this.drawingPoints = []; this.activeCursorPosition = null; }, - // 绘制椭圆:三点(圆心、长轴端点、短轴端点) - startEllipseDrawing() { - this.drawingPoints = []; - this.activeCursorPosition = null; - if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } - if (this.tempPreviewEntity) { this.viewer.entities.remove(this.tempPreviewEntity); this.tempPreviewEntity = null; } - this.drawingHandler.removeInputAction(Cesium.ScreenSpaceEventType.MOUSE_MOVE); - this.drawingHandler.removeInputAction(Cesium.ScreenSpaceEventType.LEFT_CLICK); - this.drawingHandler.removeInputAction(Cesium.ScreenSpaceEventType.RIGHT_CLICK); - this.drawingHandler.setInputAction((movement) => { - const newPosition = this.getClickPosition(movement.endPosition); - if (newPosition) this.activeCursorPosition = newPosition; - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); - this.drawingHandler.setInputAction((click) => { - const position = this.getClickPosition(click.position); - if (!position) return; - this.drawingPoints.push(position); - if (this.drawingPoints.length === 1) { - this.activeCursorPosition = position; - this.tempEntity = this.viewer.entities.add({ - position: position, - ellipse: { - semiMajorAxis: new Cesium.CallbackProperty(() => { - let a = 100; - let b = 50; - if (this.drawingPoints.length >= 2 && this.drawingPoints[0] && this.drawingPoints[1]) { - a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.drawingPoints[1]), 50); - } else if (this.activeCursorPosition && this.drawingPoints.length === 1) { - a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.activeCursorPosition), 50); - } - if (this.drawingPoints.length >= 3 && this.drawingPoints[0] && this.drawingPoints[1] && this.drawingPoints[2]) { - b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.drawingPoints[2]), 20); - } else if (this.drawingPoints.length >= 2 && this.activeCursorPosition) { - b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.activeCursorPosition), 20); - } - return Math.max(a, b); - }, false), - semiMinorAxis: new Cesium.CallbackProperty(() => { - let a = 100; - let b = 50; - if (this.drawingPoints.length >= 2 && this.drawingPoints[0] && this.drawingPoints[1]) { - a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.drawingPoints[1]), 50); - } else if (this.activeCursorPosition && this.drawingPoints.length === 1) { - a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.activeCursorPosition), 50); - } - if (this.drawingPoints.length >= 3 && this.drawingPoints[0] && this.drawingPoints[1] && this.drawingPoints[2]) { - b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.drawingPoints[2]), 20); - } else if (this.drawingPoints.length >= 2 && this.activeCursorPosition) { - b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.activeCursorPosition), 20); - } - return Math.min(a, b); - }, false), - rotation: new Cesium.CallbackProperty(() => { - if (this.drawingPoints.length >= 2 && this.drawingPoints[0] && this.drawingPoints[1]) { - let heading = this.ellipseHeadingFromCenterAndMajor(this.drawingPoints[0], this.drawingPoints[1]); - let a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.drawingPoints[1]), 50); - let b = 50; - if (this.drawingPoints.length >= 3 && this.drawingPoints[2]) { - b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.drawingPoints[2]), 20); - } else if (this.activeCursorPosition) { - b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.activeCursorPosition), 20); - } - if (b > a) heading += Math.PI / 2; - return heading; - } - return 0; - }, false), - material: Cesium.Color.fromCssColorString(this.defaultStyles.ellipse.color).withAlpha(0.5), - outline: true, - outlineColor: Cesium.Color.fromCssColorString(this.defaultStyles.ellipse.color), - outlineWidth: 2 - } - }); - } else if (this.drawingPoints.length === 2) { - this.activeCursorPosition = this.drawingPoints[1]; - } else if (this.drawingPoints.length === 3) { - this.activeCursorPosition = null; - if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } - this.finishEllipseDrawing(); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - this.drawingHandler.setInputAction(() => this.cancelDrawing(), Cesium.ScreenSpaceEventType.RIGHT_CLICK); - }, ellipseHeadingFromCenterAndMajor(center, majorEnd) { const ellipsoid = this.viewer.scene.globe.ellipsoid; const enu = Cesium.Transforms.eastNorthUpToFixedFrame(center); @@ -2330,261 +2211,6 @@ export default { const minorComponent = -e3 * Math.sin(heading) + n3 * Math.cos(heading); return Math.abs(minorComponent); }, - finishEllipseDrawing() { - const center = this.drawingPoints[0]; - const majorEnd = this.drawingPoints[1]; - const third = this.drawingPoints[2]; - let a = Math.max(Cesium.Cartesian3.distance(center, majorEnd), 50); - let b = Math.max(this.ellipseSemiMinorFromThreePoints(center, majorEnd, third), 20); - let heading = this.ellipseHeadingFromCenterAndMajor(center, majorEnd); - if (b > a) { - [a, b] = [b, a]; - heading += Math.PI / 2; - } - const semiMajor = a; - const semiMinor = b; - this.entityCounter++; - const id = `ellipse_${this.entityCounter}`; - const entity = this.viewer.entities.add({ - id, - name: `椭圆 ${this.entityCounter}`, - position: center, - ellipse: { - semiMajorAxis: semiMajor, - semiMinorAxis: semiMinor, - rotation: heading, - material: Cesium.Color.fromCssColorString(this.defaultStyles.ellipse.color).withAlpha(this.defaultStyles.ellipse.opacity), - outline: true, - outlineColor: Cesium.Color.fromCssColorString(this.defaultStyles.ellipse.color), - outlineWidth: this.defaultStyles.ellipse.width - } - }); - const entityData = { - id, - type: 'ellipse', - points: this.drawingPoints.map(p => this.cartesianToLatLng(p)), - positions: [...this.drawingPoints], - entity, - semiMajorAxis: semiMajor, - semiMinorAxis: semiMinor, - headingDeg: (heading * 180 / Math.PI), - color: this.defaultStyles.ellipse.color, - opacity: this.defaultStyles.ellipse.opacity, - width: this.defaultStyles.ellipse.width, - label: `椭圆 ${this.entityCounter}` - }; - this.allEntities.push(entityData); - this.measurementResult = { type: 'ellipse', semiMajorAxis: semiMajor, semiMinorAxis: semiMinor, area: Math.PI * semiMajor * semiMinor }; - this.drawingPoints = []; - this.activeCursorPosition = null; - }, - // 圆形盘旋:仅边框、不填充,交互与圆一致(圆心 + 边缘点) - startHoldCircleDrawing() { - this.drawingPoints = []; - this.activeCursorPosition = null; - if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } - if (this.tempPreviewEntity) { this.viewer.entities.remove(this.tempPreviewEntity); this.tempPreviewEntity = null; } - const initialRadius = 100; - this.drawingHandler.setInputAction((movement) => { - const newPosition = this.getClickPosition(movement.endPosition); - if (newPosition) this.activeCursorPosition = newPosition; - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); - this.drawingHandler.setInputAction((click) => { - const position = this.getClickPosition(click.position); - if (!position) return; - if (this.drawingPoints.length === 0) { - this.drawingPoints.push(position); - this.activeCursorPosition = position; - this.tempEntity = this.viewer.entities.add({ - position: position, - ellipse: { - semiMajorAxis: new Cesium.CallbackProperty(() => { - if (this.activeCursorPosition && this.drawingPoints[0]) { - const d = Cesium.Cartesian3.distance(this.drawingPoints[0], this.activeCursorPosition); - return isFinite(d) && d > 0 ? d : initialRadius; - } - return initialRadius; - }, false), - semiMinorAxis: new Cesium.CallbackProperty(() => { - if (this.activeCursorPosition && this.drawingPoints[0]) { - const d = Cesium.Cartesian3.distance(this.drawingPoints[0], this.activeCursorPosition); - return isFinite(d) && d > 0 ? d : initialRadius; - } - return initialRadius; - }, false), - material: Cesium.Color.TRANSPARENT, - outline: true, - outlineColor: Cesium.Color.fromCssColorString(this.defaultStyles.hold_circle.color), - outlineWidth: this.defaultStyles.hold_circle.width || 2 - } - }); - } else if (this.drawingPoints.length === 1) { - this.drawingPoints.push(position); - this.activeCursorPosition = null; - this.finishHoldCircleDrawing(position); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - this.drawingHandler.setInputAction(() => this.cancelDrawing(), Cesium.ScreenSpaceEventType.RIGHT_CLICK); - }, - finishHoldCircleDrawing(edgePosition) { - const centerPoint = this.drawingPoints[0]; - const radius = Cesium.Cartesian3.distance(centerPoint, edgePosition); - if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } - this.entityCounter++; - const id = `hold_circle_${this.entityCounter}`; - const finalEntity = this.viewer.entities.add({ - id, - position: centerPoint, - ellipse: { - semiMajorAxis: radius, - semiMinorAxis: radius, - material: Cesium.Color.TRANSPARENT, - outline: true, - outlineColor: Cesium.Color.fromCssColorString(this.defaultStyles.hold_circle.color), - outlineWidth: this.defaultStyles.hold_circle.width || 2 - } - }); - const entityData = { - id, - type: 'hold_circle', - points: [centerPoint, edgePosition].map(p => this.cartesianToLatLng(p)), - positions: [centerPoint, edgePosition], - entity: finalEntity, - color: this.defaultStyles.hold_circle.color, - width: this.defaultStyles.hold_circle.width || 2, - radius, - label: `圆形盘旋 ${this.entityCounter}` - }; - this.allEntities.push(entityData); - this.measurementResult = { type: 'hold_circle', radius, area: Math.PI * radius * radius }; - this.drawingPoints = []; - this.activeCursorPosition = null; - }, - // 椭圆盘旋:仅边框、不填充,交互与椭圆一致(圆心、长轴端点、短轴端点) - startHoldEllipseDrawing() { - this.drawingPoints = []; - this.activeCursorPosition = null; - if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } - if (this.tempPreviewEntity) { this.viewer.entities.remove(this.tempPreviewEntity); this.tempPreviewEntity = null; } - this.drawingHandler.setInputAction((movement) => { - const newPosition = this.getClickPosition(movement.endPosition); - if (newPosition) this.activeCursorPosition = newPosition; - }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); - this.drawingHandler.setInputAction((click) => { - const position = this.getClickPosition(click.position); - if (!position) return; - this.drawingPoints.push(position); - if (this.drawingPoints.length === 1) { - this.activeCursorPosition = position; - this.tempEntity = this.viewer.entities.add({ - position: position, - ellipse: { - semiMajorAxis: new Cesium.CallbackProperty(() => { - let a = 100, b = 50; - if (this.drawingPoints.length >= 2 && this.drawingPoints[0] && this.drawingPoints[1]) { - a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.drawingPoints[1]), 50); - } else if (this.activeCursorPosition && this.drawingPoints.length === 1) { - a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.activeCursorPosition), 50); - } - if (this.drawingPoints.length >= 3 && this.drawingPoints[0] && this.drawingPoints[1] && this.drawingPoints[2]) { - b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.drawingPoints[2]), 20); - } else if (this.drawingPoints.length >= 2 && this.activeCursorPosition) { - b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.activeCursorPosition), 20); - } - return Math.max(a, b); - }, false), - semiMinorAxis: new Cesium.CallbackProperty(() => { - let a = 100, b = 50; - if (this.drawingPoints.length >= 2 && this.drawingPoints[0] && this.drawingPoints[1]) { - a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.drawingPoints[1]), 50); - } else if (this.activeCursorPosition && this.drawingPoints.length === 1) { - a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.activeCursorPosition), 50); - } - if (this.drawingPoints.length >= 3 && this.drawingPoints[0] && this.drawingPoints[1] && this.drawingPoints[2]) { - b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.drawingPoints[2]), 20); - } else if (this.drawingPoints.length >= 2 && this.activeCursorPosition) { - b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.activeCursorPosition), 20); - } - return Math.min(a, b); - }, false), - rotation: new Cesium.CallbackProperty(() => { - if (this.drawingPoints.length >= 2 && this.drawingPoints[0] && this.drawingPoints[1]) { - let heading = this.ellipseHeadingFromCenterAndMajor(this.drawingPoints[0], this.drawingPoints[1]); - let a = Math.max(Cesium.Cartesian3.distance(this.drawingPoints[0], this.drawingPoints[1]), 50); - let b = 50; - if (this.drawingPoints.length >= 3 && this.drawingPoints[2]) { - b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.drawingPoints[2]), 20); - } else if (this.activeCursorPosition) { - b = Math.max(this.ellipseSemiMinorFromThreePoints(this.drawingPoints[0], this.drawingPoints[1], this.activeCursorPosition), 20); - } - if (b > a) heading += Math.PI / 2; - return heading; - } - return 0; - }, false), - material: Cesium.Color.TRANSPARENT, - outline: true, - outlineColor: Cesium.Color.fromCssColorString(this.defaultStyles.hold_ellipse.color), - outlineWidth: this.defaultStyles.hold_ellipse.width || 2 - } - }); - } else if (this.drawingPoints.length === 2) { - this.activeCursorPosition = this.drawingPoints[1]; - } else if (this.drawingPoints.length === 3) { - this.activeCursorPosition = null; - if (this.tempEntity) { this.viewer.entities.remove(this.tempEntity); this.tempEntity = null; } - this.finishHoldEllipseDrawing(); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - this.drawingHandler.setInputAction(() => this.cancelDrawing(), Cesium.ScreenSpaceEventType.RIGHT_CLICK); - }, - finishHoldEllipseDrawing() { - const center = this.drawingPoints[0]; - const majorEnd = this.drawingPoints[1]; - const third = this.drawingPoints[2]; - let a = Math.max(Cesium.Cartesian3.distance(center, majorEnd), 50); - let b = Math.max(this.ellipseSemiMinorFromThreePoints(center, majorEnd, third), 20); - let heading = this.ellipseHeadingFromCenterAndMajor(center, majorEnd); - if (b > a) { - [a, b] = [b, a]; - heading += Math.PI / 2; - } - const semiMajor = a; - const semiMinor = b; - this.entityCounter++; - const id = `hold_ellipse_${this.entityCounter}`; - const entity = this.viewer.entities.add({ - id, - name: `椭圆盘旋 ${this.entityCounter}`, - position: center, - ellipse: { - semiMajorAxis: semiMajor, - semiMinorAxis: semiMinor, - rotation: heading, - material: Cesium.Color.TRANSPARENT, - outline: true, - outlineColor: Cesium.Color.fromCssColorString(this.defaultStyles.hold_ellipse.color), - outlineWidth: this.defaultStyles.hold_ellipse.width || 2 - } - }); - const entityData = { - id, - type: 'hold_ellipse', - points: this.drawingPoints.map(p => this.cartesianToLatLng(p)), - positions: [...this.drawingPoints], - entity, - semiMajorAxis: semiMajor, - semiMinorAxis: semiMinor, - headingDeg: (heading * 180 / Math.PI), - color: this.defaultStyles.hold_ellipse.color, - width: this.defaultStyles.hold_ellipse.width || 2, - label: `椭圆盘旋 ${this.entityCounter}` - }; - this.allEntities.push(entityData); - this.measurementResult = { type: 'hold_ellipse', semiMajorAxis: semiMajor, semiMinorAxis: semiMinor, area: Math.PI * semiMajor * semiMinor }; - this.drawingPoints = []; - this.activeCursorPosition = null; - }, // 绘制扇形 startSectorDrawing() { // 重置绘制状态 diff --git a/ruoyi-ui/src/views/childRoom/RightPanel.vue b/ruoyi-ui/src/views/childRoom/RightPanel.vue index 1f3a46b..3187dc1 100644 --- a/ruoyi-ui/src/views/childRoom/RightPanel.vue +++ b/ruoyi-ui/src/views/childRoom/RightPanel.vue @@ -42,7 +42,6 @@
-
@@ -453,10 +452,6 @@ export default { this.$emit('create-route', plan) }, - handleCreateRouteWithHold(plan) { - this.$emit('create-route-with-hold', plan) - }, - handleDeletePlan(plan) { this.$confirm(`是否确认删除方案 "${plan.name}"?这将同时删除该方案下的所有航线。`, "警告", { confirmButtonText: "确定", diff --git a/ruoyi-ui/src/views/childRoom/index.vue b/ruoyi-ui/src/views/childRoom/index.vue index ae32850..ba97f03 100644 --- a/ruoyi-ui/src/views/childRoom/index.vue +++ b/ruoyi-ui/src/views/childRoom/index.vue @@ -160,7 +160,6 @@ @hide="hideRightPanel" @select-route="selectRoute" @create-route="createRoute" - @create-route-with-hold="openAddHoldThenCreateRoute" @delete-route="handleDeleteRoute" @select-plan="selectPlan" @create-plan="createPlan" @@ -631,14 +630,12 @@ export default { addHoldDialogTitle() { if (!this.addHoldContext) return '添加盘旋'; if (this.addHoldContext.mode === 'drawing') return '在最后两航点之间插入盘旋'; - if (this.addHoldContext.mode === 'drawing_first') return '先画盘旋再画航线'; return '在此航段添加盘旋'; }, addHoldDialogTip() { if (!this.addHoldContext) return ''; if (this.addHoldContext.mode === 'drawing') return '在最后两航点之间插入盘旋,保存航线时生效。'; - if (this.addHoldContext.mode === 'drawing_first') return '设置盘旋参数后,将进入航线绘制:第一个点击为盘旋中心,第二个点击为下一航点。'; - return `在 ${this.addHoldContext.fromName} 与 ${this.addHoldContext.toName} 之间添加盘旋,到计划时间后沿切线飞往下一航点(原「下一格」航点将被移除)。`; + return `将冲突位置「${this.addHoldContext.toName}」替换为盘旋航点,到计划时间后沿切线飞往下一航点,路径顺序不变。`; } }, mounted() { @@ -1034,12 +1031,6 @@ export default { this.showAddHoldDialog = true; }, - openAddHoldThenCreateRoute(plan) { - if (!plan || !plan.id) return; - this.addHoldContext = { mode: 'drawing_first', plan }; - this.addHoldForm = { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: 60 }; - this.showAddHoldDialog = true; - }, /** 弹窗点击“确定”:正式将数据保存到后端数据库 */ async confirmSaveNewRoute() { // 1. 严格校验 @@ -2230,22 +2221,6 @@ export default { this.$message.success('已插入盘旋,继续点选航点后右键结束保存'); return; } - if (this.addHoldContext.mode === 'drawing_first') { - const holdParams = this.addHoldForm.holdType === 'hold_circle' - ? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise } - : { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise }; - this.showAddHoldDialog = false; - const plan = this.addHoldContext.plan; - this.addHoldContext = null; - this.createRoute(plan); - this.$nextTick(() => { - if (this.$refs.cesiumMap && this.$refs.cesiumMap.setInitialHoldParams) { - this.$refs.cesiumMap.setInitialHoldParams(holdParams); - } - }); - this.$message.success('请先点击盘旋中心,再点击下一航点,然后继续绘制或右键结束'); - return; - } const { routeId, legIndex } = this.addHoldContext; const route = this.routes.find(r => r.id === routeId); if (!route || !route.waypoints || route.waypoints.length < 2) { @@ -2253,36 +2228,32 @@ export default { return; } const waypoints = route.waypoints; - const prevWp = waypoints[legIndex]; - const nextWp = waypoints[legIndex + 1]; - const newSeq = (prevWp.seq != null ? Number(prevWp.seq) : legIndex + 1) + 1; - const baseSeq = prevWp.seq != null ? Number(prevWp.seq) : legIndex + 1; + const targetWp = waypoints[legIndex + 1]; + if (!targetWp || !targetWp.id) { + this.$message.warning('目标航点无效'); + return; + } const holdParams = this.addHoldForm.holdType === 'hold_circle' ? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise } : { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise }; const startTime = this.addHoldForm.startTimeMinutes !== '' && this.addHoldForm.startTimeMinutes != null && !Number.isNaN(Number(this.addHoldForm.startTimeMinutes)) ? this.minutesToStartTime(Number(this.addHoldForm.startTimeMinutes)) - : (nextWp.startTime || 'K+01:00'); + : (targetWp.startTime || 'K+01:00'); try { - await addWaypoints({ - routeId, + await updateWaypoints({ + id: targetWp.id, + routeId: routeId, name: 'HOLD', - seq: newSeq, - lat: nextWp.lat, - lng: nextWp.lng, - alt: nextWp.alt != null ? nextWp.alt : prevWp.alt, - speed: prevWp.speed || 800, + seq: targetWp.seq, + lat: targetWp.lat, + lng: targetWp.lng, + alt: targetWp.alt, + speed: targetWp.speed != null ? targetWp.speed : 800, startTime, + turnAngle: targetWp.turnAngle != null ? targetWp.turnAngle : 0, pointType: this.addHoldForm.holdType, holdParams: JSON.stringify(holdParams) }); - await delWaypoints(nextWp.id); - for (let i = legIndex + 2; i < waypoints.length; i++) { - const w = waypoints[i]; - if (w.id) { - await updateWaypoints({ ...w, seq: baseSeq + (i - legIndex) }); - } - } this.showAddHoldDialog = false; this.addHoldContext = null; await this.getList(); @@ -2292,9 +2263,9 @@ export default { this.$refs.cesiumMap.renderRouteWaypoints(updated.waypoints, routeId, updated.platformId, updated.platform, this.parseRouteStyle(updated.attributes)); this.$nextTick(() => this.updateDeductionPositions()); } - this.$message.success('已添加盘旋航点'); + this.$message.success('已将冲突位置航点替换为盘旋航点'); } catch (e) { - this.$message.error(e.msg || '添加盘旋失败'); + this.$message.error(e.msg || '替换盘旋失败'); console.error(e); } }, From b9f3daa8c6c9c856a92810b370b472027775b04b Mon Sep 17 00:00:00 2001 From: ctw <1051735452@qq.com> Date: Sun, 8 Feb 2026 14:53:58 +0800 Subject: [PATCH 05/19] =?UTF-8?q?=E5=8F=AF=E6=8B=96=E6=8B=BD=E5=9B=BE?= =?UTF-8?q?=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ruoyi-ui/.env.development | 2 +- ruoyi-ui/src/views/cesiumMap/ContextMenu.vue | 30 ++ ruoyi-ui/src/views/cesiumMap/index.vue | 526 ++++++++++++++++++++++++++- ruoyi-ui/src/views/childRoom/RightPanel.vue | 58 ++- ruoyi-ui/src/views/childRoom/index.vue | 87 +++-- ruoyi-ui/vue.config.js | 2 +- 6 files changed, 659 insertions(+), 46 deletions(-) diff --git a/ruoyi-ui/.env.development b/ruoyi-ui/.env.development index 4d098ba..0e7127a 100644 --- a/ruoyi-ui/.env.development +++ b/ruoyi-ui/.env.development @@ -8,7 +8,7 @@ ENV = 'development' VUE_APP_BASE_API = '/dev-api' # 访问地址(绕过 /dev-api 代理,用于解决静态资源/图片访问 401 认证问题) -VUE_APP_BACKEND_URL = 'http://localhost:8080' +VUE_APP_BACKEND_URL = 'http://127.0.0.1:8080' # 路由懒加载 VUE_CLI_BABEL_TRANSPILE_MODULES = true diff --git a/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue b/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue index 796c828..096d43b 100644 --- a/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue +++ b/ruoyi-ui/src/views/cesiumMap/ContextMenu.vue @@ -272,6 +272,24 @@ + + + @@ -324,6 +342,18 @@ export default { this.$emit('delete') }, + handleShowTransformBox() { + this.$emit('show-transform-box') + }, + + handleEditPlatformPosition() { + this.$emit('edit-platform-position') + }, + + handleEditPlatformHeading() { + this.$emit('edit-platform-heading') + }, + toggleColorPicker(property) { if (this.showColorPickerFor === property) { this.showColorPickerFor = null diff --git a/ruoyi-ui/src/views/cesiumMap/index.vue b/ruoyi-ui/src/views/cesiumMap/index.vue index 9843259..f25a40e 100644 --- a/ruoyi-ui/src/views/cesiumMap/index.vue +++ b/ruoyi-ui/src/views/cesiumMap/index.vue @@ -34,6 +34,9 @@ :entity-data="contextMenu.entityData" @delete="deleteEntityFromContextMenu" @update-property="updateEntityProperty" + @edit-platform-position="openPlatformIconPositionDialog" + @edit-platform-heading="openPlatformIconHeadingDialog" + @show-transform-box="showPlatformIconTransformBox" /> @@ -44,6 +47,11 @@ @cancel="handleLocateCancel" /> + +
+ {{ platformIconRotateTip }} +
+
@@ -174,7 +182,25 @@ export default { currentScaleUnit: 'm', isApplyingScale: false, // 定位相关 - locateDialogVisible: false + locateDialogVisible: false, + // 平台图标图形化编辑:拖拽移动、旋转、伸缩框 + draggingPlatformIcon: null, + rotatingPlatformIcon: null, + platformIconRotateTip: '', + platformIconDragCameraEnabled: true, + selectedPlatformIcon: null, + pendingDragIcon: null, + dragStartScreenPos: null, + draggingRotateHandle: null, + draggingScaleHandle: null, + clickedOnEmpty: false, + PLATFORM_ICON_BASE_SIZE: 72, + PLATFORM_SCALE_BOX_HALF_DEG: 0.0005, + PLATFORM_SCALE_BOX_MIN_HALF_DEG: 0.0007, + PLATFORM_ROTATE_HANDLE_OFFSET_DEG: 0.0009, + PLATFORM_DRAG_THRESHOLD_PX: 10, + DESIRED_BOX_HALF_PX: 58, + DESIRED_ROTATE_OFFSET_PX: 50 } }, components: { @@ -538,6 +564,234 @@ export default { const backendUrl = process.env.VUE_APP_BACKEND_URL || ''; return backendUrl + cleanPath; }, + + /** 默认平台图标(无 imageUrl 时使用):简单飞机剪影 SVG */ + getDefaultPlatformIconDataUrl() { + const svg = ''; + return 'data:image/svg+xml,' + encodeURIComponent(svg); + }, + + /** 伸缩框旋转手柄图标 SVG:蓝底、白边、白色弧形箭头 */ + getRotationHandleIconDataUrl() { + const svg = '' + + '' + + '' + + '' + + ''; + return 'data:image/svg+xml,' + encodeURIComponent(svg); + }, + + /** 从手柄实体 id 解析出平台图标 entityData 与类型(rotate / scale-0~3) */ + getPlatformIconDataFromHandleId(handleEntityId) { + if (!handleEntityId || typeof handleEntityId !== 'string') return null; + if (handleEntityId.endsWith('-rotate-handle')) { + const baseId = handleEntityId.replace(/-rotate-handle$/, ''); + const ed = this.allEntities.find(e => e.type === 'platformIcon' && e.id === baseId); + return ed ? { entityData: ed, type: 'rotate' } : null; + } + const scaleIdx = handleEntityId.lastIndexOf('-scale-'); + if (scaleIdx !== -1) { + const baseId = handleEntityId.substring(0, scaleIdx); + const cornerIndex = parseInt(handleEntityId.substring(scaleIdx + 7), 10); + if (!isNaN(cornerIndex) && cornerIndex >= 0 && cornerIndex <= 3) { + const ed = this.allEntities.find(e => e.type === 'platformIcon' && e.id === baseId); + return ed ? { entityData: ed, type: 'scale', cornerIndex } : null; + } + } + return null; + }, + + /** 在当前视野下,图标位置处 1 像素对应的经纬度(用于伸缩框固定屏幕尺寸) */ + getDegreesPerPixelAt(lng, lat) { + if (!this.viewer || this.viewer.scene.mode !== Cesium.SceneMode.SCENE2D) { + return { degPerPxLng: this.PLATFORM_SCALE_BOX_HALF_DEG / this.DESIRED_BOX_HALF_PX, degPerPxLat: this.PLATFORM_SCALE_BOX_HALF_DEG / this.DESIRED_BOX_HALF_PX }; + } + const center = Cesium.Cartesian3.fromDegrees(lng, lat); + const east = Cesium.Cartesian3.fromDegrees(lng + 0.005, lat); + const north = Cesium.Cartesian3.fromDegrees(lng, lat + 0.005); + const sc = Cesium.SceneTransforms.worldToWindowCoordinates(this.viewer.scene, center); + const se = Cesium.SceneTransforms.worldToWindowCoordinates(this.viewer.scene, east); + const sn = Cesium.SceneTransforms.worldToWindowCoordinates(this.viewer.scene, north); + if (!sc || !se || !sn) { + return { degPerPxLng: this.PLATFORM_SCALE_BOX_HALF_DEG / this.DESIRED_BOX_HALF_PX, degPerPxLat: this.PLATFORM_SCALE_BOX_HALF_DEG / this.DESIRED_BOX_HALF_PX }; + } + const pxLng = Math.max(1, Math.abs(se.x - sc.x)); + const pxLat = Math.max(1, Math.abs(sn.y - sc.y)); + return { + degPerPxLng: 0.005 / pxLng, + degPerPxLat: 0.005 / pxLat + }; + }, + + /** 更新平台图标 billboard 的宽高(根据 iconScale) */ + updatePlatformIconBillboardSize(entityData) { + if (!entityData || !entityData.entity || !entityData.entity.billboard) return; + const scale = Math.max(0.2, Math.min(3, entityData.iconScale || 1)); + entityData.iconScale = scale; + const size = this.PLATFORM_ICON_BASE_SIZE * scale; + entityData.entity.billboard.width = new Cesium.ConstantProperty(size); + entityData.entity.billboard.height = new Cesium.ConstantProperty(size); + }, + + /** 显示伸缩框:旋转手柄 + 四角缩放手柄 + 矩形边线(按屏幕像素固定尺寸,任意缩放都易点) */ + showTransformHandles(entityData) { + if (!this.viewer || !entityData || entityData.type !== 'platformIcon') return; + this.removeTransformHandles(entityData); + const id = entityData.id; + const lng = entityData.lng; + const lat = entityData.lat; + const dpp = this.getDegreesPerPixelAt(lng, lat); + const baseHalfDeg = this.DESIRED_BOX_HALF_PX * Math.min(dpp.degPerPxLng, dpp.degPerPxLat); + const half = Math.max((entityData.iconScale || 1) * baseHalfDeg, baseHalfDeg * 0.5); + const rotOffsetLat = this.DESIRED_ROTATE_OFFSET_PX * dpp.degPerPxLat; + const rotOffset = Math.max(rotOffsetLat, this.PLATFORM_ROTATE_HANDLE_OFFSET_DEG); + const rotationHandle = this.viewer.entities.add({ + id: id + '-rotate-handle', + position: Cesium.Cartesian3.fromDegrees(lng, lat + rotOffset), + billboard: { + image: this.getRotationHandleIconDataUrl(), + width: 36, + height: 36, + verticalOrigin: Cesium.VerticalOrigin.BOTTOM, + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + disableDepthTestDistance: Number.POSITIVE_INFINITY + } + }); + const corners = [ + Cesium.Cartesian3.fromDegrees(lng + half, lat + half), + Cesium.Cartesian3.fromDegrees(lng - half, lat + half), + Cesium.Cartesian3.fromDegrees(lng - half, lat - half), + Cesium.Cartesian3.fromDegrees(lng + half, lat - half) + ]; + const scaleHandles = corners.map((pos, i) => + this.viewer.entities.add({ + id: id + '-scale-' + i, + position: pos, + point: { + pixelSize: 14, + color: Cesium.Color.WHITE, + outlineColor: Cesium.Color.fromCssColorString('#008aff'), + outlineWidth: 3, + disableDepthTestDistance: Number.POSITIVE_INFINITY + } + }) + ); + const linePositions = [corners[0], corners[1], corners[2], corners[3], corners[0]]; + const frameLine = this.viewer.entities.add({ + id: id + '-scale-frame', + polyline: { + positions: linePositions, + width: 3, + material: Cesium.Color.fromCssColorString('#008aff'), + clampToGround: true, + disableDepthTestDistance: Number.POSITIVE_INFINITY + } + }); + entityData.transformHandles = { + rotation: rotationHandle, + scale: scaleHandles, + frame: frameLine + }; + }, + + /** 移除伸缩框手柄与边线 */ + removeTransformHandles(entityData) { + if (!entityData || !entityData.transformHandles) return; + const h = entityData.transformHandles; + if (h.rotation) this.viewer.entities.remove(h.rotation); + if (h.scale) h.scale.forEach(e => this.viewer.entities.remove(e)); + if (h.frame) this.viewer.entities.remove(h.frame); + entityData.transformHandles = null; + }, + + /** 根据图标当前位置、iconScale 与当前视野更新伸缩框(保持固定像素尺寸) */ + updateTransformHandlePositions(entityData) { + if (!entityData || !entityData.transformHandles) return; + const lng = entityData.lng; + const lat = entityData.lat; + const dpp = this.getDegreesPerPixelAt(lng, lat); + const baseHalfDeg = this.DESIRED_BOX_HALF_PX * Math.min(dpp.degPerPxLng, dpp.degPerPxLat); + const half = Math.max((entityData.iconScale || 1) * baseHalfDeg, baseHalfDeg * 0.5); + const rotOffsetLat = this.DESIRED_ROTATE_OFFSET_PX * dpp.degPerPxLat; + const rotOffset = Math.max(rotOffsetLat, this.PLATFORM_ROTATE_HANDLE_OFFSET_DEG); + entityData.transformHandles.rotation.position = Cesium.Cartesian3.fromDegrees(lng, lat + rotOffset); + const corners = [ + Cesium.Cartesian3.fromDegrees(lng + half, lat + half), + Cesium.Cartesian3.fromDegrees(lng - half, lat + half), + Cesium.Cartesian3.fromDegrees(lng - half, lat - half), + Cesium.Cartesian3.fromDegrees(lng + half, lat - half) + ]; + entityData.transformHandles.scale.forEach((ent, i) => { ent.position = corners[i]; }); + const linePositions = [corners[0], corners[1], corners[2], corners[3], corners[0]]; + entityData.transformHandles.frame.polyline.positions = new Cesium.ConstantProperty(linePositions); + this.viewer.scene.requestRender(); + }, + + /** + * 从右侧平台列表拖拽放置到地图:在放置点添加平台图标实体,支持后续修改位置与朝向。 + * @param {Object} platform - 平台数据 { id, name, type, imageUrl, iconUrl, icon, color } + * @param {number} clientX - 放置点的视口 X + * @param {number} clientY - 放置点的视口 Y + */ + addPlatformIconFromDrag(platform, clientX, clientY) { + if (!this.viewer || !platform) return; + const canvas = this.viewer.scene.canvas; + const rect = canvas.getBoundingClientRect(); + const x = clientX - rect.left; + const y = clientY - rect.top; + const cartesian = this.viewer.camera.pickEllipsoid(new Cesium.Cartesian2(x, y), this.viewer.scene.globe.ellipsoid); + if (!cartesian) { + this.$message && this.$message.warning('请将图标放置到地图有效区域内'); + return; + } + const iconUrl = platform.imageUrl || platform.iconUrl; + const imageSrc = iconUrl ? this.formatPlatformIconUrl(iconUrl) : this.getDefaultPlatformIconDataUrl(); + this.entityCounter++; + const id = `platformIcon_${this.entityCounter}`; + const headingDeg = 0; + const rotation = Math.PI / 2 - (headingDeg * Math.PI / 180); + const iconScale = 1.0; + const size = this.PLATFORM_ICON_BASE_SIZE * iconScale; + const entity = this.viewer.entities.add({ + id, + name: platform.name || '平台', + position: cartesian, + billboard: { + image: imageSrc, + width: size, + height: size, + verticalOrigin: Cesium.VerticalOrigin.CENTER, + horizontalOrigin: Cesium.HorizontalOrigin.CENTER, + rotation, + scaleByDistance: new Cesium.NearFarScalar(500, 1.2, 200000, 0.35), + translucencyByDistance: new Cesium.NearFarScalar(1000, 1.0, 500000, 0.6) + } + }); + const { lat, lng } = this.cartesianToLatLng(cartesian); + const entityData = { + id, + type: 'platformIcon', + platformId: platform.id, + platform, + name: platform.name || '平台', + heading: headingDeg, + lat, + lng, + entity, + imageUrl: iconUrl, + label: platform.name || '平台', + iconScale, + transformHandles: null + }; + this.allEntities.push(entityData); + this.$nextTick(() => { + if (this.selectedPlatformIcon) this.removeTransformHandles(this.selectedPlatformIcon); + this.selectedPlatformIcon = entityData; + this.showTransformHandles(entityData); + this.$message && this.$message.success('已放置。上方箭头旋转、四角调大小、拖动图标移动;点击空白收起'); + }); + }, //正式航线渲染函数 renderRouteWaypoints(waypoints, routeId = 'default', platformId, platform, style) { if (!waypoints || waypoints.length < 1) return; @@ -1238,6 +1492,7 @@ export default { }) this.initScaleBar() this.initPointMovement() + this.initPlatformIconInteraction() this.initRightClickHandler() this.initHoverHandler() this.initMouseCoordinates() @@ -1254,6 +1509,10 @@ export default { const pickedObject = this.viewer.scene.pick(click.position); if (Cesium.defined(pickedObject) && pickedObject.id) { const entity = pickedObject.id; + const idStr = (entity && entity.id) ? entity.id : ''; + if (idStr && (idStr.endsWith('-rotate-handle') || idStr.indexOf('-scale-') !== -1)) return; + const platformIconData = this.allEntities.find(e => e.type === 'platformIcon' && e.entity === entity); + if (platformIconData) return; // --- 修正后的安全日志 --- console.log(">>> [点击检测] 实体ID:", entity.id); @@ -1328,6 +1587,183 @@ export default { } }, Cesium.ScreenSpaceEventType.RIGHT_CLICK) }, + + /** 平台图标图形化操作:伸缩框(旋转手柄 + 四角缩放)、拖拽移动、单击选中 */ + initPlatformIconInteraction() { + const canvas = this.viewer.scene.canvas; + this.platformIconHandler = new Cesium.ScreenSpaceEventHandler(canvas); + + this.platformIconHandler.setInputAction((click) => { + if (this.isDrawing || this.rotatingPlatformIcon) return; + const picked = this.viewer.scene.pick(click.position); + this.clickedOnEmpty = !Cesium.defined(picked) || !picked.id; + if (picked && picked.id) { + const idStr = typeof picked.id === 'string' ? picked.id : (picked.id.id || ''); + if (idStr.endsWith('-scale-frame')) { + this.clickedOnEmpty = false; + return; + } + const handleInfo = this.getPlatformIconDataFromHandleId(idStr); + if (handleInfo) { + this.clickedOnEmpty = false; + if (handleInfo.type === 'rotate') { + this.draggingRotateHandle = handleInfo.entityData; + this.platformIconDragCameraEnabled = this.viewer.scene.screenSpaceCameraController.enableInputs; + this.viewer.scene.screenSpaceCameraController.enableInputs = false; + return; + } + if (handleInfo.type === 'scale') { + this.draggingScaleHandle = { entityData: handleInfo.entityData, cornerIndex: handleInfo.cornerIndex }; + this.platformIconDragCameraEnabled = this.viewer.scene.screenSpaceCameraController.enableInputs; + this.viewer.scene.screenSpaceCameraController.enableInputs = false; + return; + } + } + const entityData = this.allEntities.find(e => e.type === 'platformIcon' && e.entity === picked.id); + if (entityData) { + this.pendingDragIcon = entityData; + this.dragStartScreenPos = { x: click.position.x, y: click.position.y }; + return; + } + } + }, Cesium.ScreenSpaceEventType.LEFT_DOWN); + + this.platformIconHandler.setInputAction((movement) => { + if (this.draggingRotateHandle) { + const ed = this.draggingRotateHandle; + if (ed.entity && ed.entity.position) { + const now = Cesium.JulianDate.now(); + const position = ed.entity.position.getValue(now); + if (position) { + const screenPos = Cesium.SceneTransforms.worldToWindowCoordinates(this.viewer.scene, position); + if (screenPos) { + const dx = movement.endPosition.x - screenPos.x; + const dy = movement.endPosition.y - screenPos.y; + const screenAngle = Math.atan2(dy, dx); + ed.entity.billboard.rotation = -screenAngle; + let headingDeg = (screenAngle + Math.PI / 2) * (180 / Math.PI); + if (headingDeg < 0) headingDeg += 360; + if (headingDeg >= 360) headingDeg -= 360; + ed.heading = Math.round(headingDeg); + } + } + } + this.viewer.scene.requestRender(); + return; + } + if (this.draggingScaleHandle) { + const { entityData: ed, cornerIndex } = this.draggingScaleHandle; + const cartesian = this.viewer.camera.pickEllipsoid(movement.endPosition, this.viewer.scene.globe.ellipsoid); + if (cartesian) { + const { lat: newLat, lng: newLng } = this.cartesianToLatLng(cartesian); + const lng = ed.lng; + const lat = ed.lat; + const dpp = this.getDegreesPerPixelAt(lng, lat); + const baseHalf = this.DESIRED_BOX_HALF_PX * Math.min(dpp.degPerPxLng, dpp.degPerPxLat); + let newHalfDeg; + if (cornerIndex === 0) newHalfDeg = Math.min(newLng - lng, newLat - lat); + else if (cornerIndex === 1) newHalfDeg = Math.min(lng - newLng, newLat - lat); + else if (cornerIndex === 2) newHalfDeg = Math.min(lng - newLng, lat - newLat); + else newHalfDeg = Math.min(newLng - lng, lat - newLat); + if (newHalfDeg > 0.0001 && baseHalf > 1e-10) { + ed.iconScale = Math.max(0.2, Math.min(3, newHalfDeg / baseHalf)); + this.updatePlatformIconBillboardSize(ed); + this.updateTransformHandlePositions(ed); + } + } + return; + } + if (this.pendingDragIcon) { + const dx = movement.endPosition.x - this.dragStartScreenPos.x; + const dy = movement.endPosition.y - this.dragStartScreenPos.y; + if (Math.sqrt(dx * dx + dy * dy) > (this.PLATFORM_DRAG_THRESHOLD_PX || 10)) { + this.draggingPlatformIcon = this.pendingDragIcon; + this.pendingDragIcon = null; + this.platformIconDragCameraEnabled = this.viewer.scene.screenSpaceCameraController.enableInputs; + this.viewer.scene.screenSpaceCameraController.enableInputs = false; + } + } + if (this.draggingPlatformIcon) { + const cartesian = this.viewer.camera.pickEllipsoid(movement.endPosition, this.viewer.scene.globe.ellipsoid); + if (cartesian) { + this.draggingPlatformIcon.entity.position = cartesian; + const { lat, lng } = this.cartesianToLatLng(cartesian); + this.draggingPlatformIcon.lat = lat; + this.draggingPlatformIcon.lng = lng; + if (this.selectedPlatformIcon === this.draggingPlatformIcon) { + this.updateTransformHandlePositions(this.draggingPlatformIcon); + } + } + this.viewer.scene.requestRender(); + } + if (this.rotatingPlatformIcon && this.rotatingPlatformIcon.entity && this.rotatingPlatformIcon.entity.position) { + const now = Cesium.JulianDate.now(); + const position = this.rotatingPlatformIcon.entity.position.getValue(now); + if (position) { + const screenPos = Cesium.SceneTransforms.worldToWindowCoordinates(this.viewer.scene, position); + if (screenPos) { + const dx = movement.endPosition.x - screenPos.x; + const dy = movement.endPosition.y - screenPos.y; + const screenAngle = Math.atan2(dy, dx); + this.rotatingPlatformIcon.entity.billboard.rotation = -screenAngle; + let headingDeg = (screenAngle + Math.PI / 2) * (180 / Math.PI); + if (headingDeg < 0) headingDeg += 360; + if (headingDeg >= 360) headingDeg -= 360; + this.rotatingPlatformIcon.heading = Math.round(headingDeg); + } + } + this.viewer.scene.requestRender(); + } + }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); + + this.platformIconHandler.setInputAction(() => { + if (this.pendingDragIcon) { + if (this.selectedPlatformIcon === this.pendingDragIcon) { + this.removeTransformHandles(this.selectedPlatformIcon); + this.selectedPlatformIcon = null; + } else { + if (this.selectedPlatformIcon) this.removeTransformHandles(this.selectedPlatformIcon); + this.selectedPlatformIcon = this.pendingDragIcon; + this.showTransformHandles(this.pendingDragIcon); + } + this.pendingDragIcon = null; + this.dragStartScreenPos = null; + } + if (this.clickedOnEmpty && this.selectedPlatformIcon) { + this.removeTransformHandles(this.selectedPlatformIcon); + this.selectedPlatformIcon = null; + } + this.clickedOnEmpty = false; + if (this.draggingRotateHandle) { + this.viewer.scene.screenSpaceCameraController.enableInputs = this.platformIconDragCameraEnabled; + this.draggingRotateHandle = null; + } + if (this.draggingScaleHandle) { + this.viewer.scene.screenSpaceCameraController.enableInputs = this.platformIconDragCameraEnabled; + this.draggingScaleHandle = null; + } + if (this.draggingPlatformIcon) { + this.viewer.scene.screenSpaceCameraController.enableInputs = this.platformIconDragCameraEnabled; + this.draggingPlatformIcon = null; + } + if (this.rotatingPlatformIcon) { + this.viewer.scene.screenSpaceCameraController.enableInputs = this.platformIconDragCameraEnabled; + this.rotatingPlatformIcon = null; + this.platformIconRotateTip = ''; + } + }, Cesium.ScreenSpaceEventType.LEFT_UP); + + const onCameraMoveEnd = () => { + if (this.selectedPlatformIcon && this.selectedPlatformIcon.transformHandles) { + this.updateTransformHandlePositions(this.selectedPlatformIcon); + } + }; + this.viewer.camera.moveEnd.addEventListener(onCameraMoveEnd); + this.platformIconCameraListener = () => { + this.viewer.camera.moveEnd.removeEventListener(onCameraMoveEnd); + }; + }, + // 初始化鼠标悬停事件处理器 initHoverHandler() { // 创建屏幕空间事件处理器 @@ -3385,6 +3821,53 @@ export default { this.contextMenu.visible = false } }, + + /** 右键「显示伸缩框」:选中该图标并显示旋转/缩放手柄 */ + showPlatformIconTransformBox() { + const fromMenu = this.contextMenu.entityData + if (!fromMenu || fromMenu.type !== 'platformIcon' || !fromMenu.entity) { + this.contextMenu.visible = false + return + } + const ed = this.allEntities.find(e => e.type === 'platformIcon' && e.id === fromMenu.id) || fromMenu + if (!ed.entity) { + this.contextMenu.visible = false + return + } + if (ed.lat == null || ed.lng == null) { + const now = Cesium.JulianDate.now() + const pos = ed.entity.position && ed.entity.position.getValue(now) + if (pos) { + const ll = this.cartesianToLatLng(pos) + ed.lat = ll.lat + ed.lng = ll.lng + } + } + if (this.selectedPlatformIcon) this.removeTransformHandles(this.selectedPlatformIcon) + this.selectedPlatformIcon = ed + this.showTransformHandles(ed) + this.contextMenu.visible = false + this.viewer.scene.requestRender() + this.$message && this.$message.success('已显示伸缩框') + }, + + /** 位置改为图形化:直接拖动图标即可,无需弹窗 */ + openPlatformIconPositionDialog() { + this.contextMenu.visible = false + this.$message && this.$message.info('请直接拖动图标以修改位置') + }, + + /** 进入旋转模式:移动鼠标设置朝向,单击结束(期间锁定地图) */ + openPlatformIconHeadingDialog() { + const ed = this.contextMenu.entityData + if (!ed || ed.type !== 'platformIcon' || !ed.entity) return + this.rotatingPlatformIcon = ed + this.platformIconRotateTip = '移动鼠标设置朝向,单击结束' + this.platformIconDragCameraEnabled = this.viewer.scene.screenSpaceCameraController.enableInputs + this.viewer.scene.screenSpaceCameraController.enableInputs = false + this.contextMenu.visible = false + this.$message && this.$message.info('移动鼠标可调整朝向,单击地图任意处结束') + }, removeEntity(id) { // 查找对应的实体数据 const index = this.allEntities.findIndex(e => @@ -3394,23 +3877,23 @@ export default { ) if (index > -1) { const entity = this.allEntities[index] + // 平台图标:移除伸缩框并清除选中 + if (entity.type === 'platformIcon') { + this.removeTransformHandles(entity) + if (this.selectedPlatformIcon === entity) this.selectedPlatformIcon = null + } // 从地图中移除 if (entity instanceof Cesium.Entity) { - // 情况 A: 直接是 Cesium Entity 对象 this.viewer.entities.remove(entity) } else if (entity.entity) { - // 情况 B: 包装对象,包含 entity 属性 this.viewer.entities.remove(entity.entity) } - // 移除线实体相关的点实体 if (entity.type === 'line' && entity.pointEntities) { entity.pointEntities.forEach(pointEntity => { this.viewer.entities.remove(pointEntity) }) } - // 从数组中移除 this.allEntities.splice(index, 1) - // 如果删除的是选中的实体,清空选中状态 if (this.selectedEntity && (this.selectedEntity.id === id || (this.selectedEntity.entity && this.selectedEntity.entity.id === id))) { this.selectedEntity = null } @@ -3466,12 +3949,15 @@ export default { this.viewer.entities.remove(entity); } - // 移除线实体相关的点实体 if (item.type === 'line' && item.pointEntities) { item.pointEntities.forEach(pointEntity => { this.viewer.entities.remove(pointEntity); }); } + if (item.type === 'platformIcon') { + this.removeTransformHandles(item); + if (this.selectedPlatformIcon === item) this.selectedPlatformIcon = null; + } } catch (e) { console.warn('删除实体失败:', e); } @@ -4093,6 +4579,15 @@ export default { this.pointMovementHandler = null } + if (this.platformIconHandler) { + this.platformIconHandler.destroy() + this.platformIconHandler = null + } + if (typeof this.platformIconCameraListener === 'function') { + this.platformIconCameraListener() + this.platformIconCameraListener = null + } + if (this.rightClickHandler) { this.rightClickHandler.destroy() this.rightClickHandler = null @@ -4209,6 +4704,23 @@ export default { display: none !important; } +/* 平台图标旋转模式提示条:放在顶部菜单下方,避免被遮挡(顶部栏约 60px) */ +.platform-icon-rotate-tip { + position: absolute; + top: 72px; + left: 50%; + transform: translateX(-50%); + z-index: 99; + background: rgba(0, 138, 255, 0.95); + color: #fff; + padding: 10px 20px; + border-radius: 6px; + font-size: 14px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + pointer-events: none; + white-space: nowrap; +} + /* 地图右下角信息面板:比例尺在上、经纬度在下,整体略下移减少遮挡 */ .map-info-panel { position: absolute; diff --git a/ruoyi-ui/src/views/childRoom/RightPanel.vue b/ruoyi-ui/src/views/childRoom/RightPanel.vue index 17eaaa0..27c6bbb 100644 --- a/ruoyi-ui/src/views/childRoom/RightPanel.vue +++ b/ruoyi-ui/src/views/childRoom/RightPanel.vue @@ -164,8 +164,10 @@
{ this.platformJustDragged = false }, 300) + try { + ev.dataTransfer.setData('application/json', JSON.stringify({ + id: platform.id, + name: platform.name, + type: platform.type, + imageUrl: platform.imageUrl, + iconUrl: platform.iconUrl, + icon: platform.icon, + color: platform.color + })) + ev.dataTransfer.effectAllowed = 'copy' + } catch (e) { + console.warn('Platform drag start failed', e) + } } } } @@ -829,6 +865,14 @@ export default { box-shadow: 0 2px 8px rgba(0, 138, 255, 0.15); } +.platform-item-draggable { + cursor: grab; +} + +.platform-item-draggable:active { + cursor: grabbing; +} + .platform-icon { width: 40px; height: 40px; diff --git a/ruoyi-ui/src/views/childRoom/index.vue b/ruoyi-ui/src/views/childRoom/index.vue index 09fe537..5c8a99c 100644 --- a/ruoyi-ui/src/views/childRoom/index.vue +++ b/ruoyi-ui/src/views/childRoom/index.vue @@ -1,8 +1,13 @@