diff --git a/ruoyi-ui/src/views/cesiumMap/index.vue b/ruoyi-ui/src/views/cesiumMap/index.vue index 2f957da..6781269 100644 --- a/ruoyi-ui/src/views/cesiumMap/index.vue +++ b/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,8 +613,31 @@ export default { } this.allEntities = this.allEntities.filter(item => item.id !== routeId && item.id !== `route-platform-${routeId}`); }, - /** 动态推演:更新某条航线的平台图标位置(position: { lng, lat, alt } 或 Cesium.Cartesian3) */ - updatePlatformPosition(routeId, position) { + /** + * 根据当前点与另一点计算航向角(弧度),用于飞机图标朝向。 + * 航向:北为 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; @@ -583,6 +651,13 @@ export default { 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') { diff --git a/ruoyi-ui/src/views/childRoom/index.vue b/ruoyi-ui/src/views/childRoom/index.vue index d9a2719..57978ad 100644 --- a/ruoyi-ui/src/views/childRoom/index.vue +++ b/ruoyi-ui/src/views/childRoom/index.vue @@ -331,6 +331,7 @@ import { listRoutes, getRoutes, addRoutes, updateRoutes, delRoutes } from "@/api import { updateWaypoints } 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"; import PlatformImportDialog from "@/views/dialogs/PlatformImportDialog.vue"; export default { name: 'MissionPlanningView', @@ -544,8 +545,9 @@ export default { this.isMenuHidden = true; // 初始化时右侧面板隐藏 this.isRightPanelHidden = true; - // 初始化菜单项为默认配置 + // 初始化菜单项为默认配置,再尝试加载当前用户已保存的配置 this.menuItems = [...this.defaultMenuItems]; + this.loadUserMenuConfig(); // 更新时间 this.updateTime(); @@ -1111,9 +1113,7 @@ export default { }, openKTimeSetDialog() { console.log("当前登录 ID (myId):", this.$store.getters.id); - console.log("当前房间 ownerId:", this.roomDetail ? this.roomDetail.ownerId : '无房间信息'); - console.log("当前角色 roles:", this.$store.getters.roles); if (!this.canSetKTime) { this.$message.info('仅房主或管理员可设定或修改 K 时'); @@ -1229,8 +1229,14 @@ export default { this.isIconEditMode = false }, - handleResetMenuItems() { + async handleResetMenuItems() { this.menuItems = [...this.defaultMenuItems] + try { + await saveMenuConfig({ + menuItems: JSON.stringify(this.menuItems), + position: this.menuPosition || 'left' + }) + } catch (e) { /* 未登录时仅本地恢复默认 */ } }, updateMenuItems(newItems) { @@ -1308,8 +1314,43 @@ export default { } }, - handleSaveMenuItems(savedItems) { + async handleSaveMenuItems(savedItems) { this.menuItems = [...savedItems] + // 持久化到当前账号 + try { + await saveMenuConfig({ + menuItems: JSON.stringify(this.menuItems), + position: this.menuPosition || 'left' + }) + } catch (e) { + // 未登录或接口失败时仅本地生效,仍提示保存成功(LeftMenu 已提示) + if (e && e.response && e.response.status === 401) { + this.$message.info('当前未登录,配置仅在本页有效;登录后保存可同步到账号') + } + } + }, + + /** 加载当前用户的左侧菜单配置(登录且有过保存时生效) */ + async loadUserMenuConfig() { + try { + const res = await getMenuConfig() + const data = res && res.data + if (!data) return + if (data.menuItems) { + let arr = [] + try { + arr = typeof data.menuItems === 'string' ? JSON.parse(data.menuItems) : data.menuItems + } catch (e) { /* 解析失败保留默认 */ } + if (Array.isArray(arr) && arr.length > 0) { + this.menuItems = arr + } + } + if (data.position && ['left', 'top', 'bottom'].includes(data.position)) { + this.menuPosition = data.position + } + } catch (e) { + // 未登录或接口失败则使用默认菜单,不提示 + } }, attributeEdit() { @@ -1418,9 +1459,15 @@ export default { this.$message.success('外部参数保存成功'); }, - savePageLayout(position) { + async savePageLayout(position) { this.menuPosition = position; this.$message.success(`菜单位置已设置为:${this.getPositionLabel(position)}`); + try { + await saveMenuConfig({ + menuItems: JSON.stringify(this.menuItems), + position: this.menuPosition || 'left' + }) + } catch (e) { /* 未登录时仅本地生效 */ } }, getPositionLabel(position) { @@ -1721,25 +1768,70 @@ export default { 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' }); + 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' }); + segments.push({ startTime: actualArrival, endTime: effectiveTime[i + 1], startPos: posNext, endPos: posNext, type: 'wait', legIndex: i }); } } return { segments, warnings }; }, - /** 从时间轴中取当前推演时间对应的位置 */ - getPositionFromTimeline(segments, minutesFromK) { + /** 从路径片段中按比例 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 + }; + }, + + /** 从时间轴中取当前推演时间对应的位置;若有 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) return last.endPos; + 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') return s.startPos; + 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, @@ -1750,12 +1842,24 @@ export default { return last.endPos; }, - /** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;返回 { position, warnings } */ + /** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧路径运动;返回 { position, nextPosition, previousPosition, warnings },用于计算机头朝向 */ getPositionAtMinutesFromK(waypoints, minutesFromK, globalMin, globalMax) { - if (!waypoints || waypoints.length === 0) return { position: null, warnings: [] }; + if (!waypoints || waypoints.length === 0) return { position: null, nextPosition: null, previousPosition: null, warnings: [] }; const { segments, warnings } = this.buildRouteTimeline(waypoints, globalMin, globalMax); - const position = this.getPositionFromTimeline(segments, minutesFromK); - return { position, warnings }; + 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)更新平台图标位置,并汇总航段提示 */ @@ -1767,9 +1871,9 @@ 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, warnings } = this.getPositionAtMinutesFromK(route.waypoints, minutesFromK, minMinutes, maxMinutes); + 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); + if (position) this.$refs.cesiumMap.updatePlatformPosition(routeId, position, nextPosition || previousPosition); }); this.deductionWarnings = [...new Set(allWarnings)]; },