feat(cockpit): 添加带有可折叠地图图例的图例工具栏组件
* 引入 LegendToolbar.vue 组件,用于显示和切换地图图例 * 将工具栏集成到 CockpitLayout.vue 中,并采用绝对定位 * 为各种地图元素(道路、视频、桥梁等)添加相应的图标素材 * 调整 CSS 网格属性以提高布局响应能力
BIN
packages/screen/src/views/cockpit/assets/legendTool/上箭头 .png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/下箭头.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/交调icon.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/交调icon定位.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/应急力量icon.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/普通公路icon.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/服务设施icon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/桥梁icon.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/桥梁icon定位.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/气象预警icon.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/视频icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/视频icon定位.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/阻断事件icon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/隧洞icon.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/隧洞icon定位.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
packages/screen/src/views/cockpit/assets/legendTool/风险路段icon.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
@ -9,6 +9,7 @@
|
||||
|
||||
<div class="center-panel">
|
||||
<MapCenter />
|
||||
<LegendToolbar style="position: absolute; bottom: 10px; right: 50%; transform: translateX(50%);"/>
|
||||
</div>
|
||||
|
||||
<div class="right-panel">
|
||||
@ -26,6 +27,7 @@ import EmergencyResources from './EmergencyResources.vue'
|
||||
import MapCenter from './MapCenter.vue'
|
||||
import BlockEvent from './BlockEvent.vue'
|
||||
import YearStatistics from './YearStatistics.vue'
|
||||
import LegendToolbar from './LegendToolbar.vue'
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@ -72,7 +74,7 @@ import YearStatistics from './YearStatistics.vue'
|
||||
|
||||
/* 嵌入场景的 CSS 自定义属性 */
|
||||
--cockpit-side-width: minmax(15rem, 28%);
|
||||
--cockpit-gap: 1.25rem;
|
||||
--cockpit-gap: 1rem;
|
||||
--cockpit-padding-x: 0.625rem;
|
||||
--cockpit-padding-top: 0;
|
||||
--cockpit-padding-bottom: 0;
|
||||
@ -102,6 +104,7 @@ import YearStatistics from './YearStatistics.vue'
|
||||
min-height: 0; /* 允许网格在 flex 上下文中收缩 */
|
||||
display: grid;
|
||||
grid-template-columns: var(--cockpit-side-width) 1fr var(--cockpit-side-width);
|
||||
grid-auto-rows: 1fr; /* 强制行占据可用高度 */
|
||||
gap: var(--cockpit-gap);
|
||||
padding: var(--cockpit-padding-top) var(--cockpit-padding-x) var(--cockpit-padding-bottom);
|
||||
}
|
||||
@ -112,6 +115,7 @@ import YearStatistics from './YearStatistics.vue'
|
||||
flex-direction: column;
|
||||
gap: var(--cockpit-gap);
|
||||
min-width: 0; /* 防止在窄容器中溢出 */
|
||||
min-height: 0; /* 允许 flex 子元素收缩并启用滚动 */
|
||||
}
|
||||
|
||||
.center-panel {
|
||||
|
||||
446
packages/screen/src/views/cockpit/components/LegendToolbar.vue
Normal file
@ -0,0 +1,446 @@
|
||||
<template>
|
||||
<div class="legend-toolbar">
|
||||
<!-- 图例面板(包含图例容器和折叠按钮) -->
|
||||
<div class="legend-panel">
|
||||
<!-- 图例容器 -->
|
||||
<transition name="legend-collapse">
|
||||
<div
|
||||
v-show="!isCollapsed"
|
||||
class="legend-container"
|
||||
:style="{ backgroundImage: `url(${backgroundImg})` }"
|
||||
>
|
||||
<div
|
||||
v-for="item in computedLegendItems"
|
||||
:key="item.key"
|
||||
class="legend-item"
|
||||
:class="{ active: isActive(item.key) }"
|
||||
@click="handleItemClick(item.key)"
|
||||
>
|
||||
<img :src="item.icon" :alt="item.label" class="legend-icon" />
|
||||
<span class="legend-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 折叠/展开按钮 -->
|
||||
<button
|
||||
type="button"
|
||||
class="collapse-toggle"
|
||||
:aria-label="isCollapsed ? '展开图例' : '折叠图例'"
|
||||
:aria-expanded="!isCollapsed"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<img
|
||||
:src="isCollapsed ? upArrowIcon : downArrowIcon"
|
||||
:alt="isCollapsed ? '展开图例' : '折叠图例'"
|
||||
class="collapse-icon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 清除按钮 -->
|
||||
<!-- <div class="clear-button" @click="handleClearClick">
|
||||
<img :src="clearIcon" alt="清除" class="clear-icon" />
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
// 导入图例背景图和控制图标
|
||||
import backgroundImg from '../assets/legendTool/图例工具栏iconBg.png'
|
||||
import downArrowIcon from '../assets/legendTool/下箭头.png'
|
||||
import upArrowIcon from '../assets/legendTool/上箭头 .png'
|
||||
// import clearIcon from '../assets/legend/清除icon.png'
|
||||
|
||||
// 导入图例图标(普通图标和定位图标)
|
||||
import commonRoadIcon from '../assets/legendTool/普通公路icon.png'
|
||||
import commonRoadMarkerIcon from '../assets/legendTool/普通公路icon定位.png'
|
||||
import videoIcon from '../assets/legendTool/视频icon.png'
|
||||
import videoMarkerIcon from '../assets/legendTool/视频icon定位.png'
|
||||
import trafficMonitorIcon from '../assets/legendTool/交调icon.png'
|
||||
import trafficMonitorMarkerIcon from '../assets/legendTool/交调icon定位.png'
|
||||
import bridgeIcon from '../assets/legendTool/桥梁icon.png'
|
||||
import bridgeMarkerIcon from '../assets/legendTool/桥梁icon定位.png'
|
||||
import tunnelIcon from '../assets/legendTool/隧洞icon.png'
|
||||
import tunnelMarkerIcon from '../assets/legendTool/隧洞icon定位.png'
|
||||
import serviceFacilityIcon from '../assets/legendTool/服务设施icon.png'
|
||||
import serviceFacilityMarkerIcon from '../assets/legendTool/服务设施icon定位.png'
|
||||
import riskRoadIcon from '../assets/legendTool/风险路段icon.png'
|
||||
import riskRoadMarkerIcon from '../assets/legendTool/风险路段icon定位.png'
|
||||
import hazardPointIcon from '../assets/legendTool/涉灾隐患点icon.png'
|
||||
import hazardPointMarkerIcon from '../assets/legendTool/涉灾隐患点icon定位.png'
|
||||
import blockEventIcon from '../assets/legendTool/阻断事件icon.png'
|
||||
import blockEventMarkerIcon from '../assets/legendTool/阻断事件icon定位.png'
|
||||
import emergencyForceIcon from '../assets/legendTool/应急力量icon.png'
|
||||
import emergencyForceMarkerIcon from '../assets/legendTool/应急力量icon定位.png'
|
||||
import weatherAlertIcon from '../assets/legendTool/气象预警icon.png'
|
||||
|
||||
// 默认图例项配置(包含普通图标和地图定位图标)
|
||||
const defaultLegendItems = [
|
||||
{ key: 'commonRoad', label: '普通公路', icon: commonRoadIcon, markerIcon: commonRoadMarkerIcon },
|
||||
{ key: 'video', label: '视频', icon: videoIcon, markerIcon: videoMarkerIcon },
|
||||
{ key: 'trafficMonitor', label: '交调', icon: trafficMonitorIcon, markerIcon: trafficMonitorMarkerIcon },
|
||||
{ key: 'bridge', label: '桥梁', icon: bridgeIcon, markerIcon: bridgeMarkerIcon },
|
||||
{ key: 'tunnel', label: '隧洞', icon: tunnelIcon, markerIcon: tunnelMarkerIcon },
|
||||
{ key: 'serviceFacility', label: '服务设施', icon: serviceFacilityIcon, markerIcon: serviceFacilityMarkerIcon },
|
||||
{ key: 'riskRoad', label: '风险路段', icon: riskRoadIcon, markerIcon: riskRoadMarkerIcon },
|
||||
{ key: 'hazardPoint', label: '涉灾隐患点', icon: hazardPointIcon, markerIcon: hazardPointMarkerIcon },
|
||||
{ key: 'blockEvent', label: '阻断事件', icon: blockEventIcon, markerIcon: blockEventMarkerIcon },
|
||||
{ key: 'emergencyForce', label: '应急力量', icon: emergencyForceIcon, markerIcon: emergencyForceMarkerIcon },
|
||||
{ key: 'weatherAlert', label: '气象预警', icon: weatherAlertIcon, markerIcon: weatherAlertIcon }
|
||||
]
|
||||
|
||||
// 定义 props
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 自定义图例项配置
|
||||
* @type {Array<{ key: string; label: string; icon: string; markerIcon?: string; markerType?: string }>}
|
||||
*/
|
||||
legendItems: {
|
||||
type: Array,
|
||||
default: null
|
||||
},
|
||||
|
||||
/**
|
||||
* Marker 数据配置(可选)
|
||||
* 如果提供,将在事件中传递完整的 marker 数据
|
||||
* @type {Record<string, { markers?: Array; icon?: string; markerIcon?: string; meta?: any }>}
|
||||
*/
|
||||
markerConfig: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始激活的图例项 key 列表
|
||||
* @type {Array<string>}
|
||||
*/
|
||||
defaultActive: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
|
||||
/**
|
||||
* 支持 v-model 的激活项列表(受控模式)
|
||||
* @type {Array<string>}
|
||||
*/
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: null
|
||||
},
|
||||
|
||||
/**
|
||||
* 兼容旧版:标记点切换回调函数
|
||||
* @deprecated 推荐使用 @marker-toggle 事件
|
||||
*/
|
||||
onMarkerToggle: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// 定义 emits
|
||||
const emit = defineEmits(['marker-toggle', 'clear', 'update:modelValue', 'collapse-change'])
|
||||
|
||||
// 计算图例项配置(支持自定义)
|
||||
const computedLegendItems = computed(() => {
|
||||
return props.legendItems && props.legendItems.length > 0
|
||||
? props.legendItems
|
||||
: defaultLegendItems
|
||||
})
|
||||
|
||||
// 激活的图例项(使用 Set 管理唯一值)
|
||||
// 支持受控和非受控两种模式
|
||||
const internalActiveItems = ref(new Set(props.defaultActive))
|
||||
|
||||
// 折叠状态(默认展开)
|
||||
const isCollapsed = ref(false)
|
||||
|
||||
// 如果是受控模式(提供了 modelValue),同步外部状态
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (newValue !== null && Array.isArray(newValue)) {
|
||||
internalActiveItems.value = new Set(newValue)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 同步 defaultActive 的变化
|
||||
watch(
|
||||
() => props.defaultActive,
|
||||
(newValue) => {
|
||||
// 仅在非受控模式下同步
|
||||
if (props.modelValue === null && Array.isArray(newValue)) {
|
||||
internalActiveItems.value = new Set(newValue)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 获取当前激活项集合(优先使用外部状态)
|
||||
const activeItems = computed(() => {
|
||||
return props.modelValue !== null && Array.isArray(props.modelValue)
|
||||
? new Set(props.modelValue)
|
||||
: internalActiveItems.value
|
||||
})
|
||||
|
||||
/**
|
||||
* 判断图例项是否激活
|
||||
* @param {string} key - 图例项 key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isActive = (key) => {
|
||||
return activeItems.value.has(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建事件 payload
|
||||
* @param {string} key - 图例项 key
|
||||
* @param {boolean} active - 是否激活
|
||||
* @returns {Object}
|
||||
*/
|
||||
const buildPayload = (key, active) => {
|
||||
const payload = { key, active }
|
||||
const legendItem = computedLegendItems.value.find((item) => item.key === key)
|
||||
|
||||
// 如果提供了 markerConfig,附加完整数据
|
||||
if (props.markerConfig && props.markerConfig[key]) {
|
||||
const config = props.markerConfig[key]
|
||||
if (config.markers) payload.markers = config.markers
|
||||
if (config.icon) payload.icon = config.icon
|
||||
if (config.markerIcon) payload.markerIcon = config.markerIcon
|
||||
if (config.meta) payload.meta = config.meta
|
||||
}
|
||||
|
||||
// 从默认图例项或自定义图例项中获取图标信息
|
||||
if (legendItem) {
|
||||
// 只在 payload 中不存在时才赋值(markerConfig 优先)
|
||||
if (!payload.icon && legendItem.icon) {
|
||||
payload.icon = legendItem.icon
|
||||
}
|
||||
if (!payload.markerIcon && legendItem.markerIcon) {
|
||||
payload.markerIcon = legendItem.markerIcon
|
||||
}
|
||||
if (legendItem.label) payload.label = legendItem.label
|
||||
if (legendItem.markerType) payload.markerType = legendItem.markerType
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理图例项点击事件
|
||||
* @param {string} key - 图例项 key
|
||||
*/
|
||||
const handleItemClick = (key) => {
|
||||
const newIsActive = !activeItems.value.has(key)
|
||||
|
||||
// 更新状态
|
||||
const isControlled = Array.isArray(props.modelValue)
|
||||
if (isControlled) {
|
||||
// 受控模式:通过 emit 通知父组件更新
|
||||
const newActiveKeys = newIsActive
|
||||
? [...props.modelValue, key]
|
||||
: props.modelValue.filter((k) => k !== key)
|
||||
emit('update:modelValue', newActiveKeys)
|
||||
} else {
|
||||
// 非受控模式:直接更新内部状态
|
||||
const newSet = new Set(internalActiveItems.value)
|
||||
if (newIsActive) {
|
||||
newSet.add(key)
|
||||
} else {
|
||||
newSet.delete(key)
|
||||
}
|
||||
internalActiveItems.value = newSet
|
||||
}
|
||||
|
||||
// 构建并发送事件
|
||||
const payload = buildPayload(key, newIsActive)
|
||||
emit('marker-toggle', payload)
|
||||
|
||||
// 兼容旧版 props 回调
|
||||
if (props.onMarkerToggle) {
|
||||
props.onMarkerToggle(
|
||||
key,
|
||||
payload.markers || [],
|
||||
payload.icon || null,
|
||||
!newIsActive
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换图例工具栏的折叠状态
|
||||
*/
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
emit('collapse-change', { collapsed: isCollapsed.value })
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理清除按钮点击事件
|
||||
*/
|
||||
const handleClearClick = () => {
|
||||
const clearedKeys = Array.from(activeItems.value)
|
||||
|
||||
// 逐一发送关闭通知
|
||||
clearedKeys.forEach((key) => {
|
||||
const payload = buildPayload(key, false)
|
||||
emit('marker-toggle', payload)
|
||||
|
||||
// 兼容旧版回调
|
||||
if (props.onMarkerToggle) {
|
||||
props.onMarkerToggle(key, payload.markers || [], payload.icon || null, false)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新状态
|
||||
const isControlled = Array.isArray(props.modelValue)
|
||||
if (isControlled) {
|
||||
emit('update:modelValue', [])
|
||||
} else {
|
||||
internalActiveItems.value = new Set()
|
||||
}
|
||||
|
||||
// 发送清除事件
|
||||
emit('clear', { cleared: clearedKeys })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '@/styles/mixins.scss' as *;
|
||||
|
||||
.legend-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: vw(15);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.legend-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: vh(8);
|
||||
}
|
||||
|
||||
.legend-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: vh(10) vw(20);
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
gap: vw(20);
|
||||
border-radius: vw(8);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: vh(8);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
padding: vh(8) vw(8);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(vh(-2));
|
||||
|
||||
.legend-icon {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
.legend-icon {
|
||||
filter: brightness(1.3) drop-shadow(0 0 vw(8) rgba(30, 144, 255, 0.8));
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
color: #1e90ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend-icon {
|
||||
width: vw(40);
|
||||
height: vh(40);
|
||||
object-fit: contain;
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: fs(12);
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
text-shadow: 0 vh(1) vh(2) rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.collapse-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
padding: vh(4) 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(vh(-2));
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid rgba(30, 144, 255, 0.8);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
width: vw(24);
|
||||
height: vh(24);
|
||||
object-fit: contain;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
// 折叠动画
|
||||
.legend-collapse-enter-active,
|
||||
.legend-collapse-leave-active {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.legend-collapse-enter-from,
|
||||
.legend-collapse-leave-to {
|
||||
opacity: 0;
|
||||
transform: scaleY(0.95);
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(vh(-2));
|
||||
box-shadow: 0 vh(4) vh(12) rgba(30, 144, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
width: vw(40);
|
||||
height: vh(40);
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||