添加智能配图功能

This commit is contained in:
扈兆增 2026-07-28 18:28:07 +08:00
parent aea105c1f3
commit f3228fca74
27 changed files with 37634 additions and 4 deletions

View File

@ -7,13 +7,13 @@ VITE_APP_TITLE = '水电水利建设项目全过程环境管理信息平台'
VITE_APP_PORT = 3000
VITE_APP_BASE_API = '/dev-api'
# 本地环境
# VITE_APP_BASE_URL = 'http://localhost:8093'
VITE_APP_BASE_URL = 'http://localhost:8093'
# 测试环境
# VITE_APP_BASE_URL = 'http://172.16.21.142:8093'
# 汤伟
VITE_APP_BASE_URL = 'http://10.84.111.235:8093'
# VITE_APP_BASE_URL = 'http://10.84.111.235:8093'
# 李林
# VITE_APP_BASE_URL = 'http://10.84.111.25:8093'
VITE_APP_BASE_URL = 'http://10.84.111.25:8093'
## 开发环境 附件服务地址
VITE_APP_ATTACHMENT_URL = 'https://211.99.26.225:12125'

View File

@ -0,0 +1,343 @@
# 地图可视化配置平台MapStudio技术实现说明文档
## 1. 项目概述
本项目是一个基于 **Vue3** + **Ant Design Vue 4.0** + **OpenLayers (ol ^10.8.0)** 的独立地图配图功能模块。 **核心定位**:用于流域水电场景的地图配图工具,支持多类型底图切换、基础图层叠加、业务锚点管理(含样式编辑)。 **关键特性**:作为一个高内聚、低耦合的独立模块,支持整体复制迁移至其他 Vue3 项目。
## 2. 技术栈与环境
- **框架**Vue 3 (Composition API)
- **UI 组件库**Ant Design Vue 4.0 (`a-drawer`, `a-tabs`, `a-popover`, `a-modal` 等)
- **地图引擎**OpenLayers (`ol@^10.8.0`)
- **状态管理**Vue `reactive` + `provide`/`inject` 模式(`useMapStudioState.ts`
- **辅助库**`html2canvas`(配图截图预览,待接入)
- **图标资源**`/src/assets/legend/*.svg`Vite glob 批量导入)
## 3. 页面布局结构
整体采用 **“左侧固定工具栏 + 中央地图容器 + 右侧可收抽屉 + 左下悬浮图例”** 的布局方案。
| 区域 | 组件/实现 | 交互说明 |
| :--------------------- | :--------------------- | :------------------------------------------------------------------------------------------- |
| **工具栏** | `LeftToolbar.vue` | 垂直排列【放大】、【缩小】、【全屏】、【导入(占位)】、【保存配图】。独立于抽屉,抽屉收起时依然显示。目前按钮均为静态占位,无实际交互逻辑。 |
| **中央地图容器** | `index.vue` 内联 div | 承载 OL Map 实例,直接在 `index.vue` 中通过 `ref` 挂载地图,无独立 `MapContainer.vue` 组件。 |
| **右侧抽屉** | `RightDrawer.vue` | `a-drawer` 组件,支持收起/展开。内部嵌套 `a-tabs`,包含【基础底图】、【图例/标题管理】、【锚点样式】三个 Tab。 |
| **左下悬浮图例** | `Legend.vue` | 动态显示当前可见锚点分类的图标和标签,支持图例标题/子级样式配置,位置可通过配置调整(左上/左下/右上/右下)。 |
## 4. 功能模块详细说明
### 4.1 底图切换
- **支持类型**:【影像图】、【矢量图】、【地形图】、【等高线图】。通过替换 `ol/layer/Tile``source` 实现。
- 影像图ArcGIS World Imagery
- 矢量图:内网 GeoServer WMTS`qgc_qsj_arcgistiles_l13`
- 地形图:天地图地形 `ter_w`
- 等高线图Thunderforest Cycle
- **边界图层**:可通过 `a-checkbox` 控制显示/隐藏内网 GeoServer 提供的流域边界线。切换到矢量图时强制隐藏边界。
- **空间范围限定**:未实现(后续可扩展)。
### 4.2 叠加基础图层(显隐控制)
通过 `useOverlayLayers.ts` 管理,右侧抽屉"基础底图"Tab 中的 Checkbox 勾选控制图层显隐(取消勾选时仅 `setVisible(false)`,不删除图层)。
支持以下图层(部分为 WMTS/ArcGIS/GeoServer 服务):
| 图层 | 类型 | 数据来源 |
| :----------- | :---------- | :------------------------------- |
| 行政标注 | TileLayer | 天地图矢量标注 `cia_w` |
| 区域服务 | ImageLayer | ArcGIS REST `ZH_QUYU` |
| 九段线 | ImageLayer | ArcGIS REST `九段线服务1` |
| 岛屿 | ImageLayer | GeoServer WMS `cite:dy` |
| 流域注记 | ImageLayer | ArcGIS REST `zh_LiuYu_annotation` |
| 流域线 | ImageLayer | ArcGIS REST `zh_LiuYu_line` |
| 轮渡 | ImageLayer | ArcGIS REST `jiaotong_ferry` |
| 铁路 | ImageLayer | ArcGIS REST `jiaotong_railway` |
| 地铁 | ImageLayer | ArcGIS REST `jiaotong_metro` |
| 行政区面状 | ImageLayer | ArcGIS REST `zh_XingZhengQu_Polygon` |
| 行政区 | ImageLayer | ArcGIS REST `ZH_XINGZHEGNQU` |
### 4.3 业务锚点系统
锚点类型4 类):电站、过鱼设施、鱼类增殖站、低温水减缓设施。页面初始化时即并行加载所有分类的接口数据,勾选子类型后渲染到地图上,同时图例自动显示。
#### 4.3.1 数据模型
锚点核心接口定义在 `useMapStudioState.ts` 中:
```typescript
interface AnchorCategory {
key: string; // 分类 keypowerStation / fishPassage / fishFarm / coldWater
label: string; // 分类名称
icon: string; // 分类图标 key
children: AnchorItem[];
}
interface AnchorItem {
key: string; // 子类型 key如 large_eng_built
label: string; // 子类型名称
icon: string; // 子类型图标 key
checked: boolean; // 是否勾选
}
```
每个锚点 Feature 的 properties 包含:
- `_iconUrl` - 当前图标 URL
- `_label` - 显示文字
- `_stnm` - 站点名称
- `_lng`, `_lat` - 经纬度
- `_categoryKey`, `_subTypeKey` - 分类/子类型标识
- `_iconSource` - 图标来源inherit / custom
- `_customIconUrl` - 自定义图标 URL
- `_customTypeLabel` - 自定义类型名称(更换图标时可自定义)
- `_ov_*` - 样式覆盖字段(字体、字号、颜色、加粗、图标大小、描边、文字位置/边距、显示文字)
#### 4.3.2 样式继承与优先级
- **全局样式**:存储在 `anchorGlobalStyle` ref 中,修改时全局锚点刷新。
- **单个覆盖**:存储在 feature properties 的 `_ov_*` 字段中。`null` 表示继承全局。
- **渲染合并**:在 `createStyleFunction()` 中执行:`getOv(feature, key, globalValue)` - 有覆盖用覆盖值,否则回退到全局。
- **恢复默认**:将所有 `_ov_*` 置为 `null`
#### 4.3.3 锚点交互
- **勾选显示**:右侧抽屉"基础底图"Tab 提供四类锚点的 Checkbox 组,勾选子类型后地图渲染对应锚点,图例自动更新。
- **选中与编辑**
- **点击地图锚点**:触发选中事件,右侧抽屉自动切换到"锚点样式"Tab 并展开该锚点的详情编辑区域。
- **右侧面板编辑**:可编辑当前选中锚点的:
- **经纬度**(支持直接修改并更新地图位置)
- **显示文字**(修改锚点显示的文本)
- **更换图标**(上传自定义图标,支持两个选项)
- **样式覆盖**(每个样式字段独立覆盖全局,支持一键恢复继承)
- **无气泡 popover**:目前不弹出地图气泡。
- **更换图标**
- **选项 A - 应用于此锚点**:仅替换当前选中锚点的图标。可选择"添加到图例",输入父级名称和当前名称,若图例中不存在则自动添加。
- **选项 B - 应用于所有同类型**:替换当前分类下所有锚点的图标,同时图例中该分类的图标也会更新。
- **碰撞检测**:不做碰撞检测与抽稀(`declutterMode: 'none'`)。
#### 4.3.4 锚点导入(暂为占位)
左侧工具栏【导入】按钮为静态占位,无实际交互逻辑。
#### 4.3.5 图标资源映射
图标 SVG 文件存放于 `/src/assets/legend/`,通过 `ICON_SVG_MAP``useMapStudioState.ts`)建立 icon key 到文件名不含扩展名的映射Vite glob 批量导入获取 URL。
### 4.4 图例/标题管理
右侧抽屉"图例/标题管理"Tab
#### 4.4.1 图例配置
- **显示控制**`a-checkbox` 控制图例和标题的显隐
- **图例属性**:位置(左上/左下/右上/右下)、宽度、高度、左边距/右边距、上边距/下边距
- **图例标题**:对齐方式(左/中/右)、字体、字号、颜色、加粗
- **子级样式**:字体、字号、颜色、加粗、图标大小
#### 4.4.2 标题配置
- 标题内容、X/Y 位置、宽度、高度(通过 `BasicControlOptions[1].checked` 控制显隐)
### 4.5 锚点全局样式管理
右侧抽屉"锚点样式"Tab在无选中锚点时仅显示"全局样式"配置区:
- 字体、字号、颜色、加粗
- 图标大小
- 文字描边宽度、描边颜色
- 文字位置(上/下/左/右)、水平边距、垂直边距
- 是否显示文字标注
- **恢复默认**:一键重置为 `DEFAULT_ANCHOR_STYLE`
有选中锚点时,顶部显示"当前选中"区域,包含:
- 分类标签(含自定义类型名称)
- 站点名称
- 经纬度编辑
- 显示文字编辑
- 当前图标预览 + 更换图标按钮
- **样式覆盖**区:每个样式字段独立可调,带"恢复为继承"按钮(×)
- **恢复默认**:一键清空所有覆盖
### 4.6 自定义图形绘制(未实现)
预留功能,尚未开发。
### 4.7 自定义图片叠加(未实现)
预留功能,尚未开发。
### 4.8 配图方案管理(未实现)
预留功能,尚未开发。
## 5. 目录结构与实际代码
```
src/modules/MapStudio/
├── index.vue # 主入口组件
├── api/
│ └── index.ts # 4 类锚点 API 接口
├── components/
│ ├── LeftToolbar.vue # 左侧工具栏
│ ├── RightDrawer.vue # 右侧抽屉
│ └── Legend.vue # 左下悬浮图例
├── composables/
│ ├── useMapStudioState.ts # 共享状态创建/注入
│ ├── useMapInit.ts # 地图初始化与底图切换
│ ├── useOverlayLayers.ts # 基础图层叠加管理
│ └── useAnchorLayers.ts # 锚点图层管理
└── styles/
└── index.scss # 模块样式
```
## 6. 核心数据流
- **状态管理**`useMapStudioState.ts` 通过 `createMapStudioState()` 创建所有响应式状态,`provideMapStudioState()` 注入,子组件通过 `injectMapStudioState()` 获取。
- **锚点图层**`useAnchorLayers.ts` 管理 4 类锚点的 API 数据加载、OL 图层创建/显隐、样式函数、地图点击选中、样式覆盖同步。
- **基础图层**`useOverlayLayers.ts` 管理基础底图的叠加,通过 `LAYER_MAP` 定义各图层的创建工厂,响应式同步勾选状态。
- **API 层**`api/index.ts` 定义 4 类锚点的请求参数和接口函数,使用项目通用 `request` 工具(基于 axios
## 7. 开发注意事项与难点规避
- **性能优化**:锚点数量较多(>500务必开启 VectorSource 的 `renderMode: 'vector'`,并批量添加 Features`source.addFeatures()`)。
- **样式刷新**:覆盖字段写入 feature 后,必须重新 `setStyle(styleFn)` 触发重绘。
- **交互冲突**:左侧工具栏按钮需阻止点击事件冒泡,避免触发地图的 `singleclick` 事件。
- **底图切换**:切换时将新底图 `insertAt(0)` 插入最底层,避免覆盖上层叠加图层。
- **边界图层**:切换到底图"矢量图"时强制移除边界图层(因矢量图 WMTS 已包含边界信息)。
- **图标覆盖**:选项 B 替换图标时,通过 `categoryIconOverrides` 存储覆盖 URL图例组件会读取该字段更新显示。
## 8. 未实现功能列表与推荐优先级
### 功能完成度概览
| 功能模块 | 状态 | 说明 |
| :--------------------- | :----------- | :------------------------------------------- |
| 底图切换 | ✅ 已完成 | 4 种底图,边界图层显隐控制 |
| 基础图层叠加 | ✅ 已完成 | 11 个子图层,分组 Checkbox 勾选控制 |
| 锚点数据加载与展示 | ✅ 已完成 | 4 类接口初始化加载,勾选渲染,图例自动更新 |
| 锚点全局样式控制 | ✅ 已完成 | 字体/字号/颜色/加粗/图标大小/描边/位置/边距 |
| 锚点选中与编辑 | ✅ 已完成 | 点击选中,右侧面板编辑经纬度/文字/样式 |
| 更换图标(单锚点) | ✅ 已完成 | 上传+添加到图例,自定义类型名称 |
| 更换图标(同分类) | ✅ 已完成 | 替换全部分类图标,更新图例 |
| 图例/标题配置 | ✅ 已完成 | 位置/尺寸/对齐/字体/颜色/加粗 |
| 配图方案管理 | ❌ 未实现 | 保存/加载/历史配图 |
| 工具栏按钮功能 | ❌ 未实现 | 放大/缩小/全屏/导入/保存按钮均为静态占位 |
| 自定义图形绘制 | ❌ 未实现 | 线条/箭头/矩形/圆圈 |
| 自定义图片叠加 | ❌ 未实现 | PNG 上传叠加,调整位置/大小 |
| 空间范围限定 | ❌ 未实现 | 选择区域并裁切地图 |
| 锚点拖拽 | ❌ 未实现 | Translate 交互拖动锚点位置 |
| 选中锚点 popover 气泡 | ❌ 未实现 | 点击锚点在地图弹出小气泡 |
| 锚点导入功能 | ❌ 未实现 | Excel/CSV 批量导入 |
### 推荐开发优先级
**P0 - 核心功能(建议优先完成)**
1. **配图方案管理** - 这是配图工具的核心闭环功能。左侧【保存】按钮应弹出命名输入框保存当前所有状态底图类型、图层显隐、锚点数据与样式覆盖、图例配置、标题配置为JSON配图方案并支持历史记录查看、加载、删除。
- 涉及文件:`LeftToolbar.vue`(接入保存/导入按钮逻辑)、`RightDrawer.vue`(新增加载方案界面)、新建 `useSerializer.ts`(序列化/反序列化)
- 依赖:需要后端存储接口或本地 localStorage
2. **工具栏按钮功能** - 放大/缩小/全屏/导入/保存按钮目前均为静态。放大缩小应绑定 `map.getView().zoom`,全屏应调用 `FullScreen` API导入按钮应弹出功能弹框。
- 涉及文件:`LeftToolbar.vue`(接入 mapInstance
**P1 - 重要功能(配图方案之后)**
3. **自定义图形绘制** - 利用 `ol/interaction/Draw` 实现线条、矩形、圆圈,以及箭头(起点终点+终点三角头)。图形存入独立 `VectorSource`,支持选中删除。保存配图时需包含图形坐标数据。
- 涉及文件:新建 `useDrawTools.ts`、`RightDrawer.vue`新增绘制Tab或面板
4. **自定义图片叠加** - 上传 PNG 图片以绝对定位 div 覆盖在地图上,支持鼠标拖拽移动位置。保存配图时记录 CSS 位置数据。
- 涉及文件:新建 `CustomImageOverlay.vue`、`RightDrawer.vue`(新增图片管理面板)
**P2 - 增强功能(基础功能稳定后)**
5. **空间范围限定** - 下拉选择水电基地/流域/省份,地图 fit 至对应边界,并可调用 `clippingPolygons` 实现区域外变灰的裁切效果。
- 涉及文件:`useMapInit.ts`(扩展)、`RightDrawer.vue`(新增下拉选择)
6. **选中锚点气泡 popover** - 点击锚点时在地图弹出轻量 `a-popover` 气泡,显示站点名称和"删除"按钮。
- 涉及文件:`useAnchorLayers.ts`(点击事件扩展)
7. **锚点拖拽** - 启用 `ol/interaction/Translate` 允许拖动锚点改变地理位置,拖拽结束自动更新选中面板的经纬度。
- 涉及文件:`useAnchorLayers.ts`(添加 Translate 交互)
8. **锚点导入** - 左侧【导入】按钮弹出 a-modal 弹框,支持 Excel/CSV 批量导入经纬度数据。
- 涉及文件:`LeftToolbar.vue`(接入导入弹框)、新建导入逻辑 composable
**P3 - 工程优化(可逐步完善)**
9. **迁移为 Pinia 状态管理** - 当前使用 `provide`/`inject`,迁移到 Pinia 可更好地支持 Vue Devtools 调试和模块隔离。
- 涉及文件:新建 `store/mapStudioStore.ts`,调整 `useMapStudioState.ts`
10. **目录结构调整** - 将类型定义迁移到 `types/index.ts`,新建 `MapContainer.vue` 独立组件,对外暴露 `index.ts`app.use 安装)。
## 9. 与现有底层地图的集成方案(接入指南)
### 9.1 核心原则(禁止叠放)
在集成至已有地图项目时,**严禁**使用 `z-index` 将本模块地图叠放于底层地图之上。
**原因**
1. **事件冲突**:鼠标滚轮缩放、点击拖拽等事件会同时触发两层地图,导致画面闪烁、缩放倍数混乱。
2. **性能浪费**:被遮盖的底层地图仍在持续进行瓦片请求和渲染,消耗 CPU/GPU 与网络资源。
**正确策略**:必须将底层地图**隐藏/销毁**后,再加载本模块地图。
### 9.2 推荐集成方案(二选一)
#### 方案 A隐藏底层容器推荐保留底层状态
适用于底层地图涉及复杂的业务交互状态(如选中的点位、高亮区域),退出模块后需无缝恢复。
- **进入模块时**:设置底层地图容器的 CSS 样式为 `display: none``visibility: hidden`
- **模块内部**:在底层地图容器同级位置,新建一个绝对定位的 `div` 作为本模块地图的挂载点,初始化 `ol.Map`
- **退出模块时**
1. 调用本模块地图实例的 `map.dispose()` 销毁地图。
2. 移除新建的挂载 `div` 元素。
3. 恢复底层地图容器的显示样式。
#### 方案 B销毁底层地图更彻底
适用于底层地图仅作展示、无复杂临时状态的场景。
- **进入模块时**:直接获取底层地图容器,销毁原有 `ol.Map` 实例(`targetElement.innerHTML = ''`),并复用该容器作为本模块地图的挂载点。
- **退出模块时**:销毁本模块地图实例,根据业务需求决定是否重新初始化底层地图。
### 9.3 解耦约定(确保模块可迁移)
由于本模块设计为**独立可迁移**,模块内部代码**严禁**直接操作父级项目的 DOM 元素或地图实例。
**正确的通信方式**:本模块 `MapStudio/index.vue` 通过暴露生命周期事件,由父级项目自行控制底层地图的显隐与销毁。
```vue
<!-- 父级项目使用示例 -->
<template>
<div id="app">
<!-- 原有底层地图容器 -->
<div id="old-map-container" v-show="!showMapStudio"></div>
<!-- MapStudio 模块 -->
<MapStudio
v-if="showMapStudio"
@before-mount="handleBeforeMount" <!-- 进入模块时触发 -->
@after-destroy="handleAfterDestroy" <!-- 退出模块时触发 -->
/>
</div>
</template>
<script setup>
const showMapStudio = ref(false);
const handleBeforeMount = () => {
// 父级在此处执行:隐藏底层地图或销毁其实例
// 例如document.getElementById('old-map-container').style.display = 'none';
};
const handleAfterDestroy = () => {
// 父级在此处执行:恢复底层地图显示或重新初始化
// 例如document.getElementById('old-map-container').style.display = 'block';
};
</script>
```

View File

@ -34,6 +34,7 @@
"esri-leaflet": "^3.0.19",
"exceljs": "^4.4.0",
"file-saver": "^2.0.5",
"html2canvas": "^1.4.1",
"js-base64": "^3.7.5",
"js-cookie": "^3.0.1",
"jsencrypt": "^3.3.2",
@ -57,6 +58,7 @@
"vue-ant-modal-enhance": "^1.2.2",
"vue-i18n": "^9.1.9",
"vue-router": "^4.1.6",
"vue3-draggable-resizable": "^1.6.5",
"vuedraggable": "^2.24.3",
"wujie": "^1.0.24",
"wujie-vue3": "^1.0.24",

View File

@ -80,6 +80,9 @@ importers:
file-saver:
specifier: ^2.0.5
version: 2.0.5
html2canvas:
specifier: ^1.4.1
version: 1.4.1
js-base64:
specifier: ^3.7.5
version: 3.7.8
@ -149,6 +152,9 @@ importers:
vue-router:
specifier: ^4.1.6
version: 4.6.4(vue@3.5.33(typescript@6.0.3))
vue3-draggable-resizable:
specifier: ^1.6.5
version: 1.6.5
vuedraggable:
specifier: ^2.24.3
version: 2.24.3
@ -2720,6 +2726,10 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@ -3016,6 +3026,9 @@ packages:
css-box-model@1.2.1:
resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==}
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
css-select@4.3.0:
resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==}
@ -3772,6 +3785,10 @@ packages:
html-void-elements@2.0.1:
resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
htmlparser2@3.10.1:
resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==}
@ -5462,6 +5479,9 @@ packages:
resolution: {integrity: sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==}
engines: {node: '>=0.10'}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@ -5714,6 +5734,9 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
uuid@13.0.2:
resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==}
hasBin: true
@ -5858,6 +5881,9 @@ packages:
peerDependencies:
vue: ^3.0.0
vue3-draggable-resizable@1.6.5:
resolution: {integrity: sha512-31142E31fGNnq3HKqvmFLSsqIbhck7TyGuQWhUKrDw6DOcGAuRx4ddRjaxvT6fe7dgeKH53qAh+i0ZlWtPLl2g==}
vue@3.5.33:
resolution: {integrity: sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==}
peerDependencies:
@ -10304,6 +10330,8 @@ snapshots:
balanced-match@1.0.2: {}
base64-arraybuffer@1.0.2: {}
base64-js@1.5.1: {}
base@0.11.2:
@ -10654,6 +10682,10 @@ snapshots:
dependencies:
tiny-invariant: 1.3.3
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
css-select@4.3.0:
dependencies:
boolbase: 1.0.0
@ -11558,6 +11590,11 @@ snapshots:
html-void-elements@2.0.1: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
htmlparser2@3.10.1:
dependencies:
domelementtype: 1.3.1
@ -13279,6 +13316,10 @@ snapshots:
text-extensions@1.9.0: {}
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
text-table@0.2.0: {}
thenify-all@1.6.0:
@ -13533,6 +13574,10 @@ snapshots:
util-deprecate@1.0.2: {}
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
uuid@13.0.2: {}
uuid@8.3.2: {}
@ -13695,6 +13740,8 @@ snapshots:
is-plain-object: 3.0.1
vue: 3.5.33(typescript@6.0.3)
vue3-draggable-resizable@1.6.5: {}
vue@3.5.33(typescript@6.0.3):
dependencies:
'@vue/compiler-dom': 3.5.33

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -11,11 +11,16 @@ const modelStore = useModelStore();
const tagsViewStore = useTagsViewStore();
const router = useRoute();
const routeKey = computed(() => router.path + Math.random());
// GisView
const isZhiNengPeiTu = computed(() => {
return router.path === '/zhiNengFenXi/zhiNengPeiTu/index';
});
</script>
<template>
<section class="app-main">
<GisView />
<GisView v-if="!isZhiNengPeiTu" />
<div class="gi-panels">
<router-view v-slot="{ Component, route }" :key="routeKey">
<transition name="router-fade" mode="out-in">

View File

@ -0,0 +1,210 @@
import request from '@/utils/request';
/** Kendo UI 列表接口通用响应结构 */
export interface KendoListResponse<T = any> {
Data: T[];
Total: number;
AggregateResults?: any[];
Errors?: any;
}
/** 锚点基础字段 */
export interface AnchorPointItem {
baseId?: string;
ennm?: string;
lgtd?: number;
lttd?: number;
anchoPointState?: string;
sttp?: string;
[key: string]: any;
}
// ───────────────────────── 1. 电站 ─────────────────────────
export interface EngPointFilter {
filter: {
logic: string;
filters: Array<{
field: string;
operator: string;
dataType: string;
value?: string;
}>;
};
sort: Array<{
field: string;
dir: string;
}>;
}
/** 电站 - 请求体 */
export function getEngPointList(data: EngPointFilter) {
return request({
url: '/eng/base/point/GetKendoListCust',
method: 'post',
data
});
}
/** 电站 - 默认参数 */
export const ENG_POINT_DEFAULT_PARAMS: EngPointFilter = {
filter: {
logic: 'and',
filters: [
{
field: 'anchoPointState',
operator: 'isnotnull',
dataType: 'string'
}
]
},
sort: [
{ field: 'baseId', dir: 'asc' },
{ field: 'rvcdStepSort', dir: 'asc' },
{ field: 'siteStepSort', dir: 'asc' },
{ field: 'ennm', dir: 'asc' }
]
};
// ───────────────────────── 2. 过鱼设施 ─────────────────────────
export interface FpPointFilter {
filter: {
logic: string;
filters: Array<{
field: string;
operator: string;
dataType: string;
value?: string;
}>;
};
}
/** 过鱼设施 - 请求体 */
export function getFpPointList(data: FpPointFilter) {
return request({
url: '/fp/point/qgc/GetKendoListCust',
method: 'post',
data
});
}
/** 过鱼设施 - 默认参数 */
export const FP_POINT_DEFAULT_PARAMS: FpPointFilter = {
filter: {
logic: 'and',
filters: [
{
field: 'lgtd',
operator: 'isnotnull',
dataType: 'string'
}
]
}
};
// ───────────────────────── 3. 鱼类增殖站 ─────────────────────────
export interface FbPointFilter {
filter: {
logic: string;
filters: Array<{
field: string;
operator: string;
dataType: string;
value?: string;
}>;
};
}
/** 鱼类增殖站 - 请求体 */
export function getFbPointList(data: FbPointFilter) {
return request({
url: '/fb/point/GetKendoListCust',
method: 'post',
data
});
}
/** 鱼类增殖站 - 默认参数 */
export const FB_POINT_DEFAULT_PARAMS: FbPointFilter = {
filter: {
logic: 'and',
filters: [
{
field: 'sttp',
operator: 'eq',
dataType: 'string',
value: 'FB'
},
{
field: 'lgtd',
operator: 'isnotnull',
dataType: 'string'
}
]
}
};
// ───────────────────────── 4. 低温水减缓设施 ─────────────────────────
export interface WtPointFilter {
filter: {
logic: string;
filters: Array<{
field: string;
operator: string;
dataType: string;
value?: string;
}>;
};
}
/** 低温水减缓设施 - 请求体 */
export function getWtPointList(data: WtPointFilter) {
return request({
url: '/wt/getFacilityPointList/GetKendoListCust',
method: 'post',
data
});
}
/** 低温水减缓设施 - 默认参数 */
export const WT_POINT_DEFAULT_PARAMS: WtPointFilter = {
filter: {
logic: 'and',
filters: [
{
field: 'lgtd',
operator: 'isnotnull',
dataType: 'string'
}
]
}
};
// 保存配图
export function saveImage(data: any) {
return request({
url: '/smartImage/save',
method: 'post',
data
});
}
// 删除配图
export function deleteImage(data: any) {
return request({
url: '/smartImage/delete',
method: 'post',
data
});
}
// 获取配图列表
export function getImageList(data: any) {
return request({
url: '/smartImage/GetKendoList',
method: 'post',
data
});
}

View File

@ -0,0 +1,251 @@
<template>
<a-modal
:open="visible"
title="历史记录"
width="700px"
:footer="null"
:get-container="getModalContainer"
@cancel="handleCancel"
>
<!-- 搜索栏 -->
<div class="history-modal__search">
<a-input
v-model:value="searchKeyword"
placeholder="输入配图名称"
style="width: 260px"
allowClear
@press-enter="handleQuery"
/>
<a-button type="primary" :loading="loading" @click="handleQuery">
查询
</a-button>
</div>
<!-- 表格 -->
<a-table
:data-source="list"
:columns="columns"
:loading="loading"
:pagination="pagination"
row-key="id"
size="small"
@change="onTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'actions'">
<a-button type="link" size="small" @click="handleEdit(record)"
>编辑</a-button
>
<a-button type="link" size="small" @click="handlePreview(record)"
>预览</a-button
>
<a-popconfirm
title="确定删除该配图?"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</template>
</template>
</a-table>
</a-modal>
<!-- 隐藏的图片预览使用 Ant Design Image 组件 -->
<a-image
:src="previewImageSrc"
:preview="{
visible: previewVisible,
onVisibleChange: (v: boolean) => { previewVisible = v; },
getContainer: getModalContainer
}"
style="width: 0; height: 0; overflow: hidden"
/>
</template>
<script setup lang="ts">
import { ref, reactive, watch, nextTick } from 'vue';
import { Modal, message } from 'ant-design-vue';
import { useMapStudioStore } from '../stores/mapStudioStore';
import { getImageList, deleteImage } from '../api';
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{
'update:visible': [val: boolean];
}>();
const store = useMapStudioStore();
//
function getModalContainer() {
return document.querySelector('.map-studio') || document.body;
}
//
const searchKeyword = ref('');
const loading = ref(false);
const list = ref<any[]>([]);
const total = ref(0);
const pagination = reactive({
current: 1,
pageSize: 10,
showSizeChanger: false,
showTotal: (t: number) => `${t}`,
total: 0
});
const columns = [
{ title: '配图名称', dataIndex: 'peituName', key: 'peituName' },
{ title: '操作', key: 'actions', width: 200 }
];
//
async function fetchList() {
loading.value = true;
try {
const skip = (pagination.current - 1) * pagination.pageSize;
const params: any = {
skip,
take: pagination.pageSize
};
if (searchKeyword.value.trim()) {
params.filter = {
logic: 'and',
filters: [
{
field: 'peituName',
dataType: 'string',
operator: 'contains',
value: searchKeyword.value.trim()
}
]
};
}
const res = await getImageList(params);
const data = res.data;
//
if (data) {
list.value = data.records || data.data || [];
total.value = data.total ?? data.total ?? 0;
} else {
list.value = data.items || [];
total.value = data.total ?? 0;
}
pagination.total = total.value;
} catch (err) {
console.error('[HistoryModal] 查询失败:', err);
message.error('查询失败');
} finally {
loading.value = false;
}
}
function handleQuery() {
pagination.current = 1;
fetchList();
}
function onTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current;
pagination.pageSize = pag.pageSize;
fetchList();
}
//
function handleEdit(record: any) {
//
const hasUnsaved =
store.importedImages.length > 0 ||
store.anchorCategories.some(cat =>
cat.children.some(child => child.checked)
);
const applyEdit = () => {
try {
// config
const config =
typeof record.config === 'string'
? JSON.parse(record.config)
: record.config;
//
store.editConfigId = record.id || '';
store.editPeituName = record.peituName || '';
// store index.vue
store.restorePayload = config;
store.restoreVersion++;
message.success('配置已恢复');
handleCancel();
} catch (err) {
console.error('[HistoryModal] 编辑恢复失败:', err);
message.error('配置恢复失败');
}
};
if (hasUnsaved) {
Modal.confirm({
title: '当前地图有未保存的内容',
content: '应用历史配置将覆盖当前地图的所有内容,确定继续?',
okText: '确定覆盖',
cancelText: '取消',
onOk: applyEdit
});
} else {
applyEdit();
}
}
//
const previewImageSrc = ref('');
const previewVisible = ref(false);
function handlePreview(record: any) {
previewImageSrc.value =
import.meta.env.VITE_APP_ATTACHMENT_URL + '/?' + record.imageKey;
nextTick(() => {
previewVisible.value = true;
});
}
//
async function handleDelete(record: any) {
try {
let res = await deleteImage([record.id]);
if (res.code == 0) {
message.success('删除成功');
fetchList();
} else {
message.error('删除失败');
}
} catch (err) {
console.error('[HistoryModal] 删除失败:', err);
message.error('删除失败');
}
}
//
watch(
() => props.visible,
val => {
if (val) {
searchKeyword.value = '';
pagination.current = 1;
fetchList();
}
}
);
function handleCancel() {
emit('update:visible', false);
}
</script>
<style scoped>
.history-modal__search {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
</style>

View File

@ -0,0 +1,456 @@
<template>
<a-modal
:open="visible"
title="导入锚点"
width="620px"
:footer="null"
:destroyOnClose="true"
@update:open="emit('update:visible', $event)"
>
<!-- ====== Step 1: 上传图标 ====== -->
<div v-if="step === 1" class="import-modal-step">
<div class="import-step-header">第一步上传图标</div>
<div class="import-icon-upload-area">
<!-- 图标上传框 -->
<div
class="import-icon-upload-box"
:class="{ 'has-preview': uploadIconUrl }"
@click="iconFileInput?.click()"
>
<img
v-if="uploadIconUrl"
:src="uploadIconUrl"
class="import-icon-upload-img"
/>
<div v-else class="import-icon-upload-placeholder">
<span class="import-icon-upload-plus">+</span>
<span>点击上传图标</span>
</div>
</div>
<input
ref="iconFileInput"
type="file"
accept="image/*"
style="display: none"
@change="onIconFileSelected"
/>
<div v-if="uploadIconUrl" class="import-icon-upload-hint">
点击上方图片可重新选择
</div>
<div v-else class="import-icon-upload-hint">
支持 PNG / JPG / SVG 等格式自动缩放到 64x64
</div>
</div>
<div class="import-modal-actions">
<a-button type="primary" :disabled="!uploadIconUrl" @click="step = 2">
下一步
</a-button>
<a-button @click="emit('update:visible', false)">取消</a-button>
</div>
</div>
<!-- ====== Step 2: 填写信息 + 上传 xlsx ====== -->
<div v-if="step === 2" class="import-modal-step">
<div class="import-step-header">第二步填写信息并上传文件</div>
<div class="import-form-row">
<label class="import-form-label">父级名称</label>
<a-input
v-model:value="parentName"
placeholder="请输入父级名称"
style="width: 240px"
/>
</div>
<div class="import-form-row">
<label class="import-form-label">图例名称</label>
<a-input
v-model:value="legendName"
placeholder="选填,默认使用父级名称"
style="width: 240px"
/>
</div>
<a-divider />
<div class="import-upload-area">
<div class="import-upload-label">上传 xlsx 文件</div>
<a-upload
:beforeUpload="handleBeforeUpload"
:showUploadList="false"
accept=".xlsx,.xls"
>
<a-button type="dashed">
<template #icon><span>📂</span></template>
选择文件
</a-button>
</a-upload>
<span v-if="fileName" class="import-file-name">{{ fileName }}</span>
</div>
<!-- 解析结果预览 -->
<div v-if="parsedData.length > 0" class="import-preview">
<div class="import-preview-title">
已解析 <strong>{{ parsedData.length }}</strong> 条锚点数据
</div>
<div class="import-preview-table-wrap">
<table class="import-preview-table">
<thead>
<tr>
<th>#</th>
<th>锚点名称</th>
<th>经度</th>
<th>纬度</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in parsedData" :key="idx">
<td>{{ idx + 1 }}</td>
<td>{{ item.name || '(空)' }}</td>
<td>{{ item.lng }}</td>
<td>{{ item.lat }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 解析错误 -->
<div v-if="parseError" class="import-parse-error">{{ parseError }}</div>
<div class="import-modal-actions">
<a-button @click="step = 1">上一步</a-button>
<a-button
type="primary"
:disabled="parsedData.length === 0"
@click="confirmImport"
>
确定
</a-button>
<a-button @click="emit('update:visible', false)">取消</a-button>
</div>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import * as XLSX from 'xlsx';
import { anchorActions } from '../composables/useAnchorLayers';
const props = defineProps<{
visible: boolean;
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
}>();
const addUserImportAnchors = anchorActions.addUserImportAnchors;
// Step 1:
const iconFileInput = ref<HTMLInputElement | null>(null);
const uploadIconUrl = ref('');
function onIconFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
// 64x64
const img = new Image();
img.onload = () => {
const maxSize = 64;
let w = img.naturalWidth;
let h = img.naturalHeight;
if (w > maxSize || h > maxSize) {
const ratio = Math.min(maxSize / w, maxSize / h);
w = Math.round(w * ratio);
h = Math.round(h * ratio);
}
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, w, h);
ctx.drawImage(img, 0, 0, w, h);
uploadIconUrl.value = canvas.toDataURL('image/png');
} else {
uploadIconUrl.value = reader.result as string;
}
};
img.onerror = () => {
uploadIconUrl.value = reader.result as string;
};
img.src = reader.result as string;
};
reader.readAsDataURL(file);
}
//
const step = ref(1);
const parentName = ref('');
const legendName = ref('');
const fileName = ref('');
const parsedData = ref<{ name: string; lng: number; lat: number }[]>([]);
const parseError = ref('');
//
watch(
() => props.visible,
(val: boolean) => {
if (!val) {
step.value = 1;
uploadIconUrl.value = '';
parentName.value = '';
legendName.value = '';
fileName.value = '';
parsedData.value = [];
parseError.value = '';
}
}
);
// xlsx
function handleBeforeUpload(file: File): boolean {
fileName.value = file.name;
parseError.value = '';
parsedData.value = [];
const reader = new FileReader();
reader.onload = (e: ProgressEvent<FileReader>) => {
try {
const data = new Uint8Array(e.target!.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const json = XLSX.utils.sheet_to_json<(string | number)[][]>(sheet, {
header: 1
});
if (json.length === 0) {
parseError.value = '文件为空,请检查';
return;
}
const results: { name: string; lng: number; lat: number }[] = [];
for (let i = 0; i < json.length; i++) {
const row = json[i];
if (!row || row.length < 3) continue;
const name = String(row[0] ?? '').trim();
const lng = parseFloat(String(row[1]).trim());
const lat = parseFloat(String(row[2]).trim());
if (!isNaN(lng) && !isNaN(lat)) {
results.push({ name, lng, lat });
}
}
if (results.length === 0) {
parseError.value =
'未解析到有效数据,请确保第一列为锚点名称,第二列为经度,第三列为纬度';
return;
}
parsedData.value = results;
} catch (err) {
parseError.value = '文件解析失败:' + (err as Error).message;
}
};
reader.onerror = () => {
parseError.value = '文件读取失败';
};
reader.readAsArrayBuffer(file);
return false;
}
// useAnchorLayers
function confirmImport() {
if (parsedData.value.length === 0 || !addUserImportAnchors) return;
addUserImportAnchors({
parentName: parentName.value || '未命名',
legendName: legendName.value || parentName.value || '未命名',
iconUrl: uploadIconUrl.value,
items: parsedData.value
});
//
emit('update:visible', false);
}
</script>
<style scoped>
.import-modal-step {
min-height: 300px;
}
.import-step-header {
font-size: 15px;
font-weight: 600;
color: #262626;
margin-bottom: 16px;
padding-left: 10px;
border-left: 3px solid #0c72c5;
line-height: 1;
}
/* ── 图标上传 ── */
.import-icon-upload-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 0;
}
.import-icon-upload-box {
width: 160px;
height: 160px;
border: 2px dashed #d9d9d9;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
background: #fafafa;
overflow: hidden;
}
.import-icon-upload-box:hover {
border-color: #1890ff;
background: #f0f9ff;
}
.import-icon-upload-box.has-preview {
border-style: solid;
border-color: #d9d9d9;
}
.import-icon-upload-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.import-icon-upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #bbb;
user-select: none;
}
.import-icon-upload-plus {
font-size: 36px;
line-height: 1;
color: #d9d9d9;
}
.import-icon-upload-hint {
font-size: 13px;
color: #999;
}
/* ── 表单 ── */
.import-form-row {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.import-form-label {
width: 90px;
font-size: 14px;
color: #262626;
flex-shrink: 0;
text-align: right;
margin-right: 12px;
}
/* ── 上传区域 ── */
.import-upload-area {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.import-upload-label {
font-size: 14px;
color: #262626;
flex-shrink: 0;
}
.import-file-name {
font-size: 13px;
color: #1890ff;
}
/* ── 预览表格 ── */
.import-preview {
margin-top: 16px;
}
.import-preview-title {
font-size: 14px;
color: #262626;
margin-bottom: 10px;
}
.import-preview-table-wrap {
max-height: 220px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 4px;
}
.import-preview-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.import-preview-table th,
.import-preview-table td {
padding: 6px 10px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.import-preview-table th {
background: #fafafa;
font-weight: 600;
color: #262626;
position: sticky;
top: 0;
}
.import-preview-table td {
color: #595959;
}
/* ── 错误提示 ── */
.import-parse-error {
margin-top: 12px;
padding: 8px 12px;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 4px;
color: #ff4d4f;
font-size: 13px;
}
/* ── 操作按钮 ── */
.import-modal-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
</style>

View File

@ -0,0 +1,416 @@
<template>
<div class="map-studio__toolbar">
<button class="map-studio__toolbar-btn" title="放大" @click="zoomIn">
<span class="btn-icon text-[16px]"><PlusOutlined /></span>
<span class="btn-label">放大</span>
</button>
<button class="map-studio__toolbar-btn" title="缩小" @click="zoomOut">
<span class="btn-icon text-[16px]"><MinusOutlined /></span>
<span class="btn-label">缩小</span>
</button>
<div class="map-studio__toolbar-divider" />
<button
class="map-studio__toolbar-btn"
title="全屏"
@click="toggleFullScreen"
>
<span class="btn-icon text-[20px]"></span>
<span class="btn-label">全屏</span>
</button>
<div class="map-studio__toolbar-divider" />
<button class="map-studio__toolbar-btn" title="重置" @click="resetMap">
<span class="btn-icon text-[16px]"></span>
<span class="btn-label">重置</span>
</button>
<div class="map-studio__toolbar-divider" />
<button
class="map-studio__toolbar-btn"
title="撤回"
:disabled="!canUndo"
@click="undo"
>
<span class="btn-icon text-[16px]"></span>
<span class="btn-label">撤回</span>
</button>
<button
class="map-studio__toolbar-btn"
title="取消撤回"
:disabled="!canRedo"
@click="redo"
>
<span class="btn-icon text-[16px]"></span>
<span class="btn-label">取消</span>
</button>
<div class="map-studio__toolbar-divider" />
<a-dropdown
v-model:open="importDropdownOpen"
placement="rightTop"
:trigger="['click']"
:getPopupContainer="getPopupContainer"
>
<button class="map-studio__toolbar-btn" title="导入">
<span class="btn-icon text-[16px]">📁</span>
<span class="btn-label">导入</span>
</button>
<template #overlay>
<a-menu>
<a-menu-item @click="triggerImportAnchors">
<span>导入锚点</span>
</a-menu-item>
<a-menu-item @click="triggerImportImage">
<span>导入图片</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<!-- 隐藏的文件输入用于导入 PNG 图片 -->
<input
ref="importFileInput"
type="file"
accept="image/png"
style="display: none"
@change="onImageFileSelected"
/>
<!-- 导入锚点 Modal -->
<ImportAnchorModal v-model:visible="importAnchorModalVisible" />
<!-- 添加表格按钮 -->
<button
class="map-studio__toolbar-btn"
title="添加表格"
@click="triggerAddTable"
>
<span class="btn-icon text-[16px]"></span>
<span class="btn-label">表格</span>
</button>
<!-- 保存配图 Modal -->
<SaveModal v-model:visible="saveVisible" @saved="onSaved" />
<!-- 历史记录 Modal -->
<HistoryModal v-model:visible="historyVisible" />
<div class="map-studio__toolbar-divider" />
<!-- 绘制工具下拉 -->
<a-dropdown
v-model:open="drawDropdownOpen"
placement="rightTop"
:trigger="['click']"
:getPopupContainer="getPopupContainer"
>
<button
class="map-studio__toolbar-btn"
:class="{ 'is-active': drawToolActive !== null }"
title="绘制"
>
<span class="btn-icon text-[16px]"></span>
<span class="btn-label">{{
drawToolActive ? drawToolLabel : '绘制'
}}</span>
</button>
<template #overlay>
<a-menu @click="onDrawToolSelect" :selectedKeys="menuSelectedKeys">
<a-menu-item key="point">📍 标记点</a-menu-item>
<a-menu-item key="line"> 折线</a-menu-item>
<a-menu-item key="polygon"> 多边形</a-menu-item>
<a-menu-item key="rect"> 矩形</a-menu-item>
<a-menu-item key="circle"> 圆形</a-menu-item>
<a-menu-item key="freehand"> 自由手绘</a-menu-item>
<a-menu-item key="text">A 文字标注</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<!-- 若当前有绘制的工具激活显示删除按钮 -->
<button
v-if="drawToolActive !== null"
class="map-studio__toolbar-btn"
title="删除选中"
@click="deleteSelected"
>
<span class="btn-icon text-[16px]">🗑</span>
<span class="btn-label">删除</span>
</button>
<div class="map-studio__toolbar-divider" />
<button
class="map-studio__toolbar-btn"
title="保存配图"
@click="saveVisible = true"
>
<span class="btn-icon text-[16px]">💾</span>
<span class="btn-label">保存</span>
</button>
<button
class="map-studio__toolbar-btn"
title="历史记录"
@click="historyVisible = true"
>
<span class="btn-icon text-[16px]">🔄</span>
<span class="btn-label">历史</span>
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, h } from 'vue';
import { storeToRefs } from 'pinia';
import { Modal, Input } from 'ant-design-vue';
import { PlusOutlined, MinusOutlined } from '@ant-design/icons-vue';
import { useMapStudioStore } from '../stores/mapStudioStore';
import type { DrawToolType } from '../types';
import type { TableOverlayData } from '../types';
import { useUndoRedo } from '../composables/useUndoRedo';
import ImportAnchorModal from './ImportAnchorModal.vue';
import SaveModal from './SaveModal.vue';
import HistoryModal from './HistoryModal.vue';
const store = useMapStudioStore();
const {
mapInstance,
importedImages,
selectedImageId,
activeTab,
drawToolActive
} = storeToRefs(store);
const { canUndo, canRedo, undo, redo } = useUndoRedo();
const { drawCheckpoint } = useUndoRedo();
const importDropdownOpen = ref(false);
const importFileInput = ref<HTMLInputElement | null>(null);
const importAnchorModalVisible = ref(false);
const saveVisible = ref(false);
const historyVisible = ref(false);
const drawDropdownOpen = ref(false);
function getPopupContainer() {
return document.querySelector('.map-studio') || document.body;
}
const DRAW_TOOL_LABELS: Record<DrawToolType, string> = {
point: '标记点',
line: '折线',
polygon: '多边形',
rect: '矩形',
circle: '圆形',
freehand: '自由手绘',
text: '文字标注',
select: '选中/编辑'
};
const drawToolLabel = computed(() =>
drawToolActive.value ? DRAW_TOOL_LABELS[drawToolActive.value] ?? '绘制' : ''
);
/** 菜单选中项,当前激活的工具高亮显示 */
const menuSelectedKeys = computed(() =>
drawToolActive.value ? [drawToolActive.value] : []
);
let imageIdCounter = 0;
let tableIdCounter = 0;
function triggerAddTable() {
const defaultRows = 3;
const defaultCols = 4;
let tempRows = defaultRows;
let tempCols = defaultCols;
Modal.confirm({
title: '添加表格',
content: () =>
h(
'div',
{ style: 'display:flex;flex-direction:column;gap:16px;padding:8px 0;' },
[
h('div', { style: 'display:flex;align-items:center;gap:8px;' }, [
h('span', { style: 'white-space:nowrap;' }, '行数:'),
h(Input, {
style: 'width:120px',
placeholder: '行数',
value: String(tempRows),
type: 'number',
min: 1,
max: 50,
onInput: (e: any) => {
const v = parseInt(e.target?.value ?? '');
if (!isNaN(v) && v > 0) tempRows = v;
}
})
]),
h('div', { style: 'display:flex;align-items:center;gap:8px;' }, [
h('span', { style: 'white-space:nowrap;' }, '列数:'),
h(Input, {
style: 'width:120px',
placeholder: '列数',
value: String(tempCols),
type: 'number',
min: 1,
max: 50,
onInput: (e: any) => {
const v = parseInt(e.target?.value ?? '');
if (!isNaN(v) && v > 0) tempCols = v;
}
})
])
]
),
onOk: () => {
const rows = Math.max(tempRows, 1);
const cols = Math.max(tempCols, 1);
//
const cellData: string[][] = [];
for (let r = 0; r < rows; r++) {
const row: string[] = [];
for (let c = 0; c < cols; c++) {
row.push('');
}
cellData.push(row);
}
tableIdCounter++;
const table: TableOverlayData = {
id: `table_${Date.now()}_${tableIdCounter}`,
x: 30,
y: 30,
width: 400,
height: 60 + rows * 30,
rows,
cols,
cellData,
fontSize: 14,
fontFamily: 'Microsoft YaHei',
textColor: '#333333',
borderColor: '#000000',
borderWidth: 1,
fillColor: '#ffffff',
fillTransparent: false,
fontBold: false,
hAlign: 'center',
vAlign: 'middle',
rowHeights: Array(rows).fill(30),
colWidths: Array(cols).fill(80)
};
store.tableOverlays.push(table);
store.selectedTableId = table.id;
store.activeTab = 'table';
//
drawCheckpoint();
}
});
}
function triggerImportImage() {
importDropdownOpen.value = false;
importFileInput.value?.click();
}
function triggerImportAnchors() {
importDropdownOpen.value = false;
importAnchorModalVisible.value = true;
}
function onImageFileSelected(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const src = reader.result as string;
imageIdCounter++;
// 200x200
importedImages.value.push({
id: `img_${Date.now()}_${imageIdCounter}`,
src,
x: 10,
y: 10,
width: 200,
height: 200
});
// tab
selectedImageId.value =
importedImages.value[importedImages.value.length - 1].id;
activeTab.value = 'images';
};
reader.readAsDataURL(file);
// input
input.value = '';
}
//
function onDrawToolSelect(ev: { key: string }) {
drawDropdownOpen.value = false;
const key = ev.key as DrawToolType;
if (key === drawToolActive.value) {
//
drawToolActive.value = null;
} else {
drawToolActive.value = key;
}
// tab
if (key === 'select' && drawToolActive.value === 'select') {
activeTab.value = 'draw';
}
}
function deleteSelected() {
// useDrawTools
window.dispatchEvent(new CustomEvent('map-studio:delete-draw'));
}
function zoomIn() {
if (!mapInstance.value) return;
const view = mapInstance.value.getView();
const zoom = view.getZoom() ?? 1;
view.setZoom(zoom + 0.2);
}
function zoomOut() {
if (!mapInstance.value) return;
const view = mapInstance.value.getView();
const zoom = view.getZoom() ?? 1;
view.setZoom(zoom - 0.2);
}
function toggleFullScreen() {
if (!document.fullscreenElement) {
// MapStudio
const studioEl = document.querySelector('.map-studio') as HTMLElement;
if (!studioEl) return;
studioEl.requestFullscreen().catch(err => {
console.error('进入全屏失败:', err);
});
} else {
document.exitFullscreen().catch(err => {
console.error('退出全屏失败:', err);
});
}
}
function resetMap() {
//
if (store.clipMode !== 'none' && store.fitClipView) {
store.fitClipView();
return;
}
if (store.resetView) {
store.resetView();
}
}
function onSaved() {
// modal
console.log('[LeftToolbar] 配图保存成功');
}
</script>

View File

@ -0,0 +1,291 @@
<template>
<div
v-if="
BasicControlOptions[0].checked &&
(checkedAnchorData.length > 0 || customLegendGroups.length > 0)
"
class="map-studio__legend"
:style="legendStyle"
>
<div class="map-studio__legend-title-row" :style="titleStyle">
<span class="map-studio__legend-title">图例</span>
</div>
<div class="map-studio__legend-scroll">
<div
v-for="category in checkedAnchorData"
:key="category.key"
class="map-studio__legend-group"
>
<div class="map-studio__legend-group-title" :style="subtitleStyle">
{{ category.label }}
</div>
<div class="map-studio__legend-group-content">
<div
v-for="item in category.children"
:key="item.key"
class="map-studio__legend-item"
>
<img
v-if="item.iconSrc"
:src="item.iconSrc"
:style="childIconStyle"
class="map-studio__legend-icon"
/>
<span class="map-studio__legend-label" :style="childLabelStyle">{{
item.label
}}</span>
</div>
</div>
</div>
<!-- 自定义图例分组 -->
<div
v-for="group in customLegendGroups"
:key="group.key"
class="map-studio__legend-group"
>
<div class="map-studio__legend-group-title" :style="subtitleStyle">
{{ group.label }}
</div>
<div class="map-studio__legend-group-content">
<div
v-for="(item, idx) in group.children"
:key="idx"
class="map-studio__legend-item"
>
<img
v-if="item.iconUrl"
:src="item.iconUrl"
:style="childIconStyle"
class="map-studio__legend-icon"
/>
<span class="map-studio__legend-label" :style="childLabelStyle">{{
item.label
}}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useMapStudioStore } from '../stores/mapStudioStore';
import { ICON_SVG_MAP } from '../types';
const store = useMapStudioStore();
const {
anchorCategories,
BasicControlOptions,
legendConfig,
anchorGlobalStyle,
categoryIconOverrides,
customLegendGroups,
categoryChildExtensions
} = storeToRefs(store);
// Vite glob SVG
const legendIconModules = import.meta.glob('/src/assets/legend/*.svg', {
eager: true,
query: '?url',
import: 'default'
}) as Record<string, string>;
function getIconSrc(iconKey: string): string {
const filename = ICON_SVG_MAP[iconKey];
if (!filename) return '';
const modulePath = `/src/assets/legend/${filename}.svg`;
return legendIconModules[modulePath] || '';
}
/** 所有被勾选的锚点 + 自定义扩展,按大类分组 */
const checkedAnchorData = computed(() => {
return anchorCategories.value
.map(cat => {
const checked = cat.children.filter(c => c.checked);
const extensions = categoryChildExtensions.value[cat.label] || [];
if (checked.length === 0 && extensions.length === 0) return null;
const catOverrideIconUrl = categoryIconOverrides.value[cat.key];
const children: {
key: string;
label: string;
iconSrc: string;
}[] = [];
// 使 customIconUrl
for (const c of checked) {
children.push({
key: c.key,
label: c.label,
iconSrc:
c.customIconUrl ||
catOverrideIconUrl ||
(ICON_SVG_MAP[c.icon] ? getIconSrc(c.icon) : '')
});
}
//
const checkedLabels = new Set(checked.map(c => c.label));
for (let i = 0; i < extensions.length; i++) {
const ext = extensions[i];
if (checkedLabels.has(ext.label)) continue;
children.push({
key: `ext-${cat.key}-${i}`,
label: ext.label,
iconSrc: ext.iconUrl
});
}
return { key: cat.key, label: cat.label, children };
})
.filter(Boolean) as {
key: string;
label: string;
children: { key: string; label: string; iconSrc: string }[];
}[];
});
/** 图例容器样式(位置、宽高、偏移) */
const legendStyle = computed(() => {
const cfg = legendConfig.value;
const pos: Record<string, string> = {};
if (cfg.position.includes('top')) pos.top = `${cfg.offsetY}px`;
else pos.bottom = `${cfg.offsetY}px`;
if (cfg.position.includes('left')) pos.left = `${cfg.offsetX}px`;
else pos.right = `${cfg.offsetX}px`;
return {
width: `${cfg.width}px`,
maxHeight: `${cfg.height}px`,
...pos
};
});
/** 图标尺寸样式 */
const iconSizeStyle = computed(() => ({
width: `${anchorGlobalStyle.value.iconWidth}px`,
height: `${anchorGlobalStyle.value.iconHeight}px`
}));
/** 文字样式 */
const labelStyle = computed(() => ({
fontSize: `${anchorGlobalStyle.value.fontSize}px`,
color: anchorGlobalStyle.value.color,
fontWeight: anchorGlobalStyle.value.bold
? ('bold' as const)
: ('normal' as const)
}));
/** 图例标题样式 */
const titleStyle = computed(() => ({
fontSize: `${legendConfig.value.titleFontSize}px`,
fontFamily: legendConfig.value.titleFontFamily,
fontWeight: legendConfig.value.titleBold
? ('bold' as const)
: ('normal' as const),
textAlign: legendConfig.value.titleAlign as 'left' | 'center' | 'right',
color: legendConfig.value.titleColor
}));
/** 一级大类标题样式(比图例标题小 2px */
const subtitleStyle = computed(() => ({
fontSize: `${Math.max(legendConfig.value.titleFontSize - 2, 10)}px`,
fontWeight: legendConfig.value.titleBold
? ('bold' as const)
: ('normal' as const),
fontFamily: legendConfig.value.titleFontFamily,
color: legendConfig.value.titleColor
}));
/** 图例子级文字样式 */
const childLabelStyle = computed(() => ({
fontSize: `${legendConfig.value.childFontSize}px`,
fontFamily: legendConfig.value.childFontFamily,
color: legendConfig.value.childColor,
fontWeight: legendConfig.value.childBold
? ('bold' as const)
: ('normal' as const)
}));
/** 图例子级图标尺寸 */
const childIconStyle = computed(() => ({
width: `${legendConfig.value.childIconSize}px`,
height: `${legendConfig.value.childIconSize}px`
}));
</script>
<style lang="scss" scoped>
.map-studio__legend {
position: absolute;
background: #fff;
padding: 8px 10px;
z-index: 10;
overflow: hidden;
}
.map-studio__legend-title-row {
margin-bottom: 6px;
padding-bottom: 4px;
border-bottom: 1px solid #eee;
}
.map-studio__legend-scroll {
overflow-y: auto;
max-height: calc(100% - 24px);
display: flex;
gap: 6px 16px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: #d9d9d9;
border-radius: 2px;
}
}
.map-studio__legend-group {
display: inline-block;
vertical-align: top;
margin-left: 4px;
}
.map-studio__legend-group-title {
font-size: 12px;
font-weight: 600;
color: #262626;
margin-bottom: 8px;
}
.map-studio__legend-group-content {
display: flex;
flex-wrap: wrap;
gap: 2px 6px;
margin-left: 4px;
}
.map-studio__legend-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
white-space: nowrap;
margin-bottom: 2px;
span {
margin-top: -2px;
}
}
.map-studio__legend-icon {
flex-shrink: 0;
object-fit: contain;
}
.map-studio__legend-label {
line-height: 1.2;
}
</style>

View File

@ -0,0 +1,13 @@
<template>
<div ref="containerRef" class="map-studio__map-area">
<slot />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const containerRef = ref<HTMLElement | null>(null);
defineExpose({ containerRef });
</script>

View File

@ -0,0 +1,224 @@
<template>
<div
ref="containerRef"
class="map-studio__image-overlay-container"
@keydown.delete="onDeleteKey"
tabindex="0"
>
<template v-for="img in importedImages" :key="img.id">
<vue3-draggable-resizable
style="pointer-events: all"
:init-w="img.width"
:init-h="img.height"
:x="img.x"
:y="img.y"
:w="img.width"
:h="img.height"
:parent="true"
:active="selectedImageId === img.id"
:min-w="20"
:min-h="20"
:handles="['tl', 'tm', 'tr', 'ml', 'mr', 'bl', 'bm', 'br']"
@update:x="onUpdateX(img, $event)"
@update:y="onUpdateY(img, $event)"
@update:w="onUpdateW(img, $event)"
@update:h="onUpdateH(img, $event)"
@activated="onActivated(img)"
@drag-start="onDragStart(img)"
@drag-end="onDragEnd"
@resize-start="onResizeStart(img)"
@resize-end="onResizeEnd"
>
<img
:src="img.src"
class="map-studio__overlay-image"
draggable="false"
/>
<!-- 删除按钮左上角不遮挡右上角缩放手柄 -->
<div
v-if="selectedImageId === img.id"
class="map-studio__overlay-delete"
@mousedown.stop
@click.stop="onDeleteImage(img)"
>
×
</div>
</vue3-draggable-resizable>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { storeToRefs } from 'pinia';
import Vue3DraggableResizable from 'vue3-draggable-resizable';
import 'vue3-draggable-resizable/dist/Vue3DraggableResizable.css';
import { useMapStudioStore } from '../stores/mapStudioStore';
import type { ImportedImage } from '../types';
const store = useMapStudioStore();
const { importedImages, selectedImageId, activeTab } = storeToRefs(store);
const containerRef = ref<HTMLElement | null>(null);
const isInteracting = ref(false);
// activated
function onActivated(img: ImportedImage) {
selectedImageId.value = img.id;
}
//
function handleDocumentClick(e: MouseEvent) {
if (!selectedImageId.value || isInteracting.value) return;
const target = e.target as Node;
// .vdr
const vdrs = document.querySelectorAll('.vdr');
for (const vdr of vdrs) {
if (vdr.contains(target)) return;
}
//
const delBtns = document.querySelectorAll('.map-studio__overlay-delete');
for (const btn of delBtns) {
if (btn.contains(target)) return;
}
// X/Y
const drawers = document.querySelectorAll('.map-studio__drawer-section');
for (const drawer of drawers) {
if (drawer.contains(target)) return;
}
// ant-modal
const modals = document.querySelectorAll('.ant-modal');
for (const modal of modals) {
if (modal.contains(target)) return;
}
selectedImageId.value = null;
}
// /
function onDragStart(img: ImportedImage) {
selectedImageId.value = img.id;
isInteracting.value = true;
activeTab.value = 'images';
}
function onDragEnd() {
isInteracting.value = false;
}
function onResizeStart(img: ImportedImage) {
selectedImageId.value = img.id;
isInteracting.value = true;
activeTab.value = 'images';
}
function onResizeEnd() {
isInteracting.value = false;
}
// /
function onUpdateX(img: ImportedImage, val: number) {
img.x = Math.floor(val);
}
function onUpdateY(img: ImportedImage, val: number) {
img.y = Math.floor(val);
}
function onUpdateW(img: ImportedImage, val: number) {
img.width = Math.floor(val);
}
function onUpdateH(img: ImportedImage, val: number) {
img.height = Math.floor(val);
}
//
function onDeleteImage(img: ImportedImage) {
const idx = importedImages.value.indexOf(img);
if (idx !== -1) {
importedImages.value.splice(idx, 1);
}
if (selectedImageId.value === img.id) {
selectedImageId.value = null;
}
// tab
if (importedImages.value.length === 0 && activeTab.value === 'images') {
activeTab.value = 'baseMap';
}
}
function onDeleteKey(e: KeyboardEvent) {
// container Delete
if (e.key === 'Delete' && selectedImageId.value) {
const img = importedImages.value.find(i => i.id === selectedImageId.value);
if (img) {
onDeleteImage(img);
}
}
}
//
function handleKeyDown(e: KeyboardEvent) {
if (
e.key === 'Delete' &&
selectedImageId.value &&
containerRef.value?.contains(e.target as Node)
) {
const img = importedImages.value.find(i => i.id === selectedImageId.value);
if (img) {
onDeleteImage(img);
}
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleDocumentClick, true);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleDocumentClick, true);
});
</script>
<style scoped>
.map-studio__image-overlay-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
pointer-events: none;
outline: none;
}
.map-studio__image-overlay-container > :deep(.vdr) {
pointer-events: auto;
}
.map-studio__overlay-image {
width: 100%;
height: 100%;
object-fit: contain;
user-select: none;
pointer-events: none;
}
.map-studio__overlay-delete {
position: absolute;
top: 0px;
right: -30px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #ff4d4f;
color: #fff;
font-size: 14px;
line-height: 20px;
text-align: center;
cursor: pointer;
z-index: 10;
pointer-events: auto;
user-select: none;
box-shadow: 0 1px 4px rgba(255, 77, 79, 0.5);
}
.map-studio__overlay-delete:hover {
transform: scale(1.15);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,363 @@
<template>
<a-modal
:open="visible"
title="保存配图"
width="520px"
:confirm-loading="saving"
:ok-button-props="{ disabled: !peituName.trim() }"
:get-container="getModalContainer"
@ok="handleSave"
@cancel="handleCancel"
>
<div class="save-modal__body">
<!-- 配图名称 -->
<div class="save-modal__field">
<label class="save-modal__label">
<span class="save-modal__required">*</span>配图名称
</label>
<a-input
v-model:value="peituName"
placeholder="请输入配图名称"
:maxlength="50"
:status="nameError ? 'error' : undefined"
/>
<span v-if="nameError" class="save-modal__error">请输入配图名称</span>
</div>
<!-- 预览图 -->
<div class="save-modal__field">
<label class="save-modal__label">预览图</label>
<div class="save-modal__preview-wrapper">
<a-image
v-if="previewBase64"
:src="previewBase64"
class="save-modal__preview"
alt="预览图"
:preview="{ getContainer: getModalContainer }"
/>
<div v-else class="save-modal__preview-empty">
<span v-if="capturing">正在截图...</span>
<span v-else>截取预览图中...</span>
</div>
</div>
<div class="save-modal__preview-actions">
<a-button
size="small"
:disabled="!previewBase64"
@click="downloadPreview"
>
下载预览图
</a-button>
</div>
</div>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
import { message, Modal } from 'ant-design-vue';
import { storeToRefs } from 'pinia';
import { useMapStudioStore } from '../stores/mapStudioStore';
import { useUserStore } from '@/store/modules/user';
import { saveImage } from '../api';
import { drawActions } from '../composables/useDrawTools';
import { anchorActions } from '../composables/useAnchorLayers';
const props = defineProps<{ visible: boolean }>();
const emit = defineEmits<{
'update:visible': [val: boolean];
saved: [];
}>();
const store = useMapStudioStore();
const {
selectedBaseMap,
showBoundary,
clipMode,
clipBasin,
clipDistrict,
layerOptions,
BasicControlOptions,
legendConfig,
anchorGlobalStyle,
anchorCategories,
customLegendEntries,
customLegendGroups,
categoryChildExtensions,
categoryIconOverrides,
importedImages,
drawStyle,
tableOverlays
} = storeToRefs(store);
/** 获取 modal 挂载容器,全屏时挂载到 .map-studio 内 */
function getModalContainer() {
return document.querySelector('.map-studio') || document.body;
}
const peituName = ref('');
const previewBase64 = ref('');
const saving = ref(false);
const capturing = ref(false);
const nameError = ref(false);
// modal
watch(
() => props.visible,
async val => {
if (val) {
// store
peituName.value = store.editPeituName || '';
previewBase64.value = '';
nameError.value = false;
await nextTick();
takeScreenshot();
}
}
);
/** 截图:使用 html2canvas 捕获地图区域 */
async function takeScreenshot() {
capturing.value = true;
previewBase64.value = '';
await nextTick();
try {
const html2canvas = (await import('html2canvas')).default;
const el = document.querySelector('.map-studio__map-area') as HTMLElement;
if (!el) {
console.warn('[SaveModal] 未找到 .map-studio__map-area 元素');
return;
}
const canvas = await html2canvas(el, {
useCORS: true,
allowTaint: false,
backgroundColor: '#ffffff',
scale: 1
});
previewBase64.value = canvas.toDataURL('image/png');
} catch (err) {
console.error('[SaveModal] 截图失败:', err);
} finally {
capturing.value = false;
}
}
/** 下载预览图 */
function downloadPreview() {
if (!previewBase64.value) return;
const link = document.createElement('a');
link.download = `${peituName.value || '预览图'}.png`;
link.href = previewBase64.value;
link.click();
}
/** 收集全部配置状态 */
function collectConfig(): any {
// checked
const flattenLayers = (items: any[]): string[] => {
const keys: string[] = [];
for (const item of items) {
if (item.children) {
for (const child of item.children) {
if (child.checked) keys.push(child.key);
}
} else if (item.checked) {
keys.push(item.key);
}
}
return keys;
};
// +
const anchorChecked: string[] = [];
const anchorIcons: Record<string, string> = {};
for (const cat of anchorCategories.value) {
for (const child of cat.children) {
if (child.checked) anchorChecked.push(child.key);
if (child.customIconUrl) anchorIcons[child.key] = child.customIconUrl;
}
}
//
const drawFeatures = drawActions.getSerializedFeatures
? drawActions.getSerializedFeatures()
: [];
//
const anchorOverrides = anchorActions.getSerializedOverrides
? anchorActions.getSerializedOverrides()
: [];
// anchorDeletedIds + importedAnchors
const serializedAnchors = anchorActions.getSerializedAnchors
? anchorActions.getSerializedAnchors()
: [];
return {
version: '1.0',
baseMap: {
source: selectedBaseMap.value,
showBoundary: showBoundary.value,
clip: {
mode: clipMode.value,
basin: clipBasin.value,
district: clipDistrict.value
}
},
overlayLayers: {
checkedKeys: flattenLayers(layerOptions.value)
},
legend: {
visible: BasicControlOptions.value[0]?.checked ?? true,
config: { ...legendConfig.value },
customEntries: customLegendEntries.value,
customGroups: customLegendGroups.value,
categoryChildExtensions: categoryChildExtensions.value,
categoryIconOverrides: categoryIconOverrides.value
},
anchorCategories: {
checkedKeys: anchorChecked,
customIcons: anchorIcons
},
anchorGlobalStyle: { ...anchorGlobalStyle.value },
anchorOverrides,
anchors: serializedAnchors,
drawFeatures,
drawStyle: { ...drawStyle.value },
importedImages: importedImages.value.map(img => ({
id: img.id,
src: img.src,
x: img.x,
y: img.y,
width: img.width,
height: img.height
})),
tableOverlays: tableOverlays.value.map(tbl => ({ ...tbl }))
};
}
/** 保存到后端 */
async function handleSave() {
if (!peituName.value.trim()) {
nameError.value = true;
return;
}
nameError.value = false;
// id
if (store.editConfigId) {
await new Promise<void>((resolve, reject) => {
Modal.confirm({
title: '确认覆盖',
content: '该配图已存在,确定覆盖原配图?',
okText: '确定覆盖',
cancelText: '取消',
onOk: () => resolve(),
onCancel: () => reject(new Error('cancel'))
});
}).catch(() => {
return; //
});
}
saving.value = true;
try {
//
if (!previewBase64.value) {
await takeScreenshot();
}
const config = collectConfig();
const userStore = useUserStore();
// JSON
const payload = {
id: store.editConfigId || '',
peituName: peituName.value,
operator: userStore.username || '',
config: JSON.stringify(config),
bsfile: previewBase64.value || ''
};
let res = await saveImage(payload);
if (res.code == 0) {
message.success('保存成功');
//
store.editConfigId = '';
store.editPeituName = '';
emit('saved');
handleCancel();
} else {
message.error(res.msg || '保存失败');
}
} catch (err) {
console.error('[SaveModal] 保存失败:', err);
} finally {
saving.value = false;
}
}
function handleCancel() {
emit('update:visible', false);
}
</script>
<style scoped>
.save-modal__body {
display: flex;
flex-direction: column;
gap: 16px;
}
.save-modal__field {
display: flex;
flex-direction: column;
gap: 6px;
}
.save-modal__label {
font-size: 13px;
color: #333;
font-weight: 500;
}
.save-modal__required {
color: #ff4d4f;
margin-right: 2px;
}
.save-modal__error {
color: #ff4d4f;
font-size: 12px;
line-height: 1;
}
.save-modal__preview-wrapper {
width: 100%;
height: 200px;
border: 1px solid #d9d9d9;
border-radius: 4px;
overflow: hidden;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
}
.save-modal__preview {
width: 100%;
height: 100%;
object-fit: contain;
}
.save-modal__preview-empty {
color: #999;
font-size: 13px;
}
.save-modal__preview-actions {
display: flex;
gap: 8px;
}
</style>

View File

@ -0,0 +1,299 @@
<template>
<div
ref="containerRef"
class="map-studio__table-overlay-container"
@keydown.delete="onDeleteKey"
tabindex="0"
>
<template v-for="tbl in tableOverlays" :key="tbl.id">
<vue3-draggable-resizable
style="pointer-events: all"
:init-w="tbl.width"
:init-h="tbl.height"
:x="tbl.x"
:y="tbl.y"
:w="tbl.width"
:h="tbl.height"
:parent="true"
:active="selectedTableId === tbl.id"
:min-w="100"
:min-h="50"
:handles="['tl', 'tm', 'tr', 'ml', 'mr', 'bl', 'bm', 'br']"
@update:x="onUpdateX(tbl, $event)"
@update:y="onUpdateY(tbl, $event)"
@update:w="onUpdateW(tbl, $event)"
@update:h="onUpdateH(tbl, $event)"
@activated="onActivated(tbl)"
@drag-start="onDragStart(tbl)"
@drag-end="onDragEnd"
@resize-start="onResizeStart(tbl)"
@resize-end="onResizeEnd"
>
<div class="map-studio__table-overlay" :style="tableStyle(tbl)">
<table
class="map-studio__table-element"
:style="tableInnerStyle(tbl)"
>
<colgroup v-if="tbl.colWidths && tbl.colWidths.length > 0">
<col
v-for="(cw, ci) in tbl.colWidths"
:key="ci"
:style="{ width: cw + 'px' }"
/>
</colgroup>
<tr
v-for="(row, ri) in tbl.cellData"
:key="ri"
:style="trStyle(tbl, ri)"
>
<td
v-for="(cell, ci) in row"
:key="`${ri}-${ci}`"
class="map-studio__table-cell"
:style="cellStyle(tbl, ri, ci)"
>
{{ cell }}
</td>
</tr>
</table>
</div>
<!-- 删除按钮 -->
<div
v-if="selectedTableId === tbl.id"
class="map-studio__table-delete"
@mousedown.stop
@click.stop="onDeleteTable(tbl)"
>
×
</div>
</vue3-draggable-resizable>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { storeToRefs } from 'pinia';
import Vue3DraggableResizable from 'vue3-draggable-resizable';
import 'vue3-draggable-resizable/dist/Vue3DraggableResizable.css';
import { useMapStudioStore } from '../stores/mapStudioStore';
import { useUndoRedo } from '../composables/useUndoRedo';
import type { TableOverlayData } from '../types';
const store = useMapStudioStore();
const { tableOverlays, selectedTableId, activeTab } = storeToRefs(store);
const { drawCheckpoint } = useUndoRedo();
const containerRef = ref<HTMLElement | null>(null);
const isInteracting = ref(false);
function onActivated(tbl: TableOverlayData) {
selectedTableId.value = tbl.id;
}
//
function handleDocumentClick(e: MouseEvent) {
if (!selectedTableId.value || isInteracting.value) return;
const target = e.target as Node;
const vdrs = document.querySelectorAll('.vdr');
for (const vdr of vdrs) {
if (vdr.contains(target)) return;
}
const delBtns = document.querySelectorAll('.map-studio__table-delete');
for (const btn of delBtns) {
if (btn.contains(target)) return;
}
const drawers = document.querySelectorAll('.map-studio__drawer-section');
for (const drawer of drawers) {
if (drawer.contains(target)) return;
}
// ant-design-vue a-select body
const dropdowns = document.querySelectorAll('.ant-select-dropdown');
for (const dd of dropdowns) {
if (dd.contains(target)) return;
}
const modals = document.querySelectorAll('.ant-modal');
for (const modal of modals) {
if (modal.contains(target)) return;
}
selectedTableId.value = null;
}
function onDragStart(tbl: TableOverlayData) {
selectedTableId.value = tbl.id;
isInteracting.value = true;
activeTab.value = 'table';
}
function onDragEnd() {
isInteracting.value = false;
drawCheckpoint();
}
function onResizeStart(tbl: TableOverlayData) {
selectedTableId.value = tbl.id;
isInteracting.value = true;
activeTab.value = 'table';
}
function onResizeEnd() {
isInteracting.value = false;
drawCheckpoint();
}
function onUpdateX(tbl: TableOverlayData, val: number) {
tbl.x = Math.floor(val);
}
function onUpdateY(tbl: TableOverlayData, val: number) {
tbl.y = Math.floor(val);
}
function onUpdateW(tbl: TableOverlayData, val: number) {
tbl.width = Math.floor(val);
}
function onUpdateH(tbl: TableOverlayData, val: number) {
tbl.height = Math.floor(val);
}
function onDeleteTable(tbl: TableOverlayData) {
//
drawCheckpoint();
const idx = tableOverlays.value.indexOf(tbl);
if (idx !== -1) tableOverlays.value.splice(idx, 1);
if (selectedTableId.value === tbl.id) selectedTableId.value = null;
if (tableOverlays.value.length === 0 && activeTab.value === 'table') {
activeTab.value = 'baseMap';
}
}
function onDeleteKey(e: KeyboardEvent) {
if (e.key === 'Delete' && selectedTableId.value) {
const tbl = tableOverlays.value.find(t => t.id === selectedTableId.value);
if (tbl) onDeleteTable(tbl);
}
}
function handleKeyDown(e: KeyboardEvent) {
if (
e.key === 'Delete' &&
selectedTableId.value &&
containerRef.value?.contains(e.target as Node)
) {
const tbl = tableOverlays.value.find(t => t.id === selectedTableId.value);
if (tbl) onDeleteTable(tbl);
}
}
//
function tableStyle(tbl: TableOverlayData) {
return {
width: '100%',
height: '100%',
overflow: 'auto',
background: tbl.fillTransparent ? 'transparent' : tbl.fillColor,
border: `${tbl.borderWidth}px solid ${tbl.borderColor}`,
boxSizing: 'border-box' as const
};
}
function tableInnerStyle(tbl: TableOverlayData) {
const hasCustomRows = tbl.rowHeights && tbl.rowHeights.some(h => h > 0);
return {
width: '100%',
height: hasCustomRows ? 'auto' : '100%',
borderCollapse: 'collapse' as const,
tableLayout: (tbl.colWidths && tbl.colWidths.length > 0 ? 'auto' : 'fixed') as any
};
}
function trStyle(tbl: TableOverlayData, ri: number) {
if (tbl.rowHeights && tbl.rowHeights[ri] != null && tbl.rowHeights[ri] > 0) {
return { height: tbl.rowHeights[ri] + 'px' };
}
return {};
}
function cellStyle(tbl: TableOverlayData, ri: number, ci: number) {
const vAlignMap: Record<string, string> = {
top: 'top',
middle: 'middle',
bottom: 'bottom'
};
const style: Record<string, any> = {
border: `${tbl.borderWidth}px solid ${tbl.borderColor}`,
padding: '4px 6px',
fontSize: `${tbl.fontSize}px`,
fontFamily: tbl.fontFamily,
color: tbl.textColor,
fontWeight: tbl.fontBold ? ('bold' as const) : ('normal' as const),
textAlign: tbl.hAlign as any,
verticalAlign: (vAlignMap[tbl.vAlign] || 'middle') as any,
wordBreak: 'break-all' as const,
boxSizing: 'border-box' as const,
height: '100%'
};
//
if (tbl.colWidths && tbl.colWidths[ci] != null && tbl.colWidths[ci] > 0) {
style.width = tbl.colWidths[ci] + 'px';
}
return style;
}
onMounted(() => {
document.addEventListener('keydown', handleKeyDown);
document.addEventListener('mousedown', handleDocumentClick, true);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('mousedown', handleDocumentClick, true);
});
</script>
<style scoped>
.map-studio__table-overlay-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
pointer-events: none;
outline: none;
}
.map-studio__table-overlay-container > :deep(.vdr) {
pointer-events: auto;
}
.map-studio__table-overlay {
user-select: none;
pointer-events: none;
}
.map-studio__table-element {
pointer-events: none;
}
.map-studio__table-cell {
white-space: pre-wrap;
overflow: hidden;
}
.map-studio__table-delete {
position: absolute;
top: 0px;
right: -30px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #ff4d4f;
color: #fff;
font-size: 14px;
line-height: 20px;
text-align: center;
cursor: pointer;
z-index: 10;
pointer-events: auto;
user-select: none;
box-shadow: 0 1px 4px rgba(255, 77, 79, 0.5);
}
.map-studio__table-delete:hover {
transform: scale(1.15);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,250 @@
import { ref, watch, onMounted, onBeforeUnmount, type Ref } from 'vue';
import { useMapStudioStore } from '../stores/mapStudioStore';
import Map from 'ol/Map';
import View from 'ol/View';
import TileLayer from 'ol/layer/Tile';
import XYZ from 'ol/source/XYZ';
import { fromLonLat } from 'ol/proj';
/** 天地图 token */
const TDT_TOKEN = 'e90d56e5a09d1767899ad45846b0cefd';
/** 天地图墨卡托投影标识 */
const TDT_MAP_TYPE = 'w';
/** 中国中心点(经纬度) */
const CENTER_CHINA: [number, number] = [105, 38.5];
const INITIAL_ZOOM = 4.5;
const MIN_ZOOM = 4;
const MAX_ZOOM = 19;
/** 边界图层 WMTS URL */
const BOUNDARY_URL =
'https://211.99.26.225:18085/geoserver/gwc/service/wmts?REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0&LAYER=qgc_sx_gjjdx_arcgistiles_l13&STYLE=&TILEMATRIX=EPSG:3857_qgc_sx_gjjdx_arcgistiles_l13:{z}&TILEMATRIXSET=EPSG:3857_qgc_sx_gjjdx_arcgistiles_l13&FORMAT=image/png&TILECOL={x}&TILEROW={y}';
/** 底图源配置 */
export const BASE_MAP_SOURCES: Record<
string,
{ label: string; createSource: () => XYZ }
> = {
satellite: {
label: '影像图',
createSource: () =>
new XYZ({
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
maxZoom: 19,
crossOrigin: 'anonymous',
wrapX: true
})
},
vector: {
label: '矢量图',
createSource: () =>
new XYZ({
url: 'https://211.99.26.225:18085/geoserver/gwc/service/wmts?REQUEST=GetTile&SERVICE=WMTS&VERSION=1.0.0&LAYER=qgc_qsj_arcgistiles_l13&STYLE=&TILEMATRIX=EPSG:3857_qgc_qsj_arcgistiles_l13:{z}&TILEMATRIXSET=EPSG:3857_qgc_qsj_arcgistiles_l13&FORMAT=image/png&TILECOL={x}&TILEROW={y}',
maxZoom: 18,
crossOrigin: 'anonymous',
wrapX: true
})
},
terrain: {
label: '地形图',
createSource: () =>
new XYZ({
url: `https://t0.tianditu.gov.cn/ter_${TDT_MAP_TYPE}/wmts?tk=${TDT_TOKEN}&SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ter&STYLE=default&TILEMATRIXSET=${TDT_MAP_TYPE}&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=tiles`,
maxZoom: 18,
crossOrigin: 'anonymous',
wrapX: true
})
},
contour: {
label: '等高线',
createSource: () =>
new XYZ({
url: 'https://c.tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=6170aad10dfd42a38d4d8c709a536f38',
maxZoom: 18,
crossOrigin: 'anonymous',
wrapX: true
})
}
};
export function useMapInit(
mapContainerRef: Ref<HTMLElement | null>,
selectedBaseMap: Ref<string>,
showBoundary: Ref<boolean>
) {
const mapInstance = ref<Map | null>(null);
/** 当前底图图层 */
const baseLayer = ref<TileLayer<XYZ> | null>(null);
/** 边界图层 */
const boundaryLayer = ref<TileLayer<XYZ> | null>(null);
function createBaseLayer(key: string): TileLayer<XYZ> {
const config = BASE_MAP_SOURCES[key];
if (!config) {
console.warn(`未知底图类型: ${key},使用默认矢量图`);
return createBaseLayer('vector');
}
return new TileLayer({
source: config.createSource(),
properties: { baseMapKey: key }
});
}
function createBoundaryLayer(): TileLayer<XYZ> {
return new TileLayer({
source: new XYZ({
url: BOUNDARY_URL,
maxZoom: 18,
crossOrigin: 'anonymous',
wrapX: true
}),
properties: { isBoundary: true }
});
}
/** 勾选/取消边界图层(矢量图时禁止加载) */
function toggleBoundaryLayer(show: boolean) {
if (!mapInstance.value) return;
// 矢量图时永远不加载边界
if (selectedBaseMap.value === 'vector') {
removeBoundaryLayer();
return;
}
if (show) {
// 勾选 → 创建并添加
if (!boundaryLayer.value) {
boundaryLayer.value = createBoundaryLayer();
}
if (
!mapInstance.value.getLayers().getArray().includes(boundaryLayer.value)
) {
// 插入到底图上方index 1确保在锚点图层下方
mapInstance.value.getLayers().insertAt(1, boundaryLayer.value);
}
} else {
removeBoundaryLayer();
}
}
/** 移除边界图层 */
function removeBoundaryLayer() {
if (
boundaryLayer.value &&
mapInstance.value &&
mapInstance.value.getLayers().getArray().includes(boundaryLayer.value)
) {
mapInstance.value.removeLayer(boundaryLayer.value);
boundaryLayer.value = null;
}
}
function initMap() {
if (!mapContainerRef.value) return;
// 只创建底图
const layer = createBaseLayer(selectedBaseMap.value);
baseLayer.value = layer;
const map = new Map({
target: mapContainerRef.value,
layers: [layer],
view: new View({
center: fromLonLat(CENTER_CHINA),
zoom: INITIAL_ZOOM,
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
constrainOnlyCenter: false,
smoothExtentConstraint: false
}),
controls: []
});
mapInstance.value = map;
// 如果默认勾选且不是矢量图,则添加边界图层
if (showBoundary.value && selectedBaseMap.value !== 'vector') {
toggleBoundaryLayer(true);
}
}
function switchBaseMap(key: string) {
if (!mapInstance.value) return;
const newLayer = createBaseLayer(key);
if (baseLayer.value) {
mapInstance.value.removeLayer(baseLayer.value);
}
// 底图插入到最底层,避免覆盖边界等上层图层
mapInstance.value.getLayers().insertAt(0, newLayer);
baseLayer.value = newLayer;
// 如果边界图层存在重新插入到底图上方index 1避免覆盖锚点
if (
boundaryLayer.value &&
mapInstance.value.getLayers().getArray().includes(boundaryLayer.value)
) {
mapInstance.value.removeLayer(boundaryLayer.value);
mapInstance.value.getLayers().insertAt(1, boundaryLayer.value);
}
// 切换到矢量图时强制移除边界;切出矢量图时如果勾选了则重新加载
if (key === 'vector') {
removeBoundaryLayer();
} else if (showBoundary.value && !boundaryLayer.value) {
toggleBoundaryLayer(true);
}
}
/** 监听底图切换 */
watch(
() => selectedBaseMap.value,
newKey => {
switchBaseMap(newKey);
}
);
/** 监听边界显隐 */
watch(
() => showBoundary.value,
show => {
toggleBoundaryLayer(show);
}
);
onMounted(() => {
initMap();
});
onBeforeUnmount(() => {
if (mapInstance.value) {
mapInstance.value.setTarget(undefined);
mapInstance.value.dispose();
}
});
/** 重置地图视图到初始状态 */
function resetView() {
if (!mapInstance.value) return;
const view = mapInstance.value.getView();
view.setCenter(fromLonLat(CENTER_CHINA));
view.setZoom(INITIAL_ZOOM);
}
// 将地图实例同步到 Pinia store
const store = useMapStudioStore();
watch(
mapInstance,
val => {
store.mapInstance = val;
},
{ immediate: true }
);
return {
mapInstance,
switchBaseMap,
boundaryLayer,
resetView
};
}

View File

@ -0,0 +1,238 @@
import { watch, onBeforeUnmount, type Ref } from 'vue';
import OlMap from 'ol/Map';
import TileLayer from 'ol/layer/Tile';
import ImageLayer from 'ol/layer/Image';
import XYZ from 'ol/source/XYZ';
import ImageArcGISRest from 'ol/source/ImageArcGISRest';
import ImageWMS from 'ol/source/ImageWMS';
/** 单个图层项定义 */
export interface LayerItem {
key: string;
label: string;
checked: boolean;
children?: LayerItem[];
}
/** 扁平 key → OL 图层映射 */
const LAYER_MAP: Record<
string,
() => TileLayer<XYZ> | ImageLayer<ImageArcGISRest | ImageWMS>
> = {
// 行政标注 - 天地图矢量标注
adminLabel: () =>
new TileLayer({
source: new XYZ({
url: 'http://t4.tianditu.gov.cn/DataServer?T=cia_w&x={x}&y={y}&l={z}&tk=9884c7460528e32e3bbf3d1a233d7fea',
maxZoom: 18,
crossOrigin: 'anonymous',
wrapX: true
})
}),
// 区域服务 - ArcGIS
regionService: () =>
new ImageLayer({
source: new ImageArcGISRest({
url: 'https://211.99.26.225:18085/arcgis/rest/services/ZH_QUYU/MapServer',
crossOrigin: 'anonymous'
})
}),
// 九段线 - ArcGIS
nineDashLine: () =>
new ImageLayer({
source: new ImageArcGISRest({
url: 'https://211.99.26.225:18085/arcgis/rest/services/%E4%B9%9D%E6%AE%B5%E7%BA%BF%E6%9C%8D%E5%8A%A11/MapServer',
crossOrigin: 'anonymous'
})
}),
// 岛屿 - GeoServer WMS需确认 layer 名称)
island: () =>
new ImageLayer({
source: new ImageWMS({
url: 'https://211.99.26.225:18085/geoserver/cite/wms',
params: { LAYERS: 'cite:dy' },
crossOrigin: 'anonymous'
})
}),
// 流域注记 - ArcGIS
basinLabel: () =>
new ImageLayer({
source: new ImageArcGISRest({
url: 'https://211.99.26.225:18085/arcgis/rest/services/zh_LiuYu_annotation/MapServer',
crossOrigin: 'anonymous'
})
}),
// 流域线 - ArcGIS
basinLine: () =>
new ImageLayer({
source: new ImageArcGISRest({
url: 'https://211.99.26.225:18085/arcgis/rest/services/zh_LiuYu_line/MapServer',
crossOrigin: 'anonymous'
})
}),
// 轮渡 - ArcGIS
ferry: () =>
new ImageLayer({
source: new ImageArcGISRest({
url: 'https://211.99.26.225:18085/arcgis/rest/services/jiaotong_ferry/MapServer',
crossOrigin: 'anonymous'
})
}),
// 铁路 - ArcGIS
railway: () =>
new ImageLayer({
source: new ImageArcGISRest({
url: 'https://211.99.26.225:18085/arcgis/rest/services/jiaotong_railway/MapServer',
crossOrigin: 'anonymous'
})
}),
// 地铁 - ArcGIS
metro: () =>
new ImageLayer({
source: new ImageArcGISRest({
url: 'https://211.99.26.225:18085/arcgis/rest/services/jiaotong_metro/MapServer',
crossOrigin: 'anonymous'
})
}),
// 行政区面状 - ArcGIS
adminAreaPolygon: () =>
new ImageLayer({
source: new ImageArcGISRest({
url: 'https://211.99.26.225:18085/arcgis/rest/services/zh_XingZhengQu_Polygon/MapServer',
crossOrigin: 'anonymous'
})
}),
// 行政区 - ArcGIS
adminArea: () =>
new ImageLayer({
source: new ImageArcGISRest({
url: 'https://211.99.26.225:18085/arcgis/rest/services/ZH_XINGZHEGNQU/MapServer',
crossOrigin: 'anonymous'
})
})
};
export function useOverlayLayers(
mapInstance: Ref<OlMap | null>,
layerOptions: Ref<LayerItem[]>
) {
// 已创建的 OL 图层映射
const activeLayers = new Map<
string,
TileLayer<XYZ> | ImageLayer<ImageArcGISRest | ImageWMS>
>();
/** 收集所有扁平 key */
function getLeafKeys(items: LayerItem[]): string[] {
const keys: string[] = [];
for (const item of items) {
if (item.children) {
keys.push(...getLeafKeys(item.children));
} else {
keys.push(item.key);
}
}
return keys;
}
/** 同步单个 key 的显隐 */
function syncLayer(key: string, checked: boolean) {
if (!mapInstance.value) return;
const factory = LAYER_MAP[key];
if (!factory) return;
let layer = activeLayers.get(key);
if (checked) {
// 勾选 → 创建并显示
if (!layer) {
layer = factory();
activeLayers.set(key, layer);
mapInstance.value.addLayer(layer);
}
layer.setVisible(true);
} else {
// 取消勾选 → 仅隐藏,不删除
if (layer) {
layer.setVisible(false);
}
}
}
/** 深度遍历所有叶子节点并同步 */
function syncAll(items: LayerItem[]) {
for (const item of items) {
if (item.children) {
syncAll(item.children);
} else {
syncLayer(item.key, item.checked);
}
}
}
// 深度 watch 所有叶子节点的 checked 变化
watch(
layerOptions,
() => {
if (!mapInstance.value) return;
syncAll(layerOptions.value);
},
{ deep: true }
);
// 地图就绪后同步初始状态
watch(
() => mapInstance.value,
map => {
if (map) {
syncAll(layerOptions.value);
}
},
{ immediate: true }
);
onBeforeUnmount(() => {
// 清理所有图层
for (const [key, layer] of activeLayers.entries()) {
layer.getSource()?.dispose();
activeLayers.delete(key);
}
});
return { activeLayers };
}
/** 默认基础图层选项(分组结构) */
export const DEFAULT_LAYER_OPTIONS: LayerItem[] = [
{ key: 'adminLabel', label: '行政标注', checked: false },
{ key: 'regionService', label: '区域服务', checked: false },
{ key: 'nineDashLine', label: '九段线', checked: false },
// { key: 'island', label: '岛屿', checked: false },
{
key: 'basin',
label: '流域',
checked: false,
children: [
{ key: 'basinLabel', label: '流域注记', checked: false },
{ key: 'basinLine', label: '流域线', checked: false }
]
},
{
key: 'traffic',
label: '交通',
checked: false,
children: [
{ key: 'ferry', label: '轮渡', checked: false },
{ key: 'railway', label: '铁路', checked: false },
{ key: 'metro', label: '地铁', checked: false }
]
},
{
key: 'adminArea',
label: '行政区',
checked: false,
children: [
{ key: 'adminAreaPolygon', label: '行政区面状', checked: false },
{ key: 'adminArea', label: '行政区', checked: false }
]
}
];

View File

@ -0,0 +1,445 @@
/**
* undo / redo
*
*
* - RightDrawer legend / anchors / images / draw
* -
* drawSource addfeature/removefeature drawCheckpoint()
*
* store ref deep watchflush:'sync' + 沿 200ms
* checkpoint200ms
* - suppressUntil drawCheckpoint store watcher 50ms
* - drawToolActive watcher restore
*
* - restore isRestoring
* - MAX_STEPS50
* - Ctrl+Z / Ctrl+Shift+Z
*/
import { ref, watch, onMounted, onUnmounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useMapStudioStore } from '../stores/mapStudioStore';
import { drawActions } from './useDrawTools';
import { anchorActions } from './useAnchorLayers';
import type {
LegendConfig,
AnchorGlobalStyle,
SelectedAnchorInfo,
ImportedImage,
DrawStyle,
DrawToolType,
TableOverlayData
} from '../types';
const MAX_STEPS = 50;
const COOLDOWN_MS = 200;
interface UndoSnapshot {
legendConfig: LegendConfig;
anchorGlobalStyle: AnchorGlobalStyle;
selectedAnchor: SelectedAnchorInfo | null;
importedImages: ImportedImage[];
selectedImageId: string | null;
tableOverlays: TableOverlayData[];
selectedTableId: string | null;
drawStyle: DrawStyle;
// selectedDrawStyle / selectedDrawInfo / selectedDrawText 不参与快照
drawFeatures: any[];
drawToolActive: DrawToolType | null;
activeTab: string;
/** 锚点 features 序列化数据(位置、图标、标签等) */
anchorFeatures: any[];
/** 锚点样式覆盖序列化数据 */
anchorOverrides: any[];
}
const undoStack: UndoSnapshot[] = [];
const redoStack: UndoSnapshot[] = [];
/** 上一次 checkpoint 时的状态(下一次 checkpoint 时 push 到 undoStack */
let previousSnapshot: UndoSnapshot | null = null;
/** 正在 restore 中,阻止一切快照 */
let isRestoring = false;
/** store watcher 前沿节流定时器null = 可发起 checkpoint */
let cooldownTimer: ReturnType<typeof setTimeout> | null = null;
/**
* drawCheckpoint
* drawSource drawCheckpoint store watcher
* drawend store checkpoint
*/
let suppressUntil = 0;
/**
* store watcher checkpoint
* clearSelection selectedAnchor
*/
let suppressWatcher = false;
const canUndo = ref(false);
const canRedo = ref(false);
function updateButtons() {
canUndo.value = undoStack.length > 0;
canRedo.value = redoStack.length > 0;
}
/** 深拷贝当前 store 状态 + 绘制图形序列化数据 */
function takeSnapshot(): UndoSnapshot {
const store = useMapStudioStore();
const s = storeToRefs(store);
return {
legendConfig: JSON.parse(JSON.stringify(s.legendConfig.value)),
anchorGlobalStyle: JSON.parse(JSON.stringify(s.anchorGlobalStyle.value)),
selectedAnchor: (() => {
if (!s.selectedAnchor.value) return null;
const cloned = JSON.parse(JSON.stringify(s.selectedAnchor.value));
// 不保存 displayLabel锚点名称名称修改应独立于样式/位置撤回问题2/3修复
delete cloned.displayLabel;
return cloned;
})(),
importedImages: JSON.parse(JSON.stringify(s.importedImages.value)),
selectedImageId: s.selectedImageId.value,
tableOverlays: JSON.parse(JSON.stringify(s.tableOverlays.value)),
selectedTableId: s.selectedTableId.value,
drawStyle: JSON.parse(JSON.stringify(s.drawStyle.value)),
// selectedDrawInfo / selectedDrawStyle / selectedDrawText 不参与快照—
// 右侧绘制面板跟随地图选中状态自然变化不应产生撤回步骤问题8修复
drawFeatures: drawActions.getSerializedFeatures
? drawActions.getSerializedFeatures()
: [],
drawToolActive: s.drawToolActive.value,
activeTab: s.activeTab.value,
anchorFeatures: anchorActions.getSerializedAnchors
? anchorActions.getSerializedAnchors()
: [],
anchorOverrides: anchorActions.getSerializedOverrides
? anchorActions.getSerializedOverrides()
: []
};
}
/** 将快照恢复到 store + 绘制图层 */
function restoreSnapshot(snapshot: UndoSnapshot) {
isRestoring = true;
const store = useMapStudioStore();
const s = storeToRefs(store);
// 保存当前锚点名称恢复后重新覆盖displayLabel 不参与快照问题2/3修复
const currentDisplayLabel = s.selectedAnchor.value?.displayLabel ?? '';
s.legendConfig.value = JSON.parse(JSON.stringify(snapshot.legendConfig));
s.anchorGlobalStyle.value = JSON.parse(
JSON.stringify(snapshot.anchorGlobalStyle)
);
s.selectedAnchor.value = snapshot.selectedAnchor
? JSON.parse(JSON.stringify(snapshot.selectedAnchor))
: null;
// 恢复后保留当前锚点名称displayLabel 不参与快照问题2/3修复
if (s.selectedAnchor.value && currentDisplayLabel) {
s.selectedAnchor.value.displayLabel = currentDisplayLabel;
}
s.importedImages.value = JSON.parse(JSON.stringify(snapshot.importedImages));
s.selectedImageId.value = snapshot.selectedImageId;
s.tableOverlays.value = JSON.parse(JSON.stringify(snapshot.tableOverlays));
s.selectedTableId.value = snapshot.selectedTableId;
s.drawStyle.value = JSON.parse(JSON.stringify(snapshot.drawStyle));
// 若表格 tab 但无选中表格,切到别处避免空内容闪烁
if (s.activeTab.value === 'table' && !s.selectedTableId.value) {
s.activeTab.value = 'baseMap';
}
// 恢复绘制图形selectedDrawStyle / selectedDrawInfo / selectedDrawText 不参与恢复—
// 右侧绘制面板的自然状态不应被撤回覆盖问题8修复
// ⚠ 不恢复 drawToolActive / activeTab —— 撤回不应切换工具或标签页
// s.drawToolActive.value = snapshot.drawToolActive;
// s.activeTab.value = snapshot.activeTab;
// 恢复绘制图形
if (drawActions.clearAllDrawings && drawActions.restoreDrawingsFromSave) {
drawActions.clearAllDrawings();
if (snapshot.drawFeatures.length > 0) {
drawActions.restoreDrawingsFromSave(snapshot.drawFeatures, {});
}
}
// 撤回后清除绘制选中状态让右侧面板自然跟随地图状态问题8修复
s.selectedDrawInfo.value = null;
// 恢复锚点 features位置、图标、标签等
// 注意:如果 snapshot 没有锚点数据anchorFeatures.length === 0
// 不清除当前锚点避免撤回初始状态锚点加载前时丢失所有已加载的锚点问题6修复
if (snapshot.anchorFeatures.length > 0) {
if (
anchorActions.clearAllAnchors &&
anchorActions.restoreAnchorsFromSave &&
anchorActions.restoreAnchorOverridesFromSave
) {
anchorActions.clearAllAnchors();
anchorActions.restoreAnchorsFromSave(snapshot.anchorFeatures);
if (snapshot.anchorOverrides.length > 0) {
anchorActions.restoreAnchorOverridesFromSave(snapshot.anchorOverrides);
}
}
// 重新关联选中的锚点 featureclearAllAnchors 后旧引用已失效问题4修复
if (anchorActions.reselectSelectedAnchor) {
anchorActions.reselectSelectedAnchor();
}
// 同步选中锚点的覆盖样式回 OL feature
if (anchorActions.syncSelectedOverrides) {
anchorActions.syncSelectedOverrides();
}
// 将保留的锚点名称同步回 featurerestoreAnchorsFromSave 的 _label = stnm 会覆盖自定义名称)
if (
anchorActions.updateSelectedLabel &&
s.selectedAnchor.value?.displayLabel
) {
anchorActions.updateSelectedLabel(s.selectedAnchor.value.displayLabel);
}
}
// 恢复后刷新 previousSnapshot
previousSnapshot = takeSnapshot();
// 所有同步操作完成后再恢复 watcher避免中间状态触发不必要的 checkpoint
isRestoring = false;
}
/**
* store watcher checkpoint suppressWatcher
*/
function checkpoint() {
if (isRestoring) return;
if (suppressWatcher) return;
if (previousSnapshot) {
const current = takeSnapshot();
// 去重:如果前后快照完全一致,不产生撤回步骤
if (JSON.stringify(previousSnapshot) !== JSON.stringify(current)) {
undoStack.push(previousSnapshot);
if (undoStack.length > MAX_STEPS) {
undoStack.shift();
}
redoStack.length = 0; // 新操作 → 清空 redo
updateButtons();
}
previousSnapshot = current;
} else {
previousSnapshot = takeSnapshot();
}
}
/**
* checkpoint
* suppressUntil store watcher checkpoint
*/
function drawCheckpoint() {
if (isRestoring) return;
// 设置抑制窗口,阻止紧随其后的 store ref 变更再次 checkpoint
suppressUntil = performance.now() + 50;
if (previousSnapshot) {
const current = takeSnapshot();
// 如果唯一变化是 drawToolActive/activeTab 且图形数未变,则跳过(不产生虚假撤回步骤)
if (
(previousSnapshot.drawFeatures?.length ?? 0) ===
(current.drawFeatures?.length ?? 0)
) {
const changedKeys: string[] = [];
for (const key of Object.keys(current) as (keyof UndoSnapshot)[]) {
if (
JSON.stringify(previousSnapshot[key]) !== JSON.stringify(current[key])
) {
changedKeys.push(key);
}
}
const nonDisplayKeys = changedKeys.filter(
k => k !== 'drawToolActive' && k !== 'activeTab'
);
if (nonDisplayKeys.length === 0) {
previousSnapshot = current;
return;
}
}
// 去重:如果前后快照完全一致,不产生撤回步骤
if (JSON.stringify(previousSnapshot) !== JSON.stringify(current)) {
undoStack.push(previousSnapshot);
if (undoStack.length > MAX_STEPS) {
undoStack.shift();
}
redoStack.length = 0;
updateButtons();
}
previousSnapshot = current;
} else {
previousSnapshot = takeSnapshot();
}
}
/**
* previousSnapshot
*
*/
function syncSnapshot() {
if (isRestoring) return;
previousSnapshot = takeSnapshot();
}
/** 临时抑制 store watcher checkpoint用于锚点显隐切换等不应产生撤回步骤的场景 */
function setSuppressWatcher(val: boolean) {
suppressWatcher = val;
}
/**
* drawend storeupdateSelectedDrawInfo
* store watcher checkpoint
* setSuppressWatcher cooldownTimerwatcher
* cooldownTimer watcher
*/
function beginDrawBatch() {
suppressUntil = performance.now() + 50;
}
// ── 公开 API ──
function undo() {
if (undoStack.length === 0) return;
// 清除可能的抑制窗口,避免 restore 后 watcher 被意外跳过
suppressUntil = 0;
suppressWatcher = false;
const snapshot = undoStack.pop()!;
const currentSnapshot = takeSnapshot();
redoStack.push(currentSnapshot);
restoreSnapshot(snapshot);
updateButtons();
}
function redo() {
if (redoStack.length === 0) return;
suppressUntil = 0;
suppressWatcher = false;
undoStack.push(previousSnapshot || takeSnapshot());
if (undoStack.length > MAX_STEPS) {
undoStack.shift();
}
const snapshot = redoStack.pop()!;
restoreSnapshot(snapshot);
updateButtons();
}
/** 在 RightDrawer setup 中调用,启动对 store 状态变更的 deep watch */
function initUndoWatch() {
const store = useMapStudioStore();
const {
legendConfig,
anchorGlobalStyle,
selectedAnchor,
importedImages,
selectedImageId,
drawStyle
// selectedDrawStyle / selectedDrawInfo / selectedDrawText 不参与 watcher —
// 右侧绘制面板状态变更不应产生撤回步骤问题8修复
// drawToolActive 不在 watcher 中 — 单纯切换工具不产生撤回步骤
} = storeToRefs(store);
// 初始化 previousSnapshot
previousSnapshot = takeSnapshot();
watch(
[
legendConfig,
anchorGlobalStyle,
selectedAnchor,
importedImages,
selectedImageId,
drawStyle
],
() => {
if (isRestoring) return;
// drawCheckpoint 已处理 → 跳过,避免重复
if (performance.now() < suppressUntil) return;
// 前沿节流:冷却中 → 仅重置定时器
if (cooldownTimer !== null) {
clearTimeout(cooldownTimer);
cooldownTimer = setTimeout(() => {
cooldownTimer = null;
}, COOLDOWN_MS);
return;
}
// 冷却结束 → 立即 checkpoint启动新冷却
checkpoint();
cooldownTimer = setTimeout(() => {
cooldownTimer = null;
}, COOLDOWN_MS);
},
{ deep: true, flush: 'sync' }
);
}
// ── 键盘快捷键 ──
function onKeyDown(e: KeyboardEvent) {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
) {
return;
}
if (e.ctrlKey || e.metaKey) {
if (e.shiftKey && (e.key === 'z' || e.key === 'Z')) {
e.preventDefault();
redo();
return;
}
if (e.key === 'z' || e.key === 'Z') {
e.preventDefault();
undo();
return;
}
}
}
function registerShortcuts() {
onMounted(() => {
document.addEventListener('keydown', onKeyDown);
});
onUnmounted(() => {
document.removeEventListener('keydown', onKeyDown);
});
}
export function useUndoRedo() {
return {
canUndo,
canRedo,
undo,
redo,
initUndoWatch,
registerShortcuts,
drawCheckpoint,
setSuppressWatcher,
beginDrawBatch,
syncSnapshot
};
}

View File

@ -0,0 +1,268 @@
<template>
<div class="map-studio">
<!-- 中央地图容器 -->
<MapContainer ref="mapContainerComp">
<Legend />
<MapImageOverlay />
<TableOverlay />
</MapContainer>
<!-- 右侧区域工具栏 + 抽屉展开按钮 + 抽屉 -->
<div class="map-studio__right-area">
<LeftToolbar />
<!-- 抽屉收起时的展开按钮 -->
<button
class="map-studio__drawer-toggle"
@click="drawerOpen = true"
v-show="!drawerOpen"
title="展开"
>
<img src="@/assets/components/arrow-left.png" alt="" />
</button>
<RightDrawer v-model:open="drawerOpen" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { storeToRefs } from 'pinia';
import MapContainer from './components/MapContainer.vue';
import LeftToolbar from './components/LeftToolbar.vue';
import RightDrawer from './components/RightDrawer.vue';
import Legend from './components/Legend.vue';
import MapImageOverlay from './components/MapImageOverlay.vue';
import TableOverlay from './components/TableOverlay.vue';
import { useMapStudioStore } from './stores/mapStudioStore';
import { useMapInit } from './composables/useMapInit';
import { useOverlayLayers } from './composables/useOverlayLayers';
import { useAnchorLayers, anchorActions } from './composables/useAnchorLayers';
import { useDrawTools, drawActions } from './composables/useDrawTools';
import './styles/index.scss';
const drawerOpen = ref(true);
// 使 Pinia store
const store = useMapStudioStore();
if (!store) {
throw new Error('[MapStudio] Pinia store 未正确初始化');
}
const { selectedBaseMap, showBoundary, layerOptions } = storeToRefs(store);
// MapContainer
const mapContainerComp = ref<InstanceType<typeof MapContainer> | null>(null);
// MapContainer containerRef
const mapContainerRef = computed(
() => mapContainerComp.value?.containerRef ?? null
);
// mapInstance store
const { mapInstance, resetView } = useMapInit(
mapContainerRef as any,
selectedBaseMap,
showBoundary
);
// resetView store LeftToolbar / RightDrawer 使
store.resetView = resetView as any;
//
useOverlayLayers(mapInstance, layerOptions as any);
// Pinia store
useAnchorLayers(mapInstance);
// Pinia store
const { deleteSelectedDraw } = useDrawTools(mapInstance);
//
function onDeleteDrawEvent() {
deleteSelectedDraw();
}
window.addEventListener('map-studio:delete-draw', onDeleteDrawEvent);
// tab
watch(
() => store.selectedAnchor,
anchor => {
if (anchor) {
store.activeTab = 'anchors';
}
},
{ deep: true }
);
//
watch(
() => store.restoreVersion,
() => {
const payload = store.restorePayload;
if (!payload) return;
console.log('[MapStudio] 开始恢复配置', payload.version);
const map = mapInstance.value;
if (!map) return;
// 1.
//
store.selectedAnchor = null;
store.selectedDrawInfo = null;
store.drawToolActive = null;
//
if (anchorActions.clearAllAnchors) {
anchorActions.clearAllAnchors();
}
//
if (drawActions.clearAllDrawings) {
drawActions.clearAllDrawings();
}
//
store.importedImages = [];
//
store.tableOverlays = [];
store.selectedTableId = null;
// 2.
if (payload.baseMap) {
store.selectedBaseMap = payload.baseMap.source || 'vector';
store.showBoundary = payload.baseMap.showBoundary ?? true;
store.clipMode = payload.baseMap.clip?.mode || 'none';
store.clipBasin = payload.baseMap.clip?.basin || '';
store.clipDistrict = payload.baseMap.clip?.district || '';
}
// 3.
if (payload.overlayLayers?.checkedKeys) {
const checkedKeys = payload.overlayLayers.checkedKeys as string[];
const setChecked = (items: any[]) => {
for (const item of items) {
if (item.children) {
for (const child of item.children) {
child.checked = checkedKeys.includes(child.key);
}
} else {
item.checked = checkedKeys.includes(item.key);
}
}
};
setChecked(store.layerOptions);
// layerOptions
store.layerOptions = [...store.layerOptions];
}
// 4.
if (payload.legend) {
store.BasicControlOptions[0].checked = payload.legend.visible ?? true;
if (payload.legend.config) {
Object.assign(store.legendConfig, payload.legend.config);
}
store.customLegendEntries = payload.legend.customEntries || [];
store.customLegendGroups = payload.legend.customGroups || [];
store.categoryChildExtensions =
payload.legend.categoryChildExtensions || {};
store.categoryIconOverrides = payload.legend.categoryIconOverrides || {};
}
// 5.
if (payload.anchorGlobalStyle) {
Object.assign(store.anchorGlobalStyle, payload.anchorGlobalStyle);
}
// 6. 使 checkedKeys
const savedCheckedKeys: string[] =
payload.anchorCategories?.checkedKeys || [];
for (const cat of store.anchorCategories) {
for (const child of cat.children) {
child.checked = savedCheckedKeys.includes(child.key);
}
}
// 7.
if (payload.anchors && anchorActions.restoreAnchorsFromSave) {
anchorActions.restoreAnchorsFromSave(payload.anchors);
}
// 8.
if (
payload.anchorOverrides?.length &&
anchorActions.restoreAnchorOverridesFromSave
) {
anchorActions.restoreAnchorOverridesFromSave(payload.anchorOverrides);
}
// 8.
store.drawStyle = payload.drawStyle || store.drawStyle;
console.log(
'[MapStudio] drawFeatures 数据:',
payload.drawFeatures?.length,
'条'
);
console.log(
'[MapStudio] drawActions.restoreDrawingsFromSave 是否存在:',
!!drawActions.restoreDrawingsFromSave
);
if (payload.drawFeatures?.length && drawActions.restoreDrawingsFromSave) {
drawActions.restoreDrawingsFromSave(
payload.drawFeatures,
payload.drawStyle
);
} else {
console.warn(
'[MapStudio] 跳过绘制恢复: drawFeatures=',
payload.drawFeatures?.length,
'restoreFn=',
!!drawActions.restoreDrawingsFromSave
);
}
// 9.
if (payload.importedImages) {
store.importedImages = payload.importedImages.map((img: any) => ({
...img
}));
}
// 10.
if (payload.tableOverlays) {
store.tableOverlays = payload.tableOverlays.map((t: any) => ({ ...t }));
}
//
store.selectedAnchor = null;
// 10.
store.restorePayload = null;
console.log('[MapStudio] 配置恢复完成');
}
);
</script>
<style lang="scss" scoped>
.map-studio {
width: 100%;
height: 100%;
position: relative;
display: flex;
overflow: hidden;
:deep(.map-studio__map-area) {
flex: 1;
position: relative;
overflow: hidden;
}
.map-studio__right-area {
position: relative;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
}
</style>

View File

@ -0,0 +1,263 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type OlMap from 'ol/Map';
import { DEFAULT_LAYER_OPTIONS } from '../composables/useOverlayLayers';
import {
DEFAULT_ANCHOR_STYLE,
DEFAULT_DRAW_STYLE,
DEFAULT_LEGEND_CONFIG,
type AnchorCategory,
type AnchorGlobalStyle,
type CustomLegendEntry,
type CustomLegendGroup,
type DrawStyle,
type DrawToolType,
type ImportedImage,
type LayerItem,
type LegendConfig,
type SelectedAnchorInfo,
type SelectedDrawInfo,
type TableOverlayData
} from '../types';
export const useMapStudioStore = defineStore('mapStudio', () => {
// ── 锚点分类 ──
const anchorCategories = ref<AnchorCategory[]>([
{
key: 'powerStation',
label: '电站',
icon: 'power-station',
children: [
{
key: 'large_eng_built',
label: '大型水电站-已建',
icon: 'large-built',
checked: false
},
{
key: 'large_eng_ubuilt',
label: '大型水电站-在建',
icon: 'large-building',
checked: false
},
{
key: 'large_eng_nbuilt',
label: '大型水电站-未建',
icon: 'large-unbuilt',
checked: false
},
{
key: 'mid_eng_built',
label: '中型水电站-已建',
icon: 'medium-built',
checked: false
},
{
key: 'mid_eng_ubuilt',
label: '中型水电站-在建',
icon: 'medium-building',
checked: false
},
{
key: 'mid_eng_nbuilt',
label: '中型水电站-未建',
icon: 'medium-unbuilt',
checked: false
}
]
},
{
key: 'fishPassage',
label: '过鱼设施',
icon: 'fish-passage',
children: [
{ key: 'gy_1', label: '鱼道', icon: 'fishway', checked: false },
{
key: 'gy_2',
label: '仿自然通道',
icon: 'nature-channel',
checked: false
},
{
key: 'GY_3_INCOMPLETE',
label: '集运鱼系统',
icon: 'collect-system',
checked: false
},
{ key: 'gy_4', label: '升鱼机', icon: 'fish-lift', checked: false },
{ key: 'gy_5', label: '其他', icon: 'other-fish', checked: false }
]
},
{
key: 'fishFarm',
label: '鱼类增殖站',
icon: 'fish-farm',
children: [
{
key: 'FB',
label: '鱼类增殖站',
icon: 'fish-farm-station',
checked: false
}
]
},
{
key: 'coldWater',
label: '低温水减缓设施',
icon: 'cold-water',
children: [
{ key: 'dws_1', label: '叠梁门', icon: 'closing-gate', checked: false },
{ key: 'dws_2', label: '前置挡墙', icon: 'front-wall', checked: false },
{
key: 'dws_3',
label: '隔水幕墙',
icon: 'curtain-wall',
checked: false
},
{ key: 'dws_4', label: '其他', icon: 'other-cold', checked: false },
{
key: 'dws_1_incomplete',
label: '叠梁门-未完成',
icon: 'closing-gate-unfinished',
checked: false
},
{
key: 'dws_2_incomplete',
label: '前置挡墙-未完成',
icon: 'front-wall-unfinished',
checked: false
},
{
key: 'dws_3_incomplete',
label: '隔水幕墙-未完成',
icon: 'curtain-wall-unfinished',
checked: false
},
{
key: 'dws_4_incomplete',
label: '其他-未完成',
icon: 'other-cold-unfinished',
checked: false
}
]
}
]);
// ── 基础控制选项 ──
const BasicControlOptions = ref<
{ key: string; label: string; checked: boolean }[]
>([{ key: 'legend', label: '是否显示图例', checked: true }]);
// ── 图例配置 ──
const legendConfig = ref<LegendConfig>({ ...DEFAULT_LEGEND_CONFIG });
// ── 锚点全局样式 ──
const anchorGlobalStyle = ref<AnchorGlobalStyle>({ ...DEFAULT_ANCHOR_STYLE });
// ── 底图 / 边界 ──
const selectedBaseMap = ref('vector');
const showBoundary = ref(true);
// ── 裁切设置 ──
const clipMode = ref<'none' | 'basin' | 'district'>('none');
const clipBasin = ref<string>('');
const clipDistrict = ref<string>('');
// ── 右侧抽屉 ──
const activeTab = ref('baseMap');
// ── 基础图层叠加 ──
const layerOptions = ref<LayerItem[]>(
JSON.parse(JSON.stringify(DEFAULT_LAYER_OPTIONS))
);
// ── 选中锚点 ──
const selectedAnchor = ref<SelectedAnchorInfo | null>(null);
// ── 自定义图例 ──
const customLegendEntries = ref<CustomLegendEntry[]>([]);
const customLegendGroups = ref<CustomLegendGroup[]>([]);
const categoryChildExtensions = ref<
Record<string, { label: string; iconUrl: string }[]>
>({});
const categoryIconOverrides = ref<Record<string, string>>({});
// ── 图片叠加 ──
const importedImages = ref<ImportedImage[]>([]);
const selectedImageId = ref<string | null>(null);
// ── 表格叠加 ──
const tableOverlays = ref<TableOverlayData[]>([]);
const selectedTableId = ref<string | null>(null);
// ── 导入锚点记录(用于保存) ──
const importedAnchors = ref<
{
parentName: string;
legendName: string;
iconUrl: string;
items: { lng: number; lat: number; name: string }[];
}[]
>([]);
// ── 绘制工具 ──
const drawToolActive = ref<DrawToolType>(null);
const drawStyle = ref<DrawStyle>({ ...DEFAULT_DRAW_STYLE });
const selectedDrawInfo = ref<SelectedDrawInfo | null>(null);
const selectedDrawStyle = ref<DrawStyle>({ ...DEFAULT_DRAW_STYLE });
const selectedDrawText = ref<string>('');
// ── 地图实例(由 useMapInit 自动同步) ──
const mapInstance = ref<OlMap | null>(null);
// 由 useMapInit 的 resetView 填充,供子组件调用
const resetView = ref<(() => void) | null>(null);
// 由 RightDrawer 裁切时填充,供 LeftToolbar 重置视角到裁切区域使用
const fitClipView = ref<(() => void) | null>(null);
// ── 恢复配置(从历史记录编辑时使用) ──
const restorePayload = ref<any>(null);
const restoreVersion = ref(0);
// ── 当前编辑的配图信息(历史记录编辑 → 保存时复用) ──
const editConfigId = ref('');
const editPeituName = ref('');
return {
// 状态
anchorCategories,
BasicControlOptions,
legendConfig,
anchorGlobalStyle,
selectedBaseMap,
showBoundary,
clipMode,
clipBasin,
clipDistrict,
activeTab,
layerOptions,
selectedAnchor,
customLegendEntries,
customLegendGroups,
categoryChildExtensions,
categoryIconOverrides,
importedImages,
selectedImageId,
tableOverlays,
selectedTableId,
importedAnchors,
drawToolActive,
drawStyle,
selectedDrawInfo,
selectedDrawStyle,
selectedDrawText,
mapInstance,
resetView,
fitClipView,
// 恢复
restorePayload,
restoreVersion,
// 编辑
editConfigId,
editPeituName
};
});

View File

@ -0,0 +1,191 @@
.map-studio {
width: 100%;
height: 100%;
display: flex;
position: relative;
pointer-events: all;
background-color: #fafafa;
}
/* 中央地图容器 */
.map-studio__map-area {
flex: 1;
position: relative;
overflow: hidden;
min-height: 0;
min-width: 0;
}
/* 工具栏(位于抽屉左侧) */
.map-studio__toolbar {
position: absolute;
left: -50px;
top: 4px;
width: 44px;
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 0;
gap: 4px;
z-index: 10;
background: #fff;
border-radius: 4px;
}
.map-studio__toolbar-btn {
width: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
color: #595959;
font-size: 12px;
transition: all 0.2s;
flex-direction: column;
gap: 4px;
padding: 4px 0;
}
.map-studio__toolbar-btn:hover {
background: #e6f7ff;
color: #1890ff;
}
.map-studio__toolbar-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.map-studio__toolbar-btn:disabled:hover {
background: transparent;
color: inherit;
}
.map-studio__toolbar-btn.is-active {
background: #bae7ff;
color: #1890ff;
font-weight: 600;
}
.map-studio__toolbar-btn .btn-icon {
line-height: 1;
}
.map-studio__toolbar-btn .btn-label {
font-size: 12px;
line-height: 1;
white-space: nowrap;
}
.map-studio__toolbar-divider {
width: 28px;
height: 1px;
background: #e8e8e8;
margin: 4px 0;
}
/* 抽屉外层容器(控制宽度过渡,配合工具栏滑动) */
.map-studio__drawer-section {
transition: width 0.3s ease;
position: relative;
}
.map-studio__drawer-section.is-closed {
width: 0;
}
.map-studio__drawer-section:not(.is-closed) {
width: 450px;
height: 100%;
}
/* 抽屉收起时,右侧的展开按钮(在 right-area 层级定位) */
.map-studio__drawer-toggle {
width: 18px;
position: absolute;
right: -2px;
cursor: pointer;
z-index: 10;
height: 88px;
line-height: 88px;
top: 45%;
vertical-align: middle;
background-image: url('@/assets/components/bg-toggle.e1dabcf3.svg');
background-repeat: no-repeat;
display: flex;
justify-content: center;
align-items: center;
}
/* 抽屉展开时,左侧的收起按钮(在 a-drawer 内容区定位) */
.map-studio__drawer-trigger {
width: 18px;
position: absolute;
cursor: pointer;
z-index: 10;
height: 88px;
line-height: 88px;
top: 45%;
left: -17px;
vertical-align: middle;
background-image: url('@/assets/components/bg-toggle.e1dabcf3.svg');
background-repeat: no-repeat;
display: flex;
justify-content: center;
align-items: center;
}
/* Drawer body */
.map-studio__drawer-body {
padding: 4px 20px 20px;
}
/* 覆盖 a-drawer 默认 body padding */
.map-studio .ant-drawer-body {
padding: 0 !important;
}
/* Tabs 内容区 */
.map-studio__tab-content {
padding-top: 16px;
}
/* Tabs 样式覆盖 - tabs 标签紧贴顶部 */
.map-studio .ant-tabs-nav {
margin-bottom: 0 !important;
}
.placeholder-desc {
font-size: 12px;
color: #999;
}
/* ── 锚点删除弹窗 ── */
.map-studio__delete-popup {
background: #ff4d4f;
border-radius: 50%;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(255, 77, 79, 0.5);
cursor: pointer;
transition: transform 0.15s;
z-index: 1000;
}
.map-studio__delete-popup:hover {
transform: scale(1.15);
}
.map-studio__delete-btn {
color: #fff;
font-size: 15px;
font-weight: bold;
line-height: 1;
background: none;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
}

View File

@ -0,0 +1,359 @@
// ── 锚点分类 ──
export interface AnchorItem {
key: string;
label: string;
icon: string;
checked: boolean;
/** 自定义图标 URL导入锚点用优先级高于 SVG icon key */
customIconUrl?: string;
}
export interface AnchorCategory {
key: string;
label: string;
icon: string;
children: AnchorItem[];
}
// ── 图例配置 ──
export interface LegendConfig {
position: string;
width: number;
height: number;
offsetX: number;
offsetY: number;
title: string;
showTitle: boolean;
/** 图例标题字号 */
titleFontSize: number;
/** 图例标题字体 */
titleFontFamily: string;
/** 图例标题加粗 */
titleBold: boolean;
/** 图例标题对齐方式 */
titleAlign: 'left' | 'center' | 'right';
/** 图例标题颜色 */
titleColor: string;
/** 图例子级文字字号 */
childFontSize: number;
/** 图例子级文字字体 */
childFontFamily: string;
/** 图例子级文字颜色 */
childColor: string;
/** 图例子级文字加粗 */
childBold: boolean;
/** 图例子级图标大小 */
childIconSize: number;
}
// ── 标题配置 ──
export interface TitleConfig {
x: number;
y: number;
width: number;
height: number;
text: string;
}
// ── 锚点全局样式 ──
export interface AnchorGlobalStyle {
fontSize: number;
fontFamily: string;
color: string;
bold: boolean;
/** 图标大小(宽=高),默认 16 */
iconSize: number;
/** 文字描边颜色 */
strokeColor: string;
/** 文字描边宽度 */
strokeWidth: number;
/** 文字相对于图标的位置: top / bottom / left / right */
textPosition: 'top' | 'bottom' | 'left' | 'right';
/** 文字水平方向与图标的间距 */
textMarginX: number;
/** 文字垂直方向与图标的间距 */
textMarginY: number;
/** 是否显示文字标注 */
showText: boolean;
}
/** 锚点样式覆盖(单个锚点可覆盖全局样式中的任意字段) */
export interface AnchorStyleOverride {
fontSize: number | null;
fontFamily: string | null;
color: string | null;
bold: boolean | null;
iconSize: number | null;
strokeColor: string | null;
strokeWidth: number | null;
textPosition: 'top' | 'bottom' | 'left' | 'right' | null;
textMarginX: number | null;
textMarginY: number | null;
showText: boolean | null;
}
/** 当前选中的锚点信息 */
export interface SelectedAnchorInfo {
/** 站点名称 */
stnm: string;
/** 当前显示的文字(可编辑) */
displayLabel: string;
/** 当前图标 URL */
iconUrl: string;
/** 图标来源 */
iconSource: 'inherit' | 'custom';
/** 自定义图标 URL */
customIconUrl: string;
/** 所属分类 key */
categoryKey: string;
/** 所属分类 label */
categoryLabel: string;
/** 所属子类型 key */
subTypeKey: string;
/** 所属子类型 label */
subTypeLabel: string;
/** 经度 */
lng: number;
/** 纬度 */
lat: number;
/** 样式覆盖值null 表示继承全局) */
overrides: AnchorStyleOverride;
}
// ── 自定义图例 ──
export interface CustomLegendEntry {
id: string;
label: string;
iconUrl: string;
}
export interface CustomLegendGroup {
key: string;
label: string;
children: {
label: string;
iconUrl: string;
}[];
}
// ── 绘制工具 ──
/** 绘制图形样式 */
export interface DrawStyle {
/** 线条/描边颜色 */
strokeColor: string;
/** 线条/描边宽度 */
strokeWidth: number;
/** 填充颜色(仅面) */
fillColor: string;
/** 点半径 */
pointRadius: number;
/** 点颜色 */
pointColor: string;
/** 线条类型 */
lineType: 'solid' | 'dashed' | 'dotted' | 'dash-dot';
/** 是否显示箭头 */
showArrow: boolean;
/** 箭头位置 */
arrowPosition: 'end' | 'both';
/** 箭头大小 */
arrowSize: 'small' | 'medium' | 'large';
/** 字号 */
fontSize: number;
/** 字体 */
fontFamily: string;
/** 文字颜色 */
textColor: string;
/** 文字描边颜色 */
textStrokeColor: string;
/** 文字描边宽度 */
textStrokeWidth: number;
/** 文字是否加粗 */
fontBold: boolean;
}
export const DEFAULT_DRAW_STYLE: DrawStyle = {
strokeColor: '#000000',
strokeWidth: 2,
fillColor: '#000000',
pointRadius: 6,
pointColor: '#000000',
lineType: 'solid',
showArrow: true,
arrowPosition: 'end',
arrowSize: 'medium',
fontSize: 14,
fontFamily: 'Microsoft YaHei',
textColor: '#ffffff',
textStrokeColor: '#000000',
textStrokeWidth: 3,
fontBold: false
};
/** 绘制工具类型 */
export type DrawToolType =
| 'point'
| 'line'
| 'polygon'
| 'rect'
| 'circle'
| 'freehand'
| 'text'
| 'select'
| null;
/** 绘制图形的选中信息 */
export interface SelectedDrawInfo {
id: string;
type: 'Point' | 'LineString' | 'Polygon' | 'Circle';
/** 原始绘制工具类型,用于区分 point / text / line / freehand 等 */
drawTypeKey: string;
/** 坐标(经度/纬度) */
coords: number[];
/** 对线的长度(米) */
length?: number;
/** 对面/圆的面积(平方米) */
area?: number;
}
// ── 图片叠加 ──
export interface ImportedImage {
id: string;
src: string;
x: number;
y: number;
width: number;
height: number;
}
// ── 表格叠加 ──
export interface TableOverlayData {
id: string;
x: number;
y: number;
width: number;
height: number;
rows: number;
cols: number;
/** 单元格文本数据 [row][col] */
cellData: string[][];
/** 字号 */
fontSize: number;
/** 字体 */
fontFamily: string;
/** 文字颜色 */
textColor: string;
/** 边框颜色 */
borderColor: string;
/** 边框宽度 */
borderWidth: number;
/** 填充颜色 */
fillColor: string;
/** 是否透明背景(为 true 时忽略 fillColor */
fillTransparent: boolean;
/** 文字加粗 */
fontBold: boolean;
/** 水平对齐 */
hAlign: 'left' | 'center' | 'right';
/** 垂直对齐 */
vAlign: 'top' | 'middle' | 'bottom';
/** 自定义行高px空数组=自适应 */
rowHeights: number[];
/** 自定义列宽px空数组=自适应 */
colWidths: number[];
}
// ── 基础图层 ──
/** 单个图层项定义 */
export interface LayerItem {
key: string;
label: string;
checked: boolean;
children?: LayerItem[];
}
// ── 常量 ──
export const FONT_OPTIONS = [
{ value: 'SimSun', label: '宋体' },
{ value: 'Microsoft YaHei', label: '微软雅黑' },
{ value: 'SimHei', label: '黑体' },
{ value: 'KaiTi', label: '楷体' },
{ value: 'FangSong', label: '仿宋' },
{ value: 'PingFang SC', label: '苹方' },
{ value: 'Source Han Sans SC', label: '思源黑体' }
];
export const DEFAULT_LEGEND_CONFIG: LegendConfig = {
position: 'bottom-left',
width: 200,
height: 200,
offsetX: 20,
offsetY: 20,
title: '图例',
showTitle: true,
titleFontSize: 14,
titleFontFamily: 'Microsoft YaHei',
titleBold: true,
titleAlign: 'center',
titleColor: '#000000',
childFontSize: 12,
childFontFamily: 'Microsoft YaHei',
childColor: '#000000',
childBold: false,
childIconSize: 16
};
export const DEFAULT_TITLE_CONFIG: TitleConfig = {
x: 30,
y: 20,
width: 300,
height: 60,
text: '主标题'
};
export const DEFAULT_ANCHOR_STYLE: AnchorGlobalStyle = {
fontSize: 10,
fontFamily: 'Microsoft YaHei',
color: '#ffffff',
bold: false,
iconSize: 22,
strokeColor: '#000000',
strokeWidth: 2,
textPosition: 'top',
textMarginX: 0,
textMarginY: 8,
showText: true
};
/** icon key → SVG 文件名映射 */
export const ICON_SVG_MAP: Record<string, string> = {
'large-built': 'map-dxsdzYijian',
'large-building': 'map-dxsdzZaijian',
'large-unbuilt': 'map-dxsdzGuihua',
'medium-built': 'map-zxsdzYijian',
'medium-building': 'map-zxsdzZaijian',
'medium-unbuilt': 'map-zxsdzGuihua',
fishway: 'map-gyssYudao',
'nature-channel': 'map-gyssFangzirantongdao',
'collect-system': 'map-gyssJiyunyuxitong',
'fish-lift': 'map-gyssShengyuji',
'other-fish': 'map-gyssQita',
'fish-farm-station': 'map-yuleizengzhizhan',
'closing-gate': 'map-dwsjhDieliangmenshi',
'front-wall': 'map-dwsjhQianzhidangqiang',
'curtain-wall': 'map-dwsjhGeshuimuqiang',
'other-cold': 'map-dwsjhQita',
'closing-gate-unfinished': 'map-dwsjhDieliangmenshiZWeijian',
'front-wall-unfinished': 'map-dwsjhQianzhidangqiangZWeijian',
'curtain-wall-unfinished': 'map-dwsjhGeshuimuqiangZWeijian',
'other-cold-unfinished': 'map-dwsjhQitaZWeijian'
};

View File

@ -0,0 +1,7 @@
<template>
<MapStudio />
</template>
<script setup lang="ts">
import MapStudio from '@/modules/MapStudio/index.vue';
</script>