地图整体重构优化,添加梯级图显示和弹框选择
This commit is contained in:
parent
a82a1142f7
commit
de72eb2281
1158
frontend/docs/地图模块现状与改造方案.md
Normal file
1158
frontend/docs/地图模块现状与改造方案.md
Normal file
File diff suppressed because it is too large
Load Diff
380
frontend/docs/地图模块重构实施清单.md
Normal file
380
frontend/docs/地图模块重构实施清单.md
Normal 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` 体积明显下降;
|
||||||
|
- 新增地图业务时有明确扩展点;
|
||||||
|
- 后续继续做底图体系优化、长期性能建设时不需要再推翻本轮结构。
|
||||||
@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
1
frontend/src/assets/icons/gth.svg
Normal file
1
frontend/src/assets/icons/gth.svg
Normal 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 |
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
<!-- 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 组件控制动画 -->
|
|
||||||
<transition name="drawer-slide">
|
|
||||||
<a-drawer
|
<a-drawer
|
||||||
:get-container="false"
|
:get-container="false"
|
||||||
:style="{ position: 'relative' }"
|
:style="{ position: 'relative' }"
|
||||||
@ -20,28 +22,27 @@
|
|||||||
<div @click="handleToggle" class="drawerController">
|
<div @click="handleToggle" class="drawerController">
|
||||||
<img src="../../assets/components/arrow-right.png" alt="" />
|
<img src="../../assets/components/arrow-right.png" alt="" />
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:16px 16px 0;" class="text_she">
|
<div style="padding: 16px 16px 0" class="text_she">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</a-drawer>
|
</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>
|
||||||
|
|
||||||
@ -108,6 +109,8 @@ const handleToggle = () => {
|
|||||||
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>
|
||||||
|
|||||||
@ -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,338 +59,77 @@ 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('当前是水电开发菜单,检查层级变化');
|
|
||||||
// 从小于12缩放到12或以上时,添加动态图层
|
|
||||||
if (oldZoomLevel < 12 && newZoomLevel >= 12) {
|
|
||||||
console.log('层级从小于12变为>=12,添加动态图层');
|
|
||||||
addDynamicLayers();
|
|
||||||
}
|
|
||||||
// 从12或以上缩放到小于12时,移除动态图层
|
|
||||||
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,
|
||||||
|
getIsHydroMenu: () => isShuiDianKaiFaMenu.value
|
||||||
|
});
|
||||||
|
|
||||||
mapIsInited.value = true;
|
mapIsInited.value = true;
|
||||||
|
|
||||||
// 地图初始化完成后设置缩放监听
|
|
||||||
setupZoomListener();
|
|
||||||
console.log('缩放监听器已设置');
|
|
||||||
|
|
||||||
// 初始化图层数据和图例数据
|
|
||||||
fetchMapConfigs();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 请求地图配置数据:图层、图例 */
|
// 备注:地图控制器只分发简单视图切换命令,避免在页面入口堆叠复杂业务逻辑。
|
||||||
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(() => {
|
||||||
@ -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>
|
||||||
|
|||||||
673
frontend/src/components/gis/TjCascadeChart.vue
Normal file
673
frontend/src/components/gis/TjCascadeChart.vue
Normal 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>
|
||||||
538
frontend/src/components/gis/TjLayerModal.vue
Normal file
538
frontend/src/components/gis/TjLayerModal.vue
Normal 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>
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
10
frontend/src/components/gis/map.d.ts
vendored
10
frontend/src/components/gis/map.d.ts
vendored
@ -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
325
frontend/src/components/gis/ol/point-layer-manager.ts
Normal file
325
frontend/src/components/gis/ol/point-layer-manager.ts
Normal 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)) || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
290
frontend/src/components/gis/ol/popup-manager.ts
Normal file
290
frontend/src/components/gis/ol/popup-manager.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
341
frontend/src/components/gis/ol/region-mask-manager.ts
Normal file
341
frontend/src/components/gis/ol/region-mask-manager.ts
Normal 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
|
||||||
│
|
|
||||||
└─> 显示选中的锚点 (遍历 legendData,checked === 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() - 更新图层显隐,控制锚点显示
|
|
||||||
```
|
|
||||||
@ -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%;
|
||||||
|
|||||||
425
frontend/src/modules/map/application/map-orchestrator.ts
Normal file
425
frontend/src/modules/map/application/map-orchestrator.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
147
frontend/src/modules/map/domain/legend-deriver.ts
Normal file
147
frontend/src/modules/map/domain/legend-deriver.ts
Normal 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];
|
||||||
|
};
|
||||||
80
frontend/src/modules/map/domain/map-layer-rules.ts
Normal file
80
frontend/src/modules/map/domain/map-layer-rules.ts
Normal 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;
|
||||||
|
};
|
||||||
205
frontend/src/modules/map/stores/map-config.store.ts
Normal file
205
frontend/src/modules/map/stores/map-config.store.ts
Normal 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
|
||||||
|
};
|
||||||
|
});
|
||||||
208
frontend/src/modules/map/stores/map-data.store.ts
Normal file
208
frontend/src/modules/map/stores/map-data.store.ts
Normal 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
|
||||||
|
};
|
||||||
|
});
|
||||||
92
frontend/src/modules/map/stores/map-view.store.ts
Normal file
92
frontend/src/modules/map/stores/map-view.store.ts
Normal 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
@ -213,6 +213,42 @@ 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: '上旬',
|
||||||
@ -225,85 +261,85 @@ namespace DateSetting {
|
|||||||
{
|
{
|
||||||
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')]
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -426,7 +581,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -436,7 +591,7 @@ const onSmsLogin = async () => {
|
|||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -747,11 +914,11 @@ const handleResetPassword = async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
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%;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user