feat(cockpit): 添加带有可折叠地图图例的图例工具栏组件

*   引入 LegendToolbar.vue 组件,用于显示和切换地图图例
*   将工具栏集成到 CockpitLayout.vue 中,并采用绝对定位
*   为各种地图元素(道路、视频、桥梁等)添加相应的图标素材
*   调整 CSS 网格属性以提高布局响应能力
This commit is contained in:
Zzc 2025-11-13 11:34:12 +08:00
parent a4e94c4fb7
commit 49ea7f26fd
26 changed files with 451 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -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 {

View 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>