Browse Source

冲突1.0

mh
menghao 3 weeks ago
parent
commit
c5b3c8b8d9
  1. 431
      ruoyi-ui/src/utils/conflictDetection.js
  2. 445
      ruoyi-ui/src/views/childRoom/ConflictDrawer.vue

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

@ -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
}
/**
* 合并并规范化冲突列表统一 idseveritycategory 等供 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
}

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

@ -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…
Cancel
Save