This commit is contained in:
huangchenhao 2025-11-13 16:22:32 +08:00
commit 3bd42ef2a7
31 changed files with 548 additions and 30 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

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

@ -14,9 +14,12 @@
<img src="../assets/img/block-stat-icon.png" alt="icon" class="stat-icon" /> <img src="../assets/img/block-stat-icon.png" alt="icon" class="stat-icon" />
<div class="stat-content"> <div class="stat-content">
<div class="stat-title">{{ stat.title }}</div> <div class="stat-title">{{ stat.title }}</div>
<div class="stat-value-container">
<div class="stat-value">{{ stat.value }}</div> <div class="stat-value">{{ stat.value }}</div>
<div class="stat-unit">公里</div> <div class="stat-unit">公里</div>
<img src="../assets/img/block-stat-decoration.png" alt="decoration" class="stat-decoration" /> </div>
<!-- <img src="../assets/img/block-stat-decoration.png" alt="decoration" class="stat-decoration" /> -->
</div> </div>
</div> </div>
</div> </div>
@ -113,13 +116,24 @@ const stats = ref([
} }
.stat-unit { .stat-unit {
position: absolute; // position: absolute;
top: vh(50); // top: vh(50);
right: vw(20); // right: vw(20);
color: rgba(255, 255, 255, 1); color: rgba(255, 255, 255, 1);
font-size: fs(15); font-size: fs(15);
} }
.stat-value-container {
display: flex;
align-items: flex-end;
justify-content: space-between;
background: url(../assets/img/block-stat-decoration.png) no-repeat;
background-size: 100% auto;
background-position: bottom;
padding: 0 vw(40) vh(10) 0;
}
.stat-decoration { .stat-decoration {
width: vw(154); width: vw(154);
height: vh(29); height: vh(29);

View File

@ -9,6 +9,7 @@
<div class="center-panel"> <div class="center-panel">
<MapCenter /> <MapCenter />
<LegendToolbar style="position: absolute; bottom: 10px; right: 50%; transform: translateX(50%);"/>
</div> </div>
<div class="right-panel"> <div class="right-panel">
@ -26,6 +27,7 @@ import EmergencyResources from './EmergencyResources.vue'
import MapCenter from './MapCenter.vue' import MapCenter from './MapCenter.vue'
import BlockEvent from './BlockEvent.vue' import BlockEvent from './BlockEvent.vue'
import YearStatistics from './YearStatistics.vue' import YearStatistics from './YearStatistics.vue'
import LegendToolbar from './LegendToolbar.vue'
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -72,7 +74,7 @@ import YearStatistics from './YearStatistics.vue'
/* 嵌入场景的 CSS 自定义属性 */ /* 嵌入场景的 CSS 自定义属性 */
--cockpit-side-width: minmax(15rem, 28%); --cockpit-side-width: minmax(15rem, 28%);
--cockpit-gap: 1.25rem; --cockpit-gap: 1rem;
--cockpit-padding-x: 0.625rem; --cockpit-padding-x: 0.625rem;
--cockpit-padding-top: 0; --cockpit-padding-top: 0;
--cockpit-padding-bottom: 0; --cockpit-padding-bottom: 0;
@ -102,6 +104,7 @@ import YearStatistics from './YearStatistics.vue'
min-height: 0; /* 允许网格在 flex 上下文中收缩 */ min-height: 0; /* 允许网格在 flex 上下文中收缩 */
display: grid; display: grid;
grid-template-columns: var(--cockpit-side-width) 1fr var(--cockpit-side-width); grid-template-columns: var(--cockpit-side-width) 1fr var(--cockpit-side-width);
grid-auto-rows: 1fr; /* 强制行占据可用高度 */
gap: var(--cockpit-gap); gap: var(--cockpit-gap);
padding: var(--cockpit-padding-top) var(--cockpit-padding-x) var(--cockpit-padding-bottom); 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; flex-direction: column;
gap: var(--cockpit-gap); gap: var(--cockpit-gap);
min-width: 0; /* 防止在窄容器中溢出 */ min-width: 0; /* 防止在窄容器中溢出 */
min-height: 0; /* 允许 flex 子元素收缩并启用滚动 */
} }
.center-panel { .center-panel {

View File

@ -13,6 +13,7 @@
<span>应急设备</span> <span>应急设备</span>
</div> </div>
<div class="table-body">
<div <div
v-for="(resource, index) in resources" v-for="(resource, index) in resources"
:key="resource.id" :key="resource.id"
@ -20,11 +21,11 @@
:class="{ 'row-alt': index % 2 === 0 }" :class="{ 'row-alt': index % 2 === 0 }"
> >
<div class="row-number">{{ index + 1 }}</div> <div class="row-number">{{ index + 1 }}</div>
<span class="district-name">{{ resource.name }}</span> <span class="district-name">{{ resource.qxmc }}</span>
<span class="count green">{{ resource.stations }}</span> <span class="count green">{{ resource.yhzCount }}</span>
<span class="count orange">{{ resource.supplies }}</span> <span class="count orange">{{ resource.wzCount }}</span>
<div class="equipment-cell"> <div class="equipment-cell">
<span class="count" :class="resource.equipmentClass">{{ resource.equipment }}</span> <span class="count" :class="resource.equipmentClass">{{ resource.sbCount }}</span>
<img <img
v-if="resource.hasAlert" v-if="resource.hasAlert"
src="../assets/img/emergency-alert-icon.png" src="../assets/img/emergency-alert-icon.png"
@ -35,10 +36,12 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { request } from '@shared/utils/request'
const resources = ref([ const resources = ref([
{ {
@ -96,6 +99,25 @@ const resources = ref([
hasAlert: false hasAlert: false
} }
]) ])
// /district/statistics
const getDistrictStatistics = async () => {
const res = await request({
url: '/snow-ops-platform/district/statistics',
method: 'GET'
})
console.log(res)
if(res.code === '00000') {
resources.value = res.data
} else {
console.log(res.message)
}
}
onMounted(() => {
getDistrictStatistics()
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -106,6 +128,7 @@ const resources = ref([
background-size: 100% 100%; background-size: 100% 100%;
padding: vw(20) vw(30); padding: vw(20) vw(30);
flex: 1; flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@ -131,6 +154,7 @@ const resources = ref([
.resource-table { .resource-table {
flex: 1; flex: 1;
min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -149,6 +173,34 @@ const resources = ref([
margin-bottom: vh(5); margin-bottom: vh(5);
} }
.table-body {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(28, 161, 255, 0.4) transparent;
&::-webkit-scrollbar {
width: vw(4);
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: rgba(28, 161, 255, 0.4);
border-radius: vw(2);
&:hover {
background: rgba(28, 161, 255, 0.6);
}
}
}
.table-row { .table-row {
height: vh(35); height: vh(35);
display: grid; display: grid;

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>

View File

@ -15,16 +15,16 @@
<div class="badge-container"> <div class="badge-container">
<!-- 六边形徽章 --> <!-- 六边形徽章 -->
<div class="level-badge"> <div class="level-badge">
<img <!-- <img
src="../assets/img/weather-badge-hexagon.png" src="../assets/img/weather-badge-hexagon.png"
alt="badge" alt="badge"
class="badge-bg" class="badge-bg"
/> /> -->
<span class="level-count" :style="{ color: level.color }">{{ level.count }}</span> <span class="level-count" :style="{ color: level.color }">{{ level.count }}</span>
</div> </div>
<!-- 底座光圈双背景图 --> <!-- 底座光圈双背景图 -->
<div class="glow-base"></div> <!-- <div class="glow-base"></div> -->
</div> </div>
<!-- 底部文字 --> <!-- 底部文字 -->
@ -158,7 +158,7 @@ const districts = ref([
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: vh(10); // gap: vh(10);
// //
.badge-container { .badge-container {
@ -177,6 +177,7 @@ const districts = ref([
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 2; z-index: 2;
background: url(../assets/img/weather-badge-bg.png) center center / 100% 100% no-repeat;
.badge-bg { .badge-bg {
position: absolute; position: absolute;
@ -192,6 +193,7 @@ const districts = ref([
font-weight: normal; font-weight: normal;
z-index: 1; z-index: 1;
line-height: 1; line-height: 1;
margin-top: vh(-18);
} }
} }