Browse Source
# Conflicts: # ruoyi-admin/src/main/java/com/ruoyi/web/controller/RoomPlatformIconController.java # ruoyi-ui/src/lang/en.js # ruoyi-ui/src/lang/zh.jsctw
18 changed files with 1024 additions and 118 deletions
@ -0,0 +1,402 @@ |
|||||
|
<template> |
||||
|
<el-dialog |
||||
|
:title="$t('generateAirspace.title')" |
||||
|
:visible.sync="visible" |
||||
|
width="540px" |
||||
|
append-to-body |
||||
|
:close-on-click-modal="false" |
||||
|
@closed="onClosed" |
||||
|
> |
||||
|
<el-form ref="formRef" :model="form" label-width="150px" size="small"> |
||||
|
<el-form-item :label="$t('generateAirspace.shapeType')"> |
||||
|
<el-select v-model="form.shapeType" style="width: 100%;"> |
||||
|
<el-option :label="$t('generateAirspace.polygon')" value="polygon" /> |
||||
|
<el-option :label="$t('generateAirspace.rectangle')" value="rectangle" /> |
||||
|
<el-option :label="$t('generateAirspace.circle')" value="circle" /> |
||||
|
<el-option :label="$t('generateAirspace.sector')" value="sector" /> |
||||
|
</el-select> |
||||
|
</el-form-item> |
||||
|
<el-form-item :label="$t('generateAirspace.name')"> |
||||
|
<el-input v-model="form.name" :placeholder="$t('generateAirspace.namePlaceholder')" clearable /> |
||||
|
</el-form-item> |
||||
|
<el-form-item :label="$t('generateAirspace.color')"> |
||||
|
<el-color-picker v-model="form.color" show-alpha /> |
||||
|
</el-form-item> |
||||
|
<el-form-item :label="$t('generateAirspace.borderWidth')"> |
||||
|
<el-input-number v-model="form.width" :min="1" :max="20" :step="1" controls-position="right" style="width: 100%;" /> |
||||
|
</el-form-item> |
||||
|
|
||||
|
<template v-if="form.shapeType === 'polygon'"> |
||||
|
<el-form-item :label="$t('generateAirspace.vertices')" required> |
||||
|
<el-input |
||||
|
v-model="form.polygonText" |
||||
|
type="textarea" |
||||
|
:rows="6" |
||||
|
:placeholder="$t('generateAirspace.polygonPlaceholder')" |
||||
|
/> |
||||
|
</el-form-item> |
||||
|
</template> |
||||
|
|
||||
|
<template v-else-if="form.shapeType === 'rectangle'"> |
||||
|
<el-form-item :label="$t('generateAirspace.rectangleSwCorner')" required> |
||||
|
<el-input v-model="form.swCorner" :placeholder="$t('generateAirspace.cornerLonLatPlaceholder')" clearable /> |
||||
|
</el-form-item> |
||||
|
<el-form-item :label="$t('generateAirspace.rectangleNeCorner')" required> |
||||
|
<el-input v-model="form.neCorner" :placeholder="$t('generateAirspace.cornerLonLatPlaceholder')" clearable /> |
||||
|
</el-form-item> |
||||
|
</template> |
||||
|
|
||||
|
<template v-else-if="form.shapeType === 'circle'"> |
||||
|
<el-form-item :label="$t('generateAirspace.centerLonLat')" required> |
||||
|
<el-input v-model="form.centerCorner" :placeholder="$t('generateAirspace.cornerLonLatPlaceholder')" clearable /> |
||||
|
</el-form-item> |
||||
|
<el-form-item :label="$t('generateAirspace.radiusM')" required> |
||||
|
<div class="input-with-unit"> |
||||
|
<el-input-number |
||||
|
v-model="form.radius" |
||||
|
:min="0.1" |
||||
|
:max="2000" |
||||
|
:step="1" |
||||
|
:precision="2" |
||||
|
controls-position="right" |
||||
|
class="input-with-unit__num" |
||||
|
/> |
||||
|
<span class="input-with-unit__suffix">{{ $t('generateAirspace.radiusUnit') }}</span> |
||||
|
</div> |
||||
|
</el-form-item> |
||||
|
</template> |
||||
|
|
||||
|
<template v-else-if="form.shapeType === 'sector'"> |
||||
|
<el-form-item :label="$t('generateAirspace.centerLonLat')" required> |
||||
|
<el-input v-model="form.centerCorner" :placeholder="$t('generateAirspace.cornerLonLatPlaceholder')" clearable /> |
||||
|
</el-form-item> |
||||
|
<el-form-item :label="$t('generateAirspace.radiusM')" required> |
||||
|
<div class="input-with-unit"> |
||||
|
<el-input-number |
||||
|
v-model="form.radius" |
||||
|
:min="0.1" |
||||
|
:max="2000" |
||||
|
:step="1" |
||||
|
:precision="2" |
||||
|
controls-position="right" |
||||
|
class="input-with-unit__num" |
||||
|
/> |
||||
|
<span class="input-with-unit__suffix">{{ $t('generateAirspace.radiusUnit') }}</span> |
||||
|
</div> |
||||
|
</el-form-item> |
||||
|
<el-form-item :label="$t('generateAirspace.startBearing')" required> |
||||
|
<el-input-number v-model="form.startBearing" :min="0" :max="360" :precision="2" :step="1" controls-position="right" style="width: 100%;" /> |
||||
|
</el-form-item> |
||||
|
<el-form-item :label="$t('generateAirspace.endBearing')" required> |
||||
|
<el-input-number v-model="form.endBearing" :min="0" :max="360" :precision="2" :step="1" controls-position="right" style="width: 100%;" /> |
||||
|
</el-form-item> |
||||
|
</template> |
||||
|
</el-form> |
||||
|
<span slot="footer" class="dialog-footer"> |
||||
|
<el-button @click="visible = false">{{ $t('generateAirspace.cancel') }}</el-button> |
||||
|
<el-button type="primary" @click="submit">{{ $t('generateAirspace.confirm') }}</el-button> |
||||
|
</span> |
||||
|
</el-dialog> |
||||
|
</template> |
||||
|
|
||||
|
<script> |
||||
|
function parseNum(v) { |
||||
|
if (v === null || v === undefined || v === '') return NaN |
||||
|
const n = typeof v === 'number' ? v : parseFloat(String(v).trim().replace(/,/g, '')) |
||||
|
return n |
||||
|
} |
||||
|
|
||||
|
/** 从颜色选择器返回值解析填充不透明度(与 Cesium importEntity 的 data.opacity 一致) */ |
||||
|
function extractOpacityFromColor(colorStr) { |
||||
|
if (colorStr == null || colorStr === '') return 0 |
||||
|
const s = String(colorStr).trim() |
||||
|
const rgba = s.match( |
||||
|
/^rgba\s*\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)/i |
||||
|
) |
||||
|
if (rgba) return Math.max(0, Math.min(1, parseFloat(rgba[1]))) |
||||
|
if (/^rgb\s*\(/i.test(s) && !/^rgba/i.test(s)) return 1 |
||||
|
const hsla = s.match( |
||||
|
/^hsla\s*\(\s*[\d.]+\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*,\s*([\d.]+)\s*\)/i |
||||
|
) |
||||
|
if (hsla) return Math.max(0, Math.min(1, parseFloat(hsla[1]))) |
||||
|
if (s[0] === '#' && s.length === 9) { |
||||
|
const a = parseInt(s.slice(7, 9), 16) / 255 |
||||
|
return Number.isFinite(a) ? Math.max(0, Math.min(1, a)) : 0 |
||||
|
} |
||||
|
if (s[0] === '#') return 0 |
||||
|
return 0 |
||||
|
} |
||||
|
|
||||
|
/** 解析单个角点:(经度,纬度) 或(经度,纬度),也支持无括号的 经度,纬度 */ |
||||
|
function parseLonLatPair(text) { |
||||
|
const raw = String(text == null ? '' : text).trim() |
||||
|
if (!raw) return null |
||||
|
const parenPair = |
||||
|
/[((]\s*([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*[,,]\s*([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*[))]/ |
||||
|
const m = raw.match(parenPair) |
||||
|
if (m) { |
||||
|
const lng = parseFloat(m[1]) |
||||
|
const lat = parseFloat(m[2]) |
||||
|
if (Number.isFinite(lng) && Number.isFinite(lat)) return { lng, lat } |
||||
|
} |
||||
|
const cleaned = raw.replace(/^[((]\s*|\s*[))]$/g, '').trim() |
||||
|
const parts = cleaned.split(/[,,\s]+/).map(s => s.trim()).filter(Boolean) |
||||
|
if (parts.length >= 2) { |
||||
|
const lng = parseNum(parts[0]) |
||||
|
const lat = parseNum(parts[1]) |
||||
|
if (Number.isFinite(lng) && Number.isFinite(lat)) return { lng, lat } |
||||
|
} |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
export default { |
||||
|
name: 'GenerateAirspaceDialog', |
||||
|
props: { |
||||
|
value: { |
||||
|
type: Boolean, |
||||
|
default: false |
||||
|
} |
||||
|
}, |
||||
|
data() { |
||||
|
return { |
||||
|
form: { |
||||
|
shapeType: 'polygon', |
||||
|
name: '', |
||||
|
color: 'rgba(0, 138, 255, 0)', |
||||
|
width: 2, |
||||
|
polygonText: '', |
||||
|
swCorner: '', |
||||
|
neCorner: '', |
||||
|
centerCorner: '', |
||||
|
radius: 50, |
||||
|
startBearing: 0, |
||||
|
endBearing: 90 |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
computed: { |
||||
|
visible: { |
||||
|
get() { |
||||
|
return this.value |
||||
|
}, |
||||
|
set(v) { |
||||
|
this.$emit('input', v) |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
methods: { |
||||
|
onClosed() { |
||||
|
this.$emit('closed') |
||||
|
}, |
||||
|
resetForm() { |
||||
|
this.form = { |
||||
|
shapeType: 'polygon', |
||||
|
name: '', |
||||
|
color: 'rgba(0, 138, 255, 0)', |
||||
|
width: 2, |
||||
|
polygonText: '', |
||||
|
swCorner: '', |
||||
|
neCorner: '', |
||||
|
centerCorner: '', |
||||
|
radius: 50, |
||||
|
startBearing: 0, |
||||
|
endBearing: 90 |
||||
|
} |
||||
|
if (this.$refs.formRef) this.$refs.formRef.clearValidate() |
||||
|
}, |
||||
|
/** |
||||
|
* 支持多种写法: |
||||
|
* - 每行一对:经度,纬度(可中英文逗号、空格) |
||||
|
* - 一行多对:用顿号/分号分隔,如 121.47,31.23、120.15,30.28 |
||||
|
* - 带括号:(121.47, 31.23)、(120.15, 30.28)(全角/半角括号与逗号均可) |
||||
|
*/ |
||||
|
parsePolygonPoints(text) { |
||||
|
const raw = String(text || '').trim() |
||||
|
if (!raw) return [] |
||||
|
const points = [] |
||||
|
const parenPair = |
||||
|
/[((]\s*([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*[,,]\s*([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*[))]/g |
||||
|
let m |
||||
|
while ((m = parenPair.exec(raw)) !== null) { |
||||
|
const lng = parseFloat(m[1]) |
||||
|
const lat = parseFloat(m[2]) |
||||
|
if (Number.isFinite(lng) && Number.isFinite(lat)) points.push({ lng, lat }) |
||||
|
} |
||||
|
if (points.length > 0) return points |
||||
|
|
||||
|
const lines = raw.split(/\r?\n/).map(l => l.trim()).filter(Boolean) |
||||
|
for (const line of lines) { |
||||
|
const segments = line.split(/[、;;]+/).map(s => s.trim()).filter(Boolean) |
||||
|
const segs = segments.length > 0 ? segments : [line] |
||||
|
for (const seg of segs) { |
||||
|
const cleaned = seg.replace(/^[((]\s*|\s*[))]$/g, '').trim() |
||||
|
const parts = cleaned.split(/[,,\s]+/).map(s => s.trim()).filter(Boolean) |
||||
|
if (parts.length < 2) continue |
||||
|
const lng = parseNum(parts[0]) |
||||
|
const lat = parseNum(parts[1]) |
||||
|
if (Number.isFinite(lng) && Number.isFinite(lat)) points.push({ lng, lat }) |
||||
|
} |
||||
|
} |
||||
|
return points |
||||
|
}, |
||||
|
/** 方位角:正北为 0°、顺时针增大,转为 importEntity 扇形所用的数学角(与地图绘制一致) */ |
||||
|
bearingDegToSectorAngle(bearingDeg) { |
||||
|
const b = (bearingDeg % 360 + 360) % 360 |
||||
|
return Math.PI / 2 - (b * Math.PI) / 180 |
||||
|
}, |
||||
|
buildPayload() { |
||||
|
const color = this.form.color || 'rgba(0, 138, 255, 0)' |
||||
|
const opacity = extractOpacityFromColor(color) |
||||
|
const width = this.form.width != null ? this.form.width : 2 |
||||
|
const borderColor = color |
||||
|
const name = (this.form.name || '').trim() |
||||
|
const labelBase = { |
||||
|
polygon: this.$t('generateAirspace.polygon'), |
||||
|
rectangle: this.$t('generateAirspace.rectangle'), |
||||
|
circle: this.$t('generateAirspace.circle'), |
||||
|
sector: this.$t('generateAirspace.sector') |
||||
|
} |
||||
|
const label = name || labelBase[this.form.shapeType] || this.$t('generateAirspace.defaultLabel') |
||||
|
|
||||
|
switch (this.form.shapeType) { |
||||
|
case 'polygon': { |
||||
|
const points = this.parsePolygonPoints(this.form.polygonText) |
||||
|
if (points.length < 3) { |
||||
|
this.$message.error(this.$t('generateAirspace.errPolygonPoints')) |
||||
|
return null |
||||
|
} |
||||
|
return { |
||||
|
type: 'polygon', |
||||
|
label, |
||||
|
color, |
||||
|
data: { |
||||
|
points, |
||||
|
opacity, |
||||
|
width, |
||||
|
borderColor, |
||||
|
name |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
case 'rectangle': { |
||||
|
const sw = parseLonLatPair(this.form.swCorner) |
||||
|
const ne = parseLonLatPair(this.form.neCorner) |
||||
|
if (!sw || !ne) { |
||||
|
this.$message.error(this.$t('generateAirspace.errRectNumbers')) |
||||
|
return null |
||||
|
} |
||||
|
const w = Math.min(sw.lng, ne.lng) |
||||
|
const e = Math.max(sw.lng, ne.lng) |
||||
|
const s = Math.min(sw.lat, ne.lat) |
||||
|
const n = Math.max(sw.lat, ne.lat) |
||||
|
return { |
||||
|
type: 'rectangle', |
||||
|
label, |
||||
|
color, |
||||
|
data: { |
||||
|
coordinates: { west: w, south: s, east: e, north: n }, |
||||
|
points: [ |
||||
|
{ lng: w, lat: s }, |
||||
|
{ lng: e, lat: n } |
||||
|
], |
||||
|
opacity, |
||||
|
width, |
||||
|
borderColor, |
||||
|
name |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
case 'circle': { |
||||
|
const c = parseLonLatPair(this.form.centerCorner) |
||||
|
const radiusKm = Number(this.form.radius) |
||||
|
const radius = radiusKm * 1000 |
||||
|
if (!c || !Number.isFinite(radiusKm) || radiusKm <= 0) { |
||||
|
this.$message.error(this.$t('generateAirspace.errCircle')) |
||||
|
return null |
||||
|
} |
||||
|
const lng = c.lng |
||||
|
const lat = c.lat |
||||
|
return { |
||||
|
type: 'circle', |
||||
|
label, |
||||
|
color, |
||||
|
data: { |
||||
|
center: { lng, lat }, |
||||
|
radius, |
||||
|
opacity, |
||||
|
width, |
||||
|
borderColor, |
||||
|
name |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
case 'sector': { |
||||
|
const c = parseLonLatPair(this.form.centerCorner) |
||||
|
const radiusKm = Number(this.form.radius) |
||||
|
const radius = radiusKm * 1000 |
||||
|
const sb = Number(this.form.startBearing) |
||||
|
const eb = Number(this.form.endBearing) |
||||
|
if (!c || !Number.isFinite(radiusKm) || radiusKm <= 0) { |
||||
|
this.$message.error(this.$t('generateAirspace.errSector')) |
||||
|
return null |
||||
|
} |
||||
|
const lng = c.lng |
||||
|
const lat = c.lat |
||||
|
if (!Number.isFinite(sb) || !Number.isFinite(eb)) { |
||||
|
this.$message.error(this.$t('generateAirspace.errBearing')) |
||||
|
return null |
||||
|
} |
||||
|
const startAngle = this.bearingDegToSectorAngle(sb) |
||||
|
const endAngle = this.bearingDegToSectorAngle(eb) |
||||
|
return { |
||||
|
type: 'sector', |
||||
|
label, |
||||
|
color, |
||||
|
data: { |
||||
|
center: { lng, lat }, |
||||
|
radius, |
||||
|
startAngle, |
||||
|
endAngle, |
||||
|
opacity, |
||||
|
width, |
||||
|
borderColor, |
||||
|
name |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
default: |
||||
|
return null |
||||
|
} |
||||
|
}, |
||||
|
submit() { |
||||
|
const payload = this.buildPayload() |
||||
|
if (!payload) return |
||||
|
this.$emit('confirm', payload) |
||||
|
this.visible = false |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<style scoped> |
||||
|
.input-with-unit { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
width: 100%; |
||||
|
} |
||||
|
.input-with-unit__num { |
||||
|
flex: 1; |
||||
|
min-width: 0; |
||||
|
} |
||||
|
.input-with-unit__suffix { |
||||
|
flex-shrink: 0; |
||||
|
font-size: 13px; |
||||
|
color: #606266; |
||||
|
} |
||||
|
.dialog-footer { |
||||
|
display: flex; |
||||
|
justify-content: flex-end; |
||||
|
gap: 8px; |
||||
|
} |
||||
|
</style> |
||||
Loading…
Reference in new issue