2 changed files with 876 additions and 0 deletions
@ -0,0 +1,431 @@ |
|||
/** |
|||
* 冲突检测工具:时间冲突、空间冲突、频谱冲突 |
|||
* - 时间:航线时间窗重叠、无法按时到达、提前到达、盘旋时间不足、资源占用缓冲 |
|||
* - 空间:航迹最小间隔、摆放平台距离过小、禁限区入侵(考虑高度带) |
|||
* - 频谱:同频/邻频 + 时间 + 带宽 + 地理范围叠加判定,支持频谱资源台账 |
|||
*/ |
|||
|
|||
/** 冲突类型 */ |
|||
export const CONFLICT_TYPE = { |
|||
TIME: 'time', |
|||
SPACE: 'space', |
|||
SPECTRUM: 'spectrum' |
|||
} |
|||
|
|||
/** 默认冲突配置(可由界面配置覆盖) */ |
|||
export const defaultConflictConfig = { |
|||
// 时间
|
|||
timeWindowOverlapMinutes: 0, // 时间窗重叠判定:两航线时间窗重叠即报(0=任意重叠)
|
|||
resourceBufferMinutes: 0, // 资源占用前后缓冲(分钟)
|
|||
// 空间
|
|||
minTrackSeparationMeters: 5000, // 推演中两平台航迹最小间隔(米)
|
|||
minPlatformPlacementMeters: 3000, // 摆放平台图标最小间距(米)
|
|||
trackSampleStepMinutes: 1, // 航迹采样步长(分钟)
|
|||
restrictedZoneNameKeywords: ['禁限', '禁区', '限制区'], // 空域名称含这些词视为禁限区
|
|||
useRestrictedAltitudeBand: true, // 禁限区是否考虑高度带(若空域有 altMin/altMax)
|
|||
// 频谱
|
|||
adjacentFreqGuardMHz: 5, // 邻频保护间隔(MHz),两频段中心差小于此视为邻频冲突
|
|||
spectrumConflictGeoOverlap: true // 频谱冲突是否要求地理范围叠加
|
|||
} |
|||
|
|||
/** |
|||
* 计算两点地表距离(米),近似球面 |
|||
*/ |
|||
export function distanceMeters(lng1, lat1, alt1, lng2, lat2, alt2) { |
|||
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)) |
|||
const horizontal = R * c |
|||
const dAlt = (alt2 != null ? Number(alt2) : 0) - (alt1 != null ? Number(alt1) : 0) |
|||
return Math.sqrt(horizontal * horizontal + dAlt * dAlt) |
|||
} |
|||
|
|||
/** |
|||
* 两段时间段是否重叠 [s1,e1] 与 [s2,e2](分钟数) |
|||
*/ |
|||
export function timeRangesOverlap(s1, e1, s2, e2, bufferMinutes = 0) { |
|||
const a1 = s1 - bufferMinutes |
|||
const b1 = e1 + bufferMinutes |
|||
const a2 = s2 - bufferMinutes |
|||
const b2 = e2 + bufferMinutes |
|||
return !(b1 < a2 || b2 < a1) |
|||
} |
|||
|
|||
/** |
|||
* 时间冲突:航线时间窗重叠 |
|||
* routes: [{ id, name, waypoints: [{ startTime }] }],waypoints 需含 startTime(如 K+00:10) |
|||
* waypointStartTimeToMinutes: (startTimeStr) => number |
|||
*/ |
|||
export function detectTimeWindowOverlap(routes, waypointStartTimeToMinutes, config = {}) { |
|||
const buffer = config.resourceBufferMinutes != null ? config.resourceBufferMinutes : defaultConflictConfig.resourceBufferMinutes |
|||
const list = [] |
|||
const timeRanges = routes.map(r => { |
|||
if (!r.waypoints || r.waypoints.length === 0) return { routeId: r.id, routeName: r.name || `航线${r.id}`, min: 0, max: 0 } |
|||
const minutes = r.waypoints.map(w => waypointStartTimeToMinutes(w.startTime)).filter(m => Number.isFinite(m)) |
|||
const min = minutes.length ? Math.min(...minutes) : 0 |
|||
const max = minutes.length ? Math.max(...minutes) : 0 |
|||
return { routeId: r.id, routeName: r.name || `航线${r.id}`, min, max } |
|||
}) |
|||
for (let i = 0; i < timeRanges.length; i++) { |
|||
for (let j = i + 1; j < timeRanges.length; j++) { |
|||
const a = timeRanges[i] |
|||
const b = timeRanges[j] |
|||
if (a.min === a.max && b.min === b.max) continue |
|||
if (timeRangesOverlap(a.min, a.max, b.min, b.max, buffer)) { |
|||
list.push({ |
|||
type: CONFLICT_TYPE.TIME, |
|||
subType: 'time_window_overlap', |
|||
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)} 重叠`, |
|||
suggestion: '建议错开两条航线的计划时间窗,或为资源设置缓冲时间后再检测。' |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
return list |
|||
} |
|||
|
|||
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')}` |
|||
} |
|||
|
|||
/** |
|||
* 空间冲突:推演航迹最小间隔 |
|||
* getPositionAtMinutesFromK: (routeId, minutesFromK) => { position: { lng, lat, alt } } | null |
|||
* routeIds: 参与推演的航线 id 列表 |
|||
* minMinutes, maxMinutes: 推演时间范围(相对 K 分钟) |
|||
*/ |
|||
export function detectTrackSeparation(routeIds, minMinutes, maxMinutes, getPositionAtMinutesFromK, config = {}) { |
|||
const minSep = config.minTrackSeparationMeters != null ? config.minTrackSeparationMeters : defaultConflictConfig.minTrackSeparationMeters |
|||
const step = config.trackSampleStepMinutes != null ? config.trackSampleStepMinutes : defaultConflictConfig.trackSampleStepMinutes |
|||
const list = [] |
|||
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] |
|||
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 d = distanceMeters(posA.lng, posA.lat, posA.alt, posB.lng, posB.lat, posB.alt) |
|||
if (d < minSep) { |
|||
list.push({ |
|||
type: CONFLICT_TYPE.SPACE, |
|||
subType: 'track_separation', |
|||
title: '航迹间隔过小', |
|||
routeIds: [ridA, ridB], |
|||
time: `约 K+${formatMinutes(t)}`, |
|||
position: `经度 ${(posA.lng + posB.lng) / 2}°, 纬度 ${(posA.lat + posB.lat) / 2}°`, |
|||
suggestion: `两机在该时刻距离约 ${(d / 1000).toFixed(1)} km,小于最小间隔 ${minSep / 1000} km。建议调整航线或时间窗,或增大最小间隔配置。`, |
|||
severity: 'high', |
|||
positionLng: (posA.lng + posB.lng) / 2, |
|||
positionLat: (posA.lat + posB.lat) / 2, |
|||
positionAlt: (posA.alt + posB.alt) / 2, |
|||
minutesFromK: t, |
|||
distanceMeters: d |
|||
}) |
|||
break // 同一对航线只报一次(取首次发生时刻)
|
|||
} |
|||
} |
|||
} |
|||
} |
|||
return list |
|||
} |
|||
|
|||
/** |
|||
* 空间冲突:摆放平台图标距离过小 |
|||
* platformIcons: [{ id, lng, lat, name? }] |
|||
*/ |
|||
export function detectPlatformPlacementTooClose(platformIcons, config = {}) { |
|||
const minDist = config.minPlatformPlacementMeters != null ? config.minPlatformPlacementMeters : defaultConflictConfig.minPlatformPlacementMeters |
|||
const list = [] |
|||
for (let i = 0; i < platformIcons.length; i++) { |
|||
for (let j = i + 1; j < platformIcons.length; j++) { |
|||
const a = platformIcons[i] |
|||
const b = platformIcons[j] |
|||
const lng1 = Number(a.lng) |
|||
const lat1 = Number(a.lat) |
|||
const lng2 = Number(b.lng) |
|||
const lat2 = Number(b.lat) |
|||
if (!Number.isFinite(lng1) || !Number.isFinite(lat1) || !Number.isFinite(lng2) || !Number.isFinite(lat2)) continue |
|||
const d = distanceMeters(lng1, lat1, 0, lng2, lat2, 0) |
|||
if (d < minDist) { |
|||
list.push({ |
|||
type: CONFLICT_TYPE.SPACE, |
|||
subType: 'platform_placement', |
|||
title: '平台摆放距离过小', |
|||
routeIds: [], |
|||
routeNames: [a.name || `平台${a.id}`, b.name || `平台${b.id}`], |
|||
position: `经度 ${((lng1 + lng2) / 2).toFixed(5)}°, 纬度 ${((lat1 + lat2) / 2).toFixed(5)}°`, |
|||
suggestion: `两平台距离约 ${(d / 1000).toFixed(1)} km,小于最小摆放间隔 ${minDist / 1000} km。建议移动其中一方或增大最小间隔配置。`, |
|||
severity: 'medium', |
|||
positionLng: (lng1 + lng2) / 2, |
|||
positionLat: (lat1 + lat2) / 2, |
|||
positionAlt: 0, |
|||
distanceMeters: d |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
return list |
|||
} |
|||
|
|||
/** |
|||
* 点是否在多边形内(二维,不考虑高度) |
|||
* points: [{ lng, lat }] 或 [{ x, y }],顺时针或逆时针 |
|||
*/ |
|||
export function pointInPolygon(lng, lat, points) { |
|||
if (!points || points.length < 3) return false |
|||
const x = Number(lng) |
|||
const y = Number(lat) |
|||
let inside = false |
|||
const n = points.length |
|||
for (let i = 0, j = n - 1; i < n; j = i++) { |
|||
const pi = points[i] |
|||
const pj = points[j] |
|||
const xi = pi.lng != null ? Number(pi.lng) : Number(pi.x) |
|||
const yi = pi.lat != null ? Number(pi.lat) : Number(pi.y) |
|||
const xj = pj.lng != null ? Number(pj.lng) : Number(pj.x) |
|||
const yj = pj.lat != null ? Number(pj.lat) : Number(pj.y) |
|||
if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside |
|||
} |
|||
return inside |
|||
} |
|||
|
|||
/** |
|||
* 空间冲突:禁限区入侵(考虑高度带) |
|||
* 每条航线在推演时间内采样位置,若落入禁限区多边形且高度在 [altMin, altMax] 内则报 |
|||
* getPositionAtMinutesFromK: (routeId, minutesFromK) => { position: { lng, lat, alt } } |
|||
* restrictedZones: [{ name, points: [{lng,lat}], altMin?, altMax? }],points 可从 frontend_drawings 中 type 为 polygon 且 name 含禁限关键词的实体解析 |
|||
*/ |
|||
export function detectRestrictedZoneIntrusion(routeIds, minMinutes, maxMinutes, getPositionAtMinutesFromK, restrictedZones, config = {}) { |
|||
const step = config.trackSampleStepMinutes != null ? config.trackSampleStepMinutes : defaultConflictConfig.trackSampleStepMinutes |
|||
const useAlt = config.useRestrictedAltitudeBand !== false |
|||
const list = [] |
|||
if (!restrictedZones || restrictedZones.length === 0) return list |
|||
for (const rid of routeIds) { |
|||
for (let t = minMinutes; t <= maxMinutes; t += step) { |
|||
const pos = getPositionAtMinutesFromK(rid, t) |
|||
if (!pos || pos.lng == null) continue |
|||
const alt = pos.alt != null ? Number(pos.alt) : 0 |
|||
for (const zone of restrictedZones) { |
|||
const points = zone.points || zone.positions |
|||
if (!points || points.length < 3) continue |
|||
const inPoly = pointInPolygon(pos.lng, pos.lat, points) |
|||
if (!inPoly) continue |
|||
const altMin = zone.altMin != null ? Number(zone.altMin) : -Infinity |
|||
const altMax = zone.altMax != null ? Number(zone.altMax) : Infinity |
|||
const inAlt = !useAlt || (alt >= altMin && alt <= altMax) |
|||
if (inAlt) { |
|||
list.push({ |
|||
type: CONFLICT_TYPE.SPACE, |
|||
subType: 'restricted_zone', |
|||
title: '禁限区入侵', |
|||
routeIds: [rid], |
|||
zoneName: zone.name || '禁限区', |
|||
time: `约 K+${formatMinutes(t)}`, |
|||
position: `经度 ${pos.lng.toFixed(5)}°, 纬度 ${pos.lat.toFixed(5)}°, 高度 ${alt} m`, |
|||
suggestion: `航迹在 ${zone.name || '禁限区'} 内且高度在 [${altMin}, ${altMax}] m 范围内。建议调整航线避开该区域或调整禁限区高度带。`, |
|||
severity: 'high', |
|||
positionLng: pos.lng, |
|||
positionLat: pos.lat, |
|||
positionAlt: alt, |
|||
minutesFromK: t |
|||
}) |
|||
break // 该航线该时刻只报一个区
|
|||
} |
|||
} |
|||
} |
|||
} |
|||
return list |
|||
} |
|||
|
|||
/** |
|||
* 从 frontend_drawings 的 entities 中解析禁限区(polygon/rectangle/circle/sector) |
|||
* entity: { type, label/name, points/positions, data: { altMin, altMax } } |
|||
*/ |
|||
export function parseRestrictedZonesFromDrawings(entities, keywords = defaultConflictConfig.restrictedZoneNameKeywords) { |
|||
const zones = [] |
|||
if (!entities || !Array.isArray(entities)) return zones |
|||
const nameMatches = (name) => { |
|||
const n = (name || '').toString() |
|||
return keywords.some(kw => n.includes(kw)) |
|||
} |
|||
for (const e of entities) { |
|||
const name = (e.label || e.name || (e.data && e.data.name) || '').toString() |
|||
if (!nameMatches(name)) continue |
|||
let points = e.points || (e.data && e.data.points) || (e.positions && e.positions.map(p => { |
|||
if (p.lng != null) return { lng: p.lng, lat: p.lat } |
|||
if (p.x != null) return { lng: p.x, lat: p.y } |
|||
return null |
|||
}).filter(Boolean)) |
|||
if (!points && e.data) { |
|||
if (e.type === 'circle' && e.data.center) { |
|||
const c = e.data.center |
|||
const r = (e.data.radius || 0) / 111320 // 约米转度
|
|||
const lng = c.lng != null ? c.lng : c.x |
|||
const lat = c.lat != null ? c.lat : c.y |
|||
const n = 32 |
|||
points = [] |
|||
for (let i = 0; i <= n; i++) { |
|||
const angle = (i / n) * 2 * Math.PI |
|||
points.push({ lng: lng + r * Math.cos(angle), lat: lat + r * Math.sin(angle) }) |
|||
} |
|||
} else if (e.type === 'rectangle' && (e.data.bounds || e.data.coordinates || (e.data.points && e.data.points.length >= 2))) { |
|||
const b = e.data.bounds || e.data.coordinates || {} |
|||
const west = b.west ?? b.minLng |
|||
const south = b.south ?? b.minLat |
|||
const east = b.east ?? b.maxLng |
|||
const north = b.north ?? b.maxLat |
|||
if (e.data.points && e.data.points.length >= 2) { |
|||
points = e.data.points |
|||
} else if (west != null && south != null && east != null && north != null) { |
|||
points = [ |
|||
{ lng: west, lat: south }, |
|||
{ lng: east, lat: south }, |
|||
{ lng: east, lat: north }, |
|||
{ lng: west, lat: north } |
|||
] |
|||
} |
|||
} |
|||
} |
|||
if (!points || points.length < 3) continue |
|||
const altMin = e.data && e.data.altMin != null ? e.data.altMin : undefined |
|||
const altMax = e.data && e.data.altMax != null ? e.data.altMax : undefined |
|||
zones.push({ name, points, altMin, altMax }) |
|||
} |
|||
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 |
|||
} |
|||
|
|||
/** |
|||
* 合并并规范化冲突列表,统一 id、severity、category 等供 UI 展示 |
|||
*/ |
|||
export function normalizeConflictList(conflictsByType, startId = 1) { |
|||
const list = [] |
|||
let id = startId |
|||
for (const c of conflictsByType) { |
|||
list.push({ |
|||
id: id++, |
|||
type: c.type || CONFLICT_TYPE.TIME, |
|||
subType: c.subType || '', |
|||
title: c.title || '冲突', |
|||
routeName: c.routeNames && c.routeNames.length ? c.routeNames.join('、') : c.routeName, |
|||
routeIds: c.routeIds || [], |
|||
fromWaypoint: c.fromWaypoint, |
|||
toWaypoint: c.toWaypoint, |
|||
time: c.time, |
|||
position: c.position, |
|||
suggestion: c.suggestion, |
|||
severity: c.severity || 'high', |
|||
positionLng: c.positionLng, |
|||
positionLat: c.positionLat, |
|||
positionAlt: c.positionAlt, |
|||
minutesFromK: c.minutesFromK, |
|||
holdCenter: c.holdCenter, |
|||
...c |
|||
}) |
|||
} |
|||
return list |
|||
} |
|||
@ -0,0 +1,445 @@ |
|||
<template> |
|||
<!-- 冲突列表弹窗:类似 4T,可拖动、可调整大小,带分页 --> |
|||
<div v-show="visible" class="conflict-drawer-wrap"> |
|||
<div class="conflict-drawer-container" :style="panelStyle"> |
|||
<!-- 标题栏:可拖动 --> |
|||
<div class="conflict-drawer-header" @mousedown="onDragStart"> |
|||
<span class="conflict-drawer-title">冲突列表</span> |
|||
<button class="conflict-drawer-close" @mousedown.stop @click="handleClose">×</button> |
|||
</div> |
|||
|
|||
<!-- 筛选与每页条数 --> |
|||
<div class="conflict-drawer-toolbar"> |
|||
<div class="toolbar-row"> |
|||
<span class="toolbar-label">{{ $t('rightPanel.conflictFilter') }}:</span> |
|||
<el-select v-model="filterType" size="mini" class="filter-select"> |
|||
<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"> |
|||
<span class="toolbar-label">每页条数:</span> |
|||
<el-select v-model="pageSize" size="mini" class="page-size-select"> |
|||
<el-option :label="'5'" :value="5" /> |
|||
<el-option :label="'10'" :value="10" /> |
|||
<el-option :label="'20'" :value="20" /> |
|||
<el-option :label="'50'" :value="50" /> |
|||
</el-select> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 列表区域 --> |
|||
<div class="conflict-drawer-body"> |
|||
<div v-if="paginatedList.length > 0" class="conflict-list"> |
|||
<div |
|||
v-for="conflict in paginatedList" |
|||
:key="conflict.id" |
|||
class="conflict-item" |
|||
> |
|||
<div class="conflict-header"> |
|||
<i class="el-icon-warning conflict-icon"></i> |
|||
<span class="conflict-title">{{ conflict.title }}</span> |
|||
<el-tag size="mini" :type="conflictTypeTagType(conflict.type)" class="conflict-type-tag">{{ conflictTypeLabel(conflict.type) }}</el-tag> |
|||
<el-tag v-if="conflict.severity === 'high'" size="mini" type="danger">{{ $t('rightPanel.serious') }}</el-tag> |
|||
</div> |
|||
<div class="conflict-details"> |
|||
<div class="detail-item"> |
|||
<span class="label">{{ $t('rightPanel.involvedRoutes') }}:</span> |
|||
<span class="value">{{ conflict.routeName || (conflict.routeNames && conflict.routeNames.join('、')) }}</span> |
|||
</div> |
|||
<div v-if="conflict.fromWaypoint && conflict.toWaypoint" class="detail-item"> |
|||
<span class="label">问题航段:</span> |
|||
<span class="value">{{ conflict.fromWaypoint }} → {{ conflict.toWaypoint }}</span> |
|||
</div> |
|||
<div v-if="conflict.time" class="detail-item"> |
|||
<span class="label">{{ $t('rightPanel.conflictTime') }}:</span> |
|||
<span class="value">{{ conflict.time }}</span> |
|||
</div> |
|||
<div v-if="conflict.position" class="detail-item"> |
|||
<span class="label">{{ $t('rightPanel.conflictPosition') }}:</span> |
|||
<span class="value">{{ conflict.position }}</span> |
|||
</div> |
|||
<div v-if="conflict.suggestion" class="detail-item suggestion"> |
|||
<span class="label">建议:</span> |
|||
<span class="value">{{ conflict.suggestion }}</span> |
|||
</div> |
|||
</div> |
|||
<div class="conflict-actions"> |
|||
<el-button type="text" size="mini" class="blue-text-btn" @click="$emit('view-conflict', conflict)"> |
|||
{{ $t('rightPanel.locate') }} |
|||
</el-button> |
|||
<el-button type="text" size="mini" class="blue-text-btn" @click="$emit('resolve-conflict', conflict)"> |
|||
{{ $t('rightPanel.resolveConflict') }} |
|||
</el-button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div v-else class="no-conflict"> |
|||
<i class="el-icon-success no-conflict-icon"></i> |
|||
<p>{{ filteredConflicts.length > 0 ? $t('rightPanel.noMatchFilter') : $t('rightPanel.noConflict') }}</p> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- 分页 --> |
|||
<div v-if="filteredConflicts.length > pageSize" class="conflict-drawer-footer"> |
|||
<el-pagination |
|||
small |
|||
layout="prev, pager, next, total" |
|||
:total="filteredConflicts.length" |
|||
:page-size="pageSize" |
|||
:current-page.sync="currentPage" |
|||
:pager-count="5" |
|||
/> |
|||
</div> |
|||
|
|||
<!-- 右下角调整大小手柄 --> |
|||
<div class="conflict-resize-handle" @mousedown="onResizeStart" title="拖动调整大小"></div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
export default { |
|||
name: 'ConflictDrawer', |
|||
props: { |
|||
visible: { |
|||
type: Boolean, |
|||
default: false |
|||
}, |
|||
conflicts: { |
|||
type: Array, |
|||
default: () => [] |
|||
} |
|||
}, |
|||
data() { |
|||
return { |
|||
filterType: 'all', |
|||
pageSize: 10, |
|||
currentPage: 1, |
|||
isDragging: false, |
|||
dragStartX: 0, |
|||
dragStartY: 0, |
|||
panelLeft: null, |
|||
panelTop: null, |
|||
panelWidth: 420, |
|||
panelHeight: 480, |
|||
isResizing: false, |
|||
resizeStartX: 0, |
|||
resizeStartY: 0, |
|||
resizeStartW: 0, |
|||
resizeStartH: 0 |
|||
} |
|||
}, |
|||
computed: { |
|||
filteredConflicts() { |
|||
const list = this.conflicts || [] |
|||
if (this.filterType === 'all') return list |
|||
return list.filter(c => c.type === this.filterType) |
|||
}, |
|||
paginatedList() { |
|||
const list = this.filteredConflicts |
|||
const start = (this.currentPage - 1) * this.pageSize |
|||
return list.slice(start, start + this.pageSize) |
|||
}, |
|||
panelStyle() { |
|||
const left = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20 |
|||
const top = this.panelTop != null ? this.panelTop : 80 |
|||
return { |
|||
left: `${Math.max(0, left)}px`, |
|||
top: `${Math.max(0, top)}px`, |
|||
width: `${this.panelWidth}px`, |
|||
height: `${this.panelHeight}px` |
|||
} |
|||
} |
|||
}, |
|||
watch: { |
|||
filterType() { |
|||
this.currentPage = 1 |
|||
}, |
|||
pageSize() { |
|||
this.currentPage = 1 |
|||
} |
|||
}, |
|||
methods: { |
|||
handleClose() { |
|||
this.$emit('update:visible', false) |
|||
}, |
|||
conflictTypeLabel(type) { |
|||
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) { |
|||
e.preventDefault() |
|||
this.isDragging = true |
|||
const currentLeft = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20 |
|||
const currentTop = this.panelTop != null ? this.panelTop : 80 |
|||
this.dragStartX = e.clientX - currentLeft |
|||
this.dragStartY = e.clientY - currentTop |
|||
document.addEventListener('mousemove', this.onDragMove) |
|||
document.addEventListener('mouseup', this.onDragEnd) |
|||
}, |
|||
onDragMove(e) { |
|||
if (!this.isDragging) return |
|||
e.preventDefault() |
|||
let left = e.clientX - this.dragStartX |
|||
let top = e.clientY - this.dragStartY |
|||
left = Math.max(0, Math.min(window.innerWidth - this.panelWidth, left)) |
|||
top = Math.max(0, Math.min(window.innerHeight - this.panelHeight, top)) |
|||
this.panelLeft = left |
|||
this.panelTop = top |
|||
}, |
|||
onDragEnd() { |
|||
this.isDragging = false |
|||
document.removeEventListener('mousemove', this.onDragMove) |
|||
document.removeEventListener('mouseup', this.onDragEnd) |
|||
}, |
|||
onResizeStart(e) { |
|||
e.preventDefault() |
|||
e.stopPropagation() |
|||
this.isResizing = true |
|||
this.resizeStartX = e.clientX |
|||
this.resizeStartY = e.clientY |
|||
this.resizeStartW = this.panelWidth |
|||
this.resizeStartH = this.panelHeight |
|||
document.addEventListener('mousemove', this.onResizeMove) |
|||
document.addEventListener('mouseup', this.onResizeEnd) |
|||
}, |
|||
onResizeMove(e) { |
|||
if (!this.isResizing) return |
|||
e.preventDefault() |
|||
const dx = e.clientX - this.resizeStartX |
|||
const dy = e.clientY - this.resizeStartY |
|||
this.panelWidth = Math.max(320, Math.min(800, this.resizeStartW + dx)) |
|||
this.panelHeight = Math.max(300, Math.min(window.innerHeight - 60, this.resizeStartH + dy)) |
|||
}, |
|||
onResizeEnd() { |
|||
this.isResizing = false |
|||
document.removeEventListener('mousemove', this.onResizeMove) |
|||
document.removeEventListener('mouseup', this.onResizeEnd) |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.conflict-drawer-wrap { |
|||
position: fixed; |
|||
inset: 0; |
|||
background: transparent; |
|||
z-index: 200; |
|||
pointer-events: none; |
|||
} |
|||
|
|||
.conflict-drawer-wrap .conflict-drawer-container { |
|||
pointer-events: auto; |
|||
position: fixed; |
|||
background: #fff; |
|||
border-radius: 12px; |
|||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); |
|||
border: 1px solid #e0edff; |
|||
display: flex; |
|||
flex-direction: column; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.conflict-drawer-header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
padding: 12px 16px; |
|||
background: #f0f7ff; |
|||
border-bottom: 1px solid #cce5ff; |
|||
cursor: move; |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.conflict-drawer-title { |
|||
font-weight: 600; |
|||
color: #1994fe; |
|||
font-size: 15px; |
|||
} |
|||
|
|||
.conflict-drawer-close { |
|||
width: 28px; |
|||
height: 28px; |
|||
border: 1px solid #cce5ff; |
|||
background: #fff; |
|||
color: #1994fe; |
|||
border-radius: 50%; |
|||
cursor: pointer; |
|||
font-size: 18px; |
|||
line-height: 1; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
padding: 0; |
|||
} |
|||
|
|||
.conflict-drawer-close:hover { |
|||
background: #e8f4ff; |
|||
} |
|||
|
|||
.conflict-drawer-toolbar { |
|||
padding: 10px 16px; |
|||
border-bottom: 1px solid #eee; |
|||
flex-shrink: 0; |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
gap: 12px; |
|||
align-items: center; |
|||
} |
|||
|
|||
.toolbar-row { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 6px; |
|||
} |
|||
|
|||
.toolbar-label { |
|||
font-size: 12px; |
|||
color: #666; |
|||
} |
|||
|
|||
.filter-select, |
|||
.page-size-select { |
|||
width: 100px; |
|||
} |
|||
|
|||
.conflict-drawer-body { |
|||
flex: 1; |
|||
overflow-y: auto; |
|||
padding: 12px; |
|||
} |
|||
|
|||
.conflict-list { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 12px; |
|||
} |
|||
|
|||
.conflict-item { |
|||
background: #fafafa; |
|||
border-radius: 8px; |
|||
padding: 12px; |
|||
border: 1px solid rgba(245, 108, 108, 0.2); |
|||
} |
|||
|
|||
.conflict-item:hover { |
|||
background: #fff5f5; |
|||
} |
|||
|
|||
.conflict-header { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 6px; |
|||
margin-bottom: 8px; |
|||
} |
|||
|
|||
.conflict-icon { |
|||
color: #f56c6c; |
|||
font-size: 16px; |
|||
} |
|||
|
|||
.conflict-title { |
|||
flex: 1; |
|||
font-weight: 600; |
|||
color: #f56c6c; |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.conflict-type-tag { |
|||
margin-left: 4px; |
|||
} |
|||
|
|||
.conflict-details { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 6px; |
|||
margin-bottom: 8px; |
|||
} |
|||
|
|||
.conflict-details .detail-item { |
|||
font-size: 12px; |
|||
display: flex; |
|||
gap: 6px; |
|||
} |
|||
|
|||
.conflict-details .label { |
|||
color: #999; |
|||
min-width: 70px; |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.conflict-details .value { |
|||
color: #333; |
|||
} |
|||
|
|||
.conflict-details .suggestion .value { |
|||
color: #e6a23c; |
|||
} |
|||
|
|||
.conflict-actions { |
|||
display: flex; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.blue-text-btn { |
|||
color: #1994fe; |
|||
font-size: 12px; |
|||
} |
|||
|
|||
.no-conflict { |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
justify-content: center; |
|||
padding: 40px 20px; |
|||
color: #67c23a; |
|||
} |
|||
|
|||
.no-conflict-icon { |
|||
font-size: 48px; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.no-conflict p { |
|||
margin: 0; |
|||
font-size: 14px; |
|||
color: #666; |
|||
} |
|||
|
|||
.conflict-drawer-footer { |
|||
padding: 8px 16px; |
|||
border-top: 1px solid #eee; |
|||
flex-shrink: 0; |
|||
display: flex; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.conflict-resize-handle { |
|||
position: absolute; |
|||
right: 0; |
|||
bottom: 0; |
|||
width: 20px; |
|||
height: 20px; |
|||
cursor: nwse-resize; |
|||
user-select: none; |
|||
z-index: 10; |
|||
background: linear-gradient(to top left, transparent 50%, rgba(25, 148, 254, 0.2) 50%); |
|||
} |
|||
|
|||
.conflict-resize-handle:hover { |
|||
background: linear-gradient(to top left, transparent 50%, rgba(25, 148, 254, 0.4) 50%); |
|||
} |
|||
</style> |
|||
Loading…
Reference in new issue