Compare commits

...

7 Commits

  1. 6
      package-lock.json
  2. 4
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/WhiteboardRoomService.java
  3. 1
      ruoyi-ui/src/lang/en.js
  4. 1
      ruoyi-ui/src/lang/zh.js
  5. 338
      ruoyi-ui/src/utils/conflictDetection.js
  6. 51
      ruoyi-ui/src/utils/timelinePosition.js
  7. 85
      ruoyi-ui/src/views/cesiumMap/ContextMenu.vue
  8. 537
      ruoyi-ui/src/views/cesiumMap/index.vue
  9. 3
      ruoyi-ui/src/views/childRoom/ConflictDrawer.vue
  10. 595
      ruoyi-ui/src/views/childRoom/GanttDrawer.vue
  11. 155
      ruoyi-ui/src/views/childRoom/WhiteboardPanel.vue
  12. 931
      ruoyi-ui/src/views/childRoom/index.vue
  13. 52
      ruoyi-ui/src/views/dialogs/ExportRoutesDialog.vue
  14. 73
      ruoyi-ui/src/views/index.vue
  15. 69
      ruoyi-ui/src/workers/conflictCheck.worker.js

6
package-lock.json

@ -1,6 +0,0 @@
{
"name": "cesium-map-object",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

4
ruoyi-admin/src/main/java/com/ruoyi/websocket/service/WhiteboardRoomService.java

@ -18,7 +18,7 @@ public class WhiteboardRoomService {
private static final String ROOM_WHITEBOARDS_PREFIX = "room:";
private static final String ROOM_WHITEBOARDS_SUFFIX = ":whiteboards";
private static final int EXPIRE_HOURS = 24;
private static final int EXPIRE_DAYS = 60;
@Autowired
@Qualifier("stringObjectRedisTemplate")
@ -119,6 +119,6 @@ public class WhiteboardRoomService {
private void saveWhiteboards(Long roomId, List<Object> list) {
String key = whiteboardsKey(roomId);
redisTemplate.opsForValue().set(key, list, EXPIRE_HOURS, TimeUnit.HOURS);
redisTemplate.opsForValue().set(key, list, EXPIRE_DAYS, TimeUnit.DAYS);
}
}

1
ruoyi-ui/src/lang/en.js

@ -127,7 +127,6 @@ export default {
conflictTypeAll: 'All',
conflictTypeTime: 'Time',
conflictTypeSpace: 'Space',
conflictTypeSpectrum: 'Spectrum',
air: 'Air',
sea: 'Sea',
ground: 'Ground'

1
ruoyi-ui/src/lang/zh.js

@ -127,7 +127,6 @@ export default {
conflictTypeAll: '全部',
conflictTypeTime: '时间',
conflictTypeSpace: '空间',
conflictTypeSpectrum: '频谱',
air: '空中',
sea: '海上',
ground: '地面'

338
ruoyi-ui/src/utils/conflictDetection.js

@ -1,15 +1,13 @@
/**
* 冲突检测工具时间冲突空间冲突频谱冲突
* 冲突检测工具时间冲突空间冲突
* - 时间航线时间窗重叠无法按时到达提前到达盘旋时间不足资源占用缓冲
* - 空间航迹最小间隔摆放平台距离过小禁限区入侵考虑高度带
* - 频谱同频/邻频 + 时间 + 带宽 + 地理范围叠加判定支持频谱资源台账
* - 空间航迹最小间隔摆放平台距离过小禁限区入侵考虑高度带Safety Column 间距
*/
/** 冲突类型 */
export const CONFLICT_TYPE = {
TIME: 'time',
SPACE: 'space',
SPECTRUM: 'spectrum'
SPACE: 'space'
}
/** 默认冲突配置(可由界面配置覆盖) */
@ -27,9 +25,18 @@ export const defaultConflictConfig = {
trackSampleStepMinutes: 1, // 航迹采样步长(分钟)
restrictedZoneNameKeywords: ['禁限', '禁区', '限制区'], // 空域名称含这些词视为禁限区
useRestrictedAltitudeBand: true, // 禁限区是否考虑高度带(若空域有 altMin/altMax)
// 频谱
adjacentFreqGuardMHz: 5, // 邻频保护间隔(MHz),两频段中心差小于此视为邻频冲突
spectrumConflictGeoOverlap: true // 频谱冲突是否要求地理范围叠加
/** 隐藏圆柱体(Safety Column):水平半径 R、垂直高度差阈值 H、同点先后时间阈值 ΔT(秒) */
safetyColumnRadiusMeters: 10000, // R:单柱水平半径(米)
safetyColumnHeightMeters: 300, // H:两机高度差小于此值且水平重叠时视为垂直方向不安全(米)
safetyColumnTimeThresholdSeconds: 60, // ΔT:先后经过同一位置的时间间隔小于此值报时间类冲突(秒)
/** 空间重叠采样步长(分钟),默认 0.5 即 30 秒 */
safetyColumnSpatialStepMinutes: 0.5,
/** 同点先后判定:时间维采样步长(分钟),默认 5 秒 */
safetyColumnTemporalStepMinutes: 5 / 60,
/** 判定「同一物理位置」的网格:经纬度量化(度),约百米量级 */
safetyColumnTemporalGridLngDeg: 0.001,
safetyColumnTemporalGridLatDeg: 0.001,
safetyColumnTemporalGridAltMeters: 100
}
/**
@ -87,7 +94,7 @@ export function detectTimeWindowOverlap(routes, waypointStartTimeToMinutes, conf
title: '航线时间窗重叠',
routeIds: [a.routeId, b.routeId],
routeNames: [a.routeName, b.routeName],
time: `K+${formatMinutes(a.min)}K+${formatMinutes(a.max)}K+${formatMinutes(b.min)}K+${formatMinutes(b.max)} 重叠`,
time: `${formatKLabel(a.min)}${formatKLabel(a.max)}${formatKLabel(b.min)}${formatKLabel(b.max)} 重叠`,
suggestion: '建议错开两条航线的计划时间窗,或为资源设置缓冲时间后再检测。'
})
}
@ -96,13 +103,230 @@ export function detectTimeWindowOverlap(routes, waypointStartTimeToMinutes, conf
return list
}
function formatMinutes(m) {
export function formatMinutes(m) {
const h = Math.floor(Math.abs(m) / 60)
const min = Math.floor(Math.abs(m) % 60)
const sign = m >= 0 ? '+' : '-'
return `${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`
}
/** 展示用完整 K 时标签:K+00:10(formatMinutes 已含 ±,勿再拼接「K+」前缀以免出现 K++) */
export function formatKLabel(m) {
return `K${formatMinutes(m)}`
}
/**
* 地表水平距离不含高度
*/
export function horizontalDistanceMeters(lng1, lat1, lng2, lat2) {
const R = 6371000
const toRad = x => (x * Math.PI) / 180
const φ1 = toRad(lat1)
const φ2 = toRad(lat2)
const Δφ = toRad(lat2 - lat1)
const Δλ = toRad(lng2 - lng1)
const a = Math.sin(Δφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) ** 2
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}
/**
* Safety Column 空间冲突同一时刻 t水平距离 < 2R |Δ高度| < H
* getSpeedKmhAtMinutes可选(routeId, minutesFromK) => number用于给出可执行的航速建议
*/
export function detectSafetyColumnSpatial(
routeIds,
minMinutes,
maxMinutes,
getPositionAtMinutesFromK,
config = {},
routeNamesById = {},
canPairFn = null,
getSpeedKmhAtMinutes = null
) {
const R = config.safetyColumnRadiusMeters != null ? Number(config.safetyColumnRadiusMeters) : defaultConflictConfig.safetyColumnRadiusMeters
const H = config.safetyColumnHeightMeters != null ? Number(config.safetyColumnHeightMeters) : defaultConflictConfig.safetyColumnHeightMeters
const step = config.safetyColumnSpatialStepMinutes != null ? Number(config.safetyColumnSpatialStepMinutes) : defaultConflictConfig.safetyColumnSpatialStepMinutes
const list = []
const twoR = 2 * R
const sampleTimes = []
for (let t = minMinutes; t <= maxMinutes; t += step) sampleTimes.push(t)
for (let i = 0; i < routeIds.length; i++) {
for (let j = i + 1; j < routeIds.length; j++) {
const ridA = routeIds[i]
const ridB = routeIds[j]
if (canPairFn && !canPairFn(ridA, ridB)) continue
for (const t of sampleTimes) {
const posA = getPositionAtMinutesFromK(ridA, t)
const posB = getPositionAtMinutesFromK(ridB, t)
if (!posA || !posB || posA.lng == null || posB.lng == null) continue
const horiz = horizontalDistanceMeters(posA.lng, posA.lat, posB.lng, posB.lat)
const altA = posA.alt != null ? Number(posA.alt) : 0
const altB = posB.alt != null ? Number(posB.alt) : 0
const dAlt = Math.abs(altA - altB)
if (horiz < twoR && dAlt < H) {
const needVertSep = Math.max(0, H - dAlt)
const nameA = routeNamesById[ridA] || `航线${ridA}`
const nameB = routeNamesById[ridB] || `航线${ridB}`
const vA = getSpeedKmhAtMinutes ? getSpeedKmhAtMinutes(ridA, t) : null
const vB = getSpeedKmhAtMinutes ? getSpeedKmhAtMinutes(ridB, t) : null
const vNumA = vA != null && Number.isFinite(vA) && vA > 0 ? vA : null
const vNumB = vB != null && Number.isFinite(vB) && vB > 0 ? vB : null
let vRef = 800
if (vNumA != null && vNumB != null) vRef = (vNumA + vNumB) / 2
else if (vNumA != null) vRef = vNumA
else if (vNumB != null) vRef = vNumB
const horizDeficitM = Math.max(0, twoR - horiz)
const reductionKmh = Math.min(150, Math.max(35, Math.round((horizDeficitM / 1000) * 10)))
const vSuggest = Math.max(120, Math.round(vRef - reductionKmh))
const kShiftMin = 1
const kLater = formatKLabel(t + kShiftMin)
const kEarlier = formatKLabel(t - kShiftMin)
const speedLine =
vNumA != null && vNumB != null
? `② 按当前推演航段速度(「${nameA}」约 ${Math.round(vNumA)} km/h、「${nameB}」约 ${Math.round(vNumB)} km/h),建议将其中一机在本段调至约 ${vSuggest} km/h(约比两机均值低 ${Math.round(vRef - vSuggest)} km/h),使通过该位置的时刻与另一机至少错开约 1 分钟。`
: `② 按参考航段速度约 ${Math.round(vRef)} km/h 计,建议将其中一机在本段调至约 ${vSuggest} km/h(约降低 ${Math.round(vRef - vSuggest)} km/h),使通过该冲突区域的先后间隔至少约 1 分钟。`
const kTimeLine =
`③ 将其中一条航线在冲突时刻附近航点的相对 K 时整体推迟或提前至少 ${kShiftMin} 分钟:例如由 ${formatKLabel(t)} 调至 ${kLater} 或更晚,或调至 ${kEarlier} 或更早。`
list.push({
type: CONFLICT_TYPE.SPACE,
subType: 'safety_column_spatial',
title: '飞机间距不足',
routeIds: [ridA, ridB],
routeNames: [nameA, nameB],
time: `${formatKLabel(t)}`,
position: `经度 ${((posA.lng + posB.lng) / 2).toFixed(5)}°, 纬度 ${((posA.lat + posB.lat) / 2).toFixed(5)}°, 高度约 ${((altA + altB) / 2).toFixed(0)} m`,
suggestion:
`两机在相同时刻占位过近:水平间距约 ${(horiz / 1000).toFixed(2)} km,高度差约 ${dAlt.toFixed(0)} m。` +
`① 将其中一条航线在冲突航段附近的高度上调或下调,使两机垂直间隔至少再拉开约 ${Math.ceil(needVertSep)} m(择一执行即可)。` +
speedLine +
kTimeLine,
severity: 'high',
positionLng: (posA.lng + posB.lng) / 2,
positionLat: (posA.lat + posB.lat) / 2,
positionAlt: (altA + altB) / 2,
minutesFromK: t,
safetyColumnHorizM: horiz,
safetyColumnDAltM: dAlt
})
break
}
}
}
}
return list
}
/**
* 隐藏圆柱体 时间冲突不同航线先后经过同一物理位置时间间隔 < ΔT
* 使用网格量化同一位置采样步长应小于 ΔT 以避免漏检
*/
export function detectSafetyColumnTemporal(
routeIds,
minMinutes,
maxMinutes,
getPositionAtMinutesFromK,
config = {},
routeNamesById = {}
) {
const deltaSec = config.safetyColumnTimeThresholdSeconds != null
? Number(config.safetyColumnTimeThresholdSeconds)
: defaultConflictConfig.safetyColumnTimeThresholdSeconds
const step = config.safetyColumnTemporalStepMinutes != null
? Number(config.safetyColumnTemporalStepMinutes)
: defaultConflictConfig.safetyColumnTemporalStepMinutes
const gLng = config.safetyColumnTemporalGridLngDeg != null
? Number(config.safetyColumnTemporalGridLngDeg)
: defaultConflictConfig.safetyColumnTemporalGridLngDeg
const gLat = config.safetyColumnTemporalGridLatDeg != null
? Number(config.safetyColumnTemporalGridLatDeg)
: defaultConflictConfig.safetyColumnTemporalGridLatDeg
const gAlt = config.safetyColumnTemporalGridAltMeters != null
? Number(config.safetyColumnTemporalGridAltMeters)
: defaultConflictConfig.safetyColumnTemporalGridAltMeters
const cellMap = new Map()
for (const rid of routeIds) {
for (let t = minMinutes; t <= maxMinutes; t += step) {
const pos = getPositionAtMinutesFromK(rid, t)
if (!pos || pos.lng == null || pos.lat == null) continue
const lng = Number(pos.lng)
const lat = Number(pos.lat)
const altN = Number(pos.alt) || 0
const ix = gLng > 0 ? Math.floor(lng / gLng) : 0
const iy = gLat > 0 ? Math.floor(lat / gLat) : 0
const iz = gAlt > 0 ? Math.floor(altN / gAlt) : 0
const key = `${ix}_${iy}_${iz}`
let arr = cellMap.get(key)
if (!arr) {
arr = []
cellMap.set(key, arr)
}
if (arr.length < 200) {
arr.push({ routeId: rid, tMinutes: t, lng: pos.lng, lat: pos.lat, alt: Number(pos.alt) || 0 })
}
}
}
const reported = new Set()
const list = []
for (const [, samples] of cellMap) {
if (samples.length < 2) continue
samples.sort((a, b) => a.tMinutes - b.tMinutes)
for (let a = 0; a < samples.length; a++) {
for (let b = a + 1; b < samples.length; b++) {
const sa = samples[a]
const sb = samples[b]
if (sa.routeId === sb.routeId) continue
const dtSec = Math.abs(sb.tMinutes - sa.tMinutes) * 60
if (dtSec <= 1e-6 || dtSec >= deltaSec) continue
const idA = String(sa.routeId)
const idB = String(sb.routeId)
const rLo = idA < idB ? idA : idB
const rHi = idA < idB ? idB : idA
const t1 = Math.min(sa.tMinutes, sb.tMinutes)
const t2 = Math.max(sa.tMinutes, sb.tMinutes)
const pairKey = `${rLo}_${rHi}_${t1.toFixed(5)}_${t2.toFixed(5)}`
if (reported.has(pairKey)) continue
reported.add(pairKey)
const nameA = routeNamesById[sa.routeId] || `航线${sa.routeId}`
const nameB = routeNamesById[sb.routeId] || `航线${sb.routeId}`
const tEarly = sa.tMinutes <= sb.tMinutes ? sa : sb
const tLate = sa.tMinutes <= sb.tMinutes ? sb : sa
const needDelaySec = Math.ceil(deltaSec - dtSec)
const midLng = (tEarly.lng + tLate.lng) / 2
const midLat = (tEarly.lat + tLate.lat) / 2
const midAlt = (tEarly.alt + tLate.alt) / 2
list.push({
type: CONFLICT_TYPE.TIME,
subType: 'safety_column_temporal',
title: '先后经过同点时间过近',
routeIds: [sa.routeId, sb.routeId],
routeNames: [nameA, nameB],
time: `先后约 ${Math.round(dtSec)} s(约 ${formatKLabel(tEarly.tMinutes)}${formatKLabel(tLate.tMinutes)}`,
position: `经度 ${midLng.toFixed(5)}°, 纬度 ${midLat.toFixed(5)}°, 高度约 ${midAlt.toFixed(0)} m`,
suggestion:
`两机先后经过同一空域位置,时间间隔仅约 ${Math.round(dtSec)} s,低于安全先后间隔要求。` +
`① 将其中一条航线相关航点的相对 K 时整体前移或后移,使先后到达间隔至少再拉开约 ${needDelaySec} s 以上。` +
`② 微调其中一机在邻近航段的飞行速度,使到达该位置的时刻错开。` +
`③ 若仍受航路结构限制,可为其中一机分配不同高度层,通过垂直间隔避免同点先后过近。`,
severity: 'high',
positionLng: midLng,
positionLat: midLat,
positionAlt: midAlt,
minutesFromK: tEarly.tMinutes,
safetyColumnTemporalDtSec: dtSec
})
}
}
}
return list
}
/**
* 空间冲突推演航迹最小间隔
* getPositionAtMinutesFromK: (routeId, minutesFromK) => { position: { lng, lat, alt } } | null
@ -130,7 +354,7 @@ export function detectTrackSeparation(routeIds, minMinutes, maxMinutes, getPosit
subType: 'track_separation',
title: '航迹间隔过小',
routeIds: [ridA, ridB],
time: `K+${formatMinutes(t)}`,
time: `${formatKLabel(t)}`,
position: `经度 ${(posA.lng + posB.lng) / 2}°, 纬度 ${(posA.lat + posB.lat) / 2}°`,
suggestion: `两机在该时刻距离约 ${(d / 1000).toFixed(1)} km,小于最小间隔 ${minSep / 1000} km。建议调整航线或时间窗,或增大最小间隔配置。`,
severity: 'high',
@ -239,7 +463,7 @@ export function detectRestrictedZoneIntrusion(routeIds, minMinutes, maxMinutes,
title: '禁限区入侵',
routeIds: [rid],
zoneName: zone.name || '禁限区',
time: `K+${formatMinutes(t)}`,
time: `${formatKLabel(t)}`,
position: `经度 ${pos.lng.toFixed(5)}°, 纬度 ${pos.lat.toFixed(5)}°, 高度 ${alt} m`,
suggestion: `航迹在 ${zone.name || '禁限区'} 内且高度在 [${altMin}, ${altMax}] m 范围内。建议调整航线避开该区域或调整禁限区高度带。`,
severity: 'high',
@ -313,96 +537,6 @@ export function parseRestrictedZonesFromDrawings(entities, keywords = defaultCon
return zones
}
// --------------- 频谱冲突与台账 ---------------
/**
* 频谱资源台账项同频/邻频 + 时间 + 带宽 + 地理范围
*/
export function createSpectrumLedgerEntry({ id, name, freqMHz, bandwidthMHz, startTimeMinutes, endTimeMinutes, geo }) {
return {
id: id || `spec_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
name: name || '',
freqMHz: Number(freqMHz) || 0,
bandwidthMHz: Number(bandwidthMHz) || 0,
startTimeMinutes: Number(startTimeMinutes) || 0,
endTimeMinutes: Number(endTimeMinutes) || 0,
geo: geo || null // { type: 'bbox', minLng, minLat, maxLng, maxLat } 或 { type: 'polygon', points: [{lng,lat}] }
}
}
/**
* 两频段是否同频或邻频中心频率差在带宽一半或 guard
*/
export function isSameOrAdjacentFreq(freq1, bw1, freq2, bw2, guardMHz = defaultConflictConfig.adjacentFreqGuardMHz) {
const c1 = freq1
const c2 = freq2
const half = (bw1 + bw2) / 2
const gap = Math.abs(c1 - c2)
if (gap <= 1e-6) return true // 同频
if (gap <= half || gap <= guardMHz) return true // 重叠或邻频
return false
}
/**
* 两地理范围是否重叠bbox polygon 简化bbox bbox
*/
export function geoOverlap(geo1, geo2) {
if (!geo1 || !geo2) return true // 未设范围视为全局重叠
const toBbox = (g) => {
if (g.type === 'bbox') return g
if (g.type === 'polygon' && g.points && g.points.length) {
const lngs = g.points.map(p => p.lng != null ? p.lng : p.x)
const lats = g.points.map(p => p.lat != null ? p.lat : p.y)
return { minLng: Math.min(...lngs), maxLng: Math.max(...lngs), minLat: Math.min(...lats), maxLat: Math.max(...lats) }
}
return null
}
const b1 = toBbox(geo1)
const b2 = toBbox(geo2)
if (!b1 || !b2) return true
const minLng1 = b1.minLng ?? b1.west
const maxLng1 = b1.maxLng ?? b1.east
const minLat1 = b1.minLat ?? b1.south
const maxLat1 = b1.maxLat ?? b1.north
const minLng2 = b2.minLng ?? b2.west
const maxLng2 = b2.maxLng ?? b2.east
const minLat2 = b2.minLat ?? b2.south
const maxLat2 = b2.maxLat ?? b2.north
return !(maxLng1 < minLng2 || maxLng2 < minLng1 || maxLat1 < minLat2 || maxLat2 < minLat1)
}
/**
* 频谱冲突检测台账内两两判定 同频/邻频 + 时间重叠 + 地理重叠
* spectrumLedger: createSpectrumLedgerEntry 数组
*/
export function detectSpectrumConflicts(spectrumLedger, config = {}) {
const guard = config.adjacentFreqGuardMHz != null ? config.adjacentFreqGuardMHz : defaultConflictConfig.adjacentFreqGuardMHz
const checkGeo = config.spectrumConflictGeoOverlap !== false
const list = []
for (let i = 0; i < spectrumLedger.length; i++) {
for (let j = i + 1; j < spectrumLedger.length; j++) {
const a = spectrumLedger[i]
const b = spectrumLedger[j]
if (!isSameOrAdjacentFreq(a.freqMHz, a.bandwidthMHz, b.freqMHz, b.bandwidthMHz, guard)) continue
if (!timeRangesOverlap(a.startTimeMinutes, a.endTimeMinutes, b.startTimeMinutes, b.endTimeMinutes, 0)) continue
if (checkGeo && !geoOverlap(a.geo, b.geo)) continue
list.push({
type: CONFLICT_TYPE.SPECTRUM,
subType: 'spectrum_overlap',
title: '频谱冲突',
routeIds: [],
routeNames: [a.name || `频谱${a.id}`, b.name || `频谱${b.id}`],
time: `时间重叠:K+${formatMinutes(a.startTimeMinutes)}~K+${formatMinutes(a.endTimeMinutes)} 与 K+${formatMinutes(b.startTimeMinutes)}~K+${formatMinutes(b.endTimeMinutes)}`,
position: checkGeo && a.geo ? `地理范围重叠` : '时间与频段重叠',
suggestion: `同频/邻频且时间与地理范围重叠。建议错开使用时间、调整频点或带宽、或缩小地理范围。`,
severity: 'high',
spectrumIds: [a.id, b.id]
})
}
}
return list
}
/**
* 合并并规范化冲突列表统一 idseveritycategory 等供 UI 展示
*/

51
ruoyi-ui/src/utils/timelinePosition.js

@ -77,28 +77,35 @@
return s.startPos
}
if (s.type === 'hold' && s.holdPath && s.holdPath.length) {
if (s.holdClosedLoopPath && s.holdClosedLoopPath.length >= 2 && s.holdLoopLength > 0 && s.speedKmh != null) {
const distM = (minutesFromK - s.startTime) * (s.speedKmh * 1000 / 60)
const n = Math.max(0, s.holdN != null ? s.holdN : 0)
const totalFly = (s.holdExitDistanceOnLoop || 0) + n * s.holdLoopLength
if (totalFly <= 0) return s.startPos
if (distM >= totalFly) return s.endPos
if (distM < n * s.holdLoopLength) {
const distOnLoop = ((distM % s.holdLoopLength) + s.holdLoopLength) % s.holdLoopLength
const tPath = distOnLoop / s.holdLoopLength
return getPositionAlongPathSlice(s.holdClosedLoopPath, tPath)
}
const distToExit = distM - n * s.holdLoopLength
const exitDist = s.holdExitDistanceOnLoop || 1
const tPath = Math.min(1, distToExit / exitDist)
if (s.holdEntryToExitPath && s.holdEntryToExitPath.length > 0) {
return getPositionAlongPathSlice(s.holdEntryToExitPath, tPath)
}
return getPositionAlongPathSlice(s.holdClosedLoopPath, distToExit / s.holdLoopLength)
}
return getPositionAlongPathSlice(s.holdPath, t)
}
if (s.type === 'hold' && s.holdPath && s.holdPath.length) {
if (s.holdClosedLoopPath && s.holdClosedLoopPath.length >= 2 && s.holdLoopLength > 0 && s.speedKmh != null) {
const distM = (minutesFromK - s.startTime) * (s.speedKmh * 1000 / 60)
const n = Math.max(0, s.holdN != null ? s.holdN : 0)
const totalFly = (s.holdExitDistanceOnLoop || 0) + n * s.holdLoopLength
if (totalFly <= 0) return s.startPos
if (distM >= totalFly) return s.endPos
if (distM < n * s.holdLoopLength) {
const distOnLoop = ((distM % s.holdLoopLength) + s.holdLoopLength) % s.holdLoopLength
const tPath = distOnLoop / s.holdLoopLength
return getPositionAlongPathSlice(s.holdClosedLoopPath, tPath)
}
const distToExit = distM - n * s.holdLoopLength
const exitDist = s.holdExitDistanceOnLoop || 1
const tPath = Math.min(1, distToExit / exitDist)
if (s.holdEntryToExitPath && s.holdEntryToExitPath.length > 0) {
return getPositionAlongPathSlice(s.holdEntryToExitPath, tPath)
}
return getPositionAlongPathSlice(s.holdClosedLoopPath, distToExit / s.holdLoopLength)
}
if (s.holdGeometricDist != null && s.holdGeometricDist > 0 && s.speedKmh != null) {
const speedMpMin = (s.speedKmh * 1000) / 60
const distM = (minutesFromK - s.startTime) * speedMpMin
if (distM >= s.holdGeometricDist) return s.endPos
const tPath = Math.max(0, Math.min(1, distM / s.holdGeometricDist))
return getPositionAlongPathSlice(s.holdPath, tPath)
}
return getPositionAlongPathSlice(s.holdPath, t)
}
if (s.type === 'fly' && s.pathSlice && s.pathSlice.length) {
return getPositionAlongPathSlice(s.pathSlice, t)

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

@ -1,5 +1,12 @@
<template>
<div class="context-menu" v-if="visible" :style="positionStyle">
<!-- 重叠/接近时切换选择其他图形 -->
<div class="menu-section" v-if="pickList && pickList.length > 1">
<div class="menu-item" @click="$emit('switch-pick')">
<span class="menu-icon">🔄</span>
<span>切换选择 ({{ (pickIndex || 0) + 1 }}/{{ pickList.length }})</span>
</div>
</div>
<div class="menu-section" v-if="!entityData || (entityData.type !== 'routePlatform' && entityData.type !== 'route')">
<div class="menu-item" @click="handleDelete">
<span class="menu-icon">🗑</span>
@ -14,6 +21,10 @@
<span class="menu-icon">📝</span>
<span>编辑航点</span>
</div>
<div class="menu-item" v-if="isHoldWaypoint" @click="openEditHoldSpeedDialog">
<span class="menu-icon"></span>
<span>编辑盘旋速度</span>
</div>
<div class="menu-item" @click.stop="toggleAddWaypointExpand('before')">
<span class="menu-icon"></span>
<span>向前增加航点</span>
@ -83,7 +94,33 @@
</span>
</el-dialog>
<!-- 航线上锁/解锁复制航点右键时也显示 routeId -->
<el-dialog
title="编辑盘旋速度"
:visible.sync="showHoldSpeedDialog"
width="360px"
append-to-body
:close-on-click-modal="false"
>
<el-form :model="holdSpeedForm" label-width="120px" size="small">
<el-form-item label="盘旋速度(km/h)">
<el-input-number
v-model.number="holdSpeedForm.speed"
:min="100"
:max="2000"
:precision="1"
:step="10"
style="width:100%"
/>
<div class="form-tip-small">该值用于盘旋段推演速度默认 800 km/h</div>
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button @click="showHoldSpeedDialog = false">取消</el-button>
<el-button type="primary" @click="confirmEditHoldSpeed">确定</el-button>
</span>
</el-dialog>
<!-- 航线上锁/解锁复制单条航线推演航点右键时也显示 routeId -->
<div class="menu-section" v-if="entityData && (entityData.type === 'route' || entityData.type === 'routeWaypoint')">
<div class="menu-title">航线编辑</div>
<div class="menu-item" @click="handleToggleRouteLock">
@ -94,6 +131,10 @@
<span class="menu-icon">📋</span>
<span>复制</span>
</div>
<div class="menu-item" @click="handleSingleRouteDeduction">
<span class="menu-icon"></span>
<span>单条航线推演</span>
</div>
</div>
<!-- 航线上飞机显示/隐藏/编辑标牌编辑平台 -->
@ -534,6 +575,14 @@ export default {
type: Object,
default: () => ({ x: 0, y: 0 })
},
pickList: {
type: Array,
default: null
},
pickIndex: {
type: Number,
default: 0
},
entityData: {
type: Object,
default: null
@ -555,9 +604,11 @@ export default {
return {
expandedAddWaypoint: null,
showAddWaypointDialog: false,
showHoldSpeedDialog: false,
addWaypointDialogMode: null,
addWaypointDialogSegmentMode: null,
addWaypointForm: { segmentTargetMinutes: 10, segmentTargetSpeed: 800 },
holdSpeedForm: { speed: 800 },
showColorPickerFor: null,
showWidthPicker: false,
showSizePicker: false,
@ -601,6 +652,13 @@ export default {
if (!this.entityData || this.entityData.routeId == null) return false
return !!this.routeLocked[this.entityData.routeId]
},
isHoldWaypoint() {
const ed = this.entityData || {}
if (!ed || ed.type !== 'routeWaypoint') return false
if (ed.fromHold) return true
const pt = ed.pointType || ed.point_type || ''
return pt === 'hold_circle' || pt === 'hold_ellipse'
},
addWaypointDialogTitle() {
if (!this.addWaypointDialogMode) return '设置参数'
const dir = this.addWaypointDialogMode === 'before' ? '向前' : '向后'
@ -671,9 +729,34 @@ export default {
this.$emit('copy-route')
},
handleSingleRouteDeduction() {
this.$emit('single-route-deduction', this.entityData.routeId)
},
handleEditWaypoint() {
this.$emit('open-waypoint-dialog', this.entityData.dbId, this.entityData.routeId, this.entityData.waypointIndex)
},
openEditHoldSpeedDialog() {
const current = this.entityData && this.entityData.holdSpeed != null
? Number(this.entityData.holdSpeed)
: (this.entityData && this.entityData.speed != null ? Number(this.entityData.speed) : 800)
this.holdSpeedForm = { speed: Number.isFinite(current) && current > 0 ? current : 800 }
this.showHoldSpeedDialog = true
},
confirmEditHoldSpeed() {
const v = Number(this.holdSpeedForm.speed)
if (!Number.isFinite(v) || v <= 0) {
this.$message && this.$message.warning('请填写大于0的盘旋速度')
return
}
this.showHoldSpeedDialog = false
this.$emit('edit-hold-speed', {
routeId: this.entityData.routeId,
dbId: this.entityData.dbId,
waypointIndex: this.entityData.waypointIndex,
speed: v
})
},
toggleAddWaypointExpand(which) {
this.expandedAddWaypoint = this.expandedAddWaypoint === which ? null : which

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

@ -34,10 +34,13 @@
:visible="contextMenu.visible"
:position="contextMenu.position"
:entity-data="contextMenu.entityData"
:pick-list="contextMenu.pickList"
:pick-index="contextMenu.pickIndex"
:route-locked="routeLocked"
:detection-zone-visible="contextMenuZoneDetectionVisible"
:power-zone-visible="contextMenuZonePowerVisible"
@delete="deleteEntityFromContextMenu"
@switch-pick="handleContextMenuSwitchPick"
@update-property="updateEntityProperty"
@edit-platform-position="openPlatformIconPositionDialog"
@edit-platform-heading="openPlatformIconHeadingDialog"
@ -47,6 +50,7 @@
@start-route-before-platform="handleStartRouteBeforePlatform"
@start-route-after-platform="handleStartRouteAfterPlatform"
@copy-route="handleCopyRouteFromMenu"
@single-route-deduction="handleSingleRouteDeductionFromMenu"
@edit-platform="openEditPlatformDialog"
@detection-zone="openDetectionZoneDialog"
@power-zone="openPowerZoneDialog"
@ -55,6 +59,7 @@
@open-waypoint-dialog="handleContextMenuOpenWaypointDialog"
@add-waypoint-at="handleAddWaypointAt"
@toggle-waypoint-hold="handleToggleWaypointHold"
@edit-hold-speed="handleEditHoldSpeed"
@launch-missile="openLaunchMissileDialog"
@adjust-airspace-position="startAirspacePositionEdit"
/>
@ -442,6 +447,10 @@ export default {
position: { x: 0, y: 0 },
entityData: null
},
/** 右键菜单高亮的空域实体(用于关闭时恢复样式) */
_contextMenuHighlightedEntityData: null,
/** 高亮闪烁定时器 */
_contextMenuHighlightTimer: null,
editPlatformLabelDialogVisible: false,
editPlatformLabelForm: {
routeId: null,
@ -606,6 +615,7 @@ export default {
copyPreviewMouseCartesian: null,
// /{ routeId, waypointIndex, mode: 'before'|'after', waypoints }线
addWaypointContext: null,
addWaypointSolidEntity: null,
addWaypointPreviewEntity: null,
// { entityData } CallbackProperty
airspacePositionEditContext: null,
@ -701,10 +711,17 @@ export default {
deep: true,
immediate: true,
handler(entities) {
if (this.whiteboardMode && entities && entities.length > 0) {
this.renderWhiteboardEntities(entities)
if (this.whiteboardMode) {
if (entities && entities.length > 0) {
this.renderWhiteboardEntities(entities)
} else {
this.clearWhiteboardEntities()
}
}
}
},
'contextMenu.visible'(val) {
if (!val) this.clearAirspaceHighlight()
}
},
computed: {
@ -2543,20 +2560,21 @@ export default {
const nextPosCloned = nextPos ? Cesium.Cartesian3.clone(nextPos) : null;
const routeIdHold = routeId;
const that = this;
const buildHoldPositions = (radiusOrEllipse) => {
const buildHoldPositions = (radiusOrEllipse, centerOverride) => {
const isCircleArg = typeof radiusOrEllipse === 'number';
const R = isCircleArg ? radiusOrEllipse : 0;
const smj = isCircleArg ? defaultSemiMajor : (radiusOrEllipse.semiMajor ?? defaultSemiMajor);
const smn = isCircleArg ? defaultSemiMinor : (radiusOrEllipse.semiMinor ?? defaultSemiMinor);
const hd = isCircleArg ? defaultHeadingRad : ((radiusOrEllipse.headingDeg != null ? radiusOrEllipse.headingDeg * Math.PI / 180 : defaultHeadingRad));
const centerPos = centerOverride || currPosCloned;
let entry; let exit; let centerForCircle;
if (useCircle) {
centerForCircle = that.getHoldCenterFromPrevNext(lastPosCloned, currPosCloned, R, clockwise);
centerForCircle = that.getHoldCenterFromPrevNext(lastPosCloned, centerPos, R, clockwise);
entry = that.getCircleTangentEntryPoint(centerForCircle, lastPosCloned, R, clockwise);
exit = that.getCircleTangentExitPoint(centerForCircle, nextPosCloned || currPosCloned, R, clockwise);
exit = that.getCircleTangentExitPoint(centerForCircle, nextPosCloned || centerPos, R, clockwise);
} else {
entry = that.getEllipseTangentEntryPoint(currPosCloned, lastPosCloned, smj, smn, hd, clockwise);
exit = that.getEllipseTangentExitPoint(currPosCloned, nextPosCloned || currPosCloned, smj, smn, hd, clockwise);
entry = that.getEllipseTangentEntryPoint(centerPos, lastPosCloned, smj, smn, hd, clockwise);
exit = that.getEllipseTangentExitPoint(centerPos, nextPosCloned || centerPos, smj, smn, hd, clockwise);
}
let arcPoints;
if (useCircle) {
@ -2573,10 +2591,10 @@ export default {
if (!arcPoints || arcPoints.length < 2) arcPoints = [Cesium.Cartesian3.clone(entry), Cesium.Cartesian3.clone(exit)];
return arcPoints;
} else {
const tEntry = that.cartesianToEllipseParam(currPosCloned, smj, smn, hd, entry);
const tEntry = that.cartesianToEllipseParam(centerPos, smj, smn, hd, entry);
const entryLocalAngle = Math.atan2(smn * Math.sin(tEntry), smj * Math.cos(tEntry));
const fullCirclePoints = that.getEllipseFullCircle(currPosCloned, smj, smn, hd, entryLocalAngle, clockwise, 128);
arcPoints = that.buildEllipseHoldArc(currPosCloned, smj, smn, hd, entry, exit, clockwise, 120);
const fullCirclePoints = that.getEllipseFullCircle(centerPos, smj, smn, hd, entryLocalAngle, clockwise, 128);
arcPoints = that.buildEllipseHoldArc(centerPos, smj, smn, hd, entry, exit, clockwise, 120);
return [entry, ...(fullCirclePoints || []).slice(1), ...(arcPoints || []).slice(1)];
}
};
@ -2586,17 +2604,26 @@ export default {
nextPosCloned,
params && params.headingDeg != null ? params.headingDeg : 0
);
const buildRaceTrackPositions = () => that.buildRaceTrackWithEntryExit(currPosCloned, lastPosCloned, nextPosCloned, raceTrackDirectionRad, edgeLengthM, arcRadiusM, clockwise, 24);
const buildRaceTrackPositions = (centerOverride) => that.buildRaceTrackWithEntryExit(centerOverride || currPosCloned, lastPosCloned, nextPosCloned, raceTrackDirectionRad, edgeLengthM, arcRadiusM, clockwise, 24);
const holdPositions = useCircle ? buildHoldPositions(radius) : buildRaceTrackPositions();
for (let k = 0; k < holdPositions.length; k++) finalPathPositions.push(holdPositions[k]);
const wpIdHold = wp.id;
const getHoldPositions = () => {
let centerOverride = null;
if (that.waypointDragging && that.waypointDragging.routeId === routeIdHold && that.waypointDragging.dbId === wpIdHold) {
const wpEnt = that.viewer.entities.getById(`wp_${routeIdHold}_${wpIdHold}`);
if (wpEnt && wpEnt.position) {
const p = wpEnt.position.getValue(Cesium.JulianDate.now());
if (p) centerOverride = p;
}
}
if (useCircle) {
const R = (that._routeHoldRadiiByRoute && that._routeHoldRadiiByRoute[routeIdHold] && that._routeHoldRadiiByRoute[routeIdHold][legIndexHold] != null)
? that._routeHoldRadiiByRoute[routeIdHold][legIndexHold]
: turnRadiusForHold;
return buildHoldPositions(R);
return buildHoldPositions(R, centerOverride);
}
return buildRaceTrackPositions();
return buildRaceTrackPositions(centerOverride);
};
this.viewer.entities.add({
id: `hold-line-${routeId}-${i}`,
@ -2643,10 +2670,20 @@ export default {
const currPosClonedForArc = Cesium.Cartesian3.clone(currPos);
if (radius > 0) {
const currPosCloned = Cesium.Cartesian3.clone(currPos);
const routeIdCloned = routeId;
const dbIdCloned = wp.id;
const routeIdArc = routeId;
const dbIdArc = wp.id;
const that = this;
const getArcPoints = () => that.computeArcPositions(lastPosCloned, currPosCloned, nextLogicalCloned, radius);
const getArcPoints = () => {
let centerPos = currPosCloned;
if (that.waypointDragging && that.waypointDragging.routeId === routeIdArc && that.waypointDragging.dbId === dbIdArc) {
const wpEnt = that.viewer.entities.getById(`wp_${routeIdArc}_${dbIdArc}`);
if (wpEnt && wpEnt.position) {
const p = wpEnt.position.getValue(Cesium.JulianDate.now());
if (p) centerPos = p;
}
}
return that.computeArcPositions(lastPosCloned, centerPos, nextLogicalCloned, radius);
};
this.viewer.entities.add({
id: `arc-line-${routeId}-${i}`,
show: false,
@ -2665,9 +2702,16 @@ export default {
const arcWpOutline = wp.outlineColor != null ? wp.outlineColor : defaultWpOutline;
[0, 1].forEach((idx) => {
const suffix = idx === 0 ? '_entry' : '_exit';
const getPos = () => { const pts = getArcPoints(); return idx === 0 ? pts[0] : pts[pts.length - 1]; };
const entId = `wp_${routeId}_${wp.id}${suffix}`;
const getPos = () => {
if (that.waypointDragging && that.waypointDragging.entity && (that.waypointDragging.entity.id === entId) && that.waypointDragPreview && that.waypointDragPreview.routeId === routeIdArc && that.waypointDragPreview.dbId === dbIdArc) {
return that.waypointDragPreview.position;
}
const pts = getArcPoints();
return idx === 0 ? pts[0] : pts[pts.length - 1];
};
this.viewer.entities.add({
id: `wp_${routeId}_${wp.id}${suffix}`,
id: entId,
name: wpName,
position: new Cesium.CallbackProperty(getPos, false),
properties: { isMissionWaypoint: true, routeId: routeId, dbId: wp.id },
@ -2693,6 +2737,11 @@ export default {
const hitLineId = lineId + '-hit';
const hasHold = waypoints.some((wp) => that.isHoldWaypoint(wp));
const routePositionsCallback = new Cesium.CallbackProperty(function () {
// 使线
if (that.waypointDragging && that.waypointDragging.routeId === routeId) {
const livePos = that.getRouteLinePositionsFromWaypointEntities(routeId);
if (livePos && livePos.length > 0) return livePos;
}
if (hasHold) {
const pathPos = that.getRoutePathPositionsForLine(routeId);
if (pathPos && pathPos.length > 0) return pathPos;
@ -4689,8 +4738,10 @@ export default {
const endLat = Cesium.Math.toDegrees(lat2)
const endLng = Cesium.Math.toDegrees(lon2)
const durationSec = d / 1000
const durationMinutes = durationSec / 60
// GanttDrawer.loadMissiles d/1000 /
const distanceKm = Number(distance) || 1000
const missileSpeedKmh = 1400
const durationMinutes = (distanceKm / missileSpeedKmh) * 60
const endK = launchK + durationMinutes
const startPoint = Cesium.Cartesian3.fromDegrees(Number(entityData.lng), Number(entityData.lat), 1000)
@ -4781,8 +4832,9 @@ export default {
const lon2 = lon1 + Math.atan2(Math.sin(brng) * Math.sin(d / R) * Math.cos(lat1), Math.cos(d / R) - Math.sin(lat1) * Math.sin(lat2))
const endLng = Cesium.Math.toDegrees(lon2)
const endLat = Cesium.Math.toDegrees(lat2)
const durationSec = d / 1000
const durationMinutes = durationSec / 60
const distanceKm = Number(distance) || 1000
const missileSpeedKmh = 1400
const durationMinutes = (distanceKm / missileSpeedKmh) * 60
const endK = launchK + durationMinutes
const startPoint = Cesium.Cartesian3.fromDegrees(startLng, startLat, 1000)
const endPoint = Cesium.Cartesian3.fromDegrees(endLng, endLat, 0)
@ -5154,10 +5206,13 @@ export default {
}
}
if (this.addWaypointContext) {
const cartesian = this.getClickPosition(movement.endPosition);
const ctx = this.addWaypointContext;
const refAlt = ctx.mode === 'before'
? (ctx.waypoints[ctx.waypointIndex - 1] && Number(ctx.waypoints[ctx.waypointIndex - 1].alt)) || (ctx.waypoints[ctx.waypointIndex] && Number(ctx.waypoints[ctx.waypointIndex].alt)) || 5000
: (ctx.waypoints[ctx.waypointIndex] && Number(ctx.waypoints[ctx.waypointIndex].alt)) || (ctx.waypoints[ctx.waypointIndex + 1] && Number(ctx.waypoints[ctx.waypointIndex + 1].alt)) || 5000;
const cartesian = this.getClickPositionWithHeight(movement.endPosition, refAlt);
if (cartesian) {
this.addWaypointContext.mouseCartesian = cartesian;
this.updateAddWaypointPreview();
ctx.mouseCartesian = cartesian;
if (this.viewer.scene.requestRender) this.viewer.scene.requestRender();
}
}
@ -5256,6 +5311,11 @@ export default {
//
this.contextMenu.visible = false;
// 使 drillPick /11x11 便
const drillPicks = this.viewer.scene.drillPick(click.position, 20, 11, 11);
const pickList = [];
const seenEntities = new Set();
const pickedObject = this.viewer.scene.pick(click.position)
if (Cesium.defined(pickedObject) && pickedObject.id) {
const pickedEntity = pickedObject.id
@ -5269,14 +5329,14 @@ export default {
let platformId = 0
let platformName = '平台'
if (pickedEntity.properties) {
const now = Cesium.JulianDate.now();
//
if (pickedEntity.properties.platformId) {
platformId = pickedEntity.properties.platformId.getValue ? pickedEntity.properties.platformId.getValue(now) : pickedEntity.properties.platformId
}
if (pickedEntity.properties.platformName) {
platformName = pickedEntity.properties.platformName.getValue ? pickedEntity.properties.platformName.getValue(now) : pickedEntity.properties.platformName
}
const now = Cesium.JulianDate.now();
//
if (pickedEntity.properties.platformId) {
platformId = pickedEntity.properties.platformId.getValue ? pickedEntity.properties.platformId.getValue(now) : pickedEntity.properties.platformId
}
if (pickedEntity.properties.platformName) {
platformName = pickedEntity.properties.platformName.getValue ? pickedEntity.properties.platformName.getValue(now) : pickedEntity.properties.platformName
}
}
entityData = {
@ -5431,11 +5491,23 @@ export default {
if (dbId && dbId.getValue) dbId = dbId.getValue();
const ids = this._routeWaypointIdsByRoute && this._routeWaypointIdsByRoute[rId];
const waypointIndex = ids && dbId != null ? ids.indexOf(dbId) : -1;
if (rId != null) entityData = { type: 'routeWaypoint', routeId: rId, dbId, waypointIndex };
const routeWps = this._routeWaypointsByRoute && this._routeWaypointsByRoute[rId];
const wp = routeWps && waypointIndex >= 0 ? routeWps[waypointIndex] : null;
const hp = wp ? this.parseHoldParams(wp) : null;
const holdSpeed = hp && hp.holdSpeed != null ? Number(hp.holdSpeed) : null;
if (rId != null) entityData = {
type: 'routeWaypoint',
routeId: rId,
dbId,
waypointIndex,
speed: wp && wp.speed != null ? Number(wp.speed) : null,
holdSpeed,
pointType: wp && (wp.pointType || wp.point_type)
};
} else if (isLine) {
let rId = props.routeId;
if (rId && rId.getValue) rId = rId.getValue();
if (rId) entityData = { type: 'route', routeId: rId };
if (rId) entityData = {type: 'route', routeId: rId};
}
}
}
@ -5445,26 +5517,69 @@ export default {
if (parts.length >= 4) {
const routeId = parts[2];
const segIdx = parseInt(parts[3], 10);
if (!isNaN(segIdx)) entityData = { type: 'routeWaypoint', routeId, waypointIndex: segIdx, fromHold: true };
if (!isNaN(segIdx)) {
const routeWps = this._routeWaypointsByRoute && this._routeWaypointsByRoute[routeId];
const holdWp = routeWps && routeWps[segIdx] ? routeWps[segIdx] : null;
const hp = holdWp ? this.parseHoldParams(holdWp) : null;
const holdSpeed = hp && hp.holdSpeed != null ? Number(hp.holdSpeed) : null;
entityData = {
type: 'routeWaypoint',
routeId,
waypointIndex: segIdx,
dbId: holdWp && holdWp.id != null ? holdWp.id : null,
speed: holdWp && holdWp.speed != null ? Number(holdWp.speed) : 800,
holdSpeed,
pointType: holdWp && (holdWp.pointType || holdWp.point_type),
fromHold: true
};
}
}
}
}
// 线 allEntities routeId id
if (entityData && entityData.type === 'route' && entityData.id && !entityData.routeId) {
entityData = { ...entityData, routeId: entityData.id.replace('route-line-', '') };
}
if (entityData) {
this.contextMenu = {
visible: true,
position: {
x: click.position.x,
y: click.position.y
},
entityData: entityData
};
entityData = {...entityData, routeId: entityData.id.replace('route-line-', '')};
}
for (const pick of drillPicks) {
const pickedEntity = pick.id || pick.object;
if (!pickedEntity || seenEntities.has(pickedEntity)) continue;
const entityData = this.resolveEntityDataFromPick(pickedEntity);
if (entityData) {
seenEntities.add(pickedEntity);
pickList.push({entityData, pickedEntity});
}
}
}
if (pickList.length > 0) {
const { entityData, pickedEntity } = pickList[0];
const anchorCartesian = this.getContextMenuAnchorCartesian(entityData, pickedEntity, click.position);
this.contextMenu = {
visible: true,
position: { x: click.position.x, y: click.position.y },
entityData,
anchorCartesian,
pickList,
pickIndex: 0
};
if (['polygon', 'rectangle', 'circle', 'sector', 'auxiliaryLine', 'arrow'].includes(entityData.type)) {
this.applyAirspaceHighlight(entityData);
}
}
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
// 使/
const that = this;
const updateContextMenuPosition = () => {
if (!that.contextMenu.visible || !that.contextMenu.anchorCartesian || !that.viewer || !that.viewer.scene) return;
try {
const screenPos = Cesium.SceneTransforms.worldToWindowCoordinates(that.viewer.scene, that.contextMenu.anchorCartesian);
if (Cesium.defined(screenPos)) {
that.contextMenu.position = { x: screenPos.x, y: screenPos.y };
}
} catch (e) {}
};
this.viewer.scene.preRender.addEventListener(updateContextMenuPosition);
this._contextMenuCameraListener = updateContextMenuPosition;
},
openEditPlatformLabelDialog() {
@ -5610,7 +5725,7 @@ export default {
if (!entityData && idStr.startsWith('wb_')) {
entityData = this.whiteboardEntityDataMap && this.whiteboardEntityDataMap[idStr];
}
if (entityData) {
if (entityData && entityData.type === 'platformIcon') {
this.pendingDragIcon = entityData;
this.dragStartScreenPos = { x: click.position.x, y: click.position.y };
return;
@ -6109,6 +6224,12 @@ export default {
this.addPointEntity(lat, lng)
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
// 退
this.drawingHandler.setInputAction(() => {
this.stopDrawing();
this.drawingMode = null;
this.$message && this.$message.info('已退出绘制');
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
},
// 线
startLineDrawing() {
@ -6234,7 +6355,7 @@ export default {
});
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// 3.
// 3. 退
this.drawingHandler.setInputAction(() => {
//
if (this.tempPreviewEntity) {
@ -6257,6 +6378,9 @@ export default {
}
//
this.activeCursorPosition = null;
// 退
this.stopDrawing();
this.drawingMode = null;
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
},
finishLineDrawing() {
@ -6362,7 +6486,7 @@ export default {
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// 4.
// 4. 退
this.drawingHandler.setInputAction(() => {
if (this.drawingPoints.length >= 3) {
this.finishPolygonDrawing(); //
@ -6371,6 +6495,9 @@ export default {
}
//
this.activeCursorPosition = null;
// 退
this.stopDrawing();
this.drawingMode = null;
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
},
finishPolygonDrawing() {
@ -6480,9 +6607,11 @@ export default {
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// 4.
// 4. 退
this.drawingHandler.setInputAction(() => {
this.cancelDrawing();
this.stopDrawing();
this.drawingMode = null;
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
},
finishRectangleDrawing() {
@ -6737,10 +6866,12 @@ export default {
console.warn('Mouse click error:', e);
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// 5.
// 5. 退
this.drawingHandler.setInputAction(() => {
try {
this.cancelDrawing();
this.stopDrawing();
this.drawingMode = null;
} catch (e) {
console.warn('Right click error:', e);
}
@ -6945,9 +7076,11 @@ export default {
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// 4.
// 4. 退
this.drawingHandler.setInputAction(() => {
this.cancelDrawing();
this.stopDrawing();
this.drawingMode = null;
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
},
//
@ -7181,6 +7314,8 @@ export default {
}, Cesium.ScreenSpaceEventType.LEFT_CLICK)
this.drawingHandler.setInputAction(() => {
this.cancelDrawing()
this.stopDrawing();
this.drawingMode = null;
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK)
},
finishAuxiliaryLineDrawing() {
@ -7301,9 +7436,11 @@ export default {
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// 4.
// 4. 退
this.drawingHandler.setInputAction(() => {
this.cancelDrawing();
this.stopDrawing();
this.drawingMode = null;
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
},
//
@ -7391,9 +7528,11 @@ export default {
}
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// 3.
// 3. 退
this.drawingHandler.setInputAction(() => {
this.cancelDrawing();
this.stopDrawing();
this.drawingMode = null;
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
},
//
@ -7482,9 +7621,11 @@ export default {
fileInput.click();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// 3.
// 3. 退
this.drawingHandler.setInputAction(() => {
this.cancelDrawing();
this.stopDrawing();
this.drawingMode = null;
}, Cesium.ScreenSpaceEventType.RIGHT_CLICK);
},
//
@ -8156,6 +8297,18 @@ export default {
this.contextMenu.visible = false;
this.$emit('copy-route', ed.routeId);
},
/** 右键「单条航线推演」:弹出时间轴,仅推演该航线 */
handleSingleRouteDeductionFromMenu(routeId) {
const ed = this.contextMenu.entityData;
const rid = routeId != null ? routeId : (ed && ed.routeId);
if (rid == null) {
this.contextMenu.visible = false;
return;
}
this.contextMenu.visible = false;
this.$emit('single-route-deduction', rid);
},
/** 开始航线复制预览:整条航线跟随鼠标,左键放置后父组件弹窗保存 */
startRouteCopyPreview(waypoints) {
if (!waypoints || waypoints.length < 2) return;
@ -8219,6 +8372,10 @@ export default {
this.contextMenu.visible = false;
this.$emit('toggle-waypoint-hold', payload);
},
handleEditHoldSpeed(payload) {
this.contextMenu.visible = false;
this.$emit('edit-hold-speed', payload);
},
/** 开始“在航点前/后增加航点”模式:显示预览折线,左键放置、右键取消。segmentMode 可选:null(默认)、fixed_time(定时点)、fixed_speed(定速点)。segmentTargetMinutes/segmentTargetSpeed 为用户填写的固定值。 */
startAddWaypointAt(routeId, waypointIndex, mode, waypoints, segmentMode, segmentTargetMinutes, segmentTargetSpeed) {
if (!waypoints || waypoints.length === 0) return;
@ -8240,7 +8397,7 @@ export default {
this.$message && this.$message.info(`${mode === 'before' ? '在当前位置前' : '在当前位置后'}插入${modeLabel},点击地图放置,右键取消`);
this.updateAddWaypointPreview();
},
/** 更新“增加航点”预览折线:上一/当前/下一与鼠标位置连线,含盘旋段视觉效果 */
/** 更新“增加航点”预览折线:与新建航线一致,已确定段实线 + 最后一段到鼠标的虚线实时预览 */
updateAddWaypointPreview() {
const ctx = this.addWaypointContext;
if (!ctx || !this.viewer || !ctx.waypoints || ctx.waypoints.length === 0) return;
@ -8252,30 +8409,48 @@ export default {
const alt = Number(wp.alt) || 5000;
return Cesium.Cartesian3.fromDegrees(lon, lat, alt);
};
let positions = [];
const color = Cesium.Color.fromCssColorString('#64748b');
const that = this;
// 1. 线
if (this.addWaypointSolidEntity) {
this.viewer.entities.remove(this.addWaypointSolidEntity);
this.addWaypointSolidEntity = null;
}
let solidPositions = [];
if (ctx.mode === 'before') {
const prev = idx > 0 ? toCartesian(waypoints[idx - 1]) : null;
const curr = toCartesian(waypoints[idx]);
if (ctx.mouseCartesian) {
if (prev) positions = [prev, ctx.mouseCartesian, curr];
else positions = [ctx.mouseCartesian, curr];
}
solidPositions = waypoints.slice(0, idx).map(wp => toCartesian(wp));
} else {
const curr = toCartesian(waypoints[idx]);
const next = idx + 1 < waypoints.length ? toCartesian(waypoints[idx + 1]) : null;
if (ctx.mouseCartesian) {
if (next) positions = [curr, ctx.mouseCartesian, next];
else positions = [curr, ctx.mouseCartesian];
}
solidPositions = waypoints.slice(0, idx + 1).map(wp => toCartesian(wp));
}
if (positions.length < 2) return;
if (solidPositions.length >= 2) {
this.addWaypointSolidEntity = this.viewer.entities.add({
polyline: {
positions: solidPositions,
width: 2,
material: color,
arcType: Cesium.ArcType.NONE
}
});
}
// 2. 线使 CallbackProperty
if (this.addWaypointPreviewEntity) {
this.viewer.entities.remove(this.addWaypointPreviewEntity);
this.addWaypointPreviewEntity = null;
}
const getAnchor = () => (ctx.mode === 'before' ? (idx > 0 ? toCartesian(waypoints[idx - 1]) : null) : toCartesian(waypoints[idx]));
const getNext = () => (ctx.mode === 'before' ? toCartesian(waypoints[idx]) : (idx + 1 < waypoints.length ? toCartesian(waypoints[idx + 1]) : null));
this.addWaypointPreviewEntity = this.viewer.entities.add({
polyline: {
positions: positions,
positions: new Cesium.CallbackProperty(() => {
const anchor = getAnchor();
const next = getNext();
const mouse = that.addWaypointContext && that.addWaypointContext.mouseCartesian;
if (!mouse) return anchor && next ? [anchor, next] : (anchor ? [anchor, anchor] : (next ? [next, next] : []));
if (anchor && next) return [anchor, mouse, next];
if (anchor) return [anchor, mouse];
if (next) return [mouse, next];
return [mouse, mouse];
}, false),
width: 2,
material: new Cesium.PolylineDashMaterialProperty({
color: Cesium.Color.fromCssColorString('#64748b'),
@ -8287,6 +8462,10 @@ export default {
},
/** 清除“增加航点”模式及预览折线 */
clearAddWaypointContext() {
if (this.addWaypointSolidEntity) {
this.viewer.entities.remove(this.addWaypointSolidEntity);
this.addWaypointSolidEntity = null;
}
if (this.addWaypointPreviewEntity) {
this.viewer.entities.remove(this.addWaypointPreviewEntity);
this.addWaypointPreviewEntity = null;
@ -8603,6 +8782,195 @@ export default {
return null;
},
/** 从拾取的实体解析出 entityData(供 drillPick 等多选场景复用) */
resolveEntityDataFromPick(pickedEntity) {
if (!pickedEntity) return null;
const idStr = typeof pickedEntity.id === 'string' ? pickedEntity.id : (pickedEntity.id || '');
let entityData = null;
if (idStr.startsWith('route-platform-')) {
const routeId = idStr.replace('route-platform-', '').replace('route-platform-label-', '');
let platformId = 0, platformName = '平台';
if (pickedEntity.properties) {
const now = Cesium.JulianDate.now();
if (pickedEntity.properties.platformId) platformId = pickedEntity.properties.platformId.getValue ? pickedEntity.properties.platformId.getValue(now) : pickedEntity.properties.platformId;
if (pickedEntity.properties.platformName) platformName = pickedEntity.properties.platformName.getValue ? pickedEntity.properties.platformName.getValue(now) : pickedEntity.properties.platformName;
}
entityData = { type: 'routePlatform', routeId, entity: pickedEntity, platformId, platformName, labelVisible: this.routeLabelVisible[routeId] !== false };
const now = Cesium.JulianDate.now();
if (pickedEntity.position) {
const pos = pickedEntity.position.getValue ? pickedEntity.position.getValue(now) : pickedEntity.position;
if (pos) {
const ll = this.cartesianToLatLng(pos);
if (ll) { entityData.lat = ll.lat; entityData.lng = ll.lng; }
}
}
}
if (!entityData && idStr.startsWith('wb_')) entityData = this.whiteboardEntityDataMap && this.whiteboardEntityDataMap[idStr];
if (!entityData) {
entityData = this.allEntities.find(e => e.entity === pickedEntity || e === pickedEntity);
if (!entityData) {
for (const lineEntity of this.allEntities) {
if (lineEntity.type === 'line' && lineEntity.pointEntities && lineEntity.pointEntities.includes(pickedEntity)) {
entityData = lineEntity; break;
}
}
}
if (!entityData) {
for (const powerZoneEntity of this.allEntities) {
if (powerZoneEntity.type === 'powerZone' && powerZoneEntity.centerEntity === pickedEntity) {
entityData = powerZoneEntity; break;
}
}
}
if (!entityData && pickedEntity.properties) {
const now = Cesium.JulianDate.now();
const props = pickedEntity.properties.getValue ? pickedEntity.properties.getValue(now) : null;
if (props) {
const isWp = props.isMissionWaypoint && props.isMissionWaypoint.getValue ? props.isMissionWaypoint.getValue() : props.isMissionWaypoint;
const isLine = props.isMissionRouteLine && props.isMissionRouteLine.getValue ? props.isMissionRouteLine.getValue() : props.isMissionRouteLine;
if (isWp) {
let rId = props.routeId; if (rId && rId.getValue) rId = rId.getValue();
let dbId = props.dbId; if (dbId && dbId.getValue) dbId = dbId.getValue();
const ids = this._routeWaypointIdsByRoute && this._routeWaypointIdsByRoute[rId];
const waypointIndex = ids && dbId != null ? ids.indexOf(dbId) : -1;
const routeWps = this._routeWaypointsByRoute && this._routeWaypointsByRoute[rId];
const wp = routeWps && waypointIndex >= 0 ? routeWps[waypointIndex] : null;
const hp = wp ? this.parseHoldParams(wp) : null;
const holdSpeed = hp && hp.holdSpeed != null ? Number(hp.holdSpeed) : null;
if (rId != null) entityData = {
type: 'routeWaypoint',
routeId: rId,
dbId,
waypointIndex,
speed: wp && wp.speed != null ? Number(wp.speed) : null,
holdSpeed,
pointType: wp && (wp.pointType || wp.point_type)
};
} else if (isLine) {
let rId = props.routeId; if (rId && rId.getValue) rId = rId.getValue();
if (rId) entityData = { type: 'route', routeId: rId };
}
}
}
if (!entityData && idStr.startsWith('hold-line-')) {
const parts = idStr.split('-');
if (parts.length >= 4) {
const routeId = parts[2];
const segIdx = parseInt(parts[3], 10);
if (!isNaN(segIdx)) {
const routeWps = this._routeWaypointsByRoute && this._routeWaypointsByRoute[routeId];
const holdWp = routeWps && routeWps[segIdx] ? routeWps[segIdx] : null;
const hp = holdWp ? this.parseHoldParams(holdWp) : null;
const holdSpeed = hp && hp.holdSpeed != null ? Number(hp.holdSpeed) : null;
entityData = {
type: 'routeWaypoint',
routeId,
waypointIndex: segIdx,
dbId: holdWp && holdWp.id != null ? holdWp.id : null,
speed: holdWp && holdWp.speed != null ? Number(holdWp.speed) : 800,
holdSpeed,
pointType: holdWp && (holdWp.pointType || holdWp.point_type),
fromHold: true
};
}
}
}
}
if (entityData && entityData.type === 'route' && entityData.id && !entityData.routeId) {
entityData = { ...entityData, routeId: entityData.id.replace('route-line-', '') };
}
return entityData;
},
/** 为空域/辅助线/箭头实体应用高亮闪烁(右键选中时,保持原色,出现与消失闪烁) */
applyAirspaceHighlight(entityData) {
if (!entityData || !entityData.entity) return;
const types = ['polygon', 'rectangle', 'circle', 'sector', 'auxiliaryLine', 'arrow'];
if (!types.includes(entityData.type)) return;
this.clearAirspaceHighlight();
const entity = entityData.entity;
this._contextMenuHighlightedEntityData = entityData;
let visible = true;
this._contextMenuHighlightTimer = setInterval(() => {
if (!this._contextMenuHighlightedEntityData || this._contextMenuHighlightedEntityData !== entityData) return;
visible = !visible;
try {
entity.show = visible;
if (this.viewer && this.viewer.scene && this.viewer.scene.requestRender) {
this.viewer.scene.requestRender();
}
} catch (e) {}
}, 350);
},
/** 清除空域高亮,恢复显示 */
clearAirspaceHighlight() {
if (this._contextMenuHighlightTimer) {
clearInterval(this._contextMenuHighlightTimer);
this._contextMenuHighlightTimer = null;
}
const entityData = this._contextMenuHighlightedEntityData;
if (!entityData || !entityData.entity) {
this._contextMenuHighlightedEntityData = null;
return;
}
try {
entityData.entity.show = true;
} catch (e) { console.warn('clearAirspaceHighlight:', e); }
this._contextMenuHighlightedEntityData = null;
if (this.viewer && this.viewer.scene && this.viewer.scene.requestRender) {
this.viewer.scene.requestRender();
}
},
/** 获取右键菜单的锚点(笛卡尔坐标),用于随地图移动时更新菜单位置 */
getContextMenuAnchorCartesian(entityData, pickedEntity, clickPosition) {
if (!entityData || !this.viewer || !this.viewer.scene) return null;
const now = Cesium.JulianDate.now();
// 使
const airspaceCenter = this.getAirspaceCenter(entityData);
if (airspaceCenter) return airspaceCenter;
// entity position
const ent = entityData.entity || pickedEntity;
if (ent && ent.position) {
try {
const pos = ent.position.getValue ? ent.position.getValue(now) : ent.position;
if (pos) return Cesium.Cartesian3.clone(pos);
} catch (e) {}
}
// line使线
if (entityData.type === 'line' && entityData.positions && entityData.positions.length > 0) {
const sum = entityData.positions.reduce((acc, p) => Cesium.Cartesian3.add(acc, p, new Cesium.Cartesian3()), new Cesium.Cartesian3());
return Cesium.Cartesian3.divideByScalar(sum, entityData.positions.length, new Cesium.Cartesian3());
}
// point position
if (entityData.type === 'point' && entityData.position) {
return Cesium.Cartesian3.clone(entityData.position);
}
if (entityData.type === 'point' && (entityData.lat != null || entityData.lng != null)) {
return Cesium.Cartesian3.fromDegrees(entityData.lng || 0, entityData.lat || 0);
}
// powerZone
if (entityData.type === 'powerZone' && entityData.centerEntity && entityData.centerEntity.position) {
try {
const pos = entityData.centerEntity.position.getValue ? entityData.centerEntity.position.getValue(now) : entityData.centerEntity.position;
if (pos) return Cesium.Cartesian3.clone(pos);
} catch (e) {}
}
if (entityData.type === 'powerZone' && entityData.center) {
const c = entityData.center;
if (typeof c.lng === 'number' && typeof c.lat === 'number') {
return Cesium.Cartesian3.fromDegrees(c.lng, c.lat);
}
}
// 使
if (clickPosition) {
const cartesian = this.viewer.camera.pickEllipsoid(clickPosition, this.viewer.scene.globe.ellipsoid);
if (cartesian) return cartesian;
}
return null;
},
/** 应用空域位置调整并退出模式 */
applyAirspacePositionEdit(newCenter) {
const ctx = this.airspacePositionEditContext;
@ -10237,6 +10605,21 @@ export default {
}
},
/** 右键菜单:切换选择重叠/接近的图形 */
handleContextMenuSwitchPick() {
const pickList = this.contextMenu.pickList;
if (!pickList || pickList.length <= 1) return;
const nextIndex = ((this.contextMenu.pickIndex || 0) + 1) % pickList.length;
const { entityData, pickedEntity } = pickList[nextIndex];
const anchorCartesian = this.getContextMenuAnchorCartesian(entityData, pickedEntity, null);
this.contextMenu.entityData = entityData;
this.contextMenu.pickIndex = nextIndex;
if (anchorCartesian) this.contextMenu.anchorCartesian = anchorCartesian;
if (['polygon', 'rectangle', 'circle', 'sector', 'auxiliaryLine', 'arrow'].includes(entityData.type)) {
this.applyAirspaceHighlight(entityData);
}
},
//
deleteEntityFromContextMenu() {
if (this.contextMenu.entityData) {
@ -12453,6 +12836,7 @@ export default {
}
},
destroyViewer() {
this.clearAirspaceHighlight();
this.stopDrawing()
this.clearAll(false)
if (this.entityClickDebounceTimer) {
@ -12484,6 +12868,10 @@ export default {
this.rightClickHandler.destroy()
this.rightClickHandler = null
}
if (this._contextMenuCameraListener && this.viewer && this.viewer.scene) {
this.viewer.scene.preRender.removeEventListener(this._contextMenuCameraListener)
this._contextMenuCameraListener = null
}
if (this._boundPreventContextMenuWindow) {
window.removeEventListener('contextmenu', this._boundPreventContextMenuWindow, true)
this._boundPreventContextMenuWindow = null
@ -12675,6 +13063,11 @@ export default {
width: 100vw;
height: 100vh;
position: relative;
/* 防止拖拽地图/航点时误触导致整页文字被选中 */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
#cesiumViewer {

3
ruoyi-ui/src/views/childRoom/ConflictDrawer.vue

@ -16,7 +16,6 @@
<el-option :label="$t('rightPanel.conflictTypeAll')" value="all" />
<el-option :label="$t('rightPanel.conflictTypeTime')" value="time" />
<el-option :label="$t('rightPanel.conflictTypeSpace')" value="space" />
<el-option :label="$t('rightPanel.conflictTypeSpectrum')" value="spectrum" />
</el-select>
</div>
<div class="toolbar-row">
@ -177,13 +176,11 @@ export default {
const t = type || ''
if (t === 'time') return this.$t('rightPanel.conflictTypeTime')
if (t === 'space') return this.$t('rightPanel.conflictTypeSpace')
if (t === 'spectrum') return this.$t('rightPanel.conflictTypeSpectrum')
return this.$t('rightPanel.conflictTypeAll')
},
conflictTypeTagType(type) {
if (type === 'time') return 'warning'
if (type === 'space') return 'danger'
if (type === 'spectrum') return 'primary'
return 'info'
},
onDragStart(e) {

595
ruoyi-ui/src/views/childRoom/GanttDrawer.vue

@ -1,21 +1,14 @@
<template>
<div class="gantt-shell">
<!-- 主甘特图弹窗 -->
<el-dialog
v-if="!isMinimized"
:visible.sync="dialogVisible"
width="1200px"
top="5vh"
:modal="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
custom-class="gantt-dialog gantt-dialog-fixed"
@close="handleClose"
<!-- 主甘特图面板单层可拖动/可缩放 -->
<div
v-if="dialogVisible && !isMinimized"
class="gantt-panel"
:style="panelStyle"
>
<div slot="title" class="gantt-dialog-title">
<div class="gantt-dialog-title" @mousedown.prevent="onPanelDragStart">
<span class="gantt-dialog-title-text">甘特图</span>
<span class="gantt-dialog-title-actions">
<span class="gantt-dialog-title-actions" @mousedown.stop>
<i class="gantt-dialog-header-icon el-icon-minus" title="最小化" @click="minimize"></i>
<i class="gantt-dialog-header-icon el-icon-close" title="关闭" @click="onClose"></i>
</span>
@ -27,13 +20,12 @@
<span class="k-time-range">K 时范围{{ kTimeRangeText }}</span>
</div>
<div class="toolbar-right">
<el-button size="small" @click="restoreInnerSize" title="恢复内框默认大小">恢复大小</el-button>
<el-button size="small" @click="refreshData">刷新</el-button>
<el-dropdown trigger="click" @command="handleExport">
<el-button size="small" type="primary">
导出<i class="el-icon-arrow-down el-icon--right"></i>
</el-button>
<el-dropdown-menu slot="dropdown">
<el-dropdown-menu slot="dropdown" class="gantt-export-dropdown-menu">
<el-dropdown-item command="png">导出 PNG</el-dropdown-item>
<el-dropdown-item command="pdf">导出 PDF</el-dropdown-item>
</el-dropdown-menu>
@ -41,13 +33,7 @@
</div>
</div>
<!-- 内框可缩放overflow:auto 产生局部滚动条HOUR_WIDTH 不变通过滚动查看超出部分 -->
<div
class="gantt-scroll-container"
ref="ganttScrollContainer"
:style="{ width: innerWidth + 'px', height: innerHeight + 'px' }"
>
<div class="gantt-inner-content" ref="ganttInnerContent">
<div class="gantt-scroll-container" ref="ganttScrollContainer">
<div class="gantt-main">
<div class="gantt-sidebar">
<div class="sidebar-header">任务名称</div>
@ -91,16 +77,45 @@
<el-tooltip
effect="dark"
placement="top"
:append-to-body="true"
popper-class="gantt-bar-tooltip"
:open-delay="150"
:content="buildTooltipText(item)"
>
<!-- 外层占满色块区域避免 tooltip absolute 子元素时触发区域为 0 -->
<div
class="timeline-bar"
class="timeline-bar-host"
:style="{
position: 'absolute',
left: item.startPx + 'px',
top: '6px',
width: item.widthPx + 'px',
height: '32px',
zIndex: 1
}"
:title="ganttBarTitleOneLine(item)"
>
<div
class="timeline-bar"
:class="{ 'timeline-bar-editable': isBarEditable(item) }"
:style="{
left: '0',
width: '100%',
backgroundColor: item.color
}"
>
<span
v-if="isBarEditable(item)"
class="bar-resize-handle bar-resize-handle-left"
title="拖动调整开始时间"
@mousedown.prevent.stop="onBarResizeStart($event, item, 'start')"
></span>
<span
v-if="isBarEditable(item)"
class="bar-resize-handle bar-resize-handle-right"
title="拖动调整结束时间"
@mousedown.prevent.stop="onBarResizeStart($event, item, 'end')"
></span>
<div class="bar-inner">
<span class="bar-text bar-text-name">
{{ item.name }}
@ -110,6 +125,7 @@
</span>
</div>
</div>
</div>
</el-tooltip>
</div>
</div>
@ -118,18 +134,6 @@
<!-- 横向滚动由 .timeline-scroll 自带原生滚动条完成不再使用自定义灰条 -->
</div>
</div>
</div>
<!-- 内框左上角右下角缩放手柄仅改变本容器宽高 -->
<div
class="gantt-inner-resize-handle gantt-inner-resize-handle-tl"
title="拖拽调整内框大小"
@mousedown.prevent.stop="onInnerResizeTopLeft"
></div>
<div
class="gantt-inner-resize-handle gantt-inner-resize-handle-br"
title="拖拽调整内框大小"
@mousedown.prevent.stop="onInnerResizeBottomRight"
></div>
</div>
<!-- 固定图例不随内框缩放 -->
@ -146,7 +150,12 @@
</span>
</div>
</div>
</el-dialog>
<div
class="gantt-panel-resize-handle"
title="拖拽调整窗口大小"
@mousedown.prevent.stop="onPanelResizeStart"
></div>
</div>
<!-- 底部最小化状态条 -->
<transition name="fade">
@ -179,11 +188,27 @@ function minutesToKTime(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 = Math.round(abs % 60);
// 使 min=60 K+03:60
const totalMin = Math.round(abs);
const h = Math.floor(totalMin / 60);
const min = totalMin % 60;
return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
}
function formatDurationText(startMinutes, endMinutes) {
const s = Number(startMinutes);
const e = Number(endMinutes);
if (!Number.isFinite(s) || !Number.isFinite(e)) return '--';
const deltaMin = Math.max(0, e - s);
const totalSec = Math.round(deltaMin * 60);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const sec = totalSec % 60;
if (h > 0) return `${h}小时${m}${sec}`;
if (m > 0) return `${m}${sec}`;
return `${sec}`;
}
export default {
name: 'GanttDrawer',
props: {
@ -202,11 +227,21 @@ export default {
isMinimized: false,
displayBars: [],
hourWidth: HOUR_WIDTH,
// 1200*800
innerWidth: 1168,
innerHeight: 560,
innerMinWidth: 400,
innerMinHeight: 280
panelLeft: null,
panelTop: 60,
panelWidth: 1200,
panelHeight: 800,
panelMinWidth: 860,
panelMinHeight: 460,
isPanelDragging: false,
panelDragOffsetX: 0,
panelDragOffsetY: 0,
isPanelResizing: false,
panelResizeStartX: 0,
panelResizeStartY: 0,
panelResizeStartW: 0,
panelResizeStartH: 0,
barResizeState: null
};
},
computed: {
@ -214,6 +249,17 @@ export default {
get() { return this.value; },
set(v) { this.$emit('input', v); }
},
panelStyle() {
const defaultLeft = Math.max(0, (window.innerWidth - this.panelWidth) / 2 - 20);
const left = this.panelLeft == null ? defaultLeft : this.panelLeft;
const top = this.panelTop == null ? 60 : this.panelTop;
return {
left: `${Math.max(0, left)}px`,
top: `${Math.max(0, top)}px`,
width: `${this.panelWidth}px`,
height: `${this.panelHeight}px`
};
},
minMinutes() {
return this.timeRange && Number.isFinite(this.timeRange.minMinutes) ? this.timeRange.minMinutes : 0;
},
@ -255,6 +301,7 @@ export default {
value(v) {
if (v) {
this.isMinimized = false;
this.ensurePanelInViewport();
this.loadMissiles();
this.rebuildGanttData();
}
@ -298,10 +345,6 @@ export default {
}
},
methods: {
handleClose() {
//
this.onClose();
},
minimize() {
this.isMinimized = true;
this.dialogVisible = false;
@ -313,6 +356,62 @@ export default {
onClose() {
this.$emit('input', false);
},
ensurePanelInViewport() {
if (this.panelLeft == null) return;
const maxLeft = Math.max(0, window.innerWidth - this.panelWidth);
const maxTop = Math.max(0, window.innerHeight - this.panelHeight);
this.panelLeft = Math.max(0, Math.min(maxLeft, this.panelLeft));
this.panelTop = Math.max(0, Math.min(maxTop, this.panelTop));
},
onPanelDragStart(e) {
if (this.isPanelResizing) return;
this.isPanelDragging = true;
const currentLeft = this.panelLeft == null ? Math.max(0, (window.innerWidth - this.panelWidth) / 2 - 20) : this.panelLeft;
const currentTop = this.panelTop == null ? 60 : this.panelTop;
this.panelDragOffsetX = e.clientX - currentLeft;
this.panelDragOffsetY = e.clientY - currentTop;
document.addEventListener('mousemove', this.onPanelDragMove);
document.addEventListener('mouseup', this.onPanelDragEnd);
},
onPanelDragMove(e) {
if (!this.isPanelDragging) return;
e.preventDefault();
const maxLeft = Math.max(0, window.innerWidth - this.panelWidth);
const maxTop = Math.max(0, window.innerHeight - this.panelHeight);
const left = e.clientX - this.panelDragOffsetX;
const top = e.clientY - this.panelDragOffsetY;
this.panelLeft = Math.max(0, Math.min(maxLeft, left));
this.panelTop = Math.max(0, Math.min(maxTop, top));
},
onPanelDragEnd() {
this.isPanelDragging = false;
document.removeEventListener('mousemove', this.onPanelDragMove);
document.removeEventListener('mouseup', this.onPanelDragEnd);
},
onPanelResizeStart(e) {
if (this.isPanelDragging) return;
this.isPanelResizing = true;
this.panelResizeStartX = e.clientX;
this.panelResizeStartY = e.clientY;
this.panelResizeStartW = this.panelWidth;
this.panelResizeStartH = this.panelHeight;
document.addEventListener('mousemove', this.onPanelResizeMove);
document.addEventListener('mouseup', this.onPanelResizeEnd);
},
onPanelResizeMove(e) {
if (!this.isPanelResizing) return;
e.preventDefault();
const dx = e.clientX - this.panelResizeStartX;
const dy = e.clientY - this.panelResizeStartY;
this.panelWidth = Math.max(this.panelMinWidth, Math.min(window.innerWidth - 20, this.panelResizeStartW + dx));
this.panelHeight = Math.max(this.panelMinHeight, Math.min(window.innerHeight - 20, this.panelResizeStartH + dy));
this.ensurePanelInViewport();
},
onPanelResizeEnd() {
this.isPanelResizing = false;
document.removeEventListener('mousemove', this.onPanelResizeMove);
document.removeEventListener('mouseup', this.onPanelResizeEnd);
},
rebuildGanttData() {
const list = [];
const span = this.spanMinutes || 1;
@ -330,6 +429,8 @@ export default {
color: r.color || '#409EFF',
startMinutes: r.startMinutes,
endMinutes: r.endMinutes,
actualStartKTime: minutesToKTime(r.startMinutes),
actualEndKTime: minutesToKTime(r.endMinutes),
startKTime: minutesToKTime(r.startMinutes),
endKTime: minutesToKTime(endLabelMinutes),
startPx,
@ -349,6 +450,8 @@ export default {
color: h.color || '#E6A23C',
startMinutes: h.startMinutes,
endMinutes: h.endMinutes,
actualStartKTime: minutesToKTime(h.startMinutes),
actualEndKTime: minutesToKTime(h.endMinutes),
startKTime: minutesToKTime(h.startMinutes),
endKTime: minutesToKTime(endLabelMinutes),
startPx,
@ -368,6 +471,8 @@ export default {
color: m.color || '#F56C6C',
startMinutes: m.startMinutes,
endMinutes: m.endMinutes,
actualStartKTime: minutesToKTime(m.startMinutes),
actualEndKTime: minutesToKTime(m.endMinutes),
startKTime: minutesToKTime(m.startMinutes),
endKTime: minutesToKTime(endLabelMinutes),
startPx,
@ -377,56 +482,80 @@ export default {
this.displayBars = list.sort((a, b) => a.startMinutes - b.startMinutes);
},
/** 内框右下角拖拽:仅改变 innerWidth / innerHeight,拖拽中实时重算布局 */
onInnerResizeBottomRight(e) {
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const startW = this.innerWidth;
const startH = this.innerHeight;
const onMove = (e2) => {
e2.preventDefault();
const dx = e2.clientX - startX;
const dy = e2.clientY - startY;
this.innerWidth = Math.max(this.innerMinWidth, Math.min(2000, startW + dx));
this.innerHeight = Math.max(this.innerMinHeight, Math.min(1200, startH + dy));
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
isBarEditable(item) {
return item && item.type === 'route';
},
/** 内框左上角拖拽:仅改变 innerWidth / innerHeight */
onInnerResizeTopLeft(e) {
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const startW = this.innerWidth;
const startH = this.innerHeight;
const onMove = (e2) => {
e2.preventDefault();
const dx = e2.clientX - startX;
const dy = e2.clientY - startY;
this.innerWidth = Math.max(this.innerMinWidth, Math.min(2000, startW - dx));
this.innerHeight = Math.max(this.innerMinHeight, Math.min(1200, startH - dy));
onBarResizeStart(e, item, edge) {
if (!this.isBarEditable(item)) return;
this.barResizeState = {
id: item.id,
edge,
startX: e.clientX,
startMinutes: item.startMinutes,
endMinutes: item.endMinutes
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
document.addEventListener('mousemove', this.onBarResizeMove);
document.addEventListener('mouseup', this.onBarResizeEnd);
},
onBarResizeMove(e) {
if (!this.barResizeState) return;
e.preventDefault();
const state = this.barResizeState;
const deltaMinutes = ((e.clientX - state.startX) / this.hourWidth) * 60;
const roundedDelta = Math.round(deltaMinutes);
const minDuration = 1;
const idx = this.displayBars.findIndex(b => b.id === state.id);
if (idx < 0) return;
const target = this.displayBars[idx];
let nextStart = state.startMinutes;
let nextEnd = state.endMinutes;
if (state.edge === 'start') {
nextStart = Math.min(state.endMinutes - minDuration, state.startMinutes + roundedDelta);
} else {
nextEnd = Math.max(state.startMinutes + minDuration, state.endMinutes + roundedDelta);
}
this.applyBarMinutes(target, nextStart, nextEnd);
},
onBarResizeEnd() {
if (!this.barResizeState) return;
const state = this.barResizeState;
const target = this.displayBars.find(b => b.id === state.id);
if (target) {
this.$emit('bar-time-change', {
id: target.id,
type: target.type,
name: target.name,
startMinutes: target.startMinutes,
endMinutes: target.endMinutes
});
}
this.barResizeState = null;
document.removeEventListener('mousemove', this.onBarResizeMove);
document.removeEventListener('mouseup', this.onBarResizeEnd);
},
/** 恢复内框默认大小 */
restoreInnerSize() {
this.innerWidth = 1168;
this.innerHeight = 560;
this.$message.success('已恢复内框大小');
applyBarMinutes(bar, startMinutes, endMinutes) {
const durationMin = Math.max(0, endMinutes - startMinutes);
bar.startMinutes = startMinutes;
bar.endMinutes = endMinutes;
bar.startKTime = minutesToKTime(startMinutes);
bar.endKTime = minutesToKTime(Math.min(endMinutes, this.maxMinutes));
bar.actualStartKTime = minutesToKTime(startMinutes);
bar.actualEndKTime = minutesToKTime(endMinutes);
bar.startPx = ((startMinutes - this.minMinutes) / 60) * this.hourWidth;
bar.widthPx = Math.max(40, (durationMin / 60) * this.hourWidth);
},
buildTooltipText(item) {
return `${item.name}\n${item.startKTime}${item.endKTime}`;
const start = item.actualStartKTime || item.startKTime;
const end = item.actualEndKTime || item.endKTime;
const duration = formatDurationText(item.startMinutes, item.endMinutes);
return `${item.name}\n时间段:${start}${end}\n时长:${duration}`;
},
/** 浏览器原生 title(单行),作后备;悬浮块区域小时也能看到 */
ganttBarTitleOneLine(item) {
const start = item.actualStartKTime || item.startKTime;
const end = item.actualEndKTime || item.endKTime;
const duration = formatDurationText(item.startMinutes, item.endMinutes);
return `${item.name} | ${start}${end} | ${duration}`;
},
refreshData() {
this.loadMissiles();
@ -480,8 +609,8 @@ export default {
/** 导出用:捕获内框完整内容(含超出可视区域部分),非仅视口 */
getExportTarget() {
const container = this.$refs.ganttScrollContainer;
const content = this.$refs.ganttInnerContent;
if (container && content) return { container, content };
const content = container;
if (container) return { container, content };
return null;
},
exportPng() {
@ -593,6 +722,11 @@ export default {
this.exporting = false;
});
}
},
beforeDestroy() {
this.onPanelDragEnd();
this.onPanelResizeEnd();
this.onBarResizeEnd();
}
};
</script>
@ -601,12 +735,24 @@ export default {
.gantt-shell {
position: relative;
}
.gantt-panel {
position: fixed;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 8px;
border: 1px solid #e4e7ed;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.12);
z-index: 2500;
overflow: hidden;
}
.gantt-dialog-body {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
min-height: 0;
padding: 0 16px 16px;
background: #f7f8fa;
background: #f5f7fb;
overflow: hidden;
}
.gantt-toolbar {
@ -614,9 +760,9 @@ export default {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
padding: 10px 0;
border-bottom: 1px solid #e4e7ed;
margin-bottom: 12px;
margin-bottom: 10px;
}
.toolbar-left { font-size: 13px; color: #606266; }
.toolbar-right { display: flex; gap: 8px; align-items: center; }
@ -624,16 +770,13 @@ export default {
/* 内框:可缩放,overflow:auto 产生局部滚动条 */
.gantt-scroll-container {
position: relative;
flex-shrink: 0;
flex: 1;
min-height: 0;
overflow: auto;
border-radius: 6px;
box-shadow: 0 0 0 1px #ebeef5;
background: #fff;
}
.gantt-inner-content {
min-width: min-content;
min-height: min-content;
}
.gantt-main {
display: flex;
background: #fff;
@ -695,7 +838,7 @@ export default {
display: flex;
flex-direction: column;
}
/* 横向仍可滚动,但隐藏本层滚动条,只用下边 gantt-scroll-container 的滚动条 */
/* 横向仍可滚动;同时显示在同一层,避免“内外两层感” */
.timeline-scroll {
flex: 1;
min-height: 0;
@ -711,11 +854,6 @@ export default {
scrollbar-width: none;
-ms-overflow-style: none;
}
.timeline-scroll::-webkit-scrollbar {
display: none !important;
width: 0;
height: 0;
}
.timeline-header {
flex-shrink: 0;
height: 40px;
@ -739,23 +877,14 @@ export default {
font-weight: 500;
text-align: left;
}
/* 右侧禁止任何垂直滚动条 */
.timeline-content {
flex: 1;
min-height: 0;
position: relative;
overflow-x: hidden;
overflow-y: hidden !important;
overflow-y: hidden;
background: #fff;
border-bottom: none;
scrollbar-width: none;
-ms-overflow-style: none;
}
.timeline-content::-webkit-scrollbar,
.timeline-content::-webkit-scrollbar-vertical {
display: none !important;
width: 0;
height: 0;
}
.timeline-row {
height: 44px;
@ -789,6 +918,17 @@ export default {
.timeline-scroll-clip {
border-bottom: none !important;
}
/* 色块外包一层,保证 tooltip 触发区域与色块一致 */
.timeline-bar-host {
pointer-events: auto;
}
.timeline-bar-host .timeline-bar {
top: 0;
left: 0;
width: 100%;
height: 100%;
min-width: 0;
}
.timeline-bar {
position: absolute;
top: 6px;
@ -801,6 +941,38 @@ export default {
min-width: 80px;
box-shadow: 0 0 0 1px rgba(0,0,0,0.05);
}
.timeline-bar.timeline-bar-editable {
cursor: ew-resize;
}
.bar-resize-handle {
position: absolute;
top: 0;
width: 10px;
height: 100%;
z-index: 2;
}
.bar-resize-handle-left {
left: 0;
cursor: w-resize;
}
.bar-resize-handle-right {
right: 0;
cursor: e-resize;
}
.bar-resize-handle::after {
content: '';
position: absolute;
top: 7px;
bottom: 7px;
width: 2px;
background: rgba(255, 255, 255, 0.85);
}
.bar-resize-handle-left::after {
left: 2px;
}
.bar-resize-handle-right::after {
right: 2px;
}
.bar-inner {
display: flex;
flex-direction: column;
@ -826,69 +998,22 @@ export default {
opacity: 0.9;
margin-top: 2px;
}
/* 图例:固定不随内框缩放 */
.gantt-legend {
flex-shrink: 0;
margin-top: 12px;
padding-top: 0;
margin-top: 10px;
padding: 6px 0 2px;
border-top: none;
font-size: 13px;
font-size: 12px;
line-height: 1.7;
color: #606266;
white-space: normal;
word-break: keep-all;
}
/* 内框缩放手柄 */
.gantt-inner-resize-handle {
position: absolute;
width: 20px;
height: 20px;
z-index: 10;
background: rgba(0, 0, 0, 0.05);
pointer-events: auto;
}
.gantt-inner-resize-handle-tl {
left: 0;
top: 0;
cursor: nw-resize;
border-radius: 4px 0 0 0;
}
.gantt-inner-resize-handle-tl::after {
content: '';
position: absolute;
left: 4px;
top: 4px;
width: 8px;
height: 8px;
border-left: 2px solid #909399;
border-top: 2px solid #909399;
border-radius: 2px 0 0 0;
}
.gantt-inner-resize-handle-br {
right: 0;
bottom: 0;
cursor: se-resize;
border-radius: 0 0 4px 0;
}
.gantt-inner-resize-handle-br::after {
content: '';
position: absolute;
right: 4px;
bottom: 4px;
width: 8px;
height: 8px;
border-right: 2px solid #909399;
border-bottom: 2px solid #909399;
border-radius: 0 0 2px 0;
}
.gantt-inner-resize-handle:hover {
background: rgba(0, 0, 0, 0.1);
}
.gantt-inner-resize-handle:hover::after {
border-color: #606266;
}
.legend-title { margin-right: 16px; }
.legend-item { margin-right: 16px; display: inline-flex; align-items: center; gap: 6px; }
.legend-title { margin-right: 10px; color: #909399; }
.legend-item { margin-right: 14px; display: inline-flex; align-items: center; gap: 6px; }
.legend-color {
width: 14px;
height: 14px;
width: 12px;
height: 12px;
border-radius: 2px;
display: inline-block;
}
@ -915,6 +1040,20 @@ export default {
z-index: 3000;
border: 1px solid #e4e7ed;
}
.gantt-panel-resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
user-select: none;
z-index: 20;
background: linear-gradient(to top left, transparent 50%, rgba(64, 158, 255, 0.28) 50%);
}
.gantt-panel-resize-handle:hover {
background: linear-gradient(to top left, transparent 50%, rgba(64, 158, 255, 0.42) 50%);
}
.minibar-left {
display: flex;
align-items: center;
@ -934,107 +1073,46 @@ export default {
</style>
<style>
.gantt-dialog .el-dialog__body {
padding: 0 16px 16px;
overflow: hidden;
}
/* 时间轴区域:禁止垂直滚动条;.timeline-scroll 也隐藏横向条,只用下边容器的滚动条 */
.gantt-dialog .gantt-timeline-wrap,
.gantt-dialog .timeline-scroll-clip,
.gantt-dialog .timeline-scroll,
.gantt-dialog .timeline-content {
overflow-y: hidden !important;
scrollbar-width: none;
-ms-overflow-style: none;
}
.gantt-dialog .gantt-timeline-wrap::-webkit-scrollbar,
.gantt-dialog .timeline-scroll-clip::-webkit-scrollbar,
.gantt-dialog .timeline-scroll::-webkit-scrollbar,
.gantt-dialog .timeline-content::-webkit-scrollbar {
display: none !important;
width: 0;
height: 0;
}
.gantt-dialog .el-dialog__header {
padding: 12px 20px;
border-bottom: 1px solid #e4e7ed;
}
.gantt-dialog-title {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding-right: 0;
padding: 8px 12px 8px 16px;
border-bottom: 1px solid #e9edf3;
background: linear-gradient(180deg, #fafbfd 0%, #f5f8fc 100%);
cursor: move;
}
.gantt-dialog-title-text {
font-size: 18px;
color: #303133;
font-size: 14px;
line-height: 20px;
font-weight: 500;
color: #5c6778;
letter-spacing: 0.2px;
}
.gantt-dialog-title-actions {
display: inline-flex;
align-items: center;
gap: 4px;
gap: 2px;
}
.gantt-dialog-header-icon {
width: 40px;
height: 40px;
margin: -12px 0;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-size: 14px;
color: #909399;
cursor: pointer;
transition: color 0.2s;
border-radius: 4px;
border: none;
background: none;
outline: none;
}
.gantt-dialog-header-icon:hover {
color: #303133;
}
.gantt-dialog .el-dialog__headerbtn {
display: none;
}
/* 外框固定:不可缩放,固定 1200*800 */
.el-dialog.gantt-dialog.gantt-dialog-fixed {
width: 1200px !important;
height: 800px !important;
max-width: none !important;
resize: none !important;
overflow: hidden !important;
position: relative;
}
.gantt-dialog .gantt-dialog-body {
position: relative;
}
/* el-table:先清掉所有 border,再只给格子加线(甘特图内若使用 el-table 时生效) */
.gantt-dialog .el-table,
.gantt-dialog .el-table th,
.gantt-dialog .el-table td {
border: none !important;
}
.gantt-dialog .el-table::before {
display: none !important;
}
.gantt-dialog .el-table__fixed::before,
.gantt-dialog .el-table__fixed-right::before {
display: none !important;
}
.gantt-dialog .el-table__body-wrapper,
.gantt-dialog .el-table__header-wrapper {
border: none !important;
}
/* 手动给格子加线:只保留单元格之间的分隔线 */
.gantt-dialog .el-table td {
border-bottom: 1px solid #ebeef5 !important;
}
.gantt-dialog .el-table th {
border-bottom: 1px solid #ebeef5 !important;
}
.gantt-dialog .el-table td + td,
.gantt-dialog .el-table th + th {
border-left: 1px solid #ebeef5 !important;
background: #eef2f8;
}
.fade-enter-active,
.fade-leave-active {
@ -1044,4 +1122,15 @@ export default {
.fade-leave-to {
opacity: 0;
}
/* 挂到 body 的 tooltip,需高于甘特面板 z-index:2500 */
.gantt-bar-tooltip {
z-index: 10050 !important;
max-width: 360px;
white-space: pre-line;
line-height: 1.45;
}
/* 下拉挂到 body 时默认 z-index≈2001,低于 .gantt-panel(2500),菜单会被面板挡住 */
.gantt-export-dropdown-menu.el-dropdown-menu {
z-index: 10060 !important;
}
</style>

155
ruoyi-ui/src/views/childRoom/WhiteboardPanel.vue

@ -67,6 +67,13 @@
</el-dropdown-menu>
</el-dropdown>
</div>
<el-button type="text" size="small" @click="$emit('export-whiteboard')" title="导出当前时间块" :disabled="!currentTimeBlock">
<i class="el-icon-download"></i> 导出
</el-button>
<el-button type="text" size="small" @click="triggerImport" title="导入到当前时间块" :disabled="!currentTimeBlock">
<i class="el-icon-upload2"></i> 导入
</el-button>
<input ref="importFileInput" type="file" accept=".json" style="display:none" @change="onImportFileChange" />
<el-button type="text" size="small" @click="$emit('create-whiteboard')" title="新建白板">
<i class="el-icon-plus"></i> 新建
</el-button>
@ -103,7 +110,7 @@
</div>
<!-- 添加时间块弹窗 -->
<el-dialog title="添加时间块" :visible.sync="showAddTimeBlock" width="400px" append-to-body @close="newTimeBlockValue = null; newTimeBlockInput = ''">
<el-dialog title="添加时间块" :visible.sync="showAddTimeBlock" width="400px" append-to-body @close="newTimeBlockInput = ''">
<el-form label-width="100px" size="small">
<el-form-item label="快捷选择">
<div class="time-presets">
@ -117,17 +124,8 @@
</el-tag>
</div>
</el-form-item>
<el-form-item label="选择时间">
<el-time-picker
v-model="newTimeBlockValue"
format="HH:mm:ss"
value-format="HH:mm:ss"
placeholder="选择 K+ 后的时间"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="或手动输入">
<el-input v-model="newTimeBlockInput" placeholder="如 K+00:05:00" size="small" />
<el-form-item label="手动输入">
<el-input v-model="newTimeBlockInput" placeholder="如 -5 或 10,表示 K-5 或 K+10 分钟" size="small" @input="onManualInputChange" />
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
@ -150,7 +148,7 @@
</el-dialog>
<!-- 修改时间弹窗与新建时间块同结构 -->
<el-dialog title="修改时间" :visible.sync="showRenameTimeBlock" width="400px" append-to-body @open="initModifyTimeBlock" @close="renameTimeBlockValue = null; renameTimeBlockInput = ''">
<el-dialog title="修改时间" :visible.sync="showRenameTimeBlock" width="400px" append-to-body @open="initModifyTimeBlock" @close="renameTimeBlockInput = ''">
<el-form label-width="100px" size="small">
<el-form-item label="快捷选择">
<div class="time-presets">
@ -164,17 +162,8 @@
</el-tag>
</div>
</el-form-item>
<el-form-item label="选择时间">
<el-time-picker
v-model="renameTimeBlockValue"
format="HH:mm:ss"
value-format="HH:mm:ss"
placeholder="选择 K+ 后的时间"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="或手动输入">
<el-input v-model="renameTimeBlockInput" placeholder="如 K+00:10:00" size="small" />
<el-form-item label="手动输入">
<el-input v-model="renameTimeBlockInput" placeholder="如 -5 或 10,表示 K-5 或 K+10 分钟" size="small" @input="onRenameManualInputChange" />
</el-form-item>
</el-form>
<span slot="footer" class="dialog-footer">
@ -228,15 +217,14 @@ export default {
renameWhiteboardName: '',
showAddTimeBlock: false,
showRenameTimeBlock: false,
newTimeBlockValue: null,
newTimeBlockInput: '',
renameTimeBlockValue: null,
renameTimeBlockInput: ''
}
},
computed: {
timeBlockPresets() {
return [
{ label: 'K-5', value: 'K-00:05:00' },
{ label: 'K+0', value: 'K+00:00:00' },
{ label: 'K+5', value: 'K+00:05:00' },
{ label: 'K+10', value: 'K+00:10:00' },
@ -301,9 +289,11 @@ export default {
},
compareTimeBlock(a, b) {
const parse = (s) => {
const m = /K\+(\d+):(\d+):(\d+)/.exec(s)
if (!m) return 0
return parseInt(m[1], 10) * 3600 + parseInt(m[2], 10) * 60 + parseInt(m[3], 10)
const mPlus = /K\+(\d+):(\d+):(\d+)/.exec(s)
if (mPlus) return parseInt(mPlus[1], 10) * 3600 + parseInt(mPlus[2], 10) * 60 + parseInt(mPlus[3], 10)
const mMinus = /K-(\d+):(\d+):(\d+)/.exec(s)
if (mMinus) return -(parseInt(mMinus[1], 10) * 3600 + parseInt(mMinus[2], 10) * 60 + parseInt(mMinus[3], 10))
return 0
}
return parse(a) - parse(b)
},
@ -338,34 +328,68 @@ export default {
exitWhiteboard() {
this.$emit('exit-whiteboard')
},
triggerImport() {
this.$refs.importFileInput && this.$refs.importFileInput.click()
},
onImportFileChange(ev) {
const file = ev.target.files && ev.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const json = JSON.parse(e.target.result)
this.$emit('import-whiteboard', json)
} catch (err) {
this.$message.error('文件解析失败,请确保是有效的白板导出文件')
}
ev.target.value = ''
}
reader.readAsText(file, 'UTF-8')
},
selectTimeBlock(tb) {
this.currentTimeBlock = tb
this.$emit('select-time-block', tb)
},
selectTimePreset(value) {
this.$emit('add-time-block', value)
this.newTimeBlockValue = null
this.newTimeBlockInput = ''
this.showAddTimeBlock = false
},
onManualInputChange(val) {
let v = (val || '').replace(/[^\d-]/g, '')
if (v.startsWith('-')) v = '-' + v.slice(1).replace(/-/g, '')
else v = v.replace(/-/g, '')
if (v !== val) this.$nextTick(() => { this.newTimeBlockInput = v })
},
onRenameManualInputChange(val) {
let v = (val || '').replace(/[^\d-]/g, '')
if (v.startsWith('-')) v = '-' + v.slice(1).replace(/-/g, '')
else v = v.replace(/-/g, '')
if (v !== val) this.$nextTick(() => { this.renameTimeBlockInput = v })
},
parseNumericToTimeBlock(input) {
const s = (input || '').trim()
if (!s) return null
const num = parseInt(s, 10)
if (isNaN(num)) return null
const abs = Math.abs(num)
const h = Math.floor(abs / 60)
const m = abs % 60
const pad = (n) => String(n).padStart(2, '0')
return num < 0 ? `K-${pad(h)}:${pad(m)}:00` : `K+${pad(h)}:${pad(m)}:00`
},
addTimeBlock() {
let timeStr = ''
if (this.newTimeBlockValue) {
timeStr = 'K+' + this.newTimeBlockValue
} else {
const input = (this.newTimeBlockInput || '').trim()
if (!input) {
this.$message.warning('请选择时间或输入格式,如 K+00:05:00')
return
}
if (!/^K\+\d+:\d{2}:\d{2}$/.test(input)) {
this.$message.warning('格式应为 K+HH:MM:SS,如 K+00:05:00')
return
}
timeStr = input
const input = (this.newTimeBlockInput || '').trim()
if (!input) {
this.$message.warning('请输入数字,如 -5 或 10')
return
}
const timeStr = this.parseNumericToTimeBlock(input)
if (!timeStr) {
this.$message.warning('请输入有效数字,负数表示 K-,正数表示 K+')
return
}
this.$emit('add-time-block', timeStr)
this.newTimeBlockValue = null
this.newTimeBlockInput = ''
this.showAddTimeBlock = false
},
@ -374,40 +398,37 @@ export default {
},
initModifyTimeBlock() {
if (!this.currentTimeBlock) return
const m = /K\+(\d+):(\d{2}):(\d{2})/.exec(this.currentTimeBlock)
if (m) {
this.renameTimeBlockValue = `${String(m[1]).padStart(2, '0')}:${m[2]}:${m[3]}`
this.renameTimeBlockInput = this.currentTimeBlock
const s = this.currentTimeBlock
const mPlus = /K\+(\d+):(\d+):(\d+)/.exec(s)
const mMinus = /K-(\d+):(\d+):(\d+)/.exec(s)
if (mPlus) {
const totalMin = parseInt(mPlus[1], 10) * 60 + parseInt(mPlus[2], 10) + parseInt(mPlus[3], 10) / 60
this.renameTimeBlockInput = String(Math.round(totalMin))
} else if (mMinus) {
const totalMin = parseInt(mMinus[1], 10) * 60 + parseInt(mMinus[2], 10) + parseInt(mMinus[3], 10) / 60
this.renameTimeBlockInput = String(-Math.round(totalMin))
} else {
this.renameTimeBlockValue = null
this.renameTimeBlockInput = this.currentTimeBlock
this.renameTimeBlockInput = ''
}
},
selectRenamePreset(value) {
this.$emit('rename-time-block', this.currentTimeBlock, value)
this.renameTimeBlockValue = null
this.renameTimeBlockInput = ''
this.showRenameTimeBlock = false
},
renameTimeBlock() {
let timeStr = ''
if (this.renameTimeBlockValue) {
timeStr = 'K+' + this.renameTimeBlockValue
} else {
const input = (this.renameTimeBlockInput || '').trim()
if (!input) {
this.$message.warning('请选择时间或输入格式,如 K+00:05:00')
return
}
if (!/^K\+\d+:\d{2}:\d{2}$/.test(input)) {
this.$message.warning('格式应为 K+HH:MM:SS,如 K+00:05:00')
return
}
timeStr = input
const input = (this.renameTimeBlockInput || '').trim()
if (!input) {
this.$message.warning('请输入数字,如 -5 或 10')
return
}
const timeStr = this.parseNumericToTimeBlock(input)
if (!timeStr) {
this.$message.warning('请输入有效数字,负数表示 K-,正数表示 K+')
return
}
if (!this.currentTimeBlock) return
this.$emit('rename-time-block', this.currentTimeBlock, timeStr)
this.renameTimeBlockValue = null
this.renameTimeBlockInput = ''
this.showRenameTimeBlock = false
},

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

File diff suppressed because it is too large

52
ruoyi-ui/src/views/dialogs/ExportRoutesDialog.vue

@ -3,7 +3,7 @@
title="导出航线"
:visible.sync="visible"
width="720px"
top="52vh"
top="22vh"
append-to-body
class="export-routes-dialog"
@close="handleClose"
@ -18,9 +18,10 @@
<el-button type="text" size="small" @click="selectNone">全不选</el-button>
</div>
<div class="tree-list">
<!-- 按方案分组的航线 -->
<div
v-for="plan in plansWithRoutes"
:key="plan.id"
:key="'plan-' + plan.id"
class="tree-item plan-item"
>
<div class="tree-item-header" @click="togglePlan(plan.id)">
@ -33,7 +34,35 @@
<div v-if="expandedPlans.includes(plan.id)" class="tree-children route-children">
<div
v-for="route in planRoutes(plan.id)"
:key="route.id"
:key="'route-' + route.id"
class="tree-item route-item"
:class="{ selected: selectedIds.includes(route.id) }"
@click.stop="toggleRouteSelect(route.id)"
>
<el-checkbox
:value="selectedIds.includes(route.id)"
@change="(v) => setRouteSelected(route.id, v)"
@click.native.stop
>
<span class="route-name">{{ route.name }}</span>
<span class="route-meta">{{ route.points || (route.waypoints && route.waypoints.length) || 0 }} 个航点</span>
</el-checkbox>
</div>
</div>
</div>
<!-- 未归属方案的航线避免因 scenarioId 不匹配而遗漏 -->
<div v-if="orphanRoutesList.length > 0" class="tree-item plan-item">
<div class="tree-item-header" @click="togglePlan('orphan')">
<i :class="expandedPlans.includes('orphan') ? 'el-icon-folder-opened' : 'el-icon-folder'" class="tree-icon"></i>
<div class="tree-item-info">
<div class="tree-item-name">未归属方案</div>
<div class="tree-item-meta">{{ orphanRoutesList.length }} 个航线</div>
</div>
</div>
<div v-if="expandedPlans.includes('orphan')" class="tree-children route-children">
<div
v-for="route in orphanRoutesList"
:key="'orphan-' + route.id"
class="tree-item route-item"
:class="{ selected: selectedIds.includes(route.id) }"
@click.stop="toggleRouteSelect(route.id)"
@ -95,19 +124,32 @@ export default {
/** 有航线的方案列表(用于展示) */
plansWithRoutes() {
return this.plans.filter(p => this.planRoutes(p.id).length > 0);
},
/** 未归属任何方案的航线(scenarioId 为空或不匹配任何 plan) */
orphanRoutesList() {
const planIds = (this.plans || []).map(p => p.id);
return this.routes.filter(r => {
if (r.scenarioId == null) return true;
const sid = Number(r.scenarioId);
return !planIds.some(pid => Number(pid) === sid);
});
}
},
watch: {
value(v) {
if (v) {
this.selectedIds = this.routes.map(r => r.id);
this.expandedPlans = this.plansWithRoutes.map(p => p.id);
this.expandedPlans = [
...this.plansWithRoutes.map(p => p.id),
...(this.orphanRoutesList.length > 0 ? ['orphan'] : [])
];
}
}
},
methods: {
/** 获取某方案下的航线(使用宽松比较,兼容 scenarioId 与 planId 的类型差异) */
planRoutes(planId) {
return this.routes.filter(r => r.scenarioId === planId);
return this.routes.filter(r => r.scenarioId != null && (r.scenarioId === planId || Number(r.scenarioId) === Number(planId)));
},
togglePlan(planId) {
const idx = this.expandedPlans.indexOf(planId);

73
ruoyi-ui/src/views/index.vue

@ -3,53 +3,25 @@
<el-row :gutter="20">
<el-col :span="24">
<div class="header-section">
<h1 class="system-title">联合作战指挥系统入口</h1>
<p class="system-desc">请选择下方功能模块进入</p>
<h1 class="system-title">联合作战指挥系统</h1>
<p class="system-desc">点击下方进入作战系统</p>
</div>
</el-col>
</el-row>
<el-row :gutter="30" type="flex" justify="center" style="margin-top: 30px;">
<el-col :xs="24" :sm="8" :md="6">
<el-card shadow="hover" class="entry-card" @click.native="goCesium">
<el-row type="flex" justify="center" style="margin-top: 30px;">
<el-col :xs="24" :sm="12" :md="8">
<el-card shadow="hover" class="entry-card" @click.native="goCombatSystem">
<div class="card-content">
<div class="icon-wrapper blue">
<i class="el-icon-map-location"></i>
</div>
<h3>三维全屏地图</h3>
<p>Cesium三维地球态势展示</p>
<el-button type="text" class="enter-btn">点击进入 &gt;</el-button>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="8" :md="6">
<el-card shadow="hover" class="entry-card" @click.native="goSelectRoom">
<div class="card-content">
<div class="icon-wrapper green">
<i class="el-icon-office-building"></i>
</div>
<h3>选择/管理房间</h3>
<p>进入大厅查看或创建作战室</p>
<el-button type="text" class="enter-btn">点击进入 &gt;</el-button>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="8" :md="6">
<el-card shadow="hover" class="entry-card" @click.native="goChildRoom">
<div class="card-content">
<div class="icon-wrapper orange">
<div class="icon-wrapper primary">
<i class="el-icon-s-operation"></i>
</div>
<h3>进入子房间</h3>
<p>直接进入作战推演与标绘界面</p>
<h3>进入作战系统</h3>
<p>选择房间进入作战推演与标绘界面</p>
<el-button type="text" class="enter-btn">点击进入 &gt;</el-button>
</div>
</el-card>
</el-col>
</el-row>
<div class="footer-tips">
@ -67,20 +39,9 @@ export default {
};
},
methods: {
// 1. Cesium
goCesium() {
// path router/index.js
this.$router.push({ path: "/cesiumMap" }).catch(() => {});
},
// 2.
goSelectRoom() {
//
goCombatSystem() {
this.$router.push({ path: "/selectRoom" }).catch(() => {});
},
// 3. ()
goChildRoom() {
this.$router.push({ path: "/childRoom" }).catch(() => {});
}
}
};
@ -165,20 +126,10 @@ export default {
font-size: 40px;
margin-bottom: 10px;
&.blue {
background-color: rgba(64, 158, 255, 0.1);
&.primary {
background-color: rgba(64, 158, 255, 0.15);
color: #409EFF;
}
&.green {
background-color: rgba(103, 194, 58, 0.1);
color: #67C23A;
}
&.orange {
background-color: rgba(230, 162, 60, 0.1);
color: #E6A23C;
}
}
/* 进入按钮动画 */

69
ruoyi-ui/src/workers/conflictCheck.worker.js

@ -4,7 +4,9 @@ import {
defaultConflictConfig,
detectPlatformPlacementTooClose,
detectRestrictedZoneIntrusion,
detectSpectrumConflicts,
detectSafetyColumnSpatial,
detectSafetyColumnTemporal,
formatKLabel,
parseRestrictedZonesFromDrawings
} from '@/utils/conflictDetection'
import { getPositionFromTimeline } from '@/utils/timelinePosition'
@ -65,6 +67,30 @@ function bboxesOverlap(a, b) {
return !(a.maxLng < b.minLng || b.maxLng < a.minLng || a.maxLat < b.minLat || b.maxLat < a.minLat || a.maxAlt < b.minAlt || b.maxAlt < a.minAlt)
}
/** 从 timeline.segments 取当前时刻所在段的 speedKmh,用于间距不足时的速度建议 */
function getSpeedKmhFromTimeline(timelines, routeId, minutesFromK) {
const tl = timelines[routeId]
if (!tl || !Array.isArray(tl.segments) || tl.segments.length === 0) return 800
for (let i = 0; i < tl.segments.length; i++) {
const s = tl.segments[i]
if (minutesFromK >= s.startTime && minutesFromK < s.endTime) {
const sp = Number(s.speedKmh)
return Number.isFinite(sp) && sp > 0 ? sp : 800
}
}
const last = tl.segments[tl.segments.length - 1]
if (minutesFromK >= last.endTime) {
const sp = Number(last.speedKmh)
return Number.isFinite(sp) && sp > 0 ? sp : 800
}
const first = tl.segments[0]
if (minutesFromK < first.startTime) {
const sp = Number(first.speedKmh)
return Number.isFinite(sp) && sp > 0 ? sp : 800
}
return 800
}
self.onmessage = (evt) => {
const msg = evt && evt.data ? evt.data : {}
const { requestId } = msg
@ -76,7 +102,7 @@ self.onmessage = (evt) => {
const routeTimelines = msg.routeTimelines || {}
const platformIcons = Array.isArray(msg.platformIcons) ? msg.platformIcons : []
const drawingsEntities = Array.isArray(msg.drawingsEntities) ? msg.drawingsEntities : []
const spectrumLedger = Array.isArray(msg.spectrumLedger) ? msg.spectrumLedger : []
const routeNamesById = (msg.routeNamesById && typeof msg.routeNamesById === 'object') ? msg.routeNamesById : {}
const getPos = (routeId, minutesFromK) => {
const c = routeTimelines[routeId]
@ -93,9 +119,16 @@ self.onmessage = (evt) => {
}
const filteredRouteIds = routeIds.filter(rid => !!expanded[rid])
const safetyR = config.safetyColumnRadiusMeters != null ? Number(config.safetyColumnRadiusMeters) : defaultConflictConfig.safetyColumnRadiusMeters
const expandedSafety = {}
for (const rid of routeIds) {
expandedSafety[rid] = expandBboxByMeters(bboxes[rid], 2 * safetyR)
}
// detectTrackSeparation 内部仍是两两循环,这里通过“剔除 bbox 不交的航线对”来降低平均复杂度
// 做法:把 routeIds 按 bbox 相交关系分组(简化:两两判定时跳过),通过 getPos 闭包实现。
const canPair = (a, b) => bboxesOverlap(expanded[a], expanded[b])
const canPairSafety = (a, b) => bboxesOverlap(expandedSafety[a], expandedSafety[b])
const getPosWithPairHint = (routeId, minutesFromK) => getPos(routeId, minutesFromK)
@ -136,7 +169,7 @@ self.onmessage = (evt) => {
subType: 'track_separation',
title: '航迹间隔过小',
routeIds: [ridA, ridB],
time: `K+${(t >= 0 ? '+' : '-')}${String(Math.floor(Math.abs(t) / 60)).padStart(2, '0')}:${String(Math.floor(Math.abs(t) % 60)).padStart(2, '0')}`,
time: `${formatKLabel(t)}`,
position: `经度 ${(posA.lng + posB.lng) / 2}°, 纬度 ${(posA.lat + posB.lat) / 2}°`,
suggestion: `两机在该时刻距离约 ${(d / 1000).toFixed(1)} km,小于最小间隔 ${minSep / 1000} km。建议调整航线或时间窗,或增大最小间隔配置。`,
severity: 'high',
@ -164,10 +197,34 @@ self.onmessage = (evt) => {
}
const restrictedConflicts = detectRestrictedZoneIntrusion(filteredRouteIds, minMinutes, maxMinutes, getPos, restrictedZones, config)
// 频谱冲突
const spectrumConflicts = (spectrumLedger && spectrumLedger.length >= 2) ? detectSpectrumConflicts(spectrumLedger, config) : []
const getSpeedKmhAtMinutes = (rid, mins) => getSpeedKmhFromTimeline(routeTimelines, rid, mins)
let safetySpatial = []
let safetyTemporal = []
if (filteredRouteIds.length >= 2) {
safetySpatial = detectSafetyColumnSpatial(
filteredRouteIds,
minMinutes,
maxMinutes,
getPos,
config,
routeNamesById,
canPairSafety,
getSpeedKmhAtMinutes
)
}
if (filteredRouteIds.length >= 2) {
safetyTemporal = detectSafetyColumnTemporal(
filteredRouteIds,
minMinutes,
maxMinutes,
getPos,
config,
routeNamesById
)
}
const allRaw = [...trackConflicts, ...placementConflicts, ...restrictedConflicts, ...spectrumConflicts]
const allRaw = [...trackConflicts, ...placementConflicts, ...restrictedConflicts, ...safetySpatial, ...safetyTemporal]
self.postMessage({ requestId, ok: true, conflicts: allRaw })
} catch (e) {
self.postMessage({ requestId, ok: false, error: (e && e.message) ? e.message : String(e) })

Loading…
Cancel
Save