地图整体重构优化,添加梯级图显示和弹框选择

This commit is contained in:
扈兆增 2026-06-03 15:03:31 +08:00
parent a82a1142f7
commit de72eb2281
33 changed files with 6659 additions and 2864 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,380 @@
# 地图模块重构实施清单
## 1. 使用说明
本文档是地图模块重构的执行清单,只保留当前已经明确、可以直接落地的实施项。
以下内容暂不纳入本清单:
- 仍需进一步讨论的方案;
- 依赖后端配合后才能确定的优化项;
- 暂未确定收益与改造成本的预研项;
- 调试面板、埋点体系、长期性能平台化建设等扩展项。
执行原则:
- 先止血,再拆层,再提速;
- 每一步都要保证现有业务可用;
- 每个阶段结束后都要做功能回归;
- 不在同一阶段同时改动 UI、Store、地图渲染层的大块逻辑。
---
## 2. 本轮重构目标
本轮只完成以下确定目标:
- 收口当前明显缺陷和遗留代码;
- 建立基础类型和索引;
- 将分散在组件中的核心业务规则收口;
- 拆分地图 Store建立更清晰的数据职责
- 抽离统一编排层;
- 拆分 `map.ol.ts` 的核心管理职责;
- 保证图层树、图例、锚点显隐链路更清晰;
- 为后续继续重构预留稳定结构。
---
## 3. 阶段一:现状收口与问题修复
## 3.1 清理明显遗留问题
- [ ] 删除 `map.ol.ts` 中无意义代码和重复日志
- [ ] 清理大段失效注释,保留必要说明
- [ ] 统一地图相关文件中的临时 `console.log`
涉及文件:
- `src/components/gis/map.ol.ts`
验收标准:
- 页面可正常编译;
- 首次进入地图页面不报错;
- 控制台不再出现未定义函数或变量错误;
- 默认图层、图例、锚点仍可正常显示。
## 3.2 补充基础类型定义
- [ ] 新增地图图层类型定义
- [ ] 新增图例结构类型定义
- [ ] 新增锚点数据类型定义
- [ ] 新增筛选参数类型定义
- [ ] 为 `map.ts`、`map.class.ts`、`map.ol.ts` 替换第一批高频 `any`
建议新增文件:
- `src/modules/map/types/map-layer.ts`
- `src/modules/map/types/map-legend.ts`
- `src/modules/map/types/map-point.ts`
- `src/modules/map/types/map-filter.ts`
验收标准:
- 核心地图模块类型边界可读;
- 新增类型后不影响现有运行;
- `map.ts`、`map.ol.ts` 中关键方法参数不再全部使用 `any`
## 3.3 建立基础索引
- [x] 建立 `layerByKey` 索引
- [x] 建立 `legendByNameEn` 索引
- [x] 建立 `legendByLayerCode` 索引
- [x] 将 `findLayerByKey()` 的高频调用逐步替换为索引访问
- [x] 将图例派生中的重复递归改为“初始化索引 + 增量查询”
优先改造位置:
- `src/store/modules/map.ts`
验收标准:
- 图层查找和图例查找不再完全依赖全量递归;
- 图层勾选、图例切换后响应速度不下降;
- 功能表现与现状一致。
---
## 4. 阶段二:规则收口
## 4.1 收口互斥规则
- [x] 将图层互斥逻辑从 `LayerController.vue` 中移出
- [x] 将视频监控站与 AI 视频监控站互斥统一到规则函数
- [x] 将环保设施与环保设施在建互斥统一到规则函数
- [x] 将珍稀鱼类与沿程鱼类互斥统一到规则函数
- [x] 保证互斥规则只有一个入口执行
建议新增文件:
- `src/modules/map/domain/map-layer-rules.ts`
涉及文件:
- `src/components/mapController/LayerController.vue`
- `src/store/modules/map.ts`
验收标准:
- 图层树勾选时互斥结果稳定;
- 不再同时在组件和 Store 两处维护同一套互斥规则;
- 勾选结果、图例结果、地图显示结果保持一致。
## 4.2 收口图例派生规则
- [x] 将“图层选中后生成图例”逻辑提炼为独立函数
- [x] 将“环保设施图例自动补充”逻辑提炼为独立规则
- [x] 分离图例原始结构与图例选中状态
- [x] 保证 `legendDataOriginal` 不再被运行态逻辑污染
建议新增文件:
- `src/modules/map/domain/legend-deriver.ts`
涉及文件:
- `src/store/modules/map.ts`
- `src/components/mapLegend/index.vue`
验收标准:
- 图例显示仍按当前选中图层正确联动;
- 图例点击后锚点显隐正确;
- 图例原始结构与运行时状态职责更清晰。
---
## 5. 阶段三:拆分 Store
## 5.1 拆分配置状态
- [ ] 抽离图层树配置状态
- [ ] 抽离图例原始配置状态
- [ ] 抽离页面地图配置加载逻辑
建议新增文件:
- `src/modules/map/stores/map-config.store.ts`
当前迁移来源:
- `src/store/modules/map.ts`
- `src/api/map.ts`
## 5.2 拆分运行状态
- [ ] 抽离当前选中图层 key
- [ ] 抽离当前选中图例 key
- [ ] 抽离当前基地、时间范围、缩放级别等运行态
- [ ] 统一“当前地图状态”的唯一来源
建议新增文件:
- `src/modules/map/stores/map-view.store.ts`
## 5.3 拆分数据缓存
- [ ] 抽离锚点缓存
- [ ] 抽离图层加载状态
- [ ] 抽离锚点合并数据 `pointData`
- [ ] 为每个图层维护独立 `loading / loaded / error`
建议新增文件:
- `src/modules/map/stores/map-data.store.ts`
验收标准:
- 原 `map.ts` 体积明显下降;
- 配置、运行态、数据缓存职责分离;
- 现有组件仍可通过兼容方式读取数据;
- 默认图层、图例、锚点加载不受影响。
---
## 6. 阶段四:建立统一编排层
## 6.1 新增地图编排器
- [x] 新增地图模块统一编排入口
- [x] 将地图初始化流程迁移到编排器
- [x] 将图层勾选、图例勾选、基地切换、时间筛选、搜索定位改为统一命令
- [x] 收口 `GisView.vue` 中的配置加载与监听联动逻辑
建议新增文件:
- `src/modules/map/application/map-orchestrator.ts`
## 6.2 统一入口命令
- [x] 定义 `initialize(pageKey)`
- [x] 定义 `toggleLayer(layerKey)`
- [x] 定义 `toggleLegend(layerKey, legendKey)`
- [x] 定义 `changeBaseId(baseId)`
- [x] 定义 `changeTimeRange(range)`
- [x] 定义 `focusPoint(pointId)`
涉及文件:
- `src/components/gis/GisView.vue`
- `src/components/mapController/LayerController.vue`
- `src/components/mapLegend/index.vue`
- `src/components/mapFilter/index.vue`
验收标准:
- 地图相关 UI 组件不再直接拼复杂业务逻辑;
- 组件交互都通过统一入口触发;
- 图层树、图例、筛选的联动链路更容易追踪。
---
## 7. 阶段五:拆分 OpenLayers 渲染层
## 7.1 拆分点图层管理
- [ ] 从 `map.ol.ts` 中抽离锚点图层创建逻辑
- [ ] 从 `map.ol.ts` 中抽离锚点显隐逻辑
- [ ] 从 `map.ol.ts` 中抽离图例控制锚点逻辑
- [ ] 在点图层管理模块中维护图层注册表
建议新增文件:
- `src/components/gis/ol/point-layer-manager.ts`
当前迁移来源:
- `src/components/gis/map.ol.ts`
## 7.2 拆分 Popup 管理
- [ ] 抽离 `initPopupOverlay()`
- [ ] 抽离 `detectFeatureAtPixel()`
- [ ] 抽离 `showPopup()`
- [ ] 收口 hover 和 click 交互逻辑
建议新增文件:
- `src/components/gis/ol/popup-manager.ts`
## 7.3 拆分区域过滤管理
- [x] 抽离基地切换后的区域裁切逻辑
- [x] 抽离点在面内判断逻辑
- [x] 抽离区域外锚点显隐逻辑
- [x] 抽离遮罩应用与清理逻辑
建议新增文件:
- `src/components/gis/ol/region-mask-manager.ts`
验收标准:
- `map.ol.ts` 文件长度和职责明显收缩;
- 点图层、Popup、基地裁切三类逻辑各自独立
- 现有地图交互行为不变。
---
## 8. 阶段六:组件瘦身
## 8.1 `GisView.vue`
- [x] 仅保留地图容器、地图初始化调用、组件挂载
- [x] 移除配置请求和复杂 watch 编排逻辑
- [x] 只通过编排器触发初始化和页面切换
## 8.2 `LayerController.vue`
- [x] 只保留树渲染与勾选事件
- [x] 移除互斥规则
- [x] 移除基础图层初始化编排逻辑
## 8.3 `MapLegend.vue`
- [x] 只保留图例渲染与点击事件分发
- [x] 不直接调用地图实例
## 8.4 `MapFilter.vue`
- [x] 只保留表单与用户输入
- [x] 搜索、容量、时间、基地变化全部通过编排器分发
- [x] 不直接修改图例运行态和锚点显隐
验收标准:
- 组件文件职责单一;
- UI 组件中不再出现大段业务逻辑;
- 地图相关行为主要集中在编排层和 Store。
---
## 9. 阶段七:性能优化
## 9.1 请求与缓存优化
- [x] 将 `GisUrlList.ts` 预编译为索引字典,而不是每次请求时重建
- [x] 实现真实请求去重
- [x] 增加请求取消能力,避免菜单切换时旧请求回写
- [x] 对图层加载增加并发控制
- [x] 将缓存 key 与图层 key、筛选条件绑定
## 9.2 锚点显示优化
- [x] 图例切换时只处理目标图例对应的锚点集合
- [x] 图层切换时优先切换图层可见性,不重复加点
- [x] 减少 hover 时全量点图层重绘
- [x] 减少样式函数中的业务判断复杂度
验收标准:
- 首屏加载时长不高于当前;
- 图层勾选、图例勾选、基地切换响应速度优于当前;
- 菜单快速切换时不出现旧数据回写。
---
## 10. 回归测试清单
每完成一个阶段,至少回归以下功能:
- [x] 地图初始化正常
- [x] 默认基础图层正常显示
- [x] 图层树勾选与取消勾选正常
- [x] 图例随图层选中动态显示
- [x] 图例点击能正确控制锚点显隐
- [x] 基地切换后区域裁切和锚点过滤正常
- [x] 锚点 hover Popup 正常
- [x] 锚点点击详情弹窗正常
- [x] 时间筛选、容量筛选、搜索定位正常
- [x] 视频监控站与 AI 视频监控站互斥正常
- [x] 菜单切换后地图数据不串页
---
## 11. 实施顺序建议
推荐严格按以下顺序执行:
1. 阶段一:现状收口与问题修复
2. 阶段二:规则收口
3. 阶段三:拆分 Store
4. 阶段四:建立统一编排层
5. 阶段五:拆分 OpenLayers 渲染层
6. 阶段六:组件瘦身
7. 阶段七:性能优化
不建议直接跳到渲染层拆分或性能优化,否则会在旧结构上重复返工。
---
## 12. 本轮交付标准
当本清单全部完成后,应达到以下结果:
- 地图模块核心职责划分清晰;
- 图层树、图例、锚点、筛选、基地切换链路可追踪;
- 关键规则不再散落在多个组件里;
- `map.ts``map.ol.ts` 体积明显下降;
- 新增地图业务时有明确扩展点;
- 后续继续做底图体系优化、长期性能建设时不需要再推翻本轮结构。

View File

@ -10,7 +10,6 @@ export function getModuleMapLegendList(params?: { moduleId?: string }) {
method: 'get' method: 'get'
}); });
} }
// 获取地图配置列表 // 获取地图配置列表
export function getMapList(data: any) { export function getMapList(data: any) {
return request({ return request({
@ -19,3 +18,28 @@ export function getMapList(data: any) {
data data
}); });
} }
// 获取梯级流域地图
export function getQgcRvcd(data: any) {
return request({
url: '/api/wmp-eng-server/eng/rsvrcscdb/getQgcRvcd',
method: 'post',
data
});
}
// 获取梯级流域下拉框列表
export function getRvcdList(data: any) {
return request({
url: '/api/wmp-eng-server/eng/rsvrcscdb/rvcd',
method: 'post',
data
});
}
// 获取梯级流域下拉框图表数据
export function getKendoList(data: any) {
return request({
url: '/api/wmp-eng-server/eng/rsvrcscdb/GetKendoList',
method: 'post',
data
});
}

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1722846922436" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8522" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M512 56.888889C261.688889 56.888889 56.888889 261.688889 56.888889 512s204.8 455.111111 455.111111 455.111111 455.111111-204.8 455.111111-455.111111-204.8-455.111111-455.111111-455.111111m0 853.333333c-221.866667 0-398.222222-176.355556-398.222222-398.222222s176.355556-398.222222 398.222222-398.222222 398.222222 176.355556 398.222222 398.222222-176.355556 398.222222-398.222222 398.222222" fill="#1296db" p-id="8523"></path><path d="M512 682.666667c-17.066667 0-28.444444 5.688889-39.822222 17.066666-11.377778 11.377778-17.066667 22.755556-17.066667 39.822223 0 17.066667 5.688889 28.444444 17.066667 39.822222 11.377778 11.377778 22.755556 17.066667 39.822222 17.066666 17.066667 0 28.444444-5.688889 39.822222-17.066666 11.377778-11.377778 17.066667-22.755556 17.066667-39.822222 0-17.066667-5.688889-28.444444-17.066667-39.822223-11.377778-11.377778-22.755556-17.066667-39.822222-17.066666z m-51.2-455.111111l17.066667 409.6h62.577777L563.2 227.555556H460.8z" fill="#1296db" p-id="8524"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -22,7 +22,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useUiStore } from '@/store/modules/ui'; import { useUiStore } from '@/store/modules/ui';
import { useMapStore } from '@/store/modules/map'; import { useMapConfigStore } from '@/modules/map/stores/map-config.store';
import { useMapViewStore } from '@/modules/map/stores/map-view.store';
import { layerConfig2Flat } from '@/components/gis/gisUtils'; import { layerConfig2Flat } from '@/components/gis/gisUtils';
import shiliangImg from '@/assets/images/map-shiliangtu.png'; import shiliangImg from '@/assets/images/map-shiliangtu.png';
import dixingImg from '@/assets/images/map-dixingtu.png'; import dixingImg from '@/assets/images/map-dixingtu.png';
@ -37,7 +38,8 @@ const props = defineProps({
} }
}); });
const uiStore = useUiStore(); const uiStore = useUiStore();
const mapStore = useMapStore(); const mapConfigStore = useMapConfigStore();
const mapViewStore = useMapViewStore();
const drawerOpen = ref(uiStore.drawerOpen); const drawerOpen = ref(uiStore.drawerOpen);
// store drawerOpen // store drawerOpen
@ -64,7 +66,7 @@ const activeKey = ref(layers[0].key);
* 判断图层管理中的基础底图customBaseLayer是否被选中 * 判断图层管理中的基础底图customBaseLayer是否被选中
*/ */
const isCustomBaseLayerChecked = (): boolean => { const isCustomBaseLayerChecked = (): boolean => {
const layerConfig = layerConfig2Flat(mapStore.layerData); const layerConfig = layerConfig2Flat(mapConfigStore.layerConfigTree);
const customBaseLayer = layerConfig.find( const customBaseLayer = layerConfig.find(
(item: any) => item.key === 'customBaseLayer' (item: any) => item.key === 'customBaseLayer'
); );
@ -74,7 +76,7 @@ const isCustomBaseLayerChecked = (): boolean => {
} }
// //
const checkedKeys = mapStore.checkedLayerKeys; const checkedKeys = mapViewStore.checkedLayerKeys;
if (checkedKeys.includes('customBaseLayer')) { if (checkedKeys.includes('customBaseLayer')) {
return true; return true;
} }

View File

@ -1,47 +1,48 @@
<!-- SidePanelItem.vue --> <!-- SidePanelItem.vue -->
<template> <template>
<div class="rightContentDrawer"> <div class="rightContentDrawer">
<div @click="handleToggle" class="drawerController1" v-if="!uiStore.drawerOpen"> <div
@click="handleToggle"
class="drawerController1"
v-if="!uiStore.drawerOpen"
>
<img src="../../assets/components/arrow-left.png" alt="" /> <img src="../../assets/components/arrow-left.png" alt="" />
</div> </div>
<!-- 使用 Vue Transition 组件控制动画 --> <a-drawer
<transition name="drawer-slide"> :get-container="false"
<a-drawer :style="{ position: 'relative' }"
:get-container="false" v-model:open="uiStore.drawerOpen"
:style="{ position: 'relative' }" :mask="false"
v-model:open="uiStore.drawerOpen" placement="right"
:mask="false" width="450"
placement="right" :closable="false"
width="450" :headerStyle="{ color: '#FAFCFE' }"
:closable="false" >
:headerStyle="{ color: '#FAFCFE' }" <div @click="handleToggle" class="drawerController">
> <img src="../../assets/components/arrow-right.png" alt="" />
<div @click="handleToggle" class="drawerController"> </div>
<img src="../../assets/components/arrow-right.png" alt="" /> <div style="padding: 16px 16px 0" class="text_she">
</div> <slot />
<div style="padding:16px 16px 0;" class="text_she"> </div>
<slot /> </a-drawer>
</div>
</a-drawer>
</transition>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, defineOptions, watch } from "vue"; import { ref, defineOptions, watch } from 'vue';
import { useUiStore } from "@/store/modules/ui"; import { useUiStore } from '@/store/modules/ui';
import { set } from "nprogress"; import { set } from 'nprogress';
// (便) // (便)
defineOptions({ defineOptions({
name: "rightDrawer", name: 'rightDrawer'
}); });
const uiStore = useUiStore(); const uiStore = useUiStore();
const handleToggle = () => { const handleToggle = () => {
uiStore.toggleDrawer() uiStore.toggleDrawer();
}; };
</script> </script>
@ -51,7 +52,7 @@ const handleToggle = () => {
height: 100%; height: 100%;
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
pointer-events: all; pointer-events: all;
.drawerController1 { .drawerController1 {
width: 18px; width: 18px;
@ -104,10 +105,12 @@ const handleToggle = () => {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.text_she{ .text_she {
font-size: 14px; font-size: 14px;
color:#262626; color: #262626;
font-variant: tabular-nums; font-variant: tabular-nums;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji,
Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
} }
</style> </style>

View File

@ -1,24 +1,19 @@
<template> <template>
<!-- 1. 切换菜单的时候图层没有切换 是因为接口的原因现在没有接口
2. 切换菜单的时候图例现在是不对的默认选中的数据他们没有做处理
1. 图层切换显示
2. 鼠标悬停点击完成
3. 文字重叠
4. 搜索
5. 公共弹框基础信息是eng的时候才显示电站专题
-->
<div class="gis-view"> <div class="gis-view">
<div id="mapContainer" /> <div id="mapContainer" />
<div ref="popupRef" class="map-popup-container" style="display: none"></div> <div ref="popupRef" class="map-popup-container" style="display: none"></div>
<!-- - 1. 切换菜单的时候图层没有切换 是因为接口的原因现在没有接口
2. 切换菜单的时候图例现在是不对的默认选中的数据他们没有做处理
1. 图层切换显示完成
2. 鼠标悬停点击完成
3. 文字重叠完成
4. 搜索 (完成)
5. 公共弹框基础信息是eng的时候才显示电站专题
6.图层加载的时候如果接口报错loading 一直加载完成
popup 隐藏还是有问题 完成 -->
<!-- 地图图例 --> <!-- 地图图例 -->
<MapLegend <MapLegend />
:setLegendDataMap="updateLegendDataMap"
:legendData="mapStore.legendData"
:legendDataMap="mapStore.legendDataMap"
:pointData="mapStore.pointData"
/>
<!-- 地图筛选器 --> <!-- 地图筛选器 -->
<MapFilter v-if="showMapFilter" :map="mapClass" /> <MapFilter v-if="showMapFilter" :map="mapClass" />
@ -28,38 +23,33 @@
<!-- 基础图层切换器 --> <!-- 基础图层切换器 -->
<BaseLayerSwitcher :map="mapClass" /> <BaseLayerSwitcher :map="mapClass" />
<!-- 梯级弹框 -->
<TjLayerModal v-model:open="tjModalVisible" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import _ from 'lodash';
import { ref, onMounted, watch, onUnmounted, computed } from 'vue'; import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
import MapLegend from '@/components/mapLegend/index.vue'; import MapLegend from '@/components/mapLegend/index.vue';
import MapFilter from '@/components/mapFilter/index.vue'; import MapFilter from '@/components/mapFilter/index.vue';
import MapController from '@/components/mapController/index.vue'; import MapController from '@/components/mapController/index.vue';
import BaseLayerSwitcher from '@/components/BaseLayerSwitcher/index.vue'; import BaseLayerSwitcher from '@/components/BaseLayerSwitcher/index.vue';
import TjLayerModal from './TjLayerModal.vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useMapStore } from '@/store/modules/map'; import { useMapOrchestrator } from '@/modules/map/application/map-orchestrator';
import { MapClass } from './map.class'; import { MapClass } from './map.class';
import { useJidiSelectEventStore } from '@/store/modules/jidiSelectEvent'; import { getQgcRvcd } from '@/api/map';
import { getMapList, getModuleMapLegendList } from '@/api/map'; import { servers } from './mapurlManage';
// Store const mapOrchestrator = useMapOrchestrator();
const mapStore = useMapStore();
const JidiSelectEventStore = useJidiSelectEventStore();
//
const route = useRoute(); const route = useRoute();
//
const mapClass = MapClass.getInstance(); const mapClass = MapClass.getInstance();
//
const mapIsInited = ref(false); const mapIsInited = ref(false);
const tlyLayerVisible = ref(false); const tlyLayerVisible = ref(false);
const popupRef = ref<HTMLDivElement>(null); const tjModalVisible = ref(false);
const loading = ref(false); const popupRef = ref<HTMLDivElement | null>(null);
// pageKey // key
const pageKey = computed(() => { const pageKey = computed(() => {
const path = route.path; const path = route.path;
const parts = path.split('/'); const parts = path.split('/');
@ -69,340 +59,79 @@ const pageKey = computed(() => {
return ''; return '';
}); });
// MapFilter //
const showMapFilter = computed(() => { const showMapFilter = computed(() => {
const path = route.path; const path = route.path;
// dianZhanZhuanTi/dianZhanZhuanTi return !path.includes('dianZhanZhuanTi/dianZhanZhuanTi');
if (path.includes('dianZhanZhuanTi/dianZhanZhuanTi')) {
return false;
}
return true;
}); });
// //
const isShuiDianKaiFaMenu = computed(() => { const isShuiDianKaiFaMenu = computed(() => {
const path = route.path; return route.path.includes('home/shuiDianKaiFaZhuangKuang');
return path.includes('home/shuiDianKaiFaZhuangKuang');
}); });
// //
const currentZoomLevel = ref(4.5);
// keys
const dynamicLayerKeys = [
'dw_point',
'stinfo_video_point',
'stinfo_gjllz_point'
];
//
let zoomListenerKey: any = null;
const setupZoomListener = () => {
if (mapClass.view) {
console.log('设置缩放监听器mapClass.view:', mapClass.view);
// OpenLayers 使 map change:resolution
const view = mapClass.view.getView
? mapClass.view.getView()
: mapClass.view;
console.log('view 对象:', view);
if (view && view.on) {
zoomListenerKey = view.on('change:resolution', () => {
const zoom = view.getZoom ? view.getZoom() : mapClass.view.getZoom();
console.log('缩放事件触发,当前缩放级别:', zoom);
if (zoom !== undefined) {
const oldZoomLevel = Math.floor(currentZoomLevel.value);
const newZoomLevel = Math.floor(zoom);
currentZoomLevel.value = zoom;
//
if (isShuiDianKaiFaMenu.value) {
console.log('当前是水电开发菜单,检查层级变化');
// 1212
if (oldZoomLevel < 12 && newZoomLevel >= 12) {
console.log('层级从小于12变为>=12添加动态图层');
addDynamicLayers();
}
// 1212
else if (oldZoomLevel >= 12 && newZoomLevel < 12) {
console.log('层级从>=12变为小于12移除动态图层');
removeDynamicLayers();
}
}
}
});
console.log('缩放监听器设置完成key:', zoomListenerKey);
} else {
console.error('view 对象没有 on 方法');
}
} else {
console.error('mapClass.view 未初始化');
}
};
//
const addDynamicLayers = async () => {
console.log('地图层级>=12添加动态图层:', dynamicLayerKeys);
// keys
const currentCheckedKeys = mapStore.getCheckedKeys();
//
const layersToAdd = dynamicLayerKeys.filter(
key => !currentCheckedKeys.includes(key)
);
if (layersToAdd.length === 0) {
console.log('动态图层已全部添加,跳过');
return;
}
//
const layersToLoad = layersToAdd
.map(key => mapStore.findLayerByKey(mapStore.layerData, key))
.filter(Boolean);
//
for (const layer of layersToLoad) {
if (layer && layer.url) {
await mapStore.loadLayerData(layer);
}
}
// keys
const newCheckedKeys = [...currentCheckedKeys, ...layersToAdd];
await mapStore.updateLayerData(newCheckedKeys, true);
console.log('动态图层添加完成');
};
//
const removeDynamicLayers = async () => {
console.log('地图层级<12移除动态图层:', dynamicLayerKeys);
// keys
const currentCheckedKeys = mapStore.getCheckedKeys();
// keys
const newCheckedKeys = currentCheckedKeys.filter(
key => !dynamicLayerKeys.includes(key)
);
//
if (newCheckedKeys.length === currentCheckedKeys.length) {
console.log('动态图层已移除,跳过');
return;
}
await mapStore.updateLayerData(newCheckedKeys, true);
console.log('动态图层移除完成');
};
// moduleId sessionStorage
const getModuleId = () => {
const menuList: any = JSON.parse(sessionStorage.getItem('menuList') || '[]');
const pathParts = route.path.split('/');
if (pathParts.length >= 3) {
const page = pathParts[2];
const menuItem = menuList.find((item: any) => item.actionCode === page);
return menuItem?.id || '';
}
return '';
};
/** 更新图例关系数据,同时更新缓存 */
const updateLegendDataMap = (data: any) => {
mapStore.legendDataMap = data;
};
//
const init = async () => { const init = async () => {
const container = document.getElementById('mapContainer') as HTMLElement; const container = document.getElementById('mapContainer') as HTMLElement;
if (!container) return; if (!container) return;
mapClass.init(container).then(() => { await mapOrchestrator.mountView({
if (popupRef.value) { container,
mapClass?.initPopupOverlay(popupRef.value); popupContainer: popupRef.value,
} pageKey: pageKey.value,
mapIsInited.value = true; getIsHydroMenu: () => isShuiDianKaiFaMenu.value
//
setupZoomListener();
console.log('缩放监听器已设置');
//
fetchMapConfigs();
}); });
mapIsInited.value = true;
}; };
/** 请求地图配置数据:图层、图例 */ //
const fetchMapConfigs = _.debounce(async () => {
loading.value = true;
mapStore.loading = true;
try {
//
const [layerRes, legendAllRes, legendSelectedRes] = await Promise.all([
getMapList({
systemId: '974975A6-47FD-4C04-9ACD-68938D2992BD',
moduleId: '2157c1a1-e909-4c9f-af94-b4ccebe05808',
description: 'true'
}),
getModuleMapLegendList(),
getModuleMapLegendList({ moduleId: pageKey.value })
]);
// - legendDataMap
if (legendAllRes.data) {
mapStore.setLegendData(legendAllRes.data, legendSelectedRes.data || []);
}
//
if (layerRes.data && layerRes.data.mapLayerVos) {
// TODO:
const limitedLayers = layerRes.data.mapLayerVos;
console.log('临时限制只处理前3个图层');
// 1. keys
mapStore.setLayerData(limitedLayers);
// 2. keys
const checkedKeys = collectCheckedKeys(limitedLayers);
console.log('初始选中的图层 keys:', checkedKeys);
// 3. - checkedKeys
// loadAllLayerData updateLayerData
await mapStore.loadAllLayerData(limitedLayers, checkedKeys);
}
// loading
mapStore.loading = false;
} catch (error) {
console.error('获取地图配置失败:', error);
} finally {
loading.value = false;
}
}, 400);
/**
* 递归收集所有选中的图层 keys包括子级
*/
const collectCheckedKeys = (layers: any[]): string[] => {
const keys: string[] = [];
const collect = (items: any[]) => {
items.forEach(item => {
if (item.checked === 1 && item.key) {
keys.push(item.key);
}
if (item.children && item.children.length > 0) {
collect(item.children);
}
});
};
collect(layers);
return keys;
};
// menu
const handleMapController = async (e: any, mapType: any) => { const handleMapController = async (e: any, mapType: any) => {
switch (e) { switch (e) {
case 'dim': case 'dim':
await mapClass.switchView(mapType); await mapClass.switchView(mapType);
fetchPointData();
break; break;
case 4: // case 4: //
tlyLayerVisible.value = !tlyLayerVisible.value; tlyLayerVisible.value = !tlyLayerVisible.value;
if (tlyLayerVisible.value) { if (tlyLayerVisible.value) {
// tjModalVisible.value = true;
fetchTjData();
} else { } else {
// tjModalVisible.value = false;
mapClass.hideTertiarybasinLayer(servers.Tertiarybasin);
} }
break; break;
default: default:
break; break;
} }
}; };
const fetchTjData = async () => {
// const res = await getQgcRvcd({});
watch( if (res && res.data) {
() => mapIsInited.value, let datas = [];
newVal => { for (let i = 0; i < res.data.data.length; i++) {
if (newVal === true) { datas.push(res.data.data[i].rvcd);
console.log('地图初始化完成');
} }
console.log(servers.Tertiarybasin);
mapClass.addTertiarybasinLayer(
servers.Tertiarybasin,
'#4DFFDD',
'#92A0A5',
datas
);
} }
); };
// pageKey //
watch( watch(
() => pageKey.value, () => pageKey.value,
newVal => { async newVal => {
console.log('pageKey:', newVal);
console.log('当前菜单是否为水电开发:', isShuiDianKaiFaMenu.value);
if (newVal && mapIsInited.value) { if (newVal && mapIsInited.value) {
// await mapOrchestrator.handlePageChange(newVal, isShuiDianKaiFaMenu.value);
if (!isShuiDianKaiFaMenu.value) {
removeDynamicLayers();
}
//
fetchMapConfigs();
// >=12
if (isShuiDianKaiFaMenu.value) {
// view
const view = mapClass.view?.getView
? mapClass.view.getView()
: mapClass.view;
const currentZoom = view?.getZoom?.();
console.log('当前地图层级:', currentZoom);
console.log('view 对象:', view);
if (currentZoom !== undefined && currentZoom >= 12) {
console.log('切换到水电开发菜单,当前层级已>=12添加动态图层');
//
setTimeout(() => {
console.log('延迟后调用 addDynamicLayers');
addDynamicLayers();
}, 1000);
}
}
} }
} }
); );
//
watch(
() => JidiSelectEventStore.selectedItem,
newVal => {
if (newVal) {
if (newVal.wbsCode == 'all') {
mapClass.jdPanelControlShowAndHidden(newVal.wbsCode, false);
} else {
mapClass.jdPanelControlShowAndHidden(newVal.wbsCode, true);
}
}
},
{ deep: true }
);
//
watch(
() => mapStore.legendDataSelected,
newVal => {
if (newVal && newVal.length > 0) {
newVal.forEach((legend: any) => {
if (legend.checked === 1) {
// mapClass.mdLayerTreeShowOrHidden(legend.layerCode, true);
} else {
// mapClass.mdLayerTreeShowOrHidden(legend.layerCode, false);
}
});
}
},
{ deep: true }
);
onMounted(() => { onMounted(() => {
init(); init();
// @ts-ignore // @ts-ignore
@ -410,16 +139,7 @@ onMounted(() => {
}); });
onUnmounted(() => { onUnmounted(() => {
// mapOrchestrator.unmountView();
if (zoomListenerKey && mapClass.view) {
const view = mapClass.view.getView
? mapClass.view.getView()
: mapClass.view;
if (view && view.unByKey) {
view.unByKey(zoomListenerKey);
console.log('缩放监听器已移除');
}
}
mapClass.destroy(); mapClass.destroy();
}); });
</script> </script>

View File

@ -0,0 +1,673 @@
<template>
<div ref="chartRef" class="tj-cascade-chart"></div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import * as echarts from 'echarts';
import type { ECharts, EChartsOption } from 'echarts';
import Gth from '@/assets/icons/gth.svg';
interface BasinStepItem {
dmhg: number | null;
ennm: string;
normz: number | null;
srcdis: number | null;
stcd: string;
bldsttCcode: string | number;
estrydis: number | null;
retentionExponent: number | null;
qo?: string;
}
const props = withDefaults(
defineProps<{
dataArr: BasinStepItem[];
hideText?: boolean;
specially?: boolean;
}>(),
{
hideText: false,
specially: true
}
);
const chartRef = ref<HTMLDivElement | null>(null);
const chartInstance = ref<ECharts | null>(null);
let removeChartListeners: (() => void) | null = null;
let pendingWheelFallbackTimer: number | null = null;
const getValueArrayByKey = (
dataArr: BasinStepItem[],
key: keyof BasinStepItem
) => {
return dataArr.map(item => {
let value = item[key];
if (value === undefined || value === null || isNaN(Number(value))) {
return 0;
}
return Number(value);
}) as number[];
};
const generateCascaderData = (valArr: number[], valArrEstrydis: number[]) => {
const result: number[][] = [];
if (!valArr.length || !valArrEstrydis.length) return result;
const len = valArr.length + 1;
const valMax = Math.max(...valArr) * 1.1;
const estrydisMax = Math.max(...valArrEstrydis) * 1.05;
for (let i = 0; i < len; i += 1) {
if (i === len - 1) {
result.push([estrydisMax, valMax]);
} else {
result.push([valArrEstrydis[i], valArr[i]]);
}
}
return result;
};
const generateMarkAreaData = (
valArr: number[],
valArrEstrydis: number[],
labelArrQo: Array<string | undefined>,
valArrDmhg: number[],
dataArr: BasinStepItem[]
) => {
const markAreaArr: any[] = [];
if (!valArr.length || !valArrEstrydis.length || !valArrDmhg.length) {
return markAreaArr;
}
const len = valArr.length;
const minArr = Math.min(...valArr);
const maxArr = Math.max(...valArr);
const valArea = maxArr - minArr;
const criticalValue = valArea * 0.05;
const estrydisMax = Math.max(...valArrEstrydis) * 1.05;
const estrydisMin = Math.min(...valArrEstrydis) * 1.05;
const step = (maxArr - minArr) / 20;
const xStep = (estrydisMax - estrydisMin) / 1440;
for (let i = 0; i < len; i += 1) {
const current = dataArr[i];
const bldsttCcode = current?.bldsttCcode;
const normz = current?.normz;
const isLast = i === len - 1;
const color = String(bldsttCcode) === '2' ? '#6E7079' : '#D49CAE';
if (isLast) {
const tempBlue = [
{
label: props.hideText
? undefined
: {
color: '#58b438',
formatter: [
`{a|▽${valArr[i] || 0}}`,
`{b|${labelArrQo[i] || ''}}`
].join('\n'),
distance: normz ? 0 : 30,
rich: {
a: { color: '#2F6B98', lineHeight: 24 },
b: { color: '#2F6B98', height: 14 }
}
},
itemStyle: { color: '#56C2E3' },
coord: [estrydisMax, maxArr]
},
{
itemStyle: { color: '#56C2E3' },
coord: [valArrEstrydis[i], valArrDmhg[i - 1] ?? valArrDmhg[i] ?? 0]
}
];
const tempText = [
{
itemStyle: { color: '#D49CAE' },
coord: [valArrEstrydis[i] + xStep, maxArr + step],
label: props.hideText
? undefined
: {
color,
formatter: current?.ennm?.split('')?.join('\n'),
distance: 20,
fontSize: 16
},
sdt: current
},
{
itemStyle: { color: '#D49CAE' },
coord: [valArrEstrydis[i], valArrDmhg[i - 1] ?? valArrDmhg[i] ?? 0],
label: props.hideText
? undefined
: {
color,
formatter: current?.ennm?.split('')?.join('\n'),
distance: 20,
fontSize: 16
},
sdt: current
}
];
markAreaArr.push(tempBlue, tempText);
continue;
}
const tempBlue = [];
const nextNormz = valArr[i + 1];
tempBlue.push({
label: props.hideText
? undefined
: {
color: '#58b438',
formatter: [
`{a|▽${valArr[i] || 0}}`,
`{b|${labelArrQo[i] || ''}}`
].join('\n'),
distance: normz ? 0 : 30,
rich: {
a: { color: '#2F6B98', lineHeight: 24 },
b: { color: '#2F6B98', height: 14 }
}
},
itemStyle: { color: '#56C2E3' },
coord: [
valArrEstrydis[i + 1],
nextNormz - valArr[i] < criticalValue ? valArr[i] : valArr[i] || 0
]
});
tempBlue.push({
itemStyle: { color: '#56C2E3' },
coord: [valArrEstrydis[i], valArrDmhg[i] ?? 0]
});
const tempText = [
{
itemStyle: { color: '#D49CAE' },
coord: [valArrEstrydis[i] + xStep, valArr[i] + step],
label: props.hideText
? undefined
: {
color,
formatter: current?.ennm?.split('')?.join('\n'),
distance: 30,
fontSize: 16
},
sdt: current
},
{
itemStyle: { color: '#D49CAE' },
coord: [valArrEstrydis[i], valArrDmhg[i - 1] ?? 0]
}
];
markAreaArr.push(tempBlue, tempText);
}
return markAreaArr;
};
const disposeChart = () => {
if (pendingWheelFallbackTimer !== null) {
window.clearTimeout(pendingWheelFallbackTimer);
pendingWheelFallbackTimer = null;
}
if (removeChartListeners) {
removeChartListeners();
removeChartListeners = null;
}
if (chartInstance.value) {
chartInstance.value.dispose();
chartInstance.value = null;
}
};
const clampNumber = (value: number, min: number, max: number) => {
if (Number.isNaN(value)) return min;
return Math.min(Math.max(value, min), max);
};
const cloneMarkAreaData = (markAreaData: any[]) => {
return JSON.parse(JSON.stringify(markAreaData ?? []));
};
const renderChart = async () => {
if (!chartRef.value || !props.dataArr.length) {
disposeChart();
return;
}
await nextTick();
const chartEl = chartRef.value;
if (!chartEl) return;
const valArrNormz = getValueArrayByKey(props.dataArr, 'normz');
const valArrDdz = getValueArrayByKey(props.dataArr, 'retentionExponent');
const valArrDmhg = getValueArrayByKey(props.dataArr, 'dmhg');
let valArrEstrydis = getValueArrayByKey(props.dataArr, 'estrydis');
const labelArrQo = props.dataArr.map(item => item.qo);
if (!valArrNormz.length || !valArrEstrydis.length || !valArrDmhg.length) {
disposeChart();
return;
}
if (props.specially) {
valArrEstrydis = [...valArrEstrydis];
}
const valMin = Math.floor(Math.min(...valArrDmhg));
const xMin = Math.min(...valArrEstrydis);
const xDeduce = 50;
const estrydisMaxForMarkArea = Math.max(...valArrEstrydis) * 1.05;
let xMax = Math.max(...valArrEstrydis) + xDeduce;
xMax = Math.max(xMax, estrydisMaxForMarkArea);
generateCascaderData(valArrNormz, valArrEstrydis);
generateCascaderData(valArrDdz, valArrEstrydis);
const markAreaArr = generateMarkAreaData(
valArrNormz,
valArrEstrydis,
labelArrQo,
valArrDmhg,
props.dataArr
);
if (!markAreaArr.length) {
disposeChart();
return;
}
const lineData = props.dataArr.map(item => [item.srcdis, item.dmhg]);
lineData.push([
xMax,
(markAreaArr[markAreaArr.length - 2]?.[0]?.coord?.[1] ?? 0) + 100
]);
const baseMarkAreaArr = cloneMarkAreaData(markAreaArr);
const option: EChartsOption = {
title: {
text: `图中 " ▽ " 表示正常蓄水位。电站高程指坝址高程,里程指河口里程。 已建电站{a|(黑色)} 在建电站{b|(红色)} `,
top: 10,
left: '3%',
textStyle: {
fontSize: 14,
fontWeight: 'normal',
rich: {
a: {
color: '#6E7079',
fontSize: 14,
fontWeight: 'normal'
},
b: {
color: '#D49CAE',
fontSize: 14,
fontWeight: 'normal'
}
}
}
},
graphic: {
elements: [
{
type: 'image',
z: 3,
style: {
image: Gth,
width: 16,
height: 16
},
left: '2%',
top: 13,
cursor: ' '
},
{
type: 'text',
z: 3,
style: {
text: '高\n程\n︵\nm\n︶',
fontSize: 16,
fill: '#6E7079'
},
left: 20,
top: 100,
cursor: ' '
}
]
},
xAxis: {
boundaryGap: false,
type: 'value',
inverse: true,
min: xMin,
max: xMax,
splitLine: {
show: true
},
axisLine: {
show: true
},
axisTick: {
show: true
},
name: '里程(km)',
nameLocation: 'start'
},
grid: {
top: 100,
right: 140,
left: 100,
bottom: 30
},
dataZoom: [
{
type: 'inside',
xAxisIndex: [0],
filterMode: 'none',
zoomOnMouseWheel: false,
moveOnMouseWheel: false,
moveOnMouseMove: false
}
],
tooltip: {
show: true,
trigger: 'item',
formatter: (params: any) => {
const sdt = params?.data?.sdt;
const distance = params?.data?.coord?.[1]?.[0] ?? sdt?.srcdis ?? 0;
if (sdt) {
return (
`${sdt.ennm}</br>` +
`里程:${distance}km</br>` +
`高程:${sdt.dmhg ?? ''}m`
);
}
return null as any;
}
},
toolbox: {
right: 20,
feature: {
saveAsImage: {
name: '梯级图',
title: '保存为图片'
}
}
},
yAxis: [
{
offset: 2,
splitNumber: 5,
type: 'value',
min: valMin - 100,
splitLine: {
show: false
},
axisLabel: {
fontSize: 14
},
axisLine: {
onZero: false
},
axisTick: {}
}
],
series: [
{
type: 'line',
smooth: 0.4,
showSymbol: false,
lineStyle: { normal: { color: '#000000' } },
areaStyle: { normal: { color: '#FEFCF0', opacity: 1 } },
markArea: {
data: markAreaArr,
itemStyle: {
opacity: 1,
color: 'rgba(0,0,0,0)'
}
},
data: lineData
}
]
};
const primarySeries = (option as any).series[0] || {};
const { markArea: _unusedMarkArea, ...secondarySeriesBase } = primarySeries;
(option as any).series[1] = {
...secondarySeriesBase,
yAxisIndex: 1,
lineStyle: {
color: '',
opacity: 0
}
};
(option as any).yAxis[1] = {
...(option as any).yAxis[0],
offset: 70,
position: 'right'
};
const applyZoomVisualState = (
startValue?: number | null,
endValue?: number | null
) => {
if (!chartInstance.value) return;
const viewStart = Math.min(
Number(startValue ?? xMin),
Number(endValue ?? xMax)
);
const viewEnd = Math.max(
Number(startValue ?? xMin),
Number(endValue ?? xMax)
);
const visibleRange = Math.max(viewEnd - viewStart, 1);
const chartWidth = Math.max(chartEl.offsetWidth - 80, 1);
const xPx = chartWidth / visibleRange;
let prePoint = viewStart;
const nextMarkAreaArr = baseMarkAreaArr.map((pair: any) => {
if (!Array.isArray(pair)) return pair;
return pair.map((point: any) => {
const nextPoint = {
...point,
coord: Array.isArray(point?.coord) ? [...point.coord] : point?.coord,
itemStyle: point?.itemStyle
? { ...point.itemStyle }
: point?.itemStyle,
label: point?.label ? { ...point.label } : point?.label
};
if (nextPoint?.label && Array.isArray(nextPoint?.coord)) {
const currentX = Number(nextPoint.coord[0]);
if (currentX >= viewStart && currentX <= viewEnd) {
if ((currentX - prePoint) * xPx > 30) {
nextPoint.label.show = true;
prePoint = currentX;
}
} else {
nextPoint.label.show = false;
}
}
return nextPoint;
});
});
const visibleYValues: number[] = [];
lineData.forEach(point => {
const [x, y] = point;
if (x >= viewStart && x <= viewEnd) {
visibleYValues.push(y);
}
});
nextMarkAreaArr.forEach((pair: any) => {
if (!Array.isArray(pair) || pair.length < 2) return;
const firstCoord = pair[0]?.coord;
const secondCoord = pair[1]?.coord;
if (!Array.isArray(firstCoord) || !Array.isArray(secondCoord)) return;
const pairMinX = Math.min(Number(firstCoord[0]), Number(secondCoord[0]));
const pairMaxX = Math.max(Number(firstCoord[0]), Number(secondCoord[0]));
const intersects = pairMaxX >= viewStart && pairMinX <= viewEnd;
if (!intersects) return;
visibleYValues.push(Number(firstCoord[1]), Number(secondCoord[1]));
});
const fallbackMin = valMin - 100;
const fallbackMax = Math.max(
...baseMarkAreaArr.flatMap((pair: any) => {
if (!Array.isArray(pair)) return [];
return pair
.map((point: any) =>
Array.isArray(point?.coord) ? Number(point.coord[1]) : null
)
.filter((value: number | null) => value != null) as number[];
})
);
const minVisibleY =
visibleYValues.length > 0 ? Math.min(...visibleYValues) : fallbackMin;
const maxVisibleY =
visibleYValues.length > 0 ? Math.max(...visibleYValues) : fallbackMax;
const yPadding = Math.max((maxVisibleY - minVisibleY) * 0.08, 80);
const nextYAxisMin = Math.floor(minVisibleY - yPadding);
const nextYAxisMax = Math.ceil(maxVisibleY + yPadding);
chartInstance.value.setOption({
yAxis: [
{
min: nextYAxisMin,
max: nextYAxisMax
},
{
min: nextYAxisMin,
max: nextYAxisMax
}
],
series: [
{
markArea: {
data: nextMarkAreaArr
}
}
]
});
};
disposeChart();
chartInstance.value = echarts.init(chartEl);
chartInstance.value.setOption(option, true);
chartInstance.value.on('datazoom', (event: any) => {
const currentOption = chartInstance.value?.getOption();
const currentDataZoom = Array.isArray(currentOption?.dataZoom)
? currentOption.dataZoom[0]
: null;
const nextStartValue =
event?.batch?.[0]?.startValue ?? currentDataZoom?.startValue ?? xMin;
const nextEndValue =
event?.batch?.[0]?.endValue ?? currentDataZoom?.endValue ?? xMax;
applyZoomVisualState(nextStartValue, nextEndValue);
});
const zr = chartInstance.value.getZr();
const handleWheel = (event: any) => {
if (pendingWheelFallbackTimer !== null) {
window.clearTimeout(pendingWheelFallbackTimer);
}
pendingWheelFallbackTimer = window.setTimeout(() => {
pendingWheelFallbackTimer = null;
if (!chartInstance.value) return;
try {
const currentOption = chartInstance.value.getOption();
const currentDataZoom = Array.isArray(currentOption?.dataZoom)
? currentOption.dataZoom[0]
: null;
const currentXAxis = Array.isArray(currentOption?.xAxis)
? currentOption.xAxis[0]
: currentOption?.xAxis;
const axisMin = Number(currentXAxis?.min ?? xMin);
const axisMax = Number(currentXAxis?.max ?? xMax);
const startValue = Number(currentDataZoom?.startValue ?? axisMin);
const endValue = Number(currentDataZoom?.endValue ?? axisMax);
const totalRange = axisMax - axisMin;
const currentRange = endValue - startValue;
if (!totalRange || !currentRange) return;
const delta =
Number(event?.wheelDelta ?? event?.zrDelta ?? event?.zrDeltaY ?? 0) ||
0;
const zoomFactor = delta > 0 ? 0.9 : 1 / 0.9;
const nextRange = clampNumber(
currentRange * zoomFactor,
totalRange * 0.05,
totalRange
);
const offsetX = Number(event?.offsetX ?? chartEl.offsetWidth / 2);
const anchorRatio = clampNumber(
offsetX / Math.max(chartEl.offsetWidth, 1),
0,
1
);
const anchorValue = startValue + currentRange * anchorRatio;
let nextStartValue = anchorValue - nextRange * anchorRatio;
let nextEndValue = nextStartValue + nextRange;
if (nextStartValue < axisMin) {
nextEndValue += axisMin - nextStartValue;
nextStartValue = axisMin;
}
if (nextEndValue > axisMax) {
nextStartValue -= nextEndValue - axisMax;
nextEndValue = axisMax;
}
nextStartValue = clampNumber(nextStartValue, axisMin, axisMax);
nextEndValue = clampNumber(nextEndValue, axisMin, axisMax);
chartInstance.value.dispatchAction({
type: 'dataZoom',
dataZoomIndex: 0,
startValue: nextStartValue,
endValue: nextEndValue
});
} catch (_error: any) {}
}, 32);
};
zr.on('mousewheel', handleWheel);
removeChartListeners = () => {
zr.off('mousewheel', handleWheel);
};
};
watch(
() => [props.dataArr],
async () => {
await renderChart();
},
{ deep: true, immediate: true, flush: 'post' }
);
onMounted(async () => {
await renderChart();
});
onBeforeUnmount(() => {
disposeChart();
});
</script>
<style scoped lang="scss">
.tj-cascade-chart {
width: 100%;
height: 32vh;
min-height: 280px;
}
</style>

View File

@ -0,0 +1,538 @@
<template>
<a-modal
:open="open"
:width="modalWidth"
:footer="null"
:closable="false"
:destroy-on-close="false"
@cancel="handleClose"
>
<template #title>
<div class="tj-modal-header">
<div class="tj-modal-title">梯级图</div>
<div class="tj-modal-actions">
<ExpandAltOutlined
v-if="!isExpanded"
class="tj-modal-action-icon"
@click.stop="toggleExpand"
/>
<ShrinkOutlined
v-else
class="tj-modal-action-icon"
@click.stop="toggleExpand"
/>
<CloseOutlined
class="tj-modal-action-icon ml-3"
@click.stop="handleClose"
/>
</div>
</div>
</template>
<div class="tj-modal">
<a-form :model="{ rvcd }" class="tj-form">
<a-form-item label="梯级流域">
<a-select
v-model:value="rvcd"
style="width: 220px"
placeholder="请选择流域"
:options="options"
:loading="loadingOptions"
show-search
option-filter-prop="label"
allow-clear
@change="handleRvcdChange"
/>
</a-form-item>
</a-form>
<div v-if="tableShowType === 'dataZoom'" class="tj-toolbar">
<RollbackOutlined
class="tj-back-icon"
@click="handleBackTableShowType"
/>
</div>
<a-empty
v-if="!rvcd"
class="tj-empty"
description="请先选择流域"
:image="false"
/>
<a-spin v-else :spinning="loadingChart">
<a-empty
v-show="rvcd && !currentDisplayData.length && !loadingChart"
class="tj-empty"
description="暂无数据"
:image="false"
/>
<div v-show="currentDisplayData.length > 0" class="tj-content">
<!-- <div class="tj-tip">
<ExclamationCircleOutlined class="tj-tip-icon" />
<span style="font-size: 14px; font-weight: normal; color: #464646">
图中 " ▽ "
表示正常蓄水位电站高程指坝址高程里程指河口里程已建电站
<span style="color: #6e7079; font-size: 14px; font-weight: normal"
>黑色</span
>
在建电站
<span style="color: #d49cae; font-size: 14px; font-weight: normal"
>红色</span
>
</span>
</div> -->
<TjCascadeChart
:key="`${rvcd || 'empty'}-${isExpanded ? 'expanded' : 'normal'}`"
class="tj-chart"
:data-arr="basinStepChartData"
:specially="true"
/>
<div class="tj-table-wrap">
<div class="tj-table-left">
<table cellspacing="0" cellpadding="0">
<tbody>
<tr>
<td>电站</td>
</tr>
<tr>
<td>高程(m)</td>
</tr>
<tr>
<td>里程(km)</td>
</tr>
</tbody>
</table>
</div>
<div class="tj-table-right">
<table cellspacing="0" cellpadding="0">
<tbody>
<tr>
<td
v-for="(item, index) in displayDataReversed"
:key="`${item.stcd || item.ennm || index}-name`"
:style="{ color: getStationColor(item) }"
>
<a-tooltip :title="item.ennm">
<span class="tj-station-name">
<template
v-for="(char, charIndex) in getTitleChars(
item.ennm
)"
:key="`${item.ennm || 'station'}-${charIndex}`"
>
<span>{{ char }}</span>
<br v-if="char !== '...'" />
</template>
</span>
</a-tooltip>
</td>
</tr>
<tr>
<td
v-for="(item, index) in displayDataReversed"
:key="`${item.stcd || item.ennm || index}-dmhg`"
>
<span>{{ item.dmhg ?? '' }}</span>
</td>
</tr>
<tr>
<td
v-for="(item, index) in displayDataReversed"
:key="`${item.stcd || item.ennm || index}-srcdis`"
>
<span>{{ item.estrydis ?? '' }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</a-spin>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
CloseOutlined,
ExpandAltOutlined,
RollbackOutlined,
ShrinkOutlined
} from '@ant-design/icons-vue';
import { getKendoList, getRvcdList } from '@/api/map';
import TjCascadeChart from './TjCascadeChart.vue';
interface BasinOption {
label: string;
value: string;
objId: string;
}
interface BasinStepItem {
dmhg: number | null;
ennm: string;
normz: number | null;
srcdis: number | null;
stcd: string;
bldsttCcode: string | number;
estrydis: number | null;
retentionExponent: number | null;
qo?: string;
}
const props = defineProps<{
open: boolean;
}>();
const emit = defineEmits<{
(e: 'update:open', value: boolean): void;
}>();
const isExpanded = ref(false);
const modalWidth = computed(() => (isExpanded.value ? '95%' : '75%'));
const rvcd = ref<string | undefined>();
const loadingOptions = ref(false);
const loadingChart = ref(false);
const options = ref<BasinOption[]>([]);
const basinStepChartData = ref<BasinStepItem[]>([]);
const dataZoomBasinStepChartData = ref<BasinStepItem[]>([]);
const tableShowType = ref<'table' | 'dataZoom'>('table');
const currentDisplayData = computed(() =>
tableShowType.value === 'dataZoom'
? dataZoomBasinStepChartData.value
: basinStepChartData.value
);
const displayDataReversed = computed(() =>
[...currentDisplayData.value].reverse()
);
const resetModalState = () => {
rvcd.value = undefined;
basinStepChartData.value = [];
dataZoomBasinStepChartData.value = [];
tableShowType.value = 'table';
loadingChart.value = false;
};
const handleClose = () => {
resetModalState();
emit('update:open', false);
};
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
const toNumber = (value: any): number | null => {
if (value === null || value === undefined || value === '') return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
};
const getStationColor = (item: BasinStepItem) =>
String(item?.bldsttCcode) === '2' ? '#262626' : '#D49CAE';
const getTitleChars = (title?: string) => {
const chars = (title || '').split('');
if (chars.length <= 6) return chars;
return [...chars.slice(0, 6), '...'];
};
const parseZoomValues = (event: any) => {
const batch = event?.batch?.[0] || event || {};
let { startValue, endValue } = batch;
if (startValue === undefined || endValue === undefined) {
const srcdisValues = basinStepChartData.value
.map(item => item.srcdis)
.filter(value => value !== null && value !== undefined) as number[];
if (!srcdisValues.length) return null;
const min = Math.min(...srcdisValues);
const max = Math.max(...srcdisValues);
const start = Number(batch.start ?? 0);
const end = Number(batch.end ?? 100);
startValue = min + ((max - min) * start) / 100;
endValue = min + ((max - min) * end) / 100;
}
return {
min: Math.min(Number(startValue), Number(endValue)),
max: Math.max(Number(startValue), Number(endValue)),
start: Number(batch.start ?? 0),
end: Number(batch.end ?? 100)
};
};
const handleChartDataZoom = (event: any) => {
const zoomValues = parseZoomValues(event);
if (!zoomValues) return;
if (zoomValues.start <= 0 && zoomValues.end >= 100) {
tableShowType.value = 'table';
dataZoomBasinStepChartData.value = [];
return;
}
const filtered = basinStepChartData.value.filter(item => {
const distance = Number(item.srcdis ?? 0);
return distance >= zoomValues.min && distance <= zoomValues.max;
});
if (!filtered.length) return;
tableShowType.value = 'dataZoom';
dataZoomBasinStepChartData.value = filtered;
};
const loadRvcdOptions = async () => {
if (options.value.length > 0) return;
loadingOptions.value = true;
try {
const res = await getRvcdList({
filter: {
logic: 'and',
filters: [
{
field: 'wbsType',
operator: 'eq',
dataType: 'string',
value: 'PSB_RVCD'
},
{
field: 'treeLevel',
operator: 'eq',
dataType: 'string',
value: '1'
}
]
}
});
const list = Array.isArray(res?.data?.data)
? res.data.data
: Array.isArray(res?.data)
? res.data
: [];
options.value = list.map((item: any) => ({
label: item.wbsName,
value: item.wbsCode,
objId: item.objId
}));
} finally {
loadingOptions.value = false;
}
};
const fetchBasinStepChart = async (value?: string) => {
rvcd.value = value;
basinStepChartData.value = [];
dataZoomBasinStepChartData.value = [];
tableShowType.value = 'table';
if (!value) {
return;
}
loadingChart.value = true;
try {
const res = await getKendoList({
filter: {
logic: 'and',
filters: [
{
field: 'rvcd',
operator: 'eq',
dataType: 'string',
value
}
]
},
sort: [
{
field: 'srcdis',
dir: 'desc'
}
]
});
const list = Array.isArray(res?.data?.data)
? res.data.data
: Array.isArray(res?.data)
? res.data
: [];
basinStepChartData.value = list
.map((item: any) => ({
dmhg: toNumber(item.dmhg),
ennm: item.stnm || item.ennm || '',
normz: toNumber(item.normz),
srcdis: toNumber(item.srcdis),
stcd: item.stcd || '',
bldsttCcode: item.bldsttCcode ?? '',
estrydis: toNumber(item.srcdis),
retentionExponent: toNumber(item.dmhg),
qo: item.qo
}))
.filter(
item =>
item.dmhg !== null && item.normz !== null && item.srcdis !== null
)
.reverse();
} finally {
loadingChart.value = false;
}
};
const handleRvcdChange = async (value: string | undefined) => {
await fetchBasinStepChart(value);
};
const handleBackTableShowType = () => {
tableShowType.value = 'table';
dataZoomBasinStepChartData.value = [];
};
watch(
() => props.open,
async newVal => {
if (newVal) {
await loadRvcdOptions();
} else {
resetModalState();
}
},
{ immediate: true }
);
</script>
<style scoped lang="scss">
.tj-modal {
position: relative;
}
.tj-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.tj-modal-title {
font-size: 16px;
font-weight: 500;
color: #ffffff;
}
.tj-modal-actions {
display: flex;
align-items: center;
gap: 12px;
margin-right: 8px;
}
.tj-modal-action-icon {
font-size: 16px;
color: #ffffff;
cursor: pointer;
}
.tj-form :deep(.ant-form-item) {
margin-bottom: 12px;
}
.tj-toolbar {
display: flex;
justify-content: flex-end;
margin-bottom: 4px;
}
.tj-back-icon {
font-size: 16px;
color: #00000073;
cursor: pointer;
}
.tj-empty {
height: 250px;
display: flex;
align-items: center;
justify-content: center;
}
.tj-content {
position: relative;
height: 70vh;
padding: 0 10px;
}
.tj-chart {
width: 100%;
height: 32vh;
min-height: 280px;
}
.tj-table-wrap {
display: flex;
margin-top: 12px;
padding-right: 64px;
}
.tj-table-left {
width: 94px;
flex: 0 0 94px;
}
.tj-table-right {
flex: 1;
overflow-x: auto;
}
.tj-table-left table,
.tj-table-right table {
width: 100%;
table-layout: fixed;
text-align: center;
border-collapse: collapse;
}
.tj-table-left td,
.tj-table-right td {
width: 80px;
min-width: 80px;
border: 1px solid #a9afb3;
position: relative;
padding: 0;
}
.tj-table-left tr:nth-child(1) td,
.tj-table-right tr:nth-child(1) td {
height: 150px;
}
.tj-table-left tr:nth-child(2) td,
.tj-table-left tr:nth-child(3) td,
.tj-table-right tr:nth-child(2) td,
.tj-table-right tr:nth-child(3) td {
height: 90px;
}
.tj-station-name {
display: inline-block;
line-height: 1.2;
word-break: break-all;
}
</style>

View File

@ -122,7 +122,11 @@ export class MapCesium implements MapInterface {
checked: boolean checked: boolean
): void {} ): void {}
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void {} mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void {}
setLegendPointVisible(layerKey: string, anchoPointState: string, checked: boolean): void {} setLegendPointVisible(
layerKey: string,
anchoPointState: string,
checked: boolean
): void {}
addInitDataLayer(pointData: any[], layerType: any, mdoptions?: any): void {} addInitDataLayer(pointData: any[], layerType: any, mdoptions?: any): void {}
switchView(type: any): void {} switchView(type: any): void {}
fitBounds(bounds: any): void {} fitBounds(bounds: any): void {}
@ -133,7 +137,7 @@ export class MapCesium implements MapInterface {
outlineColor: any, outlineColor: any,
datas: any datas: any
): void {} ): void {}
removeTertiarybasinLayer(layer: layer): void {} hideTertiarybasinLayer(layer: layer): void {}
zoomToggle(type: 'out' | 'in'): void { zoomToggle(type: 'out' | 'in'): void {
if (!this.viewer) return; if (!this.viewer) return;
const factor = 1.5; const factor = 1.5;

View File

@ -53,12 +53,12 @@ export class MapClass implements MapClassInterface {
mdLayerShowOrHidden( mdLayerShowOrHidden(
layerType: string, layerType: string,
baseid: string,
key?: string, key?: string,
baseid: string,
checked?: boolean, checked?: boolean,
isAll?: boolean isAll?: boolean
): void { ): void {
this.service.mdLayerShowOrHidden(layerType, baseid, key, checked, isAll); this.service.mdLayerShowOrHidden(layerType, key, baseid, checked, isAll);
} }
// 添加基础数据图层 // 添加基础数据图层
addBaseDataLayer(layer: any, isShow: boolean): void { addBaseDataLayer(layer: any, isShow: boolean): void {
@ -103,8 +103,8 @@ export class MapClass implements MapClassInterface {
this.service.addTertiarybasinLayer(layer, fillcolor, outlineColor, datas); this.service.addTertiarybasinLayer(layer, fillcolor, outlineColor, datas);
} }
// 移除梯级流域图 // 移除梯级流域图
removeTertiarybasinLayer(layer: layer): void { hideTertiarybasinLayer(layer: layer): void {
this.service.removeTertiarybasinLayer?.(layer); this.service.hideTertiarybasinLayer?.(layer);
} }
// 缩放 // 缩放
zoomToggle(type: 'out' | 'in') { zoomToggle(type: 'out' | 'in') {
@ -136,6 +136,10 @@ export class MapClass implements MapClassInterface {
hasLayer(layerKey: string): void { hasLayer(layerKey: string): void {
return this.service.hasLayer(layerKey); return this.service.hasLayer(layerKey);
} }
// 删除描点图层
removePointLayer(layerKey: string): void {
this.service.removePointLayer?.(layerKey);
}
// 销毁地图 // 销毁地图
destroy(): void { destroy(): void {
this.service.destroy(); this.service.destroy();

View File

@ -163,8 +163,8 @@ export interface MapInterface {
*/ */
mdLayerShowOrHidden( mdLayerShowOrHidden(
layerType: string, layerType: string,
baseid: string,
key?: string, key?: string,
baseid: string,
checked?: boolean, checked?: boolean,
isAll?: boolean isAll?: boolean
): void; ): void;
@ -187,6 +187,12 @@ export interface MapInterface {
*/ */
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void; mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void;
/**
*
* @param layerKey key
*/
removePointLayer(layerKey: string): void;
/** /**
* anchoPointState * anchoPointState
* @param layerKey key * @param layerKey key
@ -228,7 +234,7 @@ export interface MapInterface {
* *
* @param layer * @param layer
*/ */
removeTertiarybasinLayer(layer: layer): void; hideTertiarybasinLayer(layer: layer): void;
/** /**
* *
*/ */

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,325 @@
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';
import OlMap from 'ol/Map';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { fromLonLat } from 'ol/proj';
import { getIconPath } from '@/utils/index';
type PointLayerStyleFactory = (feature: Feature) => any;
type PointLayerManagerOptions = {
map: OlMap | null;
createStyle: PointLayerStyleFactory;
};
export class PointLayerManager {
private map: OlMap | null;
private createStyle: PointLayerStyleFactory;
private layerRegistry: Map<string, VectorLayer<VectorSource>> = new Map();
private layerFeatureIndexes: Map<
string,
Map<string, Map<string, Feature[]>>
> = new Map();
constructor(options: PointLayerManagerOptions) {
this.map = options.map;
this.createStyle = options.createStyle;
}
// 备注:同步更新地图实例,供地图初始化后把真实 map 注入点图层管理器。
setMap(map: OlMap | null) {
this.map = map;
}
// 备注:统一返回当前点图层注册表,供外层做遍历或清理。
getRegistry() {
return this.layerRegistry;
}
// 备注:按图层 key 获取已注册的点图层实例。
getLayer(layerKey: string) {
return this.layerRegistry.get(layerKey);
}
// 备注:返回当前所有点图层实例,供命中检测和批量刷新使用。
getLayers(): VectorLayer<VectorSource>[] {
return Array.from(this.layerRegistry.values());
}
// 备注:判断指定图层 key 的点图层是否已存在。
hasLayer(layerKey: string): boolean {
return this.layerRegistry.has(layerKey);
}
// 备注:按需获取或创建点图层实例,并自动完成注册与挂载。
ensureLayer(layerKey: string, layerType?: string) {
if (!this.map || !layerKey) return null;
let vectorLayer = this.layerRegistry.get(layerKey);
if (vectorLayer) {
return vectorLayer;
}
const vectorSource = new VectorSource();
vectorLayer = new VectorLayer({
source: vectorSource,
zIndex: 100,
declutter: true,
style: feature => this.createStyle(feature as Feature)
});
(vectorLayer as any).type = layerType || layerKey;
this.layerRegistry.set(layerKey, vectorLayer);
this.map.addLayer(vectorLayer);
return vectorLayer;
}
// 备注:把后端点位数组转换为 OpenLayers Feature并写入指定点图层。
addDataLayer(pointData: any, layerType: string): void {
if (!this.map) return;
let dataArray: any[] = [];
let targetLayerKey = layerType;
if (Array.isArray(pointData)) {
dataArray = pointData;
} else {
dataArray = pointData?.data || [];
targetLayerKey = pointData?.key || layerType;
}
if (!targetLayerKey) {
console.warn('缺少图层 Key无法加载描点');
return;
}
const vectorLayer = this.ensureLayer(targetLayerKey, layerType);
if (!vectorLayer) return;
const vectorSource = vectorLayer.getSource();
if (!vectorSource) {
return;
}
vectorSource.clear();
this.resetLayerFeatureIndexes(targetLayerKey);
if (dataArray.length === 0) {
return;
}
const features: Feature[] = [];
dataArray.forEach((item: any) => {
const feature = this.createFeature(item, targetLayerKey);
if (feature) {
this.indexFeature(targetLayerKey, feature);
features.push(feature);
}
});
if (features.length > 0) {
vectorSource.addFeatures(features);
}
}
// 备注:统一控制整个点图层显隐,供图层树勾选联动使用。
setLayerVisible(layerKey: string, visible?: boolean): void {
const vectorLayer = this.layerRegistry.get(layerKey);
if (vectorLayer && vectorLayer.getVisible() !== visible) {
vectorLayer.setVisible(visible);
}
}
// 备注:按图层 key 删除点图层并清空其 source 数据。
removeLayer(layerKey: string): void {
if (!this.map || !layerKey) {
console.warn('removePointLayer: 无效的图层 key 或地图未初始化');
return;
}
const vectorLayer = this.layerRegistry.get(layerKey);
if (!vectorLayer) {
console.warn(`未找到标识为 [${layerKey}] 的描点图层实例`);
return;
}
const source = vectorLayer.getSource();
if (source) {
source.clear();
}
this.map.removeLayer(vectorLayer);
this.layerRegistry.delete(layerKey);
this.layerFeatureIndexes.delete(layerKey);
}
// 备注:按图层遍历所有点图层实例,供 hover 刷新和区域裁切复用。
forEachLayer(
callback: (layer: VectorLayer<VectorSource>, layerKey: string) => void
): void {
this.layerRegistry.forEach((layer, layerKey) => {
callback(layer, layerKey);
});
}
// 备注:遍历所有点要素,供区域裁切或批量状态更新使用。
forEachFeature(
callback: (
feature: Feature,
layer: VectorLayer<VectorSource>,
layerKey: string
) => void
): void {
this.forEachLayer((layer, layerKey) => {
const source = layer.getSource();
if (!source) return;
source.getFeatures().forEach(feature => {
callback(feature as Feature, layer, layerKey);
});
});
}
// 备注:按图例字段值统一控制要素显隐,供图例和容量筛选联动复用。
setLegendVisibleByField(
layerKey: string,
matchValue: string,
checked: boolean,
fieldKey: string = 'anchoPointState'
): void {
const vectorLayer = this.layerRegistry.get(layerKey);
if (!vectorLayer) return;
const source = vectorLayer.getSource();
if (!source) return;
if (!matchValue) {
source.getFeatures().forEach(feature => {
if (feature.get('_legendVisible') !== false) {
feature.set('_legendVisible', false);
feature.changed();
}
});
vectorLayer.changed();
return;
}
const targetFeatures = this.getIndexedFeatures(
layerKey,
fieldKey,
matchValue
);
if (targetFeatures.length === 0) return;
targetFeatures.forEach(feature => {
if (feature.get('_legendVisible') !== checked) {
feature.set('_legendVisible', checked);
feature.changed();
}
});
vectorLayer.changed();
}
// 备注:统一销毁所有点图层并清空注册表,供地图销毁阶段复用。
destroy(): void {
this.forEachLayer(layer => {
const source = layer.getSource();
if (source) {
source.clear();
}
if (this.map && this.map.getLayers().getArray().includes(layer)) {
this.map.removeLayer(layer);
}
if ((layer as any).setMap) {
(layer as any).setMap(null);
}
});
this.layerRegistry.clear();
this.layerFeatureIndexes.clear();
}
// 备注:统一把业务点位数据转换为 Feature并补齐样式依赖字段。
private createFeature(item: any, targetLayerKey: string): Feature | null {
const { lgtd, lttd, stnm, iconCode, titleName, ennm } = item;
if (lgtd == null || lttd == null) {
return null;
}
const lon = Number(lgtd);
const lat = Number(lttd);
if (isNaN(lon) || isNaN(lat) || !isFinite(lon) || !isFinite(lat)) {
return null;
}
if (Math.abs(lon) > 180 || Math.abs(lat) > 90) {
return null;
}
const coord = fromLonLat([lon, lat]);
if (!isFinite(coord[0]) || !isFinite(coord[1])) {
return null;
}
let iconUrl = iconCode ? getIconPath(iconCode) : '';
if (!iconUrl) {
iconUrl = getIconPath('default') || '';
}
const feature = new Feature({
geometry: new Point(coord),
...item,
_layerKey: targetLayerKey
});
feature.setId(item.stcd || item._id);
feature.set('_iconUrl', iconUrl);
feature.set('_labelText', titleName || stnm || ennm || '');
feature.set('_legendVisible', true);
feature.set('_sttpMap', item.sttpMap);
if (item.popupHtml) {
feature.set('popupHtml', item.popupHtml);
}
return feature;
}
// 备注:重置单个图层的字段索引,供重新灌入数据或删除图层时复用。
private resetLayerFeatureIndexes(layerKey: string): void {
this.layerFeatureIndexes.set(layerKey, new Map());
}
// 备注:按字段值给要素建立索引,供图例切换时快速命中目标锚点集合。
private indexFeature(layerKey: string, feature: Feature): void {
const fieldIndexMap =
this.layerFeatureIndexes.get(layerKey) ||
new Map<string, Map<string, Feature[]>>();
const legendFieldKey = 'anchoPointState';
const legendFieldValue = feature.get(legendFieldKey);
if (legendFieldValue != null) {
const valueMap =
fieldIndexMap.get(legendFieldKey) || new Map<string, Feature[]>();
const normalizedValue = String(legendFieldValue);
const targetFeatures = valueMap.get(normalizedValue) || [];
targetFeatures.push(feature);
valueMap.set(normalizedValue, targetFeatures);
fieldIndexMap.set(legendFieldKey, valueMap);
}
this.layerFeatureIndexes.set(layerKey, fieldIndexMap);
}
// 备注:根据图层 key、字段名和值快速返回目标要素集合避免图例切换时整层遍历。
private getIndexedFeatures(
layerKey: string,
fieldKey: string,
matchValue: string
): Feature[] {
const fieldIndexMap = this.layerFeatureIndexes.get(layerKey);
if (!fieldIndexMap) return [];
const valueMap = fieldIndexMap.get(fieldKey);
if (!valueMap) return [];
return valueMap.get(String(matchValue)) || [];
}
}

View File

@ -0,0 +1,290 @@
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';
import OlMap from 'ol/Map';
import Overlay from 'ol/Overlay';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { generatePopupHtml } from '@/utils/popupHtmlGenerator';
type PopupManagerOptions = {
map: OlMap | null;
getPointLayers: () => VectorLayer<VectorSource>[];
};
type PopupHitResult = {
detectedFeature: Feature | undefined;
isHitIcon: boolean;
coordinate?: number[];
};
type PointerMoveChangePayload = {
hoveredId: string | number | null;
detectedFeature: Feature | undefined;
coordinate?: number[];
};
export class PopupManager {
private map: OlMap | null;
private popupOverlay: Overlay | null = null;
private popupElement: HTMLElement | null = null;
private lastHoveredId: string | number | null = null;
private animationFrameId: number | null = null;
private getPointLayers: () => VectorLayer<VectorSource>[];
private popupMouseEnterHandler: (() => void) | null = null;
private popupMouseMoveHandler: (() => void) | null = null;
private hoverChangeHandler:
| ((payload: PointerMoveChangePayload) => void)
| undefined;
constructor(options: PopupManagerOptions) {
this.map = options.map;
this.getPointLayers = options.getPointLayers;
}
// 备注:同步地图实例,供地图初始化和销毁后更新 Popup 管理器上下文。
setMap(map: OlMap | null) {
this.map = map;
}
// 备注:初始化 Popup Overlay 并挂载到地图实例。
initPopupOverlay(container: HTMLElement) {
this.popupElement = container;
this.popupElement.style.setProperty('pointer-events', 'none', 'important');
this.popupMouseEnterHandler = () => {
this.forceHidePopup();
};
this.popupMouseMoveHandler = () => {
this.forceHidePopup();
};
this.popupElement.addEventListener(
'mouseenter',
this.popupMouseEnterHandler
);
this.popupElement.addEventListener('mousemove', this.popupMouseMoveHandler);
this.popupOverlay = new Overlay({
element: this.popupElement,
positioning: 'bottom-center',
offset: [0, -10],
stopEvent: false,
autoPan: false
});
if (this.map) {
this.map.addOverlay(this.popupOverlay);
}
}
// 备注:统一处理地图点击命中检测,命中图标时通过回调把要素抛给外层。
handleMapClick(
pixel: number[],
onHit?: (feature: Feature, coordinate?: number[]) => void
) {
const { detectedFeature, isHitIcon, coordinate } =
this.detectFeatureAtPixel(pixel);
if (detectedFeature && isHitIcon) {
onHit?.(detectedFeature, coordinate);
}
}
// 备注:统一处理鼠标悬停命中检测与节流,只有 hover 目标变化时才通知外层刷新。
handlePointerMove(
pixel: number[],
onHoverChange?: (payload: PointerMoveChangePayload) => void
) {
if (!this.map) return;
this.hoverChangeHandler = onHoverChange;
const zoom = this.map.getView().getZoom();
if (zoom !== undefined && zoom < 4.7) {
return;
}
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
this.animationFrameId = requestAnimationFrame(() => {
const { detectedFeature, isHitIcon, coordinate } =
this.detectFeatureAtPixel(pixel);
const hoveredId =
detectedFeature && isHitIcon ? (detectedFeature.getId() as any) : null;
const nextFeature = hoveredId ? detectedFeature : undefined;
const nextCoordinate = hoveredId ? coordinate : undefined;
if (hoveredId !== this.lastHoveredId) {
this.emitHoverChange({
hoveredId,
detectedFeature: nextFeature,
coordinate: nextCoordinate
});
}
});
}
// 备注:在指定像素位置检测点图层要素,并判断是否命中图标区域。
detectFeatureAtPixel(pixel: number[]): PopupHitResult {
let detectedFeature: Feature | undefined = undefined;
let isHitIcon = false;
let coordinate: number[] | undefined;
const pointLayers = this.getPointLayers();
if (this.isPixelInsidePopup(pixel)) {
return {
detectedFeature: undefined,
isHitIcon: false,
coordinate: undefined
};
}
this.map?.forEachFeatureAtPixel(
pixel,
(feature, layer) => {
if (pointLayers.includes(layer as VectorLayer<VectorSource>)) {
detectedFeature = feature as Feature;
const geom = feature.getGeometry();
if (geom && geom.getType() === 'Point') {
coordinate = (geom as Point).getCoordinates();
const iconPixel = this.map?.getPixelFromCoordinate(coordinate);
if (iconPixel) {
const dx = pixel[0] - iconPixel[0];
const dy = pixel[1] - iconPixel[1];
if (this.isPixelInsideIconArea(dx, dy)) {
isHitIcon = true;
}
}
}
return true;
}
return false;
},
{ hitTolerance: 0 }
);
return { detectedFeature, isHitIcon, coordinate };
}
// 备注:仅把图标本体区域作为 hover 命中范围,避免上方文字标签被误判为图标命中。
private isPixelInsideIconArea(dx: number, dy: number): boolean {
if (!this.map) return false;
const zoom = this.map.getView().getZoom() ?? 4.5;
let dynamicScale = 0.7 + (zoom - 4.5) * 0.08;
dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale));
const halfWidth = 12 * dynamicScale;
const topReach = 8 * dynamicScale;
const bottomReach = 12 * dynamicScale;
return (
dx >= -halfWidth &&
dx <= halfWidth &&
dy >= -topReach &&
dy <= bottomReach
);
}
// 备注:根据当前悬停要素统一显示或隐藏 Popup 内容。
showPopup(feature: Feature | undefined, coordinate: number[] | undefined) {
if (!this.popupOverlay || !this.popupElement) return;
if (feature && coordinate) {
const props = feature.getProperties();
const popupHtml = props.popupHtml || generatePopupHtml(props);
if (popupHtml) {
this.popupElement.innerHTML = popupHtml;
this.popupOverlay.setPosition(coordinate);
this.popupElement.style.display = 'block';
return;
}
}
this.popupOverlay.setPosition(undefined);
this.popupElement.style.display = 'none';
}
// 备注:统一重置 hover 和 Popup 状态,供地图销毁和切换时复用。
reset() {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.emitHoverChange({
hoveredId: null,
detectedFeature: undefined,
coordinate: undefined
});
}
// 备注:在弹窗层意外接管鼠标时,通过统一 hover 回调链路立即清空当前悬停状态。
private forceHidePopup() {
this.emitHoverChange({
hoveredId: null,
detectedFeature: undefined,
coordinate: undefined
});
}
// 备注:统一派发 hover 变化,确保地图侧的 cursor、Popup 和图层刷新走同一条处理链路。
private emitHoverChange(payload: PointerMoveChangePayload) {
this.lastHoveredId = payload.hoveredId;
this.hoverChangeHandler?.(payload);
if (!this.hoverChangeHandler) {
this.showPopup(payload.detectedFeature, payload.coordinate);
}
}
// 备注:当鼠标进入当前 Popup 可见区域时,禁止再把该区域重新识别为图标 hover。
private isPixelInsidePopup(pixel: number[]): boolean {
if (!this.map || !this.popupElement) {
return false;
}
if (this.popupElement.style.display === 'none') {
return false;
}
const popupRect = this.popupElement.getBoundingClientRect();
if (popupRect.width <= 0 || popupRect.height <= 0) {
return false;
}
const mapRect = this.map.getTargetElement().getBoundingClientRect();
const clientX = mapRect.left + pixel[0];
const clientY = mapRect.top + pixel[1];
return (
clientX >= popupRect.left &&
clientX <= popupRect.right &&
clientY >= popupRect.top &&
clientY <= popupRect.bottom
);
}
// 备注:销毁 Popup 管理器内部状态和 Overlay 引用。
destroy() {
this.reset();
if (this.popupElement && this.popupMouseEnterHandler) {
this.popupElement.removeEventListener(
'mouseenter',
this.popupMouseEnterHandler
);
}
if (this.popupElement && this.popupMouseMoveHandler) {
this.popupElement.removeEventListener(
'mousemove',
this.popupMouseMoveHandler
);
}
if (this.map && this.popupOverlay) {
this.map.removeOverlay(this.popupOverlay);
}
this.popupMouseEnterHandler = null;
this.popupMouseMoveHandler = null;
this.hoverChangeHandler = undefined;
this.popupOverlay = null;
this.popupElement = null;
this.map = null;
}
}

View File

@ -0,0 +1,341 @@
import OlMap from 'ol/Map';
import View from 'ol/View';
import TileLayer from 'ol/layer/Tile';
import GeoJSON from 'ol/format/GeoJSON';
import VectorSource from 'ol/source/Vector';
import { fromLonLat } from 'ol/proj';
import { PointLayerManager } from './point-layer-manager';
type RegionMaskManagerOptions = {
map: OlMap | null;
view: View | null;
pointLayerManager: PointLayerManager;
defaultCenter: [number, number];
defaultZoom: number;
};
export class RegionMaskManager {
private map: OlMap | null;
private view: View | null;
private pointLayerManager: PointLayerManager;
private defaultCenter: [number, number];
private defaultZoom: number;
private maskBindings: Array<{
layer: TileLayer;
prerender: (event: any) => void;
postrender: (event: any) => void;
}> = [];
constructor(options: RegionMaskManagerOptions) {
this.map = options.map;
this.view = options.view;
this.pointLayerManager = options.pointLayerManager;
this.defaultCenter = options.defaultCenter;
this.defaultZoom = options.defaultZoom;
}
// 备注:同步地图和视图上下文,供地图初始化、销毁和重建后复用区域过滤能力。
setContext(map: OlMap | null, view: View | null) {
this.map = map;
this.view = view;
}
// 备注:统一隐藏所有点位的区域显示状态,供基地裁切前的预处理复用。
hideAllPoints(): void {
this.pointLayerManager.forEachFeature(feature => {
feature.set('_regionVisible', false);
feature.changed();
});
}
// 备注:统一恢复所有点位的区域显示状态,供取消裁切或异常兜底时复用。
showAllPoints(): void {
this.pointLayerManager.forEachFeature(feature => {
feature.set('_regionVisible', true);
feature.changed();
});
}
// 备注:根据 GeoJSON 边界批量更新点位的区域显隐状态。
filterPointsByRegion(geoJson: any): void {
const regionCoords = this.extractPolygonCoords(geoJson);
if (!regionCoords.length) {
console.warn('无法解析区域边界,显示所有锚点');
this.showAllPoints();
return;
}
this.pointLayerManager.forEachFeature(feature => {
const props = feature.getProperties();
const lon = Number(props.lgtd);
const lat = Number(props.lttd);
if (!isFinite(lon) || !isFinite(lat)) {
feature.set('_regionVisible', false);
feature.changed();
return;
}
feature.set('_regionVisible', this.isPointInPolygon(lon, lat, regionCoords));
feature.changed();
});
}
// 备注:根据 GeoJSON 范围拟合地图视野,供基地切换后快速聚焦到目标区域。
fitViewToGeoJson(geoJson: any): void {
if (!this.map || !this.view) return;
try {
const features = new GeoJSON().readFeatures(geoJson, {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857'
});
if (!features.length) return;
const source = new VectorSource({
features
});
const extent = source.getExtent();
if (extent[0] === Infinity || extent[0] === -Infinity) {
console.warn('无法计算有效的地图包围盒');
return;
}
this.view.fit(extent, {
padding: [100, 200, 50, 50],
duration: 1000,
maxZoom: 13
});
} catch (error) {
console.error('调整地图视野失败:', error);
}
}
// 备注:把 GeoJSON 边界绑定到底图 prerender/postrender 事件,实现区域裁切遮罩。
applyMapMask(rasterLayer: TileLayer, clipGeoJson: any): void {
this.applyMapMaskToLayers(rasterLayer ? [rasterLayer] : [], clipGeoJson);
}
// 备注:把 GeoJSON 边界同时绑定到多个底图图层,保证多底图叠加场景下裁切边界保持一致。
applyMapMaskToLayers(rasterLayers: TileLayer[] = [], clipGeoJson: any): void {
if (!clipGeoJson) return;
const targetLayers = rasterLayers.filter(Boolean);
if (targetLayers.length === 0) return;
const features = new GeoJSON().readFeatures(clipGeoJson, {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857'
});
if (!features.length) {
console.warn('裁切数据为空,无法应用遮罩');
return;
}
this.clearMaskEvents();
targetLayers.forEach(rasterLayer => {
const maskPrerender = (event: any) => {
const context = event.context;
const frameState = event.frameState;
if (!context || !frameState || !this.map) return;
context.save();
context.setTransform(1, 0, 0, 1, 0, 0);
context.beginPath();
let hasValidPath = false;
for (const feature of features) {
const geometry = feature.getGeometry();
if (!geometry) continue;
if (geometry.getType() === 'Polygon') {
const rings = (geometry as any).getCoordinates() as number[][][];
for (const ring of rings) {
if (this.drawRingToContext(context, ring, frameState)) {
hasValidPath = true;
}
}
} else if (geometry.getType() === 'MultiPolygon') {
const polygons = (geometry as any).getCoordinates() as number[][][][];
for (const polygonRings of polygons) {
for (const ring of polygonRings) {
if (this.drawRingToContext(context, ring, frameState)) {
hasValidPath = true;
}
}
}
}
}
if (!hasValidPath) {
console.warn('未能生成有效的裁切路径');
return;
}
context.save();
context.lineWidth = 6;
context.strokeStyle = '#6D64DF';
context.stroke();
context.clip();
context.strokeStyle = '#CCC9F4';
context.lineWidth = 5;
context.stroke();
context.clip();
};
const maskPostrender = (event: any) => {
if (event.context) {
event.context.restore();
event.context.restore();
}
};
this.maskBindings.push({
layer: rasterLayer,
prerender: maskPrerender,
postrender: maskPostrender
});
rasterLayer.on('prerender', maskPrerender);
rasterLayer.on('postrender', maskPostrender);
rasterLayer.changed();
});
}
// 备注:清除当前底图遮罩并按需恢复默认视野,供切换基地和异常兜底复用。
clearMapMask(resetView: boolean = true): void {
const maskedLayers = this.maskBindings.map(binding => binding.layer);
this.clearMaskEvents();
maskedLayers.forEach(layer => layer.changed());
if (resetView && this.view) {
this.view.animate({
center: fromLonLat(this.defaultCenter),
zoom: this.defaultZoom,
duration: 1000
});
}
}
// 备注:统一销毁区域过滤相关引用和事件,供地图销毁阶段复用。
destroy(): void {
this.clearMapMask(false);
this.map = null;
this.view = null;
}
// 备注:从 GeoJSON 中提取可用于点在面判断的多边形环坐标。
private extractPolygonCoords(geoJson: any): number[][][] {
if (!geoJson?.features?.length) {
return [];
}
const coords: number[][][] = [];
geoJson.features.forEach((feature: any) => {
const geometry = feature.geometry;
if (!geometry) return;
if (geometry.type === 'Polygon') {
coords.push(...geometry.coordinates);
} else if (geometry.type === 'MultiPolygon') {
geometry.coordinates.forEach((polygon: number[][][]) => {
coords.push(...polygon);
});
}
});
return coords;
}
// 备注:使用射线法判断点是否落在任意一个区域环内。
private isPointInPolygon(
lon: number,
lat: number,
polygons: number[][][]
): boolean {
for (const polygon of polygons) {
if (this.isPointInRing(lon, lat, polygon)) {
return true;
}
}
return false;
}
// 备注:使用射线法判断点是否落在单个闭合环内。
private isPointInRing(lon: number, lat: number, ring: number[][]): boolean {
let inside = false;
const ringLength = ring.length;
for (let i = 0, j = ringLength - 1; i < ringLength; j = i++) {
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
const intersect =
yi > lat !== yj > lat &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
if (intersect) {
inside = !inside;
}
}
return inside;
}
// 备注:把单个面环转换为当前帧 Canvas 可裁切的路径。
private drawRingToContext(
context: CanvasRenderingContext2D,
ring: number[][],
frameState: any
): boolean {
if (!ring || ring.length < 3) return false;
const pixelRatio = frameState.pixelRatio || window.devicePixelRatio || 1;
let moved = false;
for (let i = 0; i < ring.length; i++) {
const coord = ring[i];
if (!coord || coord.length < 2 || typeof coord[0] !== 'number') {
continue;
}
const cssPixel = this.map?.getPixelFromCoordinate(coord as [number, number]);
if (!cssPixel) continue;
const canvasX = cssPixel[0] * pixelRatio;
const canvasY = cssPixel[1] * pixelRatio;
if (!moved) {
context.moveTo(canvasX, canvasY);
moved = true;
} else {
context.lineTo(canvasX, canvasY);
}
}
if (moved) {
context.closePath();
return true;
}
return false;
}
// 备注:解除旧底图上的遮罩事件,避免重复绑定或残留旧裁切效果。
private clearMaskEvents(): void {
this.maskBindings.forEach(binding => {
binding.layer.un('prerender', binding.prerender);
binding.layer.un('postrender', binding.postrender);
});
this.maskBindings = [];
}
}

View File

@ -30,24 +30,23 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, watch, onUnmounted } from 'vue'; import { ref, onMounted, computed, onUnmounted } from 'vue';
import { useMapStore } from '@/store/modules/map'; import { useMapOrchestrator } from '@/modules/map/application/map-orchestrator';
import { MapClass } from '@/components/gis/map.class'; import { useMapConfigStore } from '@/modules/map/stores/map-config.store';
import { layerConfig2Flat, getMapConfig } from '@/components/gis/gisUtils'; import { useMapViewStore } from '@/modules/map/stores/map-view.store';
const mapStore = useMapStore(); const mapOrchestrator = useMapOrchestrator();
const mapClass = MapClass.getInstance(); const mapConfigStore = useMapConfigStore();
const mapViewStore = useMapViewStore();
// // checked/checkable
const layerConfigs = computed(() => { const layerConfigs = computed(() => {
const convertCheckedToBoolean = (items: any[]) => { const convertCheckedToBoolean = (items: any[]) => {
return items.map(item => { return items.map(item => {
const newItem = { ...item }; const newItem = { ...item };
// checked(0/1)(false/true)
if (typeof newItem.checkable === 'number') { if (typeof newItem.checkable === 'number') {
newItem.checkable = !!newItem.checkable; newItem.checkable = !!newItem.checkable;
} }
//
if (newItem.children && Array.isArray(newItem.children)) { if (newItem.children && Array.isArray(newItem.children)) {
newItem.children = convertCheckedToBoolean(newItem.children); newItem.children = convertCheckedToBoolean(newItem.children);
} }
@ -55,151 +54,23 @@ const layerConfigs = computed(() => {
}); });
}; };
return convertCheckedToBoolean(mapStore.layerData); return convertCheckedToBoolean(mapConfigStore.layerConfigTree);
}); });
// // store
const checkedKeys = ref<string[]>([]); const checkedKeys = computed(() => mapViewStore.checkedLayerKeys || []);
const tooltipVisible = ref(false); const tooltipVisible = ref(false);
const componentRef = ref<any | null>(null); const componentRef = ref<any | null>(null);
const tipRef = ref<any | null>(null); const tipRef = ref<any | null>(null);
// key const onCheck = async (v: any, e: any) => {
let baseLayersInited = false; await mapOrchestrator.applyLayerSelection({
checkedKeys: v,
/** triggerKey: e?.node?.key,
* 广播图层配置变化 checked: e?.checked
*/
const broadcast = (v: string[]) => {
console.log('broadcast 被调用,图层 keys:', v);
// 使 store
mapStore.updateLayerData(v);
};
/**
* 添加基础图层GISMap 类型
*/
const addBaseLayers = (configs: any[]) => {
//
const layerConfigsArr = layerConfig2Flat(configs);
layerConfigsArr.forEach((item: any) => {
//
if (item.type !== 'GISMap') {
return;
}
// paramJson config
if (!item.config && item.paramJson) {
try {
const jsonObj = JSON.parse(item.paramJson);
item.config = getMapConfig(jsonObj);
} catch {
item.config = null;
}
}
//
if (item.config) {
if (item.key === 'customBaseLayer') {
sessionStorage.setItem('customBaseLayer', JSON.stringify(item.config));
}
mapClass.addBaseDataLayer(item.config, item.checked === 1);
mapClass.controlBaseLayerTreeShowAndHidden(
item.key,
item.config?.id,
item.checked === 1
);
}
}); });
}; };
/**
* 获取初始选中的图层 key
*/
const getInitCheckedKeys = (data: any[] = []): string[] => {
const keys: string[] = [];
const f = (arr: any[] = []) => {
arr.forEach((item: any) => {
if (item.key && item.checked) {
keys.push(item.key);
}
if (item.children && item.children.length > 0) {
f(item.children);
}
});
};
f(data);
return keys;
};
const onCheck = (v: any, e: any) => {
//
const fData = layerConfigs.value?.filter(
(item: any) => item.key === 'facilities'
)?.[0];
const fbData = layerConfigs.value?.filter(
(item: any) => item.key === 'facilities_built'
)?.[0];
const facilities_built_data =
(fbData && [
fbData.key,
...fbData?.children?.map((item: any) => item.key)
]) ||
[];
const facilities_data =
(fData && [fData.key, ...fData?.children?.map((item: any) => item.key)]) ||
[];
let checkKeys = v;
// 沿
if (e?.node.key === 'rare_fish_point') {
checkKeys = v?.filter((item: any) => item !== 'fish_along_point');
} else if (e?.node.key === 'fish_along_point') {
checkKeys = v?.filter((item: any) => item !== 'rare_fish_point');
}
if (e.checked) {
// AI
if (e?.node.key === 'stinfo_video_point' || e?.node.key == 'stinfo') {
checkKeys = v?.filter((item: any) => item !== 'stinfo_ai_video_point');
} else if (e?.node.key === 'stinfo_ai_video_point') {
checkKeys = checkKeys.filter(
(item: any) => item !== 'stinfo_video_point' && item !== 'stinfo'
);
}
//
if (
checkKeys.some((item: any) => facilities_data.includes(item)) &&
checkKeys.some((item: any) => facilities_built_data.includes(item))
) {
if (
facilities_built_data.some((item: any) =>
item.includes(checkKeys[checkKeys.length - 1])
)
) {
checkKeys = checkKeys.filter(
(item: any) => !facilities_data.includes(item)
);
} else if (
facilities_data.some((item: any) =>
item.includes(checkKeys[checkKeys.length - 1])
)
) {
checkKeys = checkKeys.filter(
(item: any) => !facilities_built_data.includes(item)
);
}
}
}
checkedKeys.value = checkKeys;
broadcast(checkKeys);
};
const handleClick = () => { const handleClick = () => {
tooltipVisible.value = !tooltipVisible.value; tooltipVisible.value = !tooltipVisible.value;
}; };
@ -221,43 +92,12 @@ const handleClickOutside = (event: MouseEvent) => {
}; };
onMounted(() => { onMounted(() => {
//
document.addEventListener('click', handleClickOutside); document.addEventListener('click', handleClickOutside);
//
baseLayersInited = false;
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleClickOutside); document.removeEventListener('click', handleClickOutside);
}); });
//
watch(
() => layerConfigs.value,
newVal => {
if (newVal && newVal.length > 0 && !baseLayersInited) {
addBaseLayers(newVal);
baseLayersInited = true;
const keys = getInitCheckedKeys(newVal);
checkedKeys.value = keys;
// loadAllLayerData updateLayerData GisView.vue
}
},
{ immediate: true }
);
// store checkedLayerKeys
watch(
() => mapStore.checkedLayerKeys,
newVal => {
if (newVal && newVal.length > 0) {
checkedKeys.value = newVal;
}
},
{ immediate: true, deep: true }
);
</script> </script>
<style lang="scss"> <style lang="scss">
.map-item { .map-item {

View File

@ -11,6 +11,7 @@
<div <div
v-else v-else
class="map-controller-item" class="map-controller-item"
:class="{ 'is-active': child.key === 'TJ' && tjVisible }"
@click="handleControllerClick(child)" @click="handleControllerClick(child)"
> >
<i class="icon iconfont" :class="'icon-' + child.icon"></i> <i class="icon iconfont" :class="'icon-' + child.icon"></i>
@ -34,6 +35,7 @@ const map = props.map;
// 使 Pinia store // 使 Pinia store
const uiStore = useUiStore(); const uiStore = useUiStore();
const drawerOpen = ref(uiStore.drawerOpen); const drawerOpen = ref(uiStore.drawerOpen);
const tjVisible = ref(false);
// store drawerOpen // store drawerOpen
watch( watch(
@ -43,6 +45,15 @@ watch(
} }
); );
watch(
() => uiStore.mapType,
newVal => {
if (newVal !== '2D') {
tjVisible.value = false;
}
}
);
const isFullScreen = ref(false); const isFullScreen = ref(false);
// //
@ -175,15 +186,18 @@ document.addEventListener('fullscreenchange', () => {
const handleControllerClick = (item: any) => { const handleControllerClick = (item: any) => {
switch (item.key) { switch (item.key) {
case 'fullScreen': case 'fullScreen':
isFullScreen.value = !isFullScreen.value;
// console.log(mapClass.hasLayer('eng_point'));
// map.jdPanelControlShowAndHidden("01", false); // map.jdPanelControlShowAndHidden("01", false);
// map.mdLayerTreeShowOrHidden('fp_point',!isFullScreen.value); map.mdLayerTreeShowOrHidden('eng_point', false);
map.mdLayerTreeShowOrHidden('wq_countryWq_point', true);
// map.controlBaseLayerTreeShowAndHidden( // map.controlBaseLayerTreeShowAndHidden(
// 'customBaseLayer', // 'eng_point',
// 'customBaseLayer', // 'eng_point',
// !isFullScreen.value // !isFullScreen.value
// ); // );
// map.controlBaseLayerTreeShowAndHidden('lcj_bhq','lcj_bhq',!isFullScreen.value); // map.controlBaseLayerTreeShowAndHidden('lcj_bhq','lcj_bhq',!isFullScreen.value);
toggleFullScreen(); // toggleFullScreen();
break; break;
case 'zoomIn': case 'zoomIn':
map.zoomToggle('in'); map.zoomToggle('in');
@ -209,6 +223,7 @@ const handleControllerClick = (item: any) => {
map.mapOutPut(); map.mapOutPut();
break; break;
case 'TJ': case 'TJ':
tjVisible.value = !tjVisible.value;
props.onClick(4, null); props.onClick(4, null);
// map.addTertiarybasinLayer(servers.Tertiarybasin, '#4DFFDD', '#92A0A5') // map.addTertiarybasinLayer(servers.Tertiarybasin, '#4DFFDD', '#92A0A5')
break; break;
@ -244,6 +259,10 @@ const handleControllerClick = (item: any) => {
background-color: #005292; background-color: #005292;
color: #ffffff; color: #ffffff;
} }
&.is-active {
background-color: #005292;
color: #ffffff;
}
} }
} }
.map-controller-group:not(:first-child) { .map-controller-group:not(:first-child) {

View File

@ -1,14 +1,16 @@
<template> <template>
<div class="map-filter-container"> <div class="map-filter-container">
<div class="toolbar"> <div class="toolbar">
<a-form :model="formModel" :rules="rules" ref="formRef"> <a-form :model="formModel">
<a-row :gutter="10"> <a-row :gutter="10">
<a-col> <a-col v-if="isMenu('home/shuiDianKaiFaZhuangKuang')">
<a-form-item label="" name="siteRangePicker"> <a-form-item label="" name="siteRangePicker">
<a-select <a-select
v-model:value="formModel.siteRangePicker" v-model:value="formModel.siteRangePicker"
placeholder="装机容量" placeholder="装机容量"
style="width: 120px" style="width: 120px"
allow-clear
@change="handleCapacityChange"
> >
<a-select-option <a-select-option
v-for="item in siteRangePicker" v-for="item in siteRangePicker"
@ -18,17 +20,77 @@
</a-select> </a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col> <a-col
v-if="isMenu('shengTaiLiuLiang/shengTaiLiuLiangManZuQingKuang')"
>
<a-form-item label="" name="siteRangePicker"> <a-form-item label="" name="siteRangePicker">
<a-range-picker
v-model:value="searchTimeRange"
picker="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD HH:mm:ss"
:allow-clear="false"
show-time
:presets="DateSetting.RangeButton.days1"
style="width: 100%"
@change="triggerManualValuesChange"
/>
</a-form-item>
</a-col>
<a-col v-if="isMenu('fish-survey/fishSurveyZhuanZhi')">
<a-form-item label="" name="">
<a-checkbox v-model:checked="formModel.fishSurveyZhuanZhi">
鱼类分布
</a-checkbox>
</a-form-item>
</a-col>
<a-col v-if="formModel.fishSurveyZhuanZhi">
<a-form-item label="" name="">
<a-date-picker
v-model:value="fishSurveyZhuanZhiParams.time"
picker="year"
format="YYYY"
value-format="YYYY"
:allow-clear="false"
show-time
style="width: 100%"
@change="triggerManualValuesChange"
/>
</a-form-item>
</a-col>
<a-col v-if="formModel.fishSurveyZhuanZhi">
<a-form-item label="" name="">
<a-select <a-select
v-model:value="formModel.siteRangePicker" v-model:value="fishSurveyZhuanZhiParams.value"
placeholder="请输入关键字检索" placeholder="请输入"
style="width: 200px" style="width: 140px"
show-search
allow-clear
/> />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col> <a-col>
<a-button @click="handleSearch">查询</a-button> <a-form-item label="" name="anchorPointSelect">
<a-select
v-model:value="formModel.anchorPointSelect"
placeholder="请输入关键字检索"
style="width: 200px"
show-search
allow-clear
:filter-option="filterAnchorPoint"
@change="handleAnchorPointChange"
>
<a-select-option
v-for="item in anchorPointOptions"
:key="item.stcd"
:value="item.stcd"
>{{ item.stnm || item.ennm }}
{{
item.baseName ? ',' + item.baseName : ''
}}</a-select-option
>
</a-select>
</a-form-item>
</a-col> </a-col>
</a-row> </a-row>
</a-form> </a-form>
@ -36,30 +98,148 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref, computed, watch } from 'vue';
import moment from 'moment';
import { useRoute } from 'vue-router';
import { useMapOrchestrator } from '@/modules/map/application/map-orchestrator';
import { useMapDataStore } from '@/modules/map/stores/map-data.store';
import { useMapViewStore } from '@/modules/map/stores/map-view.store';
import { DateSetting } from '@/utils/enumeration';
const props = defineProps({ const route = useRoute();
map: { const mapOrchestrator = useMapOrchestrator();
type: Object, const mapDataStore = useMapDataStore();
default: () => ({}) const mapViewStore = useMapViewStore();
}
});
const siteRangePicker = [ const siteRangePicker = [
{ label: '0-10', value: '0-10' }, { label: '全部', value: 'all' },
{ label: '10-20', value: '10-20' }, { label: '大型电站', value: 'large_eng_built' },
{ label: '20-30', value: '20-30' }, { label: '中型电站', value: 'mid_eng_built' }
{ label: '30-40', value: '30-40' },
{ label: '40-50', value: '40-50' },
{ label: '50-60', value: '50-60' }
]; ];
const largeScale = ['large_eng_built', 'large_eng_ubuilt', 'large_eng_nbuilt'];
const mediumScale = ['mid_eng_built', 'mid_eng_ubuilt', 'mid_eng_nbuilt'];
const formModel = ref({ const formModel = ref({
siteRangePicker: [] siteRangePicker: null,
anchorPointSelect: null,
fishSurveyZhuanZhi: false
}); });
const rules = ref({ const currentDate = moment();
siteRangePicker: [{ required: true, message: '请选择装机容量' }] const defaultYear =
currentDate.month() < 6 ? currentDate.year() - 1 : currentDate.year();
const fishSurveyZhuanZhiParams = ref({
time: defaultYear.toString(),
value: []
}); });
const handleSearch = () => {
props.map.flyTopanto([98.958315, 30.754818], 14); const searchTimeRange = ref([
moment(mapViewStore.searchTimeRange[0]).format('YYYY-MM-DD HH:mm:ss'),
moment(mapViewStore.searchTimeRange[1]).format('YYYY-MM-DD HH:mm:ss')
]);
//
const isMenu = (menuPath: string) => {
const path = route.path;
return path.includes(menuPath);
};
// store
watch(
() => route.path,
() => {
mapOrchestrator.resetFilterState();
searchTimeRange.value = [
moment(mapViewStore.searchTimeRange[0]).format('YYYY-MM-DD HH:mm:ss'),
moment(mapViewStore.searchTimeRange[1]).format('YYYY-MM-DD HH:mm:ss')
];
formModel.value = {
siteRangePicker: null,
anchorPointSelect: null,
fishSurveyZhuanZhi: false
};
}
);
//
const triggerManualValuesChange = async (val: any) => {
if (val && val.length === 2) {
await mapOrchestrator.changeTimeRange(val);
}
};
//
const anchorPointOptions = computed(() => {
const pointData = mapDataStore.pointData || [];
const checkedLayerKeys = new Set<string>(
mapViewStore.getCheckedLayerKeys() || []
);
const filteredByLayer = pointData.filter((item: any) => {
return checkedLayerKeys.has(item.layerKey);
});
let filteredData = filteredByLayer;
const capacityValue = formModel.value.siteRangePicker;
if (
isMenu('home/shuiDianKaiFaZhuangKuang') &&
capacityValue &&
capacityValue !== '' &&
capacityValue !== 'all'
) {
if (capacityValue === 'large_eng_built') {
filteredData = filteredData.filter((item: any) =>
largeScale.includes(item.anchoPointState)
);
} else if (capacityValue === 'mid_eng_built') {
filteredData = filteredData.filter((item: any) =>
mediumScale.includes(item.anchoPointState)
);
}
}
if (mapViewStore.selectedBaseId) {
filteredData = filteredData.filter(
(item: any) => item.baseId === mapViewStore.selectedBaseId
);
}
return filteredData;
});
const filterAnchorPoint = (input: string, option: any) => {
const item = anchorPointOptions.value.find(
(p: any) => p.stcd === option.value
);
if (!item) return false;
const keyword = input.toLowerCase();
const stnm = (item.stnm || item.ennm || '').toLowerCase();
const baseName = (item.baseName || '').toLowerCase();
return stnm.includes(keyword) || baseName.includes(keyword);
};
//
watch(
() => mapViewStore.selectedBaseId,
() => {
formModel.value.anchorPointSelect = null;
},
{ immediate: true }
);
const handleCapacityChange = (value: string) => {
formModel.value.anchorPointSelect = null;
const isAll = !value || value === '' || value === 'all';
const showLarge = isAll || value === 'large_eng_built';
const showMedium = isAll || value === 'mid_eng_built';
mapOrchestrator.toggleLegendBatch(largeScale, showLarge ? 1 : 0);
mapOrchestrator.toggleLegendBatch(mediumScale, showMedium ? 1 : 0);
};
//
const handleAnchorPointChange = (value: string) => {
mapOrchestrator.focusPoint(value, 14);
}; };
</script> </script>

View File

@ -66,100 +66,36 @@
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, watch, computed } from 'vue'; import { ref, computed } from 'vue';
import { useMapOrchestrator } from '@/modules/map/application/map-orchestrator';
import { useMapDataStore } from '@/modules/map/stores/map-data.store';
import { useMapStore } from '@/store/modules/map'; import { useMapStore } from '@/store/modules/map';
import { useRoute } from 'vue-router';
import LegendItem from '@/components/mapLegend/LegendItem.vue'; import LegendItem from '@/components/mapLegend/LegendItem.vue';
import { MapClass } from '@/components/gis/map.class';
const mapStore: any = useMapStore(); const mapStore = useMapStore();
const route = useRoute(); const mapDataStore = useMapDataStore();
const mapClass = MapClass.getInstance(); const mapOrchestrator = useMapOrchestrator();
const isOpen = ref(true);
// pageKey //
const pageKey = computed(() => { const legendData = computed(() => mapStore.legendDataSelected || []);
const path = route.path; // loading watch
const parts = path.split('/'); const loadLegendData = computed(() => mapDataStore.loading);
if (parts.length >= 3) { //
return parts[1] + '_' + parts[2]; const hasEnvFac = computed(() =>
} legendData.value.some((item: any) => item.name === '环保设施')
return ''; );
});
// 使
const legendData = ref<any[]>([]);
const loadLegendData = ref(false);
// ""
const hasEnvFac = computed(() => {
return legendData.value.some((item: any) => item.name === '环保设施');
});
const legendItemClick = (item: any) => { const legendItemClick = (item: any) => {
if (item.parentName == '环保设施') return; if (item.parentName == '环保设施') return;
if (item.canBeChecked == 0) { if (item.canBeChecked == 0) {
return; return;
} }
const newChecked = item.checked == 0 ? 1 : 0;
item.checked = newChecked;
// store
if (item.nameEn) { if (item.nameEn) {
mapStore.updateLegendChecked(item.nameEn, newChecked); mapOrchestrator.toggleLegend(item.nameEn, item.checked == 0 ? 1 : 0);
}
// nameEn anchoPointState
if (item.nameEn && item.layerCode) {
const layerKey = item.layerCode;
//
const layerItem = mapStore.findLayerByKey(mapStore.layerData, layerKey);
console.log(`图例 ${item.nameEn} 控制图层:`, layerItem);
// alarm_range_ large_eng_built_
const nameEnToUse = item.nameEn.includes('alarm_range_')
? `large_eng_built_${item.nameEn}`
: item.nameEn;
if (layerItem) {
if (layerItem.type === 'pointMap') {
//
mapClass.setLegendPointVisible(layerKey, nameEnToUse, newChecked === 1);
} else if (layerItem.type === 'GISMap') {
// GIS
mapClass.controlBaseLayerTreeShowAndHidden(
layerKey,
layerItem.config?.id,
newChecked === 1
);
}
} else {
//
mapClass.setLegendPointVisible(layerKey, nameEnToUse, newChecked === 1);
}
} }
}; };
//
watch(
() => mapStore.legendDataSelected,
newValue => {
console.log('图例数据更新:', newValue);
legendData.value = newValue;
},
{ immediate: true, deep: true }
);
watch(
() => mapStore.loading,
newValue => {
console.log('图例加载状态:', newValue);
loadLegendData.value = newValue;
},
{ immediate: true }
);
const isOpen = ref(true);
const getAllLeafItems = (group: any): any[] => { const getAllLeafItems = (group: any): any[] => {
const items: any[] = []; const items: any[] = [];
if (!group.childrenList) return items; if (!group.childrenList) return items;
@ -186,49 +122,15 @@ const toggleGroup = (group: any) => {
const hasAnyChecked = leafItems.some((item: any) => item.checked === 1); const hasAnyChecked = leafItems.some((item: any) => item.checked === 1);
const targetChecked = hasAnyChecked ? 0 : 1; const targetChecked = hasAnyChecked ? 0 : 1;
const targetNameEns = leafItems
.filter(
(item: any) => item.parentName !== '环保设施' && item.canBeChecked !== 0
)
.filter((item: any) => item.checked !== targetChecked)
.map((item: any) => item.nameEn)
.filter(Boolean);
leafItems.forEach((item: any) => { mapOrchestrator.toggleLegendBatch(targetNameEns, targetChecked);
if (item.parentName === '环保设施') return;
if (item.canBeChecked === 0) return;
if (item.checked !== targetChecked) {
item.checked = targetChecked;
if (item.nameEn) {
mapStore.updateLegendChecked(item.nameEn, targetChecked);
}
if (item.nameEn && item.layerCode) {
const layerKey = item.layerCode;
const layerItem = mapStore.findLayerByKey(mapStore.layerData, layerKey);
const nameEnToUse = item.nameEn.includes('alarm_range_')
? `large_eng_built_${item.nameEn}`
: item.nameEn;
if (layerItem) {
if (layerItem.type === 'pointMap') {
mapClass.setLegendPointVisible(
layerKey,
nameEnToUse,
targetChecked === 1
);
} else if (layerItem.type === 'GISMap') {
mapClass.controlBaseLayerTreeShowAndHidden(
layerKey,
layerItem.config?.id,
targetChecked === 1
);
}
} else {
mapClass.setLegendPointVisible(
layerKey,
nameEnToUse,
targetChecked === 1
);
}
}
}
});
}; };
</script> </script>

View File

@ -1,260 +0,0 @@
# 地图图层、图例和锚点加载逻辑
## 一、整体流程
```
GisView.vue (onMounted)
├─> 1. 获取图层配置 (mapStore.setLayerData)
├─> 2. 收集初始选中的图层 keys (collectCheckedKeys)
├─> 3. 加载所有锚点数据 (mapStore.loadAllLayerData)
│ │
│ ├─> 并行加载选中的图层数据 (优先)
│ ├─> 后台加载未选中的图层数据
│ └─> 数据缓存到 pointDataCache
└─> 4. 更新图例和图层显隐 (mapStore.updateLayerData)
├─> 更新图层 checked 状态
├─> 获取并排序图例数据 (getlegendData)
├─> 处理环保设施图例 (fp_point/eq_point 联动)
└─> 控制锚点显示/隐藏 (setLegendPointVisible)
```
## 二、核心组件和 Store
### 2.1 组件
- **GisView.vue**: 主视图,负责初始化加载流程
- **LayerController.vue**: 图层树控制器,处理图层勾选交互
- **mapLegend/index.vue**: 图例显示组件
### 2.2 Store (map.ts)
- `layerData`: 图层树配置数据
- `legendData`: 过滤排序后的图例数据
- `legendDataOriginal`: 原始图例数据
- `legendDataSelected`: 选中的图例数据
- `legendDataMap`: 图例数据映射 (nameEn -> 图例项)
- `pointDataCache`: 锚点数据缓存
- `checkedLayerKeys`: 选中的图层 key 列表
## 三、初始化加载流程 (GisView.vue)
### 3.1 获取图层配置
```typescript
const layerRes = await mapStore.getLayerConfig();
mapStore.setLayerData(limitedLayers); // 设置图层数据
```
### 3.2 收集初始选中的 keys
```typescript
const checkedKeys = collectCheckedKeys(limitedLayers);
```
### 3.3 加载锚点数据
```typescript
await mapStore.loadAllLayerData(limitedLayers, checkedKeys);
```
### 3.4 更新图例和图层显隐
```typescript
await mapStore.updateLayerData(checkedKeys, true); // isInit = true
```
## 四、锚点数据加载 (loadAllLayerData)
### 4.1 处理逻辑
1. 递归遍历所有图层配置
2. 区分**选中的图层**和**未选中的图层**
3. 选中的图层**优先加载**,阻塞等待
4. 未选中的图层**后台加载**,不阻塞 UI
### 4.2 数据缓存
- 所有加载的数据都会缓存到 `pointDataCache`
- 已缓存的数据不会重复加载
- 即使图层未选中,也会加载数据并缓存,只是隐藏不显示
### 4.3 加载完成后
1. 调用 `setSelectedLegendData()` 设置选中图例
2. 未选中图层继续后台加载,完成后打印日志
## 五、图层勾选流程 (LayerController.vue)
### 5.1 用户勾选图层
```
onCheck(v, e)
├─> 处理互斥逻辑
│ ├─> 珍稀鱼类 vs 沿程鱼类
│ ├─> 视频监控 vs AI视频监控
│ └─> 环保设施 vs 环保设施在建
├─> checkedKeys.value = checkKeys
└─> broadcast(checkKeys)
└─> mapStore.updateLayerData(v)
```
### 5.2 互斥图层处理
- `rare_fish_point``fish_along_point` 互斥
- `stinfo_video_point``stinfo_ai_video_point` 互斥
- `facilities``facilities_built` 互斥
## 六、图例数据更新 (updateLayerData)
### 6.1 处理流程
```typescript
updateLayerData(checkKeys, isInit = false)
├─> 处理互斥图层 (stinfo_video_point vs stinfo_ai_video_point)
├─> 更新图层 checked 状态 (递归遍历 layerData)
│ 记录 newlyChecked 和 newlyUnchecked
├─> 获取有效 keys (过滤掉 '-', 'customBaseLayer', 'powerBaseStation')
├─> 获取图例数据 (getlegendData)
│ 过滤掉 name === '地图' 的项
│ 递归匹配 layerCode
│ 去重 (根据 parent name)
├─> 处理环保设施图例联动
│ 勾选 fp_point 或 eq_point → 自动添加环保设施图例
│ 取消勾选 → 自动移除环保设施图例
├─> 按 orderIndex 排序
├─> 更新 legendDataSelected
└─> 控制锚点显示/隐藏
isInit = true: 处理所有图层
isInit = false: 只处理变化的图层
```
### 6.2 环保设施图例联动
```typescript
const triggerKeys = ['fp_point', 'eq_point'];
const hasTriggerLayer = triggerKeys.some(key => checkKeys.includes(key));
if (hasTriggerLayer) {
// 添加环保设施图例
} else {
// 移除环保设施图例
}
```
## 七、图例点击处理 (mapLegend)
### 7.1 单个图例项点击
```typescript
legendItemClick(item)
├─> 切换 item.checked (0 ↔ 1)
├─> 更新 legendDataMap
└─> 控制锚点显示/隐藏
pointMap 图层 → setLegendPointVisible
GISMap 图层 → controlBaseLayerTreeShowAndHidden
```
### 7.2 groupTitle 点击 (全选/取消全选)
```typescript
toggleGroup(group)
├─> 获取组内所有叶子节点 (getAllLeafItems)
├─> 判断是否有任何选中 → 决定目标是全选还是取消全选
└─> 遍历所有叶子节点,切换 checked 状态
跳过 parentName === '环保设施' 的项
跳过 canBeChecked === 0 的项
```
### 7.3 groupTitle 变灰判断
```typescript
isGroupAllUnchecked(group)
└─> 所有叶子节点 checked === 0 → 返回 true (变灰)
```
## 八、锚点显示/隐藏控制
### 8.1 pointMap 图层
```typescript
setLegendPointVisible(layerKey, nameEn, visible)
├─> 隐藏所有锚点 (setLegendPointVisible(key, '', false))
└─> 显示选中的锚点 (遍历 legendDatachecked === 1 的项)
```
### 8.2 GISMap 图层
```typescript
controlBaseLayerTreeShowAndHidden(layerKey, configId, visible)
```
## 九、关键数据结构
### 9.1 图层配置 (mapLayerVos)
```typescript
{
key: 'eng_point',
title: '水电站',
type: 'pointMap', // 或 'GISMap'
checked: 1, // 0: 未选中, 1: 选中
url: '/api/xxx',
params: {},
children: [] // 子图层
}
```
### 9.2 图例数据
```typescript
{
name: '已建',
nameEn: 'large_eng_built',
layerCode: 'eng_point',
icon: 'xxx.png',
checked: 1, // 0: 未选中, 1: 选中
canBeChecked: 1, // 0: 不可勾选, 1: 可勾选
childrenList: [] // 子图例
}
```
### 9.3 锚点数据
```typescript
{
stcd: 'xxx', // 站点编码
stnm: '三峡电站', // 站点名称
sttpMap: 'ENG', // 类型编码
anchoPointState: 'large_eng_built', // 状态
iconCode: 'xxx.png', // 图标编码
lng: 111.11, // 经度
lat: 33.33, // 纬度
}
```
## 十、加载时序图
```
时间轴
├─1─┤ setLayerData() - 设置图层配置
├─2─┤ loadAllLayerData() 开始
│ │
│ ├─3─┤ 选中图层数据请求 (阻塞)
│ │ │
│ │ └─4─┤ 数据返回,添加到地图,缓存
│ │
│ ├─5─┤ setSelectedLegendData() - 设置图例
│ │
│ └─6─┤ 未选中图层数据请求 (后台,不阻塞)
│ │
│ └─7─┤ 数据返回,添加到地图,缓存
└─8─┤ updateLayerData() - 更新图层显隐,控制锚点显示
```

View File

@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue"; import { computed } from 'vue';
import { useTagsViewStore } from "@/store/modules/tagsView"; import { useTagsViewStore } from '@/store/modules/tagsView';
import { useRoute } from "vue-router"; import { useRoute } from 'vue-router';
import MapModal from "@/components/MapModal/index.vue"; import MapModal from '@/components/MapModal/index.vue';
import { useModelStore } from "@/store/modules/model"; import { useModelStore } from '@/store/modules/model';
import GisView from "@/components/gis/GisView.vue"; import GisView from '@/components/gis/GisView.vue';
const modelStore = useModelStore(); const modelStore = useModelStore();
@ -36,7 +36,7 @@ const routeKey = computed(() => router.path + Math.random());
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@use "@/styles/variables.module.scss" as *; @use '@/styles/variables.module.scss' as *;
.app-main { .app-main {
min-height: calc(100vh - $layout-header-height - $locationbar-height); min-height: calc(100vh - $layout-header-height - $locationbar-height);
width: 100%; width: 100%;

View File

@ -0,0 +1,425 @@
import { watch, type WatchStopHandle } from 'vue';
import { unByKey } from 'ol/Observable';
import moment from 'moment';
import { MapClass } from '@/components/gis/map.class';
import { getMapConfig, layerConfig2Flat } from '@/components/gis/gisUtils';
import { useMapConfigStore } from '@/modules/map/stores/map-config.store';
import { useMapDataStore } from '@/modules/map/stores/map-data.store';
import { useMapViewStore } from '@/modules/map/stores/map-view.store';
import { useMapStore } from '@/store/modules/map';
import { useJidiSelectEventStore } from '@/store/modules/jidiSelectEvent';
type InitializeOptions = {
container: HTMLElement;
popupContainer?: HTMLDivElement | null;
pageKey: string;
};
type LoadPageOptions = {
pageKey: string;
};
type ApplyLayerSelectionOptions = {
checkedKeys: string[];
triggerKey?: string;
checked?: boolean;
};
const SYSTEM_ID = '974975A6-47FD-4C04-9ACD-68938D2992BD';
const MODULE_ID = '2157c1a1-e909-4c9f-af94-b4ccebe05808';
const HYDRO_DYNAMIC_LAYER_KEYS = [
'dw_point',
'stinfo_video_point',
'stinfo_gjllz_point',
'wt_point',
'wq_ownWq_point',
'wq_countryWq_point',
'fp_point',
'eq_point',
'fb_point',
'vp_point',
'va_point',
'sg_point'
];
export const useMapOrchestrator = () => {
const mapClass = MapClass.getInstance();
const mapStore = useMapStore();
const mapConfigStore = useMapConfigStore();
const mapDataStore = useMapDataStore();
const mapViewStore = useMapViewStore();
const jidiSelectEventStore = useJidiSelectEventStore();
let zoomListenerKey: any = null;
let stopBaseSelectionWatch: WatchStopHandle | null = null;
const initializedBaseLayerKeys = new Set<string>();
// 备注:统一获取当前地图视图对象,避免在多个组件里重复兼容 2D/3D view 访问方式。
const getCurrentView = () => {
return mapClass.view?.getView ? mapClass.view.getView() : mapClass.view;
};
// 备注:统一获取当前地图缩放级别,供页面切换和缩放监听复用。
const getCurrentZoom = (): number | undefined => {
return getCurrentView()?.getZoom?.();
};
// 备注:统一把当前页面中的 GIS 基础图层初始化到地图实例,避免在图层树组件里做配置解析和加层编排。
const ensureBaseLayersInitialized = (configs: any[] = []) => {
const flatLayerConfigs = layerConfig2Flat(configs);
flatLayerConfigs.forEach((item: any) => {
if (item.type !== 'GISMap' || !item.key) {
return;
}
if (!item.config && item.paramJson) {
try {
const jsonObj = JSON.parse(item.paramJson);
item.config = getMapConfig(jsonObj);
} catch {
item.config = null;
}
}
if (!item.config) {
return;
}
if (item.key === 'customBaseLayer') {
sessionStorage.setItem('customBaseLayer', JSON.stringify(item.config));
}
if (!initializedBaseLayerKeys.has(item.key)) {
mapClass.addBaseDataLayer(item.config, item.checked === 1);
initializedBaseLayerKeys.add(item.key);
}
mapClass.controlBaseLayerTreeShowAndHidden(
item.key,
item.config?.id || item.key,
item.checked === 1
);
});
};
// 备注:统一加载当前页面所需的图层配置、图例配置以及首批锚点数据。
const loadPage = async ({ pageKey }: LoadPageOptions) => {
mapDataStore.setLoading(true);
try {
const { layerConfig, legendOriginal, pageLegend } =
await mapConfigStore.loadPageMapConfig({
systemId: SYSTEM_ID,
moduleId: MODULE_ID,
pageKey,
description: 'true'
});
if (legendOriginal.length > 0) {
mapStore.setLegendData(legendOriginal, pageLegend);
}
if (layerConfig.length > 0) {
mapStore.setLayerData(layerConfig);
ensureBaseLayersInitialized(layerConfig);
const checkedKeys = mapViewStore.getCheckedLayerKeys();
await mapStore.loadAllLayerData(layerConfig, checkedKeys);
}
} finally {
mapDataStore.setLoading(false);
}
};
// 备注:统一完成地图实例初始化和弹窗挂载,确保后续事件监听可以在图层数据加载前生效。
const initializeMapShell = async ({
container,
popupContainer
}: InitializeOptions) => {
await mapClass.init(container);
if (popupContainer) {
mapClass.initPopupOverlay(popupContainer);
}
};
// 备注:统一完成首屏页面配置加载,供挂载阶段和后续页面重载复用。
const initialize = async ({
container,
popupContainer,
pageKey
}: InitializeOptions) => {
await initializeMapShell({
container,
popupContainer,
pageKey
});
await loadPage({ pageKey });
};
// 备注:统一绑定地图缩放监听,让页面组件只负责触发初始化,不再直接处理缩放联动细节。
const bindZoomListener = (getIsHydroMenu: () => boolean) => {
if (zoomListenerKey) {
unByKey(zoomListenerKey);
zoomListenerKey = null;
}
const view = getCurrentView();
if (!view?.on) return;
const currentZoom = getCurrentZoom();
if (currentZoom !== undefined) {
mapViewStore.setCurrentZoomLevel(currentZoom);
}
zoomListenerKey = view.on('change:resolution', async () => {
const zoom = getCurrentZoom();
if (zoom === undefined) return;
await handleZoomLevelChange(
mapViewStore.currentZoomLevel,
zoom,
getIsHydroMenu()
);
});
};
// 备注:统一绑定基地切换事件,让页面组件不再直接 watch 外部基地选择源。
const bindBaseSelection = () => {
if (stopBaseSelectionWatch) {
stopBaseSelectionWatch();
stopBaseSelectionWatch = null;
}
stopBaseSelectionWatch = watch(
() => jidiSelectEventStore.selectedItem,
newVal => {
if (newVal?.wbsCode) {
changeBaseId(newVal.wbsCode);
}
},
{ deep: true, immediate: true }
);
};
// 备注:统一挂载地图页面生命周期,初始化后自动接管缩放监听和基地切换监听。
const mountView = async (
options: InitializeOptions & {
getIsHydroMenu: () => boolean;
}
) => {
await initializeMapShell(options);
bindBaseSelection();
bindZoomListener(options.getIsHydroMenu);
await loadPage({ pageKey: options.pageKey });
};
// 备注:统一处理菜单切换后的页面配置重载,供页面 watch 直接复用。
const reloadPage = async (pageKey: string) => {
if (!pageKey) return;
await loadPage({ pageKey });
};
// 备注:统一处理图层树勾选结果归一化和地图联动,供图层树组件复用。
const applyLayerSelection = async ({
checkedKeys,
triggerKey,
checked
}: ApplyLayerSelectionOptions) => {
const normalizedKeys = mapStore.normalizeCheckedLayerKeys(
checkedKeys,
triggerKey,
checked
);
await mapStore.updateLayerData(normalizedKeys);
return normalizedKeys;
};
// 备注:统一确保指定动态图层完成数据预加载,避免缩放切换时重复在页面组件里拼装加载逻辑。
const ensureDynamicLayersLoaded = async (layerKeys: string[] = []) => {
const layersToLoad = layerKeys
.map(layerKey => mapStore.findLayerByKey(mapStore.layerData, layerKey))
.filter(
(layer: any) => layer?.url && !mapDataStore.hasPointLayerData(layer.key)
);
for (const layer of layersToLoad) {
await mapStore.loadLayerData(layer);
}
};
// 备注:统一控制水电开发菜单下的动态图层增删,避免页面组件直接操作加载和勾选细节。
const syncHydroDynamicLayers = async (visible: boolean) => {
const currentCheckedKeys = mapViewStore.getCheckedLayerKeys();
if (visible) {
const layersToAdd = HYDRO_DYNAMIC_LAYER_KEYS.filter(
key => !currentCheckedKeys.includes(key)
);
if (layersToAdd.length === 0) {
return currentCheckedKeys;
}
await ensureDynamicLayersLoaded(layersToAdd);
return applyLayerSelection({
checkedKeys: [...currentCheckedKeys, ...layersToAdd]
});
}
const nextCheckedKeys = currentCheckedKeys.filter(
key => !HYDRO_DYNAMIC_LAYER_KEYS.includes(key)
);
if (nextCheckedKeys.length === currentCheckedKeys.length) {
return currentCheckedKeys;
}
return applyLayerSelection({
checkedKeys: nextCheckedKeys
});
};
// 备注:统一处理地图缩放联动,收口水电开发菜单下的动态图层阈值切换逻辑。
const handleZoomLevelChange = async (
previousZoom: number,
currentZoom: number,
isHydroMenu: boolean
) => {
mapViewStore.setCurrentZoomLevel(currentZoom);
if (!isHydroMenu) {
return;
}
const oldZoomLevel = Math.floor(previousZoom);
const newZoomLevel = Math.floor(currentZoom);
if (oldZoomLevel < 12 && newZoomLevel >= 12) {
await syncHydroDynamicLayers(true);
return;
}
if (oldZoomLevel >= 12 && newZoomLevel < 12) {
await syncHydroDynamicLayers(false);
}
};
// 备注:统一处理菜单切换后的页面重载和动态图层收口,减少页面组件里的分支编排。
const handlePageChange = async (pageKey: string, isHydroMenu: boolean) => {
if (!pageKey) return;
await reloadPage(pageKey);
const currentZoom = getCurrentZoom();
if (isHydroMenu && currentZoom !== undefined && currentZoom >= 12) {
await syncHydroDynamicLayers(true);
return;
}
await syncHydroDynamicLayers(false);
};
// 备注:统一处理单个图层切换命令,供后续其他入口复用。
const toggleLayer = async (layerKey: string, checked?: boolean) => {
const currentKeys = mapViewStore.getCheckedLayerKeys();
const currentSet = new Set(currentKeys);
const nextChecked =
checked === undefined ? !currentSet.has(layerKey) : checked;
if (nextChecked) {
currentSet.add(layerKey);
} else {
currentSet.delete(layerKey);
}
return applyLayerSelection({
checkedKeys: Array.from(currentSet),
triggerKey: layerKey,
checked: nextChecked
});
};
// 备注:统一处理单个图例切换命令,供图例组件复用。
const toggleLegend = (nameEn: string, checked?: number) => {
if (!nameEn) return;
const nextChecked =
checked === undefined
? mapViewStore.getLegendChecked(nameEn) === 1
? 0
: 1
: checked;
mapStore.updateLegendChecked(nameEn, nextChecked);
};
// 备注:统一处理批量图例切换命令,供容量筛选和分组图例复用。
const toggleLegendBatch = (nameEns: string[] = [], checked: number) => {
const targetNameEns = nameEns.filter(Boolean);
if (targetNameEns.length === 0) return;
mapStore.updateLegendCheckedBatch(targetNameEns, checked);
};
// 备注:统一处理基地切换命令,同时驱动地图基地裁切联动。
const changeBaseId = (baseId: string) => {
const nextBaseId = !baseId || baseId === 'all' ? '' : baseId;
mapViewStore.setSelectedBaseId(nextBaseId);
mapClass.jdPanelControlShowAndHidden(
baseId || 'all',
baseId !== 'all' && !!baseId
);
};
// 备注:统一处理时间范围变更,并触发相关图层数据重载。
const changeTimeRange = async (range: [any, any]) => {
mapViewStore.setSearchTimeRange(range);
await mapStore.reloadBySearchTimeRange();
};
// 备注:统一重置地图筛选表单依赖的时间范围,供筛选组件在切页时复位默认输入。
const resetFilterState = () => {
mapViewStore.setSearchTimeRange([moment().subtract(1, 'M'), moment()]);
};
// 备注:统一处理搜索定位,按点位编码从缓存数据中查找并飞行到目标位置。
const focusPoint = (pointId: string, zoom: number = 14) => {
if (!pointId) return;
const targetPoint = mapDataStore.pointData.find((item: any) => {
return item.stcd === pointId || item._id === pointId;
});
if (!targetPoint?.lgtd || !targetPoint?.lttd) return;
mapClass.flyTopanto([targetPoint.lgtd, targetPoint.lttd], zoom);
};
// 备注:统一释放地图页面生命周期里注册的监听,避免页面卸载后残留联动。
const unmountView = () => {
if (zoomListenerKey) {
unByKey(zoomListenerKey);
zoomListenerKey = null;
}
if (stopBaseSelectionWatch) {
stopBaseSelectionWatch();
stopBaseSelectionWatch = null;
}
};
return {
initialize,
mountView,
unmountView,
loadPage,
reloadPage,
applyLayerSelection,
ensureDynamicLayersLoaded,
syncHydroDynamicLayers,
getCurrentZoom,
handleZoomLevelChange,
handlePageChange,
toggleLayer,
toggleLegend,
toggleLegendBatch,
changeBaseId,
changeTimeRange,
resetFilterState,
focusPoint
};
};

View File

@ -0,0 +1,147 @@
type BuildLegendTreeOptions = {
items?: any[];
selectedLayerCodes?: Set<string>;
getLegendChecked: (nameEn?: string) => number;
normalizeLegendNameEn: (nameEn?: string) => string;
};
// 备注:统一把 checked 值规范为 0/1避免运行态和原始配置态混用不同类型。
export const normalizeLegendCheckedValue = (
checked: any
): number | undefined => {
if (checked === null || checked === undefined || checked === '') {
return undefined;
}
const numericChecked = Number(checked);
if (Number.isNaN(numericChecked)) {
return undefined;
}
return numericChecked === 1 ? 1 : 0;
};
// 备注:收集图例树的叶子节点,供初始化图例运行态 checked 索引使用。
export const collectLegendLeaves = (items: any[] = []): any[] => {
const leaves: any[] = [];
items.forEach(item => {
if (item?.childrenList?.length > 0) {
leaves.push(...collectLegendLeaves(item.childrenList));
} else {
leaves.push(item);
}
});
return leaves;
};
// 备注:根据原始图例配置生成图例运行态 checked 映射,避免后续重复递归取默认值。
export const buildLegendCheckedState = (
sourceData: any[] = [],
normalizeLegendNameEn: (nameEn?: string) => string
): Record<string, number> => {
const nextState: Record<string, number> = {};
collectLegendLeaves(sourceData).forEach(item => {
const normalizedNameEn = normalizeLegendNameEn(item?.nameEn);
if (!normalizedNameEn) return;
const fallbackChecked = normalizeLegendCheckedValue(item?.checked) ?? 0;
nextState[normalizedNameEn] = fallbackChecked;
});
return nextState;
};
// 备注:按当前选中图层和图例运行态生成可展示的图例树结构。
export const buildLegendTree = ({
items = [],
selectedLayerCodes,
getLegendChecked,
normalizeLegendNameEn
}: BuildLegendTreeOptions): any[] => {
const buildLegendNode = (item: any): any | null => {
if (item?.name === '地图') {
return null;
}
if (item?.childrenList?.length > 0) {
const children = item.childrenList
.map((child: any) => buildLegendNode(child))
.filter(Boolean);
if (selectedLayerCodes && children.length === 0) {
return null;
}
return {
...item,
checked: children.some((child: any) => child.checked === 1) ? 1 : 0,
childrenList: children
};
}
if (item?.ifShow === 0) {
return null;
}
if (
selectedLayerCodes &&
(!item?.layerCode || !selectedLayerCodes.has(item.layerCode))
) {
return null;
}
const normalizedNameEn = normalizeLegendNameEn(item?.nameEn);
return {
...item,
nameEn: normalizedNameEn,
checked: getLegendChecked(normalizedNameEn)
};
};
return items.map(item => buildLegendNode(item)).filter(Boolean);
};
// 备注:按名称从图例树中提取指定分组,供环保设施补充规则复用。
export const pickLegendGroupByName = (
items: any[] = [],
groupName: string
): any[] => {
return items.filter(item => item?.name === groupName);
};
// 备注:统一应用环保设施图例补充规则,避免在多个位置重复拼装同一组图例。
export const applyEnvFacilityLegendRule = (
legendItems: any[] = [],
layerKeys: string[] = [],
legendSource: any[] = [],
buildLegendItems: (items: any[]) => any[]
): any[] => {
const triggerKeys = [
'fp_point',
'eq_point',
'fb_point',
'vp_point',
'va_point',
'sg_point',
'dw_point'
];
const hasTriggerLayer = triggerKeys.some(key => layerKeys.includes(key));
const withoutEnvFacility = legendItems.filter(
(item: any) => item.name !== '环保设施'
);
if (!hasTriggerLayer) {
return withoutEnvFacility;
}
const envFacilityLegend = buildLegendItems(
pickLegendGroupByName(legendSource, '环保设施')
)[0];
if (!envFacilityLegend) {
return withoutEnvFacility;
}
return [...withoutEnvFacility, envFacilityLegend];
};

View File

@ -0,0 +1,80 @@
type ApplyLayerMutualExclusionRulesOptions = {
rawKeys?: string[];
previousKeys?: string[];
triggerKey?: string;
checked?: boolean;
getLayerBranchKeys?: (rootKey: string) => string[];
};
// 备注:统一按当前业务规则收口图层互斥关系,返回归一化后的勾选结果。
export const applyLayerMutualExclusionRules = ({
rawKeys = [],
previousKeys = [],
triggerKey,
checked,
getLayerBranchKeys
}: ApplyLayerMutualExclusionRulesOptions): string[] => {
let normalizedKeys = Array.from(new Set(rawKeys.filter(Boolean)));
const hasAny = (keys: string[]) =>
keys.length > 0 && keys.some(key => normalizedKeys.includes(key));
const hadAny = (keys: string[]) =>
keys.length > 0 && keys.some(key => previousKeys.includes(key));
const removeKeys = (keys: string[]) => {
if (keys.length === 0) return;
normalizedKeys = normalizedKeys.filter(key => !keys.includes(key));
};
// 备注:统一处理两组图层的互斥关系,并优先保留当前触发的一组。
const resolveConflict = (
primaryKeys: string[],
secondaryKeys: string[],
preferredGroup: 'primary' | 'secondary' = 'primary'
) => {
if (!hasAny(primaryKeys) || !hasAny(secondaryKeys)) {
return;
}
if (triggerKey && checked !== false) {
if (primaryKeys.includes(triggerKey)) {
removeKeys(secondaryKeys);
return;
}
if (secondaryKeys.includes(triggerKey)) {
removeKeys(primaryKeys);
return;
}
}
const primaryWasChecked = hadAny(primaryKeys);
const secondaryWasChecked = hadAny(secondaryKeys);
if (primaryWasChecked && !secondaryWasChecked) {
removeKeys(secondaryKeys);
return;
}
if (!primaryWasChecked && secondaryWasChecked) {
removeKeys(primaryKeys);
return;
}
if (preferredGroup === 'primary') {
removeKeys(secondaryKeys);
} else {
removeKeys(primaryKeys);
}
};
resolveConflict(['rare_fish_point'], ['fish_along_point'], 'primary');
resolveConflict(
['stinfo', 'stinfo_video_point'],
['stinfo_ai_video_point'],
'secondary'
);
const facilityKeys = getLayerBranchKeys?.('facilities') || [];
const facilityBuiltKeys = getLayerBranchKeys?.('facilities_built') || [];
resolveConflict(facilityKeys, facilityBuiltKeys, 'primary');
return normalizedKeys;
};

View File

@ -0,0 +1,205 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { getMapList, getModuleMapLegendList } from '@/api/map';
type MapConfigLoadOptions = {
systemId: string;
moduleId: string;
pageKey?: string;
description?: string;
};
const cloneConfigData = <T>(data: T): T => {
return JSON.parse(JSON.stringify(data));
};
export const useMapConfigStore = defineStore('map-config', () => {
const layerConfigTree = ref<any[]>([]);
const layerConfigByKey = ref<Record<string, any>>({});
const legendConfigOriginal = ref<any[]>([]);
const legendConfigByNameEn = ref<Record<string, any>>({});
const legendConfigByLayerCode = ref<Record<string, any[]>>({});
const pageLegendConfig = ref<any[]>([]);
const configLoading = ref(false);
const lastLoadOptions = ref<MapConfigLoadOptions | null>(null);
// 备注:统一规范图例 nameEn避免 `_测试` 后缀影响索引命中。
const normalizeLegendNameEn = (nameEn?: string): string => {
if (!nameEn) return '';
return nameEn.includes('_测试') ? nameEn.split('_测试')[0] : nameEn;
};
// 备注:重建图层配置索引,后续按 key 读取图层配置时不再全量递归。
const rebuildLayerConfigIndex = (items: any[] = []) => {
const nextIndex: Record<string, any> = {};
const walk = (nodes: any[] = []) => {
nodes.forEach(item => {
if (item?.key) {
nextIndex[item.key] = item;
}
if (item?.children?.length > 0) {
walk(item.children);
}
});
};
walk(items);
layerConfigByKey.value = nextIndex;
};
// 备注:从图层配置中提取默认勾选项,作为页面初始化时的基础勾选状态。
const extractCheckedLayerKeys = (items: any[] = []): string[] => {
const keys: string[] = [];
const walk = (nodes: any[] = []) => {
nodes.forEach(item => {
if (item?.checked === 1 && item?.key) {
keys.push(item.key);
}
if (item?.children?.length > 0) {
walk(item.children);
}
});
};
walk(items);
return keys;
};
// 备注:重建图例配置索引,分别支持按 nameEn 和 layerCode 查询图例配置。
const rebuildLegendConfigIndexes = (items: any[] = []) => {
const nextNameEnMap: Record<string, any> = {};
const nextLayerCodeMap: Record<string, any[]> = {};
const walk = (nodes: any[] = []) => {
nodes.forEach(item => {
if (item?.childrenList?.length > 0) {
walk(item.childrenList);
return;
}
const normalizedNameEn = normalizeLegendNameEn(item?.nameEn);
if (normalizedNameEn) {
nextNameEnMap[normalizedNameEn] = item;
}
if (item?.layerCode) {
if (!nextLayerCodeMap[item.layerCode]) {
nextLayerCodeMap[item.layerCode] = [];
}
nextLayerCodeMap[item.layerCode].push(item);
}
});
};
walk(items);
legendConfigByNameEn.value = nextNameEnMap;
legendConfigByLayerCode.value = nextLayerCodeMap;
};
// 备注:设置图层树配置,并同步初始化图层索引与默认勾选状态。
const setLayerConfigTree = (data: any[] = []) => {
const nextData = cloneConfigData(data);
layerConfigTree.value = nextData;
rebuildLayerConfigIndex(nextData);
};
// 备注:设置全量原始图例配置,并同步构建图例索引。
const setLegendConfigOriginal = (data: any[] = []) => {
const nextData = cloneConfigData(data);
legendConfigOriginal.value = nextData;
rebuildLegendConfigIndexes(nextData);
};
// 备注:设置页面级图例配置,仅保留后端返回的页面图例结构,不在这里做运行态加工。
const setPageLegendConfig = (data: any[] = []) => {
pageLegendConfig.value = cloneConfigData(data);
};
// 备注:按图层 key 获取图层配置,供后续编排层和运行态 store 复用。
const getLayerConfigByKey = (layerKey: string) => {
return layerConfigByKey.value[layerKey];
};
// 备注:按图层 layerCode 获取原始图例项列表,供图层勾选后派生图例使用。
const getLegendConfigByLayerCode = (layerCode: string): any[] => {
return legendConfigByLayerCode.value[layerCode] || [];
};
// 备注:按图例 nameEn 获取原始图例项,供运行态读取默认 checked 配置。
const getLegendConfigByNameEn = (nameEn: string) => {
const normalizedNameEn = normalizeLegendNameEn(nameEn);
return legendConfigByNameEn.value[normalizedNameEn];
};
// 备注:统一清理配置 store便于菜单切换或地图销毁后重置配置状态。
const clearConfigState = () => {
layerConfigTree.value = [];
layerConfigByKey.value = {};
legendConfigOriginal.value = [];
legendConfigByNameEn.value = {};
legendConfigByLayerCode.value = {};
pageLegendConfig.value = [];
lastLoadOptions.value = null;
};
// 备注:加载页面地图配置,只负责请求和保存配置数据,不处理锚点或地图渲染逻辑。
const loadPageMapConfig = async (options: MapConfigLoadOptions) => {
configLoading.value = true;
lastLoadOptions.value = { ...options };
try {
const [layerRes, legendAllRes, legendPageRes] = await Promise.all([
getMapList({
systemId: options.systemId,
moduleId: options.moduleId,
description: options.description ?? 'true'
}),
getModuleMapLegendList(),
getModuleMapLegendList(
options.pageKey ? { moduleId: options.pageKey } : undefined
)
]);
const layerConfig = layerRes?.data?.mapLayerVos || [];
const legendOriginal = legendAllRes?.data || [];
const pageLegend = legendPageRes?.data || [];
setLayerConfigTree(layerConfig);
setLegendConfigOriginal(legendOriginal);
setPageLegendConfig(pageLegend);
return {
layerConfig,
legendOriginal,
pageLegend
};
} finally {
configLoading.value = false;
}
};
return {
layerConfigTree,
layerConfigByKey,
legendConfigOriginal,
legendConfigByNameEn,
legendConfigByLayerCode,
pageLegendConfig,
configLoading,
lastLoadOptions,
normalizeLegendNameEn,
rebuildLayerConfigIndex,
extractCheckedLayerKeys,
rebuildLegendConfigIndexes,
setLayerConfigTree,
setLegendConfigOriginal,
setPageLegendConfig,
getLayerConfigByKey,
getLegendConfigByLayerCode,
getLegendConfigByNameEn,
clearConfigState,
loadPageMapConfig
};
});

View File

@ -0,0 +1,208 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
type PointLayerCacheItem = {
checked: boolean;
data: any[];
cacheKey?: string;
};
type LayerLoadStatus = {
loading: boolean;
loaded: boolean;
error: string | null;
};
export const useMapDataStore = defineStore('map-data', () => {
const pointData = ref<any[]>([]);
const pointDataCache = ref<Record<string, PointLayerCacheItem>>({});
const layerLoadState = ref<Record<string, LayerLoadStatus>>({});
const loading = ref(true);
// 备注:统一设置地图模块整体 loading 状态,供图例和筛选组件复用。
const setLoading = (value: boolean) => {
loading.value = value;
};
// 备注:整体清空锚点合并数据,通常用于菜单切换或重新初始化。
const clearPointData = () => {
pointData.value = [];
};
// 备注:直接覆盖锚点合并数据,供地图加载完成后统一回写。
const setPointData = (data: any[] = []) => {
pointData.value = data;
};
// 备注:读取指定图层缓存,供地图渲染逻辑优先命中缓存数据。
const getPointLayerCache = (layerKey: string) => {
return pointDataCache.value[layerKey];
};
// 备注:判断单个图层缓存是否与当前请求条件匹配,避免旧筛选结果直接复用。
const hasValidPointLayerCache = (layerKey: string, cacheKey: string) => {
const cache = getPointLayerCache(layerKey);
if (!cache || !cacheKey) return false;
return cache.cacheKey === cacheKey && cache.data.length > 0;
};
// 备注:读取单个图层的缓存数据数组,未命中时返回空数组,简化外层判空逻辑。
const getPointLayerData = (layerKey: string): any[] => {
return pointDataCache.value[layerKey]?.data || [];
};
// 备注:判断单个图层缓存是否已具备可用数据,供加载和显隐逻辑快速分支。
const hasPointLayerData = (layerKey: string): boolean => {
return getPointLayerData(layerKey).length > 0;
};
// 备注:写入单个图层的锚点缓存,并保留当前是否勾选的状态。
const setPointLayerCache = (layerKey: string, cache: PointLayerCacheItem) => {
if (!layerKey) return;
pointDataCache.value = {
...pointDataCache.value,
[layerKey]: cache
};
};
// 备注:只更新缓存中的勾选状态,避免重复覆盖整份锚点数据。
const setPointLayerCacheChecked = (layerKey: string, checked: boolean) => {
const cache = pointDataCache.value[layerKey];
if (!cache) return;
setPointLayerCache(layerKey, {
...cache,
checked
});
};
// 备注:删除指定图层缓存,供时间筛选重载和图层强制刷新使用。
const removePointLayerCache = (layerKey: string) => {
if (!pointDataCache.value[layerKey]) return;
const nextCache = { ...pointDataCache.value };
delete nextCache[layerKey];
pointDataCache.value = nextCache;
};
// 备注:清空全部图层缓存,供页面切换或地图销毁时复用。
const clearPointLayerCache = () => {
pointDataCache.value = {};
};
// 备注:根据当前缓存重建合并锚点数据,避免在多个地方手写拼装逻辑。
const rebuildPointDataFromCache = (layerKeys: string[] = []) => {
const targetLayerKeys =
layerKeys.length > 0 ? layerKeys : Object.keys(pointDataCache.value);
const allPointData: any[] = [];
targetLayerKeys.forEach(layerKey => {
const cache = pointDataCache.value[layerKey];
if (!cache?.data?.length) return;
const pointsWithLayerKey = cache.data.map((point: any) => ({
...point,
layerKey
}));
allPointData.push(...pointsWithLayerKey);
});
pointData.value = allPointData;
return allPointData;
};
// 备注:统一初始化单个图层的加载状态,避免外层重复判空。
const ensureLayerLoadState = (layerKey: string): LayerLoadStatus => {
if (!layerLoadState.value[layerKey]) {
layerLoadState.value = {
...layerLoadState.value,
[layerKey]: {
loading: false,
loaded: false,
error: null
}
};
}
return layerLoadState.value[layerKey];
};
// 备注:标记单个图层进入加载中状态。
const setLayerLoading = (layerKey: string) => {
const current = ensureLayerLoadState(layerKey);
layerLoadState.value = {
...layerLoadState.value,
[layerKey]: {
...current,
loading: true,
error: null
}
};
};
// 备注:标记单个图层加载成功,供后续判断是否已完成首次加载。
const setLayerLoaded = (layerKey: string) => {
const current = ensureLayerLoadState(layerKey);
layerLoadState.value = {
...layerLoadState.value,
[layerKey]: {
...current,
loading: false,
loaded: true,
error: null
}
};
};
// 备注:标记单个图层加载失败,并记录错误信息。
const setLayerLoadError = (layerKey: string, error: unknown) => {
const current = ensureLayerLoadState(layerKey);
layerLoadState.value = {
...layerLoadState.value,
[layerKey]: {
...current,
loading: false,
loaded: false,
error: error instanceof Error ? error.message : String(error)
}
};
};
// 备注:清理指定图层的加载状态,供重载场景复用。
const clearLayerLoadState = (layerKey: string) => {
if (!layerLoadState.value[layerKey]) return;
const nextState = { ...layerLoadState.value };
delete nextState[layerKey];
layerLoadState.value = nextState;
};
// 备注:整体重置数据缓存 store供菜单切换或地图销毁时调用。
const resetDataState = () => {
pointData.value = [];
pointDataCache.value = {};
layerLoadState.value = {};
loading.value = true;
};
return {
pointData,
pointDataCache,
layerLoadState,
loading,
setLoading,
clearPointData,
setPointData,
getPointLayerCache,
hasValidPointLayerCache,
getPointLayerData,
hasPointLayerData,
setPointLayerCache,
setPointLayerCacheChecked,
removePointLayerCache,
clearPointLayerCache,
rebuildPointDataFromCache,
ensureLayerLoadState,
setLayerLoading,
setLayerLoaded,
setLayerLoadError,
clearLayerLoadState,
resetDataState
};
});

View File

@ -0,0 +1,92 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import moment from 'moment';
export const useMapViewStore = defineStore('map-view', () => {
const checkedLayerKeys = ref<string[]>([]);
const legendCheckedState = ref<Record<string, number>>({});
const searchTimeRange = ref<[any, any]>([moment().subtract(1, 'M'), moment()]);
const selectedBaseId = ref('');
const currentZoomLevel = ref(4.5);
// 备注:统一写入当前选中的图层 key始终保持去重后的结果。
const setCheckedLayerKeys = (keys: string[] = []) => {
checkedLayerKeys.value = Array.from(new Set(keys.filter(Boolean)));
};
// 备注:获取当前选中的图层 key供图层树、动态图层和筛选联动复用。
const getCheckedLayerKeys = (): string[] => {
return checkedLayerKeys.value;
};
// 备注:覆盖写入完整图例运行态勾选状态,适合图例初始化和整批替换。
const setLegendCheckedState = (state: Record<string, number> = {}) => {
legendCheckedState.value = { ...state };
};
// 备注:写入单个图例运行态勾选状态,供点击图例项时复用。
const setLegendChecked = (nameEn: string, checked: number) => {
if (!nameEn) return;
legendCheckedState.value = {
...legendCheckedState.value,
[nameEn]: checked
};
};
// 备注:批量写入多个图例运行态勾选状态,供分组图例和筛选联动复用。
const setLegendCheckedBatch = (
stateMap: Record<string, number> = {}
) => {
legendCheckedState.value = {
...legendCheckedState.value,
...stateMap
};
};
// 备注:获取单个图例当前运行态勾选状态,未命中时默认返回 0。
const getLegendChecked = (nameEn: string): number => {
return legendCheckedState.value[nameEn] ?? 0;
};
// 备注:统一更新当前时间筛选范围,供筛选组件和地图数据重载复用。
const setSearchTimeRange = (range: [any, any]) => {
searchTimeRange.value = range;
};
// 备注:统一更新当前基地 ID供地图筛选和基地联动复用。
const setSelectedBaseId = (baseId: string) => {
selectedBaseId.value = baseId || '';
};
// 备注:统一更新当前地图缩放级别,供动态图层联动复用。
const setCurrentZoomLevel = (zoom: number) => {
currentZoomLevel.value = zoom;
};
// 备注:在菜单切换或地图销毁时重置运行态到默认值。
const resetViewState = () => {
checkedLayerKeys.value = [];
legendCheckedState.value = {};
searchTimeRange.value = [moment().subtract(1, 'M'), moment()];
selectedBaseId.value = '';
currentZoomLevel.value = 4.5;
};
return {
checkedLayerKeys,
legendCheckedState,
searchTimeRange,
selectedBaseId,
currentZoomLevel,
setCheckedLayerKeys,
getCheckedLayerKeys,
setLegendCheckedState,
setLegendChecked,
setLegendCheckedBatch,
getLegendChecked,
setSearchTimeRange,
setSelectedBaseId,
setCurrentZoomLevel,
resetViewState
};
});

File diff suppressed because it is too large Load Diff

View File

@ -213,97 +213,133 @@ namespace DateSetting {
value: [dayjs().startOf('year'), dayjs().endOf('year')] value: [dayjs().startOf('year'), dayjs().endOf('year')]
} }
], ],
days1: [
{
label: '今天',
value: [dayjs().startOf('day'), dayjs().endOf('day')]
},
{
label: '昨天',
value: [
dayjs().subtract(1, 'day').startOf('day'),
dayjs().subtract(1, 'day').endOf('day')
]
},
{
label: '近七天',
// 注意:如果项目配置了 isoWeek 插件且希望周一为起始,可使用 startOf('isoWeek')
value: [dayjs().subtract(6, 'day').startOf('day'), dayjs().endOf('day')]
},
{
label: '最近一个月',
value: [
dayjs().subtract(1, 'month').startOf('month'),
dayjs().subtract(1, 'month').endOf('month')
]
},
{
label: '最近三个月',
value: [
dayjs().subtract(3, 'month').startOf('month'),
dayjs().endOf('month')
]
},
{
label: '今年',
value: [dayjs().startOf('year'), dayjs().endOf('year')]
}
],
tenDays: [ tenDays: [
{ {
label: '上旬', label: '上旬',
value: getEarlyDays() value: getEarlyDays()
}, },
{ {
label: '中旬', label: '中旬',
value: getMidmonth() value: getMidmonth()
}, },
{ {
label: '下旬', label: '下旬',
value: getLastTenDays() value: getLastTenDays()
}, }
], ],
month: [ month: [
{ {
label: '最近一个月', label: '最近一个月',
value: [getStartTime().subtract(1, 'month'), getStartTime()], value: [getStartTime().subtract(1, 'month'), getStartTime()]
}, },
{ {
label: '最近三个月', label: '最近三个月',
value: [getStartTime().subtract(3, 'month'), getStartTime()], value: [getStartTime().subtract(3, 'month'), getStartTime()]
}, },
{ {
label: '最近半年', label: '最近半年',
value: [getStartTime().subtract(6, 'month'), getStartTime()], value: [getStartTime().subtract(6, 'month'), getStartTime()]
}, },
{ {
label: '最近一年', label: '最近一年',
value: [getStartTime().subtract(1, 'year'), getStartTime()], value: [getStartTime().subtract(1, 'year'), getStartTime()]
}, }
], ],
season: [ season: [
{ {
label: '第一季度', label: '第一季度',
value: [getStartYear(), getStartYear().add(2, 'month').endOf('month')], value: [getStartYear(), getStartYear().add(2, 'month').endOf('month')]
}, },
{ {
label: '第二季度', label: '第二季度',
value: [ value: [
getStartYear().add(3, 'month'), getStartYear().add(3, 'month'),
getStartYear().add(5, 'month').endOf('month') getStartYear().add(5, 'month').endOf('month')
], ]
}, },
{ {
label: '第三季度', label: '第三季度',
value: [ value: [
getStartYear().add(6, 'month'), getStartYear().add(6, 'month'),
getStartYear().add(8, 'month').endOf('month') getStartYear().add(8, 'month').endOf('month')
], ]
}, },
{ {
label: '第四季度', label: '第四季度',
value: [ value: [
getStartYear().add(9, 'month'), getStartYear().add(9, 'month'),
getStartYear().add(11, 'month').endOf('month') getStartYear().add(11, 'month').endOf('month')
], ]
}, }
], ],
year: [ year: [
{ {
label: '最近一年', label: '最近一年',
value: [getStartTime().subtract(1, 'year'), getStartTime()], value: [getStartTime().subtract(1, 'year'), getStartTime()]
}, },
{ {
label: '近三年', label: '近三年',
value: [getStartTime().subtract(3, 'year'), getStartTime()], value: [getStartTime().subtract(3, 'year'), getStartTime()]
}, },
{ {
label: '最近五年', label: '最近五年',
value: [getStartTime().subtract(5, 'year'), getStartTime()], value: [getStartTime().subtract(5, 'year'), getStartTime()]
}, },
{ {
label: '最近十年', label: '最近十年',
value: [getStartTime().subtract(10, 'year'), getStartTime()] value: [getStartTime().subtract(10, 'year'), getStartTime()]
}, }
], ],
future: [ future: [
{ {
label: '未来一周', label: '未来一周',
value: [getStartTime(), getStartTime().add(7, 'day')], value: [getStartTime(), getStartTime().add(7, 'day')]
}, },
{ {
label: '未来一个月', label: '未来一个月',
value: [getStartTime(), getStartTime().add(1, 'month')], value: [getStartTime(), getStartTime().add(1, 'month')]
}, },
{ {
label: '未来三个月', label: '未来三个月',
value: [getStartTime(), getStartTime().add(3, 'month')] value: [getStartTime(), getStartTime().add(3, 'month')]
}, }
], ]
}; };
} }

View File

@ -2478,10 +2478,8 @@ const getStatusHtml = (props: any) => {
props; props;
const _dtin = `${dtin}`; const _dtin = `${dtin}`;
const baseId = '02'; const baseId = '02';
const _stdSstate = const _stdSstate = lcjStatus;
baseId === '07' || baseId === '02' ? `${lcjStatus}` : `${stdSstate}`; const _stdSstateName = lcjStatusName;
const _stdSstateName =
baseId === '07' || baseId === '02' ? lcjStatusName : stdSstateName;
if (_dtin == '0') { if (_dtin == '0') {
return `<span>${dtinName || '未接入'}</span>`; return `<span>${dtinName || '未接入'}</span>`;
@ -2718,25 +2716,6 @@ export function generatePopupHtml(props: any): string {
} }
if (!lastTmEngEqDataVo?.qo && !lastTmEngEqDataVo?.qi) return ''; if (!lastTmEngEqDataVo?.qo && !lastTmEngEqDataVo?.qi) return '';
const baseId = '02';
const qiHtml =
baseId !== '02' &&
lastTmEngEqDataVo?.qi != null &&
lastTmEngEqDataVo?.qi !== ''
? `
<li>
<div class="__label"></div>
<div class="__info">
${transUnitRender(
formatNumber(lastTmEngEqDataVo?.qi),
'HYDROPW_R',
'QI'
)} ${getUnitConfigByCode('HYDROPW_R', 'QI')?.unit}
</div>
</li>
`
: '';
const qecLimitVal = lastTmEngEqDataVo?.qecLimit; const qecLimitVal = lastTmEngEqDataVo?.qecLimit;
let qecLimitDisplay = '-'; let qecLimitDisplay = '-';
if (qecLimitVal == 0 || qecLimitVal == null) { if (qecLimitVal == 0 || qecLimitVal == null) {
@ -2760,11 +2739,19 @@ export function generatePopupHtml(props: any): string {
<div class="map-tooltip-title">${p?.ennm || p?.stnm}</div> <div class="map-tooltip-title">${p?.ennm || p?.stnm}</div>
<div class="map-tooltip-sub">${lastTmEngEqDataVo?.tm ?? ''}</div> <div class="map-tooltip-sub">${lastTmEngEqDataVo?.tm ?? ''}</div>
<ul class="map-tooltip-content map-tooltip-list"> <ul class="map-tooltip-content map-tooltip-list">
${qiHtml}
<li> <li>
<div class="__label">${ <div class="__label"></div>
baseId == '02' ? '生态流量:' : '出库流量:' <div class="__info">
}</div> ${transUnitRender(
formatNumber(lastTmEngEqDataVo?.qi),
'HYDROPW_R',
'QI'
)} ${getUnitConfigByCode('HYDROPW_R', 'QI')?.unit}
</div>
</li>
<li>
<div class="__label"> </div>
<div class="__info"> <div class="__info">
${transUnitRender( ${transUnitRender(
lastTmEngEqDataVo?.qo, lastTmEngEqDataVo?.qo,
@ -3103,16 +3090,6 @@ export function generatePopupHtml(props: any): string {
} }
case 'FH': { case 'FH': {
const baseId = '02';
const ennmLi =
baseId === 'all'
? ''
: `
<li>
<div class="__label"></div>
<div class="__info">${p?.ennm || '-'}</div>
</li>
`;
html = ` html = `
<div class="map-tooltip map-tooltip-small"> <div class="map-tooltip map-tooltip-small">
<div class="map-tooltip-box"> <div class="map-tooltip-box">
@ -3122,7 +3099,10 @@ export function generatePopupHtml(props: any): string {
<div class="__label"></div> <div class="__label"></div>
<div class="__info">${p?.rvcdName || '-'}</div> <div class="__info">${p?.rvcdName || '-'}</div>
</li> </li>
${ennmLi} <li>
<div class="__label"></div>
<div class="__info">${p?.ennm || '-'}</div>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -3432,16 +3412,6 @@ export function generatePopupHtml(props: any): string {
case 'VD': case 'VD':
case 'AIVD': { case 'AIVD': {
const baseId = '02';
const habitatLi =
p?.ennm === null && p?.fhstnm === null
? ''
: p?.ennm === null
? `<li><div class="__label">所属栖息地:</div><div class="__info">${p?.fhstnm}</div></li>`
: baseId === 'all'
? ''
: `<li><div class="__label">关联电站:</div><div class="__info">${p?.ennm}</div></li>`;
html = ` html = `
<div class="map-tooltip"> <div class="map-tooltip">
<div class="map-tooltip-box"> <div class="map-tooltip-box">
@ -3455,7 +3425,9 @@ export function generatePopupHtml(props: any): string {
newsttpMap == 'VD' ? p?.sttpName : p?.aiSttpName newsttpMap == 'VD' ? p?.sttpName : p?.aiSttpName
}</div> }</div>
</li> </li>
${habitatLi} <li><div class="__label"></div><div class="__info">${
p?.ennm
}</div></li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -32,7 +32,7 @@ service.interceptors.request.use(
config.headers._sysid = '10EC2E0B-AEA9-4757-83A2-201BA1BC54E9'; config.headers._sysid = '10EC2E0B-AEA9-4757-83A2-201BA1BC54E9';
config.headers.authorization = config.headers.authorization =
'bearer a31a19b6-508c-4773-bde8-62df5c448183'; 'bearer 7894f05d-7204-4117-bda5-8d2f850ae463';
config.baseURL = '/'; config.baseURL = '/';
} else { } else {
const user = useUserStoreHook(); const user = useUserStoreHook();

View File

@ -10,7 +10,7 @@
<!-- 左侧背景图区域 --> <!-- 左侧背景图区域 -->
<div class="left-section"> <div class="left-section">
<div class="slogan"> <div class="slogan">
<p>{{ $t("login.titleSjtb") }}</p> <p>{{ $t('login.titleSjtb') }}</p>
</div> </div>
</div> </div>
@ -25,9 +25,18 @@
<!-- <h3 class="forgot-password-title">忘记密码</h3> --> <!-- <h3 class="forgot-password-title">忘记密码</h3> -->
</div> </div>
<a-form :model="forgotPasswordForm" :rules="forgotPasswordRules" layout="vertical" class="form-container"> <a-form
:model="forgotPasswordForm"
:rules="forgotPasswordRules"
layout="vertical"
class="form-container"
>
<a-form-item label="" name="phone"> <a-form-item label="" name="phone">
<a-input v-model:value="forgotPasswordForm.phone" placeholder="请输入手机号" size="large"> <a-input
v-model:value="forgotPasswordForm.phone"
placeholder="请输入手机号"
size="large"
>
<template #prefix> <template #prefix>
<MobileOutlined /> <MobileOutlined />
</template> </template>
@ -36,15 +45,27 @@
<a-form-item label="" name="captcha"> <a-form-item label="" name="captcha">
<div class="captcha-row"> <div class="captcha-row">
<a-input v-model:value="forgotPasswordForm.captcha" placeholder="请输入验证码" size="large" class="captcha-input"> <a-input
v-model:value="forgotPasswordForm.captcha"
placeholder="请输入验证码"
size="large"
class="captcha-input"
>
<template #prefix> <template #prefix>
<LockOutlined /> <LockOutlined />
</template> </template>
<template #suffix> <template #suffix>
<a-button type="text" size="small" @click="sendForgotPasswordSms" :disabled="smsButtonDisabled" <a-button
:loading="loading"> type="text"
size="small"
@click="sendForgotPasswordSms"
:disabled="smsButtonDisabled"
:loading="loading"
>
{{ {{
smsCountdown > 0 ? `${smsCountdown}秒后重新获取` : "获取验证码" smsCountdown > 0
? `${smsCountdown}秒后重新获取`
: '获取验证码'
}} }}
</a-button> </a-button>
</template> </template>
@ -53,7 +74,11 @@
</a-form-item> </a-form-item>
<a-form-item label="" name="newPassword"> <a-form-item label="" name="newPassword">
<a-input-password v-model:value="forgotPasswordForm.newPassword" placeholder="请输入新密码" size="large"> <a-input-password
v-model:value="forgotPasswordForm.newPassword"
placeholder="请输入新密码"
size="large"
>
<template #prefix> <template #prefix>
<LockOutlined /> <LockOutlined />
</template> </template>
@ -61,14 +86,24 @@
</a-form-item> </a-form-item>
<a-form-item label="" name="confirmPassword"> <a-form-item label="" name="confirmPassword">
<a-input-password v-model:value="forgotPasswordForm.confirmPassword" placeholder="请再次输入密码" size="large"> <a-input-password
v-model:value="forgotPasswordForm.confirmPassword"
placeholder="请再次输入密码"
size="large"
>
<template #prefix> <template #prefix>
<LockOutlined /> <LockOutlined />
</template> </template>
</a-input-password> </a-input-password>
</a-form-item> </a-form-item>
<a-button type="primary" size="large" block @click="handleResetPassword" :loading="loading"> <a-button
type="primary"
size="large"
block
@click="handleResetPassword"
:loading="loading"
>
<span>提交</span> <span>提交</span>
</a-button> </a-button>
</a-form> </a-form>
@ -79,12 +114,23 @@
<!-- Tabs 切换账号登录 / 短信登录 --> <!-- Tabs 切换账号登录 / 短信登录 -->
<a-tabs v-model:activeKey="activeTab" class="login-tabs"> <a-tabs v-model:activeKey="activeTab" class="login-tabs">
<a-tab-pane key="account" tab="账号登录"> <a-tab-pane key="account" tab="账号登录">
<a-form :model="loginData" :rules="loginRules" layout="vertical" class="form-container" <a-form
@finish="onFinish"> :model="loginData"
:rules="loginRules"
layout="vertical"
class="form-container"
@finish="onFinish"
>
<!-- 用户名/账号/手机号输入框 --> <!-- 用户名/账号/手机号输入框 -->
<a-form-item label="" name="username"> <a-form-item label="" name="username">
<a-input ref="username" v-model:value="loginData.username" clearable type="text" <a-input
placeholder="请输入用户账号/手机号" size="large"> ref="username"
v-model:value="loginData.username"
clearable
type="text"
placeholder="请输入用户账号/手机号"
size="large"
>
<template #prefix> <template #prefix>
<UserOutlined /> <UserOutlined />
</template> </template>
@ -93,7 +139,12 @@
<!-- 密码输入框 --> <!-- 密码输入框 -->
<a-form-item label="" name="password"> <a-form-item label="" name="password">
<a-input-password type="password" v-model:value="loginData.password" placeholder="请输入密码" size="large"> <a-input-password
type="password"
v-model:value="loginData.password"
placeholder="请输入密码"
size="large"
>
<template #prefix> <template #prefix>
<LockOutlined /> <LockOutlined />
</template> </template>
@ -103,39 +154,79 @@
<a-form-item label="" name="captcha"> <a-form-item label="" name="captcha">
<div class="captcha-row"> <div class="captcha-row">
<a-row :gutter="24" align="middle"> <a-row :gutter="24" align="middle">
<a-col :span="10"><a-input v-model:value="loginData.code" placeholder="请输入验证码" size="large" <a-col :span="10"
class="captcha-input" /></a-col> ><a-input
<a-col :span="10"><img :src="codeUrl" alt="验证码" class="captcha-img" /></a-col> v-model:value="loginData.code"
placeholder="请输入验证码"
size="large"
class="captcha-input"
/></a-col>
<a-col :span="10"
><img :src="codeUrl" alt="验证码" class="captcha-img"
/></a-col>
<a-col :span="4"> <a @click="getCode">换一张</a></a-col> <a-col :span="4"> <a @click="getCode">换一张</a></a-col>
</a-row> </a-row>
</div> </div>
</a-form-item> </a-form-item>
<a-button type="primary" size="large" block htmlType="submit" :loading="loading"> <a-button
type="primary"
size="large"
block
htmlType="submit"
:loading="loading"
>
<span>登录</span> <span>登录</span>
</a-button> </a-button>
<div style="width: 100%;display: flex;align-items: center;justify-content: space-between;"> <div
<a-checkbox v-model:checked="remember" style="margin-top: 10px;">记住密码</a-checkbox> style="
<a-button type="link" size="mini" @click="showForgotPasswordPage" width: 100%;
:style="{ marginTop: '10px', border: 'none' }"> display: flex;
align-items: center;
justify-content: space-between;
"
>
<a-checkbox
v-model:checked="remember"
style="margin-top: 10px"
>记住密码</a-checkbox
>
<a-button
type="link"
size="mini"
@click="showForgotPasswordPage"
:style="{ marginTop: '10px', border: 'none' }"
>
忘记密码 忘记密码
</a-button> </a-button>
<a-button type="link" size="mini" @click="goRegister" <a-button
:style="{ marginTop: '10px', border: 'none' }"> type="link"
size="mini"
@click="goRegister"
:style="{ marginTop: '10px', border: 'none' }"
>
用户注册 用户注册
</a-button> </a-button>
</div> </div>
<!-- 忘记密码 --> <!-- 忘记密码 -->
</a-form> </a-form>
</a-tab-pane> </a-tab-pane>
<!-- 短信登录 Tab (占位) --> <!-- 短信登录 Tab (占位) -->
<a-tab-pane key="sms" tab="短信登录"> <a-tab-pane key="sms" tab="短信登录">
<a-form :model="loginData" :rules="state.smsLoginRules" layout="vertical" class="form-container" <a-form
@finish="onSmsLogin"> :model="loginData"
:rules="state.smsLoginRules"
layout="vertical"
class="form-container"
@finish="onSmsLogin"
>
<a-form-item label="" name="username"> <a-form-item label="" name="username">
<a-input v-model:value="loginData.username" placeholder="请输入手机号" size="large"> <a-input
v-model:value="loginData.username"
placeholder="请输入手机号"
size="large"
>
<template #prefix> <template #prefix>
<MobileOutlined /> <MobileOutlined />
</template> </template>
@ -143,33 +234,66 @@
</a-form-item> </a-form-item>
<a-form-item label="" name="captcha"> <a-form-item label="" name="captcha">
<div class="captcha-row"> <div class="captcha-row">
<a-input v-model:value="loginData.code" placeholder="请输入验证码" size="large" class="captcha-input"> <a-input
v-model:value="loginData.code"
placeholder="请输入验证码"
size="large"
class="captcha-input"
>
<template #prefix> <template #prefix>
<LockOutlined /> <LockOutlined />
</template> </template>
<template #suffix> <template #suffix>
<a-button type="text" size="small" @click="sendSms" :disabled="smsButtonDisabled" <a-button
:loading="loading"> type="text"
size="small"
@click="sendSms"
:disabled="smsButtonDisabled"
:loading="loading"
>
{{ {{
smsCountdown > 0 smsCountdown > 0
? `${smsCountdown}秒后重新获取` ? `${smsCountdown}秒后重新获取`
: "获取验证码" : '获取验证码'
}} }}
</a-button> </a-button>
</template> </template>
</a-input> </a-input>
</div> </div>
</a-form-item> </a-form-item>
<a-button type="primary" size="large" block htmlType="submit" :loading="loading"> <a-button
type="primary"
size="large"
block
htmlType="submit"
:loading="loading"
>
<span>登录</span> <span>登录</span>
</a-button> </a-button>
<div style="width: 100%;display: flex;align-items: center;justify-content: space-between;"> <div
<a-button type="link" size="mini" block @click="showForgotPasswordPage" style="
:style="{ marginTop: '10px', border: 'none' }"> width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
"
>
<a-button
type="link"
size="mini"
block
@click="showForgotPasswordPage"
:style="{ marginTop: '10px', border: 'none' }"
>
忘记密码 忘记密码
</a-button> </a-button>
<a-button type="link" size="mini" block @click="goRegister" <a-button
:style="{ marginTop: '10px', border: 'none' }"> type="link"
size="mini"
block
@click="goRegister"
:style="{ marginTop: '10px', border: 'none' }"
>
注册 注册
</a-button> </a-button>
</div> </div>
@ -183,34 +307,43 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref, toRefs, watch } from "vue"; import { onMounted, reactive, ref, toRefs, watch } from 'vue';
import loginImg from "@/assets/images/logo.png"; import loginImg from '@/assets/images/logo.png';
import { UserOutlined, LockOutlined, MobileOutlined } from "@ant-design/icons-vue"; import {
import { getCaptcha, sendSmsCode, smsLoginApi, resetPassword } from "@/api/auth"; UserOutlined,
import { message } from "ant-design-vue"; LockOutlined,
import { setToken } from "@/utils/auth"; MobileOutlined
import Cookies from "js-cookie"; } from '@ant-design/icons-vue';
import {
getCaptcha,
sendSmsCode,
smsLoginApi,
resetPassword
} from '@/api/auth';
import { message } from 'ant-design-vue';
import { setToken } from '@/utils/auth';
import Cookies from 'js-cookie';
// //
import router from "@/router"; import router from '@/router';
// API // API
import { useRoute } from "vue-router"; import { useRoute } from 'vue-router';
import { LoginData } from "@/api/auth/types"; import { LoginData } from '@/api/auth/types';
// //
import { encrypt, decrypt } from "@/utils/rsaEncrypt"; import { encrypt, decrypt } from '@/utils/rsaEncrypt';
// //
import { useUserStore } from "@/store/modules/user"; import { useUserStore } from '@/store/modules/user';
import { usePermissionStoreHook } from "@/store/modules/permission"; import { usePermissionStoreHook } from '@/store/modules/permission';
// //
import { useI18n } from "vue-i18n"; import { useI18n } from 'vue-i18n';
const { t } = useI18n(); const { t } = useI18n();
const userStore = useUserStore(); const userStore = useUserStore();
const route = useRoute(); const route = useRoute();
// //
const codeUrl = ref(""); const codeUrl = ref('');
const smsCountdown = ref(0); // const smsCountdown = ref(0); //
const smsButtonDisabled = ref(false); // const smsButtonDisabled = ref(false); //
@ -219,55 +352,59 @@ let remember = ref(false);
// //
const forgotPasswordForm = ref({ const forgotPasswordForm = ref({
phone: "", phone: '',
captcha: "", captcha: '',
newPassword: "", newPassword: '',
confirmPassword: "", confirmPassword: ''
}); });
// //
const showForgotPassword = ref(false); const showForgotPassword = ref(false);
// //
const activeTab = ref("account"); const activeTab = ref('account');
const state = reactive({ const state = reactive({
redirect: "", redirect: '',
loginData: { loginData: {
uuid: "", uuid: '',
username: "", username: '',
password: "", password: '',
code: "", code: ''
} as LoginData, } as LoginData,
loginRules: { loginRules: {
username: [{ required: true, trigger: "blur", message: t("login.rulesUsername") }], username: [
password: [{ required: true, trigger: "blur", message: t("login.rulesPassword") }], { required: true, trigger: 'blur', message: t('login.rulesUsername') }
code: [{ required: true, trigger: "blur", message: "请输入验证码" }], ],
password: [
{ required: true, trigger: 'blur', message: t('login.rulesPassword') }
],
code: [{ required: true, trigger: 'blur', message: '请输入验证码' }]
}, },
smsLoginRules: { smsLoginRules: {
username: [{ required: true, trigger: "blur", message: "请输入手机号" }], username: [{ required: true, trigger: 'blur', message: '请输入手机号' }],
code: [{ required: true, trigger: "blur", message: "请输入验证码" }] code: [{ required: true, trigger: 'blur', message: '请输入验证码' }]
}, },
loginImg: loginImg[0], loginImg: loginImg[0],
loading: false, loading: false,
passwordType: "password", passwordType: 'password',
// //
capslockTooltipDisabled: true, capslockTooltipDisabled: true,
otherQuery: {}, otherQuery: {},
clientHeight: document.documentElement.clientHeight, clientHeight: document.documentElement.clientHeight,
showDialog: false, showDialog: false,
cookiePass: "", cookiePass: ''
}); });
// //
const forgotPasswordRules = ref({ const forgotPasswordRules = ref({
phone: [ phone: [
{ required: true, message: "手机号不能为空", trigger: "blur" }, { required: true, message: '手机号不能为空', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: "请输入正确的手机号", trigger: "blur" }, { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
], ],
captcha: [{ required: true, message: "验证码不能为空", trigger: "blur" }], captcha: [{ required: true, message: '验证码不能为空', trigger: 'blur' }],
newPassword: [ newPassword: [
{ required: true, message: "新密码不能为空", trigger: "blur" }, { required: true, message: '新密码不能为空', trigger: 'blur' },
{ min: 10, message: "密码长度不能少于10位", trigger: "blur" }, { min: 10, message: '密码长度不能少于10位', trigger: 'blur' },
{ {
validator: (rule: any, value: string) => { validator: (rule: any, value: string) => {
if (!value) return Promise.resolve(); if (!value) return Promise.resolve();
@ -276,12 +413,23 @@ const forgotPasswordRules = ref({
const hasUpperCase = /[A-Z]/.test(value); const hasUpperCase = /[A-Z]/.test(value);
const hasLowerCase = /[a-z]/.test(value); const hasLowerCase = /[a-z]/.test(value);
const hasNumber = /\d/.test(value); const hasNumber = /\d/.test(value);
const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(value); const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(
value
);
const typeCount = [hasUpperCase, hasLowerCase, hasNumber, hasSpecialChar].filter(Boolean).length; const typeCount = [
hasUpperCase,
hasLowerCase,
hasNumber,
hasSpecialChar
].filter(Boolean).length;
if (typeCount < 3) { if (typeCount < 3) {
return Promise.reject(new Error('密码必须包含大写字母、小写字母、数字、特殊字符中的至少三种')); return Promise.reject(
new Error(
'密码必须包含大写字母、小写字母、数字、特殊字符中的至少三种'
)
);
} }
// //
@ -294,7 +442,9 @@ const forgotPasswordRules = ref({
if (/\d/.test(value[i]) && /\d/.test(value[i + 1])) { if (/\d/.test(value[i]) && /\d/.test(value[i + 1])) {
const diff = value.charCodeAt(i + 1) - value.charCodeAt(i); const diff = value.charCodeAt(i + 1) - value.charCodeAt(i);
if (Math.abs(diff) === 1) { if (Math.abs(diff) === 1) {
return Promise.reject(new Error('密码不能包含连续递增或递减的数字')); return Promise.reject(
new Error('密码不能包含连续递增或递减的数字')
);
} }
} }
} }
@ -304,14 +454,16 @@ const forgotPasswordRules = ref({
if (/[a-zA-Z]/.test(value[i]) && /[a-zA-Z]/.test(value[i + 1])) { if (/[a-zA-Z]/.test(value[i]) && /[a-zA-Z]/.test(value[i + 1])) {
const diff = value.charCodeAt(i + 1) - value.charCodeAt(i); const diff = value.charCodeAt(i + 1) - value.charCodeAt(i);
if (Math.abs(diff) === 1) { if (Math.abs(diff) === 1) {
return Promise.reject(new Error('密码不能包含连续递增或递减的字母')); return Promise.reject(
new Error('密码不能包含连续递增或递减的字母')
);
} }
} }
} }
return Promise.resolve(); return Promise.resolve();
}, },
trigger: "blur" trigger: 'blur'
} }
], ],
confirmPassword: [ confirmPassword: [
@ -326,15 +478,15 @@ const forgotPasswordRules = ref({
} }
return Promise.resolve(); return Promise.resolve();
}, },
trigger: "blur" trigger: 'blur'
} }
], ]
}); });
const { const {
loginData, loginData,
loginRules, loginRules,
loading, loading
// passwordType, capslockTooltipDisabled // passwordType, capslockTooltipDisabled
} = toRefs(state); } = toRefs(state);
@ -364,7 +516,7 @@ function onFinish() {
password: state.loginData.password, password: state.loginData.password,
// rememberMe: state.loginData.rememberMe, // rememberMe: state.loginData.rememberMe,
code: state.loginData.code, code: state.loginData.code,
uuid: state.loginData.uuid, uuid: state.loginData.uuid
}; };
if (user.password !== state.cookiePass) { if (user.password !== state.cookiePass) {
user.password = encrypt(user.password); user.password = encrypt(user.password);
@ -382,9 +534,9 @@ function onFinish() {
Cookies.remove('password'); Cookies.remove('password');
Cookies.remove('rememberMe'); Cookies.remove('rememberMe');
} }
router.push({ path: "/" }); router.push({ path: '/' });
state.loading = false; state.loading = false;
message.success("登录成功"); message.success('登录成功');
}) })
.catch(() => { .catch(() => {
getCode(); getCode();
@ -399,13 +551,16 @@ const onSmsLogin = async () => {
try { try {
// ========== ========== // ========== ==========
const phoneRegex = /^1[3-9]\d{9}$/; const phoneRegex = /^1[3-9]\d{9}$/;
if (!loginData.value.username || !phoneRegex.test(loginData.value.username)) { if (
message.error("请输入正确的手机号"); !loginData.value.username ||
!phoneRegex.test(loginData.value.username)
) {
message.error('请输入正确的手机号');
return; return;
} }
if (!loginData.value.code || loginData.value.code.trim() === '') { if (!loginData.value.code || loginData.value.code.trim() === '') {
message.error("请输入验证码"); message.error('请输入验证码');
return; return;
} }
@ -415,8 +570,8 @@ const onSmsLogin = async () => {
// ========== API ========== // ========== API ==========
console.log('开始调用短信登录API, 手机号:', loginData.value.username); console.log('开始调用短信登录API, 手机号:', loginData.value.username);
const res: any = await smsLoginApi( const res: any = await smsLoginApi(
loginData.value.username, // loginData.value.username, //
loginData.value.code // loginData.value.code //
); );
console.log('短信登录API响应:', res); console.log('短信登录API响应:', res);
@ -426,17 +581,17 @@ const onSmsLogin = async () => {
// code "0" 0 // code "0" 0
if (res.code !== 0 && res.code !== '0') { if (res.code !== 0 && res.code !== '0') {
console.error('短信登录失败, 错误码:', res.code, '错误信息:', res.msg); console.error('短信登录失败, 错误码:', res.code, '错误信息:', res.msg);
message.error(res.msg || "登录失败"); message.error(res.msg || '登录失败');
return; return;
} }
// ========== Token ========== // ========== Token ==========
console.log('开始提取Token, res.data:', res.data); console.log('开始提取Token, res.data:', res.data);
const token = res.data?.token || res.data; // const token = res.data?.token || res.data; //
if (!token) { if (!token) {
console.error('Token为空, res.data结构:', JSON.stringify(res.data)); console.error('Token为空, res.data结构:', JSON.stringify(res.data));
message.error("登录失败:未获取到Token"); message.error('登录失败:未获取到Token');
return; return;
} }
@ -458,7 +613,7 @@ const onSmsLogin = async () => {
// ========== ========== // ========== ==========
console.log('开始生成路由...'); console.log('开始生成路由...');
const permissionStore = usePermissionStoreHook(); const permissionStore = usePermissionStoreHook();
const accessRoutes:any = await permissionStore.generateRoutes(roles); const accessRoutes: any = await permissionStore.generateRoutes(roles);
console.log('路由生成成功, 路由数量:', accessRoutes.length); console.log('路由生成成功, 路由数量:', accessRoutes.length);
// ========== ========== // ========== ==========
@ -468,13 +623,11 @@ const onSmsLogin = async () => {
// ========== ========== // ========== ==========
router.push({ path: accessRoutes[0].children[0].opturl }); router.push({ path: accessRoutes[0].children[0].opturl });
message.success("登录成功"); message.success('登录成功');
} catch (error: any) { } catch (error: any) {
// ========== ========== // ========== ==========
console.error("短信登录失败", error); console.error('短信登录失败', error);
message.error(error.message || "登录失败,请重试"); message.error(error.message || '登录失败,请重试');
} finally { } finally {
// ========== ========== // ========== ==========
state.loading = false; state.loading = false;
@ -491,31 +644,31 @@ watch(
} }
}, },
{ {
immediate: true, immediate: true
} }
); );
function getOtherQuery(query: any) { function getOtherQuery(query: any) {
return Object.keys(query).reduce((acc: any, cur: any) => { return Object.keys(query).reduce((acc: any, cur: any) => {
if (cur !== "redirect") { if (cur !== 'redirect') {
acc[cur] = query[cur]; acc[cur] = query[cur];
} }
return acc; return acc;
}, {}); }, {});
} }
function getCookie() { function getCookie() {
const username = Cookies.get("username"); const username = Cookies.get('username');
let password = Cookies.get("password"); let password = Cookies.get('password');
const rememberMe = Cookies.get("rememberMe"); const rememberMe = Cookies.get('rememberMe');
rememberMe == "true" ? (remember.value = Boolean(rememberMe)) : false; rememberMe == 'true' ? (remember.value = Boolean(rememberMe)) : false;
// cookie // cookie
state.cookiePass = password === undefined ? "" : password; state.cookiePass = password === undefined ? '' : password;
password = password === undefined ? state.loginData.password : password; password = password === undefined ? state.loginData.password : password;
state.loginData = { state.loginData = {
username: username === undefined ? state.loginData.username : username, username: username === undefined ? state.loginData.username : username,
password: decrypt(password), password: decrypt(password),
code: "", code: '',
uuid: "", uuid: ''
}; };
remember.value = rememberMe === undefined ? false : Boolean(rememberMe); remember.value = rememberMe === undefined ? false : Boolean(rememberMe);
} }
@ -546,17 +699,17 @@ const showForgotPasswordPage = () => {
}; };
// //
const goRegister = () => { const goRegister = () => {
router.push({ path: "/register" }); router.push({ path: '/register' });
}; };
// //
const backToLogin = () => { const backToLogin = () => {
showForgotPassword.value = false; showForgotPassword.value = false;
// //
forgotPasswordForm.value = { forgotPasswordForm.value = {
phone: "", phone: '',
captcha: "", captcha: '',
newPassword: "", newPassword: '',
confirmPassword: "", confirmPassword: ''
}; };
}; };
@ -564,14 +717,14 @@ const backToLogin = () => {
const sendSms = async () => { const sendSms = async () => {
// //
if (!loginData.value.username) { if (!loginData.value.username) {
message.error("请输入手机号"); message.error('请输入手机号');
return; return;
} }
// //
const phoneRegex = /^1[3-9]\d{9}$/; const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(loginData.value.username)) { if (!phoneRegex.test(loginData.value.username)) {
message.error("请输入正确的手机号"); message.error('请输入正确的手机号');
return; return;
} }
@ -588,16 +741,16 @@ const sendSms = async () => {
const res: any = await sendSmsCode(loginData.value.username, 3); const res: any = await sendSmsCode(loginData.value.username, 3);
if (res.code == 1) { if (res.code == 1) {
message.success(res.msg); message.success(res.msg);
return return;
} }
// //
message.success("验证码发送成功"); message.success('验证码发送成功');
// //
startCountdown(); startCountdown();
} catch (error) { } catch (error) {
console.error("发送验证码失败", error); console.error('发送验证码失败', error);
message.error("验证码发送失败,请重试"); message.error('验证码发送失败,请重试');
smsButtonDisabled.value = false; smsButtonDisabled.value = false;
} finally { } finally {
loading.value = false; loading.value = false;
@ -607,14 +760,14 @@ const sendSms = async () => {
const sendForgotPasswordSms = async () => { const sendForgotPasswordSms = async () => {
// //
if (!forgotPasswordForm.value.phone) { if (!forgotPasswordForm.value.phone) {
message.error("请输入手机号"); message.error('请输入手机号');
return; return;
} }
// //
const phoneRegex = /^1[3-9]\d{9}$/; const phoneRegex = /^1[3-9]\d{9}$/;
if (!phoneRegex.test(forgotPasswordForm.value.phone)) { if (!phoneRegex.test(forgotPasswordForm.value.phone)) {
message.error("请输入正确的手机号"); message.error('请输入正确的手机号');
return; return;
} }
@ -637,7 +790,7 @@ const sendForgotPasswordSms = async () => {
} }
// //
message.success("验证码发送成功"); message.success('验证码发送成功');
// //
startCountdown(); startCountdown();
@ -655,48 +808,63 @@ const handleResetPassword = async () => {
try { try {
// //
const phoneRegex = /^1[3-9]\d{9}$/; const phoneRegex = /^1[3-9]\d{9}$/;
if (!forgotPasswordForm.value.phone || !phoneRegex.test(forgotPasswordForm.value.phone)) { if (
message.error("请输入正确的手机号"); !forgotPasswordForm.value.phone ||
!phoneRegex.test(forgotPasswordForm.value.phone)
) {
message.error('请输入正确的手机号');
return; return;
} }
if (!forgotPasswordForm.value.captcha) { if (!forgotPasswordForm.value.captcha) {
message.error("请输入验证码"); message.error('请输入验证码');
return; return;
} }
if (!forgotPasswordForm.value.newPassword) { if (!forgotPasswordForm.value.newPassword) {
message.error("请输入新密码"); message.error('请输入新密码');
return; return;
} }
// //
if (!forgotPasswordForm.value.confirmPassword) { if (!forgotPasswordForm.value.confirmPassword) {
message.error("请再次输入密码"); message.error('请再次输入密码');
return; return;
} }
// //
if (forgotPasswordForm.value.newPassword !== forgotPasswordForm.value.confirmPassword) { if (
message.error("两次输入的密码不一致"); forgotPasswordForm.value.newPassword !==
forgotPasswordForm.value.confirmPassword
) {
message.error('两次输入的密码不一致');
return; return;
} }
// //
const password = forgotPasswordForm.value.newPassword; const password = forgotPasswordForm.value.newPassword;
if (password.length < 10) { if (password.length < 10) {
message.error("密码长度不能少于10位"); message.error('密码长度不能少于10位');
return; return;
} }
const hasUpperCase = /[A-Z]/.test(password); const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password); const hasLowerCase = /[a-z]/.test(password);
const hasNumber = /\d/.test(password); const hasNumber = /\d/.test(password);
const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); const hasSpecialChar = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(
password
);
const typeCount = [hasUpperCase, hasLowerCase, hasNumber, hasSpecialChar].filter(Boolean).length; const typeCount = [
hasUpperCase,
hasLowerCase,
hasNumber,
hasSpecialChar
].filter(Boolean).length;
if (typeCount < 3) { if (typeCount < 3) {
message.error('密码必须包含大写字母、小写字母、数字、特殊字符中的至少三种'); message.error(
'密码必须包含大写字母、小写字母、数字、特殊字符中的至少三种'
);
return; return;
} }
@ -727,9 +895,8 @@ const handleResetPassword = async () => {
} }
} }
} }
} catch (error) { } catch (error) {
console.error("表单验证失败", error); console.error('表单验证失败', error);
return; return;
} }
@ -746,12 +913,12 @@ const handleResetPassword = async () => {
encryptedPassword encryptedPassword
); );
if (res.code == 0 ) { if (res.code == 0) {
message.success("密码重置成功"); message.success('密码重置成功');
// //
backToLogin(); backToLogin();
} else { } else {
message.error(res.msg || "密码重置失败"); message.error(res.msg || '密码重置失败');
} }
} catch (error: any) { } catch (error: any) {
// console.error("", error); // console.error("", error);
@ -822,7 +989,7 @@ onMounted(() => {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 600px; min-height: 600px;
background: url("@/assets/images/bg_sjtb.png"); background: url('@/assets/images/bg_sjtb.png');
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: 100% 100%; background-size: 100% 100%;
} }
@ -856,8 +1023,8 @@ onMounted(() => {
.ant-tabs-nav { .ant-tabs-nav {
height: 38px; height: 38px;
} }
:deep(.ant-checkbox+span){ :deep(.ant-checkbox + span) {
padding-right: 0; padding-right: 0;
} }
} }