wangxinping 2 months ago
parent
commit
c47727e800
  1. 1
      ruoyi-ui/src/assets/icons/svg/chongtu.svg
  2. 1
      ruoyi-ui/src/assets/icons/svg/circle.svg
  3. 1
      ruoyi-ui/src/assets/icons/svg/cj.svg
  4. 1
      ruoyi-ui/src/assets/icons/svg/cursor.svg
  5. 1
      ruoyi-ui/src/assets/icons/svg/dt.svg
  6. 1
      ruoyi-ui/src/assets/icons/svg/jx.svg
  7. 1
      ruoyi-ui/src/assets/icons/svg/ky.svg
  8. 1
      ruoyi-ui/src/assets/icons/svg/plan.svg
  9. 1
      ruoyi-ui/src/assets/icons/svg/sx.svg
  10. 22
      ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue
  11. 95
      ruoyi-ui/src/views/cesiumMap/index.vue
  12. 24
      ruoyi-ui/src/views/childRoom/LeftMenu.vue
  13. 13
      ruoyi-ui/src/views/childRoom/RightPanel.vue
  14. 34
      ruoyi-ui/src/views/childRoom/TopHeader.vue
  15. 459
      ruoyi-ui/src/views/childRoom/index.vue
  16. 59
      ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue

1
ruoyi-ui/src/assets/icons/svg/chongtu.svg

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770104908266" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1570" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 0C229.376 0 0 229.376 0 512s229.376 512 512 512 512-229.376 512-512S794.624 0 512 0z m0 76.8c240.128 0 435.2 194.56 435.2 434.688s-194.56 435.2-434.688 435.2c-240.128 0-435.2-195.072-435.2-435.2C76.8 271.872 271.872 76.8 512 76.8z" fill="#2F3238" p-id="1571"></path><path d="M566.784 274.944l-23.552 325.632c-2.048 17.408-17.408 30.208-34.816 28.16-14.848-1.536-26.624-13.312-28.16-28.16l-23.04-325.632c-3.072-30.208 19.456-56.832 49.664-59.904s57.344 19.456 59.904 50.176v9.728z m-12.8 540.16c23.552-22.528 24.064-60.416 1.536-83.456s-60.416-24.064-83.456-1.536l-1.536 1.536c-22.528 23.552-22.016 60.928 1.536 83.456 22.528 22.016 58.88 22.016 81.92 0z" fill="#2F3238" p-id="1572"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
ruoyi-ui/src/assets/icons/svg/circle.svg

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770108504823" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8592" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 64c60.5 0 119.2 11.8 174.4 35.2 53.3 22.6 101.3 54.9 142.4 96 41.2 41.2 73.5 89.1 96 142.4C948.2 392.8 960 451.5 960 512s-11.8 119.2-35.2 174.4c-22.6 53.3-54.9 101.3-96 142.4-41.2 41.2-89.1 73.5-142.4 96C631.2 948.2 572.5 960 512 960s-119.2-11.8-174.4-35.2c-53.3-22.6-101.3-54.9-142.4-96-41.2-41.2-73.5-89.1-96-142.4C75.8 631.2 64 572.5 64 512s11.8-119.2 35.2-174.4c22.6-53.3 54.9-101.3 96-142.4s89.1-73.5 142.4-96C392.8 75.8 451.5 64 512 64m0-64C229.2 0 0 229.2 0 512s229.2 512 512 512 512-229.2 512-512S794.8 0 512 0z" p-id="8593"></path></svg>

After

Width:  |  Height:  |  Size: 884 B

1
ruoyi-ui/src/assets/icons/svg/cj.svg

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770105055733" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3402" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M294.8 22.8L23.2 294.3c-12.5 12.5-12.5 32.8 0 45.3l656.2 656.2c12.5 12.5 32.8 12.5 45.3 0l271.5-271.5c12.5-12.5 12.5-32.8 0-45.3L340 22.8c-12.5-12.5-32.8-12.5-45.2 0z m-198 288.5l215-215c3.1-3.1 8.2-3.1 11.3 0l33.9 33.9c3.1 3.1 3.1 8.2 0 11.3l-96.2 96.2 45.3 45.3 96.2-96.2c3.1-3.1 8.2-3.1 11.3 0l22.4 22.4c3.1 3.1 3.1 8.2 0 11.3l-62.2 62.2L419 328l62.2-62.2c3.1-3.1 8.2-3.1 11.3 0l22.4 22.4c3.1 3.1 3.1 8.2 0 11.3l-62.2 62.2L498 407l62.2-62.2c3.1-3.1 8.2-3.1 11.3 0l23.1 23.1c3.1 3.1 3.1 8.2 0 11.3l-96.2 96.2 45.3 45.3 96.2-96.2c3.1-3.1 8.2-3.1 11.3 0l22.4 22.4c3.1 3.1 3.1 8.2 0 11.3l-62.2 62.2 45.3 45.3 62.2-62.2c3.1-3.1 8.2-3.1 11.3 0l22.4 22.4c3.1 3.1 3.1 8.2 0 11.3l-62.2 62.2 45.3 45.3 62.2-62.2c3.1-3.1 8.2-3.1 11.3 0l23.1 23.1c3.1 3.1 3.1 8.2 0 11.3l-96.3 96 45.3 45.3 96.2-96.2c3.1-3.1 8.2-3.1 11.3 0l33.9 33.9c3.1 3.1 3.1 8.2 0 11.3l-215 215c-3.1 3.1-8.2 3.1-11.3 0L96.8 322.6a7.85 7.85 0 0 1 0-11.3z" p-id="3403"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

1
ruoyi-ui/src/assets/icons/svg/cursor.svg

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770086690201" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5610" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M132.608 132.864l719.552 252.8-184.32 131.584 243.52 243.456-150.848 150.848-243.456-243.456-131.648 184.256-252.8-719.488z m139.52 139.392l139.328 396.8 94.72-132.48 254.336 254.272 30.144-30.144L536.32 506.368l132.48-94.72-396.736-139.392z" p-id="5611"></path></svg>

After

Width:  |  Height:  |  Size: 601 B

1
ruoyi-ui/src/assets/icons/svg/dt.svg

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770105218188" class="icon" viewBox="0 0 1185 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4428" xmlns:xlink="http://www.w3.org/1999/xlink" width="231.4453125" height="200"><path d="M1049.438316 571.661474l-438.972632 179.361684a45.433263 45.433263 0 0 1-34.70821 0h-0.592842l-438.972632-179.361684A44.894316 44.894316 0 0 1 153.546105 485.052632c5.928421 0.107789 11.802947 1.239579 17.354106 3.503157h0.700631l421.618526 172.409264 421.618527-172.409264A48.181895 48.181895 0 0 1 1032.192 485.052632a44.678737 44.678737 0 0 1 45.702737 43.654736 44.948211 44.948211 0 0 1-28.456421 42.954106z m0-208.680421l-438.972632 172.624842a46.996211 46.996211 0 0 1-34.70821 0h-0.592842L136.192 362.981053a41.930105 41.930105 0 0 1-25.6-54.433685 43.115789 43.115789 0 0 1 25.6-24.791579l438.972632-172.570947h0.646736a36.109474 36.109474 0 0 1 17.354106-3.341474 38.588632 38.588632 0 0 1 17.354105 3.341474l438.972632 172.624842c22.635789 8.192 34.061474 32.552421 25.6 54.433684a43.331368 43.331368 0 0 1-25.70779 24.791579zM153.492211 646.790737a37.241263 37.241263 0 0 1 17.354105 3.449263h0.700631l421.564632 172.139789 421.618526-172.139789c5.389474-2.533053 11.371789-3.718737 17.354106-3.449263a44.732632 44.732632 0 0 1 45.756631 43.762526v0.970105c0 18.432-11.317895 34.869895-28.456421 41.283369l-438.918737 179.846737a37.133474 37.133474 0 0 1-17.354105 3.503158 34.816 34.816 0 0 1-17.408-3.503158h-0.538947l-438.972632-179.846737a44.032 44.032 0 0 1-14.821053-73.242948 44.732632 44.732632 0 0 1 32.121264-12.773052z" fill="#5F6477" p-id="4429"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

1
ruoyi-ui/src/assets/icons/svg/jx.svg

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770105419392" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5397" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M864 896H160a32 32 0 0 1-32-32V160a32 32 0 0 1 32-32h704a32 32 0 0 1 32 32v704a32 32 0 0 1-32 32zM192 832h640V192H192v640z" p-id="5398"></path></svg>

After

Width:  |  Height:  |  Size: 482 B

1
ruoyi-ui/src/assets/icons/svg/ky.svg

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770105772490" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7581" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M511.6 98.8c227.6 0 412.8 185.2 412.8 412.8s-185.2 412.8-412.8 412.8S99.2 739.2 99.2 511.6 284 98.8 511.6 98.8m0-56c-258.8 0-468.8 210-468.8 468.8s210 468.8 468.8 468.8 468.8-210 468.8-468.8S770.4 42.8 511.6 42.8z" fill="#494949" p-id="7582"></path><path d="M105.2 344.8H916c14.8 0 26.4 12.4 26.4 28s-11.6 28-26.4 28H105.2c-14.8 0-26.4-12.4-26.4-28s11.6-28 26.4-28zM105.2 612.8H916c14.8 0 26.4 12.4 26.4 28s-11.6 28-26.4 28H105.2c-14.8 0-26.4-12.4-26.4-28s11.6-28 26.4-28z" fill="#494949" p-id="7583"></path><path d="M348.8 920.4V109.6c0-14.8 12.4-26.4 28-26.4s28 11.6 28 26.4v810.8c0 14.8-12.4 26.4-28 26.4s-28-11.6-28-26.4zM616.8 920.4V109.6c0-14.8 12.4-26.4 28-26.4s28 11.6 28 26.4v810.8c0 14.8-12.4 26.4-28 26.4s-28-11.6-28-26.4z" fill="#494949" p-id="7584"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
ruoyi-ui/src/assets/icons/svg/plan.svg

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770104718600" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7572" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M751.5 160.5v608c0 17.7-14.3 32-32 32h-511c-17.7 0-32-14.3-32-32v-608c0-17.7 14.3-32 32-32h511c17.7 0 32 14.3 32 32z m0-96h-575c-35.3 0-64 28.7-64 64v672c0 35.3 28.7 64 64 64h575c35.3 0 64-28.7 64-64v-672c0-35.3-28.7-64-64-64z" p-id="7573"></path><path d="M639.5 288.5h-352c-17.7 0-32-14.3-32-32s14.3-32 32-32h352c17.7 0 32 14.3 32 32s-14.3 32-32 32zM639.5 480.5h-352c-17.7 0-32-14.3-32-32s14.3-32 32-32h352c17.7 0 32 14.3 32 32s-14.3 32-32 32zM447.5 672.5h-160c-17.7 0-32-14.3-32-32s14.3-32 32-32h160c17.7 0 32 14.3 32 32s-14.3 32-32 32z" p-id="7574"></path><path d="M847.5 159.5h-0.5v90.4c0.3 1.8 0.5 3.7 0.5 5.6v608c0 17.7-14.3 32-32 32h-639c0 35.3 28.7 64 64 64h607c35.3 0 64-28.7 64-64v-672c0-35.3-28.7-64-64-64z" p-id="7575"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

1
ruoyi-ui/src/assets/icons/svg/sx.svg

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1770108774437" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9619" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M1022.464 956.928V64c0-38.4-25.6-64-64-64C435.2 0 1.536 433.664 1.536 956.928c0 38.4 25.6 64 64 64h893.44c37.888 0 63.488-25.6 63.488-64z m-75.776-12.288H77.312c4.096-452.608 385.536-846.336 869.376-868.864v868.864z m-51.712-51.2" p-id="9620"></path></svg>

After

Width:  |  Height:  |  Size: 589 B

22
ruoyi-ui/src/views/cesiumMap/DrawingToolbar.vue

@ -9,7 +9,8 @@
@click="handleItemClick(item)"
:title="item.name"
>
<i :class="item.icon"></i>
<svg-icon v-if="isSvgIcon(item.icon)" :icon-class="item.icon" class="toolbar-svg-icon" />
<i v-else :class="item.icon"></i>
</div>
</div>
</div>
@ -42,9 +43,9 @@ export default {
allToolbarItems: [
{ id: 'mouse', name: '鼠标', icon: 'el-icon-position' },
{ id: 'polygon', name: '面', icon: 'el-icon-house' },
{ id: 'rectangle', name: '矩形', icon: 'el-icon-crop' },
{ id: 'circle', name: '圆形', icon: 'el-icon-circle-plus-outline' },
{ id: 'sector', name: '扇形', icon: 'el-icon-pie-chart' },
{ id: 'rectangle', name: '矩形', icon: 'jx' },
{ id: 'circle', name: '圆形', icon: 'circle' },
{ id: 'sector', name: '扇形', icon: 'sx' },
{ id: 'arrow', name: '箭头', icon: 'el-icon-right' },
{ id: 'text', name: '文本', icon: 'el-icon-document' },
{ id: 'image', name: '图片', icon: 'el-icon-picture-outline' },
@ -55,7 +56,7 @@ export default {
],
//
rangingToolbarItems: [
{ id: 'mouse', name: '鼠标', icon: 'el-icon-position' },
{ id: 'mouse', name: '鼠标', icon: 'cursor' },
{ id: 'point', name: '点', icon: 'el-icon-location' },
{ id: 'line', name: '线', icon: 'el-icon-edit-outline' },
{ id: 'clear', name: '清除', icon: 'el-icon-delete' }
@ -72,6 +73,11 @@ export default {
}
},
methods: {
/** 判断是否为本地 SVG 图标(非 Element 的 el-icon-* 类名) */
isSvgIcon(icon) {
return icon && typeof icon === 'string' && !icon.startsWith('el-icon-')
},
handleItemClick(item) {
if (item.id === 'clear') {
this.$emit('clear-all')
@ -148,6 +154,12 @@ export default {
box-shadow: 0 2px 8px rgba(0, 138, 255, 0.3);
}
.toolbar-item .toolbar-svg-icon {
width: 1em;
height: 1em;
font-size: 16px;
}
.toolbar-item:disabled {
opacity: 0.5;
cursor: not-allowed;

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

@ -428,11 +428,17 @@ export default {
}
});
});
// 线
// 线线
const iconUrl = (platform && platform.imageUrl) || (platform && platform.iconUrl);
if (iconUrl && originalPositions.length > 0) {
const platformBillboardId = `route-platform-${routeId}`;
const fullUrl = this.formatPlatformIconUrl(iconUrl);
let initialRotation;
const pathData = this.getRoutePathWithSegmentIndices(waypoints);
if (pathData.path && pathData.path.length >= 2) {
const heading = this.computeHeadingFromPositions(pathData.path[0], pathData.path[1]);
if (heading !== undefined) initialRotation = Math.PI / 2 - heading;
}
this.viewer.entities.add({
id: platformBillboardId,
name: (platform && platform.name) || '平台',
@ -445,7 +451,8 @@ export default {
verticalOrigin: Cesium.VerticalOrigin.CENTER,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
scaleByDistance: new Cesium.NearFarScalar(500, 2.0, 200000, 0.4),
translucencyByDistance: new Cesium.NearFarScalar(1000, 1.0, 500000, 0.6)
translucencyByDistance: new Cesium.NearFarScalar(1000, 1.0, 500000, 0.6),
...(initialRotation !== undefined && { rotation: initialRotation })
}
});
}
@ -551,6 +558,44 @@ export default {
}
return arc;
},
/**
* 获取与地图绘制一致的带转弯弧的路径用于推演时图标沿弧线运动
* @param {Array} waypoints - 航点列表需含 lng, lat, alt, speed, turnAngle
* @returns {{ path: Array<{lng,lat,alt}>, segmentEndIndices: number[] }} path 为路径点segmentEndIndices[i] 为第 i 航点 i -> i+1 path 中的结束下标
*/
getRoutePathWithSegmentIndices(waypoints) {
if (!waypoints || waypoints.length === 0) return { path: [], segmentEndIndices: [] };
const ellipsoid = this.viewer.scene.globe.ellipsoid;
const toLngLatAlt = (cartesian) => {
const carto = Cesium.Cartographic.fromCartesian(cartesian, ellipsoid);
return {
lng: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
alt: carto.height
};
};
const originalPositions = waypoints.map(wp =>
Cesium.Cartesian3.fromDegrees(parseFloat(wp.lng), parseFloat(wp.lat), Number(wp.alt) || 0)
);
const path = [];
const segmentEndIndices = [];
for (let i = 0; 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));
} 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)));
}
if (i >= 1) segmentEndIndices[i - 1] = path.length - 1;
}
return { path, segmentEndIndices };
},
removeRouteById(routeId) {
// routeId
const entityList = this.viewer.entities.values;
@ -568,6 +613,52 @@ export default {
}
this.allEntities = this.allEntities.filter(item => item.id !== routeId && item.id !== `route-platform-${routeId}`);
},
/**
* 根据当前点与另一点计算航向角弧度用于飞机图标朝向
* 航向北为 0顺时针为正Cesium billboard rotation 为自上而下看逆时针故设置 rotation = -heading
*/
computeHeadingFromPositions(current, other) {
if (!current || !other) return undefined;
const cartesian1 = current.x !== undefined && current.y !== undefined && current.z !== undefined
? current
: Cesium.Cartesian3.fromDegrees(Number(current.lng), Number(current.lat), Number(current.alt) || 0);
const cartesian2 = other.x !== undefined && other.y !== undefined && other.z !== undefined
? other
: Cesium.Cartesian3.fromDegrees(Number(other.lng), Number(other.lat), Number(other.alt) || 0);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(cartesian1);
const east = Cesium.Matrix4.getColumn(enu, 0, new Cesium.Cartesian3());
const north = Cesium.Matrix4.getColumn(enu, 1, new Cesium.Cartesian3());
const toOther = Cesium.Cartesian3.subtract(cartesian2, cartesian1, new Cesium.Cartesian3());
const e = Cesium.Cartesian3.dot(toOther, east);
const n = Cesium.Cartesian3.dot(toOther, north);
if (Math.abs(e) < 1e-10 && Math.abs(n) < 1e-10) return undefined;
const heading = Math.atan2(e, n);
return heading;
},
/** 动态推演:更新某条航线的平台图标位置与朝向(position: { lng, lat, alt } 或 Cesium.Cartesian3;directionPoint 为用于计算机头朝向的另一点,如下一位置或上一位置) */
updatePlatformPosition(routeId, position, directionPoint) {
if (!this.viewer) return;
const entity = this.viewer.entities.getById(`route-platform-${routeId}`);
if (!entity || !entity.position) return;
let cartesian;
if (position && position.x !== undefined && position.y !== undefined && position.z !== undefined) {
cartesian = position;
} else if (position && position.lng != null && position.lat != null) {
const alt = position.alt != null ? Number(position.alt) : 0;
cartesian = Cesium.Cartesian3.fromDegrees(Number(position.lng), Number(position.lat), alt);
} else {
return;
}
entity.position = cartesian;
if (entity.billboard && directionPoint) {
const heading = this.computeHeadingFromPositions(position, directionPoint);
if (heading !== undefined) {
// 90° 使 rotation = π/2 - heading
entity.billboard.rotation = Math.PI / 2 - heading;
}
}
},
checkCesiumLoaded() {
if (typeof Cesium === 'undefined') {
console.error('Cesium未加载,请检查CDN链接');

24
ruoyi-ui/src/views/childRoom/LeftMenu.vue

@ -30,24 +30,25 @@
@contextmenu.prevent="handleRightClick(item)"
:title="item.name"
>
<i :class="item.icon"></i>
<svg-icon v-if="isSvgIcon(item.icon)" :icon-class="item.icon" class="menu-item-svg-icon" />
<i v-else :class="item.icon"></i>
<div v-if="isEditMode" class="delete-icon" @click.stop="quickDelete(item)">
<i class="el-icon-close"></i>
</div>
</div>
</draggable>
<!-- 新增按钮仅在编辑模式下显示 -->
<div v-if="isEditMode" class="add-btn" @click="handleAdd" :title="$t('leftMenu.addNewMenu')">
<i class="el-icon-plus"></i>
</div>
<div v-if="isEditMode" class="save-btn" @click="handleSave" :title="$t('leftMenu.saveIconEdit')">
<i class="el-icon-check"></i>
</div>
</div>
</div>
<!-- 删除确认弹窗 -->
<el-dialog
:title="$t('leftMenu.confirmDelete')"
@ -66,7 +67,7 @@
<el-button type="danger" @click="confirmDelete" size="small">{{ $t('leftMenu.delete') }}</el-button>
</span>
</el-dialog>
<!-- 图标选择弹窗 -->
<icon-select-dialog
:visible.sync="showIconSelectDialog"
@ -140,6 +141,11 @@ export default {
}
},
methods: {
/** 判断是否为本地 SVG 图标(非 Element 的 el-icon-* 类名) */
isSvgIcon(icon) {
return icon && typeof icon === 'string' && !icon.startsWith('el-icon-')
},
handleHide() {
this.$emit('hide')
},
@ -439,6 +445,12 @@ export default {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.menu-item .menu-item-svg-icon {
width: 1em;
height: 1em;
font-size: 16px;
}
.menu-item.edit-mode {
cursor: grab;
}
@ -599,4 +611,4 @@ export default {
font-size: 14px;
color: #606266;
}
</style>
</style>

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

@ -85,7 +85,7 @@
<i class="el-icon-location tree-icon"></i>
<div class="tree-item-info">
<div class="tree-item-name">{{ point.name }}</div>
<div class="tree-item-meta">高度: {{ point.alt }}m | 速度: {{ point.speed }}</div>
<div class="tree-item-meta">高度: {{ point.alt }}m | 速度: {{ point.speed }}<template v-if="point.startTime"> | 相对K: {{ formatWaypointKTime(point.startTime) }}</template></div>
</div>
<div class="tree-item-actions">
<i class="el-icon-edit" title="编辑" @click.stop="handleOpenWaypointDialog(point, index, route.waypoints.length)"></i>
@ -334,6 +334,17 @@ export default {
}
},
methods: {
/** 航点 startTime(如 K+00:40:00)格式化为简短显示:K+40 或 K-15 */
formatWaypointKTime(startTime) {
if (!startTime || typeof startTime !== 'string') return '—';
const m = startTime.match(/K([+-])(\d{2}):(\d{2})/);
if (!m) return startTime;
const sign = m[1];
const h = parseInt(m[2], 10);
const min = parseInt(m[3], 10);
const totalMin = h * 60 + min;
return totalMin === 0 ? 'K+0' : `K${sign}${totalMin}`;
},
// /
togglePlan(planId) {
const index = this.expandedPlans.indexOf(planId)

34
ruoyi-ui/src/views/childRoom/TopHeader.vue

@ -233,11 +233,18 @@
</div>
</div>
<div class="info-box">
<div
class="info-box"
:class="{ 'clickable': true }"
@click="$emit('set-k-time')"
>
<i class="el-icon-timer info-icon"></i>
<div class="info-content">
<div class="info-label">{{ $t('topHeader.info.combatTime') }}</div>
<div class="info-value">{{ combatTime }}</div>
<div class="info-value">
{{ combatTime }}
<i v-if="canSetKTime" class="el-icon-edit-outline set-k-hint" title="点击设定或修改 K 时(房主/管理员可随时更改)"></i>
</div>
</div>
</div>
@ -312,6 +319,14 @@ export default {
type: String,
default: ''
},
roomDetail: {
type: Object,
default: null
},
canSetKTime: {
type: Boolean,
default: false
},
userAvatar: {
type: String,
default: 'https://cube.elemecdn.com/0/88dd03f9bf287d08f58fbcf58fddbf4a8c6/avatar.png'
@ -389,9 +404,7 @@ export default {
this.$emit('import-layer')
},
importRoute() {
this.$emit('import-route')
},
exportPlan() {
this.$emit('export-plan')
@ -856,6 +869,17 @@ export default {
font-weight: 600;
}
.info-box.clickable {
cursor: pointer;
}
.info-box .set-k-hint {
margin-left: 4px;
font-size: 12px;
color: #008aff;
vertical-align: middle;
}
.info-icon {
font-size: 20px;
color: #008aff;

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

@ -36,6 +36,26 @@
<el-button type="primary" @click="confirmSaveNewRoute"> </el-button>
</div>
</el-dialog>
<!-- 设定/修改 K 时弹窗房主或管理员可随时打开并修改 -->
<el-dialog title="设定 / 修改 K 时" :visible.sync="showKTimeSetDialog" width="420px" :append-to-body="true">
<el-form label-width="90px">
<el-form-item label="K 时(基准)">
<el-date-picker
v-model="kTimeForm.dateTime"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="选择日期和时间"
style="width: 100%"
/>
</el-form-item>
<p class="k-time-tip">航线的任务时间将以此 K 时为基准进行加减航点表时间为相对 K 的分钟数房主/管理员可随时再次点击作战时间修改 K </p>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="showKTimeSetDialog = false"> </el-button>
<el-button type="primary" @click="saveKTime"> </el-button>
</div>
</el-dialog>
</div>
<!-- 顶部导航栏 -->
<top-header
@ -43,9 +63,12 @@
:online-count="onlineCount"
:combat-time="combatTime"
:astro-time="astroTime"
:room-detail="roomDetail"
:can-set-k-time="canSetKTime"
:user-avatar="userAvatar"
:is-icon-edit-mode="isIconEditMode"
@select-nav="selectTopNav"
@set-k-time="openKTimeSetDialog"
@save-plan="savePlan"
@import-plan-file="importPlanFile"
@import-acd="importACD"
@ -156,6 +179,7 @@
<div class="popup-hide-btn" @click="hideKTimePopup" title="隐藏K时">
<i class="el-icon-arrow-down"></i>
</div>
<p class="deduction-hint">仅推演当前展示的航线K 时可随时由房主/管理员在右上角作战时间处修改</p>
<div class="timeline-controls">
<div class="current-time blue-time">
<i class="el-icon-time"></i>
@ -198,8 +222,13 @@
</div>
</div>
</div>
<div v-if="deductionWarnings.length > 0" class="deduction-warnings">
<i class="el-icon-warning-outline"></i>
<span>{{ deductionWarnings[0] }}</span>
<el-tooltip v-if="deductionWarnings.length > 1" :content="deductionWarnings.join(';')" placement="top">
<span class="warnings-more"> {{ deductionWarnings.length }} </span>
</el-tooltip>
</div>
</div>
<!-- 在线成员弹窗 -->
@ -301,6 +330,7 @@ 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 { listLib,addLib,delLib} from "@/api/system/lib";
import { getRooms, updateRooms } from "@/api/system/rooms";
import PlatformImportDialog from "@/views/dialogs/PlatformImportDialog.vue";
export default {
name: 'MissionPlanningView',
@ -353,6 +383,9 @@ export default {
onlineCount: 30,
combatTime: 'K+01:30:45',
astroTime: '',
roomDetail: null,
showKTimeSetDialog: false,
kTimeForm: { dateTime: null },
//
isMenuHidden: true, //
@ -361,14 +394,14 @@ export default {
//
defaultMenuItems: [
{ id: 'file', name: '方案', icon: 'el-icon-folder-opened' },
{ id: 'start', name: '冲突', icon: 'el-icon-error' },
{ id: 'file', name: '方案', icon: 'plan' },
{ id: 'start', name: '冲突', icon: 'chongtu' },
{ id: 'insert', name: '平台', icon: 'el-icon-s-platform' },
{ id: 'pattern', name: '图案', icon: 'el-icon-picture-outline-round' },
{ id: 'pattern', name: '空域', icon: 'ky' },
{ id: 'deduction', name: '推演', icon: 'el-icon-video-play' },
{ id: 'modify', name: '修改', icon: 'el-icon-edit-outline' },
{ id: 'modify', name: '测距', icon: 'cj' },
{ id: 'refresh', name: '刷新', icon: 'el-icon-refresh' },
{ id: 'basemap', name: '底图', icon: 'el-icon-picture' },
{ id: 'basemap', name: '底图', icon: 'dt' },
{ id: 'save', name: '保存', icon: 'el-icon-document-checked' },
{ id: 'import', name: '导入', icon: 'el-icon-upload2' },
{ id: 'export', name: '导出', icon: 'el-icon-download' }
@ -468,6 +501,8 @@ export default {
//
timeProgress: 45,
currentTime: 'K+01:15:30',
deductionMinutesFromK: 0,
deductionWarnings: [],
isPlaying: false,
playbackSpeed: 1,
playbackInterval: null,
@ -476,6 +511,33 @@ export default {
userAvatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
};
},
watch: {
timeProgress: {
handler() {
this.updateTimeFromProgress();
},
immediate: true
}
},
computed: {
isRoomOwner() {
if (!this.roomDetail || this.roomDetail.ownerId == null) return false;
const myId = this.$store.getters.id;
return String(myId) === String(this.roomDetail.ownerId);
},
isAdmin() {
const roles = this.$store.getters.roles || [];
const id = this.$store.getters.id;
return (
roles.includes('admin') ||
String(id) === '1' ||
(Array.isArray(roles) && roles.some(r => String(r).toLowerCase() === 'admin'))
);
},
canSetKTime() {
return this.isRoomOwner || this.isAdmin;
}
},
mounted() {
this.getList();
//
@ -503,6 +565,7 @@ export default {
console.log("从路由接收到的真实房间 ID:", this.currentRoomId);
this.getList();
this.getPlatformList();
if (this.currentRoomId) this.getRoomDetail();
},
methods: {
//
@ -956,12 +1019,27 @@ export default {
} else {
updatedWaypoint.turnRadius = 0;
}
const response = await updateWaypoints(updatedWaypoint);
// startTimeK
const payload = {
id: updatedWaypoint.id,
routeId: updatedWaypoint.routeId,
name: updatedWaypoint.name,
seq: updatedWaypoint.seq,
lat: updatedWaypoint.lat,
lng: updatedWaypoint.lng,
alt: updatedWaypoint.alt,
speed: updatedWaypoint.speed,
startTime: (updatedWaypoint.startTime != null && updatedWaypoint.startTime !== '')
? updatedWaypoint.startTime
: 'K+00:00:00',
turnAngle: updatedWaypoint.turnAngle
};
const response = await updateWaypoints(payload);
if (response.code === 200) {
const index = this.selectedRouteDetails.waypoints.findIndex(p => p.id === updatedWaypoint.id);
if (index !== -1) {
//
this.selectedRouteDetails.waypoints.splice(index, 1, { ...updatedWaypoint });
// payload startTime
this.selectedRouteDetails.waypoints.splice(index, 1, { ...updatedWaypoint, ...payload });
//
if (this.$refs.cesiumMap) {
//
@ -998,18 +1076,72 @@ export default {
},
updateCombatTime() {
// K
//
const now = new Date();
const baseSeconds = 5400; // 130 = 5400
const currentSeconds = now.getSeconds() + now.getMinutes() * 60 + now.getHours() * 3600;
const combatSeconds = baseSeconds + (currentSeconds % 86400);
const hours = Math.floor(combatSeconds / 3600);
const minutes = Math.floor((combatSeconds % 3600) / 60);
const seconds = combatSeconds % 60;
this.combatTime = `K+${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
if (this.roomDetail && this.roomDetail.kAnchorTime) {
const k0 = new Date(this.roomDetail.kAnchorTime).getTime();
const now = Date.now();
const offsetMs = now - k0;
const sign = offsetMs >= 0 ? '+' : '-';
const absMs = Math.abs(offsetMs);
const hours = Math.floor(absMs / 3600000);
const minutes = Math.floor((absMs % 3600000) / 60000);
const seconds = Math.floor((absMs % 60000) / 1000);
this.combatTime = `K${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
} else {
this.combatTime = '未设定';
}
},
getRoomDetail() {
if (!this.currentRoomId) return;
getRooms(this.currentRoomId).then(res => {
if (res.code === 200 && res.data) this.roomDetail = res.data;
}).catch(() => {});
},
/** 将任意日期字符串格式化为 yyyy-MM-dd HH:mm:ss,供日期选择器使用 */
formatKTimeForPicker(val) {
if (!val) return null;
const d = new Date(val);
if (isNaN(d.getTime())) return null;
const y = d.getFullYear();
const m = (d.getMonth() + 1).toString().padStart(2, '0');
const day = d.getDate().toString().padStart(2, '0');
const h = d.getHours().toString().padStart(2, '0');
const min = d.getMinutes().toString().padStart(2, '0');
const s = d.getSeconds().toString().padStart(2, '0');
return `${y}-${m}-${day} ${h}:${min}:${s}`;
},
openKTimeSetDialog() {
if (!this.canSetKTime) {
this.$message.info('仅房主或管理员可设定或修改 K 时');
return;
}
if (!this.currentRoomId) {
this.$message.warning('请先进入任务房间');
return;
}
if (!this.roomDetail || !this.roomDetail.id) {
this.$message.warning('房间信息加载中或未找到,请稍后再试');
return;
}
const existing = this.roomDetail.kAnchorTime
? this.formatKTimeForPicker(this.roomDetail.kAnchorTime)
: null;
this.kTimeForm.dateTime = existing || this.formatKTimeForPicker(this.astroTime) || this.formatKTimeForPicker(new Date());
this.showKTimeSetDialog = true;
},
saveKTime() {
if (!this.roomDetail || !this.kTimeForm.dateTime) {
this.$message.warning('请选择 K 时');
return;
}
updateRooms({ id: this.roomDetail.id, kAnchorTime: this.kTimeForm.dateTime }).then(res => {
if (res.code === 200) {
this.$message.success('K 时已设定');
this.showKTimeSetDialog = false;
this.getRoomDetail();
} else {
this.$message.error(res.msg || '设定失败');
}
}).catch(() => this.$message.error('设定失败'));
},
//
@ -1401,6 +1533,9 @@ export default {
} else if (item.id === 'deduction') {
// /K
this.showKTimePopup = !this.showKTimePopup;
if (this.showKTimePopup) {
this.$nextTick(() => this.updateTimeFromProgress());
}
//
this.drawDom = false;
this.airspaceDrawDom = false;
@ -1440,7 +1575,7 @@ export default {
if (this.timeProgress >= 100) {
this.timeProgress = 0;
}
this.updateTimeFromProgress();
// watch timeProgress updateTimeFromProgress
}, 100);
},
@ -1470,15 +1605,225 @@ export default {
},
updateTimeFromProgress() {
const totalSeconds = Math.floor(this.timeProgress * 72);
const hours = Math.floor(totalSeconds / 3600) - 2;
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const sign = hours >= 0 ? '+' : '-';
const absHours = Math.abs(hours);
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
const span = Math.max(0, maxMinutes - minMinutes) || 120;
const currentMinutesFromK = minMinutes + (this.timeProgress / 100) * span;
this.deductionMinutesFromK = currentMinutesFromK;
const sign = currentMinutesFromK >= 0 ? '+' : '-';
const absMin = Math.abs(Math.floor(currentMinutesFromK));
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`;
this.updateDeductionPositions();
},
/** 仅针对当前展示的航线(activeRouteIds):从这些航线的航点中取推演时间范围(相对 K 的分钟数) */
getDeductionTimeRange() {
let minMinutes = 0;
let maxMinutes = 120;
const minutesList = [];
this.activeRouteIds.forEach(routeId => {
const route = this.routes.find(r => r.id === routeId);
if (!route || !route.waypoints || !route.waypoints.length) return;
route.waypoints.forEach(wp => {
const m = this.waypointStartTimeToMinutes(wp.startTime);
minutesList.push(m);
});
});
if (minutesList.length > 0) {
minMinutes = Math.min(...minutesList);
maxMinutes = Math.max(...minutesList);
if (maxMinutes <= minMinutes) maxMinutes = minMinutes + 120;
}
return { minMinutes, maxMinutes };
},
/** 将航点 startTime 字符串转为相对 K 的分钟数 */
waypointStartTimeToMinutes(s) {
if (!s || typeof s !== 'string') return 0;
const m = s.match(/K([+-])(\d{2}):(\d{2})/);
if (!m) return 0;
const sign = m[1] === '+' ? 1 : -1;
const h = parseInt(m[2], 10);
const min = parseInt(m[3], 10);
return sign * (h * 60 + min);
},
/** 两航点间近似距离(米),含高度差 */
segmentDistance(wp1, wp2) {
const R = 6371000;
const lat1 = (wp1.lat * Math.PI) / 180;
const lat2 = (wp2.lat * Math.PI) / 180;
const dlat = ((wp2.lat - wp1.lat) * Math.PI) / 180;
const dlng = ((wp2.lng - wp1.lng) * Math.PI) / 180;
const a = Math.sin(dlat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dlng / 2) ** 2;
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const horizontal = R * c;
const dalt = (wp2.alt || 0) - (wp1.alt || 0);
return Math.sqrt(horizontal * horizontal + dalt * dalt);
},
/**
* 按速度与计划时间构建航线时间轴含飞行段与提前到达则等待的等待段并做航段校验
* 若所有航点相对 K 时相同则在 [globalMin, globalMax] 内按航点顺序均匀分布避免闪现
*/
buildRouteTimeline(waypoints, globalMin, globalMax) {
const warnings = [];
if (!waypoints || waypoints.length === 0) return { segments: [], warnings };
const points = waypoints.map(wp => ({
lng: parseFloat(wp.lng),
lat: parseFloat(wp.lat),
alt: Number(wp.alt) || 0,
minutes: this.waypointStartTimeToMinutes(wp.startTime),
speed: Number(wp.speed) || 800
}));
const allSame = points.every(p => p.minutes === points[0].minutes);
if (allSame && points.length > 1) {
const span = Math.max(globalMax - globalMin, 1);
points.forEach((p, i) => {
p.minutes = globalMin + (span * i) / (points.length - 1);
});
} else {
points.sort((a, b) => a.minutes - b.minutes);
}
if (points.length === 1) {
const p = points[0];
const pos = { lng: p.lng, lat: p.lat, alt: p.alt };
return {
segments: [{ startTime: globalMin, endTime: globalMax, startPos: pos, endPos: pos, type: 'wait' }],
warnings
};
}
const effectiveTime = [points[0].minutes];
const segments = [];
for (let i = 0; i < points.length - 1; i++) {
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) {
const requiredSpeedKmh = (dist / 1000) / ((scheduled - points[i].minutes) / 60);
if (actualArrival > scheduled) {
warnings.push(
`某航段:距离约 ${(dist / 1000).toFixed(1)}km,计划 ${(scheduled - points[i].minutes).toFixed(0)} 分钟,当前速度 ${speedKmh}km/h 无法按时到达,约需 ≥${Math.ceil(requiredSpeedKmh)}km/h,请调整相对K时或速度。`
);
} else if (actualArrival < scheduled - 0.5) {
warnings.push('存在航段将提前到达下一航点,平台将在该点等待至计划时间再飞往下一段。');
}
}
effectiveTime[i + 1] = Math.max(actualArrival, scheduled);
const posCur = { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt };
const posNext = { lng: points[i + 1].lng, lat: points[i + 1].lat, alt: points[i + 1].alt };
segments.push({ startTime: effectiveTime[i], endTime: actualArrival, startPos: posCur, endPos: posNext, type: 'fly', legIndex: i });
if (actualArrival < effectiveTime[i + 1]) {
segments.push({ startTime: actualArrival, endTime: effectiveTime[i + 1], startPos: posNext, endPos: posNext, type: 'wait', legIndex: i });
}
}
return { segments, warnings };
},
/** 从路径片段中按比例 t(0~1)取点:按弧长比例插值 */
getPositionAlongPathSlice(pathSlice, t) {
if (!pathSlice || pathSlice.length === 0) return null;
if (pathSlice.length === 1 || t <= 0) return pathSlice[0];
if (t >= 1) return pathSlice[pathSlice.length - 1];
let totalLen = 0;
const lengths = [0];
for (let i = 1; i < pathSlice.length; i++) {
totalLen += this.segmentDistance(pathSlice[i - 1], pathSlice[i]);
lengths.push(totalLen);
}
const targetDist = t * totalLen;
let idx = 0;
while (idx < lengths.length - 1 && lengths[idx + 1] < targetDist) idx++;
const a = pathSlice[idx];
const b = pathSlice[idx + 1];
const segLen = lengths[idx + 1] - lengths[idx];
const segT = segLen > 0 ? (targetDist - lengths[idx]) / segLen : 0;
return {
lng: a.lng + (b.lng - a.lng) * segT,
lat: a.lat + (b.lat - a.lat) * segT,
alt: a.alt + (b.alt - a.alt) * segT
};
},
this.currentTime = `K${sign}${String(absHours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
/** 从时间轴中取当前推演时间对应的位置;若有 path/segmentEndIndices 则沿带转弯弧的路径插值 */
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]];
}
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);
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 (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) {
const startIdx = s.legIndex === 0 ? 0 : segmentEndIndices[s.legIndex - 1];
const endIdx = segmentEndIndices[s.legIndex];
const pathSlice = path.slice(startIdx, endIdx + 1);
if (pathSlice.length > 0) return this.getPositionAlongPathSlice(pathSlice, t);
}
return {
lng: s.startPos.lng + (s.endPos.lng - s.startPos.lng) * t,
lat: s.startPos.lat + (s.endPos.lat - s.startPos.lat) * t,
alt: s.startPos.alt + (s.endPos.alt - s.startPos.alt) * t
};
}
}
return last.endPos;
},
/** 根据当前推演时间(相对 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;
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;
}
}
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 };
},
/** 仅根据当前展示的航线(activeRouteIds)更新平台图标位置,并汇总航段提示 */
updateDeductionPositions() {
if (!this.$refs.cesiumMap || !this.$refs.cesiumMap.updatePlatformPosition) return;
const minutesFromK = this.deductionMinutesFromK != null ? this.deductionMinutesFromK : 0;
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
const allWarnings = [];
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);
if (warnings && warnings.length) allWarnings.push(...warnings);
if (position) this.$refs.cesiumMap.updatePlatformPosition(routeId, position, nextPosition || previousPosition);
});
this.deductionWarnings = [...new Set(allWarnings)];
},
//
@ -1497,9 +1842,14 @@ export default {
},
formatTimeTooltip(val) {
const hours = Math.floor(val / 4);
const minutes = (val % 4) * 15;
return `K+${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:00`;
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
const span = Math.max(0, maxMinutes - minMinutes) || 120;
const minutesFromK = minMinutes + (val / 100) * span;
const sign = minutesFromK >= 0 ? '+' : '-';
const absMin = Math.abs(Math.floor(minutesFromK));
const hours = Math.floor(absMin / 60);
const minutes = absMin % 60;
return `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
},
selectPlan(plan) {
if (plan && plan.id) {
@ -1971,6 +2321,47 @@ export default {
pointer-events: auto;
}
.k-time-tip {
font-size: 12px;
color: #909399;
margin: 8px 0 0;
line-height: 1.5;
}
.deduction-hint {
margin: 0 0 8px 0;
font-size: 12px;
color: #909399;
line-height: 1.4;
}
.deduction-warnings {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 6px 10px;
background: rgba(230, 162, 60, 0.15);
border: 1px solid rgba(230, 162, 60, 0.5);
border-radius: 6px;
font-size: 12px;
color: #b88230;
}
.deduction-warnings i {
flex-shrink: 0;
}
.deduction-warnings span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.deduction-warnings .warnings-more {
flex-shrink: 0;
color: #008aff;
cursor: help;
}
.popup-hide-btn {
position: absolute;
top: -28px;

59
ruoyi-ui/src/views/dialogs/WaypointEditDialog.vue

@ -47,14 +47,16 @@
</div>
</el-form-item>
<el-form-item label="起始时间" prop="startTime">
<el-time-picker
v-model="formData.startTime"
placeholder="请选择起始时间"
value-format="HH:mm:ss"
<el-form-item label="相对 K 时(分钟)" prop="minutesFromK">
<el-input-number
v-model="formData.minutesFromK"
:min="-9999"
:max="9999"
controls-position="right"
placeholder="相对 K 的分钟数,正数表示 K 后,负数表示 K 前"
style="width: 100%;"
>
</el-time-picker>
/>
<div class="form-tip">正数=K 之后负数=K 之前40 表示 K+00:40-15 表示 K-00:15</div>
</el-form-item>
</el-form>
@ -98,7 +100,7 @@ export default {
alt: 5000,
speed: 800,
turnAngle: 0,
startTime: '',
minutesFromK: 0,
currentIndex: -1,
totalPoints: 0,
isBankDisabled: false
@ -118,9 +120,6 @@ export default {
turnAngle: [
//
{ required: true, validator: validateNumber, message: '请输入有效转弯坡度', trigger: ['blur', 'change'] }
],
startTime: [
{ required: true, message: '请选择起始时间', trigger: 'change' }
]
}
};
@ -147,7 +146,7 @@ export default {
name: this.waypoint.name || '',
alt: this.waypoint.alt !== undefined && this.waypoint.alt !== null ? Number(this.waypoint.alt) : 0,
speed: this.waypoint.speed !== undefined && this.waypoint.speed !== null ? Number(this.waypoint.speed) : 0,
startTime: this.waypoint.startTime || '',
minutesFromK: this.startTimeToMinutes(this.waypoint.startTime),
currentIndex: index,
totalPoints: total,
isBankDisabled: locked,
@ -166,13 +165,40 @@ export default {
saveWaypoint() {
this.$refs.formRef.validate((valid) => {
if (valid) {
const { minutesFromK, ...rest } = this.formData;
const startTimeStr = this.minutesToStartTime(minutesFromK);
this.$emit('save', {
...this.waypoint,
...this.formData
...rest,
startTime: startTimeStr
});
this.closeDialog();
}
});
},
/** 将 startTime 字符串(如 K+00:40:00)转为相对 K 的分钟数 */
startTimeToMinutes(s) {
if (!s || typeof s !== 'string') return 0;
const m = s.match(/K([+-])(\d{2}):(\d{2})/);
if (!m) return 0;
const sign = m[1] === '+' ? 1 : -1;
const h = parseInt(m[2], 10);
const min = parseInt(m[3], 10);
return sign * (h * 60 + min);
},
/** 将相对 K 的分钟数转为 startTime 字符串 */
minutesToStartTime(m) {
const num = Number(m);
if (isNaN(num)) return 'K+00:00:00';
if (num >= 0) {
const h = Math.floor(num / 60);
const min = num % 60;
return `K+${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:00`;
}
const abs = Math.abs(num);
const h = Math.floor(abs / 60);
const min = abs % 60;
return `K-${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}:00`;
}
}
};
@ -262,4 +288,11 @@ export default {
border-top: 1px solid #e8e8e8;
gap: 10px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
line-height: 1.4;
}
</style>

Loading…
Cancel
Save