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.
 
 
 
 
 

899 lines
24 KiB

<template>
<div>
<div
class="right-external-hide-btn"
:class="{ hidden: isHidden }"
@click="handleHide"
:title="$t('rightPanel.hideRightPanel')"
>
<i class="el-icon-arrow-right"></i>
</div>
<div
class="floating-right-panel blue-theme"
:class="{ 'hidden': isHidden }"
>
<div v-if="activeTab === 'plan'" class="tab-content plan-content">
<div class="section">
<div class="section-header">
<div class="section-title">{{ $t('rightPanel.planList') }}</div>
<el-button
type="primary"
size="mini"
@click="handleCreatePlan"
class="create-route-btn-new"
>
{{ $t('rightPanel.createPlan') }}
</el-button>
</div>
<div class="tree-list">
<!-- 方案列表 -->
<div
v-for="plan in plans"
:key="plan.id"
class="tree-item plan-item"
:class="{ selected: selectedPlanId === plan.id }"
>
<div class="tree-item-header" @click="togglePlan(plan.id)">
<i :class="expandedPlans.includes(plan.id) ? 'el-icon-folder-opened' : 'el-icon-folder'" class="tree-icon"></i>
<div class="tree-item-info">
<div class="tree-item-name">{{ plan.name }}</div>
<div class="tree-item-meta">{{ routes.filter(r => r.scenarioId === plan.id).length }}个航线</div>
</div>
<div class="tree-item-actions">
<i class="el-icon-plus" title="新建航线" @click.stop="handleCreateRouteForPlan(plan)"></i>
<i class="el-icon-edit" title="编辑" @click.stop="handleOpenPlanDialog(plan)"></i>
<i class="el-icon-delete" title="删除" @click.stop="handleDeletePlan(plan)"></i>
</div>
</div>
<!-- 航线列表 -->
<div v-if="expandedPlans.includes(plan.id)" class="tree-children route-children">
<div
v-for="route in routes.filter(r => r.scenarioId === plan.id)"
:key="route.id"
class="tree-item route-item"
:class="getRouteClasses(route.id)"
>
<div class="tree-item-header" @click="toggleRoute(route.id)">
<i class="el-icon-map-location tree-icon"></i>
<div class="tree-item-info">
<div class="tree-item-name">{{ route.name }}</div>
<div class="tree-item-meta">{{ route.points }}{{ $t('rightPanel.points') }}</div>
<div v-if="routeLockedByOther(route.id)" class="route-locked-tag">
<i class="el-icon-lock"></i> {{ routeLockedByName(route.id) }} 正在编辑
</div>
</div>
<el-tag
v-if="route.conflict"
size="mini"
type="danger"
class="conflict-tag"
>
{{ $t('rightPanel.conflict') }}
</el-tag>
<div class="tree-item-actions">
<i class="el-icon-view" title="显示/隐藏" @click.stop="handleToggleRouteVisibility(route)"></i>
<i
v-if="!routeLockedByOther(route.id)"
:class="routeLocked[route.id] ? 'el-icon-lock' : 'el-icon-unlock'"
:title="routeLocked[route.id] ? '解锁' : '上锁'"
@click.stop="$emit('toggle-route-lock', route)"
></i>
<i
v-else
class="el-icon-lock route-locked-by-other-icon"
title="被他人锁定,无法操作"
></i>
<i
class="el-icon-edit"
:title="routeLockedByOther(route.id) ? '被他人锁定,无法编辑' : '编辑'"
:class="{ 'action-disabled': routeLockedByOther(route.id) }"
@click.stop="!routeLockedByOther(route.id) && handleOpenRouteDialog(route)"
></i>
<i
class="el-icon-delete"
:class="{ 'action-disabled': routeLockedByOther(route.id) }"
title="删除"
@click.stop="!routeLockedByOther(route.id) && $emit('delete-route', route)"
></i>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'platform'" class="tab-content platform-content">
<div class="section-header" style="padding: 10px 0; display: flex; justify-content: space-between; align-items: center;">
<div class="section-title">平台列表</div>
<el-button
type="primary"
size="mini"
icon="el-icon-upload2"
@click="handleImportPlatform"
class="create-route-btn-new"
style="width: 90px;height: 25px;line-height: 25px;padding: 0;font-size: 14px;"
>
导入平台
</el-button>
</div>
<div class="platform-categories">
<el-tabs v-model="activePlatformTab" type="card" size="mini" class="blue-tabs">
<el-tab-pane :label="$t('rightPanel.air')" name="air">
<div class="platform-list">
<div
v-for="platform in airPlatforms"
:key="platform.id"
class="platform-item platform-item-draggable"
draggable="true"
@click="handlePlatformItemClick(platform, $event)"
@dragstart="onPlatformDragStart($event, platform)"
>
<div class="platform-icon" :style="{ color: platform.color }">
<img v-if="isImg(platform.imageUrl)"
:src="formatImg(platform.imageUrl)"
class="platform-img"
/>
<i v-else :class="platform.icon || 'el-icon-picture-outline'"></i>
</div>
<div class="platform-info">
<div class="platform-name">{{ platform.name }}</div>
<div class="platform-type">{{ platform.type }}</div>
</div>
<div class="platform-action" style="margin-left: auto; padding-right: 10px;">
<i
class="el-icon-delete"
title="删除平台"
style="color: #F56C6C; cursor: pointer;"
@click.stop="$emit('delete-platform', platform)"
></i>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane :label="$t('rightPanel.sea')" name="sea">
<div class="platform-list">
<div
v-for="platform in seaPlatforms"
:key="platform.id"
class="platform-item platform-item-draggable"
draggable="true"
@click="handlePlatformItemClick(platform, $event)"
@dragstart="onPlatformDragStart($event, platform)"
>
<div class="platform-icon" :style="{ color: platform.color }">
<img v-if="isImg(platform.imageUrl)"
:src="formatImg(platform.imageUrl)"
class="platform-img"
/>
<i v-else :class="platform.icon || 'el-icon-picture-outline'"></i>
</div>
<div class="platform-info">
<div class="platform-name">{{ platform.name }}</div>
<div class="platform-type">{{ platform.type }}</div>
</div>
<div class="platform-action" style="margin-left: auto; padding-right: 10px;">
<i class="el-icon-delete" title="删除平台" style="color: #F56C6C; cursor: pointer;" @click.stop="$emit('delete-platform', platform)"></i>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane :label="$t('rightPanel.ground')" name="ground">
<div class="platform-list">
<div
v-for="platform in groundPlatforms"
:key="platform.id"
class="platform-item platform-item-draggable"
draggable="true"
@click="handlePlatformItemClick(platform, $event)"
@dragstart="onPlatformDragStart($event, platform)"
>
<div class="platform-icon" :style="{ color: platform.color }">
<img v-if="isImg(platform.imageUrl)"
:src="formatImg(platform.imageUrl)"
class="platform-img"
/>
<i v-else :class="platform.icon || 'el-icon-picture-outline'"></i>
</div>
<div class="platform-info">
<div class="platform-name">{{ platform.name }}</div>
<div class="platform-type">{{ platform.type }}</div>
</div>
<div class="platform-action" style="margin-left: auto; padding-right: 10px;">
<i class="el-icon-delete" title="删除平台" style="color: #F56C6C; cursor: pointer;" @click.stop="$emit('delete-platform', platform)"></i>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'RightPanel',
props: {
isHidden: {
type: Boolean,
default: false
},
activeTab: {
type: String,
default: 'plan'
},
plans: {
type: Array,
default: () => []
},
routes: {
type: Array,
default: () => []
},
activeRouteIds: {
type: Array,
default: () => []
},
/** 航线上锁状态:routeId -> true 上锁,与地图右键上锁/解锁同步 */
routeLocked: {
type: Object,
default: () => ({})
},
/** 协作:谁正在编辑(锁定)该航线 routeId -> { userId, userName, nickName, sessionId } */
routeLockedBy: {
type: Object,
default: () => ({})
},
/** 当前用户 ID,用于判断“正在查看/锁定”是否为自己 */
currentUserId: {
type: [Number, String],
default: null
},
selectedPlanId: {
type: [String, Number],
default: null
},
selectedPlanDetails: {
type: Object,
default: null
},
selectedRouteId: {
type: [String, Number],
default: null
},
selectedRouteDetails: {
type: Object,
default: null
},
/** 父组件要求展开的方案树(如冲突定位时),会展开对应方案以便看到航线行 */
expandRouteIds: {
type: Array,
default: () => []
},
airPlatforms: {
type: Array,
default: () => []
},
seaPlatforms: {
type: Array,
default: () => []
},
groundPlatforms: {
type: Array,
default: () => []
},
},
data() {
return {
activePlatformTab: 'air',
expandedPlans: [], // 展开的方案列表
platformJustDragged: false // 刚拖拽过,避免拖拽后误触点击打开弹窗
}
},
watch: {
expandRouteIds(newVal) {
if (newVal && newVal.length) {
newVal.forEach(routeId => {
const r = this.routes.find(route => route.id === routeId);
if (r && r.scenarioId != null && !this.expandedPlans.includes(r.scenarioId)) {
this.expandedPlans.push(r.scenarioId);
}
});
}
},
selectedPlanId(newId) {
if (newId) {
console.log('>>> [子组件同步] 检测到方案切换,自动展开 ID:', newId);
// 如果还没展开,就把它塞进展开数组里
if (!this.expandedPlans.includes(newId)) {
this.expandedPlans.push(newId);
}
}
},
activeRouteIds: {
handler(newVal) {
console.log('activeRouteIds updated:', newVal);
},
deep: true
}
},
methods: {
// 切换方案展开/折叠
togglePlan(planId) {
const index = this.expandedPlans.indexOf(planId)
if (index > -1) {
this.expandedPlans.splice(index, 1)
const planRoutes = this.routes.filter(r => r.scenarioId === planId)
planRoutes.forEach(route => {
if (this.activeRouteIds.includes(route.id)) {
this.handleToggleRouteVisibility(route)
}
})
this.$emit('select-plan', { id: null })
} else {
// 展开新方案:不再隐藏航线,保留已打开的航线显示;仅切换展开状态并选中方案
this.expandedPlans = [];
this.expandedPlans.push(planId)
const plan = this.plans.find(p => p.id === planId)
if (plan) {
this.$emit('select-plan', plan)
}
}
},
// 点击航线行:与父组件 selectRoute 联动(未选中则上图,已选中则取消上图);航点请在「编辑航线」中维护
toggleRoute(routeId) {
const route = this.routes.find(r => r.id === routeId)
if (!route) return
if (!route.waypoints) {
this.$set(route, 'waypoints', [])
}
this.handleSelectRoute(route)
this.$nextTick(() => {
if (route.scenarioId != null && !this.expandedPlans.includes(route.scenarioId)) {
this.expandedPlans.push(route.scenarioId)
}
})
},
// 判断是否是图片路径
isImg(path) {
if (!path) return false;
return path.includes('/') || path.includes('data:image');
},
// 格式化图片地址
formatImg(url) {
if (!url) return '';
// 清理路径中的双斜杠
const cleanPath = url.replace(/\/+/g, '/');
const backendUrl = process.env.VUE_APP_BACKEND_URL;
const finalUrl = backendUrl + cleanPath;
// 👈 核心排查日志:按 F12 在控制台看这个输出!
console.log('>>> [图片渲染调试] 最终拼接地址:', finalUrl);
return finalUrl;
},
handleHide() {
this.$emit('hide')
},
handleSelectRoute(route) {
// 确保航线有航点数据
this.$emit('select-route', route)
},
handleCreatePlan() {
this.$emit('create-plan')
},
handleCreateRouteForPlan(plan) {
this.$emit('create-route', plan)
},
handleDeletePlan(plan) {
this.$confirm(`是否确认删除方案 "${plan.name}"?这将同时删除该方案下的所有航线。`, "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}).then(() => {
this.$emit('delete-plan', plan);
}).catch(() => {
});
},
handleOpenPlanDialog(plan) {
this.$emit('open-plan-dialog', plan)
},
handleImportPlatform() {
this.$emit('open-import-dialog');
},
handleOpenRouteDialog(route) {
this.$emit('open-route-dialog', route)
},
handleToggleRouteVisibility(route) {
this.$emit('toggle-route-visibility', route)
},
getRouteClasses(routeId) {
return {
active: this.activeRouteIds.includes(routeId),
'locked-by-other': this.routeLockedByOther(routeId)
}
},
/** 是否有其他成员正在编辑(锁定)该航线 */
routeLockedByOther(routeId) {
const e = this.routeLockedBy[routeId]
if (!e) return false
const myId = this.currentUserId != null ? Number(this.currentUserId) : null
const uid = e.userId != null ? Number(e.userId) : null
return myId !== uid
},
routeLockedByName(routeId) {
const e = this.routeLockedBy[routeId]
return (e && (e.nickName || e.userName)) || ''
},
handleOpenPlatformDialog(platform) {
this.$emit('open-platform-dialog', platform)
},
/** 平台项点击:若刚拖拽过则不打开弹窗,避免误触 */
handlePlatformItemClick(platform, ev) {
if (this.platformJustDragged) {
this.platformJustDragged = false
return
}
this.handleOpenPlatformDialog(platform)
},
/** 拖拽平台图标到地图时传递平台数据 */
onPlatformDragStart(ev, platform) {
this.platformJustDragged = true
setTimeout(() => { this.platformJustDragged = false }, 300)
try {
ev.dataTransfer.setData('application/json', JSON.stringify({
id: platform.id,
name: platform.name,
type: platform.type,
imageUrl: platform.imageUrl,
iconUrl: platform.iconUrl,
icon: platform.icon,
color: platform.color
}))
ev.dataTransfer.effectAllowed = 'copy'
} catch (e) {
console.warn('Platform drag start failed', e)
}
}
}
}
</script>
<style scoped>
/* 右侧外部隐藏按钮 */
.right-external-hide-btn {
position: absolute;
top: 80px;
right: 20px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #165dff;
font-size: 18px;
background: rgba(255, 255, 255, 0.5);
border-radius: 50%;
z-index: 85;
box-shadow: 0 2px 8px rgba(22, 93, 255, 0.3);
transition: all 0.3s;
backdrop-filter: blur(5px);
}
.right-external-hide-btn:hover {
color: #165dff;
background: rgba(22, 93, 255, 0.2);
transform: scale(1.1);
}
.right-external-hide-btn.hidden {
opacity: 0;
transform: translateX(100%);
pointer-events: none;
}
/* 右侧浮动面板 - 蓝色主题 */
.floating-right-panel {
position: absolute;
top: 70px;
right: 20px;
width: 300px;
border-radius: 0 8px 8px 8px;
z-index: 90;
color: #333;
overflow: hidden;
box-shadow: 0 4px 20px rgba(22, 93, 255, 0.2);
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(15px);
transition: all 0.3s ease;
opacity: 1;
transform: translateX(0);
}
.floating-right-panel.hidden {
opacity: 0;
transform: translateX(100%);
pointer-events: none;
}
.tab-content {
padding: 15px;
max-height: 500px;
overflow-y: auto;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
}
.section {
margin-bottom: 20px;
}
/* 新增:标题栏容器样式 */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid rgba(22, 93, 255, 0.2);
margin-bottom: 15px;
padding-bottom: 10px;
padding-top: 10px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #165dff;
}
.create-route-btn-new {
background-color: #165dff !important;
border-color: #165dff !important;
color: #ffffff !important;
padding: 4px 10px;
font-size: 12px;
border-radius: 4px;
}
.create-route-btn-new:hover {
background-color: #285fd9 !important;
border-color: #285fd9 !important;
opacity: 0.9;
}
.tree-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.tree-item {
border-radius: 6px;
transition: all 0.3s;
border: 1px solid rgba(22, 93, 255, 0.1);
}
.tree-item-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: rgba(255, 255, 255, 0.8);
cursor: pointer;
transition: all 0.3s;
border-radius: 6px;
}
.tree-item-header:hover {
background: rgba(22, 93, 255, 0.1);
transform: translateX(-2px);
box-shadow: 0 2px 8px rgba(22, 93, 255, 0.15);
}
.tree-item.plan-item .tree-item-header {
background: rgba(255, 255, 255, 0.9) !important;
}
.tree-item.route-item .tree-item-header {
background: rgba(255, 255, 255, 0.8) !important;
}
.tree-item.route-item:not(.active) .tree-item-header {
background: rgba(255, 255, 255, 0.8) !important;
}
.tree-item.active .tree-item-header {
background: rgba(22, 93, 255, 0.15) !important;
border-color: rgba(22, 93, 255, 0.3);
box-shadow: 0 2px 10px rgba(22, 93, 255, 0.25);
}
.tree-item.selected .tree-item-header {
background: rgba(22, 93, 255, 0.1) !important;
border-color: rgba(22, 93, 255, 0.2);
}
.tree-icon {
font-size: 16px;
color: #165dff;
flex-shrink: 0;
}
.tree-item-info {
flex: 1;
}
.tree-item-name {
font-size: 14px;
font-weight: 500;
color: #333;
}
.tree-item-meta {
font-size: 12px;
color: #999;
}
.route-locked-tag {
font-size: 11px;
color: #909399;
margin-top: 2px;
}
.route-locked-tag .el-icon-lock {
margin-right: 2px;
font-size: 12px;
}
.tree-item.route-item.locked-by-other .tree-item-header {
background: rgba(240, 242, 245, 0.95) !important;
border-left: 3px solid #c0c4cc;
}
.tree-item-actions i.action-disabled {
color: #c0c4cc;
cursor: not-allowed;
opacity: 0.7;
}
.tree-item-actions i.action-disabled:hover {
background: transparent;
transform: none;
}
.route-locked-by-other-icon {
color: #909399;
cursor: default;
}
.tree-item-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.tree-item-actions i {
cursor: pointer;
color: #165dff;
font-size: 14px;
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
}
.tree-item-actions i:hover {
background: rgba(22, 93, 255, 0.1);
transform: scale(1.2);
}
.tree-children {
margin-left: 20px;
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 4px;
}
.route-children {
margin-left: 25px;
}
.action-buttons {
display: flex;
gap: 10px;
padding: 10px 0;
}
.platform-categories {
height: 100%;
}
.platform-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 450px;
overflow-y: auto;
}
.platform-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.8);
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
border: 1px solid rgba(22, 93, 255, 0.1);
}
.platform-item:hover {
background: rgba(22, 93, 255, 0.1);
transform: translateX(-2px);
box-shadow: 0 2px 8px rgba(22, 93, 255, 0.15);
}
.platform-item-draggable {
cursor: grab;
}
.platform-item-draggable:active {
cursor: grabbing;
}
.platform-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: rgba(255, 255, 255, 0.9);
border-radius: 50%;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.platform-info {
flex: 1;
}
.platform-name {
font-size: 14px;
font-weight: 500;
color: #333;
}
.platform-type {
font-size: 12px;
color: #999;
}
.platform-status {
display: flex;
align-items: center;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.online {
background: #67c23a;
box-shadow: 0 0 6px rgba(103, 194, 58, 0.6);
}
.status-dot.offline {
background: #999;
}
.status-dot.operating {
background: #165dff;
animation: pulse 2s infinite;
box-shadow: 0 0 10px rgba(22, 93, 255, 0.8);
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.blue-btn {
background: rgba(22, 93, 255, 0.1);
color: #165dff;
border: 1px solid rgba(22, 93, 255, 0.3);
}
.blue-btn:hover {
background: rgba(22, 93, 255, 0.2);
border-color: rgba(22, 93, 255, 0.5);
}
.blue-text-btn {
color: #165dff;
}
.blue-text-btn:hover {
color: #165dff;
}
.blue-badge {
background: rgba(245, 108, 108, 0.1);
color: #f56c6c;
border: 1px solid rgba(245, 108, 108, 0.3);
}
.blue-tabs >>> .el-tabs__item {
color: #666;
transition: all 0.3s;
}
.blue-tabs >>> .el-tabs__item:hover {
color: #165dff;
}
.blue-tabs >>> .el-tabs__item.is-active {
color: #165dff;
font-weight: 600;
}
.blue-tabs >>> .el-tabs__active-bar {
background-color: #165dff;
box-shadow: 0 0 6px rgba(22, 93, 255, 0.5);
}
.blue-tabs >>> .el-tabs__nav-wrap::after {
background-color: rgba(22, 93, 255, 0.3);
}
.blue-success {
color: #67c23a;
}
.blue-warning {
color: #e6a23c;
}
.platform-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden; /* 裁剪超出部分 */
}
.platform-img {
width: 100%;
height: 100%;
object-fit: cover; /* 关键:图片自动填充不拉伸 */
border-radius: 4px;
}
</style>