添加智能配图功能
This commit is contained in:
parent
aea105c1f3
commit
f3228fca74
@ -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'
|
||||
|
||||
343
frontend/docs/地图可视化配置平台(MapStudio)技术实现说明文档.md
Normal file
343
frontend/docs/地图可视化配置平台(MapStudio)技术实现说明文档.md
Normal 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; // 分类 key(powerStation / 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>
|
||||
```
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
BIN
frontend/src/assets/images/map-denggaoxian.png
Normal file
BIN
frontend/src/assets/images/map-denggaoxian.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@ -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">
|
||||
|
||||
210
frontend/src/modules/MapStudio/api/index.ts
Normal file
210
frontend/src/modules/MapStudio/api/index.ts
Normal 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
|
||||
});
|
||||
}
|
||||
251
frontend/src/modules/MapStudio/components/HistoryModal.vue
Normal file
251
frontend/src/modules/MapStudio/components/HistoryModal.vue
Normal 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>
|
||||
456
frontend/src/modules/MapStudio/components/ImportAnchorModal.vue
Normal file
456
frontend/src/modules/MapStudio/components/ImportAnchorModal.vue
Normal 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>
|
||||
416
frontend/src/modules/MapStudio/components/LeftToolbar.vue
Normal file
416
frontend/src/modules/MapStudio/components/LeftToolbar.vue
Normal 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>
|
||||
291
frontend/src/modules/MapStudio/components/Legend.vue
Normal file
291
frontend/src/modules/MapStudio/components/Legend.vue
Normal 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>
|
||||
13
frontend/src/modules/MapStudio/components/MapContainer.vue
Normal file
13
frontend/src/modules/MapStudio/components/MapContainer.vue
Normal 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>
|
||||
224
frontend/src/modules/MapStudio/components/MapImageOverlay.vue
Normal file
224
frontend/src/modules/MapStudio/components/MapImageOverlay.vue
Normal 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>
|
||||
3259
frontend/src/modules/MapStudio/components/RightDrawer.vue
Normal file
3259
frontend/src/modules/MapStudio/components/RightDrawer.vue
Normal file
File diff suppressed because it is too large
Load Diff
363
frontend/src/modules/MapStudio/components/SaveModal.vue
Normal file
363
frontend/src/modules/MapStudio/components/SaveModal.vue
Normal 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>
|
||||
299
frontend/src/modules/MapStudio/components/TableOverlay.vue
Normal file
299
frontend/src/modules/MapStudio/components/TableOverlay.vue
Normal 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>
|
||||
26913
frontend/src/modules/MapStudio/components/district.json
Normal file
26913
frontend/src/modules/MapStudio/components/district.json
Normal file
File diff suppressed because it is too large
Load Diff
1420
frontend/src/modules/MapStudio/composables/useAnchorLayers.ts
Normal file
1420
frontend/src/modules/MapStudio/composables/useAnchorLayers.ts
Normal file
File diff suppressed because it is too large
Load Diff
1097
frontend/src/modules/MapStudio/composables/useDrawTools.ts
Normal file
1097
frontend/src/modules/MapStudio/composables/useDrawTools.ts
Normal file
File diff suppressed because it is too large
Load Diff
250
frontend/src/modules/MapStudio/composables/useMapInit.ts
Normal file
250
frontend/src/modules/MapStudio/composables/useMapInit.ts
Normal 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
|
||||
};
|
||||
}
|
||||
238
frontend/src/modules/MapStudio/composables/useOverlayLayers.ts
Normal file
238
frontend/src/modules/MapStudio/composables/useOverlayLayers.ts
Normal 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 }
|
||||
]
|
||||
}
|
||||
];
|
||||
445
frontend/src/modules/MapStudio/composables/useUndoRedo.ts
Normal file
445
frontend/src/modules/MapStudio/composables/useUndoRedo.ts
Normal file
@ -0,0 +1,445 @@
|
||||
/**
|
||||
* undo / redo 管理器(模块级单例)
|
||||
*
|
||||
* 设计:
|
||||
* - 快照记录 RightDrawer 所有可撤回状态(legend / anchors / images / draw)
|
||||
* - 两条触发路径:
|
||||
* ① drawSource 的 addfeature/removefeature 事件 → drawCheckpoint()
|
||||
* 立即记录,不受节流限制,每个绘制图形增删为独立撤回步骤
|
||||
* ② store ref deep watch(flush:'sync' + 前沿节流 200ms)
|
||||
* 无绘制事件干扰时,首次变更立即 checkpoint,200ms 内后续变更只重置定时器
|
||||
* - suppressUntil 机制:drawCheckpoint 后抑制 store watcher 50ms,避免重复
|
||||
* - drawToolActive 移出 watcher 列表(仅保存在快照中供 restore),
|
||||
* 单纯切换绘制工具不产生撤回步骤
|
||||
* - 撤回时 restore 恢复快照,isRestoring 标记防循环
|
||||
* - 最多保留 MAX_STEPS(50)步
|
||||
* - 快捷键 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 重新关联选中的锚点 feature(clearAllAnchors 后旧引用已失效,问题4修复)
|
||||
if (anchorActions.reselectSelectedAnchor) {
|
||||
anchorActions.reselectSelectedAnchor();
|
||||
}
|
||||
|
||||
// 同步选中锚点的覆盖样式回 OL feature
|
||||
if (anchorActions.syncSelectedOverrides) {
|
||||
anchorActions.syncSelectedOverrides();
|
||||
}
|
||||
|
||||
// 将保留的锚点名称同步回 feature(restoreAnchorsFromSave 的 _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 处理器中调用,在修改 store(updateSelectedDrawInfo)之前设置抑制窗口,
|
||||
* 阻止 store watcher 提前触发 checkpoint 产生重复撤回步骤。
|
||||
* 与 setSuppressWatcher 的区别:不涉及 cooldownTimer,watcher 回调解耦前直接返回,
|
||||
* 不会设置 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
|
||||
};
|
||||
}
|
||||
268
frontend/src/modules/MapStudio/index.vue
Normal file
268
frontend/src/modules/MapStudio/index.vue
Normal 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>
|
||||
263
frontend/src/modules/MapStudio/stores/mapStudioStore.ts
Normal file
263
frontend/src/modules/MapStudio/stores/mapStudioStore.ts
Normal 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
|
||||
};
|
||||
});
|
||||
191
frontend/src/modules/MapStudio/styles/index.scss
Normal file
191
frontend/src/modules/MapStudio/styles/index.scss
Normal 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;
|
||||
}
|
||||
359
frontend/src/modules/MapStudio/types/index.ts
Normal file
359
frontend/src/modules/MapStudio/types/index.ts
Normal 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'
|
||||
};
|
||||
7
frontend/src/views/zhiNengFenXi/zhiNengPeiTu/index.vue
Normal file
7
frontend/src/views/zhiNengFenXi/zhiNengPeiTu/index.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<MapStudio />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MapStudio from '@/modules/MapStudio/index.vue';
|
||||
</script>
|
||||
Loading…
Reference in New Issue
Block a user