将数据管理子系统的页面挪到全过程新框架,自定义筛选列打开时会自己刷新表格一下,

环保数据改为监测数据,操作列去掉,,,把地图配置,无用api,无用代码,组件去掉。
This commit is contained in:
王兴凯 2026-08-28 10:08:15 +08:00
parent 2ed06cc2d5
commit 35db4003a1
102 changed files with 7201 additions and 26697 deletions

View File

@ -1,479 +0,0 @@
# 地图模块 3D Cesium 实施计划
## 1. 目标
在现有地图体系下补齐 `3D(Cesium)` 视图能力,使其尽量复用 `2D(OpenLayers)` 的业务编排结果,满足以下要求:
- 3D 默认进入中国视角,不再落到美国区域。
- 3D 初始化时显示过渡动画,再定位到中国。
- 3D 保留基础底图加载能力,但不提供底图切换面板。
- 3D 支持锚点展示。
- 3D 支持与 2D 一致的鼠标悬停 popup样式复用现有 popup。
- 3D 支持与 2D 一致的锚点点击行为,点击后打开同一套详情弹框逻辑。
- 菜单切换、图层树勾选、图例勾选后3D 锚点与 2D 一样联动变化。
- 3D 必须复用 `eng_point` 的层级显示规则,中型水电站仅在 `ENG_POINT_MEDIUM_VISIBLE_ZOOM = 7.5` 以上显示。
## 2. 2D 现状梳理
当前 `2D` 的能力并不是散落在页面里,而是已经形成了比较清晰的分层:
### 2.1 页面入口
- `src/components/gis/GisView.vue`
- 负责挂载地图容器、popup 容器、地图控制器、图例、图层树。
- 页面只调用 `mapOrchestrator.mountView()``handlePageChange()`,不直接拼地图业务。
### 2.2 地图编排层
- `src/modules/map/application/map-orchestrator.ts`
- 负责页面初始化、菜单切换、图层配置加载、图例配置加载、基地切换、缩放联动。
- 2D/3D 都应尽量复用这一层,避免在 Cesium 侧另起一套业务流程。
### 2.3 地图统一门面
- `src/components/gis/map.class.ts`
- 对外暴露统一接口,如:
- `addBaseDataLayer`
- `addInitDataLayer`
- `mdLayerTreeShowOrHidden`
- `setLegendPointVisible`
- `controlBaseLayerTreeShowAndHidden`
- `initPopupOverlay`
- `flyTopanto`
- 业务层只认这套接口,不应感知底层是 `OpenLayers` 还是 `Cesium`
### 2.4 2D 地图能力落点
- `src/components/gis/map.ol.ts`
- 已实现的关键能力:
- 默认中国视角初始化
- 基础底图加载与切换
- 点位图层注册和显隐
- 图例控制单类锚点显隐
- 鼠标 hover popup
- click 打开详情弹窗
- 基地裁切与底图遮罩
- 缩放/移动后的批量 popup 和标签刷新
### 2.5 2D 锚点与 popup 支撑
- `src/components/gis/ol/point-layer-manager.ts`
- 负责将点位数据转成地图要素、分层、索引、图例显隐。
- `src/components/gis/ol/popup-manager.ts`
- 负责 hover 命中、popup DOM 内容复用、批量 popup。
### 2.6 图层树和图例交互
- `src/components/mapController/LayerController.vue`
- 图层树勾选后走 `mapOrchestrator.applyLayerSelection()`
- `src/components/mapLegend/index.vue`
- 图例点击后走 `mapOrchestrator.toggleLegend()` / `toggleLegendBatch()`
- `src/store/modules/map.ts`
- 真正把缓存数据、图层状态、图例状态应用到地图实例
- 也就是说,`3D` 只要把统一接口补齐,就能直接接住现有业务流
### 2.7 2D 已有特殊业务规则
- 锚点点击逻辑已在 `src/components/gis/map.ol.ts` 实现:
- 命中锚点后,`ylfb` 走 `ylfbModalVisible`
- 其他锚点走 `modalVisible`
- 标题和参数都直接取当前锚点数据
- `eng_point` 的中型水电站显示阈值已在现有链路中实现:
- 阈值常量位于 `src/modules/map/application/map-orchestrator.ts`
- 过滤逻辑位于 `src/store/modules/map.ts``filterPointLayerDataForDisplay()`
- `3D` 不应重新发明一套规则,而应复用这套现有过滤结果
- 部分点位会随着缩放级别提升显示得更多,不是简单的“图层开/关”:
- `eng_point` 的中型电站要到 `ENG_POINT_MEDIUM_VISIBLE_ZOOM = 7.5` 以上才显示
- 水电开发页在缩放跨过 `12` 级时会自动补开一批动态图层
- `2D` 内部还存在点位密度阈值、近邻点展开、批量 popup 阈值等缩放联动规则
- `2D` 对锚点存在碰撞检测与密度控制,不是所有点始终全量显示:
- 图标和标签会分别经过碰撞计算
- 低缩放下会因为密度、碰撞、区域裁切、图例状态而隐藏部分点
- 高缩放下会逐步放开限制,显示更多锚点和标签
## 3. 3D 当前现状与缺口
### 3.1 已有内容
- `src/components/gis/map.cesium.ts`
- 当前只完成了最小化 `Cesium.Viewer` 初始化。
- 已有基础缩放、截图、`flyTopanto()` 雏形。
### 3.2 当前主要缺口
- `init()` 里虽然定义了 `flyToChina()`,但没有真正执行,也没有 `resolve(viewer)`,初始化流程不完整。
- 默认底图仍是 ArcGIS 全球影像,因此启动时容易落到美国默认视角。
- `addBaseDataLayer()` 为空,无法接住当前 `GISMap/customBaseLayer` 逻辑。
- `addInitDataLayer()` 为空,无法展示锚点。
- `mdLayerTreeShowOrHidden()` 为空,菜单切换时 3D 锚点不会同步显隐。
- `setLegendPointVisible()` 为空,图例勾选不会影响 3D 锚点。
- `initPopupOverlay()` 为空,无法复用 2D popup 容器。
- 缺少点击命中与详情弹框联动3D 当前无法做到“点击锚点与 2D 同行为”。
- `controlBaseLayerTreeShowAndHidden()` 为空3D 基础底图和叠加层无法受现有图层树控制。
- 尚未验证 `eng_point` 的缩放阈值规则在 3D 下是否能完全复用,存在实现时遗漏中型站点显隐的风险。
- 目前没有承接 `2D` 中“放大到具体层级显示更多锚点”的缩放联动显示机制。
- 目前没有承接 `2D` 中图标/标签的碰撞检测逻辑,后续若直接全量渲染,视觉效果会与 `2D` 明显不一致。
- 当前没有建立 `Cesium entity / datasource / imageryLayer` 的注册表,后续联动无从下手。
## 4. 实施原则
- 不重做业务编排,优先复用 `map-orchestrator.ts``src/store/modules/map.ts`
- 不额外引入一套 3D 专用图例和图层状态,仍以现有 store 为唯一运行态来源。
- 不单独复制 `eng_point` 的中型站显示规则,优先复用现有 `mapStore` 过滤结果。
- 不单独新建一套点击弹框协议,优先保持与 `map.ol.ts` 相同的命中后行为。
- 对“缩放越大显示越多锚点”这类规则,优先复用现有缩放阈值与显示门闩,不做拍脑袋的新规则。
- 对碰撞检测,优先与 `2D` 保持同方向行为:低缩放控量,高缩放放开,而不是 3D 全量堆点。
- 先做最小闭环,再逐步补能力:
- 第一步保证 3D 能正确初始化并定位中国。
- 第二步保证基础底图能显示。
- 第三步保证锚点能显示。
- 第四步保证 popup、图层树、图例联动打通。
- 3D 不做“基础地图切换 UI”但底层仍要支持 `customBaseLayer + addBaseDataLayer(..., true)` 这一逻辑。
## 5. 分步骤实施计划
### Step 1修正 3D 初始化并默认定位中国 ✅️
目标:
- 切到 3D 后稳定初始化 viewer。
- 首屏出现一个轻量加载动画。
- 初始化完成后飞到中国默认视角。
计划:
- 修复 `map.cesium.ts``init()`
- 保证初始化后一定 `resolve(this.viewer)`
- 执行 `flyToChina()`
- 补一个可控的启动过渡动画。
- 统一中国默认参数:
- 中心经纬度建议复用 `104.5, 36.5`
- 相机高度先按全国视角收口,后续再微调
- 如果切回 2D 再切到 3D仍然要能正确重建 viewer。
验收:
- 点击 `3D` 按钮后,不再出现美国默认视角。
- 首屏有加载过渡。
- 进入后稳定落在中国。
### Step 2展示 3D 基础底图 ✅️
目标:
- 3D 不显示空球。
- 保留“基础底图 + 3D 专用底图地址”能力。
- 不提供底图切换面板,但底层能吃现有配置。
计划:
- 在 `MapCesium` 内建立基础图层注册表。
- 实现 `addBaseDataLayer(layer, isShow)`
- 对 `customBaseLayer` 使用 `url_3d`
- 对 `BASEMAP-img`、`BASEMAP-white` 预留兼容能力
- **必须处理 `wmts` 类型图层**2D 通过 `WMTS` source 加载3D 使用 `Cesium.WebMapTileServiceImageryProvider`,从 URL 参数中提取 LAYER/TILEMATRIXSET
- **必须提供 `tileMatrixLabels`**Cesium 默认用纯数字作为 tileMatrix 参数值,但本系统 WMTS 服务器要求 `TileMatrixSet:zoom` 格式(如 `EPSG:3857_hbb_zrbhq_l13:4`)。需构建 `["TileMatrixSet:0", "TileMatrixSet:1", ...]` 传入 `tileMatrixLabels`
- 其他类型:`raster-dem`→XYZ、MapServer→ArcGIS、其余 →WMS
- **注意**:还存在一个 `key = "powerBaseStation"` 的基地底图图层,该图层**初始化时不加载**(已在 `IGNORED_BASE_LAYER_KEYS` 中忽略),但后续可通过图层树手动勾选显示。
- 实现 `controlBaseLayerTreeShowAndHidden()`
- 让图层树勾选能控制 3D 底图显隐
- 根据你的要求UI 层先不开放 `BaseLayerSwitcher` 的 3D 切换行为,只保留默认基础底图加载。
验收:
- 切到 3D 后可稳定看到基础底图。
- 图层初始化时 `addBaseDataLayer(this.baseLayerConfig, checked, true)` 的思路在 3D 可落地。
### Step 3打通 3D 锚点展示 ✅️
目标:
- 3D 能展示和 2D 同源的锚点数据。
- 菜单切换后3D 能跟随页面数据切换。
计划:
- 在 `MapCesium` 内建立点位图层注册结构,按 `layerKey -> entity[]` 管理。
- 实现 `addInitDataLayer(pointData, layerType)`
- 复用当前点位数据结构
- 解析 `lgtd/lttd/iconCode/titleName/stnm/anchoPointState/popupHtml`
- 复用现有图标资源路径
- Entity ID 使用 `${layerKey}:${item._id || `${iconType || 'point'}_${stcd}`}`,与 2D 对齐,确保同 stcd 不同 sttpMap 的点不被覆盖
- 确保 `eng_point` 仍按现有 `mapStore` 过滤后的数据展示,中型站点只在 `7.5` 以上层级出现
- 为后续缩放驱动的增量显示预留运行态字段(`_legendVisible`、`_regionVisible`、`_densityVisible`),不把所有点简单一次性硬渲染
- 实现 `mdLayerTreeShowOrHidden(layerType, checked)`
- 图层树控制整个点图层显隐
- 实现 `removePointLayer()` / `hasLayer()`
- 便于后续页面切换和重载
验收:
- 切到 3D 后当前页默认勾选图层的锚点能显示。
- 切换菜单后3D 锚点跟随变化。
- 取消勾选图层后,对应锚点消失。
- `eng_point` 在低于 `7.5` 时不显示中型水电站,高于 `7.5` 时正常显示。
### Step 4打通 3D 基地裁切jdPanelControlShowAndHidden ✅️
目标:
- 3D 点击基地面板后,与 2D 行为一致。
- 加载基地边界 GeoJSON只显示区域内锚点隐藏区域外锚点。
- 取消基地时恢复显示所有锚点。
计划:
- 在 `MapCesium` 中捕获 `hydropBase` 配置,与 2D 一样在 `addBaseDataLayer` 中记录。
- 实现 `jdPanelControlShowAndHidden(baseid, isAll)`
- `isAll=false`:清除 3D 球面遮罩实体、恢复所有锚点区域可见性、清除裁切状态。
- `isAll=true`:先隐藏所有锚点,异步加载基地边界 GeoJSON执行点在面判断后仅显示区域内锚点同时在球面绘制裁切边界多边形`applyRegionMask`),最后飞行到区域视野。
- 在 `createEntityPropertiesBag` 中新增 `_regionVisible` 属性,初始为 `true`
- 在 `applyPointVisibilityRefresh` 中跳过 `_regionVisible !== false` 的实体,确保裁切后碰撞检测不会把区域外锚点重新显示出来。
- 复用 `extractPolygonCoords` / `isPointInRing` 射线法判断逻辑(与 `region-mask-manager.ts` 一致)。
- 支持 `AbortController` 取消前一次未完成的请求,避免响应顺序错乱。
- `applyRegionMask` 将 GeoJSON 多边形转换为 Cesium `PolygonHierarchy`,在球面绘制半透明填充 + 可见边界的区域多边形,模拟 2D 底图 Canvas 裁切遮罩的视觉效果。
验收:
- 点击基地后3D 只显示该基地区域内的锚点,区域外锚点隐藏。
- 取消基地后,所有锚点恢复正常显示。
- 快速切换基地不会出现旧数据覆盖新数据的问题。
- 多次切换 2D/3D 后基地裁切行为正常。
### Step 5打通 3D popup 与 hover ✅️
目标:
- 3D 鼠标悬停时显示与 2D 一样的 popup。
- popup 样式复用现有 DOM 和样式,不再单独做一版。
计划:
- 实现 `initPopupOverlay(popupContainer)`
- 继续复用 `GisView.vue` 中现有 popup 容器
- 在 Cesium 中增加鼠标移动命中:
- 基于 `ScreenSpaceEventHandler`
- hover 到锚点时读取 entity 附带属性
- popup 内容生成逻辑复用 2D 现有字段:
- 优先使用 `popupHtml`
- 否则走通用字段渲染
- 处理离开锚点时隐藏 popup。
验收:
- 鼠标移入锚点出现 popup。
- 样式和 2D 保持一致。
- 移出后 popup 正常消失。
### Step 6补齐缩放驱动显示与碰撞检测
目标:
- 3D 在低缩放下控制锚点数量,避免全量堆叠。
- 3D 在放大到具体层级后,逐步显示更多锚点。
- 3D 的图标/标签显示效果尽量接近 2D不出现严重遮挡。
计划:
- 先承接现有明确可复用的缩放规则:
- `eng_point``7.5` 阈值
- 水电开发页的 `12` 级动态图层联动
- 再补 3D 侧的显示门闩:
- 低缩放下根据缩放级别、图层状态、图例状态控制 entity 显隐
- 高缩放下逐步放开限制,显示更多锚点
- 为 3D 补一版轻量碰撞控制:
- 至少先控制标签碰撞,避免名称完全叠在一起
- 如有必要,再补图标级碰撞或屏幕网格抽稀
- 明确与 2D 对齐的目标不是“算法完全一样”,而是“视觉行为一致”:
- 低缩放控量
- 高缩放增量显示
- 关键锚点不被完全淹没
验收:
- 3D 在全国视角下不会一次性挤满所有锚点。
- 放大到具体层级后会看到更多锚点逐步出现。
- 标签和锚点不会大面积重叠到不可读。
### Step 7打通 3D 锚点点击详情 ✅️
目标:
- 3D 点击锚点后,行为与 2D 一致。
- 继续复用现有详情弹框状态和参数结构。
计划:
- 在 Cesium 中增加点击命中:
- 基于 `ScreenSpaceEventHandler`
- 命中锚点后读取 entity 绑定的原始业务数据
- 复用 `2D` 当前点击行为:
- `sttpMap === 'ylfb'` 时打开 `ylfbModalVisible`
- 其他锚点打开 `modalVisible`
- `params`、`title` 赋值逻辑与 `map.ol.ts` 保持一致
- 避免 hover popup 与 click 弹框互相干扰。
验收:
- 点击 3D 锚点后可打开与 2D 一致的详情弹框。
- `ylfb` 和普通锚点分支行为一致。
- 点击空白区域不会误触发弹框。
### Step 8打通图例联动 ✅️
目标:
- 图例勾选后3D 只显示对应状态的锚点。
计划:
- 实现 `setLegendPointVisible(layerKey, anchoPointState, checked)`
- 复用 `anchoPointState` 作为图例匹配字段
- entity 级别维护显示状态
- 如果后续需要,也可补一个 3D 版本的图例索引表,避免每次全量遍历。
验收:
- 图例单项勾选/取消时3D 对应类别锚点同步显隐。
- 图例分组勾选/取消时3D 行为与 2D 保持一致。
### Step 9补足收口与稳定性 ✅️
目标:
- 让 3D 能稳定参与现有地图生命周期。
计划:
- 清理 Cesium 事件监听、viewer、entity、datasource避免重复切换内存泄漏。
- 检查 `switchView('2D' | '3D')` 前后 popup、底图、图层注册是否被正确重置。
- 检查 hover、click、图例、图层树、缩放阈值这几条联动链路在 3D 是否完全闭环。
- 视情况补最小诊断日志,确认菜单切换与图层联动没有漏项。
验收:
- 2D/3D 多次来回切换不报错。
- 菜单切换、图层切换、图例切换行为稳定。
## 6. 推荐落地顺序
按你的要求,建议严格按下面顺序推进:
1. 默认定位到中国 + 启动动画
2. 展示基础底图
3. 展示锚点 3.5. 补基地裁切jdPanelControlShowAndHidden
4. 补 hover popup
5. 补缩放驱动显示与碰撞检测
6. 补锚点点击详情
7. 补图层树联动
8. 补图例联动
9. 做切换稳定性收口
## 7. 预计修改文件
- `src/components/gis/map.cesium.ts`
- 3D 地图主体实现,主改动文件
- `src/components/gis/map.class.ts`
- 视情况补齐 2D/3D 切换后 popup 初始化和服务切换细节
- `src/components/gis/GisView.vue`
- 视情况控制 3D 下的底图切换器显示策略、加载态挂载位置
- `src/components/mapController/index.vue`
- 若需要补 3D 初始化后的交互控制
## 8. 当前建议
建议先只做 `Step 1`,也就是:
- 修正 Cesium 初始化完成逻辑
- 增加 3D 启动加载动画
- 默认飞到中国
这一步最小、最容易验证,也能先把“打开 3D 看起来不对”的核心问题解决掉。
## 9. 必须补齐的隐藏逻辑清单
下面这些逻辑不是表面接口能直接看出来的,但如果 `3D` 不补,最终效果就会和 `2D` 明显不一致。
### 9.1 图层与菜单切换 ✅️
- ✅ 菜单切换时,只隐藏旧页点图层,不隐藏底图。(编排器 `handlePageChange` 控制Cesium 被动接收数据)
- ✅ 图层树勾选前要先经过互斥规则归一化store 层 `updateLayerData` 处理Cesium 不感知)
- `rare_fish_point``fish_along_point` 互斥
- `stinfo/stinfo_video_point``stinfo_ai_video_point` 互斥
- `facilities``facilities_built` 两个分支互斥
- ✅ 点图层取消勾选时只隐藏,不删除缓存;重新勾选时优先从缓存恢复。`mdLayerTreeShowOrHidden` 设置 `_layerVisible` 标志,不删除 `pointLayerRegistry` 条目。
- ✅ **图层树勾选时按 `type` 字段走不同分支**(见 `updateLayerData`store 层处理):
- `type === 'pointMap'` → 走 `handlePointMapLayer` → 调用 `mdLayerTreeShowOrHidden`已实现L1624
- `type === 'GISMap'` → 走 `controlBaseLayerTreeShowAndHidden`已实现L1345
- 3D Cesium 已同时实现这两个方法。
### 9.2 图例与点内显隐 ✅️
- ✅ 图例控制的是图层内部锚点是否可见,不是图层本身是否可见。`setLegendPointVisible` 使用 `_legendVisible` 标志,与 `_layerVisible` 独立。
- ✅ 图层恢复显示时,图例要先整层全关,再按当前 checked 状态逐项恢复。支持 `anchoPointState=''` 全关 → 逐项 `(state, true)` 恢复两步模式L1764-1787
- ✅ 环保设施图例组当前是只展示不交互。编排器层面控制Cesium 不感知)
### 9.3 缩放驱动显示 ✅️
- ✅ `eng_point` 的中型水电站只在 `ENG_POINT_MEDIUM_VISIBLE_ZOOM = 7.5` 以上显示。store `filterPointLayerDataForDisplay` 在数据进入 Cesium 前过滤)
- ✅ 水电开发页缩放跨过 `12` 级时,会自动补开一批动态图层;缩回去时再自动收口。(编排器层面处理)
- ✅ `2D` 存在"缩放越大显示越多"的行为。碰撞检测中密度门控逐级放开。
- ✅ `2D` 还存在 hover、批量 popup、密度控制等缩放阈值
- hover 低于 ~zoom 4.5 不触发(`HOVER_POPUP_MAX_HEIGHT = 9_000_000`
- 高缩放进入批量 popup 模式(`BATCH_POPUP_MODE_ZOOM = 14.9`
- 点位随着缩放提升逐步放开密度限制(密度门控 + `DENSITY_ISOLATION_THRESHOLD`
- ✅ **图标动态缩放**`CallbackProperty` 实现 `billboard.scale`,公式 `clamp(0.7 + (zoom - 4.5) * 0.035, 0.4, 1.2)`L1522
- ✅ **字体动态大小**`CallbackProperty` 实现 `label.font`,钳位 `[10, 20]`L1535
- ✅ **文字在图标上方**`CallbackProperty` 实现 `label.pixelOffset`,负 Y 偏移L1549
- ✅ **Cesium 中必须使用 `CallbackProperty` 实现动态缩放**:已使用 3 个 `CallbackProperty`scale/font/offset`isConstant=false` 每帧重算。
### 9.4 锚点碰撞与控量 ✅️
- ✅ `2D` 中锚点是否可见,不只受图层开关控制,还受以下门闩共同影响:
- 图例可见性 `_legendVisible`
- 区域裁切可见性 `_regionVisible`
- 密度阈值(碰撞循环中 inline 计算,逐级放开)
- 图标碰撞结果 `_iconCollisionVisible`
- 标签碰撞结果 `_labelCollisionVisible`
- ✅ 低缩放下,为避免堆叠,部分锚点或标签会被主动隐藏。(`refreshPointLabelVisibility` 碰撞检测L2117-2455
- ✅ 高缩放下,会逐步放开图标/标签限制,显示更多内容。(密度门控 + 碰撞容差随 zoom 调整)
- ✅ 因此 `3D` 不能简单把所有 entity 永久全开。所有 entity 初始 `_iconCollisionVisible = _labelCollisionVisible = true`,碰撞检测运行后动态调整。
### 9.5 Popup 与点击 ✅️
- ✅ hover popup 不是全时开启的,有缩放门槛。相机高度 > `HOVER_POPUP_MAX_HEIGHT` 时不触发 hover。
- ✅ 点击和 hover 的命中对象是图标本体,不是任意文字区域。`ScreenSpaceEventHandler` 基于 `scene.pick` 命中 entity。
- ✅ `ylfb` 与普通锚点的点击详情弹框走不同业务分支。`sttpMap === 'ylfb'` 打开 `ylfbModalVisible`,其他走 `modalVisible`L366-375
### 9.6 基地与区域裁切 ✅️
- ✅ 基地切换不仅影响搜索候选,还会影响地图上的点位可见范围。`jdPanelControlShowAndHidden` 实现L1684-1748
- ✅ 区域裁切会直接把区域外点位隐藏,不是只改底图遮罩。`filterPointsByRegion` 设置 `_regionVisible = false`L1824-1848遮罩多边形 + 裁剪双重处理。
### 9.7 筛选器特殊逻辑 ✅️
- ✅ 装机容量筛选本质是批量切 `eng_point` 图例状态不是简单切图层。store 层处理Cesium 通过 `setLegendPointVisible` 响应)
- ✅ 鱼类分布模式会接管 `ylfb_point` 的数据、缓存、图例与显隐。store 层处理)
- ✅ 时间筛选当前只重载指定时间相关图层不是全量重载。store 层处理)
### 9.8 数据进入 addInitDataLayer 前的预处理 ✅️
- ✅ 所有点位数据在进入 `addInitDataLayer` 前会经过 `normalizePointLayerItems()` 过滤store 层 `map.ts` 执行Cesium 接收时已过滤)
- 没有 `iconCode``code !== 'colorLayer'` 的项会被抛弃
- `baseId == 'all'``anchoPointState` 结尾为 `_nbuilt` 的项会被抛弃
- ✅ `_id` 默认值生成规则store 层生成 `${sttpMap || iconType || 'point'}_${stcd}`
- ✅ **stcd+经纬度重复锚点处理**Cesium entity ID 使用 `${layerKey}:${item._id}`,与 2D 对齐,同 stcd 不同 sttpMap 的点不会互相覆盖。
### 9.9 图标渐进加载机制
- ❌ 2D 中图标不是一次性全加载完。有 `loading → loaded → error` 三种状态跟踪。
- ❌ 只有 `loaded` 状态的图标才参与样式渲染loader 失败的不渲染。
- ❌ 如果在图标加载过程中就触发了样式刷新,需要通过 `requestAnimationFrame` 回刷。
- ❌ 3D Cesium 中当前所有 entity 同步创建billboard `image` URL 直接赋值无渐进加载。Cesium 内部纹理加载无显式状态跟踪。
### 9.10 密度分档与近邻点元数据 ✅️
- ✅ 每个锚点的 `distance` 字段表示密度分档值(不是物理距离),值越大表示越稀疏。(已使用 `getEntityDensityValue``_rawData.distance` 读取L2460
- ✅ 不同 density 值对应不同的 `minZoom` 显示门槛。`getEntityDensityMinZoom` 映射 `nearby-point-rules.ts``densityDisplayRules`L2491
- ✅ 全量数据加载后store 会调用 `attachNearbyPointRuntimeMeta()` 给所有点打上 `_nearbyRule`、`_nearbyGroupId`、`_nearbyPriority` 等元数据。store 层处理Cesium 碰撞检测中读取)
- ✅ **孤立点例外规则**:如果一个点在当前视口内 ~56px 范围内没有同 layerKey 的其他可见点,它可以绕过密度门槛直接显示。`DENSITY_ISOLATION_THRESHOLD = 56` + `shouldBypassDensityGateForIsolatedEntity`L2506-2556

View File

@ -1,343 +0,0 @@
# 地图模块-OpenLayers抽吸与碰撞分析
## 1. 背景
当前地图 2D 主引擎已经切换为 OpenLayers项目依赖版本为 `ol@^10.8.0`
本次关注的问题有两个:
- 点位“抽吸”逻辑不符合业务预期;
- 文字碰撞时把整个点位一起隐藏,而不是仅隐藏文字。
典型场景是两个相邻电站,例如“积石峡”和“公伯峡”:
- 可用代号 `1`、`2` 表示两个相邻点;
- 默认缩放下只展示 `1`
- 放大到某个层级后,`1` 隐藏,`2` 展示;
- 再继续放大后,`1` 和 `2` 都展示,但 `2` 需要“抽吸/拉开”显示,避免与 `1` 重叠;
- 如果只是文字发生碰撞,希望点图标保留,仅文字隐藏或延后显示。
本文件先整理当前代码现状和问题成因,不直接修改实现。
如果需要查看**当前已经落地的抽吸规则、默认参数和调参入口**,请同时参考:
- `docs/地图模块-抽吸规则与参数说明.md`
## 2. 当前代码结构
当前前端 GIS 运行链路如下:
1. `src/components/gis/GisView.vue`
2. `src/modules/map/application/map-orchestrator.ts`
3. `src/components/gis/map.class.ts`
4. `src/components/gis/map.ol.ts`
5. `src/components/gis/ol/point-layer-manager.ts`
6. `src/components/gis/ol/popup-manager.ts`
各层职责简述:
- `GisView.vue`
- 地图入口组件;
- 挂载地图容器、图例、筛选器、控制器;
- 通过 `mapOrchestrator.mountView()` 初始化地图;
- 菜单切换时通过 `handlePageChange()` 触发页面地图重载。
- `map.class.ts`
- 地图门面类;
- 当前 2D 实现实际绑定的是 `MapOl`
- 3D 实现才会切到 `MapCesium`
- `map.ol.ts`
- OpenLayers 主实现;
- 负责地图初始化、底图加载、点位样式、Popup、区域裁切、量算等
- 当前“点位渲染规则”和“碰撞相关逻辑”主要集中在这个文件。
- `point-layer-manager.ts`
- 负责点图层创建、Feature 灌入、按图层控制显隐;
- 点位图层统一使用 `VectorLayer + VectorSource`
- `popup-manager.ts`
- 管理 hover popup 和高缩放下的批量 popup
- 内部额外做了一套基于边界框的碰撞判断。
## 3. 当前点位渲染链路
### 3.1 点图层创建
`point-layer-manager.ts` 中的 `ensureLayer()` 会统一创建点图层:
- 图层类型为 `VectorLayer<VectorSource>`
- 图层上开启了 `declutter: true`
- 样式函数统一走 `map.ol.ts` 中的 `createPointStyle()`
也就是说,当前所有点位的“是否显示”和“图标/文字如何避让”,都收敛到 OpenLayers 的 declutter 机制和样式函数里。
### 3.2 点样式生成
`map.ol.ts``createPointStyle()` 当前做了这些事:
- 读取 `_iconUrl``_labelText`
- 如果图例不可见、区域不可见,则直接返回 `null`
- 如果 `distance` 不满足当前缩放级别要求,也直接返回 `null`
- 图标使用 `Icon`
- 文字使用 `Text`
- 图标和文字都设置了 `declutterMode: 'declutter'`
- 最终把图标和文字一起放进同一个 `Style` 返回。
这意味着:
- 点图标和文字不是两个独立的渲染对象;
- 它们在当前实现中属于同一个样式结果;
- 只要这组样式参与碰撞判定,图标和文字的命运就是绑定的。
### 3.3 当前已有的“抽稀”逻辑
当前项目中确实有一层“缩放越小,显示越少”的处理,但它不是精细的“抽吸”。
`map.ol.ts``shouldRenderFeatureByDistance()` 逻辑是:
- 小缩放下要求 `distance` 更大才允许显示;
- 缩放越大,允许显示的点逐渐增多;
- 本质上是基于后端数据字段 `distance` 做全局抽稀。
这个逻辑的特点是:
- 是全局阈值,不是成对点位或同组点位的专属规则;
- 只能决定“这个点显示还是不显示”;
- 不能表达“1 和 2 是近邻关系,并且在不同缩放阶段按业务优先级切换显示”。
### 3.4 当前批量 Popup 的碰撞检测
`popup-manager.ts` 在高缩放批量 popup 模式下,又额外做了一层碰撞判断:
- 先估算图标和文字合并后的锚点边界框;
- 如果边界框碰撞,则当前要素直接不参与 popup 渲染;
- 随后再判断 popup 矩形之间是否碰撞。
这说明当前“图标 + 文字”被视为一个整体,不仅体现在 OpenLayers 的 declutter 里,也体现在 popup 的辅助判断里。
## 4. 当前仓库里“抽吸”相关代码现状
仓库中确实存在历史上的“抽吸”实现,但不在当前 OpenLayers 主链路里。
相关文件:
- `src/utils/leaflet/leaflet.inflatable-markers-group.js`
- `src/components/gis/map.leaflet.ts`
现状判断:
- `leaflet.inflatable-markers-group.js` 是旧 Leaflet 时代的抽吸插件;
- 它内部有 `inflateAsManyAsPossible()`、`_zoomend()` 等典型抽吸逻辑;
- 但当前 `map.class.ts` 实际实例化的是 `new MapOl()`
- `map.leaflet.ts` 已经不在主运行路径上。
结论是:
- 当前页面上看到的点位行为,不是 Leaflet 抽吸插件在工作;
- 现在的显示结果主要来自 OpenLayers 的 `declutter`、当前样式函数和 `distance` 抽稀逻辑;
- 因此“已经有抽吸但是不太对”,更准确地说是“历史上有抽吸代码,但现在主链路没有真正使用那套机制”。
## 5. 问题分析
### 5.1 为什么当前实现做不到你要的分阶段抽吸
你描述的目标并不是简单“避让”,而是一个明确的分阶段显示策略:
1. 默认只显示 `1`
2. 缩放到阶段 A 时,隐藏 `1`,改为显示 `2`
3. 缩放到阶段 B 时,`1` 和 `2` 都显示;
4. 阶段 B 下,`2` 还要相对 `1` 做抽吸/偏移,避免完全重叠。
而当前代码缺少以下能力:
- 没有“近邻点关系”配置
- 代码里没有看到类似 `groupId`、`neighborIds`、`priority`、`displayLevel` 这样的结构;
- 系统不知道“积石峡”和“公伯峡”是一组特殊近邻点。
- 没有“分阶段显示规则”配置
- 当前只有全局的 `distance` 阈值判断;
- 没有按缩放阶段表达“谁替代谁显示、谁先隐藏、谁后展示”。
- 没有“抽吸偏移”计算
- 当前点要素坐标就是原始经纬度转换结果;
- 没有根据缩放级别和邻近关系生成偏移坐标;
- 也没有“展开后沿圆周/沿固定方向偏移”的逻辑。
- 当前 declutter 是被动避让,不是主动编排
- declutter 只会尽量避免重叠;
- 它不会理解业务上的“1 是主点、2 是附点”;
- 也不会自动实现“先 1后 2再两个都展示且 2 偏移”的规则。
所以当前看到的结果不稳定,本质原因不是某一个参数不对,而是“现有机制和目标能力不是一类问题”。
### 5.2 为什么文字碰撞会把整个点隐藏
这是当前实现里最关键的原因:
- 点图标和文字是在同一个 `Style` 里返回的;
- 图层开启了 `declutter: true`
- 图标和文字又都设置了 `declutterMode: 'declutter'`
在这种组织方式下,当前要素的碰撞判定是按“一个整体渲染单元”处理的,而不是“图标一套规则,文字一套规则”。
因此会出现下面的现象:
- 图标本身其实不冲突;
- 但文字边界框发生碰撞;
- 最终被隐藏的是整条样式结果;
- 用户视觉上就会觉得“明明只是字挤了,为什么点也没了”。
这和你现在观察到的问题是一致的。
### 5.3 为什么当前逻辑还会进一步放大这个问题
除了 OpenLayers 自身的 declutter 组织方式,代码里还有两层因素会继续放大问题:
- `shouldRenderFeatureByDistance()` 会先根据 `distance` 直接过滤点位;
- `popup-manager.ts` 在批量 popup 模式下,也把“图标 + 文字”当作合并边界框做碰撞模拟。
也就是说,当前系统对近邻点位的处理是:
- 先用全局 `distance` 规则筛掉一部分;
- 再用 OpenLayers declutter 继续过滤;
- 高缩放时 popup 层再做一轮碰撞跳过。
这三层叠加后,显示行为更偏向“谁先撞谁消失”,而不是“按业务规则分阶段展开”。
## 6. 结合你的案例做具体映射
如果以近邻点 `1`、`2` 为例,当前代码里实际上缺的是下面这些业务语义:
- `1``2` 属于同一近邻组;
- 组内有主次优先级;
- 在不同 zoom 区间内有不同展示策略;
- 在最大放大阶段需要给 `2` 一个偏移位;
- 偏移后图标和文字还要分别控制碰撞策略。
当前实现只能表达:
- 某个点在当前 zoom 下是否允许渲染;
- 某个点图例是否可见;
- 某个点是否被区域裁切隐藏;
- 某个点的图标和文字整体是否参与 declutter。
它并不能表达:
- “1 替代 2 展示”;
- “2 替代 1 展示”;
- “1 和 2 一起展示,但 2 抽吸展开”;
- “只隐藏文字,不隐藏图标”。
所以你现在遇到的问题是结构性问题,不是简单调一个 `declutter` 参数就能彻底解决。
## 7. 后续修改时建议优先动的层
如果下一步要真正改这块,建议优先从下面几层入手:
### 7.1 先补“近邻点显示规则”
建议不要直接把业务规则写死在样式函数里,而是先增加一层近邻点编排规则,例如:
- `groupId`
- `priority`
- `showZoomMin`
- `showZoomMax`
- `replaceAtZoom`
- `expandAtZoom`
- `expandOffset`
这样才能表达:
- 默认只显示主点;
- 中间层级切换成副点;
- 更大层级两个都显示;
- 副点在展示时做偏移。
### 7.2 抽吸不要依赖 declutter 自动完成
抽吸本质上是“主动布局”问题,不是“被动避让”问题。
建议后续把近邻点抽吸拆成单独逻辑:
- 先根据原始点数据识别近邻组;
- 再根据当前 zoom 产出“本次真正参与渲染的点集合”;
- 对需要展开的点,生成偏移后的渲染坐标;
- 最后再交给图层渲染。
这样会比直接指望 OpenLayers 的 declutter 更可控。
### 7.3 图标层和文字层要分开控制
要实现“文字碰撞时只隐藏文字,不隐藏点图标”,后续最重要的一点是把图标和文字的渲染职责拆开。
至少要做到下面二选一:
- 图标层和文字层拆成两个独立图层;
- 或者把图标与文字拆成可独立决策的渲染单元。
目标是:
- 图标优先保留;
- 文字单独避让;
- 文本碰撞时只压文字,不压图标。
如果仍然维持“同一个样式对象里同时放 image 和 text”的组织方式这个问题大概率还会持续存在。
### 7.4 Popup 碰撞策略也要和新规则同步
后续如果点位抽吸和文字单独避让改了,`popup-manager.ts` 里的边界框逻辑也要一起调整。
否则会出现:
- 图标已经正常显示;
- 文字也按新规则显示了;
- 但 popup 层仍然沿用旧的合并边界框;
- 最终又在 popup 阶段把结果打回去。
## 8. 建议的改造顺序
为了降低风险,建议后续改造按以下顺序推进:
1. 先梳理近邻点数据结构
- 确定近邻点如何分组;
- 确定主次优先级和缩放阈值从哪里来。
2. 再实现“显示决策层”
- 在真正生成 Style 之前,先决定哪些点应该显示;
- 哪些点应该隐藏;
- 哪些点需要偏移展开。
3. 然后拆分图标和文字碰撞
- 先保证点图标稳定显示;
- 再单独做文字避让。
4. 最后调整 popup 逻辑
- 保证 popup 的碰撞判定和最终可见点保持一致。
## 9. 涉及文件清单
本次分析重点涉及以下文件:
- `src/components/gis/GisView.vue`
- `src/components/gis/map.class.ts`
- `src/components/gis/map.ol.ts`
- `src/components/gis/ol/point-layer-manager.ts`
- `src/components/gis/ol/popup-manager.ts`
- `src/utils/leaflet/leaflet.inflatable-markers-group.js`
- `src/components/gis/map.leaflet.ts`
- `package.json`
## 10. 当前结论
当前地图问题可以归纳为两点:
1. 现在的 OpenLayers 主链路并没有真正使用旧 Leaflet 的抽吸机制;
2. 当前点图标和文字被作为一个整体参与 declutter所以文字碰撞时会连点一起隐藏。
所以后续如果要改,方向不应该只是“调避让参数”,而应该是:
- 增加近邻点分阶段显示规则;
- 增加主动抽吸/偏移布局;
- 把图标和文字拆成可独立控制的渲染单元;
- 同步修正 popup 的碰撞判断。
在这个基础上,再进入代码修改会更稳。

View File

@ -1,316 +0,0 @@
# 地图模块-抽吸规则与参数说明
## 1. 文档目的
本文档用于记录当前地图点位“抽吸”功能的现行实现、默认参数、分层级显示规则和后续调参方式。
本文件描述的是**当前已经落地并验证通过**的规则,适合作为后续维护和继续调参的依据。
相关文档:
- `docs/地图模块-详细说明.md`:地图整体架构说明
- `docs/地图模块-OpenLayers抽吸与碰撞分析.md`:问题成因与改造分析
## 2. 当前实现范围
当前抽吸逻辑运行在 OpenLayers 主链路中,核心文件如下:
1. `src/modules/map/domain/nearby-point-rules.ts`
2. `src/store/modules/map.ts`
3. `src/components/gis/map.ol.ts`
4. `src/components/gis/ol/point-layer-manager.ts`
各文件职责:
- `nearby-point-rules.ts`
- 近邻点规则入口
- 自动识别配置入口
- 近邻点运行时元数据挂载
- `map.ts`
- 点位数据加载完成后,统一触发近邻点分组和元数据写回
- `map.ol.ts`
- 决定某个点在当前缩放级别是否显示
- 决定文字是否允许显示
- 承担图标与文字的手工碰撞控制
- `point-layer-manager.ts`
- 负责展开阶段的几何偏移
- 负责把同组点位按扇形/环形布局散开
## 3. 当前抽吸规则
### 3.1 近邻点自动识别
当前采用自动识别近邻点,不再靠固定站名手工枚举。
识别原则:
- 只在同一 `layerKey` 内做近邻识别
- 按点位空间距离自动聚组
- 满足最小组大小后,才认为是近邻组
- 组内自动生成显示优先级 `priority`
这样做的目的:
- 避免跨图层混组
- 避免后续新增点位还要手工补名字
- 让抽吸能力可以覆盖整批业务点
### 3.2 分层级显示规则
当前显示规则以 `replaceAtZoom``expandAtZoom` 为核心:
1. `zoom < replaceAtZoom`
- 只显示组内主点
- 即 `priority = 1`
2. `replaceAtZoom <= zoom < expandAtZoom`
- 显示组内前两个点
- 即 `priority <= 2`
- 当前这样设计是为了避免主点在放大过程中突然消失
3. `zoom >= expandAtZoom`
- 同组点全部显示
- 同时进入展开布局阶段
补充规则:
- 如果某个点配置了 `showZoomMin``showZoomMax`,则先受这两个值约束
- 没有进入近邻组的点,不受抽吸规则影响
### 3.3 文字显示规则
当前文字显示必须依赖点位显示。
也就是说:
- 点不显示,文字一定不显示
- 点显示后,文字还要继续通过碰撞判断才会显示
当前文字碰撞规则:
- 文字要避让已保留的图标占位盒
- 文字要避让已保留的其他文字
- 图标与文字不再分别交给 OpenLayers 的 `declutter` 自动处理
- 当前走的是项目内的手工碰撞控制
## 4. 当前默认参数
当前默认参数集中维护在:
- `src/modules/map/domain/nearby-point-rules.ts`
当前采用统一配置对象:
```ts
type NearbyPointConfig = {
autoRule: NearbyPointAutoRule;
layoutRule: NearbyPointLayoutRule;
densityDisplayRules: NearbyPointDensityDisplayRule[];
};
```
其中:
- `autoRule`:控制近邻点自动识别、缩放切换和基础展开半径
- `layoutRule`:控制展开阶段的单圈容量、扇形角度和外圈间距
- `densityDisplayRules`:控制 `distance` 密度分档在不同缩放级别的基础显示资格
### 4.1 自动识别与缩放阶段参数
当前值如下:
| 参数 | 当前值 | 作用 |
|---|---:|---|
| `distanceThresholdMeters` | `9000` | 近邻点自动识别距离阈值 |
| `replaceAtZoom` | `10.5` | 进入“主点 + 次点”阶段的缩放级别 |
| `expandAtZoom` | `12.2` | 进入“全部显示并展开”阶段的缩放级别 |
| `expandOffsetPx` | `48` | 第一圈展开的基础偏移半径 |
| `angleStepDeg` | `55` | 默认角度步长参考值 |
| `minGroupSize` | `2` | 最小成组数量 |
### 4.2 展开布局参数
当前值如下:
| 参数 | 当前值 | 作用 |
|---|---:|---|
| `ringSize` | `5` | 单圈最多容纳的次点数量 |
| `fanAngleForTwoDeg` | `110` | 单圈剩余 `2` 个点时的扇形总角度 |
| `fanAngleForFourDeg` | `150` | 单圈剩余 `3``4` 个点时的扇形总角度 |
| `fanAngleForManyDeg` | `180` | 单圈剩余 `5` 个及以上点时的扇形总角度 |
| `ringGapPx` | `22` | 外圈最小增量半径 |
| `ringGapFactor` | `0.85` | 外圈相对基础半径的增量系数 |
### 4.3 密度分档显示参数
这里的 `distance` 不是物理距离,而是点位密度分档值。
数值越大表示越稀疏,越早参与显示候选;数值越小表示越密集,需要更高缩放级别才参与显示。
当前值如下:
| `minDensityValue` | `minZoom` | 作用 |
|---:|---:|---|
| `1500000` | `0` | 很稀疏的点,从小缩放级别就可参与显示 |
| `800000` | `6.1` | 稍密集的点,从中低缩放开始参与显示 |
| `400000` | `8.1` | 中密度点,需要进一步放大 |
| `50000` | `10.6` | 高密度点,需要较高缩放级别 |
| `0` | `12.2` | 极密集点,只有更高缩放才进入候选 |
## 5. 当前展开布局
展开阶段由 `point-layer-manager.ts` 控制。
### 5.1 布局特点
当前布局不是简单把点随机散开,而是按以下方式处理:
- 主点保留原位置
- 同组其他点围绕主点展开
- 展开方向优先偏下方
- 单圈数量由 `ringSize` 控制
- 点数较多时进入第二圈
- 第二圈半径按 `ringGapPx``ringGapFactor` 继续增大,避免继续叠在一起
### 5.2 为什么优先向下展开
当前优先向下展开,主要是为了:
- 尽量给上方文字留空间
- 降低文字压住上方点的概率
- 让地图上的抽吸形态更稳定
## 6. 当前实现的几个关键约束
### 6.1 不使用淡入淡出动画
目前已移除点和文字的淡入淡出动画。
原因:
- 抽吸状态切换和动画叠加时,容易让点和文字出现闪烁
- 会干扰对“当前到底该不该显示”的判断
当前阶段优先保证:
- 显示规则稳定
- 点和文字状态一致
- 放大缩小时不出现明显错位
### 6.2 不再依赖 OL 的自动 declutter 决定图标命运
当前图标和文字都设置为 `declutterMode: 'none'`,由项目内逻辑统一控制。
这样做的原因:
- 避免图标和文字落在两套碰撞体系里
- 避免出现“文字先出来、点没出来”
- 避免文字压住其他点却仍然显示
## 7. 常见现象与调参建议
### 7.1 成组太少
表现:
- 相近点仍然容易互相盖住
- 进入展开前看起来还是压在一起
优先调整:
- 增大 `distanceThresholdMeters`
### 7.2 成组太多
表现:
- 距离并不算近的点也被当成一组
- 放大后过早进入抽吸关系
优先调整:
- 减小 `distanceThresholdMeters`
### 7.3 展开太晚
表现:
- 放大到较深层级前仍然挤在一起
优先调整:
- 减小 `expandAtZoom`
### 7.4 展开太早
表现:
- 中等缩放时地图上就出现较明显散开效果
优先调整:
- 增大 `expandAtZoom`
### 7.5 展开后间距不够
表现:
- 虽然已经展开,但图标还是比较挤
优先调整:
- 增大 `expandOffsetPx`
- 或增大 `ringGapPx`
- 或增大 `ringGapFactor`
### 7.6 单圈散开角度不合适
表现:
- 次点虽然已经展开,但仍然集中在一侧
- 或者扇形过大,展开形态显得太散
优先调整:
- 调整 `fanAngleForTwoDeg`
- 调整 `fanAngleForFourDeg`
- 调整 `fanAngleForManyDeg`
### 7.7 主点在放大过程中突然消失
当前已经通过 `replaceAtZoom <= zoom < expandAtZoom` 阶段显示 `priority <= 2` 进行修正。
如果后续又出现类似现象,优先检查:
- `map.ol.ts``shouldRenderNearbyFeature()` 的阶段逻辑
- 是否误改成只显示 `priority = 2`
### 7.8 明明周围看起来空,文字还是不显示
优先检查:
- 图标碰撞盒是否过大
- 文字碰撞盒是否过于保守
- 候选集是否发生重复统计
相关代码主要在:
- `src/components/gis/map.ol.ts`
## 8. 后续维护建议
建议后续继续保持以下约定:
1. 抽吸参数继续统一放在 `nearby-point-rules.ts`
2. 显示决策继续集中在 `map.ol.ts`
3. 几何偏移继续集中在 `point-layer-manager.ts`
4. 后续调参优先改 `DEFAULT_NEARBY_POINT_CONFIG`,不要直接把数值散落回业务代码
5. 如果继续加动画,必须放在抽吸状态机完全稳定之后
6. 如果继续优化 popup也要同步遵循“点先于文字”的显示原则
## 9. 当前结论
当前这版抽吸实现已经形成了比较稳定的主线:
- 自动识别近邻点
- 统一缩放阶段显示规则
- 展开阶段几何偏移
- 文字依赖点显示
- 文字避让图标与文字
后续如果继续调优,建议先从参数层开始,不要先动主逻辑结构。

View File

@ -1,621 +0,0 @@
# 地图模块-详细说明
## 1. 文档目的
本文档用于说明当前地图模块的真实运行结构、初始化时序、图层与图例联动、点位渲染规则、筛选与缩放行为,以及近期已经落地的关键约束。
本文档描述的是“当前可运行版本”的现状,不展开抽吸参数细节与专项问题分析。相关补充文档如下:
- `docs/地图模块-抽吸规则与参数说明.md`
- `docs/地图模块-OpenLayers抽吸与碰撞分析.md`
## 2. 当前技术栈
- 前端框架Vue 3 + TypeScript + Ant Design Vue
- 状态管理Pinia
- 2D 地图引擎OpenLayers 10.8.0
- 3D 地图引擎Cesium
- 地图服务GeoServer / WMTS / XYZ
- 运行模式:后台配置驱动 + 前台运行时编排
说明:
- 当前 2D 主链路已经完全围绕 OpenLayers 组织。
- 项目中仍保留 Leaflet 相关依赖和旧代码,但不是当前主执行路径。
## 3. 模块分层
当前地图模块可以按 6 层理解:
1. 页面入口层
2. 应用编排层
3. 地图能力门面层
4. 地图引擎执行层
5. Store 运行态层
6. 前台交互组件层
对应核心文件如下:
- 页面入口层
- `src/components/gis/GisView.vue`
- 应用编排层
- `src/modules/map/application/map-orchestrator.ts`
- 地图能力门面层
- `src/components/gis/map.class.ts`
- 地图引擎执行层
- `src/components/gis/map.ol.ts`
- `src/components/gis/map.cesium.ts`
- `src/components/gis/ol/point-layer-manager.ts`
- `src/components/gis/ol/popup-manager.ts`
- Store 运行态层
- `src/store/modules/map.ts`
- `src/modules/map/stores/map-config.store.ts`
- `src/modules/map/stores/map-data.store.ts`
- `src/modules/map/stores/map-view.store.ts`
- 前台交互组件层
- `src/components/mapLegend/index.vue`
- `src/components/mapFilter/index.vue`
- `src/components/mapController/index.vue`
- `src/components/mapController/LayerController.vue`
- `src/components/BaseLayerSwitcher/index.vue`
## 4. 整体原则
当前地图模块遵循以下几个运行原则:
### 4.1 地图容器全局常驻
地图由 `GisView.vue` 常驻挂载,不会随着菜单切换被销毁重建。页面切换本质上是:
- 重新计算 `pageKey`
- 重新加载该页图层配置和图例配置
- 用新的运行态覆盖旧页面显示内容
### 4.2 配置驱动
前端不把全部图层和图例硬编码死,而是依赖后端返回的:
- 图层树配置
- 图例配置
- 页面级图例配置
- 各业务点位接口数据
### 4.3 运行态绝对优先
初始化默认值只在第一次派生时生效;一旦进入运行态,后续图层勾选、图例勾选、筛选器切换,都必须以当前用户操作后的状态为准,不能在加载完成后回退到默认状态。
## 5. 页面入口层
### 5.1 `GisView.vue`
`GisView.vue` 的职责主要是:
- 提供地图容器 `#mapContainer`
- 提供 popup 挂载容器
- 挂载图例、筛选器、控制器、底图切换器
- 根据路由计算 `pageKey`
- 在挂载时调用 `mapOrchestrator.mountView()`
- 在页面切换时交给编排器重载页面
当前页面层已经不再直接承载复杂业务逻辑,复杂链路都收敛到了编排器和 Store。
## 6. 应用编排层
### 6.1 `map-orchestrator.ts` 的职责
`map-orchestrator.ts` 是当前地图业务的总调度中心,主要负责:
- 初始化地图壳与 popup
- 加载页面图层配置和图例配置
- 初始化 GIS 基础图层
- 触发点位图层加载
- 管理缩放监听和基地切换监听
- 处理图层树勾选
- 处理图例勾选
- 处理时间筛选、容量筛选、搜索定位
- 处理水电开发菜单下的缩放联动图层
### 6.2 页面初始化链路
当前初始化顺序如下:
1. `GisView.vue` 调用 `mapOrchestrator.mountView()`
2. `MapClass.init()` 初始化地图实例
3. 页面级 popup 容器挂到地图实例
4. 绑定缩放监听和基地监听
5. 并行请求图层树配置与图例配置
6. 图例接口先返回时,立即生成图例运行态
7. 图层树接口返回后,生成默认勾选图层并初始化 GIS 基础图层
8. 再按图层配置批量加载点位数据
这里有两个当前已落地的关键时序调整:
- 图例转圈只跟图例接口绑定,不再等待所有锚点接口完成。
- 筛选器数据会随着已加载图层逐步出现,不再必须等全量点位接口结束。
### 6.3 页面切换
页面切换时地图不销毁,只重载当前页面的:
- 图层树配置
- 图例配置
- 点位数据
- 当前页面的默认运行态
如果仍处于同一个 `pageKey` 且用户之前修改过勾选态,会尽量复用当前运行态,而不是重新回放默认勾选。
### 6.4 缩放联动
当前编排层主要有两类缩放联动:
- `eng_point` 缩放联动
- 大型电站始终允许显示
- 中型电站仅在 `zoom > 7.5` 时进入显示候选
- 水电开发菜单动态图层联动
- `HYDRO_DYNAMIC_LAYER_KEYS` 在缩放跨越阈值时参与加载与显隐编排
## 7. 地图能力门面层
### 7.1 `map.class.ts`
`MapClass` 是地图能力统一门面,屏蔽 2D / 3D 引擎差异,对外暴露统一方法。
当前常用能力包括:
- `init()`
- `initPopupOverlay()`
- `addBaseDataLayer()`
- `addInitDataLayer()`
- `mdLayerTreeShowOrHidden()`
- `controlBaseLayerTreeShowAndHidden()`
- `setLegendPointVisible()`
- `hasLayer()`
- `hasBaseLayer()`
- `removePointLayer()`
- `flyTopanto()`
- `switchView()`
当前业务主执行层仍然是 `MapOl`
## 8. 地图引擎执行层
### 8.1 OpenLayers 主执行层
`map.ol.ts` 负责 OpenLayers 地图的实际渲染与交互,是当前 2D 地图主执行文件。
当前承担的核心能力包括:
- 地图实例初始化
- 底图与 GIS 图层管理
- 点位图层渲染
- 图标与文字样式生成
- hover popup 与批量 popup
- 基地范围裁切
- 高缩放交互
- 测量、截图、飞行定位等基础地图能力
### 8.2 Cesium 执行层
`map.cesium.ts` 目前更多是保留 3D 入口和部分基础能力,复杂业务点位与碰撞规则并未与 OpenLayers 完全对齐。
结论上,当前真实业务规则仍然以 OpenLayers 链路为准。
## 9. Store 分层
### 9.1 `map-config.store.ts`
负责配置态数据:
- 图层树配置
- 图层配置索引
- 全量原始图例配置
- 图例索引
- 页面级图例配置
- 配置接口加载状态
当前这里拆分出了两个加载态:
- `configLoading`
- `legendLoading`
其中 `legendLoading` 单独控制图例转圈。
### 9.2 `map-data.store.ts`
负责数据态:
- `pointData`
- `pointDataCache`
- `layerLoadState`
- 地图整体 `loading`
这里的关键设计是:
- 每个点图层有独立缓存
- `pointData` 是由缓存合并出来的运行态数据
- 单个图层命中缓存或接口返回后,就会立即回填 `pointData`
因此当前搜索筛选下拉已经支持“渐进可用”,不再只能等全部接口结束。
### 9.3 `map-view.store.ts`
负责视图运行态:
- 当前勾选图层 `checkedLayerKeys`
- 图例勾选状态 `legendCheckedState`
- 搜索时间范围
- 当前基地
- 当前缩放级别
### 9.4 `src/store/modules/map.ts`
这是当前地图运行逻辑最重的 Store负责串联配置、缓存和地图实例。
主要职责包括:
- 生成图例运行态
- 批量加载点位数据
- 控制图层显隐
- 控制图例显隐
- 管理请求缓存与并发加载
- 刷新点图层显示数据
- 处理 `eng_point` 的显示过滤
- 在全量点位准备完成后挂载近邻点元数据
## 10. 图层分类
当前地图里的图层大体可以分成三类:
### 10.1 `GISMap`
这类图层对应 GIS 基础图层或专题底图,由 `addBaseDataLayer()` 加载到 `layerRegistry`
当前已补充的规则:
- 勾选 `GISMap` 时,不能只做显示/隐藏。
- 如果地图实例中不存在该图层,需要先重新加载,再显示。
### 10.2 点位图层
这类图层由接口返回锚点数据,最终进入 `PointLayerManager` 维护的 `VectorLayer`
### 10.3 其他专题图层
例如梯级流域、区域裁切辅助层等,这些更多由 `map.ol.ts` 单独控制。
## 11. 图层数据加载机制
### 11.1 配置与数据分离
当前初始化时,配置加载与点位数据加载是两条链:
- 图层树配置决定有哪些层、哪些默认勾选
- 图例配置决定图例结构和图例项元数据
- 点位数据接口决定地图上实际锚点内容
### 11.2 加载顺序
`loadAllLayerData()` 的主要逻辑是:
1. 先收集所有带 URL 的图层
2. 把默认勾选图层作为高优先级任务
3. 收集完成后全量并发加载,不再按 4 个一组分批发起
4. 每个图层完成后立即写入缓存
5. 每个图层命中缓存或返回成功后,立即增量更新 `pointData`
6. 全部图层结束后,再统一:
- 回填每层原始数据
- 挂载近邻点运行时元数据
- 刷新各图层显示数据
- 用最新运行态收尾
### 11.3 初始化阶段的图例绝对性
当前已修复一个关键问题:
- 图例虽然可能在界面上还在 loading
- 但只要图例配置已经返回,默认隐藏的图例项在点位首批入图时就必须生效
也就是说,现在不会再出现:
- loading 过程中先显示默认隐藏点
- loading 结束后才消失
## 12. 图例机制
### 12.1 图例来源
图例运行态由以下数据共同决定:
- 原始全量图例配置
- 当前页面图例配置
- 当前已勾选图层
- 当前图例勾选状态
### 12.2 图例派生
图例最终显示的是 `legendDataSelected`,而不是全量图例。
派生逻辑核心原则如下:
- 只有当前勾选图层对应的图例项才进入页面图例
- 图例勾选状态是运行态绝对值
- 环保设施分组有单独规则
### 12.3 图例加载时机
当前图例面板的 loading 只跟图例接口绑定:
- 图例接口返回后立即停转
- 图例内容在图层默认勾选同步后立即派生
- 不再等待所有锚点接口完成
## 13. 筛选器机制
### 13.1 `MapFilter` 的职责
筛选器当前包含以下能力:
- 时间范围筛选
- 装机容量筛选
- 搜索定位
- 个别页面的专题筛选项
### 13.2 搜索下拉来源
搜索下拉使用 `mapDataStore.pointData` 作为数据源。
当前行为已经调整为:
- 某个图层一旦命中缓存,筛选器即可出现该图层点位
- 某个图层接口一旦返回成功,筛选器也会立即出现该图层点位
因此筛选器当前是“渐进展示”,而不是“全量完成后一次性展示”。
### 13.3 装机容量筛选
装机容量筛选针对 `eng_point` 图层,当前规则如下:
- `all`
- 恢复大型与中型图例
- `large_eng_built`
- 只保留 `large_eng_` 前缀相关图例
- 地图上只显示大型电站点位
- `mid_eng_built`
- 只保留 `mid_eng_` 前缀相关图例
- 地图上只显示中型电站点位
当前容量切换不再只是影响筛选器本地下拉,而是会同步驱动:
- 图例运行态
- `eng_point` 点位显示
## 14. `eng_point` 专项规则
`eng_point` 是当前地图模块中的特殊图层,存在独立显示规则。
### 14.1 缩放规则
在数据入图阶段就生效:
- 大型电站始终允许显示
- 中型电站仅在 `zoom > 7.5` 时允许进入显示候选
这不是单纯的图例控制,而是点位入图时的过滤逻辑。
### 14.2 图例规则
点位入图之后,仍然必须再经过图例运行态控制:
- 允许入图,不等于最终可见
- 只要图例勾选为隐藏,该类点位就必须隐藏
### 14.3 容量筛选规则
装机容量下拉会进一步批量控制 `eng_point` 的图例项,使图例与点位同步切换。
## 15. 点位显示裁决链
当前点位最终是否显示,至少会经过以下几层判断:
1. 图层是否勾选
2. 图例是否勾选
3. 基地范围是否允许
4. `eng_point` 缩放过滤是否通过
5. `distance` 密度分档是否通过
6. 近邻点显示阶段是否通过
7. 图标是否已加载完成
8. 当前缩放下是否进入碰撞控制
这几层不是互斥关系,而是叠加裁决。
## 16. 点位渲染与样式
### 16.1 基础样式
当前点位渲染包括:
- 图标
- 文字标签
- 标签换行与截断
- 缩放相关的图标与文字样式变化
### 16.2 图标加载约束
当前已经补充图标加载状态缓存:
- 图标未加载完成时,不显示该点
- 图标未加载完成时,文字也不先显示
- 图标未加载完成时,不参与碰撞候选
这样可以避免“文字先出来、图标还是空的”的问题。
## 17. 抽吸、密度与碰撞
### 17.1 专项文档
抽吸与密度分档的详细参数请查看:
- `docs/地图模块-抽吸规则与参数说明.md`
### 17.2 当前裁决顺序
当前点位显示顺序可以理解为:
1. 密度分档
2. 近邻抽吸
3. 图标碰撞
4. 文字碰撞
补充约束:
- 只有满足可见性条件的点,才能进入碰撞候选集
- 被图例或缩放过滤掉的点,不能再去占用碰撞资格
- 视图内无近邻的孤立点允许局部放行
### 17.3 高缩放特例
当前在高缩放层级 `zoom >= 15` 时,行为与中低缩放不同:
- 只处理视口内点位
- 点与文字不再做碰撞淘汰
- 视口内符合基础显示条件的点和文字全部显示
- 同时进入批量 popup 模式
这条规则的目标是:
- 保证深度放大后信息完整可见
- 避免高缩放下还因为碰撞把点或文字压掉
## 18. Popup 机制
### 18.1 两类 popup
当前 popup 分成两类:
- hover popup
- batch popup
### 18.2 hover popup
鼠标悬停时由 `pointermove` 驱动,只在非 batch popup 模式下显示。
### 18.3 batch popup
`zoom >= 15` 时进入批量 popup 模式:
- 只基于当前视口内点位
- 拖动地图时持续刷新
- 但通过 `requestAnimationFrame` 做节流
- `moveend` 后立即再做一次强制刷新
当前这样做是为了兼顾两件事:
- 保留“拖动时 popup 跟着更新”的原交互
- 避免拖动过程中每个事件都重建整批 DOM
### 18.4 惯性平移已关闭
当前地图拖动已经关闭 OpenLayers 默认惯性平移:
- 普通拖动仍然保留
- 松手后不再继续滑动一小段
这样可以减少高缩放 popup 场景下“松手还轻微位移”的观感问题。
## 19. 基地与区域过滤
基地切换是当前地图模块的关键能力之一,主要影响:
- 地图裁切范围
- 当前基地相关点位的可见性
- 部分专题图层显示
整体过程通常是:
1. 外部基地选择源变化
2. 编排器接收到基地 ID
3. 地图实例切换基地范围
4. 点位更新 `_regionVisible`
5. 样式函数按区域可见性决定是否显示
## 20. 典型交互链路
### 20.1 图层树勾选
1. `LayerController` 触发勾选变化
2. 编排器归一化图层 key
3. `map.ts` 更新运行态图层勾选
4. 地图实例更新图层显隐
5. 图例重新派生
补充:
- `GISMap` 勾选时,如果地图里没有该图层,会先补载,再显示
### 20.2 图例勾选
1. 图例组件触发点击
2. 编排器转发图例切换
3. `map.ts` 更新图例运行态
4. 对应点位立即更新显隐
5. 图例树同步更新灰态
### 20.3 筛选器操作
1. 用户切换时间、容量、搜索等条件
2. 编排器接收命令
3. 视情况更新视图运行态或重载数据
4. 地图实例与图例运行态同步刷新
### 20.4 缩放操作
1. OpenLayers 视图触发 `change:resolution`
2. 编排器更新当前缩放级别
3. 处理 `eng_point` 阈值联动
4. 水电开发菜单下处理动态图层联动
5. `map.ol.ts` 处理高缩放 popup 与点位显示策略
## 21. 当前重点文件清单
- `src/components/gis/GisView.vue`
- `src/components/gis/map.class.ts`
- `src/components/gis/map.ol.ts`
- `src/components/gis/map.cesium.ts`
- `src/components/gis/gisUtils.ts`
- `src/components/gis/mapurlManage.ts`
- `src/components/gis/ol/point-layer-manager.ts`
- `src/components/gis/ol/popup-manager.ts`
- `src/modules/map/application/map-orchestrator.ts`
- `src/modules/map/stores/map-config.store.ts`
- `src/modules/map/stores/map-data.store.ts`
- `src/modules/map/stores/map-view.store.ts`
- `src/store/modules/map.ts`
- `src/components/mapLegend/index.vue`
- `src/components/mapFilter/index.vue`
- `src/components/mapController/index.vue`
- `src/components/mapController/LayerController.vue`
- `src/components/BaseLayerSwitcher/index.vue`
## 22. 当前结论
当前地图模块已经形成比较清晰的主干结构:
- `GisView` 负责挂载与页面入口
- `map-orchestrator` 负责业务编排
- `MapClass` 负责能力门面
- `MapOl` 负责 2D 主执行
- `map.ts + 三个拆分 store` 共同维护配置、数据与视图运行态
- 图层树、图例、筛选器、popup、高缩放规则都已经围绕这条主干收口
相较旧版说明,当前最重要的现状变化有 8 点:
1. 图例 loading 已与锚点全量加载解耦
2. 筛选器数据已支持渐进展示
3. 初始化阶段默认隐藏图例项会立即生效
4. 运行态勾选不会在 loading 完成后被默认值覆盖
5. `eng_point` 有独立的缩放与容量规则
6. 高缩放下视口内点和文字全部显示,不再做碰撞淘汰
7. batch popup 保留拖动跟随,但增加了节流
8. 地图拖动惯性已关闭
后续如果继续扩展地图功能,建议仍然优先沿着“编排层 + Store 分层 + OpenLayers 执行层”这条主干扩展,不要再把复杂业务回填到页面组件里。

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -3,8 +3,8 @@
"version": "1.2.0",
"scripts": {
"dev": "vite serve --mode development",
"build": "vite build --mode production",
"build:mvn": "vite build --mode production",
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build --mode production",
"build:mvn": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build --mode production",
"serve": "vite preview",
"lint": "eslint src/**/*.{ts,js,vue} --fix",
"prettier": "prettier --write ."
@ -12,7 +12,6 @@
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@element-plus/icons-vue": "^2.0.10",
"@turf/turf": "^7.3.5",
"@types/js-cookie": "^3.0.2",
"@univerjs-pro/exchange-client": "0.15.0",
"@univerjs/core": "0.14.0",
@ -25,24 +24,20 @@
"antdv-draggable-modal": "^1.1.6",
"axios": "^1.2.0",
"better-scroll": "^2.4.2",
"cesium": "^1.141.0",
"dayjs": "^1.11.20",
"default-passive-events": "^2.0.0",
"dom-to-image": "^2.6.0",
"echarts": "^5.2.2",
"element-plus": "^2.2.27",
"esri-leaflet": "^3.0.19",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
"js-base64": "^3.7.5",
"js-cookie": "^3.0.1",
"jsencrypt": "^3.3.2",
"jszip": "^3.10.1",
"leaflet": "^1.9.4",
"lodash": "^4.18.1",
"moment": "^2.30.1",
"nprogress": "^0.2.0",
"ol": "^10.8.0",
"path-browserify": "^1.0.1",
"path-to-regexp": "^6.2.0",
"pdfjs-dist": "^6.0.227",
@ -75,6 +70,7 @@
"@typescript-eslint/parser": "^5.19.0",
"@vitejs/plugin-vue": "^4.0.0",
"autoprefixer": "^10.4.13",
"cross-env": "^10.1.0",
"eslint": "^8.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.0.0",
@ -87,7 +83,6 @@
"tailwindcss": "^3.2.4",
"typescript": "latest",
"vite": "^4.0.3",
"vite-plugin-cesium": "^1.2.22",
"vite-plugin-svg-icons": "^2.0.1",
"vue-tsc": "latest"
},

View File

@ -1,61 +0,0 @@
import request from '@/utils/request';
// 获取图例列表
export function getModuleMapLegendList(params?: { moduleId?: string }) {
const url = params?.moduleId
? `/mapLegend/getModuleMapLegendList?moduleId=${params.moduleId}`
: '/mapLegend/getModuleMapLegendList';
return request({
url,
method: 'get'
});
}
// 获取地图配置列表
export function getMapList(data: any) {
return request({
url: '/mapmodule/getMapData',
method: 'post',
data
});
}
// 获取梯级流域地图
export function getQgcRvcd(data: any) {
return request({
url: '/eng/base/rsvrcscdb/getQgcRvcd',
method: 'post',
data
});
}
// 获取梯级流域下拉框列表
export function getRvcdList(data: any) {
return request({
url: '/eng/base/rsvrcscdb/rvcd',
method: 'post',
data
});
}
// 获取梯级流域下拉框图表数据
export function getKendoList(data: any) {
return request({
url: '/eng/base/rsvrcscdb/GetKendoList',
method: 'post',
data
});
}
// 鱼类分布查询 - 站点查询
export function getFishPointList(data: any) {
return request({
url: '/wte/we/fishPoint/qgc/GetKendoListCust',
method: 'post',
data
});
}
// 鱼类分布查询 -鱼查询
export function getFishList(data: any) {
return request({
url: '/wte/we/fishList/GetKendoListCust',
method: 'post',
data
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

View File

@ -1,16 +1,3 @@
// import {
// NormalDatePickerFilter,
// NormalFishFilter,
// NormalFishFilter1,
// NormalSelectFilter,
// NormalStcdFilter,
// NormalWEFilter,
// NormalYearPickerFilter
// } from '@zebras/qgc-share/components/mapModal/index'
// import { Session } from '@zebras/qgc-share/service/Session'
// import getUrl from '@zebras/qgc-share/utils/isQGCrul'
// import { Utility } from '@zebras/qgc-share/utils/Utility'
// // 水电站 √
const ENGTabs: Array<any> = [
{

View File

@ -1,195 +0,0 @@
<template>
<div class="gis-view">
<div id="mapContainer" />
<div ref="popupRef" class="map-popup-container" style="display: none"></div>
<!-- - 1. 切换菜单的时候图层没有切换 是因为接口的原因现在没有接口
2. 切换菜单的时候图例现在是不对的默认选中的数据他们没有做处理
-->
<!-- 地图图例 -->
<MapLegend />
<!-- 地图筛选器 -->
<MapFilter v-if="showMapFilter" :map="mapClass" />
<!-- 地图控制器 -->
<MapController :map="mapClass" :onClick="handleMapController" />
<!-- 基础图层切换器 -->
<BaseLayerSwitcher :map="mapClass" />
<!-- 梯级弹框 -->
<TjLayerModal v-model:open="tjModalVisible" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, onUnmounted, computed } from 'vue';
import MapLegend from '@/components/mapLegend/index.vue';
import MapFilter from '@/components/mapFilter/index.vue';
import MapController from '@/components/mapController/index.vue';
import BaseLayerSwitcher from '@/components/BaseLayerSwitcher/index.vue';
import TjLayerModal from './TjLayerModal.vue';
import { useRoute } from 'vue-router';
import { useMapOrchestrator } from '@/modules/map/application/map-orchestrator';
import { MapClass } from './map.class';
import { getQgcRvcd } from '@/api/map';
import { servers } from './mapurlManage';
import { useUiStore } from '@/store/modules/ui';
const mapOrchestrator = useMapOrchestrator();
const uiStore = useUiStore();
const route = useRoute();
const mapClass = MapClass.getInstance();
const mapIsInited = ref(false);
const tlyLayerVisible = ref(false);
const tjModalVisible = ref(false);
const popupRef = ref<HTMLDivElement | null>(null);
// key
const pageKey = computed(() => {
const path = route.path;
const parts = path.split('/');
if (parts.length >= 3) {
return parts[1] + '_' + parts[2];
}
return '';
});
//
const showMapFilter = computed(() => {
const path = route.path;
return !path.includes('dianZhanZhuanTi/dianZhanZhuanTi');
});
//
const isShuiDianKaiFaMenu = computed(() => {
return route.path.includes('home/shuiDianKaiFaZhuangKuang');
});
//
const init = async () => {
const container = document.getElementById('mapContainer') as HTMLElement;
if (!container) return;
await mapOrchestrator.mountView({
container,
popupContainer: popupRef.value,
pageKey: pageKey.value,
getIsHydroMenu: () => isShuiDianKaiFaMenu.value
});
mapIsInited.value = true;
};
//
const handleMapController = async (e: any, mapType: any) => {
switch (e) {
case 'dim':
await mapOrchestrator.switchMapType(mapType, {
popupContainer: popupRef.value,
getIsHydroMenu: () => isShuiDianKaiFaMenu.value
});
uiStore.markMapSwitchCompleted();
break;
case 4: //
tlyLayerVisible.value = !tlyLayerVisible.value;
if (tlyLayerVisible.value) {
tjModalVisible.value = true;
fetchTjData();
} else {
tjModalVisible.value = false;
mapClass.hideTertiarybasinLayer(servers.Tertiarybasin);
}
break;
default:
break;
}
};
const fetchTjData = async () => {
const res = await getQgcRvcd({});
if (res && res.data) {
let datas = [];
for (let i = 0; i < res.data.data.length; i++) {
datas.push(res.data.data[i].rvcd);
}
mapClass.addTertiarybasinLayer(
servers.Tertiarybasin,
'#4DFFDD',
'#92A0A5',
datas
);
}
};
//
watch(
() => pageKey.value,
newVal => {
if (newVal && mapIsInited.value) {
void mapOrchestrator.handlePageChange(newVal, isShuiDianKaiFaMenu.value);
}
}
);
onMounted(() => {
init();
window.mapClass = mapClass;
});
onUnmounted(() => {
mapOrchestrator.unmountView();
mapClass.destroy();
});
</script>
<style lang="scss" scoped>
.gis-view {
width: 100%;
height: 100%;
position: absolute;
visibility: visible;
}
#mapContainer {
width: 100%;
height: 100%;
position: absolute;
background-color: #fff;
cursor: grab;
z-index: 1;
}
/* 消除瓦片之间的缝隙和默认边框 */
.leaflet-tile {
border: none !important;
margin: 0 !important;
padding: 0 !important;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
/* 确保地图容器背景色与瓦片一致,避免透出底色 */
.leaflet-container {
background-color: #fff;
}
/* 如果使用 Canvas 模式,确保 Canvas 没有边框 */
.leaflet-pane canvas {
border: none;
}
.map-popup-container {
z-index: 1000;
pointer-events: auto;
}
.custom-popup {
padding: 10px;
}
.custom-popup h4 {
margin: 0 0 5px 0;
color: #333;
}
.custom-popup p {
margin: 2px 0;
font-size: 12px;
color: #666;
}
</style>

View File

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

View File

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

View File

@ -1,537 +0,0 @@
// import { Session } from '@zebras/qgc-share/service/Session'
import domtoimage from 'dom-to-image';
import {
offset2,
drawDotImg2,
drawDotImg1,
offset1,
drawDotImg3,
offset3,
drawDotImg5,
offset5
} from '@/utils/GisUrlList';
declare global {
interface Window {
__lyConfigs?: {
theme?: string;
[key: string]: any; // 根据实际配置结构补充具体字段,或使用索引签名兼容其他属性
};
__mapMode?: string; // 建议同时声明代码中用到的其他全局变量
}
}
/**
*
* @param {Number} height
*/
const A = 40487.57;
const B = 0.00007096758;
const C = 91610.74;
const D = -40467.74;
/**
*
* @param data
* @returns nameEn为下标的图例数据
*/
export const legendData2Obj = (data: any[]) => {
const _tempData: any = {};
const f = (_data: any[]) => {
_data.forEach(item => {
// childrenList有值表示这是一个分组
if (item?.childrenList && item.childrenList?.length > 0) {
f(item.childrenList);
} else {
_tempData[item.nameEn] = item;
}
});
};
f(data);
return _tempData;
};
/**
* item中
* @param item - item
* @param index - item的索引
* @param labelType -
*/
export const appendOffsetPropties = (item: any, index: any, labelType = 2) => {
let drawDotImg = null;
let offset = null;
if (labelType == 2) {
drawDotImg = drawDotImg1;
offset = offset1;
addItemProperty(item, index, drawDotImg, offset);
} else if (labelType == 3) {
drawDotImg = drawDotImg2;
offset = offset2;
addItemProperty(item, index, drawDotImg, offset);
} else if (labelType == 4) {
drawDotImg = drawDotImg3;
offset = offset3;
addItemProperty2(item, index, drawDotImg, offset);
} else if (labelType == 5) {
drawDotImg = drawDotImg5;
offset = offset5;
addItemProperty2(item, index, drawDotImg, offset);
}
};
const addItemProperty2 = (
item: any,
index: any,
drawDotImg: any,
offset: any
) => {
if (!drawDotImg || !offset) return;
item.icon_image =
index % 2 !== 0
? drawDotImg[item.anchoPointState]?.left || ''
: drawDotImg[item.anchoPointState]?.right || '';
item.text_anchor = index % 2 !== 0 ? 'right' : 'left';
item.text_offset = offset[item.icon_image]?.text_offset ?? [-10, -1.8];
item.text_offset2 = offset[item.icon_image]?.text_offset2 ?? [-10, -1.8];
item.icon_offset = [
offset[item.icon_image]?.icon_x ?? -200,
offset[item.icon_image]?.icon_y ?? -50
];
item.icon_offset2 = offset[item.icon_image]?.icon_offset2 ?? [-200, -50];
item.billboard_offset = [
offset[item.icon_image]?.billboard_x ?? -80,
offset[item.icon_image]?.billboard_y ?? -15
];
item.label_offset = offset[item.icon_image]?.labelOffset ?? [100, 41];
};
export const addItemProperty = (
item: any,
index: any,
drawDotImg: any,
offset: any
) => {
if (!drawDotImg || !offset) return;
item.icon_image =
index % 2 !== 0
? drawDotImg[item.anchoPointState]?.left || ''
: drawDotImg[item.anchoPointState]?.right || '';
item.text_offset = [
offset[item.icon_image]?.text_x ?? -10,
offset[item.icon_image]?.text_y ?? -1.8
];
item.icon_offset = [
offset[item.icon_image]?.icon_x ?? -200,
offset[item.icon_image]?.icon_y ?? -50
];
item.billboard_offset = [
offset[item.icon_image]?.billboard_x ?? -80,
offset[item.icon_image]?.billboard_y ?? -15
];
item.label_offset = offset[item.icon_image]?.labelOffset ?? [100, 41];
};
/**
* popName属性
* @param item - popName的item
*/
export const setPopName = (item: any) => {
if (item.sttp === 'ENG') {
item.popName = item.ennm || item.titleName;
} else if (item.sttp === 'ylfb') {
if (item?.ftp?.length > 20) {
item.popName = item.ftp.slice(0, 20) + '...';
} else {
item.popName = item?.ftp;
}
} else if (item.sttp === 'WE_FISH') {
item.popName =
item.fishList?.[0]?.fishName + `(${item.fishList?.[0]?.ptypeName})`;
item.popName1 = item.total + '尾';
} else {
item.popName = item.stnm || item.titleName;
}
};
/**
* key
* @param data -
* @returns key数组
*/
export const getCheckedLayerConfigs = (data: any[]): any[] => {
const rs: any[] = [];
const f = (arr: any[] = []) => {
let count = 0;
arr.forEach((item: any) => {
if (item?.children && item.children.length > 0) {
const childrenCount = f(item.children);
if (childrenCount) {
rs.push(item.key);
}
} else if (item.checked) {
count++;
rs.push(item.key); // 对应图例的layerCode
}
});
return count;
};
f(data);
return rs;
};
// type mapType = "" | "pointMap" | "gisLayer"
/**
*
* @param data -
* @returns
*/
export const layerConfig2Flat = (data: any): any[] => {
const rs: any[] = [];
const f = (arr: any[] = []) => {
arr.forEach((item: any) => {
const { type } = item;
if (type) {
if (type == 'GISMap') {
if (item?.children && item.children.length > 0) {
f(item.children);
} else {
rs.push(item);
}
} else if (type == 'pointMap') {
if (
item.url ||
item.title === '国家水文站' ||
item.title === '自建水文站'
) {
rs.push(item);
} else if (item.children.length > 0) {
f(item.children);
}
}
} else if (item.children.length > 0) {
f(item.children);
}
});
};
f(data);
return rs;
};
/**
* URL
* @param layerConfigsArr -
* @param urlList - URL
* @returns
*/
export const replaceUrl = (layerConfigsArr: any[], urlList: any[]): any[] => {
const bDict: Record<string, any[]> = {};
// 构建 URL 映射字典
for (let i = 0; i < urlList.length; i++) {
const key = urlList[i].url;
const title = urlList[i].title;
const keyType = urlList[i].keyType;
if (!bDict[key]) bDict[key] = [];
if (!bDict[title]) bDict[title] = [];
if (!bDict[keyType]) bDict[keyType] = [];
bDict[key].push(urlList[i]);
bDict[title].push(urlList[i]);
if (bDict[keyType]) {
bDict[keyType].push(urlList[i]);
}
}
// 替换图层配置中的 URL
for (let i = 0; i < layerConfigsArr.length; i++) {
const obj = layerConfigsArr[i];
const key = obj.url;
const title = obj.title;
const keyType = obj.key;
const bObjList = bDict[keyType] || bDict[title] || bDict[key];
if (bObjList && bObjList.length > 0) {
const bObj = bObjList[0];
obj.url = bObj.url;
obj.urlThd = bObj.url;
obj.params = bObj.params;
obj.orders = bObj.orders;
} else {
obj.url = '';
}
}
return layerConfigsArr;
};
/**
*
* @param config -
* @returns
*/
export const getMapConfig = (config: any) => {
const r = { ...config };
const baseUrlObj = import.meta.env.VITE_APP_MAP_URL;
if (baseUrlObj) {
r.url = baseUrlObj + r.url; // 'http://localhost:8088'
r.url_3d = baseUrlObj + r.url_3d;
// r.url = baseUrlObj.url + r.url //baseUrlObj.url
// r.url_3d = baseUrlObj.url + r.url_3d
if (r.geojson_url) {
r.geojson_url = baseUrlObj + r.geojson_url;
}
}
return r;
};
/**
*
*/
export const resetMapElPos = () => {
const legend = document.querySelector('#qgc-legendtl') as HTMLElement; // 图例
// const filter = document.querySelector('#map-filter-container') as HTMLElement // 全局表单
const controller = document.querySelector('#map-controller') as HTMLElement; // 地图工具栏
const baselayer = document.querySelector('#map-baselayer') as HTMLElement; // 底图模式切换
if (legend) {
legend.style.left = '0';
legend.style.bottom = '0';
}
if (controller) {
controller.style.right = '480px';
controller.style.bottom = '114px';
}
if (baselayer) {
baselayer.style.right = '480px';
baselayer.style.bottom = '20px';
}
};
/**
*
* @param data -
* @param position -
* @returns
*/
const getListByPosition = (data: any, position: string) =>
data?.data?.filter((el: any) => el.position === position && el.code);
/**
*
* @param layoutType -
* @param data -
* @param offset -
*/
export const setMapLegendPos = (
layoutType: string,
data: any,
offset = 456
) => {
const menuStateString = localStorage.getItem('menuState'); //处理澜沧江左侧菜单状态
const menuState =
menuStateString !== null ? JSON.parse(menuStateString) : true;
const _theme = localStorage.getItem('ly-theme') || window.__lyConfigs?.theme;
const leftEle = document.querySelector('#page-layout-left') as HTMLElement;
const rightEle = document.querySelector('#page-layout-right') as HTMLElement;
const bottomEle = document.querySelector(
'#page-layout-bottom'
) as HTMLElement;
const legend = document.querySelector('#qgc-legendtl') as HTMLElement; // 图例
const filter = document.querySelector('#map-filter-container') as HTMLElement; // 全局表单
const compassControl = document.querySelector(
'#map-compassControl'
) as HTMLElement; // 全局表单
const controller = document.querySelector('#map-controller') as HTMLElement; // 地图工具栏
const monitor = document.querySelector('#map-monitor') as HTMLElement; // 地图工具栏
const baselayer = document.querySelector('#map-baselayer') as HTMLElement; // 底图模式切换
// const vd = document.querySelector('#vd_operate') as HTMLElement // 底部视频
const left = [
'layout1',
'layout2',
'layout3',
'layout4',
'layout6',
'layout7',
'layout8',
'layout9',
'layout10',
'layout11',
'layout14',
'layout15',
'layout16',
'layout17'
]; // 左侧布局
const right = [
'layout1',
'layout2',
'layout3',
'layout4',
'layout5',
'layout6',
'layout8',
'layout10',
'layout11',
'layout15',
'layout16',
'layout17'
]; // 右侧布局
const bottom1 = [
'layout1',
'layout6',
'layout8',
'layout9',
'layout10',
'layout16'
]; // 三行底部布局
const bottom2 = ['layout2', 'layout15']; // 四行底部布局
const w = `${offset}px`;
const l = `${_theme === 'ly-8' ? (menuState ? 643 : 510) : offset}px`;
let b = `0px`;
const le = ['layout17', 'layout10'].includes(layoutType) ? 0 : 1;
const leftList = getListByPosition(data, 'left');
const rightList = getListByPosition(data, 'right');
const bottomList = getListByPosition(data, 'bottom');
let bottom = '0';
if (_theme === 'ly-8') {
if (window.__mapMode === '3D') {
b = `200px`;
} else {
b = `${menuState ? 200 : 50}px`;
}
}
if (bottomList?.length > 0) {
if (bottom1.includes(layoutType)) {
bottom = 'calc((100% - 16px) / 3 + 8px)';
}
if (bottom2.includes(layoutType)) {
bottom = 'calc((100% - 24px) / 4 + 8px)';
}
} else {
// 没有底部布局时,底部高度为
bottom = '28px';
}
const rle = ['layout6'].includes(layoutType) || bottom != '28px' ? 0 : 1;
const leftHide = leftEle?.classList?.contains('hide');
const rightHide = rightEle?.classList?.contains('hide');
const bottomHide = bottomEle?.classList?.contains('hide');
if (legend) {
legend.style.left =
!leftHide && left.includes(layoutType) && leftList?.length > le ? l : b;
legend.style.bottom = bottomHide
? '0'
: bottomList?.length > 0
? bottom
: '12px';
}
if (filter) {
if (layoutType === 'layout10') {
filter.style.left =
!leftHide && left.includes(layoutType) && leftList?.length > 1 ? l : b;
} else {
filter.style.left =
!leftHide &&
left.includes(layoutType) &&
leftList?.length > 0 &&
layoutType !== 'layout17'
? l
: b;
}
}
if (compassControl) {
if (layoutType === 'layout10') {
compassControl.style.left =
!leftHide && left.includes(layoutType) && leftList?.length > 1 ? l : b;
} else {
compassControl.style.left =
!leftHide &&
left.includes(layoutType) &&
leftList?.length > 0 &&
layoutType !== 'layout17'
? l
: b;
}
}
if (controller) {
controller.style.right =
!rightHide && right.includes(layoutType) && rightList?.length > rle
? w
: '0';
controller.style.bottom = bottomHide ? '0' : bottom;
}
if (monitor) {
monitor.style.right =
!rightHide && right.includes(layoutType) && rightList?.length > rle
? w
: '0';
// monitor.style.bottom = bottomHide ? '0' : bottom
}
if (baselayer) {
baselayer.style.right =
!rightHide && right.includes(layoutType) && rightList?.length > rle
? `calc(${w} + 60px)`
: '60px';
baselayer.style.bottom = bottomHide ? '0' : bottom;
}
};
export const altitudeToZoom = (height: number) => {
const lv =
Math.round(D + (A - D) / (1 + Math.pow(Number(height) / C, B))) + 1;
return lv > -1 ? lv : 0;
};
/**
*
* @param {Number} zoom
*/
export const zoomToAltitude = (zoom: number) => {
return Math.round(C * Math.pow((A - D) / (zoom - D) - 1, 1 / B));
};
export const mapOutPut = (imageUrl: string) => {
const canvas = document.createElement('canvas');
const downloadElement = document.createElement('a');
const mapElem = document.getElementById('mapContainer');
if (mapElem == null) {
return;
}
const context = canvas.getContext('2d')!;
canvas.width = mapElem.offsetWidth;
canvas.height = mapElem.offsetHeight;
const image = new Image();
image.src = imageUrl;
image.onload = () => {
context.drawImage(image, 0, 0);
const elem: any = document.getElementById('qgc-legendtl');
const l = elem?.style?.left === '' ? 0 : parseInt(elem?.style?.left);
if (elem) {
domtoimage
.toPng(elem, {
quality: 1.0,
width: elem.offsetWidth + (l + 24),
height: mapElem.offsetHeight - 20
})
.then((legendUrl: string) => {
const image = new Image();
image.src = legendUrl;
image.width = elem.offsetWidth;
image.onload = () => {
context.drawImage(image, 0, 0);
downloadElement.href = canvas.toDataURL('image/png');
downloadElement.download = '地图截图';
downloadElement.click();
};
})
.catch(() => {});
} else {
downloadElement.href = canvas.toDataURL('image/png');
downloadElement.download = 'download';
downloadElement.click();
}
};
};

File diff suppressed because it is too large Load Diff

View File

@ -1,297 +0,0 @@
import type { layer, MapInterface } from './map.d';
// import { MapLeaflet } from "./map.leaflet";
import { MapOl } from './map.ol';
import { MapCesium } from './map.cesium';
interface MapClassInterface extends MapInterface {
layers: Map<string, layer>;
view: any;
}
//描点参数
export type MDOptions = {
isAllowOverlap?: boolean; // 是否允许重叠
labelType?: number; // 标签类型
labelminZoom?: number; // 标签最小缩放级别
labelAltitude?: object; // 标签海拔
isRemove?: boolean; // 是否移除标签
};
export const mapServerBaseUrl =
localStorage.getItem('gisurl') || 'http://210.72.227.199:18084/';
export class MapClass implements MapClassInterface {
layers: Map<string, layer>;
view: any;
private static instance: MapClass;
private service: MapInterface;
constructor() {
this.layers = new Map();
this.view = null;
// this.service = new MapLeaflet();
this.service = new MapOl();
}
static getInstance(): MapClass {
if (!this.instance) {
this.instance = new MapClass();
}
return this.instance;
}
// 地图初始化
init(container: HTMLElement, rectangle?: any): Promise<any> {
return this.service.init(container, rectangle).then(map => {
this.view = map;
return map;
});
}
// 基地面板控制
jdPanelControlShowAndHidden(baseid: string, isAll: boolean): void {
this.service.jdPanelControlShowAndHidden(baseid, isAll);
}
mdLayerShowOrHidden(
layerType: string,
key?: string,
baseid: string,
checked?: boolean,
isAll?: boolean
): void {
this.service.mdLayerShowOrHidden(layerType, key, baseid, checked, isAll);
}
// 添加基础数据图层
addBaseDataLayer(layer: any, isShow: boolean): void {
return this.service.addBaseDataLayer(layer, isShow);
}
// 基础图层显示影隐藏方法
controlBaseLayerTreeShowAndHidden(
layerType: string,
key: string,
checked: boolean
) {
this.service.controlBaseLayerTreeShowAndHidden(layerType, key, checked);
}
// 图层树控制描点数据显示隐藏方法
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean) {
this.service.mdLayerTreeShowOrHidden(layerType, checked);
}
// 根据图例控制锚点显示隐藏
setLegendPointVisible(
layerKey: string,
anchoPointState: string,
checked: boolean
): void {
this.service.setLegendPointVisible(layerKey, anchoPointState, checked);
}
// 初始化加载描点数据
addInitDataLayer = (pointData: any[], layerType: any, mdoptions?: any) => {
return this.service.addInitDataLayer(pointData, layerType, mdoptions);
};
//切换底图
baseLayerSwitcher(key: string, checked: boolean): void {
this.service.baseLayerSwitcher(key, checked);
}
// 添加梯级流域图
addTertiarybasinLayer(
layer: layer,
fillcolor: any,
outlineColor: any,
datas: any
): void {
this.service.addTertiarybasinLayer(layer, fillcolor, outlineColor, datas);
}
// 移除梯级流域图
hideTertiarybasinLayer(layer: layer): void {
this.service.hideTertiarybasinLayer?.(layer);
}
// 缩放
zoomToggle(type: 'out' | 'in') {
this.service.zoomToggle(type);
}
/**
*
*/
lengthCalculate(): void {
this.service.lengthCalculate();
}
/**
*
*/
areCalculate(): void {
this.service.areCalculate();
}
/**
*
*/
removeQueryLayer(): void {
this.service.removeQueryLayer();
}
// 地图打印
mapOutPut() {
this.service.mapOutPut();
}
// 检查图层是否存在
hasLayer(layerKey: string): boolean {
return this.service.hasLayer(layerKey);
}
// 检查 GIS/底图图层是否存在
hasBaseLayer(layerKey: string): boolean {
return this.service.hasBaseLayer(layerKey);
}
// 删除描点图层
removePointLayer(layerKey: string): void {
this.service.removePointLayer?.(layerKey);
}
// 销毁地图
destroy(): void {
this.service.destroy();
}
// 初始化弹窗
initPopupOverlay(popupContainer: HTMLDivElement): void {
this.service.initPopupOverlay(popupContainer);
}
// 切换地图视图
switchView(type: '2D' | '3D'): Promise<any> {
// this.service.switchView(type);
return new Promise((resolve, reject) => {
const container = document.getElementById('mapContainer');
if (!container) {
reject(new Error('Map container not found'));
return;
}
try {
// 1. 销毁当前地图服务
if (this.service) {
try {
this.service.destroy();
} catch (e) {
console.warn('Error destroying previous service:', e);
}
this.service = null;
}
this.view = null;
// 2. 根据类型初始化新的地图服务
if (type === '2D') {
this.service = new MapOl();
this.service
.init(container)
.then(map => {
this.view = map;
resolve(map);
})
.catch(err => reject(err));
} else if (type === '3D') {
this.service = new MapCesium();
this.service
.init(container)
.then(viewer => {
this.view = viewer;
resolve(viewer);
})
.catch(err => reject(err));
}
// const container = this.view._container;
// if (type === '3D' && this.view) {
// if (type === '2D') {
// const rectangle = this.view.camera.computeViewRectangle()
// const heading = this.view.camera.heading
// const height = this.view.camera.positionCartographic.height
// const zoom = altitudeToZoom(height) > 11 ? 11 : altitudeToZoom(height)
// const center = this.service?.getCenterPosition()
// const west = this.transformRadian(rectangle.west)
// const north = this.transformRadian(rectangle.north)
// const east = this.transformRadian(rectangle.east)
// const south = this.transformRadian(rectangle.south)
// const bearing = this.transformRadian(heading)
// this.service = new MapOl();
// this.init(document.getElementById('mapContainer'));
// // this.service
// .init(document.getElementById('mapContainer'))
// .then(map => {
// this.view = map;
// resolve(map);
// });
// } else if (type === '3D') {
// const data: {
// _southWest: { lat: number; lng: number }
// _northEast: { lat: number; lng: number }
// } = this.view.getBounds()
// const bearing = this.view.getBearing()
// const heading = this.transformAngle(bearing)
// //获取相机高度
// const altitude = zoomToAltitude(this.view.getZoom())
// //获取地图中心点
// let lng = 0.5 * (data._ne.lng + data._sw.lng)
// let lat = 0.5 * (data._ne.lat + data._sw.lat)
// let center = { lng, lat }
// this.destroy();
// this.service = null;
// this.view = null;
// this.service = new MapCesium()
// this.service.init(container, data, center, altitude, heading).then((viewer) => {
// this.view = viewer
// let removeCallback = viewer.scene.globe.tileLoadProgressEvent.addEventListener((e) => {
// if (e == 0) {
// removeCallback()
// removeCallback = null
// if (viewer && !viewer.isDestroyed()) {
// resolve(viewer)
// }
// }
// })
// //容错机制,放置地图服务挂掉或者响应非常慢
// setTimeout(() => {
// if (removeCallback) {
// removeCallback()
// removeCallback = null
// if (viewer && !viewer.isDestroyed()) {
// resolve(viewer)
// }
// }
// }, 5000)
// })
// }
} catch {
reject();
}
});
}
// 飞行到指定的点
fitBounds(): void {
// this.service.fitBounds(bounds)
}
// 飞行到指定的点
flyTopanto(position: number[], zoom: number): void {
this.service.flyTopanto(position, zoom);
}
getCurrentZoom(): number | undefined {
return this.service.getCurrentZoom();
}
// ==================== 倾斜摄影 ====================
addQxsyLayer(item: any): void {
this.service.addQxsyLayer?.(item);
}
removeQxsyLayer(item: any): void {
this.service.removeQxsyLayer?.(item);
}
qxsyChangeClick(item: any, checked: boolean): void {
this.service.qxsyChangeClick?.(item, checked);
}
qxsyToPosition(item: any): void {
this.service.qxsyToPosition?.(item);
}
}

View File

@ -1,245 +0,0 @@
import { MDOptions } from './map.class';
export type layerType =
| 'markers'
| 'tiledMap'
| 'tiledMapQuery'
| 'geoJson'
| 'arcgisFeature'
| 'dynamicMapLayer'
| 'arcgisMap'
| 'label'
| 'vector';
export type layerOption = {
opacity?: number;
data?: Array<any>;
zIndex?: number;
clickEvent?: Function;
hoverEvent?: Function;
legendImages?: Array<any> | null | undefined;
geoJsonLegend?: Array<any> | null | undefined;
tiledMapType?: undefined | 'superMap';
};
export interface layer {
id: string;
key: string;
_layer?: any;
type?: layerType;
url?: string;
url_3d?: string;
geojson_url: string;
label?: string;
thumbnail?: string;
visible?: boolean;
option?: layerOption;
tempobj?: any;
layers?: any;
rasteropacity?: any;
imgUrl?: any;
minZoom?: any;
maxZoom?: any;
minHeight?: number;
maxHeight?: number;
/** layer 类型 */
layerType?: string;
matrixIds_index?: string[];
tileMatrixSetID?: string;
urlType: string;
}
export interface MapInterface {
/**
*
* @param container DOM容器
* @return any
*/
init(
container: HTMLElement,
rectangle?: any,
center?: any,
altitude?: number,
bearing?: number
): Promise<any>;
/**
*
* @param pointData
* @param layerType
*/
addInitDataLayer(
pointData: any[],
layerType: any,
mdoptions: MDOptions
): void;
/**
*
* @param popupContainer
*/
initPopupOverlay(popupContainer: HTMLDivElement): void;
/**
*
* @param layer
*/
addBaseDataLayer(layer: any, isShow: boolean): void;
/**
*
* @param layer
*/
baseLayerSwitcher(key: string, checked: boolean): void;
/**
* 2D或者3D视图
* @param type '2D' | '3D'
*/
switchView(type: '2D' | '3D'): any | null;
/**
*
* out
* in
*/
zoomToggle(type: 'out' | 'in'): void;
/**
*
* @param layerKey key
* @returns
*/
hasLayer(layerKey: string): boolean;
/**
* GIS/
* @param layerKey key
* @returns
*/
hasBaseLayer(layerKey: string): boolean;
/**
*
*/
mapOutPut(): void;
//飞行到指定的点
fitBounds(bbox, bearing): void;
flyTopanto(positon, zoom): void;
/**
*
* 2D zoom3D
*/
getCurrentZoom(): number | undefined;
/**
*
*/
addQxsyLayer(item: any): void;
/**
*
*/
removeQxsyLayer(item: any): void;
/**
*
*/
qxsyChangeClick(item: any, checked: boolean): void;
/**
*
*/
qxsyToPosition(item: any): void;
/**
*
* @param baseid
* @param isAll
*/
jdPanelControlShowAndHidden(baseid: string, isAll: boolean): void;
/**
*
* @param layerType
* @param key
* @param checked
* @param isAll
*/
mdLayerShowOrHidden(
layerType: string,
key?: string,
baseid: string,
checked?: boolean,
isAll?: boolean
): void;
/**
*
* @param layerType
* @param checked
*/
controlBaseLayerTreeShowAndHidden(
layerType: string,
key: string,
checked: boolean
): void;
/**
*
* @param layerType
* @param checked
*/
mdLayerTreeShowOrHidden(layerType: string, checked?: boolean): void;
/**
*
* @param layerKey key
*/
removePointLayer(layerKey: string): void;
/**
* anchoPointState
* @param layerKey key
* @param anchoPointState nameEn
* @param checked
*/
setLegendPointVisible(
layerKey: string,
anchoPointState: string,
checked: boolean
): void;
/**
*
*/
lengthCalculate(): void;
/**
*
*/
areCalculate(): void;
/**
*
*/
removeQueryLayer(): void;
/**
*
* @param layer
* @param fillcolor
*/
addTertiarybasinLayer(
layer: layer,
fillcolor: any,
outlineColor: any,
datas: any
): void;
/**
*
* @param layer
*/
hideTertiarybasinLayer(layer: layer): void;
/**
*
*/
destroy(): void;
}

View File

@ -1,440 +0,0 @@
// import { MapInterface } from './map';
// import * as L from 'leaflet';
// import * as esriLeaflet from 'esri-leaflet';
// import '@/utils/leaflet/leaflet-tilelayer-wmts-src.js';
// import { mapServerBaseUrl } from './map.class';
// import '@/utils/leaflet/leaflet.inflatable-markers-group.js';
// import {MDOptions} from './map.class';
// import { getIconPath } from "@/utils/index";
// // @ts-ignore
// import axios from 'axios';
// // import "@/components/thematicMap/leaflet/leaflet.inflatable-markers-group.js"
// const tiledMapGroup = L.layerGroup();
// const chartMapGroup = L.layerGroup();
// const overlayGroup = L.layerGroup();
// const CENTER_positionCN: any = [38, 114.17112499999996]; //中心纬经度 中国
// const basinCenter = {
// DA_HHGLSX: [103.343357, 35.931812],
// FA_CJGLSX: [111.001911, 30.821327]
// };
// let boundCavansLayer: any = null;
// let ganliulist: any = [];
// export class MapLeaflet implements MapInterface {
// map: any = null;
// htmlMakerLayer: any = [];
// defaultScale = 10;
// minimumZoom = 7;
// setDrawPlug: any = null;
// layermarkers: any = [];
// rainlayerslist: any = [];
// private layerRegistry: Map<string, any> = new Map(); // ✅ 新增:存储 key -> layer 实例
// private currentBaseLayerKey: string | null = null; // ✅ 新增:记录当前激活的底图 Key
// temperatureMapObj: any = [];
// //地图初始化
// init(container: HTMLElement, rectangle?: any): Promise<any> {
// try {
// console.log('init初始化container', container);
// var corner1 = L.latLng(55.35715491537772, 140.7821677051657);
// var corner2 = L.latLng(0.975580441812298, 67.56008229483018);
// var bounds = L.latLngBounds(corner2, corner1);
// const map = L.map(container as any, {
// preferCanvas: true,
// zoom: 4.5,
// minZoom: 4.23,
// maxZoom: 22, // 【修改点1】增大最大缩放级别允许用户继续滚轮放大
// maxNativeZoom: 12,
// zoomSnap: 0.1, // 【修改点2】让缩放更平滑不强制对齐整数
// zoomDelta: 0.5,
// center: CENTER_positionCN,
// attributionControl: false,
// zoomControl: false,
// trackResize: true,
// maxBounds: bounds,
// maxBoundsViscosity: 1.0,
// wheelPxPerZoomLevel: 180,
// });
// // ✅ 新增:可视化显示 maxBounds 范围
// // L.rectangle(bounds, {color: "#ff7800", weight: 3, opacity: 0.8, fillOpacity: 0.1})
// // .addTo(map)
// // .bindPopup("这是 maxBounds 的范围");
// this.map = map;
// this.map.on('zoomend', (e: any) => {
// console.log('当前缩放级别', e.target.getZoom());
// });
// return Promise.resolve(map);
// } catch (e) {
// console.log('测试', e);
// return Promise.reject({});
// }
// }
// addBaseDataLayer(layer: any): any {
// // The WMTS URL
// console.log(layer);
// switch (layer.type) {
// case 'wmts':
// if (layer.url) {
// console.log('https://211.99.26.225:18085' + layer.url);
// if (layer) {
// var ignLayer = L.tileLayer
// .wmts('https://211.99.26.225:18085/geoserver/gwc/service/wmts', {
// tileMatrixSet: 'EPSG:3857_qgc_qsj_arcgistiles_l13',
// tileSize: 256, //切片大小
// maxZoom: 13,
// noWrap: true,
// opacity: 0.99,
// minZoom: 4,
// styles:{
// abc:123
// },
// layer: 'qgc_qsj_arcgistiles_l13'
// })
// .addTo(this.map)
// .bringToBack();
// const registryKey = layer.key || layer.title;
// if (registryKey) {
// this.layerRegistry.set(registryKey, ignLayer);
// }
// layer._layer = ignLayer;
// layer._layer.layerGroup = overlayGroup;
// }
// }
// return layer;
// case 'markers':
// this.getRain(layer);
// break;
// default: {
// return;
// }
// }
// }
// baseLayerSwitcher(key: string) {
// if (!this.map) return;
// console.log('切换底图 key:', key);
// // 1. 如果当前已有底图且不是同一个,先移除
// if (this.currentBaseLayerKey && this.currentBaseLayerKey !== key) {
// const oldLayer = this.layerRegistry.get(this.currentBaseLayerKey);
// if (oldLayer && this.map.hasLayer(oldLayer)) {
// this.map.removeLayer(oldLayer);
// console.log(`已移除旧底图: ${this.currentBaseLayerKey}`);
// }
// }
// // 2. 检查新底图是否已存在 registry 中
// let newLayer = this.layerRegistry.get(key);
// // 3. 如果不存在,则创建并添加到地图和 registry
// if (!newLayer) {
// newLayer = this.createBaseLayerByKey(key);
// if (newLayer) {
// this.layerRegistry.set(key, newLayer);
// newLayer.addTo(this.map);
// // 确保底图在最底层
// newLayer.bringToBack();
// }
// } else {
// // 4. 如果已存在,直接添加(如果未添加)
// if (!this.map.hasLayer(newLayer)) {
// newLayer.addTo(this.map);
// newLayer.bringToBack();
// }
// }
// // 5. 更新当前激活的 Key
// if (newLayer) {
// this.currentBaseLayerKey = key;
// }
// }
// /**
// * 根据 Key 创建具体的 Leaflet 图层实例
// */
// createBaseLayerByKey(key: string): L.Layer | null {
// console.log(key)
// const tdtToken = 'e90d56e5a09d1767899ad45846b0cefd' //企业版密钥勿换e650f138c4481cca888cd13094bb9026
// const mapType = 'w' //c:天地图经纬度底图;w:天地图墨卡托底图
// const URL_TerTDT = `https://t0.tianditu.gov.cn/ter_${mapType}/wmts?tk=${tdtToken}` //terMap
// switch (key) {
// case 's_province_boundaries':
// // 假设这是之前的 WMTS 矢量图层
// // 注意:原代码中硬编码了 URL 和 layer 名称,这里需要确保与实际服务对应
// return L.tileLayer.wmts('https://211.99.26.225:18085/geoserver/gwc/service/wmts', {
// tileMatrixSet: 'EPSG:3857_qgc_qsj_arcgistiles_l13',
// tileSize: 256,
// // maxZoom: 13,
// noWrap: true,
// opacity: 1,
// minZoom: 4,
// zIndex: 1,
// layer: 'qgc_qsj_arcgistiles_l13', // 请确认此 layer 名称是否对应“矢量”
// format: 'image/png'
// });
// case 'BASEMAP-white':
// // 地形图示例 (使用 OpenStreetMap 或其他地形服务作为占位,请替换为实际地址)
// return L.tileLayer(`${URL_TerTDT}&SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ter&STYLE=default&TILEMATRIXSET=${mapType}&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=tiles`, {
// // maxZoom: 17,
// format: "image/png",
// zIndex: 12,
// attribution: 'Map data: &copy; OpenStreetMap contributors, SRTM | Map style: &copy; OpenTopoMap (CC-BY-SA)'
// });
// case 'BASEMAP-img':
// // 影像图示例 (使用 Esri World Imagery 作为占位,请替换为实际地址)
// return L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
// attribution: 'Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community'
// });
// default:
// console.warn(`未知的底图 Key: ${key}`);
// return null;
// }
// }
// controlBaseLayerTreeShowAndHidden(layerType: String, key: String, checked: boolean) {
// // 优先使用 key 查找,如果没有 key 则尝试用 layerType
// // console.log(this.getAllLayers())
// console.log(key)
// console.log(this.layerRegistry)
// const registryKey:any = key || layerType;
// const layerInstance = this.layerRegistry.get(registryKey);
// if (layerInstance) {
// if (checked) {
// // 显示:如果不在地图上,则添加
// if (!this.map.hasLayer(layerInstance)) {
// layerInstance.addTo(this.map);
// }
// } else {
// // 隐藏:如果在地图上,则移除
// if (this.map.hasLayer(layerInstance)) {
// this.map.removeLayer(layerInstance);
// }
// }
// } else {
// console.warn(`未找到标识为 ${registryKey} 的图层实例`);
// }
// }
// /**
// * 添加初始化数据图层
// * @param pointLayerObj 图层配置对象 (包含 data, key, checked 等属性)
// * @param layerType 图层类型/Key (如果 pointLayerObj 中有 key则优先使用 obj.key)
// * @param mdoptions 描点选项配置
// * @param legendArray 图例映射数据 (用于根据 anchoPointState 匹配图标)
// */
// addInitDataLayer = (pointLayerObj: any, layerType?: any, mdoptions?: MDOptions, legendArray?: any) => {
// // 1. 参数校验与数据提取
// if (!this.map || !pointLayerObj) {
// return;
// }
// let dataArray: any[] = [];
// let targetLayerKey: string = layerType;
// if (Array.isArray(pointLayerObj)) {
// dataArray = pointLayerObj;
// } else {
// dataArray = pointLayerObj.data || [];
// targetLayerKey = pointLayerObj.key || layerType;
// }
// if (!targetLayerKey) {
// console.warn('缺少图层 Key无法加载描点');
// return;
// }
// if (dataArray.length === 0) {
// const existingGroup = this.layerRegistry.get(targetLayerKey);
// if (existingGroup && existingGroup instanceof L.LayerGroup) {
// existingGroup.clearLayers();
// }
// return;
// }
// console.log(`开始加载图层 [${targetLayerKey}] 的描点,数量: ${dataArray.length}`);
// // 2. 获取或创建该图层的 LayerGroup
// let layerGroup = this.layerRegistry.get(targetLayerKey);
// const shouldClear = mdoptions?.isRemove !== false;
// if (!layerGroup || !(layerGroup instanceof L.LayerGroup)) {
// if (layerGroup && this.map.hasLayer(layerGroup)) {
// this.map.removeLayer(layerGroup);
// }
// layerGroup = L.layerGroup();
// this.layerRegistry.set(targetLayerKey, layerGroup);
// layerGroup.addTo(this.map);
// } else {
// if (shouldClear) {
// layerGroup.clearLayers();
// }
// }
// // 3. 遍历数据生成 Marker
// dataArray.forEach((item: any) => {
// const { lgtd, lttd, stcd, stnm, iconCode, anchoPointState, _id, titleName, ennm } = item;
// if (lgtd == null || lttd == null) {
// return;
// }
// // 4. 确定图标样式和文字
// let iconUrl = '';
// let iconSize = [15, 15];
// // 获取图标 URL
// if (iconCode) {
// iconUrl = getIconPath(iconCode);
// } else if (anchoPointState && legendArray) {
// const legendItem = legendArray[anchoPointState];
// if (legendItem && legendItem.icon) {
// iconUrl = getIconPath(legendItem.icon);
// if (legendItem.width && legendItem.height) {
// iconSize = [legendItem.width, legendItem.height];
// }
// }
// }
// // 如果没有找到图标,使用默认或跳过
// if (!iconUrl) {
// // 可以选择使用默认图标或者继续
// iconUrl = getIconPath('default'); // 假设有一个默认图标
// if(!iconUrl) return;
// }
// // 5. 创建带文字的 DivIcon
// // 显示的文字优先使用 titleName其次 ennm最后 stnm
// const labelText = titleName || ennm || stnm || '';
// // 构建 HTML 结构:一个容器包含图片和文字
// // 注意:这里使用内联样式简单演示,建议在实际项目中提取到 CSS 类中
// const iconHtml = `
// <div style="position: relative; display: flex; flex-direction: column; align-items: center; width: max-content;">
// <div style="
// font: 10px sans-serif;
// font-weight: bold;
// color: #fff;
// white-space: nowrap;
// margin-bottom: 2px;
// -webkit-text-stroke: 2px #2e2d2d;
// paint-order: stroke fill; /* 确保描边在填充外部,防止文字变细 */
// ">
// ${labelText}
// </div>
// <img src="${iconUrl}" style="width: ${iconSize[0]}px; height: ${iconSize[1]}px; display: block;" />
// </div>`;
// const customIcon = L.divIcon({
// html: iconHtml,
// className: '', // 重要:设置为空字符串以避免 Leaflet 默认样式干扰
// iconSize: [iconSize[0], iconSize[1] + 20], // 高度增加以容纳文字 (假设文字高约20px)
// iconAnchor: [iconSize[0] / 2, iconSize[1] + 10], // 锚点设在图片底部中心
// popupAnchor: [0, -(iconSize[1] + 20)] // 弹窗锚点设在整体顶部
// });
// // 6. 创建 Marker
// const marker = L.marker([lttd, lgtd], {
// icon: customIcon,
// // @ts-ignore
// options: {
// ...item,
// layerKey: targetLayerKey,
// originalEvent: item
// }
// });
// // 7. 绑定点击事件 (如果需要)
// marker.on('click', (e: any) => {
// // 触发全局事件,例如:
// // GlobalEvents.get('map_dataLayer_click').set(item);
// console.log('Marker clicked:', item);
// });
// // 8. 将 Marker 添加到 LayerGroup
// layerGroup.addLayer(marker);
// });
// console.log(`图层 [${targetLayerKey}] 描点加载完成`);
// }
// getRain(data: any) {
// if (data?.geojson) {
// this.removeRainLayer();
// this.removeLayermarkers();
// const renderColor = (item: any, colRules: any) => {
// let color = 'rgba(0,0,0,0)';
// colRules.map((rule: any, index: number) => {
// if (
// item.properties.hvalue >= rule?.sv &&
// item.properties.hvalue < rule?.ev
// ) {
// color = rule.colors;
// }
// if (index === colRules.length - 1) {
// if (item.properties.hvalue >= rule?.ev) {
// color = rule.colors;
// }
// }
// });
// return color;
// };
// data.geojson.features.map((item: any, index: number) => {
// let pushlist = L.geoJSON(item, {
// style: {
// color: renderColor(item, data.colRules),
// weight: 2,
// opacity: 0.8,
// fillOpacity: 0.7
// // fillColor: "#1D91C0",
// }
// }).addTo(this.map);
// this.layermarkers.push(pushlist);
// });
// }
// }
// removeLayermarkers = () => {
// // console.log('layermarkers',layermarkers);
// try {
// if (this.layermarkers.length > 0) {
// if (this.map.hasLayer(this.layermarkers)) {
// this.map.removeLayer(this.layermarkers);
// } else {
// console.log('移除图层失败,应已经移除');
// }
// }
// // removeAllGeojson(map);
// } catch (e) {
// // message.info('移除图层失败')
// console.log('移除图层失败,是否应移除');
// }
// };
// removeRainLayer(): void {
// console.log('test!删除Layer');
// const _this = this;
// if (this.rainlayerslist.length > 0) {
// this.rainlayerslist.map((item: any) => {
// _this.map.removeLayer(item);
// });
// this.rainlayerslist = [];
// }
// } // 缩放
// zoomToggle(type: 'out' | 'in') {
// if (this.map) {
// if (type === 'out') {
// this.map && this.map.zoomOut();
// } else {
// this.map && this.map.zoomIn();
// }
// }
// }
// }

File diff suppressed because it is too large Load Diff

View File

@ -1,759 +0,0 @@
/**
* @author: yjw
* @date: 2022-11-17
* @description:
*/
// import { MemoryCache } from '@zebras/qgc-share/cache/MemoryCache'
// import { Session } from '@zebras/qgc-share/service/Session'
export interface MapBaseUrlItem {
code: string;
name: string;
url: string;
}
export interface ServerConfigItem {
id?: string;
key?: string;
urlType: 'gisurl' | 'online';
url?: string;
url_3d?: string;
layers?: string;
rasteropacity?: number;
_layer?: string;
imgUrl?: string;
visible?: boolean;
visibility?: boolean; //?是否可以删除
minZoom?: number;
maxZoom?: number;
fillOpacity?: number;
lineWidth?: number;
[name: string]: any;
}
const tdtToken = 'e90d56e5a09d1767899ad45846b0cefd'; //企业版密钥勿换e650f138c4481cca888cd13094bb9026
const mapType = 'w'; //c:天地图经纬度底图;w:天地图墨卡托底图
const URL_TerTDT = `https://t0.tianditu.gov.cn/ter_${mapType}/wmts?tk=${tdtToken}`; //terMap
// const URL_TerLabelTDT = `http://t0.tianditu.gov.cn/cta_${mapType}/wmts?tk=${tdtToken}` //terLabel
// const URL_VecTDT = `http://t0.tianditu.com/vec_${mapType}/wmts?tk=${tdtToken}` //vecMap
// const URL_VecLableTDT = `http://t0.tianditu.com/cva_${mapType}/wmts?tk=${tdtToken}` //vecLabel
// const URL_ImgTDT = `http://t0.tianditu.com/img_${mapType}/wmts?tk=${tdtToken}` //imgMap
// const URL_ImgLableTDT = `http://t0.tianditu.com/cia_${mapType}/wmts?tk=${tdtToken}`
// const znyMapUrl = "http://10.219.26.6:8050/" //中南院的测试地图环境
// const djjtMapUrl = "http://210.72.227.199:18084/" //中南院的测试地图环境
//TODO以后改成在系统管理里面配置地图可视化范围
export const location_map_maxlon = 126.8267;
export const location_map_maxlat = 38.5769;
export const location_map_minlon = 73.7443;
export const location_map_minlat = 16.32;
// todo: 以后从接口获取数据
export const servers: Record<string, ServerConfigItem> | Record<string, any> = {
// 省(自治区)界
province_boundaries: {
id: 'province_boundaries',
key: 'province_boundaries',
urlType: 'gisurl',
// url: this.gisServers.gisurl + '/geoserver/qgc/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&STYLES&LAYERS=qgc%3Aprovinceline&exceptions=application%2Fvnd.ogc.se_inimage&SRS=EPSG%3A3857&WIDTH=768&HEIGHT=620&bbox={bbox-epsg-3857}',
url: '/geoserver/qgc/wms?service=WMS&version=1.1.0&request=GetMap&layers=qgc:provinceline&styles=&bbox={bbox-epsg-3857}&width=768&height=620&srs=EPSG:3857&format=image%2Fpng&TRANSPARENT=true',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:provinceline&outputFormat=application%2Fjson&token=
`,
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:provinceline',
rasteropacity: 1,
minZoom: 0,
maxZoom: 21,
type: 'wms',
layerType: 'line',
lineWidth: 2,
visible: true
},
//国界
chinaBoundary: {
id: 'chinaBoundary',
key: 'chinaBoundary',
urlType: 'gisurl',
url: '/geoserver/gwc/service/tms/1.0.0/qgc%3Achina@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:china&maxFeatures=50&outputFormat=application%2Fjson&token=
`,
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:china',
_layer: 'china',
layerType: 'line',
minZoom: 0,
maxZoom: 16,
minHeight: 0,
maxHeight: 80000000,
rasteropacity: 1,
type: 'vector_Boundary',
paint: {
'line-color': '#AAA4C5',
'line-width': 3,
'line-opacity': 0.9
}
},
//九段线
nineSegmentLine: {
id: 'nineSegmentLine',
key: 'nineSegmentLine',
urlType: 'gisurl',
url: '/geoserver/gwc/service/tms/1.0.0/qgc%3Azh_island1@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
url_3d: '/geoserver/qgc/wms',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:zh_island1&maxFeatures=50&outputFormat=application%2Fjson&token=
`,
layers: 'qgc:zh_island1',
_layer: 'zh_island1',
layerType: 'line',
minZoom: 0,
maxZoom: 16,
minHeight: 0,
maxHeight: 80000000,
rasteropacity: 1,
type: 'vector_Boundary',
paint: {
'line-color': '#AAA4C5',
'line-width': 3,
'line-opacity': 0.9
}
},
// 基础图层
BaseLayer: {
'BASEMAP-img': {
key: 'BASEMAP-img',
type: 'raster-dem',
urlType: 'online',
url:
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' +
`&token=`,
url_3d:
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' +
`&token=`,
_layer: 'img',
visible: false,
maxzoom: 20
},
'BASEMAP-white': {
key: 'BASEMAP-white',
urlType: 'online',
type: 'raster-dem',
url: `${URL_TerTDT}&SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ter&STYLE=default&TILEMATRIXSET=${mapType}&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=tiles&token=
`,
url_3d: URL_TerTDT + `&token=`,
_layer: 'ter',
visible: false,
maxzoom: 14
}
},
// 区|县
county: {
id: 'county',
key: 'county',
urlType: 'gisurl',
url: '/geoserver/gwc/service/tms/1.0.0/qgc%3Acounty@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:county&outputFormat=application%2Fjson&token=`,
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:county',
_layer: 'county',
rasteropacity: 0.5,
imgUrl: '/zfile/qgc/icons/map-xzXian.png',
minZoom: 8,
maxZoom: 16,
minHeight: 0,
maxHeight: 800000,
type: 'vector',
layerType: 'symbol',
visible: true
},
//县注记
county_lable: {
id: 'county_lable',
key: 'county_lable',
urlType: 'gisurl',
// url: this.gisServers.gisurl + '/geoserver/qgc/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&STYLES&LAYERS=qgc%3Aprovinceline&exceptions=application%2Fvnd.ogc.se_inimage&SRS=EPSG%3A3857&WIDTH=768&HEIGHT=620&bbox={bbox-epsg-3857}',
url: '/geoserver/qgc/wms?service=WMS&version=1.1.0&request=GetMap&layers=qgc:countyAno&styles=&bbox={bbox-epsg-3857}&width=768&height=620&srs=EPSG:3857&format=image%2Fpng&TRANSPARENT=true',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:countyAno&outputFormat=application%2Fjso&token=
`,
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:countyAno',
_layer: 'countyAno',
rasteropacity: 1,
minZoom: 8,
maxZoom: 16,
minHeight: 0,
maxHeight: 800000,
type: 'wms',
layerType: 'symbol',
visible: true
},
// 市
city: {
id: 'city',
key: 'city',
urlType: 'gisurl',
url: '/geoserver/gwc/service/tms/1.0.0/qgc%3Acity@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:city&outputFormat=application%2Fjson&token=`,
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:city',
_layer: 'city',
rasteropacity: 0.5,
imgUrl: '/zfile/qgc/icons/map-xzShi.png',
minZoom: 5,
maxZoom: 8,
minHeight: 800000,
maxHeight: 1200000,
type: 'vector',
layerType: 'symbol',
visible: false
},
//市注记
city_lable: {
id: 'city_lable',
key: 'city_lable',
urlType: 'gisurl',
// url: this.gisServers.gisurl + '/geoserver/qgc/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&STYLES&LAYERS=qgc%3Aprovinceline&exceptions=application%2Fvnd.ogc.se_inimage&SRS=EPSG%3A3857&WIDTH=768&HEIGHT=620&bbox={bbox-epsg-3857}',
url: '/geoserver/qgc/wms?service=WMS&version=1.1.0&request=GetMap&layers=qgc:cityAno&styles=&bbox={bbox-epsg-3857}&width=768&height=620&srs=EPSG:3857&format=image%2Fpng&TRANSPARENT=true',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:cityAno&outputFormat=application%2Fjson&token=
`,
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:cityAno',
_layer: 'cityAno',
rasteropacity: 1,
minZoom: 5,
maxZoom: 8,
minHeight: 800000,
maxHeight: 1200000,
type: 'wms',
layerType: 'symbol',
visible: true
},
// 省
provincial_capital: {
id: 'provincial_capital',
key: 'provincial_capital',
urlType: 'gisurl',
url: '/geoserver/gwc/service/tms/1.0.0/qgc%3Aprovincecity@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:provincecity&outputFormat=application%2Fjson&token=
`,
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:provincecity',
_layer: 'provincecity',
rasteropacity: 0.5,
imgUrl: '/zfile/qgc/icons/map-xzSheng.png',
visible: true,
minZoom: 0,
maxZoom: 5,
minHeight: 1200000,
maxHeight: 99999999999,
type: 'vector',
layerType: 'symbol'
},
//省注记
province_lable: {
id: 'province_lable',
key: 'province_lable',
urlType: 'gisurl',
// url: this.gisServers.gisurl + '/geoserver/qgc/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&STYLES&LAYERS=qgc%3Aprovinceline&exceptions=application%2Fvnd.ogc.se_inimage&SRS=EPSG%3A3857&WIDTH=768&HEIGHT=620&bbox={bbox-epsg-3857}',
url: '/geoserver/qgc/wms?service=WMS&version=1.1.0&request=GetMap&layers=qgc:provincecityAno&styles=&bbox={bbox-epsg-3857}&width=768&height=620&srs=EPSG:3857&format=image%2Fpng&TRANSPARENT=true',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:provincecityAno&outputFormat=application%2Fjson&token=
`,
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:provincecityAno',
_layer: 'provinceAno',
rasteropacity: 0.5,
minZoom: 0,
maxZoom: 5,
minHeight: 1200000,
maxHeight: 99999999999,
type: 'wms',
layerType: 'symbol',
visible: true
},
baseImageLayerUrl: {
id: 'BaseLayer',
key: 'BaseLayer',
urlType: 'online',
url: 'http://t{s}.tianditu.gov.cn/img_w/wmts?tk=88735d6d9bbd5930f1b1c8b8df72e762&SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TileMatrix={z}&TileCol={x}&TileRow={y}'
},
WhiteBaseLayerUrl: {
id: 'BaseLayer',
key: 'BaseLayer',
urlType: 'online',
url: `https://t0.tianditu.gov.cn/ter_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ter&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=e6372a5333c4bac9b9ef6097453c3cd6&token=
`
},
/**
*
*/
hydropBase: {
id: 'hydropBase',
key: 'hydropBase',
urlType: 'gisurl',
url: '/geoserver/gwc/service/tms/1.0.0/qgc%3AstationEra1117@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:stationEra1117&maxFeatures=50&outputFormat=application/json&token=
`,
url_3d: '/geoserver/qgc/wms',
_layer: 'stationEra1117',
layers: 'qgc:stationEra1117',
rasteropacity: 0.5,
visible: true,
minZoom: 0,
maxZoom: 20,
type: 'vector',
layerType: 'fill',
paint: {
'fill-color': '#f0dcda',
'fill-opacity': 0.8,
'fill-outline-color': '#dd7065'
}
},
/**
*
*/
powerBaseStationLayerWFSUrl: {
id: 's_hydropBase_wfs',
key: 's_hydropBase_wfs',
urlType: 'gisurl',
url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:stationEra1117&maxFeatures=50&outputFormat=application/json&token=
`,
_layer: 'stationEra1117',
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:stationEra1117',
rasteropacity: 0.5,
visible: true,
minZoom: 0,
maxZoom: 20
},
/**
* LM项目
*/
powerBaseStationLayerWFSUrlOfLM: {
id: 's_hydropBase_wfs',
key: 's_hydropBase_wfs',
urlType: 'gisurl',
url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:stationEraLM&maxFeatures=50&outputFormat=application/json&token=
`,
_layer: 'stationEraLM',
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:stationEraLM',
rasteropacity: 0.5,
visible: true,
minZoom: 0,
maxZoom: 20
},
/**
*
*/
YLJ_LCJ_LayerWFSUrl: {
id: 'river_riverEvaTemp',
key: 'river_riverEvaTemp',
urlType: 'gisurl',
url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:ylj_lcj&maxFeatures=50&outputFormat=application%2Fjson&token=
`,
_layer: 'ylj_lcj',
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:ylj_lcj',
rasteropacity: 0.5,
visible: true,
minZoom: 0,
maxZoom: 20
},
/**
* 线
*/
LCJ_WaterQuality_line: {
id: 'LCJ_WaterQuality_line',
key: 'LCJ_WaterQuality_line',
urlType: 'gisurl',
url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:lcj_waterquality_line&maxFeatures=50&outputFormat=application%2Fjson&token=
`,
_layer: 'lcj_waterquality_line',
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:lcj_waterquality_line',
rasteropacity: 0.5,
visible: true,
minZoom: 0,
maxZoom: 20
},
/**
*
*/
NationalTownships: {
id: 'NationalTownships',
key: 'NationalTownships',
layerType: 'symbol',
isShowLabel: true,
urlType: 'gisurl',
url: `/geoserver/gwc/service/tms/1.0.0/qgc%3ANationalTownships@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf?token=`,
_layer: 'NationalTownships',
layers: 'qgc:NationalTownships',
visible: true,
minZoom: 10.5,
maxZoom: 18,
isOnlyAno: true,
type: 'vector',
layout_text: {
'text-field': ['get', 'NAME'],
'text-font': ['Open Sans Bold'],
'text-size': 16,
'text-anchor': 'top',
'text-padding': 0,
'text-max-width': 100,
'text-ignore-placement': false,
'text-allow-overlap': false,
visibility: 'visible'
},
paint_text: {
'text-color': '#404040',
'text-halo-color': '#fff',
'text-halo-width': 2
}
},
/**
*
*/
heliu1: {
id: 'heliu1',
key: 'heliu1',
urlType: 'gisurl',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:river1_line&maxFeatures=50&outputFormat=application/json&token=
`,
url: '/geoserver/gwc/service/tms/1.0.0/qgc%3Ariver1_line@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
url_3d: '/geoserver/qgc/wms',
_layer: 'river1_line',
layers: 'qgc:river1_line',
rasteropacity: 0.5,
visible: true,
minZoom: 0,
maxZoom: 18,
minHeight: 0,
maxHeight: 99999999999,
type: 'vector',
layerType: 'line',
paint: {
'line-color': '#00A1E9',
'line-width': 4,
'line-opacity': 0.9
}
},
//一级河流注记
heliu1_lable: {
id: 'heliu1_lable',
key: 'heliu1_lable',
urlType: 'gisurl',
// url: this.gisServers.gisurl + '/geoserver/qgc/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&STYLES&LAYERS=qgc%3Aprovinceline&exceptions=application%2Fvnd.ogc.se_inimage&SRS=EPSG%3A3857&WIDTH=768&HEIGHT=620&bbox={bbox-epsg-3857}',
url: '/geoserver/qgc/wms?service=WMS&version=1.1.0&request=GetMap&layers=qgc:river1_lineAno&styles=&bbox={bbox-epsg-3857}&width=768&height=620&srs=EPSG:3857&format=image%2Fpng&TRANSPARENT=true',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:river1_lineAno&outputFormat=application%2Fjson&token=
`,
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:river1_lineAno',
_layer: 'river1_lineAno',
rasteropacity: 1,
minZoom: 0,
maxZoom: 18,
minHeight: 0,
maxHeight: 99999999999,
type: 'wms',
layerType: 'symbol',
visible: true
},
/**
*
*/
heliu2: {
id: 'heliu2',
key: 'heliu2',
urlType: 'gisurl',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:river2_line&maxFeatures=50&outputFormat=application/json&token=
`,
url: '/geoserver/gwc/service/tms/1.0.0/qgc%3Ariver2_line@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
url_3d: '/geoserver/qgc/wms',
_layer: 'river2_line',
layers: 'qgc:river2_line',
lineWidth: 2,
rasteropacity: 0.5,
visible: false,
minZoom: 4,
maxZoom: 18,
minHeight: 0,
maxHeight: 1200000,
type: 'vector',
layerType: 'line',
paint: {
'line-color': '#00A1E9',
'line-width': 3,
'line-opacity': 0.9
}
},
//二级河流注记
heliu2_lable: {
id: 'heliu2_lable',
key: 'heliu2_lable',
urlType: 'gisurl',
// url: this.gisServers.gisurl + '/geoserver/qgc/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&STYLES&LAYERS=qgc%3Aprovinceline&exceptions=application%2Fvnd.ogc.se_inimage&SRS=EPSG%3A3857&WIDTH=768&HEIGHT=620&bbox={bbox-epsg-3857}',
url: '/geoserver/qgc/wms?service=WMS&version=1.1.0&request=GetMap&layers=qgc:river2_lineAno&styles=&bbox={bbox-epsg-3857}&width=768&height=620&srs=EPSG:3857&format=image%2Fpng&TRANSPARENT=true',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:river2_lineAno&outputFormat=application%2Fjson&token=
`,
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:river2_lineAno',
_layer: 'river2_lineAno',
rasteropacity: 1,
minZoom: 4,
maxZoom: 18,
minHeight: 0,
maxHeight: 1200000,
type: 'wms',
layerType: 'symbol',
visible: true
},
/**
*
*/
heliu3: {
id: 'heliu3',
key: 'heliu3',
urlType: 'gisurl',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:river3_line&maxFeatures=50&outputFormat=application/json&token=
`,
url: '/geoserver/gwc/service/tms/1.0.0/qgc%3Ariver3_line@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
url_3d: '/geoserver/qgc/wms',
_layer: 'river3_line',
layers: 'qgc:river3_line',
lineWidth: 1,
rasteropacity: 0.5,
visible: false,
minZoom: 6,
maxZoom: 18,
minHeight: 0,
maxHeight: 800000,
type: 'vector',
layerType: 'line',
paint: {
'line-color': '#00A1E9',
'line-width': 2,
'line-opacity': 0.9
}
},
//三级河流注记
heliu3_lable: {
id: 'heliu3_lable',
key: 'heliu3_lable',
urlType: 'gisurl',
// url: this.gisServers.gisurl + '/geoserver/qgc/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&STYLES&LAYERS=qgc%3Aprovinceline&exceptions=application%2Fvnd.ogc.se_inimage&SRS=EPSG%3A3857&WIDTH=768&HEIGHT=620&bbox={bbox-epsg-3857}',
url: '/geoserver/qgc/wms?service=WMS&version=1.1.0&request=GetMap&layers=qgc:river3_lineAno&styles=&bbox={bbox-epsg-3857}&width=768&height=620&srs=EPSG:3857&format=image%2Fpng&TRANSPARENT=true',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:river3_lineAno&outputFormat=application%2Fjson&token=
`,
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:river3_lineAno',
_layer: 'river3_lineAno',
rasteropacity: 1,
minZoom: 6,
maxZoom: 18,
minHeight: 0,
maxHeight: 800000,
type: 'wms',
layerType: 'symbol',
visible: true
},
/**
*
*/
heliu4: {
id: 'heliu4',
key: 'heliu4',
urlType: 'gisurl',
url: '/geoserver/gwc/service/tms/1.0.0/qgc%3Ariver4_line@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
url_3d: '/geoserver/qgc/wms',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:river4_line&maxFeatures=8000&outputFormat=application%2Fjson&token=
`,
_layer: 'river4_line',
layers: 'qgc:river4_line',
visible: false,
minZoom: 8,
maxZoom: 18,
minHeight: 0,
maxHeight: 800000,
rasteropacity: 1,
type: 'vector',
layerType: 'line',
paint: {
'line-color': '#00A1E9',
'line-width': 1,
'line-opacity': 0.9
}
},
//四级河流注记
heliu4_lable: {
id: 'heliu4_lable',
key: 'heliu4_lable',
urlType: 'gisurl',
// url: this.gisServers.gisurl + '/geoserver/qgc/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&STYLES&LAYERS=qgc%3Aprovinceline&exceptions=application%2Fvnd.ogc.se_inimage&SRS=EPSG%3A3857&WIDTH=768&HEIGHT=620&bbox={bbox-epsg-3857}',
url: '/geoserver/qgc/wms?service=WMS&version=1.1.0&request=GetMap&layers=qgc:river4_lineAno&styles=&bbox={bbox-epsg-3857}&width=768&height=620&srs=EPSG:3857&format=image%2Fpng&TRANSPARENT=true',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:river4_lineAno&outputFormat=application%2Fjson&token=
`,
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:river4_lineAno',
_layer: 'river4_lineAno',
rasteropacity: 1,
minZoom: 8,
maxZoom: 18,
minHeight: 0,
maxHeight: 800000,
type: 'wms',
layerType: 'symbol',
visible: true
},
/**
* -
*/
heliu5: {
id: 'heliu5',
key: 'heliu5',
urlType: 'gisurl',
url: '/geoserver/gwc/service/tms/1.0.0/qgc%3Ariver5_line@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
url_3d: '/geoserver/qgc/wms',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:river5_line&maxFeatures=8000&outputFormat=application%2Fjson&token=
`,
_layer: 'river5_line',
layers: 'qgc:river5_line',
visible: false,
minZoom: 8,
maxZoom: 18,
minHeight: 0,
maxHeight: 800000,
rasteropacity: 1,
type: 'vector',
layerType: 'line',
paint: {
'line-color': '#00A1E9',
'line-width': 1,
'line-opacity': 0.9
}
},
//五级河流注记
heliu5_lable: {
id: 'heliu5_lable',
key: 'heliu5_lable',
urlType: 'gisurl',
// url: this.gisServers.gisurl + '/geoserver/qgc/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&FORMAT=image%2Fpng&TRANSPARENT=true&STYLES&LAYERS=qgc%3Aprovinceline&exceptions=application%2Fvnd.ogc.se_inimage&SRS=EPSG%3A3857&WIDTH=768&HEIGHT=620&bbox={bbox-epsg-3857}',
url: '/geoserver/qgc/wms?service=WMS&version=1.1.0&request=GetMap&layers=qgc:river5_lineAno&styles=&bbox={bbox-epsg-3857}&width=768&height=620&srs=EPSG:3857&format=image%2Fpng&TRANSPARENT=true',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:river5_lineAno&outputFormat=application%2Fjson&token=
`,
url_3d: '/geoserver/qgc/wms',
layers: 'qgc:river5_lineAno',
_layer: 'river5_lineAno',
rasteropacity: 1,
minZoom: 8,
maxZoom: 18,
minHeight: 0,
maxHeight: 800000,
type: 'wms',
layerType: 'symbol',
visible: true
},
//流域梯级图
Tertiarybasin: {
id: 'Tertiarybasin',
key: 'Tertiarybasin',
urlType: 'gisurl',
url: '/geoserver/gwc/service/tms/1.0.0/qgc%3Anew_LiuYuThrLev@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
url_3d: '/geoserver/qgc/wms',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:new_LiuYuThrLev&outputFormat=application%2Fjson&token=
`,
_layer: 'new_LiuYuThrLev',
layers: 'qgc:new_LiuYuThrLev',
visible: true,
minZoom: 1,
maxZoom: 18,
minHeight: 0,
maxHeight: 800000,
rasteropacity: 0.8,
type: 'vector',
layerType: 'fill'
},
// //鱼类栖息地
// fishQxd: {
// id: 'fishQxd',
// key: 'fishQxd',
// urlType: 'gisurl',
// url: '/geoserver/gwc/service/tms/1.0.0/qgc%3Afish_qxd@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
// url_3d: '/geoserver/qgc/wms',
// geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:fish_qxd&outputFormat=application%2Fjson&token=
// `,
// _layer: 'fish_qxd',
// layers: 'qgc:fish_qxd',
// visible: true,
// minZoom: 0,
// maxZoom: 18,
// minHeight: 0,
// maxHeight: 80000000,
// rasteropacity: 0.9,
// type: 'vector',
// layerType: 'line',
// lineWidth: 2,
// paint: {
// 'line-color': '#A21C83',
// 'line-width': 4,
// 'line-opacity': 1
// }
// },
/**
*
*/
WaterFunctionPartition: {
id: 'WaterFunctionPartition',
key: 'WaterFunctionPartition',
urlType: 'gisurl',
url: '/geoserver/gwc/service/tms/1.0.0/qgc%3AGNQ@EPSG%3A900913@pbf/{z}/{x}/{y}.pbf',
url_3d: '/geoserver/qgc/wms',
geojson_url: `/geoserver/qgc/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=qgc:GNQ&outputFormat=application%2Fjson&token=`,
_layer: 'GNQ',
layers: 'qgc:GNQ',
visible: true,
minZoom: 0,
maxZoom: 18,
minHeight: 0,
maxHeight: 8000000,
rasteropacity: 0.9,
type: 'vector',
layerType: 'line',
lineWidth: 2,
paint: {
'line-color': '#A21C83',
'line-width': 3,
'line-opacity': 0
}
}
};
/**
* Gis图层配置
* @param key gis服务key
* @returns
*/
// export const getConfig = (key: string) => {
// const mapBaseUrls: Record<string, MapBaseUrlItem> = MemoryCache.get('mapBaseUrls') || {}
// if (Object.prototype.hasOwnProperty.call(servers, key)) {
// const r = { ...servers[key] }
// const { urlType } = r
// const baseUrlObj = mapBaseUrls[urlType as string]
// if (baseUrlObj) {
// r.url = baseUrlObj.url + r.url
// r.url_3d = baseUrlObj.url + r.url_3d
// if (r.geojson_url) {
// r.geojson_url = baseUrlObj.url + r.geojson_url
// }
// }
// return r
// }
// console.warn(`The gis config which key is ${key} not exist!`)
// return null
// }

View File

@ -1,391 +0,0 @@
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;
}
// 备注:获取当前屏幕可视区域内的所有点要素(仅包含图层可见且图例可见的要素)。
getFeaturesInViewport(): Feature[] {
if (!this.map) return [];
const extent = this.map.getView().calculateExtent(this.map.getSize());
if (!extent) return [];
const [minX, minY, maxX, maxY] = extent;
const result: Feature[] = [];
this.forEachFeature((feature, layer) => {
// 跳过图层本身不可见的要素
if (!layer.getVisible()) return;
const geom = feature.getGeometry();
if (!geom || geom.getType() !== 'Point') return;
const coords = (geom as Point).getCoordinates();
const [x, y] = coords;
if (x >= minX && x <= maxX && y >= minY && y <= maxY) {
result.push(feature);
}
});
return result;
}
// 备注:统一返回当前点图层注册表,供外层做遍历或清理。
getRegistry() {
return this.layerRegistry;
}
// 备注:按图层 key 获取已注册的点图层实例。
getLayer(layerKey: string) {
return this.layerRegistry.get(layerKey);
}
// 备注:返回当前所有点图层实例,供命中检测和批量刷新使用。
getLayers(): VectorLayer<VectorSource>[] {
return Array.from(this.layerRegistry.values());
}
// 备注:按当前缩放级别统一刷新近邻点展开坐标,保证抽吸后的点击和 popup 与视觉位置一致。
updateNearbyFeatureLayout(currentZoom?: number): void {
if (!this.map) return;
if (currentZoom === undefined || !Number.isFinite(currentZoom)) {
return;
}
this.forEachFeature(feature => {
this.resetFeatureToBaseCoordinate(feature);
});
}
// 备注:判断指定图层 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);
vectorLayer.changed();
}
}
// 备注:按图层 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 = '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
});
const rawFeatureId =
item.stcd || item._id || `${lon.toFixed(6)},${lat.toFixed(6)}`;
feature.setId(`${targetLayerKey}:${rawFeatureId}`);
feature.set('_iconUrl', iconUrl);
feature.set(
'_labelText',
item.sttpMap === 'ylfb' ? item.ftp || '' : titleName || stnm || ennm || ''
);
feature.set('_baseCoordinates', coord);
feature.set('_legendVisible', true);
feature.set('_regionVisible', 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)) || [];
}
// 备注:先恢复要素原始坐标,再按缩放阶段决定是否做展开偏移,避免多次缩放后偏移累计。
private resetFeatureToBaseCoordinate(feature: Feature): void {
const baseCoordinates = feature.get('_baseCoordinates') as
| number[]
| undefined;
const geometry = feature.getGeometry();
if (
!baseCoordinates ||
baseCoordinates.length < 2 ||
!geometry ||
geometry.getType() !== 'Point'
) {
return;
}
(geometry as Point).setCoordinates([
baseCoordinates[0],
baseCoordinates[1]
]);
}
}

View File

@ -1,530 +0,0 @@
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,
shouldPreferEng2Popup
} 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 batchPopupContainer: HTMLDivElement | null = null;
private batchPopupItems: Array<{
feature: Feature;
element: HTMLDivElement;
}> = [];
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.display = 'none';
this.popupElement.style.removeProperty('position');
this.popupElement.style.removeProperty('transform');
this.popupElement.style.removeProperty('left');
this.popupElement.style.removeProperty('top');
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 = this.getPopupHtml(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';
}
private getPopupHtml(props: Record<string, any>) {
return props.popupHtml || generatePopupHtml(props);
}
// 备注:批量显示多个要素的 Popup使用绝对定位的 div 而非 Overlay。
showPopupsForFeatures(
features: Feature[],
styleChecker?: (feature: Feature) => boolean,
options?: {
rebuild?: boolean;
}
) {
if (!this.map || !this.popupElement) return;
const shouldRebuild = options?.rebuild !== false;
// 过滤:只显示图例可见的要素
const visibleFeatures = features.filter(
f => f.get('_legendVisible') !== false
);
if (visibleFeatures.length === 0) {
this.clearBatchPopups();
return;
}
if (!shouldRebuild && this.batchPopupItems.length > 0) {
this.updateBatchPopupPositions(styleChecker);
return;
}
this.clearBatchPopups();
const mapElement = this.map.getTargetElement();
const batchContainer = document.createElement('div');
batchContainer.className = 'batch-popup-container';
batchContainer.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1000;
`;
batchContainer.id = 'batch-popup-container';
this.batchPopupContainer = batchContainer;
// 先添加容器到 DOM以便后续元素能正确测量尺寸
mapElement.appendChild(batchContainer);
// 用于碰撞检测的已放置 popup 列表
const placedPopups: Array<{
left: number;
right: number;
top: number;
bottom: number;
}> = [];
visibleFeatures.forEach(feature => {
const geom = feature.getGeometry();
if (!geom || geom.getType() !== 'Point') return;
const coords = (geom as Point).getCoordinates();
const pixel = this.map?.getPixelFromCoordinate(coords);
if (!pixel) return;
// 检查样式是否隐藏declutter、距离过滤等
if (styleChecker && !styleChecker(feature)) {
return;
}
const props = feature.getProperties();
const popupHtml = shouldPreferEng2Popup(props)
? generatePopupHtml(props, { forceEng2: true }) ||
props.popupHtml ||
generatePopupHtml(props)
: this.getPopupHtml(props);
if (!popupHtml) return;
// 创建与原始 popupElement 完全相同的样式
const popupEl = document.createElement('div');
popupEl.className = this.popupElement.className;
popupEl.innerHTML = popupHtml;
// 不设置自定义 cssText只设置定位对应原始 popup 的 positioning: 'bottom-center', offset: [0, -10]
popupEl.style.position = 'absolute';
popupEl.style.display = 'block';
popupEl.style.transform = 'translate(-50%, -100%)'; // bottom-center 对齐
popupEl.style.pointerEvents = 'none';
// 临时设置 visibility: hidden 来测量尺寸
popupEl.style.visibility = 'hidden';
popupEl.style.left = `${pixel[0]}px`;
popupEl.style.top = `${pixel[1] - 10}px`;
batchContainer.appendChild(popupEl);
// 获取 popup 尺寸
const rect = popupEl.getBoundingClientRect();
const popupWidth = rect.width;
const popupHeight = rect.height;
// 计算 popup 的位置(对应原始 positioning: 'bottom-center', offset: [0, -10]
const popupX = pixel[0];
const popupY = pixel[1] - 10; // bottom-center 对齐 + 10px 偏移
const popupRect = {
left: popupX - popupWidth / 2,
right: popupX + popupWidth / 2,
top: popupY - popupHeight,
bottom: popupY
};
// 碰撞检测
let hasCollision = false;
for (const placed of placedPopups) {
if (this.checkCollision(popupRect, placed)) {
hasCollision = true;
break;
}
}
if (!hasCollision) {
// 无碰撞,显示 popup
popupEl.style.visibility = 'visible';
popupEl.style.left = `${popupX}px`;
popupEl.style.top = `${popupY}px`;
this.batchPopupItems.push({
feature,
element: popupEl
});
placedPopups.push({
left: popupRect.left,
right: popupRect.right,
top: popupRect.top,
bottom: popupRect.bottom
});
} else {
// 有碰撞,移除该 popup
batchContainer.removeChild(popupEl);
}
});
}
updateBatchPopupPositions(styleChecker?: (feature: Feature) => boolean) {
if (
!this.map ||
!this.batchPopupContainer ||
this.batchPopupItems.length === 0
) {
return;
}
const mapSize = this.map.getSize();
const viewportWidth = mapSize?.[0] ?? 0;
const viewportHeight = mapSize?.[1] ?? 0;
const viewportPadding = 48;
this.batchPopupItems.forEach(({ feature, element }) => {
if (feature.get('_legendVisible') === false) {
element.style.display = 'none';
return;
}
if (styleChecker && !styleChecker(feature)) {
element.style.display = 'none';
return;
}
const geom = feature.getGeometry();
if (!geom || geom.getType() !== 'Point') {
element.style.display = 'none';
return;
}
const coords = (geom as Point).getCoordinates();
const pixel = this.map?.getPixelFromCoordinate(coords);
if (!pixel) {
element.style.display = 'none';
return;
}
const popupX = pixel[0];
const popupY = pixel[1] - 10;
if (
popupX < -viewportPadding ||
popupY < -viewportPadding ||
popupX > viewportWidth + viewportPadding ||
popupY > viewportHeight + viewportPadding
) {
element.style.display = 'none';
return;
}
element.style.left = `${popupX}px`;
element.style.top = `${popupY}px`;
element.style.display = 'block';
});
}
// 备注:清除批量显示的 popups。
clearBatchPopups() {
this.batchPopupItems = [];
if (this.batchPopupContainer) {
this.batchPopupContainer.remove();
this.batchPopupContainer = null;
return;
}
const existing = document.getElementById('batch-popup-container');
if (existing) {
existing.remove();
}
}
// 备注:检测两个 popup 矩形是否碰撞。
private checkCollision(
rect1: { left: number; right: number; top: number; bottom: number },
rect2: { left: number; right: number; top: number; bottom: number }
): boolean {
const padding = 4;
return !(
rect1.right + padding < rect2.left ||
rect1.left - padding > rect2.right ||
rect1.bottom + padding < rect2.top ||
rect1.top - padding > rect2.bottom
);
}
// 备注:统一重置 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.clearBatchPopups();
this.reset();
if (this.popupElement && this.popupMouseEnterHandler) {
this.popupElement.removeEventListener(
'mouseenter',
this.popupMouseEnterHandler
);
}
if (this.popupElement && this.popupMouseMoveHandler) {
this.popupElement.removeEventListener(
'mousemove',
this.popupMouseMoveHandler
);
}
if (this.map && this.popupOverlay) {
this.map.removeOverlay(this.popupOverlay);
}
this.popupMouseEnterHandler = null;
this.popupMouseMoveHandler = null;
this.hoverChangeHandler = undefined;
this.popupOverlay = null;
this.popupElement = null;
this.map = null;
}
}

View File

@ -1,341 +0,0 @@
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 = true): void {
const maskedLayers = this.maskBindings.map(binding => binding.layer);
this.clearMaskEvents();
maskedLayers.forEach(layer => layer.changed());
if (resetView && this.view) {
this.view.animate({
center: fromLonLat(this.defaultCenter),
zoom: this.defaultZoom,
duration: 1000
});
}
}
// 备注:统一销毁区域过滤相关引用和事件,供地图销毁阶段复用。
destroy(): void {
this.clearMapMask(false);
this.map = null;
this.view = null;
}
// 备注:从 GeoJSON 中提取可用于点在面判断的多边形环坐标。
private extractPolygonCoords(geoJson: any): number[][][] {
if (!geoJson?.features?.length) {
return [];
}
const coords: number[][][] = [];
geoJson.features.forEach((feature: any) => {
const geometry = feature.geometry;
if (!geometry) return;
if (geometry.type === 'Polygon') {
coords.push(...geometry.coordinates);
} else if (geometry.type === 'MultiPolygon') {
geometry.coordinates.forEach((polygon: number[][][]) => {
coords.push(...polygon);
});
}
});
return coords;
}
// 备注:使用射线法判断点是否落在任意一个区域环内。
private isPointInPolygon(
lon: number,
lat: number,
polygons: number[][][]
): boolean {
for (const polygon of polygons) {
if (this.isPointInRing(lon, lat, polygon)) {
return true;
}
}
return false;
}
// 备注:使用射线法判断点是否落在单个闭合环内。
private isPointInRing(lon: number, lat: number, ring: number[][]): boolean {
let inside = false;
const ringLength = ring.length;
for (let i = 0, j = ringLength - 1; i < ringLength; j = i++) {
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
const intersect =
yi > lat !== yj > lat &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
if (intersect) {
inside = !inside;
}
}
return inside;
}
// 备注:把单个面环转换为当前帧 Canvas 可裁切的路径。
private drawRingToContext(
context: CanvasRenderingContext2D,
ring: number[][],
frameState: any
): boolean {
if (!ring || ring.length < 3) return false;
const pixelRatio = frameState.pixelRatio || window.devicePixelRatio || 1;
let moved = false;
for (let i = 0; i < ring.length; i++) {
const coord = ring[i];
if (!coord || coord.length < 2 || typeof coord[0] !== 'number') {
continue;
}
const cssPixel = this.map?.getPixelFromCoordinate(coord as [number, number]);
if (!cssPixel) continue;
const canvasX = cssPixel[0] * pixelRatio;
const canvasY = cssPixel[1] * pixelRatio;
if (!moved) {
context.moveTo(canvasX, canvasY);
moved = true;
} else {
context.lineTo(canvasX, canvasY);
}
}
if (moved) {
context.closePath();
return true;
}
return false;
}
// 备注:解除旧底图上的遮罩事件,避免重复绑定或残留旧裁切效果。
private clearMaskEvents(): void {
this.maskBindings.forEach(binding => {
binding.layer.un('prerender', binding.prerender);
binding.layer.un('postrender', binding.postrender);
});
this.maskBindings = [];
}
}

View File

@ -1,747 +0,0 @@
/**
* (OSGB)
* osgbTool.ts
*
* 使用字段: url, boundary, location, height, accuracy
*/
import * as turf from '@turf/turf';
import * as Cesium from 'cesium';
import { getToken } from '@/utils/auth';
// ==================== 类型定义 ====================
export interface OSGBLocation {
destination: number[]; // [x, y, z] 或 [lng, lat, height]
orientation: {
heading: number;
pitch: number;
roll: number;
};
center: number[];
}
export interface OSGBItem {
/** 3D Tileset 服务地址 */
url: string;
/** GeoJSON Polygon 边界(用于裁切) */
boundary?: any;
/** 定位信息 */
location?: OSGBLocation | string;
/** 模型高度偏移 */
height?: number;
/** 屏幕空间误差 (默认 16) */
accuracy?: number;
/** 内部状态 - 不要手动设置 */
osgbtitles?: any;
cartesian3?: any;
clippingPlanes?: any;
eventListener?: any;
_loading?: boolean;
_loadId?: number;
/** 用户手动关闭开关标志:为 true 时不自动加载/显示,只做显隐切换 */
_switchOff?: boolean;
}
// ==================== 内部状态(每个 OSGB 实例独立) ====================
interface OSGBInstanceState {
destination: Cesium.Cartesian3 | null;
orientation: any;
timelookat: any;
time: number;
num: number;
witetime: number;
timeout: any;
}
const instanceStates = new WeakMap<OSGBItem, OSGBInstanceState>();
function getState(obj: OSGBItem): OSGBInstanceState {
let state = instanceStates.get(obj);
if (!state) {
state = {
destination: null,
orientation: null,
timelookat: null,
time: 0,
num: 0,
witetime: 0,
timeout: undefined
};
instanceStates.set(obj, state);
}
return state;
}
function clearState(obj: OSGBItem) {
const state = instanceStates.get(obj);
if (state) {
clearTimeout(state.timeout);
clearInterval(state.timelookat);
}
instanceStates.delete(obj);
}
// ==================== 3D Tileset 场景管理 ====================
/**
* tileset
*/
const addOSGB = (viewer: Cesium.Viewer, tileset: Cesium.Cesium3DTileset) => {
if (!viewer || viewer.isDestroyed()) return;
viewer.scene.primitives.add(tileset);
};
/**
* tilesetPrimitiveCollection.remove destroy=true tileset
*/
const removeOSGB = (viewer: Cesium.Viewer, tileset: Cesium.Cesium3DTileset) => {
if (!viewer || viewer.isDestroyed()) return;
viewer.scene.primitives.remove(tileset);
};
// ==================== 裁切相关 ====================
/**
* GeoJSON Geometry
* Feature / FeatureCollection / Geometry
*/
const normalizeGeometry = (geo: any): any => {
if (!geo) return null;
// 已经是 Geometry 类型(有 type 且 coordinates
if (geo.type && geo.coordinates) return geo;
// Feature → 提取 geometry
if (geo.type === 'Feature' && geo.geometry) return geo.geometry;
// FeatureCollection → 取第一个 feature 的 geometry
if (geo.type === 'FeatureCollection' && geo.features?.length) {
return normalizeGeometry(geo.features[0]);
}
return null;
};
const get_verts_core = (poly: any): Cesium.Cartesian3[] => {
const verts_carte3: Cesium.Cartesian3[] = [];
if (!poly) return verts_carte3;
try {
// 规范化输入:兼容 Feature / FeatureCollection / Geometry
const geom = normalizeGeometry(poly);
if (!geom) return verts_carte3;
let verts = turf.coordAll(geom);
const clockwiseRing = turf.booleanClockwise(turf.lineString(verts));
if (clockwiseRing) {
verts = verts.reverse();
}
for (let i = 0; i < verts.length - 1; i++) {
const vert = verts[i];
verts_carte3.push(Cesium.Cartesian3.fromDegrees(vert[0], vert[1]));
}
} catch (e) {
console.warn('get_verts_core 解析 boundary 失败:', e);
}
return verts_carte3;
};
const get_clipping_planes = (
points: Cesium.Cartesian3[]
): Cesium.ClippingPlane[] => {
const pointsLength = points.length;
const clippingPlanes: Cesium.ClippingPlane[] = [];
for (let i = 0; i < pointsLength; ++i) {
const nextIndex = (i + 1) % pointsLength;
let midpoint = Cesium.Cartesian3.add(
points[i],
points[nextIndex],
new Cesium.Cartesian3()
);
midpoint = Cesium.Cartesian3.multiplyByScalar(midpoint, 0.5, midpoint);
const up = Cesium.Cartesian3.normalize(midpoint, new Cesium.Cartesian3());
let right = Cesium.Cartesian3.subtract(
points[nextIndex],
midpoint,
new Cesium.Cartesian3()
);
right = Cesium.Cartesian3.normalize(right, right);
let normal = Cesium.Cartesian3.cross(right, up, new Cesium.Cartesian3());
normal = Cesium.Cartesian3.normalize(normal, normal);
const originCenteredPlane = new Cesium.Plane(normal, 0.0);
const distance = Cesium.Plane.getPointDistance(
originCenteredPlane,
midpoint
);
clippingPlanes.push(new Cesium.ClippingPlane(normal, distance));
}
return clippingPlanes;
};
const clipping_terrain = (
viewer: Cesium.Viewer,
clippingPlanes: Cesium.ClippingPlane[]
) => {
const globe = viewer.scene.globe;
globe.clippingPlanes = new Cesium.ClippingPlaneCollection({
planes: clippingPlanes,
edgeWidth: 0,
edgeColor: Cesium.Color.GREEN,
enabled: true
});
globe.backFaceCulling = true;
globe.showSkirts = true;
};
const clipping_3dtiles = (
clippingPlanes: Cesium.ClippingPlane[],
tileset: Cesium.Cesium3DTileset
) => {
// Cesium 1.141: root.transform 是内部属性,安全访问
const rootTransform = (tileset as any)?.root?.transform;
if (!rootTransform) return;
const invtrans = Cesium.Matrix4.inverse(rootTransform, new Cesium.Matrix4());
for (let i = 0; i < clippingPlanes.length; i++) {
clippingPlanes[i] = Cesium.Plane.transform(clippingPlanes[i], invtrans);
}
tileset.clippingPlanes = new Cesium.ClippingPlaneCollection({
planes: clippingPlanes,
unionClippingRegions: true,
edgeWidth: 0,
edgeColor: Cesium.Color.RED,
enabled: false
});
};
const Clip3DTile = (boundary: any, tileset: Cesium.Cesium3DTileset) => {
try {
const geom = normalizeGeometry(boundary);
if (!geom) return;
const buffered = turf.buffer(geom, 10, { units: 'meters' });
const verts_core = get_verts_core(buffered).reverse();
if (!verts_core.length) return;
const planes = get_clipping_planes(verts_core);
clipping_3dtiles(planes, tileset);
} catch (e) {
console.warn('Clip3DTile 裁切失败:', e);
}
};
const ClipTerrain = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
// Cesium 1.141: 安全访问 clippingPlanes 内部属性 _planes
const existingPlanes = (viewer.scene.globe.clippingPlanes as any)?._planes;
if (
!osgbObj.clippingPlanes ||
(existingPlanes &&
!compareClippingPlanes(osgbObj.clippingPlanes, existingPlanes))
) {
const verts_core = get_verts_core(osgbObj.boundary);
if (!verts_core.length) return;
const planes = get_clipping_planes(verts_core);
clipping_terrain(viewer, planes);
osgbObj.clippingPlanes = planes;
}
};
const removeClip = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
if (osgbObj.clippingPlanes) {
removeClippingPlanes(viewer);
osgbObj.clippingPlanes = undefined;
}
};
const removeClippingPlanes = (viewer: Cesium.Viewer) => {
if (!Cesium.defined(viewer) || viewer.isDestroyed()) {
return;
}
if (Cesium.defined(viewer.scene.globe.clippingPlanes)) {
viewer.scene.globe.clippingPlanes.removeAll();
}
};
const compareClippingPlanes = (planes1: any, planes2: any): boolean => {
let result = true;
if (planes1.length === planes2?.length) {
for (let index = 0; index < planes1.length; index++) {
if (planes1[index]._distance !== planes2[index]._distance) {
result = false;
break;
}
}
} else {
result = false;
}
return result;
};
const getMinDistanceOfOSGB = (viewer: Cesium.Viewer): number => {
let result = Infinity;
const length = viewer.scene.primitives.length;
for (let i = 0; i < length; i++) {
const p = viewer.scene.primitives.get(i);
// Cesium 1.141: _url 和 _distanceToCamera 是内部属性,安全访问
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((p as any)?._url) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = (p as any)?.root?._distanceToCamera;
if (d != null && d < result) result = d;
}
}
return result;
};
// ==================== 动态显隐 ====================
const dynamicSetVisible = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
if (!Cesium.defined(viewer) || viewer.isDestroyed()) return;
if (!Cesium.defined(osgbObj?.cartesian3)) return;
let cameraPosition: Cesium.Cartesian3 | undefined;
if (viewer.trackedEntity) {
cameraPosition = viewer.trackedEntity.position?.getValue(
viewer.clock.currentTime
);
}
if (!cameraPosition) {
cameraPosition = viewer.camera.position.clone();
}
const distance = Cesium.Cartesian3.distance(
osgbObj.cartesian3,
cameraPosition
);
if (distance < 12000) {
// 用户手动关闭了开关 → 不自动加载
if (osgbObj._switchOff) return;
// 匹配旧代码逻辑osgbObj.osgbtitles 同步设置为 nonClassificationTileset 防重入
// 新代码用 _loading + _loadId 实现同样的效果(因为 fromUrl 是异步的)
if (!osgbObj.osgbtitles && !osgbObj._loading) {
osgbObj._loading = true;
osgbObj._loadId = (osgbObj._loadId || 0) + 1;
const loadId = osgbObj._loadId;
const url = osgbObj.url.includes('?')
? osgbObj.url + `&token=${getToken()}`
: osgbObj.url + `?token=${getToken()}`;
Cesium.Cesium3DTileset.fromUrl(url, {
maximumScreenSpaceError: Number(osgbObj?.accuracy) || 16,
show: true,
maximumMemoryUsage: 128
})
.then((tileset: Cesium.Cesium3DTileset) => {
// 竞态保护版本号变了removeQxsy 被调用或新一轮加载开始)→ 销毁退出
if (
osgbObj._loadId !== loadId ||
!Cesium.defined(viewer) ||
viewer.isDestroyed()
) {
tileset.destroy();
return;
}
osgbObj._loading = false;
if (!Cesium.defined(tileset)) return;
tileset.maximumScreenSpaceError = 32;
// height 向上偏移模型几何自带高程height 只是微调(如 44m
if (osgbObj.height != 0) {
const bs = Cesium.Cartographic.fromCartesian(
tileset.boundingSphere.center
);
const surface = Cesium.Cartesian3.fromRadians(
bs.longitude,
bs.latitude,
0.0
);
const offset = Cesium.Cartesian3.fromRadians(
bs.longitude,
bs.latitude,
osgbObj.height
);
const translation = Cesium.Cartesian3.subtract(
offset,
surface,
new Cesium.Cartesian3()
);
tileset.modelMatrix = Cesium.Matrix4.fromTranslation(translation);
}
addOSGB(viewer, tileset);
osgbObj.osgbtitles = tileset;
})
.catch((e: Error) => {
osgbObj._loading = false;
console.log('dynamicSetVisible 加载倾斜摄影报错', e);
});
}
} else if (distance > 13000) {
// 匹配旧代码逻辑:镜头拉远 → 销毁 tileset 释放内存
if (osgbObj.osgbtitles) {
removeOSGB(viewer, osgbObj.osgbtitles);
osgbObj.osgbtitles = undefined;
}
// 不重置 _switchOff开关状态由用户手动控制不受距离影响
}
};
// ==================== 地形动态裁切 ====================
const dynamicClipTerrain = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
if (!osgbObj?.osgbtitles) {
removeClip(viewer, osgbObj);
return;
}
// Cesium 1.141: root 和 _distanceToCamera 是内部属性,安全访问
const root = (osgbObj.osgbtitles as any)?.root;
if (!Cesium.defined(root)) {
removeClip(viewer, osgbObj);
if (osgbObj.osgbtitles?.clippingPlanes?.enabled) {
osgbObj.osgbtitles.clippingPlanes.enabled = false;
}
return;
}
if (!osgbObj?.osgbtitles?.show) {
removeClip(viewer, osgbObj);
if (osgbObj.osgbtitles?.clippingPlanes?.enabled) {
osgbObj.osgbtitles.clippingPlanes.enabled = false;
}
return;
}
const distance = root._distanceToCamera;
const tilesLoaded = osgbObj.osgbtitles.tilesLoaded;
if (osgbObj.boundary && tilesLoaded) {
if (distance != null && getMinDistanceOfOSGB(viewer) === distance) {
ClipTerrain(viewer, osgbObj);
if (
osgbObj.osgbtitles?.clippingPlanes &&
!osgbObj.osgbtitles.clippingPlanes.enabled
) {
osgbObj.osgbtitles.clippingPlanes.enabled = true;
}
} else {
removeClip(viewer, osgbObj);
if (osgbObj.osgbtitles?.clippingPlanes?.enabled) {
osgbObj.osgbtitles.clippingPlanes.enabled = false;
}
}
}
};
// ==================== 公开 API ====================
/**
* 3D Tileset
*
* Cesium 1.141 使 Cesium3DTileset.fromUrl()
* new Cesium3DTileset({url})
*/
export const LoadOSGB = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
if (!osgbObj.url) return;
// 已加载则跳过
if (osgbObj.osgbtitles && !osgbObj.osgbtitles.isDestroyed?.()) return;
// 正在加载中也跳过(防止重复调用 fromUrl
if (osgbObj._loading) return;
osgbObj._loading = true;
// 自增版本号:每次 LoadOSGB 都 +1.then() 里靠版本号判断是否已失效
osgbObj._loadId = (osgbObj._loadId || 0) + 1;
const loadId = osgbObj._loadId;
const url = osgbObj.url.includes('?')
? osgbObj.url + `&token=${getToken()}`
: osgbObj.url + `?token=${getToken()}`;
Cesium.Cesium3DTileset.fromUrl(url, {
maximumScreenSpaceError: Number(osgbObj?.accuracy) || 16,
show: true,
maximumMemoryUsage: 128
})
.then((tileset: Cesium.Cesium3DTileset) => {
// 竞态保护:版本号变了 → 销毁退出
if (
osgbObj._loadId !== loadId ||
!Cesium.defined(viewer) ||
viewer.isDestroyed()
) {
tileset.destroy();
return;
}
if (!Cesium.defined(tileset)) return;
osgbObj._loading = false;
tileset.maximumScreenSpaceError = 32;
// height 向上偏移模型几何自带高程height 只是微调(如 44m
if (osgbObj.height != 0) {
const bs = Cesium.Cartographic.fromCartesian(
tileset.boundingSphere.center
);
const surface = Cesium.Cartesian3.fromRadians(
bs.longitude,
bs.latitude,
0.0
);
const offset = Cesium.Cartesian3.fromRadians(
bs.longitude,
bs.latitude,
osgbObj.height
);
const translation = Cesium.Cartesian3.subtract(
offset,
surface,
new Cesium.Cartesian3()
);
tileset.modelMatrix = Cesium.Matrix4.fromTranslation(translation);
}
// 加入场景(匹配旧代码 readyPromise 中 addOSGB
addOSGB(viewer, tileset);
osgbObj.osgbtitles = tileset;
// 始终设置 cartesian3保证 dynamicSetVisible 能计算距离
osgbObj.cartesian3 = tileset.boundingSphere.center.clone();
// boundary 裁切(匹配旧代码 readyPromise 中处理)
if (osgbObj.boundary) {
if (typeof osgbObj.boundary === 'string') {
osgbObj.boundary = JSON.parse(osgbObj.boundary);
}
Clip3DTile(osgbObj.boundary, tileset);
}
// 注册 postRender 动态显隐300ms 节流,匹配旧代码 throttle
let lastTime = 0;
osgbObj.eventListener = viewer.scene.postRender.addEventListener(() => {
if (!Cesium.defined(viewer) || viewer.isDestroyed()) return;
const now = performance.now();
if (now - lastTime < 300) return;
lastTime = now;
if (!getState(osgbObj).timelookat) {
dynamicSetVisible(viewer, osgbObj);
}
dynamicClipTerrain(viewer, osgbObj);
});
})
.catch((e: Error) => {
osgbObj._loading = false;
console.log('加载倾斜摄影报错', e);
});
};
/**
*
* 2D osgbChangeClick
* removeqxsy(osgbTool.ts:233-244) cartesian3
*/
export const removeQxsy = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
// 递增版本号:使所有进行中的 fromUrl().then() 失效
osgbObj._loadId = (osgbObj._loadId || 0) + 1;
removeClippingPlanes(viewer);
osgbObj.clippingPlanes = undefined;
osgbObj._loading = false;
osgbObj._switchOff = undefined;
if (typeof osgbObj.eventListener === 'function') {
osgbObj.eventListener();
osgbObj.eventListener = undefined;
}
if (osgbObj.osgbtitles) {
removeOSGB(viewer, osgbObj.osgbtitles);
osgbObj.osgbtitles = undefined;
}
// 注意:不清理 cartesian3匹配旧代码行为
};
/**
*
* -
* - 12km /
* - 12km tileset tileset
* - 2D removeQxsy
*/
export const osgbChangeClick = (
viewer: Cesium.Viewer,
osgbObj: OSGBItem,
checked: boolean
) => {
// 先记录开关状态(无论距离远近)
osgbObj._switchOff = !checked;
// 12km 以外:只记状态不操作 tileset此时 tileset 可能不存在或已销毁)
if (osgbObj.cartesian3) {
const cameraPosition = viewer.camera.position.clone();
const distance = Cesium.Cartesian3.distance(
osgbObj.cartesian3,
cameraPosition
);
if (distance >= 12000) return;
}
if (checked) {
// 开关 ON已有未销毁的 tileset → 直接显示;否则加载
if (osgbObj.osgbtitles && !osgbObj.osgbtitles.isDestroyed?.()) {
osgbObj.osgbtitles.show = true;
} else {
LoadOSGB(viewer, osgbObj);
}
} else {
// 开关 OFF隐藏 tileset移除 terrain 裁切
if (osgbObj.osgbtitles && !osgbObj.osgbtitles.isDestroyed?.()) {
osgbObj.osgbtitles.show = false;
}
removeClip(viewer, osgbObj);
}
};
/**
* location
*/
const parseLocation = (location: any): OSGBLocation | null => {
if (!location) return null;
// 如果已经是对象,直接用
if (typeof location === 'object' && location.destination) {
return location as OSGBLocation;
}
// 如果是字符串,尝试解析
if (typeof location === 'string') {
try {
const jsonString = location.replace(/\n/g, '');
const startIndex = jsonString.indexOf('{');
const endIndex = jsonString.lastIndexOf('}');
if (startIndex === -1 || endIndex === -1) return null;
const extractedData = jsonString.substring(startIndex, endIndex + 1);
const parsed = eval('(' + extractedData + ')');
return {
destination: parsed.destination,
orientation: parsed.orientation,
center: parsed.center
};
} catch {
return null;
}
}
return null;
};
/**
*
* flyTo +
*/
export const osgbLocation = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => {
const state = getState(osgbObj);
clearTimeout(state.timeout);
state.num = 0;
state.time = 0;
state.witetime = 0;
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
let center = Cesium.Cartesian3.fromDegrees(
100.271729604817,
35.3107902811941,
2800
);
let heading = Cesium.Math.toRadians(0.0);
const pitch = Cesium.Math.toRadians(-30.0);
const range = 2000.0;
const locationInfor = parseLocation(osgbObj.location);
if (!locationInfor) {
console.warn('该模型定位坐标未配置!');
return;
}
// 判断是经纬度还是笛卡尔坐标
const destCoords = locationInfor.destination;
if (destCoords[0] <= 180 && destCoords[0] >= -180) {
state.destination = Cesium.Cartesian3.fromDegrees(
destCoords[0],
destCoords[1],
destCoords[2]
);
} else {
state.destination = new Cesium.Cartesian3(
destCoords[0],
destCoords[1],
destCoords[2]
);
}
state.orientation = locationInfor.orientation;
center = Cesium.Cartesian3.fromDegrees(
locationInfor.center[0],
locationInfor.center[1],
locationInfor.center[2]
);
if (!state.destination || !state.orientation) return;
viewer.camera.cancelFlight();
viewer.camera.flyTo({
destination: state.destination,
orientation: state.orientation,
complete: () => {
state.timeout = setTimeout(() => {
rotate();
}, 2000);
}
});
const rotate = () => {
clearInterval(state.timelookat);
state.timelookat = setInterval(() => {
if (!Cesium.defined(viewer) || viewer.isDestroyed()) {
return;
}
state.witetime += 0.7;
if (state.witetime > 180 && state.witetime < 360) {
state.time += 0.7;
heading = Cesium.Math.toRadians(state.time);
viewer.camera.lookAt(
center,
new Cesium.HeadingPitchRange(heading, pitch, range)
);
} else if (state.witetime > 360) {
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
clearInterval(state.timelookat);
state.timelookat = null;
viewer.camera.flyTo({
destination: state.destination!,
orientation: state.orientation,
duration: 2
});
return;
}
}, 10);
};
// 点击 body 两次退出旋转
const body = document.getElementsByTagName('body')[0];
body.onclick = function () {
if (viewer && !viewer.isDestroyed() && viewer.camera) {
state.num = state.num + 1;
if (state.num > 1) {
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
clearInterval(state.timelookat);
clearTimeout(state.timeout);
state.timelookat = null;
}
}
};
};

View File

@ -2,9 +2,7 @@
import { ref, onBeforeMount, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMapStore } from '@/store/modules/map';
import { usePermissionStore } from '@/store/modules/permission';
const mapStore = useMapStore();
const permissionStore = usePermissionStore();
const menus: any = ref([]); //
const subMenus: any = ref([]); //
@ -14,15 +12,6 @@ const subActiveKey = ref(''); // 子菜单选中项
const route = useRoute();
const router = useRouter();
const buildPageKeyFromPath = (path?: string) => {
const normalizedPath = path || '';
const parts = normalizedPath.split('/').filter(Boolean);
if (parts.length >= 2) {
return `${parts[0]}_${parts[1]}`;
}
return normalizedPath || '__route-change__';
};
//
const handleTabChange = (key: string) => {
let data = menus.value.find((item: any) => item.id === key);
@ -33,9 +22,6 @@ const handleTabChange = (key: string) => {
let subData = subMenus.value.find(
(item: any) => item.id === subActiveKey.value
);
mapStore.markPendingPageNavigation(
buildPageKeyFromPath(subData?.path || subData?.opturl)
);
router.push(subData.path || subData.opturl);
} else {
subActiveKey.value = '';
@ -45,9 +31,6 @@ const handleTabChange = (key: string) => {
const handleSubTabChange = (key: string) => {
subActiveKey.value = key;
let data = subMenus.value.find((item: any) => item.id === key);
mapStore.markPendingPageNavigation(
buildPageKeyFromPath(data?.path || data?.opturl)
);
router.push(data.path || data.opturl);
};

View File

@ -13,9 +13,7 @@ import 'ant-design-vue/dist/reset.css'; // Ant Design 全局样式重置
import dayjs from 'dayjs'; // ant 中文语言
import 'dayjs/locale/zh-cn';
import 'virtual:svg-icons-register';
// 3d地图
import * as Cesium from 'cesium';
import 'cesium/Build/Cesium/Widgets/widgets.css';
// 国际化
import i18n from '@/lang/index';

View File

@ -1,110 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useJidiSelectEventStore } from '@/store/modules/jidiSelectEvent';
import { useUiStore } from '@/store/modules/ui';
const isOpen = ref(true);
const uiStore = useUiStore();
const JidiSelectEventStore = useJidiSelectEventStore();
const jidiDataNum = ref(9);
const itemClick = (index: any) => {
JidiSelectEventStore.updataJidiData(index);
};
onMounted(() => {});
</script>
<template>
<div class="jidiSelectorMod" v-show="!uiStore.isRoaming">
<a-spin :spinning="JidiSelectEventStore.loading">
<div
class="qgc-dropdown-select"
@mouseenter="jidiDataNum = JidiSelectEventStore.jidiData.length"
@mouseleave="jidiDataNum = 9"
>
<div class="title" @click="isOpen = !isOpen">
<div>水电基地</div>
<div
style="padding-right: 5px"
:style="{ transform: !isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }"
>
<i class="icon iconfont icon-topOutline"></i>
</div>
</div>
<div
v-if="isOpen"
class="item"
v-for="(i, index) in JidiSelectEventStore.jidiData.slice(
0,
jidiDataNum
)"
:key="index"
:class="{ selected: i.selected }"
@click="itemClick(index)"
>
<i class="icon iconfont icon-hydroPower"></i>
<span style="margin-left: 10px">{{ i.wbsName }}</span>
</div>
</div>
</a-spin>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables.module.scss' as *;
.jidiSelectorMod {
width: 175px;
max-height: 941px;
border: 1px solid #cedce8;
border-radius: 1px;
background-color: #e5edf3;
padding: 4px;
position: relative;
border-radius: 1px;
margin: 16px 0 0 16px;
z-index: 99;
pointer-events: auto;
.qgc-dropdown-select {
width: 100%;
max-height: 941px;
overflow: auto;
.title {
height: 26.14px;
padding: 0 2px;
font-size: 16px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
color: $primary-title-color;
cursor: pointer;
.icon-topOutline {
display: inline-block;
width: 12px;
height: 12px;
font-size: 12px;
scale: 0.6;
position: relative;
top: -3px;
}
}
.item {
margin: 4px 0;
border-radius: 2px;
box-sizing: border-box;
border: 1px solid #acc4d6;
line-height: 30px;
padding: 0 8px;
font-size: 14px;
background-color: #fff;
transition: background-color 0.3s, color 0.3s;
cursor: pointer;
&:hover,
&.selected {
background-color: #2f6b98;
color: #fff;
}
}
}
}
</style>

View File

@ -1,806 +0,0 @@
import { watch, type WatchStopHandle, ref } from 'vue';
import { useRoute } from 'vue-router';
import { unByKey } from 'ol/Observable';
import dayjs from 'dayjs';
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;
isInitialLoad?: boolean;
};
type ApplyLayerSelectionOptions = {
checkedKeys: string[];
triggerKey?: string;
checked?: boolean;
};
const SYSTEM_ID = 'qgc';
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'
];
const ENG_POINT_LAYER_KEY = 'eng_point';
const ENG_POINT_MEDIUM_VISIBLE_ZOOM = 7.5;
const CESIUM_ZOOM_SYNC_DEBOUNCE_MS = 80;
const LARGE_ENG_LEGEND_PREFIX = 'large_eng_';
const MID_ENG_LEGEND_PREFIX = 'mid_eng_';
export const useMapOrchestrator = () => {
const route = useRoute();
const mapClass = MapClass.getInstance();
const mapStore = useMapStore();
const mapConfigStore = useMapConfigStore();
const mapDataStore = useMapDataStore();
const mapViewStore = useMapViewStore();
const jidiSelectEventStore = useJidiSelectEventStore();
let activePageLoadRequestId = 0;
let removeZoomListener: (() => void) | null = null;
let stopBaseSelectionWatch: WatchStopHandle | null = null;
const initializedBaseLayerKeys = new Set<string>();
const baseSelectionDebounceTimer = ref<ReturnType<typeof setTimeout> | null>(
null
);
const zoomSyncDebounceTimer = ref<ReturnType<typeof setTimeout> | null>(null);
let latestHydroMenuGetter: (() => boolean) | null = null;
let currentMapType: '2D' | '3D' = '2D';
let hydroMenuDefaultCheckedKeys = new Set<string>();
let hydroDynamicLayerSyncTask: {
visible: boolean;
promise: Promise<string[] | void>;
} | null = null;
// 备注:统一获取当前地图视图对象,避免在多个组件里重复兼容 2D/3D view 访问方式。
const getCurrentView = () => {
return mapClass.view?.getView ? mapClass.view.getView() : mapClass.view;
};
// 备注:统一获取当前地图缩放级别,供页面切换和缩放监听复用。
const getCurrentZoom = (): number | undefined => {
return mapClass.getCurrentZoom();
};
const clearZoomSyncDebounceTimer = () => {
if (zoomSyncDebounceTimer.value) {
clearTimeout(zoomSyncDebounceTimer.value);
zoomSyncDebounceTimer.value = null;
}
};
// 备注:统一把当前页面中的 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') {
const serializableBaseLayerConfig = {
key: item.config?.key,
id: item.config?.id,
name: item.config?.name,
type: item.config?.type,
url: item.config?.url,
url_3d: item.config?.url_3d,
layers: item.config?.layers,
rasteropacity: item.config?.rasteropacity
};
sessionStorage.setItem(
'customBaseLayer',
JSON.stringify(serializableBaseLayerConfig)
);
}
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,
isInitialLoad = false
}: LoadPageOptions) => {
let backgroundLoadStarted = false;
const pageLoadRequestId = ++activePageLoadRequestId;
try {
mapDataStore.setLoading(true);
const hasGlobalLegendConfig =
Array.isArray(mapConfigStore.legendConfigOriginal) &&
mapConfigStore.legendConfigOriginal.length > 0;
const shouldLoadGlobalLegend = isInitialLoad && !hasGlobalLegendConfig;
const previousPageKey = mapConfigStore.lastLoadOptions?.pageKey || '';
const previousCheckedKeys = mapViewStore.getCheckedLayerKeys();
const moduleId = (route.meta?.moduleId as string) || '';
const loadOptions = {
systemId: SYSTEM_ID,
moduleId,
pageKey,
description: 'true'
};
const layerConfigPromise =
mapConfigStore.loadPageLayerConfig(loadOptions);
const legendConfigPromise = mapConfigStore.loadPageLegendConfig(
moduleId,
{
includeGlobal: shouldLoadGlobalLegend
}
);
// 同时等待两个 promise
const [{ layerConfig }, { legendOriginal, pageLegend }] =
await Promise.all([layerConfigPromise, legendConfigPromise]);
if (pageLoadRequestId !== activePageLoadRequestId) {
return;
}
// 先设置图层数据(更新 checkedLayerKeys再设置图例数据
if (layerConfig.length > 0) {
hydroMenuDefaultCheckedKeys = new Set(
mapConfigStore.extractCheckedLayerKeys(layerConfig)
);
mapStore.setLayerData(layerConfig);
ensureBaseLayersInitialized(layerConfig);
let checkedKeys = mapViewStore.getCheckedLayerKeys();
if (previousPageKey === pageKey && previousCheckedKeys.length > 0) {
const getAllLayerKeys = (items: any[]): string[] => {
const keys: string[] = [];
const walk = (nodes: any[]) => {
nodes.forEach(item => {
if (item?.key) {
keys.push(item.key);
}
if (item?.children?.length > 0) {
walk(item.children);
}
});
};
walk(items);
return keys;
};
const currentLayerKeys = new Set(getAllLayerKeys(layerConfig));
const runtimeCheckedKeys = previousCheckedKeys.filter(key =>
currentLayerKeys.has(key)
);
if (runtimeCheckedKeys.length > 0) {
mapViewStore.setCheckedLayerKeys(runtimeCheckedKeys);
checkedKeys = runtimeCheckedKeys;
}
}
// 设置图例数据(此时 checkedLayerKeys 已正确设置)
if (legendOriginal.length > 0) {
mapStore.setLegendData(legendOriginal, pageLegend);
}
const activePageToken = mapStore.activatePageContext(
pageKey,
layerConfig
);
await mapStore.updateLayerData(checkedKeys, true);
mapStore.setSelectedLegendData();
const backgroundLoadPromise = isInitialLoad
? mapStore.loadAllLayerData(layerConfig, checkedKeys, {
pageToken: activePageToken,
skipSessionCheck: true,
pageKey
})
: mapStore.loadCurrentPageLayerData(layerConfig, checkedKeys, {
pageToken: activePageToken,
skipSessionCheck: true
});
void backgroundLoadPromise.catch(error => {
console.error(`页面锚点后台加载失败 [${pageKey}]`, error);
});
backgroundLoadStarted = true;
}
} finally {
if (
!backgroundLoadStarted &&
pageLoadRequestId === activePageLoadRequestId
) {
mapDataStore.setLoading(false);
}
}
};
// 备注:统一完成地图实例初始化和弹窗挂载,确保后续事件监听可以在图层数据加载前生效。
const initializeMapShell = async ({
container,
popupContainer
}: InitializeOptions) => {
await mapClass.init(container);
if (popupContainer) {
const popupHost = container.parentElement;
if (popupHost && popupContainer.parentElement !== popupHost) {
popupHost.appendChild(popupContainer);
}
popupContainer.style.display = 'none';
mapClass.initPopupOverlay(popupContainer);
}
};
const replayCurrentMapState = async (
options: Pick<InitializeOptions, 'popupContainer'> & {
getIsHydroMenu: () => boolean;
}
) => {
initializedBaseLayerKeys.clear();
const currentZoom = getCurrentZoom();
if (currentZoom !== undefined) {
mapViewStore.setCurrentZoomLevel(currentZoom);
}
if (options.popupContainer) {
const container = document.getElementById('mapContainer');
const popupHost = container?.parentElement || null;
if (popupHost && options.popupContainer.parentElement !== popupHost) {
popupHost.appendChild(options.popupContainer);
}
options.popupContainer.style.display = 'none';
mapClass.initPopupOverlay(options.popupContainer);
}
ensureBaseLayersInitialized(mapStore.layerData || []);
const checkedKeys = mapViewStore.getCheckedLayerKeys();
await mapStore.updateLayerData(checkedKeys, true);
mapStore.setSelectedLegendData();
await syncZoomSensitiveState({
isHydroMenu: options.getIsHydroMenu(),
refreshEngPoint: true
});
const selectedBaseId = mapViewStore.selectedBaseId;
mapClass.jdPanelControlShowAndHidden(
selectedBaseId || 'all',
!!selectedBaseId
);
bindZoomListener(options.getIsHydroMenu);
};
// 备注:统一完成首屏页面配置加载,供挂载阶段和后续页面重载复用。
const initialize = async ({
container,
popupContainer,
pageKey
}: InitializeOptions) => {
await initializeMapShell({
container,
popupContainer,
pageKey
});
await loadPage({ pageKey, isInitialLoad: true });
};
// 备注:统一绑定地图缩放监听,让页面组件只负责触发初始化,不再直接处理缩放联动细节。
const bindZoomListener = (getIsHydroMenu: () => boolean) => {
if (removeZoomListener) {
removeZoomListener();
removeZoomListener = null;
}
clearZoomSyncDebounceTimer();
const currentZoom = getCurrentZoom();
if (currentZoom !== undefined) {
mapViewStore.setCurrentZoomLevel(currentZoom);
}
const emitZoomChange = async () => {
const zoom = getCurrentZoom();
if (zoom === undefined) return;
await handleZoomLevelChange(
mapViewStore.currentZoomLevel,
zoom,
getIsHydroMenu()
);
};
if (currentMapType === '3D') {
const removeCesiumListener =
mapClass.view?.camera?.changed?.addEventListener(() => {
clearZoomSyncDebounceTimer();
zoomSyncDebounceTimer.value = setTimeout(() => {
zoomSyncDebounceTimer.value = null;
void emitZoomChange();
}, CESIUM_ZOOM_SYNC_DEBOUNCE_MS);
});
if (typeof removeCesiumListener === 'function') {
removeZoomListener = () => {
clearZoomSyncDebounceTimer();
removeCesiumListener();
};
}
return;
}
const view = getCurrentView();
if (!view?.on) return;
const zoomListenerKey = view.on('change:resolution', () => {
void emitZoomChange();
});
removeZoomListener = () => {
unByKey(zoomListenerKey);
};
};
// 备注:统一绑定基地切换事件,让页面组件不再直接 watch 外部基地选择源。
const bindBaseSelection = () => {
if (stopBaseSelectionWatch) {
stopBaseSelectionWatch();
stopBaseSelectionWatch = null;
}
stopBaseSelectionWatch = watch(
() => jidiSelectEventStore.selectedItem?.wbsCode,
newWbsCode => {
if (!newWbsCode) {
changeBaseId('');
return;
}
if (baseSelectionDebounceTimer.value) {
clearTimeout(baseSelectionDebounceTimer.value);
}
baseSelectionDebounceTimer.value = setTimeout(() => {
changeBaseId(newWbsCode);
baseSelectionDebounceTimer.value = null;
}, 100);
},
{ immediate: true }
);
};
// 备注:统一挂载地图页面生命周期,初始化后自动接管缩放监听和基地切换监听。
const mountView = async (
options: InitializeOptions & {
getIsHydroMenu: () => boolean;
}
) => {
currentMapType = '2D';
latestHydroMenuGetter = options.getIsHydroMenu;
await initializeMapShell(options);
bindBaseSelection();
bindZoomListener(options.getIsHydroMenu);
await loadPage({ pageKey: options.pageKey, isInitialLoad: true });
await syncZoomSensitiveState({
isHydroMenu: options.getIsHydroMenu(),
refreshEngPoint: true
});
};
const switchMapType = async (
type: '2D' | '3D',
options: Pick<InitializeOptions, 'popupContainer'> & {
getIsHydroMenu?: () => boolean;
} = {}
) => {
const hydroMenuGetter =
options.getIsHydroMenu || latestHydroMenuGetter || (() => false);
latestHydroMenuGetter = hydroMenuGetter;
currentMapType = type;
await mapClass.switchView(type);
await replayCurrentMapState({
popupContainer: options.popupContainer,
getIsHydroMenu: hydroMenuGetter
});
};
// 备注:统一处理菜单切换后的页面配置重载,供页面 watch 直接复用。
const reloadPage = async (pageKey: string) => {
if (!pageKey) return;
await loadPage({ pageKey, isInitialLoad: false });
};
// 备注:菜单切换前只隐藏旧页面当前可见点图层,底图保持连续显示,避免切换时闪烁。
const hideCurrentVisibleLayers = () => {
const currentCheckedKeys = mapViewStore.getCheckedLayerKeys();
currentCheckedKeys.forEach(layerKey => {
const layerItem = mapStore.findLayerByKey(mapStore.layerData, layerKey);
if (!layerItem?.key) {
return;
}
if (layerItem.type === 'pointMap') {
if (mapClass.hasLayer(layerItem.key)) {
mapClass.mdLayerTreeShowOrHidden(layerItem.key, false);
}
}
});
};
// 备注:统一处理图层树勾选结果归一化和地图联动,供图层树组件复用。
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)
);
if (layersToLoad.length === 0) {
return;
}
await Promise.all(layersToLoad.map(layer => mapStore.loadLayerData(layer)));
};
// 备注:统一控制水电开发菜单下的动态图层增删,避免页面组件直接操作加载和勾选细节。
const syncHydroDynamicLayers = async (visible: boolean) => {
if (
hydroDynamicLayerSyncTask &&
hydroDynamicLayerSyncTask.visible === visible
) {
return hydroDynamicLayerSyncTask.promise;
}
const task = (async () => {
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 => {
if (!HYDRO_DYNAMIC_LAYER_KEYS.includes(key)) {
return true;
}
return hydroMenuDefaultCheckedKeys.has(key);
});
if (nextCheckedKeys.length === currentCheckedKeys.length) {
return currentCheckedKeys;
}
return applyLayerSelection({
checkedKeys: nextCheckedKeys
});
})().finally(() => {
if (hydroDynamicLayerSyncTask?.promise === task) {
hydroDynamicLayerSyncTask = null;
}
});
hydroDynamicLayerSyncTask = {
visible,
promise: task
};
return task;
};
// 备注:统一按当前缩放对齐首页菜单的中型站过滤和 12 级动态图层状态。
const syncZoomSensitiveState = async ({
isHydroMenu,
refreshEngPoint = false
}: {
isHydroMenu: boolean;
refreshEngPoint?: boolean;
}) => {
const currentZoom = getCurrentZoom();
if (currentZoom !== undefined) {
mapViewStore.setCurrentZoomLevel(currentZoom);
}
if (
isHydroMenu &&
refreshEngPoint &&
mapViewStore.getCheckedLayerKeys().includes(ENG_POINT_LAYER_KEY)
) {
mapStore.refreshPointLayerDisplayData(ENG_POINT_LAYER_KEY);
}
await syncHydroDynamicLayers(
!!isHydroMenu && currentZoom !== undefined && currentZoom >= 12
);
};
// 备注:统一处理地图缩放联动,收口水电开发菜单下的动态图层阈值切换逻辑。
const handleZoomLevelChange = async (
previousZoom: number,
currentZoom: number,
isHydroMenu: boolean
) => {
mapViewStore.setCurrentZoomLevel(currentZoom);
const crossedEngPointMediumThreshold =
(previousZoom <= ENG_POINT_MEDIUM_VISIBLE_ZOOM &&
currentZoom > ENG_POINT_MEDIUM_VISIBLE_ZOOM) ||
(previousZoom > ENG_POINT_MEDIUM_VISIBLE_ZOOM &&
currentZoom <= ENG_POINT_MEDIUM_VISIBLE_ZOOM);
if (
isHydroMenu &&
crossedEngPointMediumThreshold &&
mapViewStore.getCheckedLayerKeys().includes(ENG_POINT_LAYER_KEY)
) {
mapStore.refreshPointLayerDisplayData(ENG_POINT_LAYER_KEY);
}
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 = (pageKey: string, isHydroMenu: boolean) => {
if (!pageKey) return Promise.resolve();
const previousPageKey = mapConfigStore.lastLoadOptions?.pageKey || '';
if (previousPageKey && previousPageKey !== pageKey) {
hideCurrentVisibleLayers();
mapViewStore.setCheckedLayerKeys([]);
mapStore.setSelectedLegendData();
}
return reloadPage(pageKey).then(async () => {
await syncZoomSensitiveState({
isHydroMenu,
refreshEngPoint: true
});
});
};
// 备注:统一处理单个图层切换命令,供后续其他入口复用。
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;
console.log('toggleLegend', nameEn, checked);
console.log(mapViewStore.getLegendChecked(nameEn));
const nextChecked =
checked === undefined
? mapViewStore.getLegendChecked(nameEn) === 1
? 0
: 1
: checked;
console.log(nextChecked);
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();
};
// 备注:统一处理装机容量筛选,让筛选器切换时同步更新 eng_point 图例运行态和点位显示。
const changeEngPointCapacity = (capacityType?: string | null) => {
const legendItems = mapStore.getLegendItemsByLayerCode(ENG_POINT_LAYER_KEY);
if (!legendItems.length) return;
const allEngLegendNames = legendItems
.map((item: any) => item?.nameEn)
.filter(Boolean);
if (!allEngLegendNames.length) return;
const normalizedCapacityType = capacityType || 'all';
let activePrefixes = [LARGE_ENG_LEGEND_PREFIX, MID_ENG_LEGEND_PREFIX];
if (normalizedCapacityType === 'large_eng_built') {
activePrefixes = [LARGE_ENG_LEGEND_PREFIX];
} else if (normalizedCapacityType === 'mid_eng_built') {
activePrefixes = [MID_ENG_LEGEND_PREFIX];
}
const visibleLegendNames = allEngLegendNames.filter((nameEn: string) =>
activePrefixes.some(prefix => nameEn.startsWith(prefix))
);
const hiddenLegendNames = allEngLegendNames.filter(
(nameEn: string) => !visibleLegendNames.includes(nameEn)
);
mapStore.updateLegendCheckedBatch(hiddenLegendNames, 0);
mapStore.updateLegendCheckedBatch(visibleLegendNames, 1);
mapStore.refreshPointLayerDisplayData(ENG_POINT_LAYER_KEY);
};
// 备注:统一重置地图筛选表单依赖的时间范围,供筛选组件在切页时复位默认输入。
const resetFilterState = () => {
mapViewStore.setSearchTimeRange([dayjs().subtract(1, 'M'), dayjs()]);
};
// 备注:统一处理搜索定位,按点位编码从缓存数据中查找并飞行到目标位置。
const focusPoint = (
pointId: string,
zoom = 15,
fallbackPoints: any[] = []
) => {
if (!pointId) return;
const pointMatcher = (item: any) => {
return item.stcd === pointId || item._id === pointId;
};
const targetPoint =
mapDataStore.pointData.find(pointMatcher) ||
fallbackPoints.find(pointMatcher);
if (!targetPoint?.lgtd || !targetPoint?.lttd) return;
const legendState = String(targetPoint?.anchoPointState || '');
if (legendState && mapViewStore.getLegendChecked(legendState) !== 1) {
mapStore.updateLegendChecked(legendState, 1);
}
const layerKey = String(targetPoint?.layerKey || '');
if (layerKey && mapClass.hasLayer(layerKey)) {
mapClass.mdLayerTreeShowOrHidden(layerKey, true);
}
mapClass.flyTopanto([targetPoint.lgtd, targetPoint.lttd], zoom);
};
// 备注:统一释放地图页面生命周期里注册的监听,避免页面卸载后残留联动。
const unmountView = () => {
if (removeZoomListener) {
removeZoomListener();
removeZoomListener = null;
}
clearZoomSyncDebounceTimer();
if (stopBaseSelectionWatch) {
stopBaseSelectionWatch();
}
if (baseSelectionDebounceTimer.value) {
clearTimeout(baseSelectionDebounceTimer.value);
baseSelectionDebounceTimer.value = null;
}
latestHydroMenuGetter = null;
currentMapType = '2D';
initializedBaseLayerKeys.clear();
hydroMenuDefaultCheckedKeys.clear();
};
return {
initialize,
mountView,
switchMapType,
unmountView,
loadPage,
reloadPage,
applyLayerSelection,
ensureDynamicLayersLoaded,
syncHydroDynamicLayers,
getCurrentZoom,
handleZoomLevelChange,
handlePageChange,
toggleLayer,
toggleLegend,
toggleLegendBatch,
changeBaseId,
changeTimeRange,
changeEngPointCapacity,
resetFilterState,
focusPoint
};
};

View File

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

View File

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

View File

@ -1,418 +0,0 @@
export type NearbyPointDisplayMode = 'default' | 'replace' | 'expand';
export type NearbyPointAutoRule = {
distanceThresholdMeters: number;
replaceAtZoom: number;
expandAtZoom: number;
expandOffsetPx: number;
angleStepDeg: number;
minGroupSize: number;
};
export type NearbyPointLayoutRule = {
ringSize: number;
fanAngleForTwoDeg: number;
fanAngleForFourDeg: number;
fanAngleForManyDeg: number;
ringGapPx: number;
ringGapFactor: number;
};
export type NearbyPointDensityDisplayRule = {
minDensityValue: number;
minZoom: number;
};
export type NearbyPointConfig = {
autoRule: NearbyPointAutoRule;
layoutRule: NearbyPointLayoutRule;
densityDisplayRules: NearbyPointDensityDisplayRule[];
};
export type NearbyPointRuntimeMeta = {
groupId: string;
displayName: string;
groupSize: number;
priority: number;
showZoomMin: number | null;
showZoomMax: number | null;
replaceAtZoom: number;
expandAtZoom: number;
expandOffsetPx: number;
expandAngleDeg: number;
displayMode: NearbyPointDisplayMode;
};
// 备注:近邻点统一调参入口。
// 当前参数偏向“更容易成组、稍早进入替换与展开、展开位移更明显”,
// 方便抽吸效果在常用浏览层级更早被感知。
export const DEFAULT_NEARBY_POINT_AUTO_RULE: NearbyPointAutoRule = {
distanceThresholdMeters: 9000,
replaceAtZoom: 10.5,
expandAtZoom: 12.2,
expandOffsetPx: 48,
angleStepDeg: 55,
minGroupSize: 2
};
// 备注:展开布局统一调参入口。这里控制单圈数量、扇形角度和外圈扩散节奏。
export const DEFAULT_NEARBY_POINT_LAYOUT_RULE: NearbyPointLayoutRule = {
ringSize: 5,
fanAngleForTwoDeg: 110,
fanAngleForFourDeg: 150,
fanAngleForManyDeg: 180,
ringGapPx: 22,
ringGapFactor: 0.85
};
// 备注密度分档显示规则。distance 越大表示越稀疏,越早参与显示候选。
export const DEFAULT_NEARBY_POINT_DENSITY_DISPLAY_RULES: NearbyPointDensityDisplayRule[] =
[
{
minDensityValue: 1500000,
minZoom: 0
},
{
minDensityValue: 800000,
minZoom: 0
},
{
minDensityValue: 400000,
minZoom: 0
},
{
minDensityValue: 50000,
minZoom: 2.0
},
{
minDensityValue: 0,
minZoom: 4.0
}
];
export const DEFAULT_NEARBY_POINT_CONFIG: NearbyPointConfig = {
autoRule: DEFAULT_NEARBY_POINT_AUTO_RULE,
layoutRule: DEFAULT_NEARBY_POINT_LAYOUT_RULE,
densityDisplayRules: DEFAULT_NEARBY_POINT_DENSITY_DISPLAY_RULES
};
export const getNearbyPointConfig = (): NearbyPointConfig => {
return DEFAULT_NEARBY_POINT_CONFIG;
};
export const getNearbyPointAutoRule = (): NearbyPointAutoRule => {
return getNearbyPointConfig().autoRule;
};
export const getNearbyPointLayoutRule = (): NearbyPointLayoutRule => {
return getNearbyPointConfig().layoutRule;
};
export const getNearbyPointDensityDisplayRules =
(): NearbyPointDensityDisplayRule[] => {
return getNearbyPointConfig().densityDisplayRules;
};
type GroupCandidate = {
index: number;
point: Record<string, any>;
lon: number;
lat: number;
layerKey: string;
};
const isAlarmRangeLegendState = (legendState: string): boolean => {
return (
legendState.startsWith('alarm_range_') ||
legendState.startsWith('large_eng_built_alarm_range_') ||
legendState.startsWith('mid_eng_built_alarm_range_')
);
};
const getPointRenderPriority = (point: Record<string, any>): number => {
const layerKey = String(point.layerKey || point.type || '').toLowerCase();
const legendState = String(point.anchoPointState || '').trim().toLowerCase();
if (
layerKey.includes('eng_alarm_point') ||
layerKey.includes('alarm_range') ||
isAlarmRangeLegendState(legendState)
) {
return 300;
}
if (
layerKey.includes('eng_point') ||
legendState.startsWith('large_eng_') ||
legendState.startsWith('mid_eng_')
) {
return 200;
}
return 100;
};
const normalizeText = (value: unknown): string => {
return String(value ?? '')
.trim()
.replace(/[()]/g, '')
.toLowerCase();
};
const toFiniteNumber = (value: unknown): number | null => {
if (value === '' || value === null || value === undefined) {
return null;
}
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : null;
};
const haversineDistanceMeters = (
lon1: number,
lat1: number,
lon2: number,
lat2: number
): number => {
const toRad = (degree: number) => (degree * Math.PI) / 180;
const earthRadius = 6371000;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const lat1Rad = toRad(lat1);
const lat2Rad = toRad(lat2);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1Rad) *
Math.cos(lat2Rad) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadius * c;
};
const clearNearbyPointRuntimeMeta = (point: Record<string, any>) => {
delete point._nearbyRule;
delete point._nearbyGroupId;
delete point._nearbyDisplayName;
delete point._nearbyGroupSize;
delete point._nearbyPriority;
delete point._nearbyShowZoomMin;
delete point._nearbyShowZoomMax;
delete point._nearbyReplaceAtZoom;
delete point._nearbyExpandAtZoom;
delete point._nearbyExpandOffsetPx;
delete point._nearbyExpandAngleDeg;
delete point._nearbyDisplayMode;
delete point._nearbyIsPrimary;
};
const getPointDisplayName = (point: Record<string, any>): string => {
return (
point.titleName || point.stnm || point.ennm || point.stcd || point._id || ''
);
};
const getPointStableIdentity = (
point: Record<string, any>,
index: number
): string => {
return normalizeText(
point.stcd ||
point._id ||
point.titleName ||
point.stnm ||
point.ennm ||
`point_${index}`
);
};
const getGroupSortKey = (candidate: GroupCandidate): string => {
return [
getPointStableIdentity(candidate.point, candidate.index),
normalizeText(candidate.point.layerKey),
String(candidate.index).padStart(6, '0')
].join('|');
};
const compareGroupCandidates = (
left: GroupCandidate,
right: GroupCandidate
): number => {
const leftRenderPriority = getPointRenderPriority(left.point);
const rightRenderPriority = getPointRenderPriority(right.point);
if (leftRenderPriority !== rightRenderPriority) {
return rightRenderPriority - leftRenderPriority;
}
const leftDensityValue = toFiniteNumber(left.point.distance);
const rightDensityValue = toFiniteNumber(right.point.distance);
if (
leftDensityValue !== null &&
rightDensityValue !== null &&
leftDensityValue !== rightDensityValue
) {
return rightDensityValue - leftDensityValue;
}
if (leftDensityValue !== null || rightDensityValue !== null) {
return leftDensityValue !== null ? -1 : 1;
}
return getGroupSortKey(left).localeCompare(getGroupSortKey(right));
};
const buildRuntimeMeta = (
groupId: string,
groupSize: number,
priority: number,
displayName: string,
rule: NearbyPointAutoRule
): NearbyPointRuntimeMeta => {
return {
groupId,
displayName,
groupSize,
priority,
showZoomMin: null,
showZoomMax: null,
replaceAtZoom: rule.replaceAtZoom,
expandAtZoom: rule.expandAtZoom,
expandOffsetPx: rule.expandOffsetPx,
expandAngleDeg: 0,
displayMode: 'default'
};
};
const buildGroupCandidates = (
points: Record<string, any>[] = []
): GroupCandidate[] => {
const candidates: GroupCandidate[] = [];
points.forEach((point, index) => {
const lon = toFiniteNumber(point.lgtd);
const lat = toFiniteNumber(point.lttd);
if (lon === null || lat === null) {
clearNearbyPointRuntimeMeta(point);
return;
}
if (Math.abs(lon) > 180 || Math.abs(lat) > 90) {
clearNearbyPointRuntimeMeta(point);
return;
}
candidates.push({
index,
point,
lon,
lat,
layerKey: String(point.layerKey || '')
});
});
return candidates;
};
const buildNearbyGroups = (
candidates: GroupCandidate[],
rule: NearbyPointAutoRule
): GroupCandidate[][] => {
const result: GroupCandidate[][] = [];
const sortedCandidates = [...candidates].sort(compareGroupCandidates);
const consumedIndexes = new Set<number>();
sortedCandidates.forEach((seedCandidate, seedIndex) => {
if (consumedIndexes.has(seedIndex)) {
return;
}
const groupItems: GroupCandidate[] = [seedCandidate];
const groupIndexes = [seedIndex];
for (
let candidateIndex = seedIndex + 1;
candidateIndex < sortedCandidates.length;
candidateIndex += 1
) {
if (consumedIndexes.has(candidateIndex)) {
continue;
}
const currentCandidate = sortedCandidates[candidateIndex];
const distanceMeters = haversineDistanceMeters(
seedCandidate.lon,
seedCandidate.lat,
currentCandidate.lon,
currentCandidate.lat
);
if (distanceMeters <= rule.distanceThresholdMeters) {
groupItems.push(currentCandidate);
groupIndexes.push(candidateIndex);
}
}
if (groupItems.length >= rule.minGroupSize) {
groupIndexes.forEach(index => consumedIndexes.add(index));
result.push(groupItems);
}
});
return result;
};
// 备注:统一按空间距离自动识别近邻点组,不按名称或点类型分规则。
export const attachNearbyPointRuntimeMeta = (
points: Record<string, any>[] = [],
rule: NearbyPointAutoRule = getNearbyPointAutoRule()
): Record<string, any>[] => {
if (!Array.isArray(points) || points.length === 0) {
return [];
}
points.forEach(point => clearNearbyPointRuntimeMeta(point));
const candidates = buildGroupCandidates(points);
if (candidates.length < rule.minGroupSize) {
return points;
}
const groupedCandidates = buildNearbyGroups(candidates, rule);
let groupSeed = 0;
groupedCandidates.forEach(groupItems => {
if (groupItems.length < rule.minGroupSize) {
return;
}
const sortedItems = [...groupItems].sort(compareGroupCandidates);
groupSeed += 1;
const groupId = `nearby-group-${groupSeed}`;
sortedItems.forEach((candidate, orderIndex) => {
const priority = orderIndex + 1;
const runtimeMeta = buildRuntimeMeta(
groupId,
sortedItems.length,
priority,
getPointDisplayName(candidate.point),
rule
);
candidate.point._nearbyRule = runtimeMeta;
candidate.point._nearbyGroupId = runtimeMeta.groupId;
candidate.point._nearbyDisplayName = runtimeMeta.displayName;
candidate.point._nearbyGroupSize = runtimeMeta.groupSize;
candidate.point._nearbyPriority = runtimeMeta.priority;
candidate.point._nearbyShowZoomMin = runtimeMeta.showZoomMin;
candidate.point._nearbyShowZoomMax = runtimeMeta.showZoomMax;
candidate.point._nearbyReplaceAtZoom = runtimeMeta.replaceAtZoom;
candidate.point._nearbyExpandAtZoom = runtimeMeta.expandAtZoom;
candidate.point._nearbyExpandOffsetPx = runtimeMeta.expandOffsetPx;
candidate.point._nearbyExpandAngleDeg = runtimeMeta.expandAngleDeg;
candidate.point._nearbyDisplayMode = runtimeMeta.displayMode;
candidate.point._nearbyIsPrimary = runtimeMeta.priority === 1;
});
});
return points;
};

View File

@ -1,251 +0,0 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { getMapList, getModuleMapLegendList } from '@/api/map';
type MapConfigLoadOptions = {
systemId: string;
moduleId: string;
pageKey?: string;
description?: string;
};
type LegendConfigLoadOptions = {
includeGlobal?: boolean;
};
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 legendLoading = ref(false);
const lastLoadOptions = ref<MapConfigLoadOptions | null>(null);
const normalizeLegendNameEn = (nameEn?: string): string => {
if (!nameEn) return '';
return 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 (Number(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 = [];
configLoading.value = false;
legendLoading.value = false;
lastLoadOptions.value = null;
};
const loadPageLayerConfig = async (options: MapConfigLoadOptions) => {
configLoading.value = true;
lastLoadOptions.value = { ...options };
try {
const layerRes = await getMapList({
systemId: options.systemId,
moduleId: options.moduleId,
description: options.description ?? 'true'
});
const layerConfig = layerRes?.data?.mapLayerVos || [];
setLayerConfigTree(layerConfig);
return {
layerConfig
};
} finally {
configLoading.value = false;
}
};
const loadPageLegendConfig = async (
pageKey?: string,
options: LegendConfigLoadOptions = {}
) => {
legendLoading.value = true;
try {
const includeGlobal = options.includeGlobal !== false;
let legendOriginal = legendConfigOriginal.value || [];
let pageLegend: any[] = [];
if (includeGlobal) {
const [legendAllRes, legendPageRes] = await Promise.all([
getModuleMapLegendList(),
getModuleMapLegendList(pageKey ? { moduleId: pageKey } : undefined)
]);
legendOriginal = legendAllRes?.data || [];
pageLegend = legendPageRes?.data || [];
} else {
const legendPageRes = await getModuleMapLegendList(
pageKey ? { moduleId: pageKey } : undefined
);
pageLegend = legendPageRes?.data || [];
}
setLegendConfigOriginal(legendOriginal);
setPageLegendConfig(pageLegend);
return {
legendOriginal,
pageLegend
};
} finally {
legendLoading.value = false;
}
};
// 备注:加载页面地图配置,只负责请求和保存配置数据,不处理锚点或地图渲染逻辑。
const loadPageMapConfig = async (options: MapConfigLoadOptions) => {
const [{ layerConfig }, { legendOriginal, pageLegend }] = await Promise.all(
[loadPageLayerConfig(options), loadPageLegendConfig(options.pageKey)]
);
return {
layerConfig,
legendOriginal,
pageLegend
};
};
return {
layerConfigTree,
layerConfigByKey,
legendConfigOriginal,
legendConfigByNameEn,
legendConfigByLayerCode,
pageLegendConfig,
configLoading,
legendLoading,
lastLoadOptions,
normalizeLegendNameEn,
rebuildLayerConfigIndex,
extractCheckedLayerKeys,
rebuildLegendConfigIndexes,
setLayerConfigTree,
setLegendConfigOriginal,
setPageLegendConfig,
getLayerConfigByKey,
getLegendConfigByLayerCode,
getLegendConfigByNameEn,
clearConfigState,
loadPageLayerConfig,
loadPageLegendConfig,
loadPageMapConfig
};
});

View File

@ -1,192 +0,0 @@
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[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;
cache.data.forEach((point: any) => {
if (point?.layerKey !== layerKey) {
point.layerKey = layerKey;
}
allPointData.push(point);
});
});
pointData.value = allPointData;
return allPointData;
};
// 备注:统一初始化单个图层的加载状态,避免外层重复判空。
const ensureLayerLoadState = (layerKey: string): LayerLoadStatus => {
if (!layerLoadState.value[layerKey]) {
layerLoadState.value[layerKey] = {
loading: false,
loaded: false,
error: null
};
}
return layerLoadState.value[layerKey];
};
// 备注:标记单个图层进入加载中状态。
const setLayerLoading = (layerKey: string) => {
const current = ensureLayerLoadState(layerKey);
layerLoadState.value[layerKey] = {
...current,
loading: true,
error: null
};
};
// 备注:标记单个图层加载成功,供后续判断是否已完成首次加载。
const setLayerLoaded = (layerKey: string) => {
const current = ensureLayerLoadState(layerKey);
layerLoadState.value[layerKey] = {
...current,
loading: false,
loaded: true,
error: null
};
};
// 备注:标记单个图层加载失败,并记录错误信息。
const setLayerLoadError = (layerKey: string, error: unknown) => {
const current = ensureLayerLoadState(layerKey);
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;
delete layerLoadState.value[layerKey];
};
// 备注:整体重置数据缓存 store供菜单切换或地图销毁时调用。
const resetDataState = () => {
pointData.value = [];
pointDataCache.value = {};
layerLoadState.value = {};
loading.value = true;
};
return {
pointData,
pointDataCache,
layerLoadState,
loading,
setLoading,
clearPointData,
setPointData,
getPointLayerCache,
hasValidPointLayerCache,
getPointLayerData,
hasPointLayerData,
setPointLayerCache,
setPointLayerCacheChecked,
removePointLayerCache,
clearPointLayerCache,
rebuildPointDataFromCache,
ensureLayerLoadState,
setLayerLoading,
setLayerLoaded,
setLayerLoadError,
clearLayerLoadState,
resetDataState
};
});

View File

@ -1,92 +0,0 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import dayjs from 'dayjs';
export const useMapViewStore = defineStore('map-view', () => {
const checkedLayerKeys = ref<string[]>([]);
const legendCheckedState = ref<Record<string, number>>({});
const searchTimeRange = ref<[any, any]>([dayjs().subtract(1, 'M'), dayjs()]);
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 = [dayjs().subtract(1, 'M'), dayjs()];
selectedBaseId.value = '';
currentZoomLevel.value = 4.5;
};
return {
checkedLayerKeys,
legendCheckedState,
searchTimeRange,
selectedBaseId,
currentZoomLevel,
setCheckedLayerKeys,
getCheckedLayerKeys,
setLegendCheckedState,
setLegendChecked,
setLegendCheckedBatch,
getLegendChecked,
setSearchTimeRange,
setSelectedBaseId,
setCurrentZoomLevel,
resetViewState
};
});

File diff suppressed because it is too large Load Diff

View File

@ -31,6 +31,7 @@ export const useShuJuTianBaoStore = defineStore('shuJuTianBao', () => {
const list = [...res.data];
// 直接赋值给 ref触发响应式更新
baseOption.value = list;
// debugger
}
} catch (error) {
console.error('获取水电基地列表失败:', error);

File diff suppressed because it is too large Load Diff

View File

@ -174,7 +174,7 @@
/>
</a-form-item>
</a-col>
<a-col :span="24">
<!-- <a-col :span="24">
<a-form-item label="简介" name="introduce" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-textarea
v-model:value="formData.introduce"
@ -184,7 +184,7 @@
:rows="2"
/>
</a-form-item>
</a-col>
</a-col> -->
<a-col :span="24">
<a-form-item label="备注" name="remark" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-textarea

View File

@ -2,7 +2,6 @@ import { UserConfig, ConfigEnv, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons';
import path from 'path';
import cesium from 'vite-plugin-cesium';
export default ({ mode }: ConfigEnv): UserConfig => {
// 获取 .env 环境配置文件
@ -11,7 +10,6 @@ export default ({ mode }: ConfigEnv): UserConfig => {
return {
plugins: [
vue(),
cesium(),
createSvgIconsPlugin({
// 指定需要缓存的图标文件夹
iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
@ -122,7 +120,6 @@ export default ({ mode }: ConfigEnv): UserConfig => {
if (id.includes('element-plus')) return 'element-plus';
if (id.includes('ant-design-vue')) return 'ant-design-vue';
if (id.includes('lodash')) return 'lodash';
if (id.includes('cesium')) return 'cesium';
// 其他 node_modules 归为 vendor
return 'vendor';
}

View File

@ -326,3 +326,156 @@ export function deleteFlowStationInfo(data) {
data
});
}
// 水温监测 - 分页列表
export function getWtPageList(data) {
return request({
url: '/base/wt/page',
method: 'post',
data
});
}
// 水质监测 - 分页列表
export function getWqPageList(data) {
return request({
url: '/base/wq/page',
method: 'post',
data
});
}
// 生态流量 - 分页列表
export function getEqPageList(data) {
return request({
url: '/base/eq/page',
method: 'post',
data
});
}
// 低温水减缓设施 - 分页列表
export function getDwPageList(data) {
return request({
url: '/base/dw/page',
method: 'post',
data
});
}
// 栖息地 - 分页列表
export function getFhPageList(data) {
return request({
url: '/base/fhbt/page',
method: 'post',
data
});
}
// 珍稀植物园 - 分页列表
export function getVpPageList(data) {
return request({
url: '/base/vp/page',
method: 'post',
data
});
}
// 动物救助站 - 分页列表
export function getVaPageList(data) {
return request({
url: '/base/va/page',
method: 'post',
data
});
}
// 过鱼设施 - 分页列表
export function getFpPageList(data) {
return request({
url: '/env/fpss/page',
method: 'post',
data
});
}
// 水生生态 - 分页列表
export function getWePageList(data) {
return request({
url: '/base/we/page',
method: 'post',
data
});
}
// 鱼类调查 - 分页列表
export function getAiPageList(data) {
return request({
url: '/base/aimonitor/page',
method: 'post',
data
});
}
// 智能告警 - 分页列表
export function getWarnPageList(data) {
return request({
url: '/base/warnRule/page',
method: 'post',
data
});
}
// AI边缘计算盒子 - 分页列表
export function getAiboxPageList(data) {
return request({
url: '/base/aibox/page',
method: 'post',
data
});
}
// 人工产卵场 - 分页列表
export function getSgPageList(data) {
return request({
url: '/base/artsg/page',
method: 'post',
data
});
}
// 其他水生生态保护 - 分页列表
export function getOtwePageList(data) {
return request({
url: '/base/otwe/page',
method: 'post',
data
});
}
// 陆生生态 - 分页列表
export function getTePageList(data) {
return request({
url: '/base/te/page',
method: 'post',
data
});
}
// 其他陆生生态保护 - 分页列表
export function getOttePageList(data) {
return request({
url: '/base/otte/page',
method: 'post',
data
});
}
// 视频监控 - 分页列表
export function getVdPageList(data) {
return request({
url: '/base/vdinfo/page',
method: 'post',
data
});
}
// 鱼类增殖站 - 分页列表
export function getFbPageList(data) {
return request({
url: '/base/fbrd/page',
method: 'post',
data
});
}
// 声呐及水下摄像头 - 分页列表
export function getSonarPageList(data) {
return request({
url: '/base/sonar/page',
method: 'post',
data
});
}

View File

@ -668,7 +668,19 @@ const enhancedColumns = computed(() => {
title: String(text)
},
{
default: () => String(text)
default: () => h(
'span',
{
style: {
display: 'inline-block',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}
},
String(text)
)
}
)
);

View File

@ -36,7 +36,6 @@ defineOptions({
const emit = defineEmits<{
(e: 'row-click', record: any): void;
}>();
const JidiSelectEventStore = useJidiSelectEventStore();
const baseid = ref('');
const dataLoading = ref(false);
@ -169,7 +168,6 @@ const getData = async () => {
};
});
});
const result = { total: 0, buildinstall: 0, ubuildinstall: 0, nbuildinstall: 0 };
list.forEach((e: any) => {
result.total += Number(e.total) * 100;
@ -180,16 +178,15 @@ const getData = async () => {
const { total, nbuildinstall, ubuildinstall, buildinstall } = newList[e.key];
newList[e.key] = {
...e,
total: Number(total) + Number(e.total),
nbuildinstall: Number(nbuildinstall) + Number(e.nbuildinstall),
ubuildinstall: Number(ubuildinstall) + Number(e.ubuildinstall),
buildinstall: Number(buildinstall) + Number(e.buildinstall)
total: (Number(total) + Number(e.total)).toFixed(2),
nbuildinstall: (Number(nbuildinstall) + Number(e.nbuildinstall)).toFixed(2),
ubuildinstall: (Number(ubuildinstall) + Number(e.ubuildinstall)).toFixed(2),
buildinstall: (Number(buildinstall) + Number(e.buildinstall)).toFixed(2)
};
} else {
newList[e.key] = e;
}
});
const { total, buildinstall, ubuildinstall, nbuildinstall } = result;
const arr: any[] = keyList.map((key) => newList[key]);
tableData.value = [
@ -202,6 +199,7 @@ const getData = async () => {
nbuildinstall: (nbuildinstall / 100)?.toFixed(2)
}
];
} else {
tableData.value = [];
}

View File

@ -342,7 +342,7 @@ const openUrl = (url:string)=>{
}
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY();
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>

View File

@ -27,25 +27,6 @@
}"
:list-url="getPowerTableList"
>
<template #action="{ record }">
<a-button
type="link"
class="text-[#2f6b98]"
size="small"
@click="handleDetail(record)"
>查看详情</a-button
>
<a-button
type="link"
class="text-[#2f6b98]"
size="small"
@click="handleEdit(record)"
>编辑</a-button
>
<a-button type="link" danger size="small" @click="handleDelete(record)"
>删除</a-button
>
</template>
</BasicTable>
<!-- 自定义数据列 Modal -->
@ -281,7 +262,7 @@ const exportBtn = () => {
const customFilterBtn = () => {
customColumnVisible.value = true;
//
fetchColumnConfig();
// fetchColumnConfig();
};
//
@ -501,24 +482,12 @@ const fetchColumnConfig = () => {
});
//
cols.push({
key: 'action',
title: '操作',
dataIndex: 'action',
fixed: 'right',
width: isEdit.value ? 180 : 100
});
columns.value = cols;
}).catch(() => {
// API
columns.value = [{
key: 'action',
title: '操作',
dataIndex: 'action',
fixed: 'right',
width: isEdit.value ? 180 : 100
}];
}).finally(() => {
columnLoaded.value = true;
//
@ -531,7 +500,7 @@ onMounted(() => {
init();
fetchColumnConfig();
nextTick(() => {
tableScrollY.value = calcTableScrollY();
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>

View File

@ -0,0 +1,77 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
(e: 'seeEdit'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'stnm',
label: '站名',
placeholder: '请输入站名',
width: 160,
fieldProps: {
allowClear: true
}
}
]);
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
onMounted(() => {
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script>

View File

@ -0,0 +1,214 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<!-- 搜索组件 -->
<AiBoxSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<!-- 表格组件 -->
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getAiboxPageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import AiBoxSearch from './AiBoxSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getAiboxPageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
//
const editVisible = ref(false);
const editRecord = ref<any>(null);
const isAdd = ref(false);
const deleteModalRef = ref();
const operationLogVisible = ref(false);
const columns = ref<any[]>([
{
key: 'stnm',
title: '站名',
dataIndex: 'stnm',
visible: true,
width: 200,
fixed: 'left',
ellipsis: true
},
{
key: 'stcd',
title: '站码',
dataIndex: 'stcd',
visible: true,
width: 200,
ellipsis: true
},
{
key: 'sttpName',
title: '站类',
dataIndex: 'sttpName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'stlc',
title: '站址',
dataIndex: 'stlc',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'mwayName',
title: '监测方式',
dataIndex: 'mwayName',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'usflName',
title: '是否启用',
dataIndex: 'usflName',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'ipaddr',
title: 'IP地址',
dataIndex: 'ipaddr',
visible: true,
width: 140,
ellipsis: true
},
{
key: 'purpose',
title: '用途',
dataIndex: 'purpose',
visible: true,
ellipsis: true
},
{
key: 'remark',
title: '备注',
dataIndex: 'remark',
visible: true,
ellipsis: true
},
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => {
return {
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null
].filter(Boolean)
};
};
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,118 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
(e: 'seeEdit'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const sttpOption = ref([]);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null,
sttp: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'stnm',
label: '工程名称',
placeholder: '请输入工程名称',
width: 160,
fieldProps: {
allowClear: true
}
},
{
col: isHide.value ? 0 : null,
type: 'Select',
name: 'sttp',
label: '测站类型',
width: 160,
fieldProps: {
allowClear: true,
placeholder: '请选择测站类型'
},
options: sttpOption.value
}
]);
const getBaseList = async () => {
let params = {
"filter": {
"logic": "and",
"filters": [
{
"field": "sttpCode",
"operator": "in",
"dataType": "string",
"value": ["OTWE"]
}
]
}
};
const res = await sttpGetKendoList(params);
sttpOption.value = res.data.data.map(item => {
return {
label: item.sttpName,
value: item.sttpCode
};
});
};
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
onMounted(() => {
getBaseList();
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script>

View File

@ -0,0 +1,203 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<!-- 搜索组件 -->
<OtweSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<!-- 表格组件 -->
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getOtwePageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import OtweSearch from './OtweSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getOtwePageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const columns = ref<any[]>([
{
key: 'stnm',
title: '工程名称',
dataIndex: 'stnm',
visible: true,
width: 200,
fixed: 'left',
ellipsis: true
},
{
key: 'stcd',
title: '工程编码',
dataIndex: 'stcd',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'sttpName',
title: '测站类型',
dataIndex: 'sttpName',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'stlc',
title: '位置',
dataIndex: 'stlc',
visible: true,
width: 200,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'usflName',
title: '是否启用',
dataIndex: 'usflName',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'inv',
title: '投资(亿元)',
dataIndex: 'inv',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'remark',
title: '备注',
dataIndex: 'remark',
visible: true,
ellipsis: true
},
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => {
return {
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
};
};
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,117 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
(e: 'seeEdit'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const sttpOption = ref([]);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null,
sttp: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'stnm',
label: '站名',
placeholder: '请输入站名',
width: 160,
fieldProps: {
allowClear: true
}
},
{
col: isHide.value ? 0 : null,
type: 'Select',
name: 'sttp',
label: '测站类型',
width: 160,
fieldProps: {
allowClear: true,
placeholder: '请选择测站类型'
},
options: sttpOption.value
}
]);
const getBaseList = async () => {
let params = {
"filter": {
"logic": "and",
"filters": [
{
"field": "sttpCode",
"operator": "in",
"dataType": "string",
"value": ["AI_5014", "WVA", "AI_5005", "FPRD", "AI_5012", "AI_5001", "AI_5006", "AI_5004", "AI_5007", "AI_5002", "AI_5008", "AI_5003", "AI_5010"]
}
]
}
};
const res = await sttpGetKendoList(params);
sttpOption.value = res.data.data.map(item => {
return {
label: item.sttpName,
value: item.sttpCode
};
});
};
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
onMounted(() => {
getBaseList();
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script>

View File

@ -0,0 +1,222 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<!-- 搜索组件 -->
<FishSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<!-- 表格组件 -->
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getAiPageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import FishSearch from './FishSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getAiPageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
//
const editVisible = ref(false);
const editRecord = ref<any>(null);
const isAdd = ref(false);
const deleteModalRef = ref();
const operationLogVisible = ref(false);
const columns = ref<any[]>([
{
key: 'stnm',
title: '站名',
dataIndex: 'stnm',
visible: true,
width: 200,
ellipsis: true
},
{
key: 'stcd',
title: '站码',
dataIndex: 'stcd',
visible: true,
width: 200,
fixed: 'left',
ellipsis: true
},
{
key: 'sttpName',
title: '测站类型',
dataIndex: 'sttpName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'stlc',
title: '站址',
dataIndex: 'stlc',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'mwayName',
title: '监测方式',
dataIndex: 'mwayName',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'usflName',
title: '是否启用',
dataIndex: 'usflName',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'ipaddr',
title: 'IP地址',
dataIndex: 'ipaddr',
visible: true,
width: 140,
ellipsis: true
},
{
key: 'purpose',
title: '用途',
dataIndex: 'purpose',
visible: true,
ellipsis: true
},
{
key: 'remark',
title: '备注',
dataIndex: 'remark',
visible: true,
ellipsis: true
}
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => {
return {
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
};
};
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,40 @@
<template>
<BasicSearch ref="basicSearchRef" :searchList="searchList" :initial-values="initSearchData" :zhujianfujian="'fu'" @reset="handleReset" @finish="onSearchFinish">
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{ (e: 'search-finish', values: any): void; (e: 'reset', values: any): void; (e: 'add'): void; (e: 'seeEdit'): void }>();
const basicSearchRef = ref();
const isHide = ref(false);
const sttpOption = ref([]);
const initSearchData = { baseId: 'all', rstcd: null, stnm: null, sttp: null };
const searchList: any = computed(() => [
{ type: 'jidiData', name: 'baseId', label: '水电基地', placeholder: '请输入水电基地名称', fieldProps: { allowClear: true }, options: [] },
{ type: 'Input', name: 'stnm', label: '设施名称', placeholder: '请输入设施名称', width: 160, fieldProps: { allowClear: true } },
{ col: isHide.value ? 0 : null, type: 'Select', name: 'sttp', label: '设施类型', width: 160, fieldProps: { allowClear: true, placeholder: '请选择设施类型' }, options: sttpOption.value }
]);
const getBaseList = async () => {
const res = await sttpGetKendoList({
filter: { logic: 'and', filters: [{ field: 'sttpCode', operator: 'in', dataType: 'string', value: ['FP', 'FP_1', 'FP_2', 'FP_3', 'FP_4', 'FP_5'] }] }
});
sttpOption.value = res.data.data.map(item => ({ label: item.sttpName, value: item.sttpCode }));
};
const onSearchFinish = (values: any) => emit('search-finish', { ...values });
const hideBtn = () => { isHide.value = !isHide.value; };
const handleReset = () => emit('reset', initSearchData);
onMounted(() => { getBaseList(); emit('search-finish', { ...initSearchData }); });
defineExpose({ basicSearchRef });
</script>

View File

@ -0,0 +1,219 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<FpSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getFpPageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import FpSearch from './FpSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getFpPageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const bldsttMap: Record<number, string> = { 0: '规划', 1: '在建', 2: '已建' };
const columns = ref<any[]>([
{
key: 'stnm',
title: '设施名称',
dataIndex: 'stnm',
visible: true,
width: 200,
ellipsis: true
},
{
key: 'stcd',
title: '设施编码',
dataIndex: 'stcd',
visible: true,
width: 180,
fixed: 'left',
ellipsis: true
},
{
key: 'sttpName',
title: '设施类型',
dataIndex: 'sttpName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 120,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'bldsttCode',
title: '建设状态',
dataIndex: 'bldsttCode',
visible: true,
width: 80,
customRender: ({ text }) =>
text !== null && text !== undefined ? bldsttMap[text] || text : '-'
},
{
key: 'sjgycnt',
title: '过鱼规模(尾)',
dataIndex: 'sjgycnt',
visible: true,
width: 120
},
{
key: 'inv',
title: '投资(亿元)',
dataIndex: 'inv',
visible: true,
width: 120
},
{
key: 'mwayName',
title: '监测方式',
dataIndex: 'mwayName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'swdt',
title: '开工日期',
dataIndex: 'swdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'stlc',
title: '站址/位置',
dataIndex: 'stlc',
visible: true,
ellipsis: true
}
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => ({
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all' && values.baseId != null
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
});
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
tableRef.value.getList(buildSearchParams(values));
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,117 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
(e: 'seeEdit'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const sttpOption = ref([]);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null,
sttp: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'stnm',
label: '断面名称',
placeholder: '请输入断面名称',
width: 160,
fieldProps: {
allowClear: true
}
},
{
col: isHide.value ? 0 : null,
type: 'Select',
name: 'sttp',
label: '测站类型',
width: 160,
fieldProps: {
allowClear: true,
placeholder: '请选择测站类型'
},
options: sttpOption.value
}
]);
const getBaseList = async () => {
let params = {
"filter": {
"logic": "and",
"filters": [
{
"field": "sttpCode",
"operator": "in",
"dataType": "string",
"value": ["TE"]
}
]
}
};
const res = await sttpGetKendoList(params);
sttpOption.value = res.data.data.map(item => {
return {
label: item.sttpName,
value: item.sttpCode
};
});
};
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
onMounted(() => {
getBaseList();
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script>

View File

@ -0,0 +1,186 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<!-- 搜索组件 -->
<TeSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<!-- 表格组件 -->
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getTePageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import TeSearch from './TeSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getTePageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const columns = ref<any[]>([
{
key: 'stnm',
title: '断面名称',
dataIndex: 'stnm',
visible: true,
width: 200,
ellipsis: true
},
{
key: 'stcd',
title: '断面编码',
dataIndex: 'stcd',
visible: true,
width: 200,
fixed: 'left',
ellipsis: true
},
{
key: 'sttpName',
title: '测站类型',
dataIndex: 'sttpName',
visible: true,
width: 140,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'stlc',
title: '站址',
dataIndex: 'stlc',
visible: true,
width: 200,
ellipsis: true
},
{
key: 'remark',
title: '备注',
dataIndex: 'remark',
visible: true,
ellipsis: true
},
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => {
return {
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
};
};
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,121 @@
<!-- <template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
<a-button type="primary" @click="handleAdd">新增</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const sttpOption = ref([]);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null,
sttp: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'stnm',
label: '站点名称',
placeholder: '请输入站点名称',
width: 160,
fieldProps: {
allowClear: true
}
},
{
col: isHide.value ? 0 : null,
type: 'Select',
name: 'sttp',
label: '测站类型',
width: 160,
fieldProps: {
allowClear: true,
placeholder: '请选择测站类型'
},
options: sttpOption.value
}
]);
const getBaseList = async () => {
let params = {
"filter": {
"logic": "and",
"filters": [
{
"field": "sttpCode",
"operator": "in",
"dataType": "string",
"value": ["OSGB"]
}
]
}
};
const res = await sttpGetKendoList(params);
sttpOption.value = res.data.data.map(item => {
return {
label: item.sttpName,
value: item.sttpCode
};
});
};
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
const handleAdd = () => {
emit('add');
};
onMounted(() => {
getBaseList();
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script> -->

View File

@ -0,0 +1,275 @@
<!-- <template>
<div class="w-full h-full flex flex-col pt-[20px] pl-[20px] body_one">
<PhotoSearch
@search-finish="onSearchFinish"
@reset="onReset"
@add="handleAdd"
ref="searchRef"
/>
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getOpPageList"
>
<template #action="{ record }">
<a-button
type="link"
class="text-[#2f6b98]"
size="small"
@click="handleEdit(record)"
>编辑</a-button
>
<a-button type="link" danger size="small" @click="handleDelete(record)"
>删除</a-button
>
</template>
</BasicTable>
<EditPhotoModal
v-model:open="editVisible"
:record="editRecord"
:is-add="isAdd"
@success="handleEditSuccess"
/>
<DeleteConfirmModal
ref="deleteModalRef"
:delete-fn="deleteOpFn"
:label="'倾斜摄影站点'"
title="删除倾斜摄影站点"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import PhotoSearch from './PhotoSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import EditPhotoModal from './EditPhotoModal.vue';
import DeleteConfirmModal from '@/views/conventionalHydropower/BasicData/DeleteConfirmModal.vue';
import { getOpPageList, deleteOpInfo } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const searchRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
//
const editVisible = ref(false);
const editRecord = ref<any>(null);
const isAdd = ref(false);
const deleteModalRef = ref();
const columns = ref<any[]>([
{
key: 'stnm',
title: '站点名称',
dataIndex: 'stnm',
visible: true,
width: 200,
fixed: 'left',
ellipsis: true
},
{
key: 'stcd',
title: '站点编码',
dataIndex: 'stcd',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'sttpName',
title: '测站类型',
dataIndex: 'sttpName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'stlc',
title: '位置',
dataIndex: 'stlc',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'url',
title: '在线地址',
dataIndex: 'url',
visible: true,
width: 200,
ellipsis: true
},
{
key: 'usflName',
title: '是否启用',
dataIndex: 'usflName',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'remark',
title: '备注',
dataIndex: 'remark',
visible: true,
ellipsis: true
},
{
key: 'action',
title: '操作',
dataIndex: 'action',
fixed: 'right',
width: 120
}
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => {
return {
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
};
};
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
//
const handleAdd = () => {
isAdd.value = true;
editRecord.value = null;
editVisible.value = true;
};
//
const handleEdit = (record: any) => {
isAdd.value = false;
editRecord.value = { ...record };
editVisible.value = true;
};
//
const handleEditSuccess = () => {
initTable(currentSearchParams.value);
};
//
const deleteOpFn = async (record: any, reason: string) => {
return deleteOpInfo({
ids: [record.stcd],
source: reason
});
};
const handleDelete = (record: any) => {
deleteModalRef.value?.open(record, () => {
initTable(currentSearchParams.value);
});
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY();
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style> -->

View File

@ -0,0 +1,80 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
(e: 'seeEdit'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: { allowClear: true },
options: []
},
{
type: 'Input',
name: 'stnm',
label: '栖息地名称',
placeholder: '请输入栖息地名称',
width: 160,
fieldProps: { allowClear: true }
}
]);
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
const handleAdd = () => {
emit('add');
};
const seeEdit = () => {
emit('seeEdit');
};
onMounted(() => {
emit('search-finish', { ...initSearchData });
});
defineExpose({ basicSearchRef });
</script>

View File

@ -0,0 +1,74 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<FhSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getFhPageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import FhSearch from './FhSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getFhPageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const columns = ref<any[]>([
{ key: 'stnm', title: '栖息地名称', dataIndex: 'stnm', visible: true, width: 260, ellipsis: true },
{ key: 'stcd', title: '栖息地站码', dataIndex: 'stcd', visible: true, width: 320, ellipsis: true },
{ key: 'baseName', title: '水电基地', dataIndex: 'baseName', visible: true, width: 120, ellipsis: true },
{ key: 'ennm', title: '所属电站', dataIndex: 'ennm', visible: true, width: 150, ellipsis: true },
{ key: 'bhdxName', title: '保护对象', dataIndex: 'bhdxName', visible: true, width: 240, ellipsis: true },
{ key: 'bhhl', title: '保护河流', dataIndex: 'bhhl', visible: true, width: 120, ellipsis: true },
{ key: 'bhhd', title: '保护河段', dataIndex: 'bhhd', visible: true, width: 240, ellipsis: true },
{ key: 'bhfw', title: '保护范围', dataIndex: 'bhfw', visible: true, width: 120, ellipsis: true },
{ key: 'bhhxcd', title: '保护核心长度(km)', dataIndex: 'bhhxcd', visible: true, width: 140 },
{ key: 'bhcs', title: '保护措施', dataIndex: 'bhcs', visible: true, width: 120, ellipsis: true },
{ key: 'bhfs', title: '保护方式', dataIndex: 'bhfs', visible: true, width: 120, ellipsis: true },
{ key: 'inv', title: '投资(亿元)', dataIndex: 'inv', visible: true, width: 120 },
{ key: 'stlc', title: '站址', dataIndex: 'stlc', visible: true, ellipsis: true,width: 220 },
{ key: 'layerCode', title: '图层编码', dataIndex: 'layerCode', visible: true, ellipsis: true, width: 160 },
]);
const tableScrollX = computed(() =>
Math.max(columns.value.reduce((sum: number, col: any) => sum + (col.width || 180), 0), 600)
);
const buildSearchParams = (values: any) => ({
logic: 'and',
filters: [
values.rstcd ? { field: 'rstcd', operator: 'eq', dataType: 'string', value: values.rstcd } : null,
values.baseId !== 'all' && values.baseId != null ? { field: 'baseId', operator: 'eq', dataType: 'string', value: values.baseId } : null,
values.stnm ? { field: 'stnm', operator: 'contains', dataType: 'string', value: values.stnm } : null
].filter(Boolean)
});
const onSearchFinish = (values: any) => { currentSearchParams.value = values; initTable(values); };
const onReset = (values: any) => { currentSearchParams.value = values; initTable(values); };
const initTable = (values: any) => { tableRef.value.getList(buildSearchParams(values)); };
onMounted(() => { nextTick(() => { tableScrollY.value = calcTableScrollY(tableRef.value); }); });
</script>
<style scoped lang="scss">
.body_one { position: relative; z-index: 900; pointer-events: all; }
</style>

View File

@ -0,0 +1,118 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
(e: 'seeEdit'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const sttpOption = ref([]);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null,
sttp: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'stnm',
label: '救助站名称',
placeholder: '请输入救助站名称',
width: 160,
fieldProps: {
allowClear: true
}
},
{
col: isHide.value ? 0 : null,
type: 'Select',
name: 'sttp',
label: '测站类型',
width: 160,
fieldProps: {
allowClear: true,
placeholder: '请选择测站类型'
},
options: sttpOption.value
}
]);
const getBaseList = async () => {
let params = {
"filter": {
"logic": "and",
"filters": [
{
"field": "sttpCode",
"operator": "in",
"dataType": "string",
"value": ["VA"]
}
]
}
};
const res = await sttpGetKendoList(params);
sttpOption.value = res.data.data.map(item => {
return {
label: item.sttpName,
value: item.sttpCode
};
});
};
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
onMounted(() => {
getBaseList();
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script>

View File

@ -0,0 +1,185 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<VaSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getVaPageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import VaSearch from './VaSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getVaPageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref(),
tableScrollY = ref<string | number>(0),
currentSearchParams = ref<any>({});
const columns = ref<any[]>([
{
key: 'stnm',
title: '救助站名称',
dataIndex: 'stnm',
visible: true,
width: 200,
fixed: 'left',
ellipsis: true
},
{
key: 'stcd',
title: '救助站编码',
dataIndex: 'stcd',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'sttpName',
title: '测站类型',
dataIndex: 'sttpName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'stlc',
title: '站址',
dataIndex: 'stlc',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'place',
title: '地点',
dataIndex: 'place',
visible: true,
width: 120,
ellipsis: true
},
{
key: 'usflName',
title: '是否启用',
dataIndex: 'usflName',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'remark',
title: '备注',
dataIndex: 'remark',
visible: true,
ellipsis: true
},
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => ({
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
});
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,42 @@
<template>
<BasicSearch ref="basicSearchRef" :searchList="searchList" :initial-values="initSearchData" :zhujianfujian="'fu'" @reset="handleReset" @finish="onSearchFinish">
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{ (e: 'search-finish', values: any): void; (e: 'reset', values: any): void; (e: 'add'): void; (e: 'seeEdit'): void }>();
const basicSearchRef = ref();
const isHide = ref(false);
const sttpOption = ref([]);
const initSearchData = { baseId: 'all', rstcd: null, stnm: null, sttp: null };
const searchList: any = computed(() => [
{ type: 'jidiData', name: 'baseId', label: '水电基地', placeholder: '请输入水电基地名称', fieldProps: { allowClear: true }, options: [] },
{ type: 'Input', name: 'stnm', label: '设施名称', placeholder: '请输入设施名称', width: 160, fieldProps: { allowClear: true } },
{ col: isHide.value ? 0 : null, type: 'Select', name: 'sttp', label: '设施类型', width: 160, fieldProps: { allowClear: true, placeholder: '请选择设施类型' }, options: sttpOption.value }
]);
const getBaseList = async () => {
const res = await sttpGetKendoList({
filter: { logic: 'and', filters: [{ field: 'sttpCode', operator: 'in', dataType: 'string', value: ['EQ', 'EQ_1', 'EQ_2', 'EQ_3', 'EQ_4', 'EQ_5', 'EQ_6'] }] }
});
sttpOption.value = res.data.data.map(item => ({ label: item.sttpName, value: item.sttpCode }));
};
const onSearchFinish = (values: any) => emit('search-finish', { ...values });
const hideBtn = () => { isHide.value = !isHide.value; };
const handleReset = () => emit('reset', initSearchData);
const handleAdd = () => emit('add');
const seeEdit = () => emit('seeEdit');
onMounted(() => { getBaseList(); emit('search-finish', { ...initSearchData }); });
defineExpose({ basicSearchRef });
</script>

View File

@ -0,0 +1,201 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<EqSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getEqPageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import EqSearch from './EqSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getEqPageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const bldsttMap: Record<number, string> = { 0: '规划', 1: '在建', 2: '已建' };
const columns = ref<any[]>([
{
key: 'stnm',
title: '设施名称',
dataIndex: 'stnm',
visible: true,
width: 160,
fixed: 'left',
ellipsis: true
},
{
key: 'stcd',
title: '设施编码',
dataIndex: 'stcd',
visible: true,
width: 180,
ellipsis: true
},
{
key: 'sttpName',
title: '设施类型',
dataIndex: 'sttpName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 120,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'bldsttCode',
title: '建设状态',
dataIndex: 'bldsttCode',
visible: true,
width: 80,
customRender: ({ text }) =>
text !== null && text !== undefined ? bldsttMap[text] || text : '-'
},
{
key: 'minFlow',
title: '最小下泄流量(m³/s)',
dataIndex: 'minFlow',
visible: true,
width: 150
},
{
key: 'swdt',
title: '开工日期',
dataIndex: 'swdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'stlc',
title: '站址/位置',
dataIndex: 'stlc',
visible: true,
ellipsis: true
},
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => ({
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all' && values.baseId != null
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
});
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
tableRef.value.getList(buildSearchParams(values));
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,40 @@
<template>
<BasicSearch ref="basicSearchRef" :searchList="searchList" :initial-values="initSearchData" :zhujianfujian="'fu'" @reset="handleReset" @finish="onSearchFinish">
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{ (e: 'search-finish', values: any): void; (e: 'reset', values: any): void; (e: 'add'): void; (e: 'seeEdit'): void }>();
const basicSearchRef = ref();
const isHide = ref(false);
const sttpOption = ref([]);
const initSearchData = { baseId: 'all', rstcd: null, stnm: null, sttp: null };
const searchList: any = computed(() => [
{ type: 'jidiData', name: 'baseId', label: '水电基地', placeholder: '请输入水电基地名称', fieldProps: { allowClear: true }, options: [] },
{ type: 'Input', name: 'stnm', label: '监控点名', placeholder: '请输入监控点名', width: 160, fieldProps: { allowClear: true } },
{ col: isHide.value ? 0 : null, type: 'Select', name: 'sttp', label: '监控点类型', width: 160, fieldProps: { allowClear: true, placeholder: '请选择监控点类型' }, options: sttpOption.value }
]);
const getBaseList = async () => {
const res = await sttpGetKendoList({
filter: { logic: 'and', filters: [{ field: 'sttpCode', operator: 'in', dataType: 'string', value: ['VD', 'VD_DW', 'VD_EQ', 'VD_EQS', 'VD_FB', 'VD_FBFM', 'VD_FBI', 'VD_FBP', 'VD_FC', 'VD_FH', 'VD_FP', 'VD_FPB', 'VD_FPC', 'VD_GZFC', 'VD_OTTE', 'VD_OTWE', 'VD_PR', 'VD_SG', 'VD_TE', 'VD_VA', 'VD_VP', 'VD_WE', 'VD_WQ', 'VD_WT'] }] }
});
sttpOption.value = res.data.data.map(item => ({ label: item.sttpName, value: item.sttpCode }));
};
const onSearchFinish = (values: any) => emit('search-finish', { ...values });
const hideBtn = () => { isHide.value = !isHide.value; };
const handleReset = () => emit('reset', initSearchData);
onMounted(() => { getBaseList(); emit('search-finish', { ...initSearchData }); });
defineExpose({ basicSearchRef });
</script>

View File

@ -0,0 +1,165 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<VdSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getVdPageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import VdSearch from './VdSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getVdPageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const columns = ref<any[]>([
{
key: 'stnm',
title: '监控点名',
dataIndex: 'stnm',
visible: true,
width: 200,
ellipsis: true
},
{
key: 'stcd',
title: '监控点编码',
dataIndex: 'stcd',
visible: true,
width: 300,
fixed: 'left',
ellipsis: true
},
{
key: 'sttpName',
title: '监控点类型',
dataIndex: 'sttpName',
visible: true,
width: 140,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 120,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'addvcdName',
title: '所属行政区',
dataIndex: 'addvcdName',
visible: true,
width: 120,
ellipsis: true
},
{
key: 'stlc',
title: '监控点地址',
dataIndex: 'stlc',
visible: true,
ellipsis: true
},
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => ({
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all' && values.baseId != null
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
});
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
tableRef.value.getList(buildSearchParams(values));
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,116 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
(e: 'seeEdit'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const sttpOption = ref([]);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null,
sttp: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'stnm',
label: '断面名称',
placeholder: '请输入断面名称',
width: 160,
fieldProps: {
allowClear: true
}
},
{
col: isHide.value ? 0 : null,
type: 'Select',
name: 'sttp',
label: '测站类型',
width: 160,
fieldProps: {
allowClear: true,
placeholder: '请选择测站类型'
},
options: sttpOption.value
}
]);
const getBaseList = async () => {
let params = {
"filter": {
"logic": "and",
"filters": [
{
"field": "sttpCode",
"operator": "in",
"dataType": "string",
"value": ["WE"]
}
]
}
};
const res = await sttpGetKendoList(params);
sttpOption.value = res.data.data.map(item => {
return {
label: item.sttpName,
value: item.sttpCode
};
});
};
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
onMounted(() => {
getBaseList();
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script>

View File

@ -0,0 +1,206 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<!-- 搜索组件 -->
<WeSearch
@search-finish="onSearchFinish"
@reset="onReset"
@add="handleAdd"
@seeEdit="handleSeeEdit"
ref="searchRef"
/>
<!-- 表格组件 -->
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getWePageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import WeSearch from './WeSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getWePageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
//
const editVisible = ref(false);
const editRecord = ref<any>(null);
const isAdd = ref(false);
const deleteModalRef = ref();
const operationLogVisible = ref(false);
const columns = ref<any[]>([
{
key: 'stnm',
title: '断面名称',
dataIndex: 'stnm',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'stcd',
title: '断面编码',
dataIndex: 'stcd',
visible: true,
width: 280,
fixed: 'left',
ellipsis: true
},
{
key: 'sttpName',
title: '测站类型',
dataIndex: 'sttpName',
visible: true,
width: 150,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'stlc',
title: '站址',
dataIndex: 'stlc',
visible: true,
width: 240,
ellipsis: true
},
{
key: 'remark',
title: '备注',
dataIndex: 'remark',
visible: true,
ellipsis: true
},
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => {
return {
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
};
};
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
//
const handleAdd = () => {
isAdd.value = true;
editRecord.value = null;
editVisible.value = true;
};
const handleSeeEdit = () => {
operationLogVisible.value = true;
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,116 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
(e: 'seeEdit'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const sttpOption = ref([]);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null,
sttp: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'stnm',
label: '测站名称',
placeholder: '请输入测站名称',
width: 160,
fieldProps: {
allowClear: true
}
},
{
col: isHide.value ? 0 : null,
type: 'Select',
name: 'sttp',
label: '测站类型',
width: 160,
fieldProps: {
allowClear: true,
placeholder: '请选择测站类型'
},
options: sttpOption.value
}
]);
const getBaseList = async () => {
let params = {
"filter": {
"logic": "and",
"filters": [
{
"field": "sttpCode",
"operator": "in",
"dataType": "string",
"value": ["WT", "WTRV", "WTVT"]
}
]
}
};
const res = await sttpGetKendoList(params);
sttpOption.value = res.data.data.map(item => {
return {
label: item.sttpName,
value: item.sttpCode
};
});
};
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
onMounted(() => {
getBaseList();
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script>

View File

@ -0,0 +1,188 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<!-- 搜索组件 -->
<WtSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<!-- 表格组件 -->
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getWtPageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import WtSearch from './WtSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getWtPageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const columns = ref<any[]>([
{
key: 'stnm',
title: '测站名称',
dataIndex: 'stnm',
visible: true,
width: 240,
ellipsis: true
},
{
key: 'stcd',
title: '水温站站码',
dataIndex: 'stcd',
visible: true,
width: 200,
fixed: 'left',
ellipsis: true
},
{
key: 'sttpName',
title: '测站类型',
dataIndex: 'sttpName',
visible: true,
width:80,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'mwayName',
title: '监测方式',
dataIndex: 'mwayName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'stlc',
title: '站址',
dataIndex: 'stlc',
visible: true,
ellipsis: true
},
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => {
return {
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
};
};
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,118 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
(e: 'seeEdit'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const sttpOption = ref([])
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null,
sttp: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'stnm',
label: '测站名称',
placeholder: '请输入测站名称',
width: 160,
fieldProps: {
allowClear: true
}
},
{
col: isHide.value ? 0 : null,
type: 'Select',
name: 'sttp',
label: '测站类型',
width: 160,
fieldProps: {
allowClear: true,
placeholder: '请选择测站类型'
},
options:sttpOption.value
}
]);
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
const getBaseList = async () => {
let params = {
"filter":
{
"logic": "and",
"filters":
[
{
"field": "sttpCode",
"operator": "in",
"dataType": "string",
"value": ["WQ", "WQFB", "WQFP", "WQH"]
}
]
}
}
const res = await sttpGetKendoList(params);
sttpOption.value = res.data.data.map(item => {
return {
label: item.sttpName,
value: item.sttpCode
};
});
};
onMounted(() => {
getBaseList()
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script>

View File

@ -0,0 +1,189 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<WqSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getWqPageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import WqSearch from './WqSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getWqPageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const columns = ref<any[]>([
{
key: 'stnm',
title: '测站名称',
dataIndex: 'stnm',
visible: true,
width: 150,
ellipsis: true
},
{
key: 'stcd',
title: '水质站站码',
dataIndex: 'stcd',
visible: true,
width: 180,
fixed: 'left',
ellipsis: true
},
{
key: 'sttpName',
title: '测站类型',
dataIndex: 'sttpName',
visible: true,
width: 120,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 120,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 150,
ellipsis: true
},
{
key: 'dtinTypeName',
title: '类别',
dataIndex: 'dtinTypeName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'wwqtgName',
title: '水质要求',
dataIndex: 'wwqtgName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'mwayName',
title: '监测方式',
dataIndex: 'mwayName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'stlc',
title: '站址',
dataIndex: 'stlc',
visible: true,
ellipsis: true
},
{
key: 'action',
title: '操作',
dataIndex: 'action',
fixed: 'right',
width: 120
}
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => {
return {
logic: 'and',
filters: [
values.rstcd
? { field: 'rstcd', operator: 'eq', dataType: 'string', value: values.rstcd }
: null,
values.baseId !== 'all' && values.baseId != null
? { field: 'baseId', operator: 'eq', dataType: 'string', value: values.baseId }
: null,
values.stnm
? { field: 'stnm', operator: 'contains', dataType: 'string', value: values.stnm }
: null,
values.sttp
? { field: 'sttp', operator: 'eq', dataType: 'string', value: values.sttp }
: null
].filter(Boolean)
};
};
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,117 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
(e: 'seeEdit'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const sttpOption = ref([]);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null,
sttp: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'stnm',
label: '站名',
placeholder: '请输入站名',
width: 160,
fieldProps: {
allowClear: true
}
},
{
col: isHide.value ? 0 : null,
type: 'Select',
name: 'sttp',
label: '监控类别',
width: 160,
fieldProps: {
allowClear: true,
placeholder: '请选择监控类别'
},
options: sttpOption.value
}
]);
const getBaseList = async () => {
let params = {
"filter": {
"logic": "and",
"filters": [
{
"field": "sttpCode",
"operator": "in",
"dataType": "string",
"value": ["VD_SN", "VD_WVD"]
}
]
}
};
const res = await sttpGetKendoList(params);
sttpOption.value = res.data.data.map(item => {
return {
label: item.sttpName,
value: item.sttpCode
};
});
};
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
onMounted(() => {
getBaseList();
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script>

View File

@ -0,0 +1,202 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<!-- 搜索组件 -->
<SonarSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<!-- 表格组件 -->
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getSonarPageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import SonarSearch from './SonarSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getSonarPageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const columns = ref<any[]>([
{
key: 'stnm',
title: '站名',
dataIndex: 'stnm',
visible: true,
width: 200,
fixed: 'left',
ellipsis: true
},
{
key: 'stcd',
title: '站码',
dataIndex: 'stcd',
visible: true,
width: 200,
ellipsis: true
},
{
key: 'sttpName',
title: '监控类别',
dataIndex: 'sttpName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'stlc',
title: '站址',
dataIndex: 'stlc',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'mwayName',
title: '监测方式',
dataIndex: 'mwayName',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'usflName',
title: '是否启用',
dataIndex: 'usflName',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'remark',
title: '备注',
dataIndex: 'remark',
visible: true,
ellipsis: true
}
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => {
return {
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
};
};
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,506 @@
<template>
<a-modal
v-model:open="visible"
:title="isAdd ? '新增人工产卵场' : '编辑人工产卵场'"
width="60vw"
:confirm-loading="confirmLoading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:label-col="{ span: 8 }"
:wrapper-col="{ span: 16 }"
:rules="formRules"
class="max-h-[70vh] overflow-y-auto pr-4"
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="产卵场编码" name="stcd">
<a-input
v-model:value="formData.stcd"
placeholder="请输入产卵场编码"
:disabled="!isAdd"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="产卵场名称" name="stnm">
<a-input
v-model:value="formData.stnm"
placeholder="请输入产卵场名称"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="测站类型" name="sttp">
<a-select
v-model:value="formData.sttp"
placeholder="请选择测站类型"
allow-clear
show-search
:filter-option="filterOption"
>
<a-select-option
v-for="item in sttpOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>{{ item.label }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="站址" name="stlc">
<a-input
v-model:value="formData.stlc"
placeholder="请输入站址"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="水电基地" name="baseId">
<a-select
v-model:value="formData.baseId"
placeholder="请选择水电基地"
allow-clear
show-search
:filter-option="filterOption"
>
<a-select-option
v-for="item in baseNameOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>{{ item.label }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="所属电站" name="rstcd">
<a-select
v-model:value="formData.rstcd"
placeholder="请选择所属电站"
allow-clear
show-search
:filter-option="filterOption"
>
<a-select-option
v-for="item in engInfoOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>{{ item.label }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="保护对象" name="prtcts">
<a-input
v-model:value="formData.prtcts"
placeholder="请输入保护对象"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="产卵场数量(个)" name="cnt">
<a-input-number
v-model:value="formData.cnt"
placeholder="请输入数量"
style="width: 100%"
:min="0"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="面积(km²)" name="ar">
<a-input-number
v-model:value="formData.ar"
placeholder="请输入面积"
style="width: 100%"
:min="0"
:precision="2"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="营造型式" name="tp">
<a-input
v-model:value="formData.tp"
placeholder="请输入营造型式"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="实施时间" name="attm">
<a-date-picker
v-model:value="formData.attm"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择实施时间"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="建成日期" name="jcdt">
<a-date-picker
v-model:value="formData.jcdt"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择建成日期"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="是否启用" name="usfl">
<a-select
v-model:value="formData.usfl"
placeholder="请选择是否启用"
allow-clear
>
<a-select-option :value="1">启用</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="投资(亿元)" name="inv">
<a-input-number
v-model:value="formData.inv"
placeholder="请输入投资"
style="width: 100%"
:min="0"
:precision="4"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="监测频次(min)" name="dtfrqcy">
<a-input-number
v-model:value="formData.dtfrqcy"
placeholder="请输入监测频次"
style="width: 100%"
:min="0"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="简介" name="introduce" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-textarea
v-model:value="formData.introduce"
placeholder="请输入简介"
:rows="2"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="备注" name="remark" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-textarea
v-model:value="formData.remark"
placeholder="请输入备注"
:rows="2"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
<ConfirmModal
v-model:open="confirmModalVisible"
:diff-list="diffList"
:confirm-loading="confirmLoading"
@confirm="handleConfirmSubmit"
@cancel="handleConfirmCancel"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addSgInfo, updateSgInfo } from '@/api/DataQueryMenuModule';
import { getEngInfoDropdown } from '@/api/select';
import { useJidiSelectEventStore } from '@/store/modules/jidiSelectEvent';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
import ConfirmModal from '@/components/ConfirmModal/index.vue';
import { useDraggable } from '@/utils/drag';
const props = defineProps<{
open: boolean;
record?: any;
isAdd?: boolean;
}>();
const emit = defineEmits(['update:open', 'success']);
const jidiSelectEventStore = useJidiSelectEventStore();
const visible = computed({
get: () => props.open,
set: val => emit('update:open', val)
});
useDraggable(visible, { boundary: true, resetOnOpen: true });
const formRef = ref();
const confirmLoading = ref(false);
const formData = ref<any>({});
const originalRecord = ref<any>({});
const confirmModalVisible = ref(false);
const formRules = ref<any>({
stcd: [{ required: true, message: '请输入产卵场编码', trigger: 'blur' }],
stnm: [{ required: true, message: '请输入产卵场名称', trigger: 'blur' }]
});
//
const engInfoOptions = ref<any[]>([]);
const sttpOptions = ref<any[]>([]);
const baseNameOptions = ref<any[]>(
jidiSelectEventStore.jidiData
.filter((item: any) => item.wbsCode !== 'all')
.map((item: any) => ({
label: item.wbsName,
value: item.wbsCode
}))
);
//
const fieldLabelMap: Record<string, string> = {
stcd: '产卵场编码',
stnm: '产卵场名称',
sttp: '测站类型',
stlc: '站址',
baseId: '水电基地',
rstcd: '所属电站',
prtcts: '保护对象',
cnt: '产卵场数量',
ar: '面积',
tp: '营造型式',
attm: '实施时间',
jcdt: '建成日期',
usfl: '是否启用',
inv: '投资',
dtfrqcy: '监测频次',
introduce: '简介',
remark: '备注'
};
// select
const selectOptionsMap: Record<string, () => any[]> = {
sttp: () => sttpOptions.value,
usfl: () => [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 }
],
baseId: () => baseNameOptions.value,
rstcd: () => engInfoOptions.value
};
//
const changeOrder = ref<
{ field: string; label: string; oldValue: string; newValue: string }[]
>([]);
const diffList = changeOrder;
const resolveSelectLabel = (field: string, value: any): string => {
if (value === null || value === undefined || value === '') return '空';
const getOptions = selectOptionsMap[field];
if (getOptions) {
const opt = getOptions().find(
(o: any) => String(o.value) === String(value)
);
if (opt) return opt.label;
}
return String(value);
};
const filterOption = (inputValue: string, option: any) => {
const label = option.label || option.value;
const keyword = inputValue || '';
return label.includes(keyword);
};
// formData
watch(
() => formData.value,
newData => {
const original = originalRecord.value;
for (const key in newData) {
const oldVal = original[key];
const newVal = newData[key];
if (oldVal !== newVal) {
const existingIdx = changeOrder.value.findIndex(c => c.field === key);
const oldNorm =
oldVal === null || oldVal === undefined || oldVal === '-'
? null
: oldVal;
const newNorm = newVal === null || newVal === undefined ? null : newVal;
if (oldNorm === newNorm) continue;
if (
oldNorm !== null &&
newNorm !== null &&
!isNaN(Number(oldNorm)) &&
!isNaN(Number(newNorm)) &&
Number(oldNorm) === Number(newNorm)
)
continue;
const isSelect = !!selectOptionsMap[key];
const oldDisplay = isSelect
? resolveSelectLabel(key, oldNorm)
: oldNorm === null
? '空'
: String(oldNorm);
const newDisplay = isSelect
? resolveSelectLabel(key, newNorm)
: newNorm === null
? '空'
: String(newNorm);
const entry = {
field: key,
label: fieldLabelMap[key] || key,
oldValue: oldDisplay,
newValue: newDisplay
};
if (existingIdx >= 0) {
changeOrder.value[existingIdx] = entry;
} else {
changeOrder.value.push(entry);
}
} else {
changeOrder.value = changeOrder.value.filter(c => c.field !== key);
}
}
},
{ deep: true }
);
// record
watch(
() => props.record,
newRecord => {
if (newRecord) {
const converted = { ...newRecord };
originalRecord.value = { ...converted };
formData.value = { ...converted };
changeOrder.value = [];
}
},
{ deep: true }
);
//
const loadDropdownData = () => {
//
sttpGetKendoList({
filter: {
logic: 'and',
filters: [
{
field: 'sttpCode',
operator: 'in',
dataType: 'string',
value: ['SG']
}
]
}
}).then(res => {
sttpOptions.value = (res.data?.data || []).map((item: any) => ({
label: item.sttpName,
value: item.sttpCode
}));
});
baseNameOptions.value = jidiSelectEventStore.jidiData
.filter((item: any) => item.wbsCode !== 'all')
.map((item: any) => ({
label: item.wbsName,
value: item.wbsCode
}));
getEngInfoDropdown({}).then(res => {
engInfoOptions.value = (res.data || []).map((item: any) => ({
label: item.ennm,
value: item.stcd
}));
});
};
watch(
() => props.open,
val => {
if (val) {
loadDropdownData();
if (props.isAdd) {
formData.value = {};
originalRecord.value = {};
changeOrder.value = [];
}
}
}
);
const handleOk = async () => {
try {
await formRef.value?.validateFields();
if (!props.isAdd && changeOrder.value.length === 0) {
message.info('未检测到任何修改');
return;
}
confirmModalVisible.value = true;
} catch (error) {
console.error('验证失败:', error);
}
};
const handleConfirmSubmit = async (source: string) => {
confirmLoading.value = true;
try {
const engInfo: Record<string, any> = {};
let res: any;
if (props.isAdd) {
Object.assign(engInfo, formData.value);
res = await addSgInfo({
engInfo,
source: source.trim() || '新增'
});
} else {
engInfo.stcd = formData.value.stcd;
changeOrder.value.forEach(item => {
engInfo[item.field] = formData.value[item.field];
});
res = await updateSgInfo({
engInfo,
source: source.trim() || '编辑'
});
}
if (res?.code == 0 || res?.success) {
message.success(props.isAdd ? '新增成功' : '编辑成功');
confirmModalVisible.value = false;
visible.value = false;
emit('success');
} else {
message.error(res?.msg || (props.isAdd ? '新增失败' : '编辑失败'));
}
} catch (error) {
message.error('提交失败,请重试');
} finally {
confirmLoading.value = false;
}
};
const handleConfirmCancel = () => {
confirmModalVisible.value = false;
};
const handleCancel = () => {
visible.value = false;
confirmModalVisible.value = false;
formRef.value?.resetFields();
};
</script>

View File

@ -0,0 +1,121 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
<a-button type="primary" @click="handleAdd">新增</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const sttpOption = ref([]);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null,
sttp: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'stnm',
label: '产卵场名称',
placeholder: '请输入产卵场名称',
width: 160,
fieldProps: {
allowClear: true
}
},
{
col: isHide.value ? 0 : null,
type: 'Select',
name: 'sttp',
label: '测站类型',
width: 160,
fieldProps: {
allowClear: true,
placeholder: '请选择测站类型'
},
options: sttpOption.value
}
]);
const getBaseList = async () => {
let params = {
"filter": {
"logic": "and",
"filters": [
{
"field": "sttpCode",
"operator": "in",
"dataType": "string",
"value": ["SG"]
}
]
}
};
const res = await sttpGetKendoList(params);
sttpOption.value = res.data.data.map(item => {
return {
label: item.sttpName,
value: item.sttpCode
};
});
};
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
const handleAdd = () => {
emit('add');
};
onMounted(() => {
getBaseList();
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script>

View File

@ -0,0 +1,295 @@
<template>
<div class="w-full h-full flex flex-col pt-[20px] pl-[20px] body_one">
<!-- 搜索组件 -->
<SpawnSearch
@search-finish="onSearchFinish"
@reset="onReset"
@add="handleAdd"
ref="searchRef"
/>
<!-- 表格组件 -->
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getSgPageList"
>
<template #action="{ record }">
<a-button
type="link"
class="text-[#2f6b98]"
size="small"
@click="handleEdit(record)"
>编辑</a-button
>
<a-button type="link" danger size="small" @click="handleDelete(record)"
>删除</a-button
>
</template>
</BasicTable>
<!-- 编辑/新增 Modal -->
<EditSpawnModal
v-model:open="editVisible"
:record="editRecord"
:is-add="isAdd"
@success="handleEditSuccess"
/>
<!-- 删除确认 Modal -->
<DeleteConfirmModal
ref="deleteModalRef"
:delete-fn="deleteSgFn"
:label="'人工产卵场'"
title="删除人工产卵场"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import SpawnSearch from './SpawnSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import EditSpawnModal from './EditSpawnModal.vue';
import DeleteConfirmModal from '@/views/conventionalHydropower/BasicData/DeleteConfirmModal.vue';
import { getSgPageList, deleteSgInfo } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const searchRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
//
const editVisible = ref(false);
const editRecord = ref<any>(null);
const isAdd = ref(false);
const deleteModalRef = ref();
const columns = ref<any[]>([
{
key: 'stnm',
title: '产卵场名称',
dataIndex: 'stnm',
visible: true,
width: 200,
fixed: 'left',
ellipsis: true
},
{
key: 'stcd',
title: '产卵场编码',
dataIndex: 'stcd',
visible: true,
width: 200,
ellipsis: true
},
{
key: 'sttpName',
title: '测站类型',
dataIndex: 'sttpName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'stlc',
title: '站址',
dataIndex: 'stlc',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'prtcts',
title: '保护对象',
dataIndex: 'prtcts',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'cnt',
title: '数量(个)',
dataIndex: 'cnt',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'ar',
title: '面积(km²)',
dataIndex: 'ar',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'usflName',
title: '是否启用',
dataIndex: 'usflName',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'remark',
title: '备注',
dataIndex: 'remark',
visible: true,
ellipsis: true
},
{
key: 'action',
title: '操作',
dataIndex: 'action',
fixed: 'right',
width: 120
}
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => {
return {
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
};
};
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
//
const handleAdd = () => {
isAdd.value = true;
editRecord.value = null;
editVisible.value = true;
};
//
const handleEdit = (record: any) => {
isAdd.value = false;
editRecord.value = { ...record };
editVisible.value = true;
};
//
const handleEditSuccess = () => {
initTable(currentSearchParams.value);
};
//
const deleteSgFn = async (record: any, reason: string) => {
return deleteSgInfo({
ids: [record.stcd],
source: reason
});
};
const handleDelete = (record: any) => {
deleteModalRef.value?.open(record, () => {
initTable(currentSearchParams.value);
});
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY();
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,116 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
(e: 'seeEdit'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const sttpOption = ref([]);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null,
sttp: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'stnm',
label: '设施名称',
placeholder: '请输入设施名称',
width: 160,
fieldProps: {
allowClear: true
}
},
{
col: isHide.value ? 0 : null,
type: 'Select',
name: 'sttp',
label: '设施类型',
width: 160,
fieldProps: {
allowClear: true,
placeholder: '请选择设施类型'
},
options: sttpOption.value
}
]);
const getBaseList = async () => {
let params = {
"filter": {
"logic": "and",
"filters": [
{
"field": "sttpCode",
"operator": "in",
"dataType": "string",
"value": ["DW", "DW_1", "DW_2", "DW_3", "DW_4", "DW_5", "DW_6", "DW_9"]
}
]
}
};
const res = await sttpGetKendoList(params);
sttpOption.value = res.data.data.map(item => {
return {
label: item.sttpName,
value: item.sttpCode
};
});
};
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
onMounted(() => {
getBaseList();
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script>

View File

@ -0,0 +1,200 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<!-- 搜索组件 -->
<DwSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<!-- 表格组件 -->
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getDwPageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import DwSearch from './DwSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getDwPageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const columns = ref<any[]>([
{
key: 'stnm',
title: '设施名称',
dataIndex: 'stnm',
visible: true,
width: 200,
fixed: 'left',
ellipsis: true
},
{
key: 'stcd',
title: '设施编码',
dataIndex: 'stcd',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'sttpName',
title: '设施类型',
dataIndex: 'sttpName',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'stlc',
title: '站址',
dataIndex: 'stlc',
visible: true,
width: 180,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'usflName',
title: '是否启用',
dataIndex: 'usflName',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'inv',
title: '投资(亿元)',
dataIndex: 'inv',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'remark',
title: '备注',
dataIndex: 'remark',
visible: true,
ellipsis: true
},
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => {
return {
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
};
};
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,118 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
(e: 'seeEdit'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const sttpOption = ref([]);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null,
sttp: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'stnm',
label: '工程名称',
placeholder: '请输入工程名称',
width: 160,
fieldProps: {
allowClear: true
}
},
{
col: isHide.value ? 0 : null,
type: 'Select',
name: 'sttp',
label: '测站类型',
width: 160,
fieldProps: {
allowClear: true,
placeholder: '请选择测站类型'
},
options: sttpOption.value
}
]);
const getBaseList = async () => {
let params = {
"filter": {
"logic": "and",
"filters": [
{
"field": "sttpCode",
"operator": "in",
"dataType": "string",
"value": ["OTTE"]
}
]
}
};
const res = await sttpGetKendoList(params);
sttpOption.value = res.data.data.map(item => {
return {
label: item.sttpName,
value: item.sttpCode
};
});
};
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
onMounted(() => {
getBaseList();
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script>

View File

@ -0,0 +1,202 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<!-- 搜索组件 -->
<OtteSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<!-- 表格组件 -->
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getOttePageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import OtteSearch from './OtteSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getOttePageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const columns = ref<any[]>([
{
key: 'stnm',
title: '工程名称',
dataIndex: 'stnm',
visible: true,
width: 200,
fixed: 'left',
ellipsis: true
},
{
key: 'stcd',
title: '工程编码',
dataIndex: 'stcd',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'sttpName',
title: '测站类型',
dataIndex: 'sttpName',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'stlc',
title: '位置',
dataIndex: 'stlc',
visible: true,
width: 200,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'usflName',
title: '是否启用',
dataIndex: 'usflName',
visible: true,
width: 80,
ellipsis: true
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'inv',
title: '投资(亿元)',
dataIndex: 'inv',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'remark',
title: '备注',
dataIndex: 'remark',
visible: true,
ellipsis: true
},
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => {
return {
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
};
};
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,394 @@
<template>
<a-modal
v-model:open="visible"
:title="isAdd ? '新增智能告警' : '编辑智能告警'"
width="60vw"
:confirm-loading="confirmLoading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:label-col="{ span: 8 }"
:wrapper-col="{ span: 16 }"
:rules="formRules"
class="max-h-[70vh] overflow-y-auto pr-4"
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="预警规则名称" name="ruleName">
<a-input
v-model:value="formData.ruleName"
placeholder="请输入预警规则名称"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="预警规则类型" name="ruleType">
<a-select
v-model:value="formData.ruleType"
placeholder="请选择预警规则类型"
allow-clear
>
<a-select-option value="WQLVL">水质预警</a-select-option>
<a-select-option value="EQMN">电站生态流量(环保部)</a-select-option>
<a-select-option value="EQMNMWR">电站生态流量(水利部)</a-select-option>
<a-select-option value="RSVRFSR">水位预警</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="规则编码" name="ruleCode">
<a-select
v-model:value="formData.ruleCode"
placeholder="请选择规则编码"
allow-clear
>
<a-select-option value="global">全局规则</a-select-option>
<a-select-option value="common">通用告警规则</a-select-option>
<a-select-option value="custom">自定义告警规则</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="所属测站" name="stcd">
<a-input
v-model:value="formData.stcd"
placeholder="请输入所属测站"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="水电基地" name="baseId">
<a-select
v-model:value="formData.baseId"
placeholder="请选择水电基地"
allow-clear
show-search
:filter-option="filterOption"
>
<a-select-option
v-for="item in baseNameOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>{{ item.label }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="所属电站" name="rstcd">
<a-select
v-model:value="formData.rstcd"
placeholder="请选择所属电站"
allow-clear
show-search
:filter-option="filterOption"
>
<a-select-option
v-for="item in engInfoOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>{{ item.label }}</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="是否展示告警等级" name="isShow">
<a-select
v-model:value="formData.isShow"
placeholder="请选择"
allow-clear
>
<a-select-option :value="1">展示</a-select-option>
<a-select-option :value="0">不展示</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="备注" name="description" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-textarea
v-model:value="formData.description"
placeholder="请输入备注"
:rows="3"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
<ConfirmModal
v-model:open="confirmModalVisible"
:diff-list="diffList"
:confirm-loading="confirmLoading"
@confirm="handleConfirmSubmit"
@cancel="handleConfirmCancel"
/>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { message } from 'ant-design-vue';
import { addWarnInfo, updateWarnInfo } from '@/api/DataQueryMenuModule';
import { getEngInfoDropdown } from '@/api/select';
import { useJidiSelectEventStore } from '@/store/modules/jidiSelectEvent';
import ConfirmModal from '@/components/ConfirmModal/index.vue';
import { useDraggable } from '@/utils/drag';
const props = defineProps<{
open: boolean;
record?: any;
isAdd?: boolean;
}>();
const emit = defineEmits(['update:open', 'success']);
const jidiSelectEventStore = useJidiSelectEventStore();
const visible = computed({
get: () => props.open,
set: val => emit('update:open', val)
});
useDraggable(visible, { boundary: true, resetOnOpen: true });
const formRef = ref();
const confirmLoading = ref(false);
const formData = ref<any>({});
const originalRecord = ref<any>({});
const confirmModalVisible = ref(false);
const formRules = ref<any>({
ruleName: [{ required: true, message: '请输入预警规则名称', trigger: 'blur' }],
ruleType: [{ required: true, message: '请选择预警规则类型', trigger: 'change' }]
});
//
const engInfoOptions = ref<any[]>([]);
const baseNameOptions = ref<any[]>(
jidiSelectEventStore.jidiData
.filter((item: any) => item.wbsCode !== 'all')
.map((item: any) => ({
label: item.wbsName,
value: item.wbsCode
}))
);
//
const fieldLabelMap: Record<string, string> = {
ruleName: '预警规则名称',
ruleType: '预警规则类型',
ruleCode: '规则编码',
stcd: '所属测站',
baseId: '水电基地',
rstcd: '所属电站',
isShow: '是否展示告警等级',
description: '备注'
};
// select
const selectOptionsMap: Record<string, () => any[]> = {
ruleType: () => [
{ label: '水质预警', value: 'WQLVL' },
{ label: '电站生态流量(环保部)', value: 'EQMN' },
{ label: '电站生态流量(水利部)', value: 'EQMNMWR' },
{ label: '水位预警', value: 'RSVRFSR' }
],
ruleCode: () => [
{ label: '全局规则', value: 'global' },
{ label: '通用告警规则', value: 'common' },
{ label: '自定义告警规则', value: 'custom' }
],
isShow: () => [
{ label: '展示', value: 1 },
{ label: '不展示', value: 0 }
],
baseId: () => baseNameOptions.value,
rstcd: () => engInfoOptions.value
};
//
const changeOrder = ref<
{ field: string; label: string; oldValue: string; newValue: string }[]
>([]);
const diffList = changeOrder;
const resolveSelectLabel = (field: string, value: any): string => {
if (value === null || value === undefined || value === '') return '空';
const getOptions = selectOptionsMap[field];
if (getOptions) {
const opt = getOptions().find(
(o: any) => String(o.value) === String(value)
);
if (opt) return opt.label;
}
return String(value);
};
const filterOption = (inputValue: string, option: any) => {
const label = option.label || option.value;
const keyword = inputValue || '';
return label.includes(keyword);
};
// formData
watch(
() => formData.value,
newData => {
const original = originalRecord.value;
for (const key in newData) {
const oldVal = original[key];
const newVal = newData[key];
if (oldVal !== newVal) {
const existingIdx = changeOrder.value.findIndex(c => c.field === key);
const oldNorm =
oldVal === null || oldVal === undefined || oldVal === '-'
? null
: oldVal;
const newNorm = newVal === null || newVal === undefined ? null : newVal;
if (oldNorm === newNorm) continue;
if (
oldNorm !== null &&
newNorm !== null &&
!isNaN(Number(oldNorm)) &&
!isNaN(Number(newNorm)) &&
Number(oldNorm) === Number(newNorm)
)
continue;
const isSelect = !!selectOptionsMap[key];
const oldDisplay = isSelect
? resolveSelectLabel(key, oldNorm)
: oldNorm === null
? '空'
: String(oldNorm);
const newDisplay = isSelect
? resolveSelectLabel(key, newNorm)
: newNorm === null
? '空'
: String(newNorm);
const entry = {
field: key,
label: fieldLabelMap[key] || key,
oldValue: oldDisplay,
newValue: newDisplay
};
if (existingIdx >= 0) {
changeOrder.value[existingIdx] = entry;
} else {
changeOrder.value.push(entry);
}
} else {
changeOrder.value = changeOrder.value.filter(c => c.field !== key);
}
}
},
{ deep: true }
);
// record
watch(
() => props.record,
newRecord => {
if (newRecord) {
const converted = { ...newRecord };
originalRecord.value = { ...converted };
formData.value = { ...converted };
changeOrder.value = [];
}
},
{ deep: true }
);
//
const loadDropdownData = () => {
baseNameOptions.value = jidiSelectEventStore.jidiData
.filter((item: any) => item.wbsCode !== 'all')
.map((item: any) => ({
label: item.wbsName,
value: item.wbsCode
}));
getEngInfoDropdown({}).then(res => {
engInfoOptions.value = (res.data || []).map((item: any) => ({
label: item.ennm,
value: item.stcd
}));
});
};
watch(
() => props.open,
val => {
if (val) {
loadDropdownData();
if (props.isAdd) {
formData.value = {};
originalRecord.value = {};
changeOrder.value = [];
}
}
}
);
const handleOk = async () => {
try {
await formRef.value?.validateFields();
if (!props.isAdd && changeOrder.value.length === 0) {
message.info('未检测到任何修改');
return;
}
confirmModalVisible.value = true;
} catch (error) {
console.error('验证失败:', error);
}
};
const handleConfirmSubmit = async (source: string) => {
confirmLoading.value = true;
try {
const engInfo: Record<string, any> = {};
let res: any;
if (props.isAdd) {
Object.assign(engInfo, formData.value);
res = await addWarnInfo({
engInfo,
source: source.trim() || '新增'
});
} else {
engInfo.id = formData.value.id;
changeOrder.value.forEach(item => {
engInfo[item.field] = formData.value[item.field];
});
res = await updateWarnInfo({
engInfo,
source: source.trim() || '编辑'
});
}
if (res?.code == 0 || res?.success) {
message.success(props.isAdd ? '新增成功' : '编辑成功');
confirmModalVisible.value = false;
visible.value = false;
emit('success');
} else {
message.error(res?.msg || (props.isAdd ? '新增失败' : '编辑失败'));
}
} catch (error) {
message.error('提交失败,请重试');
} finally {
confirmLoading.value = false;
}
};
const handleConfirmCancel = () => {
confirmModalVisible.value = false;
};
const handleCancel = () => {
visible.value = false;
confirmModalVisible.value = false;
formRef.value?.resetFields();
};
</script>

View File

@ -0,0 +1,82 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
<a-button type="primary" @click="handleAdd">新增</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const initSearchData = {
baseId: 'all',
rstcd: null,
ruleName: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: {
allowClear: true
},
options: []
},
{
type: 'Input',
name: 'ruleName',
label: '规则名称',
placeholder: '请输入规则名称',
width: 160,
fieldProps: {
allowClear: true
}
}
]);
const onSearchFinish = (values: any) => {
emit('search-finish', { ...values });
};
const hideBtn = () => {
isHide.value = !isHide.value;
};
const handleReset = () => {
emit('reset', initSearchData);
};
const handleAdd = () => {
emit('add');
};
onMounted(() => {
emit('search-finish', { ...initSearchData });
});
defineExpose({
basicSearchRef
});
</script>

View File

@ -0,0 +1,255 @@
<template>
<div class="w-full h-full flex flex-col pt-[20px] pl-[20px] body_one">
<!-- 搜索组件 -->
<WarnSearch
@search-finish="onSearchFinish"
@reset="onReset"
@add="handleAdd"
ref="searchRef"
/>
<!-- 表格组件 -->
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getWarnPageList"
>
<template #action="{ record }">
<a-button
type="link"
class="text-[#2f6b98]"
size="small"
@click="handleEdit(record)"
>编辑</a-button
>
<a-button type="link" danger size="small" @click="handleDelete(record)"
>删除</a-button
>
</template>
</BasicTable>
<!-- 编辑/新增 Modal -->
<EditWarnModal
v-model:open="editVisible"
:record="editRecord"
:is-add="isAdd"
@success="handleEditSuccess"
/>
<!-- 删除确认 Modal -->
<DeleteConfirmModal
ref="deleteModalRef"
:delete-fn="deleteWarnFn"
:label="'智能告警'"
title="删除智能告警"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import WarnSearch from './WarnSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import EditWarnModal from './EditWarnModal.vue';
import DeleteConfirmModal from '@/views/conventionalHydropower/BasicData/DeleteConfirmModal.vue';
import { getWarnPageList, deleteWarnInfo } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const searchRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
//
const editVisible = ref(false);
const editRecord = ref<any>(null);
const isAdd = ref(false);
const deleteModalRef = ref();
const columns = ref<any[]>([
{
key: 'ruleName',
title: '预警规则名称',
dataIndex: 'ruleName',
visible: true,
width: 200,
fixed: 'left',
ellipsis: true
},
{
key: 'ruleType',
title: '预警规则类型',
dataIndex: 'ruleType',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ruleCode',
title: '规则编码',
dataIndex: 'ruleCode',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'stcd',
title: '所属测站',
dataIndex: 'stcd',
visible: true,
width: 160,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'isShow',
title: '展示告警等级',
dataIndex: 'isShow',
visible: true,
width: 100,
customRender: ({ text }) => {
if (text === 1) return '展示';
if (text === 0) return '不展示';
return '-';
}
},
{
key: 'description',
title: '备注',
dataIndex: 'description',
visible: true,
ellipsis: true
},
{
key: 'action',
title: '操作',
dataIndex: 'action',
fixed: 'right',
width: 120
}
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => {
return {
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.ruleName
? {
field: 'ruleName',
operator: 'contains',
dataType: 'string',
value: values.ruleName
}
: null
].filter(Boolean)
};
};
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
const params = buildSearchParams(values);
tableRef.value.getList(params);
};
//
const handleAdd = () => {
isAdd.value = true;
editRecord.value = null;
editVisible.value = true;
};
//
const handleEdit = (record: any) => {
isAdd.value = false;
editRecord.value = { ...record };
editVisible.value = true;
};
//
const handleEditSuccess = () => {
initTable(currentSearchParams.value);
};
// 使 id
const deleteWarnFn = async (record: any, reason: string) => {
return deleteWarnInfo({
ids: [record.id],
source: reason
});
};
const handleDelete = (record: any) => {
deleteModalRef.value?.open(record, () => {
initTable(currentSearchParams.value);
});
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY();
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,92 @@
<template>
<BasicSearch
ref="basicSearchRef"
:searchList="searchList"
:initial-values="initSearchData"
:zhujianfujian="'fu'"
@reset="handleReset"
@finish="onSearchFinish"
>
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{
(e: 'search-finish', values: any): void;
(e: 'reset', values: any): void;
(e: 'add'): void;
(e: 'seeEdit'): void;
}>();
const basicSearchRef = ref();
const isHide = ref<boolean>(false);
const sttpOption = ref([]);
const initSearchData = {
baseId: 'all',
rstcd: null,
stnm: null,
sttp: null
};
const searchList: any = computed(() => [
{
type: 'jidiData',
name: 'baseId',
label: '水电基地',
placeholder: '请输入水电基地名称',
fieldProps: { allowClear: true },
options: []
},
{
type: 'Input',
name: 'stnm',
label: '增殖站名称',
placeholder: '请输入增殖站名称',
width: 160,
fieldProps: { allowClear: true }
},
{
col: isHide.value ? 0 : null,
type: 'Select',
name: 'sttp',
label: '测站类型',
width: 160,
fieldProps: { allowClear: true, placeholder: '请选择测站类型' },
options: sttpOption.value
}
]);
const getBaseList = async () => {
const res = await sttpGetKendoList({
filter: {
logic: 'and',
filters: [
{ field: 'sttpCode', operator: 'in', dataType: 'string', value: ['FB'] }
]
}
});
sttpOption.value = res.data.data.map(item => ({
label: item.sttpName,
value: item.sttpCode
}));
};
const onSearchFinish = (values: any) => emit('search-finish', { ...values });
const hideBtn = () => { isHide.value = !isHide.value; };
const handleReset = () => emit('reset', initSearchData);
onMounted(() => {
getBaseList();
emit('search-finish', { ...initSearchData });
});
defineExpose({ basicSearchRef });
</script>

View File

@ -0,0 +1,259 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<FbSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getFbPageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import FbSearch from './FbSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getFbPageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const columns = ref<any[]>([
{
key: 'stnm',
title: '增殖站名称',
dataIndex: 'stnm',
visible: true,
width: 160,
fixed: 'left',
ellipsis: true
},
{
key: 'stcd',
title: '增殖站编码',
dataIndex: 'stcd',
visible: true,
width: 180,
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 120,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'zzfldx',
title: '放流对象',
dataIndex: 'zzfldx',
visible: true,
width: 120,
ellipsis: true
},
{
key: 'zzflcnt',
title: '放流规模(尾)',
dataIndex: 'zzflcnt',
visible: true,
width: 120
},
{
key: 'zzflbjfs',
title: '标记方式',
dataIndex: 'zzflbjfs',
visible: true,
width: 100,
ellipsis: true
},
{
key: 'zzflar',
title: '总占地面积(km²)',
dataIndex: 'zzflar',
visible: true,
width: 140
},
{
key: 'inv',
title: '投资(亿元)',
dataIndex: 'inv',
visible: true,
width: 120
},
{
key: 'zzflrw',
title: '承担放流任务',
dataIndex: 'zzflrw',
visible: true,
width: 130,
ellipsis: true
},
{
key: 'zzflfllc',
title: '放流地点',
dataIndex: 'zzflfllc',
visible: true,
width: 260,
ellipsis: true
},
{
key: 'zzflyzms',
title: '养殖模式',
dataIndex: 'zzflyzms',
visible: true,
width: 220,
ellipsis: true
},
{
key: 'zzflgy',
title: '生产工艺',
dataIndex: 'zzflgy',
visible: true,
width: 180,
ellipsis: true
},
{
key: 'swdt',
title: '开工日期',
dataIndex: 'swdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'zzfltm',
title: '放流时间',
dataIndex: 'zzfltm',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'addvcdName',
title: '地址',
dataIndex: 'addvcdName',
visible: true,
width: 200,
ellipsis: true
},
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => ({
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all' && values.baseId != null
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
});
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
tableRef.value.getList(buildSearchParams(values));
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<BasicSearch ref="basicSearchRef" :searchList="searchList" :initial-values="initSearchData" :zhujianfujian="'fu'" @reset="handleReset" @finish="onSearchFinish">
<template #actions>
<a-button @click="hideBtn">{{ isHide ? '展开' : '隐藏' }}</a-button>
</template>
</BasicSearch>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import BasicSearch from '@/components/BasicSearch/index.vue';
import { sttpGetKendoList } from '@/api/system/map/ConfigManagement';
const emit = defineEmits<{ (e: 'search-finish', values: any): void; (e: 'reset', values: any): void; (e: 'add'): void; (e: 'seeEdit'): void }>();
const basicSearchRef = ref();
const isHide = ref(false);
const sttpOption = ref([]);
const initSearchData = { baseId: 'all', rstcd: null, stnm: null, sttp: null };
const searchList: any = computed(() => [
{ type: 'jidiData', name: 'baseId', label: '水电基地', placeholder: '请输入水电基地名称', fieldProps: { allowClear: true }, options: [] },
{ type: 'Input', name: 'stnm', label: '植物园名称', placeholder: '请输入植物园名称', width: 160, fieldProps: { allowClear: true } },
{ col: isHide.value ? 0 : null, type: 'Select', name: 'sttp', label: '测站类型', width: 160, fieldProps: { allowClear: true, placeholder: '请选择测站类型' }, options: sttpOption.value }
]);
const getBaseList = async () => {
const res = await sttpGetKendoList({
filter: { logic: 'and', filters: [{ field: 'sttpCode', operator: 'in', dataType: 'string', value: ['VP', 'AI_VP_GRAZE', 'AI_VP_WATER'] }] }
});
sttpOption.value = res.data.data.map(item => ({ label: item.sttpName, value: item.sttpCode }));
};
const onSearchFinish = (values: any) => emit('search-finish', { ...values });
const hideBtn = () => { isHide.value = !isHide.value; };
const handleReset = () => emit('reset', initSearchData);
onMounted(() => { getBaseList(); emit('search-finish', { ...initSearchData }); });
defineExpose({ basicSearchRef });
</script>

View File

@ -0,0 +1,197 @@
<template>
<div class="w-full h-full flex flex-col body_one">
<VpSearch
@search-finish="onSearchFinish"
@reset="onReset"
ref="searchRef"
/>
<BasicTable
ref="tableRef"
:enableEllipsis="true"
:scrollX="tableScrollX"
:scrollY="tableScrollY"
:columns="columns"
:list-url="getVpPageList"
>
</BasicTable>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import VpSearch from './VpSearch.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getVpPageList } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
const tableRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const columns = ref<any[]>([
{
key: 'stnm',
title: '植物园名称',
dataIndex: 'stnm',
visible: true,
width: 150,
ellipsis: true
},
{
key: 'stcd',
title: '植物园编码',
dataIndex: 'stcd',
visible: true,
width: 150,
fixed: 'left',
ellipsis: true
},
{
key: 'baseName',
title: '水电基地',
dataIndex: 'baseName',
visible: true,
width: 120,
ellipsis: true
},
{
key: 'ennm',
title: '所属电站',
dataIndex: 'ennm',
visible: true,
width: 150,
ellipsis: true
},
{
key: 'area',
title: '面积(km²)',
dataIndex: 'area',
visible: true,
width: 120
},
{
key: 'inv',
title: '投资(亿元)',
dataIndex: 'inv',
visible: true,
width: 120
},
{
key: 'swdt',
title: '开工日期',
dataIndex: 'swdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'jcdt',
title: '建成日期',
dataIndex: 'jcdt',
visible: true,
width: 110,
customRender: ({ text }) => {
if (!text || text === '-') return '-';
const date = dayjs(text);
return date.isValid() ? date.format('YYYY-MM-DD') : '-';
}
},
{
key: 'bhfs',
title: '保护方式',
dataIndex: 'bhfs',
visible: true,
width: 200,
ellipsis: true
},
{
key: 'stlc',
title: '站址',
dataIndex: 'stlc',
visible: true,
width: 200,
ellipsis: true
},
]);
const tableScrollX = computed(() =>
Math.max(
columns.value.reduce(
(sum: number, col: any) => sum + (col.width || 180),
0
),
600
)
);
const buildSearchParams = (values: any) => ({
logic: 'and',
filters: [
values.rstcd
? {
field: 'rstcd',
operator: 'eq',
dataType: 'string',
value: values.rstcd
}
: null,
values.baseId !== 'all' && values.baseId != null
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: values.baseId
}
: null,
values.stnm
? {
field: 'stnm',
operator: 'contains',
dataType: 'string',
value: values.stnm
}
: null,
values.sttp
? {
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: values.sttp
}
: null
].filter(Boolean)
});
const onSearchFinish = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const onReset = (values: any) => {
currentSearchParams.value = values;
initTable(values);
};
const initTable = (values: any) => {
tableRef.value.getList(buildSearchParams(values));
};
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>
<style scoped lang="scss">
.body_one {
position: relative;
z-index: 900;
pointer-events: all;
}
</style>

View File

@ -1,327 +0,0 @@
<template>
<a-modal
v-model:open="visible"
title="编辑流量站数据"
width="60vw"
:confirm-loading="confirmLoading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:label-col="{ span: 7 }"
:wrapper-col="{ span: 17 }"
>
<div class="form-scroll-area">
<div class="form-section">
<a-row :gutter="16">
<a-col :span="24"
><div class="form-group-title">基本信息</div></a-col
>
<a-col :span="12">
<a-form-item label="站点名称">
<a-input :value="recordData.stnm || '-'" disabled />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="时间">
<a-input :value="displayTime" disabled />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="所属基地">
<a-input :value="recordData.baseName || '-'" disabled />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="所属电站">
<a-input :value="recordData.ennm || '-'" disabled />
</a-form-item>
</a-col>
</a-row>
</div>
<div class="form-section">
<a-row :gutter="16">
<a-col :span="24"
><div class="form-group-title">监测数据</div></a-col
>
<a-col :span="12">
<a-form-item label="水位(m)">
<a-input-number
v-model:value="formData.z"
placeholder="请输入水位"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="流量(m³/s)">
<a-input-number
v-model:value="formData.q"
placeholder="请输入流量"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="流速(m/s)">
<a-input-number
v-model:value="formData.v"
placeholder="请输入流速"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</div>
</div>
</a-form>
</a-modal>
<a-modal
v-model:open="confirmModalVisible"
title="确认修改"
width="700px"
:confirm-loading="confirmLoading"
@ok="handleConfirmSubmit"
@cancel="handleConfirmCancel"
>
<div style="max-height: 50vh; overflow-y: auto; margin-bottom: 16px">
<a-descriptions bordered :column="1" size="small">
<a-descriptions-item
v-for="item in changedFieldList"
:key="item.key"
:label="item.label"
>
<span style="color: #ff4d4f; text-decoration: line-through">{{
item.oldValue
}}</span>
<span style="margin: 0 8px"></span>
<span style="color: #52c41a">{{ item.newValue }}</span>
</a-descriptions-item>
</a-descriptions>
</div>
<a-form-item label="修改依据">
<a-textarea
v-model:value="sourceValue"
placeholder="请输入修改依据"
:rows="5"
:maxlength="500"
show-count
/>
</a-form-item>
</a-modal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { message } from 'ant-design-vue';
import { updateFlowStationInfo } from '@/api/DataQueryMenuModule';
const props = defineProps<{
open: boolean;
record?: any;
timeScale?: string;
}>();
const emit = defineEmits(['update:open', 'success']);
const visible = computed({
get: () => props.open,
set: val => emit('update:open', val)
});
const formRef = ref();
const confirmLoading = ref(false);
const confirmModalVisible = ref(false);
const sourceValue = ref('');
const recordData = ref<any>({});
const formData = ref<{
z: number | null;
q: number | null;
v: number | null;
}>({
z: null,
q: null,
v: null
});
const normalizeValue = (value: any) => {
if (value === undefined || value === null || value === '') return null;
return Number(value);
};
const formatValue = (value: any) => {
const normalized = normalizeValue(value);
return normalized === null ? '空' : String(normalized);
};
const fieldLabelMap: Record<string, string> = {
z: '水位(m)',
q: '流量(m³/s)',
v: '流速(m/s)'
};
const displayTime = computed(() => {
const timeScale = props.timeScale;
if (timeScale === 'month') {
const year = recordData.value?.year ?? '-';
const month = recordData.value?.month ?? '-';
return `${year}-${String(month).padStart(2, '0')}`;
}
const timeValue = recordData.value?.dt ?? recordData.value?.tm;
if (!timeValue) return '-';
if (timeScale === 'dt') {
return dayjs(timeValue).format('YYYY-MM-DD');
}
return dayjs(timeValue).format('YYYY-MM-DD HH:mm:ss');
});
const changedFieldList = computed(() => {
return (['z', 'q', 'v'] as const)
.map(key => {
const oldValue = normalizeValue(recordData.value?.[key]);
const newValue = normalizeValue(formData.value[key]);
if (oldValue === newValue) return null;
return {
key,
label: fieldLabelMap[key],
oldValue: formatValue(oldValue),
newValue: formatValue(newValue)
};
})
.filter(Boolean) as {
key: string;
label: string;
oldValue: string;
newValue: string;
}[];
});
watch(
() => props.record,
newRecord => {
if (!newRecord) {
recordData.value = {};
formData.value = {
z: null,
q: null,
v: null
};
return;
}
recordData.value = JSON.parse(JSON.stringify(newRecord));
formData.value = {
z: normalizeValue(newRecord.z),
q: normalizeValue(newRecord.q),
v: normalizeValue(newRecord.v)
};
sourceValue.value = '';
confirmModalVisible.value = false;
},
{ deep: true, immediate: true }
);
const handleOk = async () => {
try {
if (changedFieldList.value.length === 0) {
message.info('未检测到任何修改');
return;
}
sourceValue.value = '';
confirmModalVisible.value = true;
} catch (error) {
console.error('验证失败:', error);
}
};
const handleConfirmSubmit = async () => {
confirmLoading.value = true;
try {
const updateData = {
stcd: recordData.value?.stcd,
tm: recordData.value?.tm,
stnm: recordData.value?.stnm,
z:
formData.value.z === null || formData.value.z === undefined
? ''
: String(formData.value.z),
q:
formData.value.q === null || formData.value.q === undefined
? ''
: String(formData.value.q),
v:
formData.value.v === null || formData.value.v === undefined
? ''
: String(formData.value.v)
};
const res = await updateFlowStationInfo({
updateData,
source: sourceValue.value.trim()
});
if (res?.code == 0 || res?.success) {
message.success('编辑成功');
confirmModalVisible.value = false;
visible.value = false;
emit('success');
} else {
message.error(res?.msg || '编辑失败');
}
} catch (error) {
console.error('提交失败:', error);
message.error('提交失败,请重试');
} finally {
confirmLoading.value = false;
}
};
const handleConfirmCancel = () => {
confirmModalVisible.value = false;
sourceValue.value = '';
};
const handleCancel = () => {
visible.value = false;
confirmModalVisible.value = false;
sourceValue.value = '';
formRef.value?.resetFields();
};
</script>
<style scoped lang="scss">
.form-scroll-area {
max-height: 500px;
overflow-y: auto;
padding-right: 8px;
}
.form-section {
padding: 4px 4px 0;
background: #fff;
border-radius: 6px;
}
.form-section + .form-section {
margin-top: 8px;
}
.form-group-title {
font-size: 16px;
font-weight: 600;
color: #333;
padding: 8px 0 12px;
border-bottom: 1px solid #e8e8e8;
margin-bottom: 16px;
}
</style>

View File

@ -19,48 +19,15 @@
sort: sort
}"
>
<template #action="{ record }">
<template v-if="currentSearchParams.timeScale === 'tm'">
<a-button
type="link"
class="text-[#2f6b98]"
size="small"
@click="handleEdit(record)"
>编辑</a-button
>
<a-button
type="link"
danger
size="small"
@click="handleDelete(record)"
>删除</a-button
>
</template>
</template>
</BasicTable>
<EditFlowStationModal
v-model:open="editVisible"
:record="editRecord"
:time-scale="currentSearchParams.timeScale"
@success="handleEditSuccess"
/>
<DeleteConfirmModal
ref="deleteModalRef"
:delete-fn="handleDeleteFn"
title="删除流量站数据"
label="数据"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import FlowStationSearch from './FlowStationSearch.vue';
import EditFlowStationModal from './EditFlowStationModal.vue';
import DeleteConfirmModal from '@/views/DataQueryMenuModule/components/conventionalHydropower/BasicData/DeleteConfirmModal.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import {
getFlowStationList,
@ -216,15 +183,7 @@ const Columns = computed(() => {
//
const baseIndex = result.findIndex(c => c.key === 'ennm');
result.splice(baseIndex + 1, 0, timeColumn.value);
if (currentSearchParams.value.timeScale === 'tm') {
result.push({
key: 'action',
title: '操作',
dataIndex: 'action',
fixed: 'right',
width: 120
});
}
return result;
});
@ -257,33 +216,6 @@ const exportBtn = () => {
});
};
const handleEdit = (record: any) => {
editRecord.value = { ...record };
editVisible.value = true;
};
const handleDeleteFn = async (record: any, reason: string) => {
return deleteFlowStationInfo({
dataList: [
{
id: record.stcd,
tm: record.tm
}
],
dataType: 'TIME',
source: reason
});
};
const handleDelete = (record: any) => {
deleteModalRef.value?.open(record, () => {
initTable(currentSearchParams.value);
});
};
const handleEditSuccess = () => {
initTable(currentSearchParams.value);
};
const initTable = (values: any) => {
const filters = [
@ -323,7 +255,7 @@ const initTable = (values: any) => {
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY();
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>

View File

@ -1,252 +0,0 @@
<template>
<a-modal
v-model:open="visible"
title="编辑表层水温数据"
width="60vw"
:confirm-loading="confirmLoading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:label-col="{ span: 7 }"
:wrapper-col="{ span: 17 }"
>
<div class="form-scroll-area">
<div class="form-section">
<a-row :gutter="16">
<a-col :span="24"
><div class="form-group-title">基本信息</div></a-col
>
<a-col :span="12">
<a-form-item label="断面名称">
<a-input :value="recordData.stnm || '-'" disabled />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="时间">
<a-input :value="displayTime" disabled />
</a-form-item>
</a-col>
</a-row>
</div>
<div class="form-section">
<a-row :gutter="16">
<a-col :span="24"
><div class="form-group-title">水温数据</div></a-col
>
<a-col :span="12">
<a-form-item label="水温(℃)">
<a-input-number
v-model:value="formData.wt"
placeholder="请输入水温"
:precision="1"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</div>
</div>
</a-form>
</a-modal>
<a-modal
v-model:open="confirmModalVisible"
title="确认修改"
width="700px"
:confirm-loading="confirmLoading"
@ok="handleConfirmSubmit"
@cancel="handleConfirmCancel"
>
<div style="max-height: 50vh; overflow-y: auto; margin-bottom: 16px">
<a-descriptions bordered :column="1" size="small">
<a-descriptions-item label="水温(℃)">
<span style="color: #ff4d4f; text-decoration: line-through">{{
originalWtText
}}</span>
<span style="margin: 0 8px"></span>
<span style="color: #52c41a">{{ currentWtText }}</span>
</a-descriptions-item>
</a-descriptions>
</div>
<a-form-item label="修改依据">
<a-textarea
v-model:value="sourceValue"
placeholder="请输入修改依据"
:rows="5"
:maxlength="500"
show-count
/>
</a-form-item>
</a-modal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { message } from 'ant-design-vue';
import { updateSurfaceTempInfo } from '@/api/DataQueryMenuModule';
const props = defineProps<{
open: boolean;
record?: any;
timeScale?: string;
}>();
const emit = defineEmits(['update:open', 'success']);
const visible = computed({
get: () => props.open,
set: val => emit('update:open', val)
});
const formRef = ref();
const confirmLoading = ref(false);
const confirmModalVisible = ref(false);
const sourceValue = ref('');
const recordData = ref<any>({});
const formData = ref<{
wt: number | null;
}>({
wt: null
});
const normalizeValue = (value: any) => {
if (value === undefined || value === null || value === '') return null;
return Number(value);
};
const formatValue = (value: any) => {
const normalized = normalizeValue(value);
return normalized === null ? '空' : String(normalized);
};
const displayTime = computed(() => {
const timeScale = props.timeScale;
if (timeScale === 'month') {
const year = recordData.value?.year ?? '-';
const month = recordData.value?.month ?? '-';
return `${year}-${String(month).padStart(2, '0')}`;
}
const timeValue = recordData.value?.dt ?? recordData.value?.tm;
if (!timeValue) return '-';
if (timeScale === 'dt') {
return dayjs(timeValue).format('YYYY-MM-DD');
}
return dayjs(timeValue).format('YYYY-MM-DD HH:mm:ss');
});
const originalWt = computed(() => normalizeValue(recordData.value?.wt));
const originalWtText = computed(() => formatValue(originalWt.value));
const currentWtText = computed(() => formatValue(formData.value.wt));
watch(
() => props.record,
newRecord => {
if (!newRecord) {
recordData.value = {};
formData.value = { wt: null };
return;
}
recordData.value = JSON.parse(JSON.stringify(newRecord));
formData.value = {
wt: normalizeValue(newRecord.wt)
};
sourceValue.value = '';
confirmModalVisible.value = false;
},
{ deep: true, immediate: true }
);
const handleOk = async () => {
try {
if (originalWt.value === formData.value.wt) {
message.info('未检测到任何修改');
return;
}
sourceValue.value = '';
confirmModalVisible.value = true;
} catch (error) {
console.error('验证失败:', error);
}
};
const handleConfirmSubmit = async () => {
confirmLoading.value = true;
try {
const updateData = {
stcd: recordData.value?.stcd,
dt: recordData.value?.tm,
stnm: recordData.value?.stnm,
wt:
formData.value.wt === null || formData.value.wt === undefined
? ''
: String(formData.value.wt)
};
const res = await updateSurfaceTempInfo({
updateData,
source: sourceValue.value.trim()
});
if (res?.code == 0 || res?.success) {
message.success('编辑成功');
confirmModalVisible.value = false;
visible.value = false;
emit('success');
} else {
message.error(res?.msg || '编辑失败');
}
} catch (error) {
console.error('提交失败:', error);
message.error('提交失败,请重试');
} finally {
confirmLoading.value = false;
}
};
const handleConfirmCancel = () => {
confirmModalVisible.value = false;
sourceValue.value = '';
};
const handleCancel = () => {
visible.value = false;
confirmModalVisible.value = false;
sourceValue.value = '';
formRef.value?.resetFields();
};
</script>
<style scoped lang="scss">
.form-scroll-area {
max-height: 500px;
overflow-y: auto;
padding-right: 8px;
}
.form-section {
padding: 4px 4px 0;
background: #fff;
border-radius: 6px;
}
.form-section + .form-section {
margin-top: 8px;
}
.form-group-title {
font-size: 16px;
font-weight: 600;
color: #333;
padding: 8px 0 12px;
border-bottom: 1px solid #e8e8e8;
margin-bottom: 16px;
}
</style>

View File

@ -19,54 +19,18 @@
sort: sort
}"
>
<template #action="{ record }">
<template v-if="currentSearchParams.timeScale === 'tm'">
<a-button
type="link"
class="text-[#2f6b98]"
size="small"
@click="handleEdit(record)"
>编辑</a-button
>
<a-button
type="link"
danger
size="small"
@click="handleDelete(record)"
>删除</a-button
>
</template>
</template>
</BasicTable>
<EditSurfaceTempModal
v-model:open="editVisible"
:record="editRecord"
:time-scale="currentSearchParams.timeScale"
@success="handleEditSuccess"
/>
<DeleteConfirmModal
ref="deleteModalRef"
:delete-fn="handleDeleteFn"
title="删除表层水温数据"
label="数据"
@success="handleEditSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import dayjs from 'dayjs';
import SurfaceTempSearch from './SurfaceTempSearch.vue';
import EditSurfaceTempModal from './EditSurfaceTempModal.vue';
import DeleteConfirmModal from '@/views/DataQueryMenuModule/components/conventionalHydropower/BasicData/DeleteConfirmModal.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import {
getSurfaceTempList,
getSurfaceTempDayList,
getSurfaceTempMonthList,
deleteSurfaceTempInfo
} from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
import { buildTimeFilters } from '@/utils/buildTimeFilters';
@ -103,9 +67,6 @@ const searchRef = ref();
const tableScrollY = ref<string | number>(0);
const currentSearchParams = ref<any>({});
const exportLoading = ref(false);
const editVisible = ref(false);
const editRecord = ref<any>(null);
const deleteModalRef = ref();
// timeScale API
const currentListUrl = computed(() => {
@ -180,15 +141,6 @@ const Columns = computed(() => {
//
const baseIndex = result.findIndex(c => c.key === 'stnm');
result.splice(baseIndex + 1, 0, timeColumn.value);
if (currentSearchParams.value.timeScale === 'tm') {
result.push({
key: 'action',
title: '操作',
dataIndex: 'action',
fixed: 'right',
width: 120
});
}
return result;
});
@ -220,35 +172,6 @@ const exportBtn = () => {
searchRef.value.btnLoading = false;
});
};
const handleEdit = (record: any) => {
editRecord.value = { ...record };
editVisible.value = true;
};
const handleDeleteFn = async (record: any, reason: string) => {
return deleteSurfaceTempInfo({
dataList: [
{
id: record.stcd,
dt: record.tm
}
],
dataType: 'TIME',
source: reason
});
};
const handleDelete = (record: any) => {
deleteModalRef.value?.open(record, () => {
initTable(currentSearchParams.value);
});
};
const handleEditSuccess = () => {
initTable(currentSearchParams.value);
};
const initTable = (values: any) => {
const filters = [
values.rvcd && values.rvcd !== 'all'
@ -287,7 +210,7 @@ const initTable = (values: any) => {
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY();
tableScrollY.value = calcTableScrollY(tableRef.value);
});
});
</script>

View File

@ -1,288 +0,0 @@
<template>
<a-modal
v-model:open="visible"
title="编辑垂向水温数据"
width="60vw"
:confirm-loading="confirmLoading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:label-col="{ span: 7 }"
:wrapper-col="{ span: 17 }"
>
<div class="form-scroll-area">
<div class="form-section">
<a-row :gutter="16">
<a-col :span="24"
><div class="form-group-title">基本信息</div></a-col
>
<a-col :span="12">
<a-form-item label="测站名称">
<a-input :value="recordData.stnm || '-'" disabled />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="时间">
<a-input :value="displayTime" disabled />
</a-form-item>
</a-col>
</a-row>
</div>
<div class="form-section">
<a-row :gutter="16">
<a-col :span="24"
><div class="form-group-title">水深数据</div></a-col
>
<a-col v-for="item in depthFieldList" :key="item.key" :span="12">
<a-form-item :label="`水深${item.key}m(℃)`">
<a-input-number
v-model:value="formData.dataList[item.key]"
placeholder="请输入水温"
:precision="1"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</div>
</div>
</a-form>
</a-modal>
<a-modal
v-model:open="confirmModalVisible"
title="确认修改"
width="700px"
:confirm-loading="confirmLoading"
@ok="handleConfirmSubmit"
@cancel="handleConfirmCancel"
>
<div style="max-height: 50vh; overflow-y: auto; margin-bottom: 16px">
<a-descriptions bordered :column="1" size="small">
<a-descriptions-item
v-for="item in changedDepthList"
:key="item.key"
:label="`水深${item.key}m(℃)`"
>
<span style="color: #ff4d4f; text-decoration: line-through">{{
item.oldValue
}}</span>
<span style="margin: 0 8px"></span>
<span style="color: #52c41a">{{ item.newValue }}</span>
</a-descriptions-item>
</a-descriptions>
</div>
<a-form-item label="修改依据">
<a-textarea
v-model:value="sourceValue"
placeholder="请输入修改依据"
:rows="5"
:maxlength="500"
show-count
/>
</a-form-item>
</a-modal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { message } from 'ant-design-vue';
import { updateVerticalInfo } from '@/api/DataQueryMenuModule';
const props = defineProps<{
open: boolean;
record?: any;
timeScale?: string;
visibleDepths?: string[];
}>();
const emit = defineEmits(['update:open', 'success']);
const visible = computed({
get: () => props.open,
set: val => emit('update:open', val)
});
const formRef = ref();
const confirmLoading = ref(false);
const confirmModalVisible = ref(false);
const sourceValue = ref('');
const recordData = ref<any>({});
const formData = ref<{
dataList: Record<string, number | null>;
}>({
dataList: {}
});
const depthFieldList = computed(() => {
const dataList = recordData.value?.dataList || {};
const visibleDepths = props.visibleDepths || [];
return Object.keys(dataList)
.filter(key => visibleDepths.includes(key))
.sort((a, b) => Number(a) - Number(b))
.map(key => ({
key
}));
});
const displayTime = computed(() => {
const timeValue = recordData.value?.dt ?? recordData.value?.tm;
if (!timeValue) return '-';
if (props.timeScale === 'month') {
return dayjs(timeValue).format('YYYY-MM');
}
if (props.timeScale === 'dt') {
return dayjs(timeValue).format('YYYY-MM-DD');
}
return dayjs(timeValue).format('YYYY-MM-DD HH:mm:ss');
});
const normalizeValue = (value: any) => {
if (value === undefined || value === null || value === '') return null;
return Number(value);
};
const formatValue = (value: any) => {
const normalized = normalizeValue(value);
return normalized === null ? '空' : String(normalized);
};
const changedDepthList = computed(() => {
return depthFieldList.value
.map(item => {
const oldValue = normalizeValue(recordData.value?.dataList?.[item.key]);
const newValue = normalizeValue(formData.value.dataList?.[item.key]);
if (oldValue === newValue) return null;
return {
key: item.key,
oldValue: formatValue(oldValue),
newValue: formatValue(newValue)
};
})
.filter(Boolean) as { key: string; oldValue: string; newValue: string }[];
});
watch(
() => props.record,
newRecord => {
if (!newRecord) {
recordData.value = {};
formData.value = {
dataList: {}
};
return;
}
recordData.value = JSON.parse(JSON.stringify(newRecord));
const dataList = recordData.value?.dataList || {};
const nextDataList: Record<string, number | null> = {};
Object.keys(dataList)
.sort((a, b) => Number(a) - Number(b))
.forEach(key => {
nextDataList[key] = normalizeValue(dataList[key]);
});
formData.value = {
dataList: nextDataList
};
sourceValue.value = '';
confirmModalVisible.value = false;
},
{ deep: true, immediate: true }
);
const handleOk = async () => {
try {
if (changedDepthList.value.length === 0) {
message.info('未检测到任何修改');
return;
}
sourceValue.value = '';
confirmModalVisible.value = true;
} catch (error) {
console.error('验证失败:', error);
}
};
const handleConfirmSubmit = async () => {
confirmLoading.value = true;
try {
const updateData = {
stcd: recordData.value?.stcd,
dt: recordData.value?.dt,
stnm: recordData.value?.stnm,
map: depthFieldList.value.reduce((result, item) => {
const value = formData.value.dataList?.[item.key];
result[item.key] =
value === null || value === undefined || value === ''
? ''
: String(value);
return result;
}, {} as Record<string, string>)
};
const res = await updateVerticalInfo({
updateData,
source: sourceValue.value.trim()
});
if (res?.code == 0 || res?.success) {
message.success('编辑成功');
confirmModalVisible.value = false;
visible.value = false;
emit('success');
} else {
message.error(res?.msg || '编辑失败');
}
} catch (error) {
console.error('提交失败:', error);
message.error('提交失败,请重试');
} finally {
confirmLoading.value = false;
}
};
const handleConfirmCancel = () => {
confirmModalVisible.value = false;
sourceValue.value = '';
};
const handleCancel = () => {
visible.value = false;
confirmModalVisible.value = false;
sourceValue.value = '';
formRef.value?.resetFields();
};
</script>
<style scoped lang="scss">
.form-scroll-area {
max-height: 500px;
overflow-y: auto;
padding-right: 8px;
}
.form-section {
padding: 0 4px 0;
background: #fff;
border-radius: 6px;
}
.form-section + .form-section {
margin-top: 8px;
}
.form-group-title {
font-size: 16px;
font-weight: 600;
color: #333;
padding: 8px 0 12px;
border-bottom: 1px solid #e8e8e8;
margin-bottom: 16px;
}
</style>

View File

@ -59,43 +59,9 @@
:min-selection-count="1"
@selection-change="handleCxswSelectionChange"
>
<template #action="{ record }">
<template v-if="currentSearchParams.timeScale === 'tm'">
<a-button
type="link"
class="text-[#2f6b98]"
size="small"
@click="handleEdit(record)"
>编辑</a-button
>
<a-button
type="link"
danger
size="small"
@click="handleDelete(record)"
>删除</a-button
>
</template>
</template>
</BasicTable>
</div>
</div>
<EditVerticalTempModal
v-model:open="editVisible"
:record="editRecord"
:time-scale="currentSearchParams.timeScale"
:visible-depths="selectedColumns"
@success="handleEditSuccess"
/>
<DeleteConfirmModal
ref="deleteModalRef"
:delete-fn="handleDeleteFn"
title="删除垂向水温数据"
label="数据"
@success="handleEditSuccess"
/>
</div>
</template>
@ -111,8 +77,6 @@ import {
import dayjs from 'dayjs';
import * as echarts from 'echarts';
import VerticalTempSearch from './VerticalTempSearch.vue';
import EditVerticalTempModal from './EditVerticalTempModal.vue';
import DeleteConfirmModal from '@/views/DataQueryMenuModule/components/conventionalHydropower/BasicData/DeleteConfirmModal.vue';
import BasicTable from '@/components/BasicTable/index.vue';
import { getVerticalList, deleteVerticalInfo } from '@/api/DataQueryMenuModule';
import { calcTableScrollY } from '@/utils/index';
@ -221,20 +185,7 @@ const tableColumns = computed(() => {
: '-';
}
}));
const actionCols =
currentSearchParams.value.timeScale === 'tm'
? [
{
title: '操作',
dataIndex: 'action',
fixed: 'right',
width: 120
}
]
: [];
return [...fixedCols, ...dataCols, ...actionCols];
return [...fixedCols, ...dataCols ];
});
const tableScrollX = computed(() => {
@ -243,13 +194,7 @@ const tableScrollX = computed(() => {
return total > 600 ? total : undefined;
});
//
const defaultSelectedRowKeys = computed(() => {
if (tableData.value && tableData.value.length > 0) {
return [tableData.value[0].dt];
}
return [];
});
//
const COLORS = [
@ -434,33 +379,7 @@ const exportBtn = () => {
});
};
const handleEdit = (record: any) => {
editRecord.value = { ...record };
editVisible.value = true;
};
const handleDeleteFn = async (record: any, reason: string) => {
return deleteVerticalInfo({
dataList: [
{
id: record.stcd ?? record.id,
tm: record.tm ?? record.dt
}
],
dataType: 'TIME',
source: reason
});
};
const handleDelete = (record: any) => {
deleteModalRef.value?.open(record, () => {
initTable(currentSearchParams.value);
});
};
const handleEditSuccess = () => {
initTable(currentSearchParams.value);
};
const initTable = (values: any) => {
const filters = [
@ -627,7 +546,7 @@ let resizeObserver: ResizeObserver | null = null;
onMounted(() => {
nextTick(() => {
tableScrollY.value = calcTableScrollY();
tableScrollY.value = calcTableScrollY(tableRef.value);
//
if (chartRef.value && !chartInstance) {

View File

@ -1,405 +0,0 @@
<template>
<a-modal
v-model:open="visible"
title="编辑水质数据"
width="60vw"
:confirm-loading="confirmLoading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:label-col="{ span: 8 }"
:wrapper-col="{ span: 16 }"
:rules="formRules"
class="max-h-[70vh] overflow-y-auto pr-4"
>
<a-row :gutter="16">
<!-- 基本信息只读展示 -->
<a-col :span="24"><div class="form-group-title">基本信息</div></a-col>
<a-col :span="8">
<a-form-item label="测站/断面名称">
<a-input :value="formData.stnm" disabled />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="所属电站">
<a-input :value="formData.ennm" disabled />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="基地">
<a-input :value="formData.baseName" disabled />
</a-form-item>
</a-col>
<!-- 地表水等级 -->
<a-col :span="24"><div class="form-group-title">地表水等级</div></a-col>
<a-col :span="8">
<a-form-item label="水质要求" name="wwqtg">
<a-input
v-model:value="formData.wwqtgName"
disabled
placeholder="请输入"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="水质等级" name="wqgrd">
<a-input
v-model:value="formData.wqgrdName"
disabled
placeholder="请输入"
/>
</a-form-item>
</a-col>
<!-- 污染物监测指标 -->
<a-col :span="24"
><div class="form-group-title">污染物监测指标</div></a-col
>
<a-col :span="8">
<a-form-item label="化学需氧量(mg/L)" name="codcr">
<a-input-number
v-model:value="formData.codcr"
placeholder="请输入"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="氨氮(mg/L)" name="nh3n">
<a-input-number
v-model:value="formData.nh3n"
placeholder="请输入"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="总磷(mg/L)" name="tp">
<a-input-number
v-model:value="formData.tp"
placeholder="请输入"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="总氮(mg/L)" name="tn">
<a-input-number
v-model:value="formData.tn"
placeholder="请输入"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<!-- 五参数 -->
<a-col :span="24"><div class="form-group-title">五参数</div></a-col>
<a-col :span="8">
<a-form-item label="电导率(μS/cm)" name="cond">
<a-input-number
v-model:value="formData.cond"
placeholder="请输入"
:precision="1"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="溶解氧(mg/L)" name="dox">
<a-input-number
v-model:value="formData.dox"
placeholder="请输入"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="pH值" name="ph">
<a-input-number
v-model:value="formData.ph"
placeholder="请输入"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="浊度(NTU)" name="tu">
<a-input-number
v-model:value="formData.tu"
placeholder="请输入"
:precision="1"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="水温(℃)" name="wtmp">
<a-input-number
v-model:value="formData.wtmp"
placeholder="请输入"
:precision="1"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
<!-- 确认修改弹框 -->
<a-modal
v-model:open="confirmModalVisible"
title="确认修改"
width="800px"
:confirm-loading="confirmLoading"
@ok="handleConfirmSubmit"
@cancel="handleConfirmCancel"
>
<div style="max-height: 50vh; overflow-y: auto; margin-bottom: 16px">
<a-descriptions bordered :column="1" size="small">
<a-descriptions-item
v-for="item in diffList"
:key="item.field"
:label="item.label"
>
<span style="color: #ff4d4f; text-decoration: line-through">{{
item.oldValue
}}</span>
<span style="margin: 0 8px"></span>
<span style="color: #52c41a">{{ item.newValue }}</span>
</a-descriptions-item>
</a-descriptions>
</div>
<a-form-item label="修改依据">
<a-textarea
v-model:value="sourceValue"
placeholder="请输入修改依据"
:rows="5"
:maxlength="500"
show-count
/>
</a-form-item>
</a-modal>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { message } from 'ant-design-vue';
import { updateWaterDataInfo } from '@/api/DataQueryMenuModule';
const props = defineProps<{
open: boolean;
record?: any;
}>();
const emit = defineEmits(['update:open', 'success']);
const visible = computed({
get: () => props.open,
set: val => emit('update:open', val)
});
const formRef = ref();
const confirmLoading = ref(false);
const formData = ref<any>({});
const originalRecord = ref<any>({});
const formRules = ref<any>({
stnm: [{ required: true, message: '请输入测站名称', trigger: 'blur' }]
});
//
const confirmModalVisible = ref(false);
const sourceValue = ref('');
//
const fieldLabelMap: Record<string, string> = {
codcr: '化学需氧量(mg/L)',
nh3n: '氨氮(mg/L)',
tp: '总磷(mg/L)',
tn: '总氮(mg/L)',
cond: '电导率(μS/cm)',
dox: '溶解氧(mg/L)',
ph: 'pH值',
tu: '浊度(NTU)',
wtmp: '水温(℃)'
};
// select diff
const selectOptionsMap: Record<string, () => any[]> = {};
const resolveSelectLabel = (field: string, value: any): string => {
if (value === null || value === undefined || value === '') return '空';
const getOptions = selectOptionsMap[field];
if (getOptions) {
const opt = getOptions().find(
(o: any) => String(o.value) === String(value)
);
if (opt) return opt.label;
}
return String(value);
};
//
const changeOrder = ref<
{ field: string; label: string; oldValue: string; newValue: string }[]
>([]);
const diffList = changeOrder;
// "-"null
const convertDashToNull = (obj: any) => {
const result: any = {};
for (const key in obj) {
if (obj[key] === '-' || obj[key] === '') {
result[key] = null;
} else {
result[key] = obj[key];
}
}
return result;
};
// formData
watch(
() => formData.value,
newData => {
const original = originalRecord.value;
for (const key in newData) {
const oldVal = original[key];
const newVal = newData[key];
if (oldVal !== newVal) {
const existingIdx = changeOrder.value.findIndex(c => c.field === key);
const oldNorm =
oldVal === null || oldVal === undefined || oldVal === '-'
? null
: oldVal;
const newNorm = newVal === null || newVal === undefined ? null : newVal;
if (oldNorm === newNorm) continue;
if (
oldNorm !== null &&
newNorm !== null &&
!isNaN(Number(oldNorm)) &&
!isNaN(Number(newNorm)) &&
Number(oldNorm) === Number(newNorm)
)
continue;
const isSelect = !!selectOptionsMap[key];
const oldDisplay = isSelect
? resolveSelectLabel(key, oldNorm)
: oldNorm === null
? '空'
: String(oldNorm);
const newDisplay = isSelect
? resolveSelectLabel(key, newNorm)
: newNorm === null
? '空'
: String(newNorm);
const entry = {
field: key,
label: fieldLabelMap[key] || key,
oldValue: oldDisplay,
newValue: newDisplay
};
if (existingIdx >= 0) {
changeOrder.value[existingIdx] = entry;
} else {
changeOrder.value.push(entry);
}
} else {
changeOrder.value = changeOrder.value.filter(c => c.field !== key);
}
}
},
{ deep: true }
);
// record
watch(
() => props.record,
newRecord => {
if (newRecord) {
const converted = convertDashToNull({ ...newRecord });
originalRecord.value = { ...converted };
formData.value = { ...converted };
changeOrder.value = [];
}
},
{ deep: true }
);
const handleOk = async () => {
try {
await formRef.value?.validateFields();
if (changeOrder.value.length === 0) {
message.info('未检测到任何修改');
return;
}
//
sourceValue.value = '';
confirmModalVisible.value = true;
} catch (error) {
console.error('验证失败:', error);
}
};
const handleConfirmSubmit = async () => {
confirmLoading.value = true;
try {
const res = await updateWaterDataInfo({
updateData: { ...formData.value },
source: sourceValue.value.trim()
});
if (res?.code == 0 || res?.success) {
message.success('编辑成功');
confirmModalVisible.value = false;
visible.value = false;
emit('success');
} else {
message.error(res?.msg || '编辑失败');
}
} catch (error) {
console.error('提交失败:', error);
message.error('提交失败,请重试');
} finally {
confirmLoading.value = false;
}
};
const handleConfirmCancel = () => {
confirmModalVisible.value = false;
sourceValue.value = '';
};
const handleCancel = () => {
visible.value = false;
confirmModalVisible.value = false;
sourceValue.value = '';
formRef.value?.resetFields();
};
</script>
<style scoped lang="scss">
.form-group-title {
font-size: 16px;
font-weight: 600;
color: #333;
padding: 12px 0;
border-bottom: 1px solid #e8e8e8;
margin-bottom: 16px;
}
</style>

Some files were not shown because too many files have changed in this diff Show More