You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
3702 lines
144 KiB
3702 lines
144 KiB
<template>
|
|
<!-- 以地图为绝对定位背景,所有组件浮动其上 -->
|
|
<div class="mission-planning-container" :class="{ 'screenshot-mode': screenshotMode }">
|
|
<!-- 地图背景:支持从右侧平台列表拖拽图标到地图 -->
|
|
<div
|
|
id="gis-map-background"
|
|
class="map-background"
|
|
@dragover.prevent="handleMapDragover"
|
|
@drop="handleMapDrop"
|
|
>
|
|
<!-- cesiummap组件 -->
|
|
<cesiumMap ref="cesiumMap" :drawDomClick="drawDom || airspaceDrawDom"
|
|
:tool-mode="drawDom ? 'ranging' : (airspaceDrawDom ? 'airspace' : 'airspace')"
|
|
:scaleConfig="scaleConfig"
|
|
:coordinateFormat="coordinateFormat"
|
|
:bottomPanelVisible="bottomPanelVisible"
|
|
:route-locked="routeLocked"
|
|
@draw-complete="handleMapDrawComplete"
|
|
@route-lock-changed="handleRouteLockChanged"
|
|
@drawing-points-update="missionDrawingPointsCount = $event"
|
|
@platform-route-drawing-started="missionDrawingActive = true"
|
|
@drawing-cancelled="missionDrawingActive = false"
|
|
@open-waypoint-dialog="handleOpenWaypointEdit"
|
|
@open-route-dialog="handleOpenRouteEdit"
|
|
@copy-route="handleCopyRoute"
|
|
@route-copy-placed="handleRouteCopyPlaced"
|
|
@add-waypoint-at="handleAddWaypointAt"
|
|
@add-waypoint-placed="handleAddWaypointPlaced"
|
|
@waypoint-position-changed="handleWaypointPositionChanged"
|
|
@scale-click="handleScaleClick"
|
|
@platform-icon-updated="onPlatformIconUpdated"
|
|
@platform-icon-removed="onPlatformIconRemoved"
|
|
@viewer-ready="onViewerReady"
|
|
@drawing-entities-changed="onDrawingEntitiesChanged" />
|
|
<div v-show="!screenshotMode" class="map-overlay-text">
|
|
<!-- <i class="el-icon-location-outline text-3xl mb-2 block"></i> -->
|
|
<!-- <p>二维GIS地图区域</p>
|
|
<p class="text-sm mt-1">支持标绘/航线/空域/实时态势</p> -->
|
|
</div>
|
|
<div v-if="missionDrawingActive && missionDrawingPointsCount >= 2 && !screenshotMode" class="mission-drawing-actions" style="position:absolute; bottom:16px; left:50%; transform:translateX(-50%); z-index:10; display:flex; gap:8px; align-items:center;">
|
|
<span class="text-white text-sm">已 {{ missionDrawingPointsCount }} 个航点,右键结束</span>
|
|
<el-button type="primary" size="small" @click="openAddHoldDuringDrawing">插入盘旋</el-button>
|
|
</div>
|
|
|
|
<!-- 地图中间的浮动红点(触发左侧菜单) -->
|
|
<div
|
|
v-show="!screenshotMode"
|
|
class="floating-red-dot left-red-dot"
|
|
:class="{ hidden: !isMenuHidden }"
|
|
@click="showMenu"
|
|
title="显示左侧菜单"
|
|
>
|
|
<div class="red-dot"></div>
|
|
<i class="el-icon-s-unfold icon-inside"></i>
|
|
</div>
|
|
<el-dialog title="保存新航线" :visible.sync="showNameDialog" width="30%" :append-to-body="true" @open="onSaveRouteDialogOpen" @close="tempMapPlatform = null; saveDialogScenarioId = null">
|
|
<el-form label-width="80px">
|
|
<el-form-item label="航线名称">
|
|
<el-input v-model="newRouteName" placeholder="例如:航线一"></el-input>
|
|
</el-form-item>
|
|
<el-form-item label="所属方案">
|
|
<el-select v-model="saveDialogScenarioId" placeholder="请选择方案" style="width:100%" clearable>
|
|
<el-option v-for="p in plans" :key="p.id" :label="p.name" :value="p.id" />
|
|
</el-select>
|
|
<div v-if="plans.length === 0" class="el-form-item__error" style="margin-top:4px;">暂无方案,请先点击地图左侧红点展开菜单,选择「方案」并新建方案后再保存。</div>
|
|
</el-form-item>
|
|
</el-form>
|
|
<div slot="footer" class="dialog-footer">
|
|
<el-button @click="showNameDialog = false">取 消</el-button>
|
|
<el-button type="primary" @click="confirmSaveNewRoute">确 定</el-button>
|
|
</div>
|
|
</el-dialog>
|
|
|
|
<!-- 设定/修改 K 时弹窗(房主或管理员可随时打开并修改) -->
|
|
<el-dialog title="设定 / 修改 K 时" :visible.sync="showKTimeSetDialog" width="420px" :append-to-body="true">
|
|
<el-form label-width="90px">
|
|
<el-form-item label="K 时(基准)">
|
|
<el-date-picker
|
|
v-model="kTimeForm.dateTime"
|
|
type="datetime"
|
|
value-format="yyyy-MM-dd HH:mm:ss"
|
|
placeholder="选择日期和时间"
|
|
style="width: 100%"
|
|
/>
|
|
</el-form-item>
|
|
<p class="k-time-tip">航线的任务时间将以此 K 时为基准进行加减;航点表时间为相对 K 的分钟数。房主/管理员可随时再次点击「作战时间」修改 K 时。</p>
|
|
</el-form>
|
|
<div slot="footer" class="dialog-footer">
|
|
<el-button @click="showKTimeSetDialog = false">取 消</el-button>
|
|
<el-button type="primary" @click="saveKTime">确 定</el-button>
|
|
</div>
|
|
</el-dialog>
|
|
</div>
|
|
<!-- 顶部导航栏 -->
|
|
<top-header
|
|
v-show="!screenshotMode"
|
|
:room-code="roomCode"
|
|
:online-count="onlineCount"
|
|
:combat-time="combatTime"
|
|
:k-time-display="kTimeDisplay"
|
|
:astro-time="astroTime"
|
|
:room-detail="roomDetail"
|
|
:can-set-k-time="canSetKTime"
|
|
:user-avatar="userAvatar"
|
|
:is-icon-edit-mode="isIconEditMode"
|
|
:current-scale-config="scaleConfig"
|
|
@select-nav="selectTopNav"
|
|
@set-k-time="openKTimeSetDialog"
|
|
@save-plan="savePlan"
|
|
@import-plan-file="importPlanFile"
|
|
@import-acd="importACD"
|
|
@import-ato="importATO"
|
|
@import-layer="importLayer"
|
|
@import-route="importRoute"
|
|
@export-plan="exportPlan"
|
|
@route-edit="routeEdit"
|
|
@military-marking="militaryMarking"
|
|
@icon-edit="iconEdit"
|
|
@toggle-icon-edit="toggleIconEditMode"
|
|
@attribute-edit="attributeEdit"
|
|
@time-settings="timeSettings"
|
|
@aircraft-settings="aircraftSettings"
|
|
@key-event-edit="keyEventEdit"
|
|
@missile-launch="missileLaunch"
|
|
@toggle-2d-3d="toggle2D3D"
|
|
@toggle-ruler="toggleRuler"
|
|
@toggle-grid="toggleGrid"
|
|
@save-scale="saveScale"
|
|
@load-terrain="loadTerrain"
|
|
@change-projection="changeProjection"
|
|
@load-aero-chart="loadAeroChart"
|
|
@start-power-zone-drawing="startPowerZoneDrawing"
|
|
@threat-zone="threatZone"
|
|
@route-calculation="routeCalculation"
|
|
@conflict-display="conflictDisplay"
|
|
@data-materials="dataMaterials"
|
|
@coordinate-conversion="coordinateConversion"
|
|
@page-layout="pageLayout"
|
|
@data-storage-path="dataStoragePath"
|
|
@save-external-params="saveExternalParams"
|
|
@import-airport="importAirport"
|
|
@import-route-data="importRouteData"
|
|
@import-landmark="importLandmark"
|
|
@toggle-airport="toggleAirport"
|
|
@toggle-landmark="toggleLandmark"
|
|
@toggle-route="toggleRoute"
|
|
@generate-gantt-chart="generateGanttChart"
|
|
@system-description="systemDescription"
|
|
@layer-favorites="layerFavorites"
|
|
@route-favorites="routeFavorites"
|
|
@show-online-members="showOnlineMembersDialog"
|
|
/>
|
|
<!-- 左侧折叠菜单栏 - 蓝色主题 -->
|
|
<left-menu
|
|
v-show="!screenshotMode"
|
|
:is-hidden="isMenuHidden"
|
|
:menu-items="menuItems"
|
|
:active-menu="activeMenu"
|
|
:is-edit-mode="isIconEditMode"
|
|
:available-icons="topNavItems"
|
|
:position="menuPosition"
|
|
@hide="hideMenu"
|
|
@select="selectMenu"
|
|
@menu-action="handleMenuAction"
|
|
@update:menu-items="updateMenuItems"
|
|
@drag-end="handleMenuDragEnd"
|
|
@add-items="handleAddMenuItems"
|
|
@delete="handleDeleteMenuItem"
|
|
@save-menu-items="handleSaveMenuItems"
|
|
@exit-icon-edit="exitIconEdit"
|
|
@reset-menu-items="handleResetMenuItems"
|
|
/>
|
|
<!-- 右侧实体列表(浮动)- 蓝色主题 -->
|
|
<right-panel
|
|
v-show="!screenshotMode"
|
|
ref="rightPanel"
|
|
:is-hidden="isRightPanelHidden"
|
|
:active-tab="activeRightTab"
|
|
:plans="plans"
|
|
:selected-plan-id="selectedPlanId"
|
|
:selected-plan-details="selectedPlanDetails"
|
|
:selected-route-id="selectedRouteId"
|
|
:routes="routes"
|
|
:active-route-ids="activeRouteIds"
|
|
:route-locked="routeLocked"
|
|
:selected-route-details="selectedRouteDetails"
|
|
:conflicts="conflicts"
|
|
:conflict-count="conflictCount"
|
|
:air-platforms="airPlatforms"
|
|
:sea-platforms="seaPlatforms"
|
|
:ground-platforms="groundPlatforms"
|
|
@hide="hideRightPanel"
|
|
@select-route="selectRoute"
|
|
@create-route="createRoute"
|
|
@delete-route="handleDeleteRoute"
|
|
@select-plan="selectPlan"
|
|
@create-plan="createPlan"
|
|
@delete-plan="executeDeletePlan"
|
|
@open-plan-dialog="openPlanDialog"
|
|
@open-route-dialog="openRouteDialog"
|
|
@open-waypoint-dialog="openWaypointDialog"
|
|
@add-waypoint="addWaypoint"
|
|
@cancel-route="cancelRoute"
|
|
@toggle-route-visibility="toggleRouteVisibility"
|
|
@toggle-route-lock="handleToggleRouteLockFromPanel"
|
|
@view-conflict="viewConflict"
|
|
@resolve-conflict="resolveConflict"
|
|
@run-conflict-check="runConflictCheck"
|
|
@open-platform-dialog="openPlatformDialog"
|
|
@delete-platform="handleDeletePlatform"
|
|
@open-import-dialog="showImportDialog = true"
|
|
/>
|
|
<!-- 左下角工具面板 -->
|
|
<bottom-left-panel v-show="!screenshotMode" @bottom-panel-visible="handleBottomPanelVisible" :room-id="currentRoomId" />
|
|
<!-- 底部时间轴(最初版本的样式)- 蓝色主题 -->
|
|
<div
|
|
v-show="!screenshotMode"
|
|
class="floating-timeline blue-theme"
|
|
:class="{ 'show': showKTimePopup }"
|
|
>
|
|
<!-- 隐藏按钮(向下箭头) -->
|
|
<div class="popup-hide-btn" @click="hideKTimePopup" title="隐藏K时">
|
|
<i class="el-icon-arrow-down"></i>
|
|
</div>
|
|
<div class="timeline-controls">
|
|
<div class="current-time blue-time">
|
|
<i class="el-icon-time"></i>
|
|
<span class="time-text">{{ currentTime }}</span>
|
|
</div>
|
|
<div class="timeline-slider">
|
|
<el-slider
|
|
v-model="timeProgress"
|
|
:max="100"
|
|
:format-tooltip="formatTimeTooltip"
|
|
class="compact-slider blue-slider"
|
|
/>
|
|
</div>
|
|
<div class="playback-controls">
|
|
<button
|
|
class="control-btn blue-control-btn"
|
|
@click="togglePlay"
|
|
:title="isPlaying ? '暂停' : '播放'"
|
|
>
|
|
<i :class="isPlaying ? 'el-icon-video-pause' : 'el-icon-video-play'"></i>
|
|
</button>
|
|
<div class="speed-control">
|
|
<button
|
|
class="control-btn blue-control-btn"
|
|
@click="decreaseSpeed"
|
|
:disabled="playbackSpeed <= 1"
|
|
title="减速"
|
|
>
|
|
<i class="el-icon-arrow-down"></i>
|
|
</button>
|
|
<span class="speed-text">{{ playbackSpeed }}x</span>
|
|
<button
|
|
class="control-btn blue-control-btn"
|
|
@click="increaseSpeed"
|
|
:disabled="playbackSpeed >= 25"
|
|
title="加速"
|
|
>
|
|
<i class="el-icon-arrow-up"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<el-dialog :title="addHoldDialogTitle" :visible.sync="showAddHoldDialog" width="420px" append-to-body>
|
|
<div v-if="addHoldContext" class="add-hold-tip">{{ addHoldDialogTip }}</div>
|
|
<el-form :model="addHoldForm" label-width="100px" size="small">
|
|
<el-form-item label="盘旋类型">
|
|
<el-radio-group v-model="addHoldForm.holdType">
|
|
<el-radio label="hold_circle">圆形</el-radio>
|
|
<el-radio label="hold_ellipse">椭圆</el-radio>
|
|
</el-radio-group>
|
|
</el-form-item>
|
|
<el-form-item v-if="addHoldForm.holdType === 'hold_circle'" label="半径(米)">
|
|
<el-input-number v-model="addHoldForm.radius" :min="100" :max="50000" style="width:100%" />
|
|
</el-form-item>
|
|
<template v-if="addHoldForm.holdType === 'hold_ellipse'">
|
|
<el-form-item label="长半轴(米)">
|
|
<el-input-number v-model="addHoldForm.semiMajor" :min="100" :max="50000" style="width:100%" />
|
|
</el-form-item>
|
|
<el-form-item label="短半轴(米)">
|
|
<el-input-number v-model="addHoldForm.semiMinor" :min="50" :max="50000" style="width:100%" />
|
|
</el-form-item>
|
|
<el-form-item label="长轴方位(度)">
|
|
<el-input-number v-model="addHoldForm.headingDeg" :min="-180" :max="180" style="width:100%" />
|
|
</el-form-item>
|
|
</template>
|
|
<el-form-item label="盘旋方向">
|
|
<el-radio-group v-model="addHoldForm.clockwise">
|
|
<el-radio :label="true">顺时针</el-radio>
|
|
<el-radio :label="false">逆时针</el-radio>
|
|
</el-radio-group>
|
|
</el-form-item>
|
|
<el-form-item label="计划离开(K时)">
|
|
<el-input
|
|
v-model.number="addHoldForm.startTimeMinutes"
|
|
type="number"
|
|
placeholder="正数=K+多少分钟,负数=K-多少分钟,留空用下一航点时间"
|
|
/>
|
|
</el-form-item>
|
|
</el-form>
|
|
<span slot="footer" class="dialog-footer">
|
|
<el-button @click="showAddHoldDialog = false">取消</el-button>
|
|
<el-button type="primary" @click="saveAddHold">确定添加</el-button>
|
|
</span>
|
|
</el-dialog>
|
|
</div>
|
|
|
|
<!-- 在线成员弹窗 -->
|
|
<online-members-dialog
|
|
v-model="showOnlineMembers"
|
|
/>
|
|
|
|
<!-- 平台编辑弹窗 -->
|
|
<platform-edit-dialog
|
|
v-model="showPlatformDialog"
|
|
:platform="selectedPlatform"
|
|
@save="updatePlatform"
|
|
/>
|
|
|
|
<!-- 航线编辑弹窗 -->
|
|
<route-edit-dialog
|
|
v-model="showRouteDialog"
|
|
:route="selectedRoute"
|
|
@save="updateRoute"
|
|
/>
|
|
|
|
<!-- 航点编辑弹窗 -->
|
|
<waypoint-edit-dialog
|
|
v-model="showWaypointDialog"
|
|
:waypoint="selectedWaypoint"
|
|
@save="updateWaypoint"
|
|
/>
|
|
|
|
|
|
|
|
<!-- 比例尺弹窗 -->
|
|
<scale-dialog
|
|
v-model="showScaleDialog"
|
|
:scale="currentScale"
|
|
@save="saveScale"
|
|
@unit-change="handleScaleUnitChange"
|
|
/>
|
|
|
|
<!-- 外部参数弹窗 -->
|
|
<external-params-dialog
|
|
v-model="showExternalParamsDialog"
|
|
:external-params="currentExternalParams"
|
|
@save="saveExternalParams"
|
|
@import-airport="importAirport"
|
|
@import-route-data="importRoute"
|
|
@import-landmark="importLandmark"
|
|
/>
|
|
|
|
<!-- 页面布局弹窗 -->
|
|
<page-layout-dialog
|
|
v-model="showPageLayoutDialog"
|
|
@save="savePageLayout"
|
|
/>
|
|
|
|
<!-- 导入平台弹窗 -->
|
|
<PlatformImportDialog
|
|
:visible.sync="showImportDialog"
|
|
@confirm="handleImportConfirm"
|
|
/>
|
|
|
|
<el-dialog
|
|
title="新建方案"
|
|
:visible.sync="showPlanNameDialog"
|
|
width="400px"
|
|
append-to-body
|
|
>
|
|
<el-form :model="newPlanForm" ref="newPlanForm" :rules="planRules" label-width="80px">
|
|
<el-form-item label="方案名称" prop="name">
|
|
<el-input v-model="newPlanForm.name" placeholder="请输入方案名称"></el-input>
|
|
</el-form-item>
|
|
</el-form>
|
|
<div slot="footer" class="dialog-footer">
|
|
<el-button @click="showPlanNameDialog = false">取 消</el-button>
|
|
<el-button type="primary" @click="confirmCreatePlan">确 定</el-button>
|
|
</div>
|
|
</el-dialog>
|
|
|
|
<!-- 截图保存弹窗:选择文件名后保存到本地(浏览器会弹出保存位置对话框) -->
|
|
<el-dialog
|
|
title="保存地图截图"
|
|
:visible.sync="showScreenshotDialog"
|
|
width="520px"
|
|
append-to-body
|
|
class="screenshot-save-dialog"
|
|
>
|
|
<div class="screenshot-preview" v-if="screenshotDataUrl">
|
|
<img :src="screenshotDataUrl" alt="截图预览" />
|
|
</div>
|
|
<el-form label-width="90px">
|
|
<el-form-item label="文件名">
|
|
<el-input v-model="screenshotFileName" placeholder="例如:地图截图.png" />
|
|
</el-form-item>
|
|
<p class="screenshot-tip">点击「保存」后,浏览器将弹出保存对话框,您可选择保存路径。</p>
|
|
</el-form>
|
|
<div slot="footer" class="dialog-footer">
|
|
<el-button @click="showScreenshotDialog = false">取 消</el-button>
|
|
<el-button type="primary" @click="confirmSaveScreenshot">
|
|
<i class="el-icon-download"></i> 保 存
|
|
</el-button>
|
|
</div>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import cesiumMap from '@/views/cesiumMap'
|
|
import OnlineMembersDialog from '@/views/dialogs/OnlineMembersDialog'
|
|
import PlatformEditDialog from '@/views/dialogs/PlatformEditDialog'
|
|
import RouteEditDialog from '@/views/dialogs/RouteEditDialog'
|
|
import WaypointEditDialog from '@/views/dialogs/WaypointEditDialog'
|
|
import ScaleDialog from '@/views/dialogs/ScaleDialog'
|
|
import ExternalParamsDialog from '@/views/dialogs/ExternalParamsDialog'
|
|
import PageLayoutDialog from '@/views/dialogs/PageLayoutDialog'
|
|
import LeftMenu from './LeftMenu'
|
|
import RightPanel from './RightPanel'
|
|
import BottomLeftPanel from './BottomLeftPanel'
|
|
import TopHeader from './TopHeader'
|
|
import { listScenario, addScenario, delScenario } from "@/api/system/scenario";
|
|
import { listRoutes, getRoutes, addRoutes, updateRoutes, delRoutes, getPlatformStyle } from "@/api/system/routes";
|
|
import { updateWaypoints, addWaypoints, delWaypoints } from "@/api/system/waypoints";
|
|
import { listLib,addLib,delLib} from "@/api/system/lib";
|
|
import { getRooms, updateRooms } from "@/api/system/rooms";
|
|
import { getMenuConfig, saveMenuConfig } from "@/api/system/userMenuConfig";
|
|
import { listByRoomId as listRoomPlatformIcons, addRoomPlatformIcon, updateRoomPlatformIcon, delRoomPlatformIcon } from "@/api/system/roomPlatformIcon";
|
|
import PlatformImportDialog from "@/views/dialogs/PlatformImportDialog.vue";
|
|
export default {
|
|
name: 'MissionPlanningView',
|
|
components: {
|
|
PlatformImportDialog,
|
|
cesiumMap,
|
|
OnlineMembersDialog,
|
|
PlatformEditDialog,
|
|
RouteEditDialog,
|
|
WaypointEditDialog,
|
|
ScaleDialog,
|
|
ExternalParamsDialog,
|
|
PageLayoutDialog,
|
|
LeftMenu,
|
|
RightPanel,
|
|
BottomLeftPanel,
|
|
TopHeader
|
|
},
|
|
data() {
|
|
return {
|
|
drawDom:false,
|
|
airspaceDrawDom:false,
|
|
// 在线成员弹窗
|
|
showOnlineMembers: false,
|
|
// 编辑弹窗控制
|
|
showPlatformDialog: false,
|
|
selectedPlatform: null,
|
|
showRouteDialog: false,
|
|
selectedRoute: null,
|
|
showWaypointDialog: false,
|
|
selectedWaypoint: null,
|
|
// 比例尺、外部参数弹窗
|
|
showScaleDialog: false,
|
|
currentScale: {
|
|
scaleNumerator: 1,
|
|
scaleDenominator: 1000,
|
|
unit: 'm'
|
|
},
|
|
scaleConfig: {
|
|
scaleNumerator: 1,
|
|
scaleDenominator: 1000,
|
|
unit: 'm'
|
|
},
|
|
showExternalParamsDialog: false,
|
|
currentExternalParams: {},
|
|
showPageLayoutDialog: false,
|
|
menuPosition: 'left',
|
|
showNameDialog: false,
|
|
newRouteName: '',
|
|
tempMapPoints: [],
|
|
/** 从平台右键「在此之前/在此之后插入航线」完成时传入,保存航线时用作 platformId */
|
|
tempMapPlatform: null,
|
|
/** 保存新航线弹窗内选择的方案 ID(弹窗内可直接选方案,无需先展开侧边) */
|
|
saveDialogScenarioId: null,
|
|
platformIconSaveTimer: null,
|
|
//导入平台弹窗
|
|
showImportDialog: false,
|
|
// 底部面板可见状态(时间轴/六步法)
|
|
bottomPanelVisible: false,
|
|
|
|
// 地图截图
|
|
screenshotMode: false,
|
|
showScreenshotDialog: false,
|
|
screenshotDataUrl: '',
|
|
screenshotFileName: '',
|
|
|
|
// 作战信息
|
|
roomCode: 'JTF-7-ALPHA',
|
|
onlineCount: 30,
|
|
combatTime: 'K+00:00:00', // 进入房间时固定作战时间,不随真实时间走
|
|
astroTime: '',
|
|
roomDetail: null,
|
|
showKTimeSetDialog: false,
|
|
kTimeForm: { dateTime: null },
|
|
saveRoomDrawingsTimer: null,
|
|
|
|
// 左侧菜单栏
|
|
isMenuHidden: true, // 是否完全隐藏左侧菜单
|
|
activeMenu: 'file',
|
|
isIconEditMode: false, // 图标编辑模式
|
|
|
|
// 默认菜单项配置
|
|
defaultMenuItems: [
|
|
{ id: 'file', name: '方案', icon: 'plan' },
|
|
{ id: 'start', name: '冲突', icon: 'chongtu' },
|
|
{ id: 'insert', name: '平台', icon: 'el-icon-s-platform' },
|
|
{ id: 'pattern', name: '空域', icon: 'ky' },
|
|
{ id: 'deduction', name: '推演', icon: 'el-icon-video-play' },
|
|
{ id: 'modify', name: '测距', icon: 'cj' },
|
|
{ id: 'refresh', name: '截图', icon: 'screenshot', action: 'refresh' },
|
|
{ id: 'basemap', name: '底图', icon: 'dt' },
|
|
{ id: 'save', name: '保存', icon: 'el-icon-document-checked' },
|
|
{ id: 'import', name: '导入', icon: 'el-icon-upload2' },
|
|
{ id: 'export', name: '导出', icon: 'el-icon-download' }
|
|
],
|
|
|
|
// 方案-航线-航点数据
|
|
selectedPlanId: null,
|
|
selectedPlanDetails: null,
|
|
routes: [],
|
|
selectedRouteId: null,
|
|
selectedRouteDetails: null,
|
|
|
|
showPlanNameDialog: false, // 控制新建方案弹窗
|
|
newPlanForm: {
|
|
name: ''
|
|
},
|
|
planRules: {
|
|
name: [{ required: true, message: '请输入方案名称', trigger: 'blur' }]
|
|
},
|
|
// 顶部导航菜单项(用于图标选择)- 只显示指定的菜单项
|
|
topNavItems: [
|
|
{ id: 'routeEdit', name: '航线编辑', icon: 'el-icon-edit-outline' },
|
|
{ id: 'militaryMarking', name: '军事标绘', icon: 'el-icon-crop' },
|
|
{ id: 'attributeEdit', name: '属性修改', icon: 'el-icon-setting' },
|
|
{ id: 'timeSettings', name: '时间设置', icon: 'el-icon-timer' },
|
|
{ id: 'aircraftSettings', name: '机型设置', icon: 'el-icon-s-custom' },
|
|
{ id: 'keyEventEdit', name: '关键事件编辑', icon: 'el-icon-edit' },
|
|
{ id: 'missileLaunch', name: '导弹发射', icon: 'el-icon-s-promotion' },
|
|
{ id: 'toggle2D3D', name: '2D/3D切换', icon: 'el-icon-picture-outline' },
|
|
{ id: 'toggleRuler', name: '显示/隐藏标尺', icon: 'el-icon-set-up' },
|
|
{ id: 'toggleGrid', name: '网格', icon: 'el-icon-s-grid' },
|
|
{ id: 'toggleScale', name: '比例尺', icon: 'el-icon-data-line' },
|
|
{ id: 'loadTerrain', name: '加载/切换地形', icon: 'el-icon-picture-outline-round' },
|
|
{ id: 'changeProjection', name: '投影', icon: 'el-icon-aim' },
|
|
{ id: 'loadAeroChart', name: '航空图', icon: 'el-icon-map-location' },
|
|
{ id: 'powerZone', name: '威力区', icon: 'el-icon-warning-outline' },
|
|
{ id: 'threatZone', name: '威胁区', icon: 'el-icon-warning' },
|
|
{ id: 'routeCalculation', name: '航线计算', icon: 'el-icon-s-data' },
|
|
{ id: 'conflictDisplay', name: '冲突显示', icon: 'el-icon-error' },
|
|
{ id: 'dataMaterials', name: '数据资料', icon: 'el-icon-folder' },
|
|
{ id: 'coordinateConversion', name: '坐标换算', icon: 'el-icon-coordinate' },
|
|
{ id: 'pageLayout', name: '页面布局', icon: 'el-icon-menu' },
|
|
{ id: 'dataStoragePath', name: '数据存储路径', icon: 'el-icon-folder-opened' },
|
|
{ id: 'externalParams', name: '外部参数', icon: 'el-icon-s-tools' },
|
|
{ id: 'toggleAirport', name: '显示/隐藏机场', icon: 'el-icon-s-flag' },
|
|
{ id: 'toggleLandmark', name: '显示/隐藏地标', icon: 'el-icon-location-outline' },
|
|
{ id: 'toggleRoute', name: '显示/隐藏航线', icon: 'el-icon-position' },
|
|
{ id: 'systemDescription', name: '系统说明', icon: 'el-icon-info' },
|
|
{ id: 'layerFavorites', name: '图层收藏', icon: 'el-icon-star-off' },
|
|
{ id: 'routeFavorites', name: '航线收藏', icon: 'el-icon-collection' }
|
|
],
|
|
|
|
// 右侧面板控制
|
|
isRightPanelHidden: true, // 是否完全隐藏右侧面板
|
|
// K时弹出框控制
|
|
showKTimePopup: false,
|
|
|
|
// 显示/隐藏控制
|
|
showAirport: true,
|
|
showLandmark: true,
|
|
showRoute: true,
|
|
|
|
// 坐标格式控制
|
|
coordinateFormat: 'dms', // 'decimal' 或 'dms'
|
|
|
|
menuItems: [],
|
|
|
|
// 右侧面板
|
|
currentRoomId: null,
|
|
plans: [],
|
|
activeRightTab: 'plan',
|
|
activeRouteIds: [], // 存储当前所有选中的航线ID
|
|
/** 航线上锁状态:routeId -> true 上锁,与地图右键及右侧列表锁图标同步 */
|
|
routeLocked: {},
|
|
// 冲突数据(由 runConflictCheck 根据当前航线与时间轴计算真实问题)
|
|
conflictCount: 0,
|
|
conflicts: [],
|
|
|
|
// 平台数据
|
|
activePlatformTab: 'air',
|
|
airPlatforms: [],
|
|
seaPlatforms: [],
|
|
groundPlatforms: [],
|
|
|
|
// 时间控制(与进入房间固定作战时间 K+00:00:00 一致)
|
|
timeProgress: 0,
|
|
currentTime: 'K+00:00:00',
|
|
deductionMinutesFromK: 0,
|
|
deductionWarnings: [],
|
|
deductionEarlyArrivalByRoute: {}, // routeId -> earlyArrivalLegs
|
|
showAddHoldDialog: false,
|
|
addHoldContext: null, // { routeId, routeName, legIndex, fromName, toName }
|
|
addHoldForm: { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: null },
|
|
missionDrawingActive: false,
|
|
missionDrawingPointsCount: 0,
|
|
isPlaying: false,
|
|
playbackSpeed: 1,
|
|
playbackInterval: null,
|
|
|
|
// 用户
|
|
userAvatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png',
|
|
};
|
|
},
|
|
watch: {
|
|
timeProgress: {
|
|
handler() {
|
|
this.updateTimeFromProgress();
|
|
},
|
|
immediate: true
|
|
},
|
|
/** 点开航线时:用当前固定作战时间同步时间轴与图标位置 */
|
|
activeRouteIds: {
|
|
handler(ids) {
|
|
if (!ids || ids.length === 0) return;
|
|
this.$nextTick(() => {
|
|
const minutes = this.combatTimeToMinutes(this.combatTime);
|
|
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
|
|
const span = Math.max(0, maxMinutes - minMinutes) || 120;
|
|
const progress = Math.max(0, Math.min(100, ((minutes - minMinutes) / span) * 100));
|
|
this.timeProgress = progress;
|
|
});
|
|
}
|
|
}
|
|
},
|
|
computed: {
|
|
isRoomOwner() {
|
|
if (!this.roomDetail || this.roomDetail.ownerId == null) return false;
|
|
const myId = this.$store.getters.id;
|
|
return String(myId) === String(this.roomDetail.ownerId);
|
|
},
|
|
isAdmin() {
|
|
const roles = this.$store.getters.roles || [];
|
|
const id = this.$store.getters.id;
|
|
return (
|
|
roles.includes('admin') ||
|
|
String(id) === '1' ||
|
|
(Array.isArray(roles) && roles.some(r => String(r).toLowerCase() === 'admin'))
|
|
);
|
|
},
|
|
canSetKTime() {
|
|
return this.isRoomOwner || this.isAdmin;
|
|
},
|
|
/** 格式化的 K 时(基准时刻),供右上角显示 */
|
|
kTimeDisplay() {
|
|
if (!this.roomDetail || !this.roomDetail.kAnchorTime) return '';
|
|
return this.formatKTimeForPicker(this.roomDetail.kAnchorTime) || '';
|
|
},
|
|
hasEarlyArrivalLegs() {
|
|
return this.activeRouteIds.some(rid => (this.deductionEarlyArrivalByRoute[rid] || []).length > 0);
|
|
},
|
|
addHoldDialogTitle() {
|
|
if (!this.addHoldContext) return '添加盘旋';
|
|
if (this.addHoldContext.mode === 'drawing') return '在最后两航点之间插入盘旋';
|
|
return '在此航段添加盘旋';
|
|
},
|
|
addHoldDialogTip() {
|
|
if (!this.addHoldContext) return '';
|
|
if (this.addHoldContext.mode === 'drawing') return '在最后两航点之间插入盘旋,保存航线时生效。';
|
|
return `在 ${this.addHoldContext.fromName} 与 ${this.addHoldContext.toName} 之间添加盘旋,到计划时间后沿切线飞往下一航点(原「下一格」航点将被移除)。`;
|
|
}
|
|
},
|
|
mounted() {
|
|
this.getList();
|
|
// 初始化时左侧菜单隐藏
|
|
this.isMenuHidden = true;
|
|
// 初始化时右侧面板隐藏
|
|
this.isRightPanelHidden = true;
|
|
// 初始化菜单项为默认配置,再尝试加载当前用户已保存的配置
|
|
this.menuItems = [...this.defaultMenuItems];
|
|
this.loadUserMenuConfig();
|
|
|
|
// 更新时间
|
|
this.updateTime();
|
|
setInterval(this.updateTime, 1000);
|
|
// 作战时间也需要实时更新
|
|
setInterval(this.updateCombatTime, 1000);
|
|
},
|
|
beforeDestroy() {
|
|
// 清除播放定时器
|
|
if (this.playbackInterval) {
|
|
clearInterval(this.playbackInterval);
|
|
this.playbackInterval = null;
|
|
}
|
|
},
|
|
created() {
|
|
this.currentRoomId = this.$route.query.roomId;
|
|
console.log("从路由接收到的真实房间 ID:", this.currentRoomId);
|
|
this.getList();
|
|
this.getPlatformList();
|
|
if (this.currentRoomId) this.getRoomDetail();
|
|
},
|
|
methods: {
|
|
handleBottomPanelVisible(visible) {
|
|
this.bottomPanelVisible = visible
|
|
},
|
|
// 处理从地图点击传来的编辑请求(支持 wpId 或 waypointIndex,如右键盘旋弧时仅有 waypointIndex)
|
|
async handleOpenWaypointEdit(wpId, routeId, waypointIndex) {
|
|
if (waypointIndex != null && (wpId == null || wpId === undefined)) {
|
|
try {
|
|
const response = await getRoutes(routeId);
|
|
if (response.code === 200 && response.data && response.data.waypoints) {
|
|
const wp = response.data.waypoints[waypointIndex];
|
|
if (wp) wpId = wp.id;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
if (wpId == null || wpId === undefined) {
|
|
this.$message.info('未找到该航点');
|
|
return;
|
|
}
|
|
console.log(`>>> [父组件接收] 航点 ID: ${wpId}, 所属航线 ID: ${routeId}`);
|
|
// 如果点击的点不属于当前选中的航线
|
|
if (this.selectedRouteId != routeId) {
|
|
console.log(">>> [自动切换] 正在获取新航线详情...");
|
|
try {
|
|
const response = await getRoutes(routeId);
|
|
if (response.code === 200 && response.data) {
|
|
const fullRouteData = response.data;
|
|
const fromList = this.routes.find(r => r.id === routeId);
|
|
// 同步更新父组件状态,合并 list 中的 platform 以便拖拽后重绘平台不丢失
|
|
this.selectedRouteId = fullRouteData.id;
|
|
this.selectedRouteDetails = {
|
|
id: fullRouteData.id,
|
|
name: fullRouteData.callSign,
|
|
waypoints: fullRouteData.waypoints || [],
|
|
platformId: fromList?.platformId,
|
|
platform: fromList?.platform,
|
|
attributes: fromList?.attributes
|
|
};
|
|
}
|
|
} catch (error) {
|
|
this.$message.error('切换航线数据失败');
|
|
return;
|
|
}
|
|
}
|
|
// 此时账本已确保是最新且正确的,开始找点
|
|
const wpData = this.selectedRouteDetails.waypoints.find(item => item.id == wpId);
|
|
if (wpData) {
|
|
// 成功找到业务数据,深拷贝后打开弹窗
|
|
this.selectedWaypoint = JSON.parse(JSON.stringify(wpData));
|
|
this.showWaypointDialog = true;
|
|
} else {
|
|
this.$message.info('未找到该航点的业务数据');
|
|
console.error("查找失败!账本内IDs:", this.selectedRouteDetails.waypoints.map(w => w.id));
|
|
}
|
|
// 获取当前最新的航点列表
|
|
const waypointsList = this.selectedRouteDetails.waypoints;
|
|
// 找到点在数组里的索引
|
|
const index = waypointsList.findIndex(item => item.id == wpId);
|
|
const total = waypointsList.length;
|
|
if (index !== -1) {
|
|
const wpData = waypointsList[index];
|
|
this.selectedWaypoint = {
|
|
...JSON.parse(JSON.stringify(wpData)),
|
|
currentIndex: index,
|
|
totalPoints: total
|
|
};
|
|
console.log(`>>> [地图触发锁定] 序号: ${index}, 总数: ${total}, 数据已装载`);
|
|
this.showWaypointDialog = true;
|
|
} else {
|
|
this.$message.info('未找到该航点的业务数据');
|
|
console.error("查找失败!账本内IDs:", waypointsList.map(w => w.id));
|
|
}
|
|
},
|
|
/** 删除平台库数据 */
|
|
handleDeletePlatform(platform) {
|
|
// 安全确认:防止误操作
|
|
this.$modal.confirm('是否确认删除名称为 "' + platform.name + '" 的平台数据?').then(() => {
|
|
// 调用后端接口删除
|
|
return delLib(platform.id);
|
|
}).then(() => {
|
|
// 删除成功后的反馈
|
|
this.$modal.msgSuccess("删除成功");
|
|
//重新分拣列表,让页面上的卡片消失
|
|
this.getPlatformList();
|
|
}).catch(() => {
|
|
});
|
|
},
|
|
|
|
// 处理从地图点击传来的航线编辑请求
|
|
async handleOpenRouteEdit(routeId) {
|
|
console.log(`>>> [父组件接收] 航线 ID: ${routeId}`);
|
|
try {
|
|
const response = await getRoutes(routeId);
|
|
if (response.code === 200 && response.data) {
|
|
const routeData = response.data;
|
|
// 构造航线对象,保持和右侧面板编辑时一致的格式
|
|
const route = {
|
|
id: routeData.id,
|
|
name: routeData.callSign,
|
|
scenarioId: routeData.scenarioId,
|
|
platformId: routeData.platformId,
|
|
platform: routeData.platform,
|
|
attributes: routeData.attributes,
|
|
points: routeData.waypoints ? routeData.waypoints.length : 0,
|
|
waypoints: routeData.waypoints || []
|
|
};
|
|
// 打开航线编辑弹窗
|
|
this.openRouteDialog(route);
|
|
}
|
|
} catch (error) {
|
|
this.$message.error('获取航线数据失败');
|
|
console.error('获取航线数据失败:', error);
|
|
}
|
|
},
|
|
|
|
/** 右键航点“向前/向后增加航点”:进入放置模式,传入 waypoints 给地图预览 */
|
|
handleAddWaypointAt({ routeId, waypointIndex, mode }) {
|
|
if (this.routeLocked[routeId]) {
|
|
this.$message.info('该航线已上锁,请先解锁');
|
|
return;
|
|
}
|
|
const route = this.routes.find(r => r.id === routeId);
|
|
if (!route || !route.waypoints || route.waypoints.length === 0) {
|
|
this.$message.warning('航线无航点数据');
|
|
return;
|
|
}
|
|
if (!this.$refs.cesiumMap || typeof this.$refs.cesiumMap.startAddWaypointAt !== 'function') return;
|
|
this.$refs.cesiumMap.startAddWaypointAt(routeId, waypointIndex, mode, route.waypoints);
|
|
},
|
|
|
|
/** 地图放置新航点后:调用 addWaypoints 插入,再按插入位置重排 seq 并重绘。向后添加时:当前第 K 个,新航点为第 K+1 个,原第 K+1 个及以后依次后移。 */
|
|
async handleAddWaypointPlaced({ routeId, waypointIndex, mode, position }) {
|
|
const route = this.routes.find(r => r.id === routeId);
|
|
if (!route || !route.waypoints) {
|
|
this.$message.warning('航线不存在或无航点');
|
|
return;
|
|
}
|
|
const waypoints = route.waypoints;
|
|
const refWp = waypoints[waypointIndex];
|
|
// 向后添加:insertIndex = waypointIndex+1,新航点成为第 (waypointIndex+2) 个,原第 waypointIndex+2 个变为第 waypointIndex+3 个
|
|
const insertIndex = mode === 'before' ? waypointIndex : waypointIndex + 1;
|
|
const prevWp = insertIndex > 0 ? waypoints[insertIndex - 1] : null;
|
|
const startTime = prevWp && prevWp.startTime ? prevWp.startTime : 'K+00:00:00';
|
|
const count = waypoints.length + 1;
|
|
const newName = `WP${insertIndex + 1}`;
|
|
try {
|
|
const addRes = await addWaypoints({
|
|
routeId,
|
|
name: newName,
|
|
seq: insertIndex + 1,
|
|
lat: position.lat,
|
|
lng: position.lng,
|
|
alt: position.alt,
|
|
speed: (refWp && refWp.speed != null) ? refWp.speed : 800,
|
|
startTime,
|
|
turnAngle: (refWp && refWp.turnAngle != null) ? refWp.turnAngle : (insertIndex === 0 || insertIndex === count - 1 ? 0 : 45)
|
|
});
|
|
await this.getList();
|
|
let updated = this.routes.find(r => r.id === routeId);
|
|
if (!updated || !updated.waypoints || updated.waypoints.length !== count) {
|
|
this.$message.warning('添加航点后数据未刷新');
|
|
return;
|
|
}
|
|
const list = updated.waypoints;
|
|
const prevIds = new Set(waypoints.map(w => w.id));
|
|
const newWp = (addRes && addRes.data && addRes.data.id != null && list.find(w => w.id === addRes.data.id))
|
|
? list.find(w => w.id === addRes.data.id)
|
|
: list.find(w => !prevIds.has(w.id)) || list[list.length - 1];
|
|
if (!newWp) {
|
|
this.$message.warning('未找到新插入的航点');
|
|
return;
|
|
}
|
|
const others = list.filter(w => w.id !== newWp.id);
|
|
others.sort((a, b) => (Number(a.seq) || 0) - (Number(b.seq) || 0));
|
|
const reordered = [...others.slice(0, insertIndex), newWp, ...others.slice(insertIndex)];
|
|
const routeInListFirst = this.routes.find(r => r.id === routeId);
|
|
if (routeInListFirst) routeInListFirst.waypoints = reordered;
|
|
if (this.selectedRouteId === routeId) this.selectedRouteDetails = { ...this.selectedRouteDetails, waypoints: reordered };
|
|
if (this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap) {
|
|
this.$refs.cesiumMap.removeRouteById(routeId);
|
|
this.$refs.cesiumMap.renderRouteWaypoints(reordered, routeId, routeInListFirst?.platformId, routeInListFirst?.platform, this.parseRouteStyle(routeInListFirst?.attributes || route?.attributes));
|
|
this.$nextTick(() => this.updateDeductionPositions());
|
|
}
|
|
const isHold = (w) => (w.pointType || w.point_type) === 'hold_circle' || (w.pointType || w.point_type) === 'hold_ellipse';
|
|
for (let i = 0; i < reordered.length; i++) {
|
|
const wp = reordered[i];
|
|
const newSeq = i + 1;
|
|
const isNewlyInserted = wp.id === newWp.id;
|
|
const nameToUse = isNewlyInserted ? (isHold(wp) ? `HOLD${newSeq}` : `WP${newSeq}`) : (wp.name || (isHold(wp) ? `HOLD${newSeq}` : `WP${newSeq}`));
|
|
await updateWaypoints({
|
|
id: wp.id,
|
|
routeId,
|
|
name: nameToUse,
|
|
seq: newSeq,
|
|
lat: wp.lat,
|
|
lng: wp.lng,
|
|
alt: wp.alt,
|
|
speed: wp.speed,
|
|
startTime: wp.startTime,
|
|
turnAngle: wp.turnAngle,
|
|
...(wp.pointType != null && { pointType: wp.pointType }),
|
|
...(wp.holdParams != null && { holdParams: wp.holdParams }),
|
|
...(wp.labelFontSize != null && { labelFontSize: wp.labelFontSize }),
|
|
...(wp.labelColor != null && { labelColor: wp.labelColor })
|
|
});
|
|
}
|
|
await this.getList();
|
|
updated = this.routes.find(r => r.id === routeId);
|
|
if (!updated || !updated.waypoints) {
|
|
this.$message.warning('刷新后未拿到航线航点');
|
|
return;
|
|
}
|
|
const sortedWaypoints = updated.waypoints.slice().sort((a, b) => (Number(a.seq) || 0) - (Number(b.seq) || 0));
|
|
updated.waypoints = sortedWaypoints;
|
|
const routeInList = this.routes.find(r => r.id === routeId);
|
|
if (routeInList) routeInList.waypoints = sortedWaypoints;
|
|
if (this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap) {
|
|
const roomId = this.currentRoomId;
|
|
if (roomId && updated.platformId) {
|
|
try {
|
|
const styleRes = await getPlatformStyle({ roomId, routeId, platformId: updated.platformId });
|
|
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(routeId, styleRes.data);
|
|
} catch (_) {}
|
|
}
|
|
this.$refs.cesiumMap.removeRouteById(routeId);
|
|
this.$refs.cesiumMap.renderRouteWaypoints(sortedWaypoints, routeId, updated.platformId, updated.platform, this.parseRouteStyle(updated.attributes));
|
|
this.$nextTick(() => this.updateDeductionPositions());
|
|
}
|
|
if (this.selectedRouteId === routeId) {
|
|
this.selectedRouteDetails = { ...this.selectedRouteDetails, waypoints: sortedWaypoints };
|
|
}
|
|
this.$message.success('已添加航点');
|
|
} catch (e) {
|
|
this.$message.error(e.msg || e.message || '添加航点失败');
|
|
console.error(e);
|
|
}
|
|
},
|
|
|
|
/** 右键「复制航线」:拉取航点后进入复制预览,左键放置后弹窗保存 */
|
|
async handleCopyRoute(routeId) {
|
|
try {
|
|
const res = await getRoutes(routeId);
|
|
if (res.code !== 200 || !res.data) {
|
|
this.$message.error('获取航线数据失败');
|
|
return;
|
|
}
|
|
const waypoints = res.data.waypoints || [];
|
|
if (waypoints.length < 2) {
|
|
this.$message.warning('航线航点不足,无法复制');
|
|
return;
|
|
}
|
|
if (this.$refs.cesiumMap && typeof this.$refs.cesiumMap.startRouteCopyPreview === 'function') {
|
|
this.$refs.cesiumMap.startRouteCopyPreview(waypoints);
|
|
this.$message.info('移动鼠标到目标位置,左键放置复制航线;右键取消');
|
|
}
|
|
} catch (e) {
|
|
this.$message.error('获取航线数据失败');
|
|
console.error(e);
|
|
}
|
|
},
|
|
|
|
/** 复制航线已放置:用当前偏移后的航点打开「保存新航线」弹窗 */
|
|
handleRouteCopyPlaced(points) {
|
|
this.tempMapPoints = points || [];
|
|
this.tempMapPlatform = null;
|
|
this.showNameDialog = true;
|
|
},
|
|
|
|
/** 地图上拖拽航点结束:将新位置写回数据库并刷新显示 */
|
|
async handleWaypointPositionChanged({ dbId, routeId, lat, lng, alt }) {
|
|
let waypoints = null;
|
|
let route = null;
|
|
if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) {
|
|
waypoints = this.selectedRouteDetails.waypoints;
|
|
route = this.selectedRouteDetails;
|
|
}
|
|
if (!waypoints) {
|
|
const r = this.routes.find(r => r.id === routeId);
|
|
if (r && r.waypoints) {
|
|
waypoints = r.waypoints;
|
|
route = r;
|
|
}
|
|
}
|
|
if (!waypoints || !route) {
|
|
this.$message.error('未找到对应航线数据');
|
|
return;
|
|
}
|
|
const wp = waypoints.find(p => p.id === dbId);
|
|
if (!wp) {
|
|
this.$message.error('未找到对应航点');
|
|
return;
|
|
}
|
|
const payload = {
|
|
id: wp.id,
|
|
routeId: wp.routeId != null ? wp.routeId : routeId,
|
|
name: wp.name,
|
|
seq: wp.seq,
|
|
lat: Number(lat),
|
|
lng: Number(lng),
|
|
alt: Number(alt),
|
|
speed: wp.speed,
|
|
startTime: (wp.startTime != null && wp.startTime !== '') ? wp.startTime : 'K+00:00:00',
|
|
turnAngle: wp.turnAngle
|
|
};
|
|
if (wp.pointType != null) payload.pointType = wp.pointType;
|
|
if (wp.holdParams != null) payload.holdParams = wp.holdParams;
|
|
if (wp.labelFontSize != null) payload.labelFontSize = wp.labelFontSize;
|
|
if (wp.labelColor != null) payload.labelColor = wp.labelColor;
|
|
try {
|
|
const response = await updateWaypoints(payload);
|
|
if (response.code === 200) {
|
|
const merged = { ...wp, ...payload };
|
|
const idx = waypoints.findIndex(p => p.id === dbId);
|
|
if (idx !== -1) waypoints.splice(idx, 1, merged);
|
|
if (this.selectedRouteDetails && this.selectedRouteDetails.id === routeId) {
|
|
const i = this.selectedRouteDetails.waypoints.findIndex(p => p.id === dbId);
|
|
if (i !== -1) this.selectedRouteDetails.waypoints.splice(i, 1, merged);
|
|
}
|
|
if (this.$refs.cesiumMap) {
|
|
const routeForPlatform = this.routes.find(r => r.id === routeId) || route;
|
|
const roomId = this.currentRoomId;
|
|
if (roomId && routeForPlatform.platformId) {
|
|
try {
|
|
const styleRes = await getPlatformStyle({ roomId, routeId, platformId: routeForPlatform.platformId });
|
|
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(routeId, styleRes.data);
|
|
} catch (_) {}
|
|
}
|
|
this.$refs.cesiumMap.renderRouteWaypoints(
|
|
waypoints,
|
|
routeId,
|
|
routeForPlatform.platformId,
|
|
routeForPlatform.platform,
|
|
this.parseRouteStyle(route.attributes)
|
|
);
|
|
}
|
|
this.$message.success('航点位置已更新');
|
|
this.$nextTick(() => this.updateDeductionPositions());
|
|
} else {
|
|
throw new Error(response.msg || '更新失败');
|
|
}
|
|
} catch (error) {
|
|
console.error('更新航点位置失败:', error);
|
|
this.$message.error(error.message || '更新失败,请重试');
|
|
}
|
|
},
|
|
|
|
// 显示在线成员弹窗
|
|
showOnlineMembersDialog() {
|
|
this.showOnlineMembers = true;
|
|
},
|
|
// 平台编辑弹窗相关方法
|
|
openPlatformDialog(platform) {
|
|
this.selectedPlatform = JSON.parse(JSON.stringify(platform));
|
|
this.showPlatformDialog = true;
|
|
},
|
|
/** 从数据库查询并分拣平台库数据 */
|
|
getPlatformList() {
|
|
listLib().then(res => {
|
|
const allData = res.rows || [];
|
|
this.airPlatforms = [];
|
|
this.seaPlatforms = [];
|
|
this.groundPlatforms = [];
|
|
allData.forEach(item => {
|
|
const platform = {
|
|
id: item.id,
|
|
name: item.name,
|
|
type: item.type,
|
|
specsJson: item.specsJson,
|
|
imageUrl: item.iconUrl || '',
|
|
icon: item.iconUrl ? '' : 'el-icon-picture-outline',
|
|
status: 'ready'
|
|
};
|
|
if (item.type === 'Air') {
|
|
this.airPlatforms.push(platform);
|
|
} else if (item.type === 'Sea') {
|
|
this.seaPlatforms.push(platform);
|
|
} else if (item.type === 'Ground') {
|
|
this.groundPlatforms.push(platform);
|
|
}
|
|
});
|
|
|
|
});
|
|
},
|
|
/** 导入确认:将弹窗填写的模版数据存入数据库 */
|
|
handleImportConfirm(formData) {
|
|
if (!formData.name || !formData.type) {
|
|
this.$modal.msgError("请填写完整的平台信息");
|
|
return;
|
|
}
|
|
// 创建 FormData 对象,这是传输二进制文件的标准格式
|
|
let data = new FormData();
|
|
// 把文件塞进去
|
|
if (formData.icon_file) {
|
|
data.append("file", formData.icon_file);
|
|
}
|
|
// 把其他文本字段也塞进去
|
|
data.append("name", formData.name);
|
|
data.append("type", formData.type);
|
|
data.append("specsJson", JSON.stringify({
|
|
scenarioId: this.selectedPlanId,
|
|
createTime: new Date().getTime()
|
|
}));
|
|
addLib(data).then(response => {
|
|
this.$modal.msgSuccess("导入成功");
|
|
this.showImportDialog = false;
|
|
this.getPlatformList();
|
|
}).catch(err => {
|
|
// 增加错误提示,防止请求失败时没有任何反应
|
|
console.error("上传出错:", err);
|
|
});
|
|
},
|
|
updatePlatform() {
|
|
// 刷新平台列表
|
|
this.getPlatformList();
|
|
// 提示成功
|
|
this.$modal.msgSuccess('平台配置更新成功');
|
|
// 关闭弹窗
|
|
this.showPlatformDialog = false;
|
|
},
|
|
/** 新建航线时写入数据库的默认样式(与地图默认显示一致:墨绿色实线线宽3) */
|
|
getDefaultRouteAttributes() {
|
|
const defaultAttrs = {
|
|
waypointStyle: { pixelSize: 7, color: '#ffffff', outlineColor: '#0078FF', outlineWidth: 2 },
|
|
lineStyle: { style: 'solid', width: 3, color: '#2E5C3E', gapColor: '#000000', dashLength: 20 }
|
|
};
|
|
return JSON.stringify(defaultAttrs);
|
|
},
|
|
/** 从航线 attributes JSON 解析出地图渲染用的样式对象 */
|
|
parseRouteStyle(attributes) {
|
|
if (attributes == null || attributes === '') return null;
|
|
try {
|
|
const attrs = typeof attributes === 'string' ? JSON.parse(attributes) : attributes;
|
|
const wp = attrs.waypointStyle || {};
|
|
const ln = attrs.lineStyle || {};
|
|
if (!wp.pixelSize && !wp.color && !ln.width && !ln.color) return null;
|
|
return {
|
|
waypoint: {
|
|
pixelSize: wp.pixelSize,
|
|
color: wp.color,
|
|
outlineColor: wp.outlineColor,
|
|
outlineWidth: wp.outlineWidth
|
|
},
|
|
line: {
|
|
style: ln.style,
|
|
width: ln.width,
|
|
color: ln.color,
|
|
gapColor: ln.gapColor,
|
|
dashLength: ln.dashLength
|
|
}
|
|
};
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
},
|
|
// 航线编辑弹窗相关方法
|
|
openRouteDialog(route) {
|
|
this.selectedRoute = route;
|
|
this.showRouteDialog = true;
|
|
},
|
|
// 更新航线数据(含航点表编辑后的批量持久化)
|
|
async updateRoute(updatedRoute) {
|
|
const index = this.routes.findIndex(r => r.id === updatedRoute.id);
|
|
if (index === -1) return;
|
|
try {
|
|
const apiData = {
|
|
id: updatedRoute.id,
|
|
callSign: updatedRoute.name,
|
|
platformId: updatedRoute.platformId || null,
|
|
attributes: updatedRoute.attributes != null ? updatedRoute.attributes : undefined
|
|
};
|
|
const res = await updateRoutes(apiData);
|
|
if (res.code === 200) {
|
|
const newRouteData = {
|
|
...this.routes[index],
|
|
name: updatedRoute.name,
|
|
platformId: updatedRoute.platformId,
|
|
platform: updatedRoute.platform,
|
|
attributes: updatedRoute.attributes
|
|
};
|
|
// 若编辑航线弹窗中提交了航点表数据,逐条持久化到数据库并合并到本地
|
|
if (updatedRoute.waypoints && updatedRoute.waypoints.length > 0) {
|
|
for (const wp of updatedRoute.waypoints) {
|
|
const payload = {
|
|
id: wp.id,
|
|
routeId: wp.routeId,
|
|
name: wp.name,
|
|
seq: wp.seq,
|
|
lat: wp.lat,
|
|
lng: wp.lng,
|
|
alt: wp.alt,
|
|
speed: wp.speed,
|
|
startTime: wp.startTime != null && wp.startTime !== '' ? wp.startTime : 'K+00:00:00',
|
|
turnAngle: wp.turnAngle
|
|
};
|
|
if (wp.pointType != null) payload.pointType = wp.pointType;
|
|
if (wp.holdParams != null) payload.holdParams = wp.holdParams;
|
|
if (wp.labelFontSize != null) payload.labelFontSize = wp.labelFontSize;
|
|
if (wp.labelColor != null) payload.labelColor = wp.labelColor;
|
|
if (payload.turnAngle > 0 && this.$refs.cesiumMap) {
|
|
payload.turnRadius = this.$refs.cesiumMap.getWaypointRadius(payload);
|
|
} else {
|
|
payload.turnRadius = 0;
|
|
}
|
|
await updateWaypoints(payload);
|
|
}
|
|
const mergedWaypoints = (newRouteData.waypoints || []).map((oldWp) => {
|
|
const fromDialog = updatedRoute.waypoints.find((w) => w.id === oldWp.id);
|
|
return fromDialog ? { ...oldWp, ...fromDialog } : oldWp;
|
|
});
|
|
newRouteData.waypoints = mergedWaypoints;
|
|
if (this.selectedRouteDetails && this.selectedRouteId === updatedRoute.id) {
|
|
this.selectedRouteDetails.waypoints = mergedWaypoints;
|
|
}
|
|
}
|
|
this.routes.splice(index, 1, newRouteData);
|
|
if (this.selectedRouteDetails && this.selectedRouteId === updatedRoute.id) {
|
|
this.selectedRouteDetails.name = updatedRoute.name;
|
|
this.selectedRouteDetails.platformId = updatedRoute.platformId;
|
|
this.selectedRouteDetails.platform = updatedRoute.platform;
|
|
this.selectedRouteDetails.attributes = updatedRoute.attributes;
|
|
}
|
|
this.$message.success(updatedRoute.waypoints && updatedRoute.waypoints.length > 0 ? '航线与航点已保存' : '航线更新成功');
|
|
const routeStyle = updatedRoute.routeStyle || this.parseRouteStyle(updatedRoute.attributes);
|
|
if (this.$refs.cesiumMap && this.activeRouteIds.includes(updatedRoute.id)) {
|
|
const route = this.routes.find(r => r.id === updatedRoute.id);
|
|
if (route && route.waypoints && route.waypoints.length > 0) {
|
|
const roomId = this.currentRoomId;
|
|
if (roomId && route.platformId) {
|
|
try {
|
|
const styleRes = await getPlatformStyle({ roomId, routeId: updatedRoute.id, platformId: route.platformId });
|
|
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(updatedRoute.id, styleRes.data);
|
|
} catch (_) {}
|
|
}
|
|
this.$refs.cesiumMap.removeRouteById(updatedRoute.id);
|
|
this.$refs.cesiumMap.renderRouteWaypoints(route.waypoints, updatedRoute.id, route.platformId, route.platform, routeStyle);
|
|
this.$nextTick(() => this.updateDeductionPositions());
|
|
}
|
|
}
|
|
} else {
|
|
this.$message.error(res.msg || '航线更新失败');
|
|
}
|
|
} catch (error) {
|
|
this.$message.error('航线更新失败');
|
|
console.error('航线更新失败:', error);
|
|
}
|
|
},
|
|
// 新建航线
|
|
createRoute(plan) {
|
|
if (!plan || !plan.id) return;
|
|
// 只有当目标方案和当前方案不同时,才执行“大扫除”
|
|
if (this.selectedPlanId !== plan.id) {
|
|
this.selectPlan(plan);
|
|
} else {
|
|
console.log(">>> 保持当前航线显示,直接进入规划模式");
|
|
}
|
|
// 进入 Cesium 绘图模式
|
|
if (this.$refs.cesiumMap) {
|
|
this.missionDrawingActive = true;
|
|
this.missionDrawingPointsCount = 0;
|
|
this.$refs.cesiumMap.startMissionRouteDrawing();
|
|
this.$message.success(`${plan.name}开启航线规划`);
|
|
}
|
|
},
|
|
async handleDeleteRoute(route) {
|
|
try {
|
|
// 二次确认,防止误删
|
|
await this.$confirm(`确定要彻底删除航线 "${route.name}" 吗?`, '提示', {
|
|
type: 'warning'
|
|
});
|
|
if (this.selectedRouteDetails && this.selectedRouteDetails.id === route.id) {
|
|
this.selectedRouteDetails = null;
|
|
}
|
|
const res = await delRoutes(route.id);
|
|
if (res.code === 200) {
|
|
this.$message.success('删除成功');
|
|
// 同步地图:如果该航线正在显示,立即清除
|
|
if (this.$refs.cesiumMap) {
|
|
this.$refs.cesiumMap.removeRouteById(route.id);
|
|
// 同时清除该航线的威力区
|
|
this.$refs.cesiumMap.removePowerZoneByRouteId(route.id);
|
|
}
|
|
// 同步状态:从选中列表中移除该 ID
|
|
const idx = this.activeRouteIds.indexOf(route.id);
|
|
if (idx > -1) {
|
|
this.activeRouteIds.splice(idx, 1);
|
|
}
|
|
await this.getList({ skipRoomPlatformIcons: true });
|
|
}
|
|
} catch (e) {
|
|
if (e !== 'cancel') {
|
|
console.error("删除航线失败:", e);
|
|
this.$message.error('删除操作失败');
|
|
}
|
|
}
|
|
},
|
|
/**
|
|
* 从数据库拉取最新的航线列表数据
|
|
* @param {Object} opts - 可选:{ skipRoomPlatformIcons: true } 时不再加载房间平台图标(用于删除航线等仅航线变化的场景,避免地图上的平台图标被清空再重画导致闪一下)
|
|
*/
|
|
async getList(opts = {}) {
|
|
const { skipRoomPlatformIcons = false } = opts;
|
|
try {
|
|
const roomId = this.$route.query.roomId || this.currentRoomId;
|
|
const scenarioRes = await listScenario({ roomId: roomId });
|
|
if (scenarioRes.code === 200) {
|
|
this.plans = scenarioRes.rows.map(s => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
frontendDrawings: s.frontendDrawings || null,
|
|
routes: []
|
|
}));
|
|
}
|
|
// 获取所有航线
|
|
const routeRes = await listRoutes({ pageNum: 1, pageSize: 9999 });
|
|
if (routeRes.code === 200) {
|
|
const allRoutes = routeRes.rows.map(item => ({
|
|
id: item.id,
|
|
name: item.callSign,
|
|
platformId: item.platformId,
|
|
platform: item.platform,
|
|
attributes: item.attributes,
|
|
points: item.waypoints ? item.waypoints.length : 0,
|
|
waypoints: item.waypoints || [],
|
|
conflict: false,
|
|
scenarioId: item.scenarioId
|
|
}));
|
|
//分配航线到方案
|
|
this.plans.forEach(plan => {
|
|
plan.routes = allRoutes.filter(r => r.scenarioId === plan.id);
|
|
});
|
|
|
|
this.routes = allRoutes;
|
|
// 先预取所有展示中航线的平台样式,再渲染,避免平台图标先黑后变色
|
|
if (this.activeRouteIds.length > 0 && this.$refs.cesiumMap) {
|
|
const roomId = this.currentRoomId;
|
|
await Promise.all(this.activeRouteIds.map(async (id) => {
|
|
const route = this.routes.find(r => r.id === id);
|
|
if (!route || !route.waypoints || route.waypoints.length === 0) return;
|
|
if (roomId && route.platformId) {
|
|
try {
|
|
const res = await getPlatformStyle({ roomId, routeId: id, platformId: route.platformId });
|
|
if (res.data) this.$refs.cesiumMap.setPlatformStyle(id, res.data);
|
|
} catch (_) {}
|
|
}
|
|
}));
|
|
this.$nextTick(() => {
|
|
this.activeRouteIds.forEach(id => {
|
|
const route = this.routes.find(r => r.id === id);
|
|
if (route && route.waypoints && route.waypoints.length > 0 && this.$refs.cesiumMap) {
|
|
this.$refs.cesiumMap.removeRouteById(id);
|
|
this.$refs.cesiumMap.renderRouteWaypoints(route.waypoints, id, route.platformId, route.platform, this.parseRouteStyle(route.attributes));
|
|
}
|
|
});
|
|
});
|
|
}
|
|
// 加载该房间下保存的地图平台图标(删除航线等操作时跳过,避免清空再重画导致平台图标闪一下)
|
|
if (!skipRoomPlatformIcons) {
|
|
const rId = roomId || this.currentRoomId;
|
|
if (rId && this.$refs.cesiumMap && typeof this.$refs.cesiumMap.loadRoomPlatformIcons === 'function') {
|
|
listRoomPlatformIcons(rId).then(res => {
|
|
if (res.code === 200 && res.data) this.$refs.cesiumMap.loadRoomPlatformIcons(rId, res.data);
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("数据加载失败:", error);
|
|
this.$message.error("获取方案列表失败");
|
|
}
|
|
},
|
|
handleMapDrawComplete(points, options) {
|
|
this.missionDrawingActive = false;
|
|
if (!points || points.length < 2) {
|
|
this.$message.error('航点太少,无法生成航线');
|
|
this.drawDom = false;
|
|
return;
|
|
}
|
|
this.tempMapPoints = points;
|
|
this.tempMapPlatform = (options && (options.platformId != null || options.platform)) ? options : null;
|
|
this.showNameDialog = true;
|
|
},
|
|
/** 保存新航线弹窗打开时:若当前未选方案则用已选方案或第一个方案填充,便于在弹窗内直接选择 */
|
|
onSaveRouteDialogOpen() {
|
|
this.saveDialogScenarioId = this.selectedPlanId || (this.plans[0] && this.plans[0].id) || null;
|
|
},
|
|
|
|
openAddHoldDuringDrawing() {
|
|
this.addHoldContext = { mode: 'drawing' };
|
|
this.addHoldForm = { holdType: 'hold_circle', radius: 500, semiMajor: 500, semiMinor: 300, headingDeg: 0, clockwise: true, startTime: '', startTimeMinutes: 60 };
|
|
this.showAddHoldDialog = true;
|
|
},
|
|
|
|
/** 弹窗点击“确定”:正式将数据保存到后端数据库 */
|
|
async confirmSaveNewRoute() {
|
|
// 1. 严格校验
|
|
if (!this.newRouteName || this.newRouteName.trim() === '') {
|
|
this.$message.error('新增航线未命名,请输入名称后保存!');
|
|
return;
|
|
}
|
|
const currentScenarioId = this.saveDialogScenarioId != null ? this.saveDialogScenarioId : this.selectedPlanId;
|
|
if (!currentScenarioId) {
|
|
this.$message.warning(this.plans.length === 0 ? '暂无方案,请先点击地图左侧红点展开菜单,选择「方案」并新建方案后再保存。' : '请在上方选择所属方案后再保存。');
|
|
return;
|
|
}
|
|
|
|
// 2. 构造数据(含盘旋航点的 pointType、holdParams;地图标签默认字号 14、颜色 #333333)
|
|
// 默认相对 K 时:按“路程÷默认速度”累加;用向上取整避免因整数分钟舍去导致冲突检测误报“无法按时到达”
|
|
const wpCount = this.tempMapPoints.length;
|
|
let cumulativeMinutes = 0;
|
|
const pointsWithStartTime = this.tempMapPoints.map((p, index) => {
|
|
const startTime = this.minutesToStartTime(index === 0 ? 0 : Math.ceil(cumulativeMinutes));
|
|
if (index < wpCount - 1) {
|
|
const next = this.tempMapPoints[index + 1];
|
|
const dist = this.segmentDistance(
|
|
{ lat: p.lat, lng: p.lng, alt: p.alt != null ? p.alt : 5000 },
|
|
{ lat: next.lat, lng: next.lng, alt: next.alt != null ? next.alt : 5000 }
|
|
);
|
|
const speedKmh = Number(p.speed) || 800;
|
|
cumulativeMinutes += (dist / 1000) * (60 / speedKmh);
|
|
}
|
|
return { ...p, startTime };
|
|
});
|
|
// 新建航线时:首尾航点转弯坡度固定为 0,中间可编辑航点默认 45°
|
|
const finalWaypoints = pointsWithStartTime.map((p, index) => {
|
|
const isFirstOrLast = index === 0 || index === wpCount - 1;
|
|
const defaultTurnAngle = isFirstOrLast ? 0.0 : 45.0;
|
|
return {
|
|
name: p.name || `WP${index + 1}`,
|
|
lat: p.lat,
|
|
lng: p.lng,
|
|
alt: p.alt != null ? p.alt : 5000.0,
|
|
speed: p.speed != null ? p.speed : 800.0,
|
|
startTime: p.startTime,
|
|
turnAngle: p.turnAngle != null ? p.turnAngle : defaultTurnAngle,
|
|
labelFontSize: p.labelFontSize != null ? p.labelFontSize : 14,
|
|
labelColor: p.labelColor || '#333333',
|
|
...(p.pointType && { pointType: p.pointType }),
|
|
...(p.holdParams != null && { holdParams: typeof p.holdParams === 'string' ? p.holdParams : JSON.stringify(p.holdParams) })
|
|
};
|
|
});
|
|
|
|
const routeData = {
|
|
callSign: this.newRouteName,
|
|
scenarioId: currentScenarioId,
|
|
platformId: (this.tempMapPlatform && this.tempMapPlatform.platformId != null) ? this.tempMapPlatform.platformId : 1,
|
|
attributes: this.getDefaultRouteAttributes(),
|
|
waypoints: finalWaypoints
|
|
};
|
|
|
|
try {
|
|
const response = await addRoutes(routeData);
|
|
if (response.code === 200) {
|
|
const savedRoute = response.data;
|
|
const newRouteId = savedRoute?.id;
|
|
|
|
if (!newRouteId || !savedRoute.waypoints) {
|
|
this.$message.error('保存成功但返回数据结构异常,请手动刷新');
|
|
return;
|
|
}
|
|
|
|
// 1. 更新当前选中详情
|
|
this.selectedRouteId = newRouteId;
|
|
this.selectedRouteDetails = JSON.parse(JSON.stringify(savedRoute));
|
|
|
|
// 2. 【修复关键 A】:使用 push 而不是赋值
|
|
// 确保新 ID 进去的同时,activeRouteIds 里原有的老 ID(如 112)还在
|
|
// 这样右侧列表里,老的航线依然会保持勾选状态
|
|
if (!this.activeRouteIds.includes(newRouteId)) {
|
|
this.activeRouteIds.push(newRouteId);
|
|
}
|
|
|
|
// 3. 先保存“用于创建航线的平台”引用,再清空(后续删除要用到)
|
|
const platformToRemove = this.tempMapPlatform;
|
|
|
|
// 4. UI 重置
|
|
this.drawDom = false;
|
|
this.showNameDialog = false;
|
|
this.newRouteName = '';
|
|
this.tempMapPoints = [];
|
|
this.tempMapPlatform = null;
|
|
|
|
// 5. 【修复关键 B】:只清理临时预览实体,不 clearAllWaypoints
|
|
if (this.$refs.cesiumMap) {
|
|
if (this.$refs.cesiumMap.tempPreviewEntity) {
|
|
this.viewer.entities.remove(this.$refs.cesiumMap.tempPreviewEntity);
|
|
this.$refs.cesiumMap.tempPreviewEntity = null;
|
|
}
|
|
}
|
|
|
|
// 6. 若本条航线是从“地图上的平台图标”创建的:先删库再刷新列表,最后从地图移除该占位图标(避免 getList 重新拉取到未删记录又画上去)
|
|
if (platformToRemove && this.$refs.cesiumMap) {
|
|
if (platformToRemove.serverId) {
|
|
await delRoomPlatformIcon(platformToRemove.serverId).catch(() => {});
|
|
if (typeof this.$refs.cesiumMap.removePlatformIconByServerId === 'function') {
|
|
this.$refs.cesiumMap.removePlatformIconByServerId(platformToRemove.serverId);
|
|
}
|
|
} else if (platformToRemove.mapEntityId && typeof this.$refs.cesiumMap.removeEntity === 'function') {
|
|
this.$refs.cesiumMap.removeEntity(platformToRemove.mapEntityId);
|
|
}
|
|
}
|
|
|
|
// 7. 刷新右侧列表(拉取最新数据,含新建航线);getList 会重新加载房间平台图标,因已删记录,不会再画出该图标
|
|
await this.getList();
|
|
const routeFromList = this.routes.find(r => r.id === newRouteId);
|
|
if (routeFromList) {
|
|
this.selectedRouteDetails = { ...routeFromList };
|
|
}
|
|
this.$message.success('航线及其航点已成功保存并同步');
|
|
}
|
|
} catch (error) {
|
|
console.error(">>> [保存异常]:", error);
|
|
this.$message.error('保存失败,请检查网络或后端日志');
|
|
}
|
|
},
|
|
// 航点编辑弹窗相关方法
|
|
openWaypointDialog(data) {
|
|
console.log(">>> [父组件接收] 编辑航点详情:", data);
|
|
// 将整个对象赋值给弹窗绑定的变量
|
|
this.selectedWaypoint = data;
|
|
this.showWaypointDialog = true;
|
|
},
|
|
/** 航点编辑保存:更新数据库并同步地图显示 */
|
|
async updateWaypoint(updatedWaypoint) {
|
|
if (!this.selectedRouteDetails || !this.selectedRouteDetails.waypoints) return;
|
|
try {
|
|
if (this.$refs.cesiumMap && updatedWaypoint.turnAngle > 0) {
|
|
updatedWaypoint.turnRadius = this.$refs.cesiumMap.getWaypointRadius(updatedWaypoint);
|
|
} else {
|
|
updatedWaypoint.turnRadius = 0;
|
|
}
|
|
// 明确构造后端需要的字段,确保 startTime(相对K时)一定会被提交并更新到数据库
|
|
const payload = {
|
|
id: updatedWaypoint.id,
|
|
routeId: updatedWaypoint.routeId,
|
|
name: updatedWaypoint.name,
|
|
seq: updatedWaypoint.seq,
|
|
lat: updatedWaypoint.lat,
|
|
lng: updatedWaypoint.lng,
|
|
alt: updatedWaypoint.alt,
|
|
speed: updatedWaypoint.speed,
|
|
startTime: (updatedWaypoint.startTime != null && updatedWaypoint.startTime !== '')
|
|
? updatedWaypoint.startTime
|
|
: 'K+00:00:00',
|
|
turnAngle: updatedWaypoint.turnAngle
|
|
};
|
|
if (updatedWaypoint.pointType != null) payload.pointType = updatedWaypoint.pointType;
|
|
if (updatedWaypoint.holdParams != null) payload.holdParams = updatedWaypoint.holdParams;
|
|
if (updatedWaypoint.labelFontSize != null) payload.labelFontSize = updatedWaypoint.labelFontSize;
|
|
if (updatedWaypoint.labelColor != null) payload.labelColor = updatedWaypoint.labelColor;
|
|
const response = await updateWaypoints(payload);
|
|
if (response.code === 200) {
|
|
const index = this.selectedRouteDetails.waypoints.findIndex(p => p.id === updatedWaypoint.id);
|
|
if (index !== -1) {
|
|
// 更新本地数据(用已提交的 payload 保证 startTime 等与数据库一致)
|
|
this.selectedRouteDetails.waypoints.splice(index, 1, { ...updatedWaypoint, ...payload });
|
|
const merged = { ...updatedWaypoint, ...payload };
|
|
const routeInList = this.routes.find(r => r.id === this.selectedRouteDetails.id);
|
|
if (routeInList && routeInList.waypoints) {
|
|
const idxInList = routeInList.waypoints.findIndex(p => p.id === updatedWaypoint.id);
|
|
if (idxInList !== -1) routeInList.waypoints.splice(idxInList, 1, merged);
|
|
}
|
|
if (this.$refs.cesiumMap) {
|
|
const roomId = this.currentRoomId;
|
|
const sd = this.selectedRouteDetails;
|
|
if (roomId && sd.platformId) {
|
|
try {
|
|
const styleRes = await getPlatformStyle({ roomId, routeId: sd.id, platformId: sd.platformId });
|
|
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(sd.id, styleRes.data);
|
|
} catch (_) {}
|
|
}
|
|
this.$refs.cesiumMap.updateWaypointGraphicById(updatedWaypoint.id, updatedWaypoint.name);
|
|
this.$refs.cesiumMap.renderRouteWaypoints(
|
|
sd.waypoints,
|
|
sd.id,
|
|
sd.platformId,
|
|
sd.platform,
|
|
this.parseRouteStyle(sd.attributes)
|
|
);
|
|
}
|
|
this.showWaypointDialog = false;
|
|
this.$message.success('航点信息已持久化至数据库');
|
|
this.$nextTick(() => this.updateDeductionPositions());
|
|
}
|
|
} else {
|
|
// 如果 code 不是 200,手动抛出错误进入catch
|
|
throw new Error(response.msg || '后端业务逻辑校验失败');
|
|
}
|
|
} catch (error) {
|
|
console.error("更新航点失败:", error);
|
|
this.$message.error(error.message || '数据库更新失败,请重试');
|
|
}
|
|
},
|
|
updateTime() {
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = (now.getMonth() + 1).toString().padStart(2, '0');
|
|
const day = now.getDate().toString().padStart(2, '0');
|
|
const hours = now.getHours().toString().padStart(2, '0');
|
|
const minutes = now.getMinutes().toString().padStart(2, '0');
|
|
const seconds = now.getSeconds().toString().padStart(2, '0');
|
|
this.astroTime = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
|
},
|
|
|
|
/** 是否有选中的航线用于推演(有则作战时间随推演时间轴变化) */
|
|
hasDeductionRange() {
|
|
return this.activeRouteIds && this.activeRouteIds.length > 0;
|
|
},
|
|
updateCombatTime() {
|
|
// 作战时间始终与推演时间轴同步:有航线时用 deductionMinutesFromK,无航线时用 currentTime(由时间轴 updateTimeFromProgress 计算)
|
|
if (this.hasDeductionRange()) {
|
|
const sign = this.deductionMinutesFromK >= 0 ? '+' : '-';
|
|
const absMin = Math.abs(Math.floor(this.deductionMinutesFromK));
|
|
const hours = Math.floor(absMin / 60);
|
|
const minutes = absMin % 60;
|
|
this.combatTime = `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
|
|
return;
|
|
}
|
|
// 无展示航线时:作战时间仍随推演时间轴变化,用 currentTime
|
|
this.combatTime = this.currentTime || 'K+00:00:00';
|
|
},
|
|
/** 将作战时间字符串(如 K+01:30:00)解析为相对 K 的分钟数 */
|
|
combatTimeToMinutes(str) {
|
|
if (!str || str === '未设定') return 0;
|
|
const m = String(str).match(/K([+-])(\d{1,2}):(\d{2})(?::(\d{2}))?/);
|
|
if (!m) return 0;
|
|
const sign = m[1] === '+' ? 1 : -1;
|
|
const h = parseInt(m[2], 10);
|
|
const min = parseInt(m[3], 10);
|
|
return sign * (h * 60 + min);
|
|
},
|
|
getRoomDetail() {
|
|
if (!this.currentRoomId) return;
|
|
getRooms(this.currentRoomId).then(res => {
|
|
if (res.code === 200 && res.data) {
|
|
this.roomDetail = res.data;
|
|
this.$nextTick(() => this.loadRoomDrawings());
|
|
}
|
|
}).catch(() => {});
|
|
},
|
|
/** 加载当前房间的空域/威力区图形(与房间 ID 绑定,进入该房间即显示) */
|
|
loadRoomDrawings() {
|
|
if (!this.roomDetail || !this.$refs.cesiumMap || typeof this.$refs.cesiumMap.loadFrontendDrawings !== 'function') return;
|
|
if (this.roomDetail.frontendDrawings) {
|
|
this.$refs.cesiumMap.loadFrontendDrawings(this.roomDetail.frontendDrawings);
|
|
} else {
|
|
this.$refs.cesiumMap.clearDrawingEntities();
|
|
}
|
|
},
|
|
/** 地图 viewer 就绪时加载当前房间图形(可能 getRoomDetail 尚未返回,此处再试一次) */
|
|
onViewerReady() {
|
|
this.loadRoomDrawings();
|
|
},
|
|
/** 空域/威力区图形增删时防抖自动保存到当前房间 */
|
|
onDrawingEntitiesChanged() {
|
|
if (!this.currentRoomId) return;
|
|
if (this.saveRoomDrawingsTimer) clearTimeout(this.saveRoomDrawingsTimer);
|
|
this.saveRoomDrawingsTimer = setTimeout(() => {
|
|
this.saveRoomDrawingsTimer = null;
|
|
this.saveRoomDrawingsToServer();
|
|
}, 600);
|
|
},
|
|
/** 将当前地图上的空域图形写入当前房间(静默保存,不弹成功提示;失败时抛出供调用方处理) */
|
|
async saveRoomDrawingsToServer() {
|
|
if (!this.currentRoomId || !this.$refs.cesiumMap) return;
|
|
if (typeof this.$refs.cesiumMap.getFrontendDrawingsData !== 'function') return;
|
|
const drawingsData = this.$refs.cesiumMap.getFrontendDrawingsData();
|
|
const frontendDrawingsStr = JSON.stringify(drawingsData);
|
|
await updateRooms({ id: this.currentRoomId, frontendDrawings: frontendDrawingsStr });
|
|
if (this.roomDetail) this.roomDetail.frontendDrawings = frontendDrawingsStr;
|
|
},
|
|
/** 将任意日期字符串格式化为 yyyy-MM-dd HH:mm:ss,供日期选择器使用 */
|
|
formatKTimeForPicker(val) {
|
|
if (!val) return null;
|
|
const d = new Date(val);
|
|
if (isNaN(d.getTime())) return null;
|
|
const y = d.getFullYear();
|
|
const m = (d.getMonth() + 1).toString().padStart(2, '0');
|
|
const day = d.getDate().toString().padStart(2, '0');
|
|
const h = d.getHours().toString().padStart(2, '0');
|
|
const min = d.getMinutes().toString().padStart(2, '0');
|
|
const s = d.getSeconds().toString().padStart(2, '0');
|
|
return `${y}-${m}-${day} ${h}:${min}:${s}`;
|
|
},
|
|
openKTimeSetDialog() {
|
|
console.log("当前登录 ID (myId):", this.$store.getters.id);
|
|
console.log("当前房间 ownerId:", this.roomDetail ? this.roomDetail.ownerId : '无房间信息');
|
|
console.log("当前角色 roles:", this.$store.getters.roles);
|
|
if (!this.canSetKTime) {
|
|
this.$message.info('仅房主或管理员可设定或修改 K 时');
|
|
return;
|
|
}
|
|
if (!this.currentRoomId) {
|
|
this.$message.warning('请先进入任务房间');
|
|
return;
|
|
}
|
|
if (!this.roomDetail || !this.roomDetail.id) {
|
|
this.$message.warning('房间信息加载中或未找到,请稍后再试');
|
|
return;
|
|
}
|
|
const existing = this.roomDetail.kAnchorTime
|
|
? this.formatKTimeForPicker(this.roomDetail.kAnchorTime)
|
|
: null;
|
|
this.kTimeForm.dateTime = existing || this.formatKTimeForPicker(this.astroTime) || this.formatKTimeForPicker(new Date());
|
|
this.showKTimeSetDialog = true;
|
|
},
|
|
saveKTime() {
|
|
if (!this.roomDetail || !this.kTimeForm.dateTime) {
|
|
this.$message.warning('请选择 K 时');
|
|
return;
|
|
}
|
|
updateRooms({ id: this.roomDetail.id, kAnchorTime: this.kTimeForm.dateTime }).then(res => {
|
|
if (res.code === 200) {
|
|
this.$message.success('K 时已设定');
|
|
this.showKTimeSetDialog = false;
|
|
this.getRoomDetail();
|
|
} else {
|
|
this.$message.error(res.msg || '设定失败');
|
|
}
|
|
}).catch(() => this.$message.error('设定失败'));
|
|
},
|
|
|
|
// 左侧菜单栏操作
|
|
showMenu() {
|
|
this.isMenuHidden = false;
|
|
},
|
|
|
|
hideMenu() {
|
|
this.isMenuHidden = true;
|
|
},
|
|
|
|
|
|
// 右侧面板操作
|
|
showRightPanel() {
|
|
this.isRightPanelHidden = false;
|
|
this.$message.info('显示右侧面板');
|
|
},
|
|
|
|
// 顶部导航栏操作
|
|
selectTopNav(item) {
|
|
this.activeMenu = item.id;
|
|
},
|
|
|
|
// 文件下拉菜单方法:立即保存当前房间的空域/威力区图形(与自动保存共用逻辑)
|
|
async savePlan() {
|
|
if (!this.currentRoomId) {
|
|
this.$message.warning('请先进入任务房间');
|
|
return;
|
|
}
|
|
try {
|
|
await this.saveRoomDrawingsToServer();
|
|
this.$message.success('房间空域图形已保存');
|
|
} catch (e) {
|
|
this.$message.error('保存失败,请检查网络');
|
|
}
|
|
},
|
|
|
|
importPlanFile() {
|
|
this.$message.success('导入计划');
|
|
// 这里可以添加导入计划文件的逻辑
|
|
},
|
|
|
|
importACD() {
|
|
this.$message.success('导入ACD');
|
|
// 这里可以添加导入ACD文件的逻辑
|
|
},
|
|
|
|
importATO() {
|
|
this.$message.success('导入ATO');
|
|
// 这里可以添加导入ATO文件的逻辑
|
|
},
|
|
|
|
importLayer() {
|
|
this.$message.success('导入图层');
|
|
// 这里可以添加导入图层的逻辑
|
|
},
|
|
|
|
importRoute() {
|
|
this.$message.success('导入航线');
|
|
// 这里可以添加导入航线的逻辑
|
|
},
|
|
|
|
exportPlan() {
|
|
this.$message.success('导出计划');
|
|
// 这里可以添加导出计划的逻辑
|
|
},
|
|
|
|
// 编辑下拉菜单方法
|
|
routeEdit() {
|
|
this.$message.success('航线编辑');
|
|
// 这里可以添加航线编辑的逻辑
|
|
},
|
|
|
|
militaryMarking() {
|
|
this.$message.success('军事标绘');
|
|
// 这里可以添加军事标绘的逻辑
|
|
},
|
|
|
|
iconEdit() {
|
|
// 这个方法现在由 TopHeader 组件处理状态切换
|
|
// 只需要在这里处理其他逻辑(如果有的话)
|
|
},
|
|
|
|
toggleIconEditMode(isEditMode) {
|
|
this.isIconEditMode = isEditMode
|
|
},
|
|
|
|
exitIconEdit() {
|
|
this.isIconEditMode = false
|
|
},
|
|
|
|
async handleResetMenuItems() {
|
|
this.menuItems = [...this.defaultMenuItems]
|
|
try {
|
|
await saveMenuConfig({
|
|
menuItems: JSON.stringify(this.menuItems),
|
|
position: this.menuPosition || 'left'
|
|
})
|
|
} catch (e) { /* 未登录时仅本地恢复默认 */ }
|
|
},
|
|
|
|
updateMenuItems(newItems) {
|
|
this.menuItems = newItems
|
|
},
|
|
|
|
handleMenuDragEnd(newItems) {
|
|
this.menuItems = newItems
|
|
},
|
|
|
|
handleAddMenuItems(selectedItems) {
|
|
selectedItems.forEach(item => {
|
|
const newId = Date.now().toString() + Math.random().toString(36).substr(2, 9)
|
|
const newMenuItem = {
|
|
id: newId,
|
|
name: item.name,
|
|
icon: item.icon,
|
|
action: item.id
|
|
}
|
|
this.menuItems.push(newMenuItem)
|
|
})
|
|
},
|
|
|
|
handleMenuAction(actionId) {
|
|
const actionMap = {
|
|
'savePlan': () => this.savePlan(),
|
|
'routeEdit': () => this.routeEdit(),
|
|
'militaryMarking': () => this.militaryMarking(),
|
|
'attributeEdit': () => this.attributeEdit(),
|
|
'timeSettings': () => this.timeSettings(),
|
|
'aircraftSettings': () => this.aircraftSettings(),
|
|
'keyEventEdit': () => this.keyEventEdit(),
|
|
'missileLaunch': () => this.missileLaunch(),
|
|
'toggle2D3D': () => this.toggle2D3D(),
|
|
'toggleRuler': () => this.toggleRuler(),
|
|
'toggleGrid': () => this.toggleGrid(),
|
|
'toggleScale': () => {
|
|
this.showScaleDialog = true
|
|
this.currentScale = {}
|
|
},
|
|
'loadTerrain': () => this.loadTerrain(),
|
|
'changeProjection': () => this.changeProjection(),
|
|
'loadAeroChart': () => this.loadAeroChart(),
|
|
'powerZone': () => this.startPowerZoneDrawing(),
|
|
'threatZone': () => this.threatZone(),
|
|
'routeCalculation': () => this.routeCalculation(),
|
|
'conflictDisplay': () => this.conflictDisplay(),
|
|
'dataMaterials': () => this.dataMaterials(),
|
|
'coordinateConversion': () => this.coordinateConversion(),
|
|
'pageLayout': () => this.pageLayout(),
|
|
'dataStoragePath': () => this.dataStoragePath(),
|
|
'externalParams': () => {
|
|
this.showExternalParamsDialog = true
|
|
this.currentExternalParams = {}
|
|
},
|
|
'toggleAirport': () => this.toggleAirport(),
|
|
'toggleLandmark': () => this.toggleLandmark(),
|
|
'toggleRoute': () => this.toggleRoute(),
|
|
'layerFavorites': () => this.layerFavorites(),
|
|
'routeFavorites': () => this.routeFavorites(),
|
|
'refresh': () => this.captureMapScreenshot()
|
|
}
|
|
|
|
if (actionMap[actionId]) {
|
|
actionMap[actionId]()
|
|
}
|
|
},
|
|
|
|
/** 截图:隐藏上下左右菜单只保留地图,用 postRender + readPixels 避免 WebGL 缓冲被清空导致黑屏 */
|
|
async captureMapScreenshot() {
|
|
const cm = this.$refs.cesiumMap
|
|
if (!cm || !cm.viewer || !cm.viewer.scene || !cm.viewer.scene.canvas) {
|
|
this.$message.warning('地图未就绪,请稍后再试')
|
|
return
|
|
}
|
|
this.screenshotMode = true
|
|
await this.$nextTick()
|
|
await new Promise(r => setTimeout(r, 350))
|
|
const viewer = cm.viewer
|
|
const canvas = viewer.scene.canvas
|
|
const self = this
|
|
const removeListener = viewer.scene.postRender.addEventListener(function captureFrame() {
|
|
removeListener()
|
|
self.screenshotMode = false
|
|
try {
|
|
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl')
|
|
if (!gl) {
|
|
self.$message.error('无法获取 WebGL 上下文')
|
|
return
|
|
}
|
|
const width = canvas.width
|
|
const height = canvas.height
|
|
if (width === 0 || height === 0) {
|
|
self.$message.error('画布尺寸为 0')
|
|
return
|
|
}
|
|
const pixels = new Uint8Array(width * height * 4)
|
|
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
|
|
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels)
|
|
const offscreen = document.createElement('canvas')
|
|
offscreen.width = width
|
|
offscreen.height = height
|
|
const ctx = offscreen.getContext('2d')
|
|
const imageData = ctx.createImageData(width, height)
|
|
for (let y = 0; y < height; y++) {
|
|
const srcRow = (height - 1 - y) * width * 4
|
|
const dstRow = y * width * 4
|
|
for (let i = 0; i < width * 4; i++) imageData.data[dstRow + i] = pixels[srcRow + i]
|
|
}
|
|
ctx.putImageData(imageData, 0, 0)
|
|
const dataUrl = offscreen.toDataURL('image/png')
|
|
const now = new Date()
|
|
const timeStr = now.getFullYear() + '-' + String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0') + '_' + String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0') + String(now.getSeconds()).padStart(2, '0')
|
|
self.screenshotFileName = `地图截图_${timeStr}.png`
|
|
self.screenshotDataUrl = dataUrl
|
|
self.showScreenshotDialog = true
|
|
} catch (e) {
|
|
self.$message.error('截图失败:' + (e && e.message ? e.message : '未知错误'))
|
|
}
|
|
})
|
|
viewer.scene.requestRender()
|
|
},
|
|
|
|
/** 确认保存截图:触发浏览器下载(用户可在保存对话框中选择路径) */
|
|
confirmSaveScreenshot() {
|
|
if (!this.screenshotDataUrl) return
|
|
let name = (this.screenshotFileName || '地图截图.png').trim()
|
|
if (!name) name = '地图截图.png'
|
|
if (!/\.(png|jpg|jpeg)$/i.test(name)) name = name + '.png'
|
|
const a = document.createElement('a')
|
|
a.href = this.screenshotDataUrl
|
|
a.download = name
|
|
a.click()
|
|
this.showScreenshotDialog = false
|
|
this.screenshotDataUrl = ''
|
|
this.$message.success('已触发下载,请在浏览器保存对话框中选择保存位置')
|
|
},
|
|
|
|
handleDeleteMenuItem(deletedItem) {
|
|
const index = this.menuItems.findIndex(item => item.id === deletedItem.id)
|
|
if (index > -1) {
|
|
this.menuItems.splice(index, 1)
|
|
}
|
|
},
|
|
|
|
async handleSaveMenuItems(savedItems) {
|
|
this.menuItems = [...savedItems]
|
|
// 持久化到当前账号
|
|
try {
|
|
await saveMenuConfig({
|
|
menuItems: JSON.stringify(this.menuItems),
|
|
position: this.menuPosition || 'left'
|
|
})
|
|
} catch (e) {
|
|
// 未登录或接口失败时仅本地生效,仍提示保存成功(LeftMenu 已提示)
|
|
if (e && e.response && e.response.status === 401) {
|
|
this.$message.info('当前未登录,配置仅在本页有效;登录后保存可同步到账号')
|
|
}
|
|
}
|
|
},
|
|
|
|
/** 加载当前用户的左侧菜单配置(登录且有过保存时生效) */
|
|
async loadUserMenuConfig() {
|
|
try {
|
|
const res = await getMenuConfig()
|
|
const data = res && res.data
|
|
if (!data) return
|
|
if (data.menuItems) {
|
|
let arr = []
|
|
try {
|
|
arr = typeof data.menuItems === 'string' ? JSON.parse(data.menuItems) : data.menuItems
|
|
} catch (e) { /* 解析失败保留默认 */ }
|
|
if (Array.isArray(arr) && arr.length > 0) {
|
|
const defaultMap = (this.defaultMenuItems || []).reduce((m, it) => { m[it.id] = it; return m }, {})
|
|
this.menuItems = arr.map(item => {
|
|
const def = defaultMap[item.id]
|
|
if (def) return { ...item, name: def.name, icon: def.icon, action: def.action }
|
|
return item
|
|
})
|
|
}
|
|
}
|
|
if (data.position && ['left', 'top', 'bottom'].includes(data.position)) {
|
|
this.menuPosition = data.position
|
|
}
|
|
} catch (e) {
|
|
// 未登录或接口失败则使用默认菜单,不提示
|
|
}
|
|
},
|
|
|
|
attributeEdit() {
|
|
this.$message.success('属性修改');
|
|
},
|
|
|
|
timeSettings() {
|
|
this.$message.success('时间设置');
|
|
// 这里可以添加时间设置的逻辑
|
|
},
|
|
|
|
aircraftSettings() {
|
|
this.$message.success('机型设置');
|
|
// 这里可以添加机型设置的逻辑
|
|
},
|
|
|
|
keyEventEdit() {
|
|
this.$message.success('关键事件编辑');
|
|
// 这里可以添加关键事件编辑的逻辑
|
|
},
|
|
|
|
missileLaunch() {
|
|
this.$message.success('导弹发射');
|
|
// 这里可以添加导弹发射的逻辑
|
|
},
|
|
|
|
// 视图下拉菜单方法
|
|
toggle2D3D() {
|
|
this.$message.success('2D/3D切换');
|
|
// 这里可以添加2D/3D切换的逻辑
|
|
},
|
|
|
|
toggleRuler() {
|
|
this.$message.success('显示/隐藏标尺');
|
|
// 这里可以添加标尺显示/隐藏的逻辑
|
|
},
|
|
|
|
toggleGrid() {
|
|
this.$message.success('显示/隐藏网格');
|
|
// 这里可以添加网格显示/隐藏的逻辑
|
|
},
|
|
|
|
saveScale(scale) {
|
|
console.log('保存比例尺:', scale)
|
|
this.scaleConfig = {
|
|
scaleNumerator: scale.scaleNumerator,
|
|
scaleDenominator: scale.scaleDenominator,
|
|
unit: scale.unit
|
|
}
|
|
|
|
if (this.$refs.cesiumMap) {
|
|
this.$refs.cesiumMap.updateScaleFromConfig(this.scaleConfig)
|
|
}
|
|
|
|
const scaleText = `${scale.scaleNumerator}:${scale.scaleDenominator}`
|
|
this.$message.success(`比例尺 "${scaleText}" 保存成功`);
|
|
},
|
|
|
|
handleScaleClick(scaleInfo) {
|
|
this.currentScale = {
|
|
scaleNumerator: this.scaleConfig.scaleNumerator,
|
|
scaleDenominator: this.scaleConfig.scaleDenominator,
|
|
unit: this.scaleConfig.unit
|
|
}
|
|
this.showScaleDialog = true
|
|
},
|
|
|
|
/** 拖拽经过地图时允许放置 */
|
|
handleMapDragover(ev) {
|
|
ev.preventDefault()
|
|
if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'copy'
|
|
},
|
|
|
|
/** 将平台图标放置到地图上,并保存到当前房间 */
|
|
async handleMapDrop(ev) {
|
|
ev.preventDefault()
|
|
const raw = ev.dataTransfer && ev.dataTransfer.getData('application/json')
|
|
if (!raw) return
|
|
try {
|
|
const platform = JSON.parse(raw)
|
|
const map = this.$refs.cesiumMap
|
|
if (!map || typeof map.addPlatformIconFromDrag !== 'function') return
|
|
const entityData = map.addPlatformIconFromDrag(platform, ev.clientX, ev.clientY)
|
|
if (!entityData || !this.currentRoomId) return
|
|
const payload = {
|
|
roomId: this.currentRoomId,
|
|
platformId: platform.id,
|
|
platformName: platform.name || '',
|
|
platformType: platform.type || '',
|
|
iconUrl: platform.imageUrl || platform.iconUrl || '',
|
|
lng: entityData.lng,
|
|
lat: entityData.lat,
|
|
heading: entityData.heading != null ? entityData.heading : 0,
|
|
iconScale: entityData.iconScale != null ? entityData.iconScale : 1
|
|
}
|
|
const res = await addRoomPlatformIcon(payload)
|
|
if (res.code === 200 && res.data && res.data.id) {
|
|
map.setPlatformIconServerId(entityData.id, res.data.id, this.currentRoomId)
|
|
entityData.serverId = res.data.id
|
|
entityData.roomId = this.currentRoomId
|
|
this.$message.success('平台图标已保存到当前房间')
|
|
}
|
|
} catch (e) {
|
|
console.warn('Parse platform drag data or save failed', e)
|
|
this.$message && this.$message.error('保存平台图标失败')
|
|
}
|
|
},
|
|
/** 平台图标移动/旋转/缩放结束:防抖更新到服务端 */
|
|
onPlatformIconUpdated(entityData) {
|
|
if (!entityData || !entityData.serverId) return
|
|
if (this.platformIconSaveTimer) clearTimeout(this.platformIconSaveTimer)
|
|
this.platformIconSaveTimer = setTimeout(() => {
|
|
this.platformIconSaveTimer = null
|
|
updateRoomPlatformIcon({
|
|
id: entityData.serverId,
|
|
lng: entityData.lng,
|
|
lat: entityData.lat,
|
|
heading: entityData.heading != null ? entityData.heading : 0,
|
|
iconScale: entityData.iconScale != null ? entityData.iconScale : 1
|
|
}).then(() => {}).catch(() => {})
|
|
}, 500)
|
|
},
|
|
/** 平台图标从地图删除时同步删除服务端记录 */
|
|
onPlatformIconRemoved({ serverId }) {
|
|
if (!serverId) return
|
|
delRoomPlatformIcon(serverId).then(() => {}).catch(() => {})
|
|
},
|
|
|
|
handleScaleUnitChange(unit) {
|
|
this.scaleConfig.unit = unit
|
|
if (this.$refs.cesiumMap) {
|
|
this.$refs.cesiumMap.currentScaleUnit = unit
|
|
}
|
|
},
|
|
|
|
// 地图下拉菜单方法
|
|
loadTerrain() {
|
|
this.$message.success('加载/切换地形');
|
|
// 这里可以添加地形加载/切换的逻辑
|
|
},
|
|
|
|
changeProjection() {
|
|
this.$message.success('投影');
|
|
// 这里可以添加投影切换的逻辑
|
|
},
|
|
|
|
loadAeroChart() {
|
|
this.$message.success('航空图');
|
|
},
|
|
|
|
// 空域下拉菜单方法
|
|
startPowerZoneDrawing() {
|
|
this.airspaceDrawDom = true
|
|
this.$refs.cesiumMap.startPowerZoneDrawing()
|
|
},
|
|
|
|
threatZone() {
|
|
this.$message.success('威胁区');
|
|
},
|
|
|
|
// 工具下拉菜单方法
|
|
routeCalculation() {
|
|
this.$message.success('航线计算');
|
|
// 这里可以添加航线计算的逻辑
|
|
},
|
|
|
|
conflictDisplay() {
|
|
this.$message.success('冲突显示');
|
|
// 这里可以添加冲突显示的逻辑
|
|
},
|
|
|
|
dataMaterials() {
|
|
this.$message.success('数据资料');
|
|
// 这里可以添加数据资料管理的逻辑
|
|
},
|
|
|
|
coordinateConversion() {
|
|
this.coordinateFormat = this.coordinateFormat === 'decimal' ? 'dms' : 'decimal'
|
|
this.$message.success(`坐标格式已切换为:${this.coordinateFormat === 'decimal' ? '十进制' : '度分秒'}`)
|
|
},
|
|
|
|
// 选项下拉菜单方法
|
|
pageLayout() {
|
|
this.showPageLayoutDialog = true;
|
|
},
|
|
|
|
dataStoragePath() {
|
|
this.$message.success('数据存储路径');
|
|
},
|
|
|
|
saveExternalParams(externalParams) {
|
|
console.log('保存外部参数:', externalParams)
|
|
this.$message.success('外部参数保存成功');
|
|
},
|
|
|
|
async savePageLayout(position) {
|
|
this.menuPosition = position;
|
|
this.$message.success(`菜单位置已设置为:${this.getPositionLabel(position)}`);
|
|
try {
|
|
await saveMenuConfig({
|
|
menuItems: JSON.stringify(this.menuItems),
|
|
position: this.menuPosition || 'left'
|
|
})
|
|
} catch (e) { /* 未登录时仅本地生效 */ }
|
|
},
|
|
|
|
getPositionLabel(position) {
|
|
const labels = {
|
|
'top': '顶部',
|
|
'bottom': '底部',
|
|
'left': '左侧',
|
|
'right': '右侧'
|
|
};
|
|
return labels[position] || position;
|
|
},
|
|
|
|
importAirport(path) {
|
|
console.log('导入机场:', path)
|
|
this.$message.success('机场数据导入成功');
|
|
},
|
|
|
|
importRouteData(path) {
|
|
console.log('导入航路:', path)
|
|
this.$message.success('航路数据导入成功');
|
|
},
|
|
|
|
importLandmark(path) {
|
|
console.log('导入地标:', path)
|
|
this.$message.success('地标数据导入成功');
|
|
},
|
|
|
|
toggleAirport() {
|
|
this.showAirport = !this.showAirport;
|
|
this.$message.success(this.showAirport ? '显示机场' : '隐藏机场');
|
|
},
|
|
|
|
toggleLandmark() {
|
|
this.showLandmark = !this.showLandmark;
|
|
this.$message.success(this.showLandmark ? '显示地标' : '隐藏地标');
|
|
},
|
|
|
|
toggleRoute() {
|
|
this.showRoute = !this.showRoute;
|
|
this.$message.success(this.showRoute ? '显示航线' : '隐藏航线');
|
|
},
|
|
|
|
generateGanttChart() {
|
|
const url = this.$router.resolve('/ganttChart').href
|
|
window.open(url, '_blank')
|
|
},
|
|
|
|
systemDescription() {
|
|
this.$message.success('系统说明');
|
|
// 这里可以添加系统说明的逻辑
|
|
},
|
|
|
|
// 收藏下拉菜单方法
|
|
layerFavorites() {
|
|
this.$message.success('图层收藏');
|
|
// 这里可以添加图层收藏的逻辑
|
|
},
|
|
|
|
routeFavorites() {
|
|
this.$message.success('航线收藏');
|
|
},
|
|
|
|
|
|
hideRightPanel() {
|
|
this.isRightPanelHidden = true;
|
|
this.$message.info('隐藏右侧面板');
|
|
},
|
|
|
|
selectMenu(item) {
|
|
this.activeMenu = item.id;
|
|
if (item.action) {
|
|
this.handleMenuAction(item.action)
|
|
}
|
|
|
|
// 点击方案、平台、冲突等菜单项时,停止地图绘制状态
|
|
if (item.id === 'file' || item.id === 'start' || item.id === 'insert') {
|
|
this.drawDom = false;
|
|
this.airspaceDrawDom = false;
|
|
}
|
|
|
|
// 点击左侧的方案、冲突、平台时,切换右侧面板内容
|
|
if (item.id === 'file') {
|
|
// 如果当前已经是方案标签页,则关闭右侧面板
|
|
if (this.activeRightTab === 'plan' && !this.isRightPanelHidden) {
|
|
this.isRightPanelHidden = true;
|
|
} else {
|
|
this.activeRightTab = 'plan';
|
|
this.isRightPanelHidden = false;
|
|
}
|
|
} else if (item.id === 'start') {
|
|
// 如果当前已经是冲突标签页,则关闭右侧面板
|
|
if (this.activeRightTab === 'conflict' && !this.isRightPanelHidden) {
|
|
this.isRightPanelHidden = true;
|
|
} else {
|
|
this.activeRightTab = 'conflict';
|
|
this.isRightPanelHidden = false;
|
|
}
|
|
} else if (item.id === 'insert') {
|
|
// 如果当前已经是平台标签页,则关闭右侧面板
|
|
if (this.activeRightTab === 'platform' && !this.isRightPanelHidden) {
|
|
this.isRightPanelHidden = true;
|
|
} else {
|
|
this.activeRightTab = 'platform';
|
|
this.isRightPanelHidden = false;
|
|
}
|
|
} else if (item.id === 'modify') {
|
|
// 点击测距时,启用测距绘制模式,关闭空域绘制模式
|
|
this.drawDom = !this.drawDom
|
|
this.airspaceDrawDom = false
|
|
// 点击测距图标进行地图绘制时,自动收起右侧面板
|
|
this.isRightPanelHidden = true;
|
|
console.log('测距绘制模式:', this.drawDom, 999999)
|
|
} else if (item.id === 'pattern') {
|
|
// 点击空域时,启用空域绘制模式,关闭测距绘制模式
|
|
this.airspaceDrawDom = !this.airspaceDrawDom
|
|
this.drawDom = false
|
|
// 点击空域图标进行地图绘制时,自动收起右侧面板
|
|
this.isRightPanelHidden = true;
|
|
console.log('空域绘制模式:', this.airspaceDrawDom, 999999)
|
|
} else if (item.id === 'deduction') {
|
|
// 点击推演按钮,显示/隐藏K时弹出框
|
|
this.showKTimePopup = !this.showKTimePopup;
|
|
if (this.showKTimePopup) {
|
|
this.$nextTick(() => this.updateTimeFromProgress());
|
|
}
|
|
// 点击推演时,也停止地图绘制状态
|
|
this.drawDom = false;
|
|
this.airspaceDrawDom = false;
|
|
} else {
|
|
// 点击其他菜单项时,也自动收起右侧面板
|
|
this.isRightPanelHidden = true;
|
|
// 点击其他菜单项时,也停止地图绘制状态
|
|
this.drawDom = false;
|
|
this.airspaceDrawDom = false;
|
|
}
|
|
},
|
|
|
|
// K时弹出框操作
|
|
hideKTimePopup() {
|
|
this.showKTimePopup = false;
|
|
this.$message.info('隐藏推演时钟控制');
|
|
},
|
|
|
|
// 播放控制
|
|
togglePlay() {
|
|
this.isPlaying = !this.isPlaying;
|
|
if (this.isPlaying) {
|
|
this.startPlayback();
|
|
this.$message.success('开始播放');
|
|
} else {
|
|
this.stopPlayback();
|
|
this.$message.info('暂停播放');
|
|
}
|
|
},
|
|
|
|
startPlayback() {
|
|
if (this.playbackInterval) {
|
|
clearInterval(this.playbackInterval);
|
|
}
|
|
this.playbackInterval = setInterval(() => {
|
|
this.timeProgress += this.playbackSpeed * 0.1;
|
|
if (this.timeProgress >= 100) {
|
|
this.timeProgress = 0;
|
|
}
|
|
// 时间显示与平台位置由 watch timeProgress 触发 updateTimeFromProgress
|
|
}, 100);
|
|
},
|
|
|
|
stopPlayback() {
|
|
if (this.playbackInterval) {
|
|
clearInterval(this.playbackInterval);
|
|
this.playbackInterval = null;
|
|
}
|
|
},
|
|
|
|
increaseSpeed() {
|
|
if (this.playbackSpeed < 25) {
|
|
this.playbackSpeed++;
|
|
if (this.isPlaying) {
|
|
this.startPlayback();
|
|
}
|
|
}
|
|
},
|
|
|
|
decreaseSpeed() {
|
|
if (this.playbackSpeed > 1) {
|
|
this.playbackSpeed--;
|
|
if (this.isPlaying) {
|
|
this.startPlayback();
|
|
}
|
|
}
|
|
},
|
|
|
|
updateTimeFromProgress() {
|
|
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
|
|
const span = Math.max(0, maxMinutes - minMinutes) || 120;
|
|
const currentMinutesFromK = minMinutes + (this.timeProgress / 100) * span;
|
|
this.deductionMinutesFromK = currentMinutesFromK;
|
|
|
|
const sign = currentMinutesFromK >= 0 ? '+' : '-';
|
|
const absMin = Math.abs(Math.floor(currentMinutesFromK));
|
|
const hours = Math.floor(absMin / 60);
|
|
const minutes = absMin % 60;
|
|
this.currentTime = `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
|
|
// 右上角作战时间随时与推演时间轴同步(无论是否展示航线)
|
|
this.combatTime = this.currentTime;
|
|
this.updateDeductionPositions();
|
|
},
|
|
|
|
/** 仅针对当前展示的航线(activeRouteIds):从这些航线的航点中取推演时间范围(相对 K 的分钟数) */
|
|
getDeductionTimeRange() {
|
|
let minMinutes = 0;
|
|
let maxMinutes = 120;
|
|
const minutesList = [];
|
|
this.activeRouteIds.forEach(routeId => {
|
|
const route = this.routes.find(r => r.id === routeId);
|
|
if (!route || !route.waypoints || !route.waypoints.length) return;
|
|
route.waypoints.forEach(wp => {
|
|
const m = this.waypointStartTimeToMinutes(wp.startTime);
|
|
minutesList.push(m);
|
|
});
|
|
});
|
|
if (minutesList.length > 0) {
|
|
minMinutes = Math.min(...minutesList);
|
|
maxMinutes = Math.max(...minutesList);
|
|
if (maxMinutes <= minMinutes) maxMinutes = minMinutes + 120;
|
|
}
|
|
return { minMinutes, maxMinutes };
|
|
},
|
|
|
|
/** 将航点 startTime 字符串转为相对 K 的分钟数 */
|
|
waypointStartTimeToMinutes(s) {
|
|
if (!s || typeof s !== 'string') return 0;
|
|
const m = s.match(/K([+-])(\d{2}):(\d{2})/);
|
|
if (!m) return 0;
|
|
const sign = m[1] === '+' ? 1 : -1;
|
|
const h = parseInt(m[2], 10);
|
|
const min = parseInt(m[3], 10);
|
|
return sign * (h * 60 + min);
|
|
},
|
|
/** 将相对 K 的分钟数转为 startTime 字符串(如 K+01:00、K-00:30) */
|
|
minutesToStartTime(minutes) {
|
|
const m = Math.floor(Number(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 = abs % 60;
|
|
return `K${sign}${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
|
|
},
|
|
|
|
isHoldWaypoint(wp) {
|
|
const t = (wp && wp.pointType) || (wp && wp.point_type) || 'normal';
|
|
return t === 'hold_circle' || t === 'hold_ellipse';
|
|
},
|
|
parseHoldParams(wp) {
|
|
const raw = (wp && wp.holdParams) || (wp && wp.hold_params);
|
|
if (!raw) return null;
|
|
try {
|
|
const p = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
return { radius: p.radius, semiMajor: p.semiMajor ?? p.semiMajorAxis, semiMinor: p.semiMinor ?? p.semiMinorAxis, headingDeg: p.headingDeg ?? 0, clockwise: p.clockwise !== false };
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/** 路径片段总距离(米) */
|
|
pathSliceDistance(pathSlice) {
|
|
if (!pathSlice || pathSlice.length < 2) return 0;
|
|
let d = 0;
|
|
for (let i = 1; i < pathSlice.length; i++) d += this.segmentDistance(pathSlice[i - 1], pathSlice[i]);
|
|
return d;
|
|
},
|
|
|
|
/** 圆周上按角度取点:圆心 lng/lat/alt,半径米。angleRad 为从北顺时针的角度弧度,0=北 */
|
|
positionOnCircle(centerLng, centerLat, centerAlt, radiusM, angleRad) {
|
|
const R = 6371000;
|
|
const latRad = (centerLat * Math.PI) / 180;
|
|
const dNorth = radiusM * Math.cos(angleRad);
|
|
const dEast = radiusM * Math.sin(angleRad);
|
|
const dLat = (dNorth / R) * (180 / Math.PI);
|
|
const dLng = (dEast / (R * Math.cos(latRad))) * (180 / Math.PI);
|
|
return {
|
|
lng: centerLng + dLng,
|
|
lat: centerLat + dLat,
|
|
alt: centerAlt != null ? centerAlt : 0
|
|
};
|
|
},
|
|
/** 从圆心到某点的角度(弧度,从北顺时针),用于盘旋入口基准角 */
|
|
angleFromCenterToPoint(centerLng, centerLat, pointLng, pointLat) {
|
|
const R = 6371000;
|
|
const latRad = (centerLat * Math.PI) / 180;
|
|
const dNorth = (pointLat - centerLat) * (R * Math.PI / 180);
|
|
const dEast = (pointLng - centerLng) * (R * Math.cos(latRad) * Math.PI / 180);
|
|
return Math.atan2(dEast, dNorth);
|
|
},
|
|
|
|
/** 两航点间近似距离(米),含高度差 */
|
|
segmentDistance(wp1, wp2) {
|
|
const R = 6371000;
|
|
const lat1 = (wp1.lat * Math.PI) / 180;
|
|
const lat2 = (wp2.lat * Math.PI) / 180;
|
|
const dlat = ((wp2.lat - wp1.lat) * Math.PI) / 180;
|
|
const dlng = ((wp2.lng - wp1.lng) * Math.PI) / 180;
|
|
const a = Math.sin(dlat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dlng / 2) ** 2;
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
const horizontal = R * c;
|
|
const dalt = (wp2.alt || 0) - (wp1.alt || 0);
|
|
return Math.sqrt(horizontal * horizontal + dalt * dalt);
|
|
},
|
|
|
|
/**
|
|
* 按速度与计划时间构建航线时间轴:含飞行段、盘旋段与“提前到达则等待”的等待段。
|
|
* pathData 可选:{ path, segmentEndIndices, holdArcRanges },由 getRoutePathWithSegmentIndices 提供,用于输出 hold 段。
|
|
*/
|
|
buildRouteTimeline(waypoints, globalMin, globalMax, pathData) {
|
|
const warnings = [];
|
|
if (!waypoints || waypoints.length === 0) return { segments: [], warnings };
|
|
const points = waypoints.map((wp, idx) => ({
|
|
lng: parseFloat(wp.lng),
|
|
lat: parseFloat(wp.lat),
|
|
alt: Number(wp.alt) || 0,
|
|
minutes: this.waypointStartTimeToMinutes(wp.startTime),
|
|
speed: Number(wp.speed) || 800,
|
|
isHold: this.isHoldWaypoint(wp)
|
|
}));
|
|
const hasHold = points.some(p => p.isHold);
|
|
const allSame = points.every(p => p.minutes === points[0].minutes);
|
|
if (allSame && points.length > 1 && !hasHold) {
|
|
const span = Math.max(globalMax - globalMin, 1);
|
|
points.forEach((p, i) => {
|
|
p.minutes = globalMin + (span * i) / (points.length - 1);
|
|
});
|
|
} else if (!hasHold) {
|
|
points.sort((a, b) => a.minutes - b.minutes);
|
|
}
|
|
if (points.length === 1) {
|
|
const p = points[0];
|
|
const pos = { lng: p.lng, lat: p.lat, alt: p.alt };
|
|
return {
|
|
segments: [{ startTime: globalMin, endTime: globalMax, startPos: pos, endPos: pos, type: 'wait' }],
|
|
warnings,
|
|
earlyArrivalLegs: [],
|
|
lateArrivalLegs: []
|
|
};
|
|
}
|
|
const effectiveTime = [points[0].minutes];
|
|
const segments = [];
|
|
const lateArrivalLegs = []; // 无法按时到达的航段,供冲突检测用
|
|
const path = pathData && pathData.path;
|
|
const segmentEndIndices = pathData && pathData.segmentEndIndices;
|
|
const holdArcRanges = pathData && pathData.holdArcRanges || {};
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
if (this.isHoldWaypoint(waypoints[i + 1]) && path && segmentEndIndices && holdArcRanges[i]) {
|
|
const range = holdArcRanges[i];
|
|
const startIdx = i === 0 ? 0 : segmentEndIndices[i - 1] + 1;
|
|
const toEntrySlice = path.slice(startIdx, range.start + 1);
|
|
const holdPathSlice = path.slice(range.start, range.end + 1);
|
|
const exitIdx = segmentEndIndices[i];
|
|
const toNextSlice = path.slice(exitIdx, (segmentEndIndices[i + 1] != null ? segmentEndIndices[i + 1] : path.length - 1) + 1);
|
|
const distToEntry = this.pathSliceDistance(toEntrySlice);
|
|
const speedKmh = points[i].speed || 800;
|
|
const travelToEntryMin = (distToEntry / 1000) * (60 / speedKmh);
|
|
const arrivalEntry = effectiveTime[i] + travelToEntryMin;
|
|
const holdEndTime = points[i + 1].minutes;
|
|
const distExitToNext = this.pathSliceDistance(toNextSlice);
|
|
const travelExitMin = (distExitToNext / 1000) * (60 / speedKmh);
|
|
const arrivalNext = holdEndTime + travelExitMin;
|
|
effectiveTime[i + 1] = holdEndTime;
|
|
if (i + 2 < points.length) effectiveTime[i + 2] = arrivalNext;
|
|
const posCur = { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt };
|
|
const entryPos = toEntrySlice.length ? toEntrySlice[toEntrySlice.length - 1] : posCur;
|
|
const exitPos = holdPathSlice.length ? holdPathSlice[holdPathSlice.length - 1] : entryPos;
|
|
const holdDurationMin = holdEndTime - arrivalEntry;
|
|
const holdWp = waypoints[i + 1];
|
|
const holdParams = this.parseHoldParams(holdWp);
|
|
const holdCenter = holdWp ? { lng: parseFloat(holdWp.lng), lat: parseFloat(holdWp.lat), alt: Number(holdWp.alt) || 0 } : null;
|
|
const holdRadius = holdParams && holdParams.radius != null ? holdParams.radius : null;
|
|
const holdClockwise = holdParams && holdParams.clockwise !== false;
|
|
const holdCircumference = holdRadius != null ? 2 * Math.PI * holdRadius : null;
|
|
const holdEntryAngle = holdCenter && entryPos && holdRadius != null
|
|
? this.angleFromCenterToPoint(holdCenter.lng, holdCenter.lat, entryPos.lng, entryPos.lat)
|
|
: null;
|
|
segments.push({ startTime: effectiveTime[i], endTime: arrivalEntry, startPos: posCur, endPos: entryPos, type: 'fly', legIndex: i, pathSlice: toEntrySlice });
|
|
segments.push({
|
|
startTime: arrivalEntry,
|
|
endTime: holdEndTime,
|
|
startPos: entryPos,
|
|
endPos: exitPos,
|
|
type: 'hold',
|
|
legIndex: i,
|
|
holdPath: holdPathSlice,
|
|
holdDurationMin,
|
|
speedKmh: points[i].speed || 800,
|
|
holdCenter,
|
|
holdRadius,
|
|
holdCircumference,
|
|
holdClockwise,
|
|
holdEntryAngle
|
|
});
|
|
segments.push({ startTime: holdEndTime, endTime: arrivalNext, startPos: exitPos, endPos: toNextSlice.length ? toNextSlice[toNextSlice.length - 1] : exitPos, type: 'fly', legIndex: i, pathSlice: toNextSlice });
|
|
i++;
|
|
continue;
|
|
}
|
|
const dist = this.segmentDistance(points[i], points[i + 1]);
|
|
const speedKmh = points[i].speed || 800;
|
|
const travelMin = (dist / 1000) * (60 / speedKmh);
|
|
const actualArrival = effectiveTime[i] + travelMin;
|
|
const scheduled = points[i + 1].minutes;
|
|
if (travelMin > 0 && scheduled - points[i].minutes > 0) {
|
|
const requiredSpeedKmh = (dist / 1000) / ((scheduled - points[i].minutes) / 60);
|
|
if (actualArrival > scheduled) {
|
|
warnings.push(
|
|
`某航段:距离约 ${(dist / 1000).toFixed(1)}km,计划 ${(scheduled - points[i].minutes).toFixed(0)} 分钟,当前速度 ${speedKmh}km/h 无法按时到达,约需 ≥${Math.ceil(requiredSpeedKmh)}km/h,请调整相对K时或速度。`
|
|
);
|
|
lateArrivalLegs.push({
|
|
legIndex: i,
|
|
fromName: waypoints[i].name,
|
|
toName: waypoints[i + 1].name,
|
|
requiredSpeedKmh: Math.ceil(requiredSpeedKmh),
|
|
speedKmh
|
|
});
|
|
} else if (actualArrival < scheduled - 0.5) {
|
|
warnings.push('存在航段将提前到达下一航点,平台将在该点等待至计划时间再飞往下一段。');
|
|
}
|
|
}
|
|
effectiveTime[i + 1] = Math.max(actualArrival, scheduled);
|
|
const posCur = { lng: points[i].lng, lat: points[i].lat, alt: points[i].alt };
|
|
const posNext = { lng: points[i + 1].lng, lat: points[i + 1].lat, alt: points[i + 1].alt };
|
|
segments.push({ startTime: effectiveTime[i], endTime: actualArrival, startPos: posCur, endPos: posNext, type: 'fly', legIndex: i });
|
|
if (actualArrival < effectiveTime[i + 1]) {
|
|
segments.push({ startTime: actualArrival, endTime: effectiveTime[i + 1], startPos: posNext, endPos: posNext, type: 'wait', legIndex: i });
|
|
}
|
|
}
|
|
const earlyArrivalLegs = [];
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
if (this.isHoldWaypoint(waypoints[i + 1])) continue;
|
|
const dist = this.segmentDistance(points[i], points[i + 1]);
|
|
const speedKmh = points[i].speed || 800;
|
|
const travelMin = (dist / 1000) * (60 / speedKmh);
|
|
const actualArrival = effectiveTime[i] + travelMin;
|
|
const scheduled = points[i + 1].minutes;
|
|
if (travelMin > 0 && scheduled - points[i].minutes > 0 && actualArrival < scheduled - 0.5) {
|
|
earlyArrivalLegs.push({ legIndex: i, scheduled, actualArrival, fromName: waypoints[i].name, toName: waypoints[i + 1].name });
|
|
}
|
|
}
|
|
return { segments, warnings, earlyArrivalLegs, lateArrivalLegs };
|
|
},
|
|
|
|
/** 从路径片段中按比例 t(0~1)取点:按弧长比例插值 */
|
|
getPositionAlongPathSlice(pathSlice, t) {
|
|
if (!pathSlice || pathSlice.length === 0) return null;
|
|
if (pathSlice.length === 1 || t <= 0) return pathSlice[0];
|
|
if (t >= 1) return pathSlice[pathSlice.length - 1];
|
|
let totalLen = 0;
|
|
const lengths = [0];
|
|
for (let i = 1; i < pathSlice.length; i++) {
|
|
totalLen += this.segmentDistance(pathSlice[i - 1], pathSlice[i]);
|
|
lengths.push(totalLen);
|
|
}
|
|
const targetDist = t * totalLen;
|
|
let idx = 0;
|
|
while (idx < lengths.length - 1 && lengths[idx + 1] < targetDist) idx++;
|
|
const a = pathSlice[idx];
|
|
const b = pathSlice[idx + 1];
|
|
const segLen = lengths[idx + 1] - lengths[idx];
|
|
const segT = segLen > 0 ? (targetDist - lengths[idx]) / segLen : 0;
|
|
return {
|
|
lng: a.lng + (b.lng - a.lng) * segT,
|
|
lat: a.lat + (b.lat - a.lat) * segT,
|
|
alt: a.alt + (b.alt - a.alt) * segT
|
|
};
|
|
},
|
|
|
|
/** 从时间轴中取当前推演时间对应的位置;支持 fly/wait/hold,hold 沿 holdPath 弧长插值 */
|
|
getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices) {
|
|
if (!segments || segments.length === 0) return null;
|
|
if (minutesFromK <= segments[0].startTime) return segments[0].startPos;
|
|
const last = segments[segments.length - 1];
|
|
if (minutesFromK >= last.endTime) {
|
|
if (last.type === 'wait' && path && segmentEndIndices && last.legIndex != null && last.legIndex < segmentEndIndices.length && path[segmentEndIndices[last.legIndex]]) {
|
|
return path[segmentEndIndices[last.legIndex]];
|
|
}
|
|
if (last.type === 'hold' && last.holdPath && last.holdPath.length) return last.holdPath[last.holdPath.length - 1];
|
|
return last.endPos;
|
|
}
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const s = segments[i];
|
|
if (minutesFromK < s.endTime) {
|
|
const t = Math.max(0, Math.min(1, (minutesFromK - s.startTime) / (s.endTime - s.startTime)));
|
|
if (s.type === 'wait') {
|
|
if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) {
|
|
const endIdx = segmentEndIndices[s.legIndex];
|
|
if (path[endIdx]) return path[endIdx];
|
|
}
|
|
return s.startPos;
|
|
}
|
|
if (s.type === 'hold' && s.holdPath && s.holdPath.length) {
|
|
const durationMin = s.holdDurationMin != null ? s.holdDurationMin : (s.endTime - s.startTime);
|
|
const speedKmh = s.speedKmh != null ? s.speedKmh : 800;
|
|
const totalHoldDistM = (speedKmh * (durationMin / 60)) * 1000;
|
|
if (s.holdCircumference != null && s.holdCircumference > 0 && s.holdCenter && s.holdRadius != null) {
|
|
const currentDistM = t * totalHoldDistM;
|
|
const distOnLap = currentDistM % s.holdCircumference;
|
|
const angleRad = (distOnLap / s.holdCircumference) * (2 * Math.PI);
|
|
const signedAngle = s.holdClockwise ? -angleRad : angleRad;
|
|
const entryAngle = s.holdEntryAngle != null ? s.holdEntryAngle : 0;
|
|
const angle = entryAngle + signedAngle;
|
|
return this.positionOnCircle(s.holdCenter.lng, s.holdCenter.lat, s.holdCenter.alt, s.holdRadius, angle);
|
|
}
|
|
const holdPathLen = this.pathSliceDistance(s.holdPath);
|
|
if (holdPathLen <= 0) return this.getPositionAlongPathSlice(s.holdPath, t);
|
|
const currentDistM = t * totalHoldDistM;
|
|
const positionOnLap = currentDistM % holdPathLen;
|
|
const tLap = holdPathLen > 0 ? positionOnLap / holdPathLen : 0;
|
|
return this.getPositionAlongPathSlice(s.holdPath, tLap);
|
|
}
|
|
if (s.type === 'fly' && s.pathSlice && s.pathSlice.length) {
|
|
return this.getPositionAlongPathSlice(s.pathSlice, t);
|
|
}
|
|
if (path && segmentEndIndices && s.legIndex != null && s.legIndex < segmentEndIndices.length) {
|
|
const startIdx = s.legIndex === 0 ? 0 : segmentEndIndices[s.legIndex - 1];
|
|
const endIdx = segmentEndIndices[s.legIndex];
|
|
const pathSlice = path.slice(startIdx, endIdx + 1);
|
|
if (pathSlice.length > 0) return this.getPositionAlongPathSlice(pathSlice, t);
|
|
}
|
|
return {
|
|
lng: s.startPos.lng + (s.endPos.lng - s.startPos.lng) * t,
|
|
lat: s.startPos.lat + (s.endPos.lat - s.startPos.lat) * t,
|
|
alt: s.startPos.alt + (s.endPos.alt - s.startPos.alt) * t
|
|
};
|
|
}
|
|
}
|
|
return last.endPos;
|
|
},
|
|
|
|
/** 根据当前推演时间(相对 K 的分钟)得到平台位置,使用速度与等待逻辑;若有地图 ref 则沿转弯弧/盘旋弧路径运动;返回 { position, nextPosition, previousPosition, warnings, earlyArrivalLegs, currentSegment },currentSegment 含 speedKmh 用于标牌 */
|
|
getPositionAtMinutesFromK(waypoints, minutesFromK, globalMin, globalMax) {
|
|
if (!waypoints || waypoints.length === 0) return { position: null, nextPosition: null, previousPosition: null, warnings: [], earlyArrivalLegs: [], currentSegment: null };
|
|
let pathData = null;
|
|
if (this.$refs.cesiumMap && this.$refs.cesiumMap.getRoutePathWithSegmentIndices) {
|
|
const ret = this.$refs.cesiumMap.getRoutePathWithSegmentIndices(waypoints);
|
|
if (ret.path && ret.path.length > 0 && ret.segmentEndIndices && ret.segmentEndIndices.length > 0) {
|
|
pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} };
|
|
}
|
|
}
|
|
const { segments, warnings, earlyArrivalLegs } = this.buildRouteTimeline(waypoints, globalMin, globalMax, pathData);
|
|
const path = pathData ? pathData.path : null;
|
|
const segmentEndIndices = pathData ? pathData.segmentEndIndices : null;
|
|
const position = this.getPositionFromTimeline(segments, minutesFromK, path, segmentEndIndices);
|
|
const stepMin = 1 / 60;
|
|
const nextPosition = this.getPositionFromTimeline(segments, minutesFromK + stepMin, path, segmentEndIndices);
|
|
const previousPosition = this.getPositionFromTimeline(segments, minutesFromK - stepMin, path, segmentEndIndices);
|
|
// 当前所在航段,用于标牌速度(与数据库航点 speed 对应)
|
|
let currentSegment = null;
|
|
if (segments && segments.length > 0) {
|
|
if (minutesFromK <= segments[0].startTime) {
|
|
const s = segments[0];
|
|
currentSegment = { legIndex: s.legIndex, speedKmh: waypoints[s.legIndex] ? (Number(waypoints[s.legIndex].speed) || 800) : 800 };
|
|
} else if (minutesFromK >= segments[segments.length - 1].endTime) {
|
|
const s = segments[segments.length - 1];
|
|
currentSegment = { legIndex: s.legIndex, speedKmh: s.speedKmh != null ? s.speedKmh : (waypoints[s.legIndex] ? (Number(waypoints[s.legIndex].speed) || 800) : 800) };
|
|
} else {
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const s = segments[i];
|
|
if (minutesFromK >= s.startTime && minutesFromK < s.endTime) {
|
|
currentSegment = { legIndex: s.legIndex, speedKmh: s.speedKmh != null ? s.speedKmh : (waypoints[s.legIndex] ? (Number(waypoints[s.legIndex].speed) || 800) : 800) };
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { position, nextPosition, previousPosition, warnings, earlyArrivalLegs: earlyArrivalLegs || [], currentSegment };
|
|
},
|
|
|
|
/** 根据两点计算航向角(度),北为 0,顺时针为正,与数据库/标牌航向一致 */
|
|
headingDegFromPositions(fromPos, toPos) {
|
|
if (!fromPos || !toPos) return 0;
|
|
const dLng = (toPos.lng != null ? Number(toPos.lng) : 0) - (fromPos.lng != null ? Number(fromPos.lng) : 0);
|
|
const dLat = (toPos.lat != null ? Number(toPos.lat) : 0) - (fromPos.lat != null ? Number(fromPos.lat) : 0);
|
|
if (Math.abs(dLng) < 1e-10 && Math.abs(dLat) < 1e-10) return 0;
|
|
const rad = Math.atan2(dLng, dLat);
|
|
let deg = (rad * 180 / Math.PI);
|
|
return ((deg % 360) + 360) % 360;
|
|
},
|
|
|
|
/** 仅根据当前展示的航线(activeRouteIds)更新平台图标位置与标牌,并汇总航段提示 */
|
|
updateDeductionPositions() {
|
|
if (!this.$refs.cesiumMap || !this.$refs.cesiumMap.updatePlatformPosition) return;
|
|
const minutesFromK = this.deductionMinutesFromK != null ? this.deductionMinutesFromK : 0;
|
|
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
|
|
const allWarnings = [];
|
|
this.activeRouteIds.forEach(routeId => {
|
|
const route = this.routes.find(r => r.id === routeId);
|
|
if (!route || !route.waypoints || route.waypoints.length === 0) return;
|
|
const { position, nextPosition, previousPosition, warnings, earlyArrivalLegs, currentSegment } = this.getPositionAtMinutesFromK(route.waypoints, minutesFromK, minMinutes, maxMinutes);
|
|
if (warnings && warnings.length) allWarnings.push(...warnings);
|
|
if (position) {
|
|
const directionPoint = nextPosition || previousPosition;
|
|
const labelData = {
|
|
name: (route.platform && route.platform.name) ? route.platform.name : '平台',
|
|
altitude: position.alt != null ? Number(position.alt) : 0,
|
|
speed: (currentSegment && currentSegment.speedKmh != null) ? currentSegment.speedKmh : 800,
|
|
headingDeg: directionPoint ? this.headingDegFromPositions(position, directionPoint) : 0
|
|
};
|
|
this.$refs.cesiumMap.updatePlatformPosition(routeId, position, directionPoint, labelData);
|
|
}
|
|
this.deductionEarlyArrivalByRoute[routeId] = earlyArrivalLegs || [];
|
|
});
|
|
this.deductionWarnings = [...new Set(allWarnings)];
|
|
},
|
|
|
|
openAddHoldFromFirstEarly() {
|
|
for (const routeId of this.activeRouteIds) {
|
|
const legs = this.deductionEarlyArrivalByRoute[routeId] || [];
|
|
if (legs.length === 0) continue;
|
|
const route = this.routes.find(r => r.id === routeId);
|
|
const leg = legs[0];
|
|
const waypoints = route && route.waypoints ? route.waypoints : [];
|
|
const nextWp = waypoints[leg.legIndex + 1];
|
|
this.addHoldContext = {
|
|
routeId,
|
|
routeName: route ? route.name : '',
|
|
legIndex: leg.legIndex,
|
|
fromName: leg.fromName,
|
|
toName: leg.toName
|
|
};
|
|
const defaultStart = nextWp && nextWp.startTime ? nextWp.startTime : 'K+01:00';
|
|
this.addHoldForm.startTime = defaultStart;
|
|
this.addHoldForm.startTimeMinutes = this.waypointStartTimeToMinutes(defaultStart);
|
|
this.showAddHoldDialog = true;
|
|
return;
|
|
}
|
|
},
|
|
|
|
async saveAddHold() {
|
|
if (!this.addHoldContext) return;
|
|
if (this.addHoldContext.mode === 'drawing') {
|
|
const holdParams = this.addHoldForm.holdType === 'hold_circle'
|
|
? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise }
|
|
: { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise };
|
|
if (this.$refs.cesiumMap && this.$refs.cesiumMap.insertHoldBetweenLastTwo) {
|
|
this.$refs.cesiumMap.insertHoldBetweenLastTwo(holdParams);
|
|
}
|
|
this.showAddHoldDialog = false;
|
|
this.addHoldContext = null;
|
|
this.$message.success('已插入盘旋,继续点选航点后右键结束保存');
|
|
return;
|
|
}
|
|
const { routeId, legIndex } = this.addHoldContext;
|
|
const route = this.routes.find(r => r.id === routeId);
|
|
if (!route || !route.waypoints || route.waypoints.length < 2) {
|
|
this.$message.warning('航线数据异常');
|
|
return;
|
|
}
|
|
const waypoints = route.waypoints;
|
|
const prevWp = waypoints[legIndex];
|
|
const nextWp = waypoints[legIndex + 1];
|
|
const newSeq = (prevWp.seq != null ? Number(prevWp.seq) : legIndex + 1) + 1;
|
|
const baseSeq = prevWp.seq != null ? Number(prevWp.seq) : legIndex + 1;
|
|
const holdParams = this.addHoldForm.holdType === 'hold_circle'
|
|
? { radius: this.addHoldForm.radius, clockwise: this.addHoldForm.clockwise }
|
|
: { semiMajor: this.addHoldForm.semiMajor, semiMinor: this.addHoldForm.semiMinor, headingDeg: this.addHoldForm.headingDeg, clockwise: this.addHoldForm.clockwise };
|
|
const startTime = this.addHoldForm.startTimeMinutes !== '' && this.addHoldForm.startTimeMinutes != null && !Number.isNaN(Number(this.addHoldForm.startTimeMinutes))
|
|
? this.minutesToStartTime(Number(this.addHoldForm.startTimeMinutes))
|
|
: (nextWp.startTime || 'K+01:00');
|
|
try {
|
|
await addWaypoints({
|
|
routeId,
|
|
name: 'HOLD',
|
|
seq: newSeq,
|
|
lat: nextWp.lat,
|
|
lng: nextWp.lng,
|
|
alt: nextWp.alt != null ? nextWp.alt : prevWp.alt,
|
|
speed: prevWp.speed || 800,
|
|
startTime,
|
|
pointType: this.addHoldForm.holdType,
|
|
holdParams: JSON.stringify(holdParams)
|
|
});
|
|
await delWaypoints(nextWp.id);
|
|
for (let i = legIndex + 2; i < waypoints.length; i++) {
|
|
const w = waypoints[i];
|
|
if (w.id) {
|
|
await updateWaypoints({ ...w, seq: baseSeq + (i - legIndex) });
|
|
}
|
|
}
|
|
this.showAddHoldDialog = false;
|
|
this.addHoldContext = null;
|
|
await this.getList();
|
|
const updated = this.routes.find(r => r.id === routeId);
|
|
if (updated && updated.waypoints && this.activeRouteIds.includes(routeId) && this.$refs.cesiumMap) {
|
|
const roomId = this.currentRoomId;
|
|
if (roomId && updated.platformId) {
|
|
try {
|
|
const styleRes = await getPlatformStyle({ roomId, routeId, platformId: updated.platformId });
|
|
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(routeId, styleRes.data);
|
|
} catch (_) {}
|
|
}
|
|
this.$refs.cesiumMap.removeRouteById(routeId);
|
|
this.$refs.cesiumMap.renderRouteWaypoints(updated.waypoints, routeId, updated.platformId, updated.platform, this.parseRouteStyle(updated.attributes));
|
|
this.$nextTick(() => this.updateDeductionPositions());
|
|
}
|
|
this.$message.success('已添加盘旋航点');
|
|
} catch (e) {
|
|
this.$message.error(e.msg || '添加盘旋失败');
|
|
console.error(e);
|
|
}
|
|
},
|
|
|
|
// 时间控制(保留用于底部时间轴)
|
|
play() {
|
|
this.$message.success('推演开始');
|
|
},
|
|
|
|
pause() {
|
|
this.$message.info('推演暂停');
|
|
},
|
|
|
|
reset() {
|
|
this.timeProgress = 0;
|
|
this.currentTime = 'K+00:00:00';
|
|
this.$message.info('推演已重置');
|
|
},
|
|
|
|
formatTimeTooltip(val) {
|
|
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
|
|
const span = Math.max(0, maxMinutes - minMinutes) || 120;
|
|
const minutesFromK = minMinutes + (val / 100) * span;
|
|
const sign = minutesFromK >= 0 ? '+' : '-';
|
|
const absMin = Math.abs(Math.floor(minutesFromK));
|
|
const hours = Math.floor(absMin / 60);
|
|
const minutes = absMin % 60;
|
|
return `K${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
|
|
},
|
|
selectPlan(plan) {
|
|
if (plan && plan.id) {
|
|
this.selectedPlanId = plan.id;
|
|
this.selectedPlanDetails = plan;
|
|
} else {
|
|
this.selectedPlanId = null;
|
|
this.selectedPlanDetails = null;
|
|
}
|
|
|
|
// 重置状态
|
|
this.selectedRouteId = null;
|
|
this.selectedRouteDetails = null;
|
|
this.activeRouteIds = [];
|
|
|
|
// 物理清场(仅航点/航线;空域图形与房间绑定,不随方案切换)
|
|
if (this.$refs.cesiumMap && this.$refs.cesiumMap.clearAllWaypoints) {
|
|
this.$refs.cesiumMap.clearAllWaypoints();
|
|
}
|
|
console.log(`>>> [切换成功] 已进入方案: ${plan && plan.name},地图已清空,列表已展开。`);
|
|
},
|
|
/** 切换航线:实现多选/开关逻辑 */
|
|
async selectRoute(route) {
|
|
const index = this.activeRouteIds.indexOf(route.id);
|
|
const isRouteExpanded = this.$refs.rightPanel ? this.$refs.rightPanel.expandedRoutes.includes(route.id) : false;
|
|
// 航线已在选中列表中
|
|
if (index > -1) {
|
|
if (isRouteExpanded) {
|
|
return;
|
|
} else {
|
|
// 航线未展开,点击则取消选中(从地图移除)
|
|
this.activeRouteIds.splice(index, 1);
|
|
if (this.$refs.cesiumMap) {
|
|
this.$refs.cesiumMap.removeRouteById(route.id);
|
|
// 隐藏航线时,同时移除关联的威力区
|
|
this.$refs.cesiumMap.removePowerZoneByRouteId(route.id);
|
|
}
|
|
if (this.selectedRouteDetails && this.selectedRouteDetails.id === route.id) {
|
|
if (this.activeRouteIds.length > 0) {
|
|
const lastId = this.activeRouteIds[this.activeRouteIds.length - 1];
|
|
try {
|
|
const res = await getRoutes(lastId);
|
|
if (res.code === 200 && res.data) {
|
|
this.selectedRouteId = res.data.id;
|
|
this.selectedRouteDetails = {
|
|
id: res.data.id,
|
|
name: res.data.callSign,
|
|
waypoints: res.data.waypoints || []
|
|
};
|
|
}
|
|
} catch (e) {
|
|
console.error("回显剩余航线失败", e);
|
|
}
|
|
} else {
|
|
this.selectedRouteId = null;
|
|
this.selectedRouteDetails = null;
|
|
}
|
|
}
|
|
this.$message.info(`已取消航线: ${route.name}`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 航线未被选中,点击则选中并显示航线和航点
|
|
try {
|
|
const response = await getRoutes(route.id);
|
|
if (response.code === 200 && response.data) {
|
|
const fullRouteData = response.data;
|
|
const waypoints = fullRouteData.waypoints || [];
|
|
this.activeRouteIds.push(route.id);
|
|
this.selectedRouteId = fullRouteData.id;
|
|
// 合并 list 中的 platformId/platform,以便拖拽航点后重绘时平台图标不丢失
|
|
this.selectedRouteDetails = {
|
|
id: fullRouteData.id,
|
|
name: fullRouteData.callSign,
|
|
waypoints: waypoints,
|
|
platformId: route.platformId,
|
|
platform: route.platform,
|
|
attributes: route.attributes
|
|
};
|
|
|
|
// 更新 routes 数组中对应航线的 waypoints 字段
|
|
const routeIndex = this.routes.findIndex(r => r.id === route.id);
|
|
if (routeIndex > -1) {
|
|
this.$set(this.routes, routeIndex, {
|
|
...this.routes[routeIndex],
|
|
waypoints: waypoints
|
|
});
|
|
}
|
|
|
|
if (waypoints.length > 0) {
|
|
if (this.$refs.cesiumMap) {
|
|
const roomId = this.currentRoomId;
|
|
if (roomId && route.platformId) {
|
|
try {
|
|
const styleRes = await getPlatformStyle({ roomId, routeId: route.id, platformId: route.platformId });
|
|
if (styleRes.data) this.$refs.cesiumMap.setPlatformStyle(route.id, styleRes.data);
|
|
} catch (_) {}
|
|
}
|
|
this.$refs.cesiumMap.renderRouteWaypoints(waypoints, route.id, route.platformId, route.platform, this.parseRouteStyle(route.attributes));
|
|
}
|
|
} else {
|
|
this.$message.warning('该航线暂无坐标数据,无法在地图展示');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("获取航线详情失败:", error);
|
|
this.$message.error('无法加载该航线的详细航点数据');
|
|
}
|
|
},
|
|
|
|
createPlan() {
|
|
this.newPlanForm.name = ''; // 重置名称
|
|
this.showPlanNameDialog = true; // 打开对话框
|
|
},
|
|
async confirmCreatePlan() {
|
|
this.$refs.newPlanForm.validate(async (valid) => {
|
|
if (!valid) return;
|
|
try {
|
|
const postData = {
|
|
roomId: this.currentRoomId,
|
|
name: this.newPlanForm.name,
|
|
};
|
|
const res = await addScenario(postData);
|
|
if (res.code === 200) {
|
|
this.$message.success('方案创建成功');
|
|
this.showPlanNameDialog = false;
|
|
// 刷新列表,确保新方案带上后端 ID
|
|
await this.getList();
|
|
// 自动选中最新创建的方案
|
|
if (res.data && res.data.id) {
|
|
const newPlan = this.plans.find(p => p.id === res.data.id);
|
|
if (newPlan) this.selectPlan(newPlan);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("创建方案失败:", error);
|
|
this.$message.error('保存方案失败,请检查网络');
|
|
}
|
|
});
|
|
},
|
|
openPlanDialog(plan) {
|
|
this.$prompt('请输入方案名称', '编辑方案', {
|
|
confirmButtonText: '确定',
|
|
cancelButtonText: '取消',
|
|
inputValue: plan.name
|
|
}).then(({value}) => {
|
|
plan.name = value;
|
|
}).catch(() => {
|
|
});
|
|
},
|
|
async executeDeletePlan(plan) {
|
|
try {
|
|
const res = await delScenario(plan.id);
|
|
if (res.code === 200) {
|
|
this.$message.success('方案删除成功');
|
|
const relatedRoutes = this.routes.filter(r => r.scenarioId === plan.id);
|
|
relatedRoutes.forEach(route => {
|
|
if (this.$refs.cesiumMap) {
|
|
this.$refs.cesiumMap.removeRouteById(route.id);
|
|
}
|
|
const idx = this.activeRouteIds.indexOf(route.id);
|
|
if (idx > -1) {
|
|
this.activeRouteIds.splice(idx, 1);
|
|
}
|
|
});
|
|
//重置方案选中状态
|
|
if (this.selectedPlanId === plan.id) {
|
|
this.selectedPlanId = null;
|
|
}
|
|
await this.getList(); // 刷新列表
|
|
}
|
|
} catch (e) {
|
|
if (e !== 'cancel') console.error("删除方案失败:", e);
|
|
}
|
|
},
|
|
addWaypoint() {
|
|
if (this.selectedRouteDetails) {
|
|
const count = this.selectedRouteDetails.waypoints.length + 1;
|
|
this.selectedRouteDetails.waypoints.push({
|
|
name: `WP${count}`,
|
|
alt: 5000,
|
|
speed: '800km/h',
|
|
eta: `K+01:${(count * 15).toString().padStart(2, '0')}:00`
|
|
});
|
|
if (this.selectedPlanDetails) {
|
|
const route = this.selectedPlanDetails.routes.find(r => r.id === this.selectedRouteId);
|
|
if (route) {
|
|
route.points = this.selectedRouteDetails.waypoints.length;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
cancelRoute() {
|
|
// 清空所有选中的航线
|
|
if (this.$refs.cesiumMap) {
|
|
this.activeRouteIds.forEach(id => {
|
|
this.$refs.cesiumMap.removeRouteById(id);
|
|
});
|
|
}
|
|
this.activeRouteIds = [];
|
|
this.selectedRouteDetails = null;
|
|
this.$message.info('已清空所有选中航线');
|
|
},
|
|
|
|
// 切换航线显示/隐藏
|
|
/** 地图右键上锁/解锁后同步到列表 */
|
|
handleRouteLockChanged({ routeId, locked }) {
|
|
this.$set(this.routeLocked, routeId, locked);
|
|
},
|
|
/** 右侧列表锁图标点击:切换该航线上锁状态,与地图右键状态同步 */
|
|
handleToggleRouteLockFromPanel(route) {
|
|
if (!route || route.id == null) return;
|
|
const nextLocked = !this.routeLocked[route.id];
|
|
this.$set(this.routeLocked, route.id, nextLocked);
|
|
this.$message.success(nextLocked ? '航线已上锁,无法修改' : '航线已解锁,可以编辑');
|
|
},
|
|
|
|
toggleRouteVisibility(route) {
|
|
const index = this.activeRouteIds.indexOf(route.id);
|
|
|
|
if (index > -1) {
|
|
// 航线已显示,隐藏它
|
|
// 使用过滤创建新数组,确保 Vue 能够检测到变化
|
|
this.activeRouteIds = this.activeRouteIds.filter(id => id !== route.id);
|
|
if (this.$refs.cesiumMap) {
|
|
this.$refs.cesiumMap.removeRouteById(route.id);
|
|
}
|
|
if (this.selectedRouteDetails && this.selectedRouteDetails.id === route.id) {
|
|
if (this.activeRouteIds.length > 0) {
|
|
const lastId = this.activeRouteIds[this.activeRouteIds.length - 1];
|
|
getRoutes(lastId).then(res => {
|
|
if (res.code === 200 && res.data) {
|
|
const fromList = this.routes.find(r => r.id === lastId);
|
|
this.selectedRouteId = res.data.id;
|
|
this.selectedRouteDetails = {
|
|
id: res.data.id,
|
|
name: res.data.callSign,
|
|
waypoints: res.data.waypoints || [],
|
|
platformId: fromList?.platformId,
|
|
platform: fromList?.platform,
|
|
attributes: fromList?.attributes
|
|
};
|
|
}
|
|
}).catch(e => {
|
|
console.error("获取航线详情失败", e);
|
|
});
|
|
} else {
|
|
this.selectedRouteId = null;
|
|
this.selectedRouteDetails = null;
|
|
}
|
|
}
|
|
} else {
|
|
// 航线已隐藏,显示它
|
|
this.selectRoute(route);
|
|
}
|
|
},
|
|
|
|
// 冲突操作:根据当前展示的航线与时间轴计算真实问题(提前到达、无法按时到达)
|
|
runConflictCheck() {
|
|
const list = [];
|
|
let id = 1;
|
|
const routeIds = this.activeRouteIds && this.activeRouteIds.length > 0 ? this.activeRouteIds : this.routes.map(r => r.id);
|
|
const { minMinutes, maxMinutes } = this.getDeductionTimeRange();
|
|
|
|
routeIds.forEach(routeId => {
|
|
const route = this.routes.find(r => r.id === routeId);
|
|
if (!route || !route.waypoints || route.waypoints.length < 2) return;
|
|
let pathData = null;
|
|
if (this.$refs.cesiumMap && this.$refs.cesiumMap.getRoutePathWithSegmentIndices) {
|
|
const ret = this.$refs.cesiumMap.getRoutePathWithSegmentIndices(route.waypoints);
|
|
if (ret.path && ret.path.length > 0 && ret.segmentEndIndices) {
|
|
pathData = { path: ret.path, segmentEndIndices: ret.segmentEndIndices, holdArcRanges: ret.holdArcRanges || {} };
|
|
}
|
|
}
|
|
const { earlyArrivalLegs, lateArrivalLegs } = this.buildRouteTimeline(route.waypoints, minMinutes, maxMinutes, pathData);
|
|
|
|
const routeName = route.name || `航线${route.id}`;
|
|
(earlyArrivalLegs || []).forEach(leg => {
|
|
list.push({
|
|
id: id++,
|
|
title: '提前到达',
|
|
routeName,
|
|
fromWaypoint: leg.fromName,
|
|
toWaypoint: leg.toName,
|
|
time: this.minutesToStartTime(leg.actualArrival),
|
|
suggestion: '该航段将提前到达下一航点,建议在此段加入盘旋或延后下一航点计划时间。',
|
|
severity: 'high'
|
|
});
|
|
});
|
|
(lateArrivalLegs || []).forEach(leg => {
|
|
list.push({
|
|
id: id++,
|
|
title: '无法按时到达',
|
|
routeName,
|
|
fromWaypoint: leg.fromName,
|
|
toWaypoint: leg.toName,
|
|
suggestion: `当前速度不足,建议将本段速度提升至 ≥${leg.requiredSpeedKmh} km/h,或延后下一航点计划时间。`,
|
|
severity: 'high'
|
|
});
|
|
});
|
|
});
|
|
|
|
this.conflicts = list;
|
|
this.conflictCount = list.length;
|
|
if (list.length > 0) {
|
|
this.$message.warning(`检测到 ${list.length} 处航线时间问题`);
|
|
} else {
|
|
this.$message.success('未发现航线时间冲突');
|
|
}
|
|
},
|
|
|
|
viewConflict(conflict) {
|
|
this.$message.info(`查看冲突:${conflict.title}`);
|
|
},
|
|
|
|
resolveConflict(conflict) {
|
|
this.$message.success(`解决冲突:${conflict.title}`);
|
|
// 移除已解决的冲突
|
|
this.conflicts = this.conflicts.filter(c => c.id !== conflict.id);
|
|
this.conflictCount = this.conflicts.length;
|
|
},
|
|
|
|
// 系统功能
|
|
exportReport() {
|
|
this.$message.success('作战报表导出成功');
|
|
},
|
|
|
|
// 新增导入功能
|
|
importData() {
|
|
this.$message.success('导入数据成功');
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.mission-planning-container {
|
|
position: relative;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* 地图背景:使用相对路径便于 IDE 与构建解析;若需图片请将 map-background.png 放到 src/assets/ */
|
|
.map-background {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: linear-gradient(135deg, #1a2f4b 0%, #2c3e50 100%);
|
|
/* 若已存在 src/assets/map-background.png,可改为:background: url('../../assets/map-background.png'); 并注释掉上一行 */
|
|
background-size: cover;
|
|
background-position: center;
|
|
z-index: 1;
|
|
}
|
|
|
|
/* 截图保存弹窗 */
|
|
.screenshot-save-dialog .screenshot-preview {
|
|
max-height: 320px;
|
|
overflow: hidden;
|
|
border-radius: 4px;
|
|
background: #f5f5f5;
|
|
margin-bottom: 12px;
|
|
}
|
|
.screenshot-save-dialog .screenshot-preview img {
|
|
display: block;
|
|
max-width: 100%;
|
|
width: auto;
|
|
height: auto;
|
|
max-height: 320px;
|
|
object-fit: contain;
|
|
}
|
|
.screenshot-save-dialog .screenshot-tip {
|
|
font-size: 12px;
|
|
color: #909399;
|
|
margin-top: 8px;
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
/* ...其余样式省略,保持不变... */
|
|
.map-overlay-text {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
color: rgba(255, 255, 255, 0.3);
|
|
text-align: center;
|
|
font-size: 18px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* 地图中间的浮动红点 - 通用样式 */
|
|
.floating-red-dot {
|
|
position: absolute;
|
|
top: 50%;
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: rgba(255, 255, 255, 0.3);
|
|
backdrop-filter: blur(5px);
|
|
border: 2px solid rgba(255, 0, 0, 0.8);
|
|
box-shadow: 0 0 20px rgba(255, 0, 0, 0.6);
|
|
cursor: pointer;
|
|
z-index: 80;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.3s ease;
|
|
animation: pulse-red 2s infinite;
|
|
}
|
|
|
|
.floating-red-dot:hover {
|
|
transform: translateY(-50%) scale(1.1);
|
|
box-shadow: 0 0 25px rgba(255, 0, 0, 0.8);
|
|
}
|
|
|
|
.floating-red-dot.hidden {
|
|
display: none;
|
|
}
|
|
|
|
/* 左侧红点 */
|
|
.left-red-dot {
|
|
left: 20px;
|
|
transform: translateY(-50%);
|
|
}
|
|
|
|
.red-dot {
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 50%;
|
|
background: radial-gradient(circle at 30% 30%, #ff4444, #cc0000);
|
|
position: relative;
|
|
}
|
|
|
|
.icon-inside {
|
|
position: absolute;
|
|
color: white;
|
|
font-size: 16px;
|
|
}
|
|
|
|
@keyframes pulse-red {
|
|
0% {
|
|
box-shadow: 0 0 20px rgba(255, 0, 0, 0.6);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 25px rgba(255, 0, 0, 0.8);
|
|
}
|
|
100% {
|
|
box-shadow: 0 0 20px rgba(255, 0, 0, 0.6);
|
|
}
|
|
}
|
|
|
|
/* 蓝色主题通用类 */
|
|
.blue-theme {
|
|
background: rgba(255, 255, 255, 0.3);
|
|
backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(0, 138, 255, 0.1);
|
|
box-shadow: 0 4px 12px rgba(0, 138, 255, 0.2);
|
|
}
|
|
|
|
.blue-btn {
|
|
background: rgba(0, 138, 255, 0.8);
|
|
border: 1px solid rgba(0, 138, 255, 0.9);
|
|
color: white;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.blue-btn:hover {
|
|
background: rgba(0, 138, 255, 0.9);
|
|
border-color: rgba(0, 138, 255, 1);
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 2px 8px rgba(0, 138, 255, 0.3);
|
|
}
|
|
|
|
.blue-text-btn {
|
|
color: #008aff;
|
|
}
|
|
|
|
.blue-text-btn:hover {
|
|
color: #0066cc;
|
|
}
|
|
|
|
.blue-badge {
|
|
background: rgba(0, 138, 255, 0.8);
|
|
color: white;
|
|
}
|
|
|
|
.blue-tag {
|
|
background: rgba(0, 138, 255, 0.2);
|
|
color: #008aff;
|
|
border: 1px solid rgba(0, 138, 255, 0.3);
|
|
}
|
|
|
|
.blue-time {
|
|
background: rgba(0, 138, 255, 0.1);
|
|
border: 1px solid rgba(0, 138, 255, 0.3);
|
|
color: #008aff;
|
|
}
|
|
|
|
.blue-success {
|
|
color: #008aff;
|
|
}
|
|
|
|
.blue-warning {
|
|
color: #ff9900;
|
|
}
|
|
|
|
.blue-mark {
|
|
color: #008aff;
|
|
}
|
|
|
|
.status-dot.operating {
|
|
background: #008aff;
|
|
animation: pulse 2s infinite;
|
|
box-shadow: 0 0 10px rgba(0, 138, 255, 0.8);
|
|
}
|
|
|
|
/* 蓝色主题标签页 */
|
|
.blue-tabs >>> .el-tabs__item {
|
|
color: #666;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.blue-tabs >>> .el-tabs__item:hover {
|
|
color: #008aff;
|
|
}
|
|
|
|
.blue-tabs >>> .el-tabs__item.is-active {
|
|
color: #008aff;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.blue-tabs >>> .el-tabs__active-bar {
|
|
background-color: #008aff;
|
|
box-shadow: 0 0 6px rgba(0, 138, 255, 0.5);
|
|
}
|
|
|
|
.blue-tabs >>> .el-tabs__nav-wrap::after {
|
|
background-color: rgba(0, 138, 255, 0.3);
|
|
}
|
|
|
|
/* 底部时间轴(最初版本的样式)- 蓝色主题 */
|
|
.floating-timeline {
|
|
position: absolute;
|
|
bottom: 20px;
|
|
left: 50%;
|
|
transform: translateX(-50%) translateY(100%);
|
|
width: 80%;
|
|
border-radius: 12px;
|
|
z-index: 95;
|
|
padding: 10px 20px;
|
|
color: #333;
|
|
transition: all 0.3s ease;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
backdrop-filter: blur(15px);
|
|
background: rgba(255, 255, 255, 0.95);
|
|
box-shadow: 0 8px 32px rgba(0, 138, 255, 0.25);
|
|
border: 1px solid rgba(0, 138, 255, 0.3);
|
|
}
|
|
|
|
.floating-timeline.show {
|
|
transform: translateX(-50%) translateY(0);
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.k-time-tip {
|
|
font-size: 12px;
|
|
color: #909399;
|
|
margin: 8px 0 0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.popup-hide-btn {
|
|
position: absolute;
|
|
top: -28px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
width: 56px;
|
|
height: 28px;
|
|
background: rgba(255, 255, 255, 0.3);
|
|
backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(0, 138, 255, 0.4);
|
|
border-bottom: none;
|
|
border-radius: 12px 12px 0 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
color: #008aff;
|
|
font-size: 18px;
|
|
transition: all 0.3s;
|
|
z-index: 10;
|
|
}
|
|
|
|
.popup-hide-btn:hover {
|
|
background: rgba(0, 138, 255, 0.2);
|
|
color: #0066cc;
|
|
height: 32px;
|
|
top: -32px;
|
|
}
|
|
|
|
.timeline-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.current-time {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-family: monospace;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
padding: 4px 12px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.timeline-slider {
|
|
flex: 1;
|
|
margin: 0 20px;
|
|
}
|
|
|
|
.compact-slider {
|
|
width: 100%;
|
|
}
|
|
|
|
.blue-slider >>> .el-slider__bar {
|
|
background-color: rgba(0, 138, 255, 0.8);
|
|
}
|
|
|
|
.blue-slider >>> .el-slider__button {
|
|
border-color: rgba(0, 138, 255, 0.8);
|
|
background-color: rgba(0, 138, 255, 0.8);
|
|
}
|
|
|
|
.playback-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.control-btn {
|
|
width: 26px;
|
|
height: 26px;
|
|
border: 1px solid rgba(0, 138, 255, 0.3);
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.3s;
|
|
color: #008aff;
|
|
}
|
|
|
|
.control-btn:hover:not(:disabled) {
|
|
background: rgba(0, 138, 255, 0.1);
|
|
border-color: rgba(0, 138, 255, 0.5);
|
|
}
|
|
|
|
.control-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.control-btn i {
|
|
font-size: 14px;
|
|
}
|
|
|
|
.speed-control {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 2px 8px;
|
|
background: rgba(255, 255, 255, 0.9);
|
|
border: 1px solid rgba(0, 138, 255, 0.3);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.speed-text {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: #008aff;
|
|
min-width: 24px;
|
|
text-align: center;
|
|
}
|
|
|
|
.system-status {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 30px;
|
|
font-size: 12px;
|
|
margin-top: 10px;
|
|
padding-top: 10px;
|
|
border-top: 1px solid rgba(0, 138, 255, 0.4);
|
|
}
|
|
|
|
.status-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
}
|
|
|
|
.status-label {
|
|
color: #666;
|
|
}
|
|
|
|
.status-value.success {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.status-value.warning {
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* 滚动条样式 */
|
|
::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: rgba(0, 138, 255, 0.1);
|
|
border-radius: 3px;
|
|
backdrop-filter: blur(5px);
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: linear-gradient(135deg, rgba(0, 138, 255, 0.5), rgba(0, 138, 255, 0.7));
|
|
border-radius: 3px;
|
|
box-shadow: 0 0 6px rgba(0, 138, 255, 0.4);
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: linear-gradient(135deg, rgba(0, 138, 255, 0.7), rgba(0, 138, 255, 0.9));
|
|
}
|
|
|
|
.ml-3 {
|
|
margin-left: 10px;
|
|
}
|
|
</style>
|
|
|