Browse Source

feat:添加标记地图位置功能

xieyong 1 day ago
parent
commit
484a271053

+ 37 - 0
src/api/dangerousWork/index.js

@@ -232,3 +232,40 @@ export async function ticketRemove(ids) {
   }
   return Promise.reject(new Error(res.data.message));
 }
+
+/**
+ * 保存/清除作业票的地图位置标记
+ *
+ * 后端契约(PUT /ehs/hazardousWorkTicket/mapPoint):
+ *  - 请求体为 { ticketId, mapPoint }
+ *  - mapPoint 是整段 map_point 字段内容,由前端自行组织,后端原样存储
+ *  - mapPoint 传空(null 或空字符串)表示清除标记
+ *  - 一张票只有一个标记,重复提交覆盖(每次提交的都是完整标记集合)
+ *
+ * mapPoint 结构(前端自定义):
+ * {
+ *   markers: [
+ *     {
+ *       mapImageId,    底图 id
+ *       xRate, yRate,  标记位置,相对底图原图宽高的比例(0~1)
+ *       mapImageName,  底图名称
+ *       imageUrl,      底图图片地址
+ *       imageWidth,    底图原始宽度(像素)
+ *       imageHeight,   底图原始高度(像素)
+ *     }
+ *   ]
+ * }
+ * 一张作业票可在多张底图上分别打点,每个底图一个标记点,互不影响。
+ *
+ * @param data { ticketId: 作业票ID, mapPoint: 标记内容,传 null 表示清除 }
+ */
+export async function ticketMapPoint(data) {
+  const res = await request.put('/ehs/hazardousWorkTicket/mapPoint', {
+    ticketId: data?.ticketId,
+    mapPoint: data?.mapPoint ?? null
+  });
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}

+ 30 - 0
src/api/mapImage/index.js

@@ -0,0 +1,30 @@
+import request from '@/utils/request';
+
+/**
+ * 地图底图管理
+ * 接口文档:http://192.168.1.102:18086/doc.html
+ *  - 地图底图:/ehs/mapImage
+ *
+ * 说明:地图底图由「统一上传接口」管理文件,底图记录只保存文件 id、
+ * 存储路径与图片访问地址,这里只做查询,不涉及文件上传。
+ */
+
+/**
+ * 查询启用的地图底图列表
+ * 用途:作业票标记地图位置时提供可选底图
+ * @returns 地图底图数组
+ *  - id          底图主键 id
+ *  - name        底图名称
+ *  - imageUrl    图片访问地址
+ *  - storePath   文件存储相对路径(imageUrl 为空时兜底使用)
+ *  - imageWidth  图片原始宽度(像素)
+ *  - imageHeight 图片原始高度(像素)
+ *  - sortNo      排序号
+ */
+export async function listEnabled() {
+  const res = await request.get('/ehs/mapImage/listEnabled');
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}

+ 103 - 15
src/views/DangerousWorkList/add.vue

@@ -130,6 +130,25 @@
         </el-col>
       </el-row>
 
+      <!-- 作业位置标记(每张底图可分别打点,由后端 map_point 字段单独保存) -->
+      <el-row :gutter="20">
+        <el-col :span="24">
+          <el-form-item label="作业位置标记:">
+            <div class="map-marker-field">
+              <map-marker-preview :markers="mapMarkers" />
+              <el-button
+                v-if="!isView"
+                size="mini"
+                icon="el-icon-edit"
+                @click="openMapMarker"
+              >
+                {{ mapMarkers.length ? '重新标记' : '标记位置' }}
+              </el-button>
+            </div>
+          </el-form-item>
+        </el-col>
+      </el-row>
+
       <el-form-item label="作业票模板:">
         <el-input
           v-model="templateName"
@@ -220,6 +239,7 @@
       <el-button @click="cancel">{{ isView ? '关闭' : '取消' }}</el-button>
     </template>
     <releaseRules ref="releaseRulesRef" @success="onReleaseRulesSuccess" />
+    <map-marker-dialog ref="mapMarkerRef" @confirm="onMapMarkerConfirm" />
   </ele-modal>
 </template>
 
@@ -230,6 +250,8 @@
   import personSelect from '@/components/CommomSelect/person-select.vue';
   import experimentationProcessNew from '@/components/releaseRules/experimentationProcessNew.vue';
   import workTicketForm from './components/workTicketForm.vue';
+  import mapMarkerDialog from './components/mapMarkerDialog.vue';
+  import mapMarkerPreview from './components/mapMarkerPreview.vue';
   import areaMixin from '@/mixins/areaMixin';
   import dictMixins from '@/mixins/dictMixins';
   import dictEnum from '@/enum/dict';
@@ -246,13 +268,15 @@
     getProcessInstanceId
   } from './constants';
   import { buildWorkTypeOptions, resolveWorkTypeLabel } from './workTypes';
+  import { parseExtension, parseMapMarkers, buildMapPoint } from './mapMarker';
   import bpmDetail from '@/views/bpm/processInstance/detail.vue';
   import {
     ticketSave,
     ticketUpdate,
     ticketGetById,
     ticketSubmit,
-    ticketListByWorkId
+    ticketListByWorkId,
+    ticketMapPoint
   } from '@/api/dangerousWork';
   import { listOrganizations, getUserPage } from '@/api/system/organization';
 
@@ -286,6 +310,8 @@
       releaseRules,
       experimentationProcessNew,
       workTicketForm,
+      mapMarkerDialog,
+      mapMarkerPreview,
       bpmDetail
     },
     mixins: [areaMixin, dictMixins],
@@ -310,6 +336,10 @@
         frontendTemplate: null,
         // 未匹配到前端模板时的原始作业票内容(降级展示)
         rawExtension: null,
+        // 作业位置标记(由后端 map_point 字段单独保存)
+        mapMarkers: [],
+        // 作业票未保存时暂存的地图位置标记,保存后补存
+        pendingMarkers: null,
         // 选中的作业票模板(发布规则)
         templateData: {},
         templateName: '',
@@ -618,6 +648,8 @@
         this.templateName = '';
         this.frontendTemplate = null;
         this.rawExtension = null;
+        this.mapMarkers = [];
+        this.pendingMarkers = null;
         this.$refs.experimentationProcessRef?.init('[]');
       },
       // 用作业票详情回填表单
@@ -646,8 +678,12 @@
             getProcessInstanceId(detail) || getProcessInstanceId(row)
         };
         // 解析已保存的作业票内容
-        const extension = this.parseExtension(detail.extensionJson);
+        const extension = parseExtension(detail.extensionJson);
         this.form.remark = extension.remark || '';
+        // 回显作业位置标记(每张底图一个标记点)
+        this.mapMarkers = parseMapMarkers(
+          detail?.mapPoint ?? detail?.map_point
+        );
         // 按作业类型匹配前端内置模板,匹配到则结构化回显
         const option = this.workTypeList.find(
           (item) => String(item.value) === String(detail.workType)
@@ -682,22 +718,52 @@
           this.$refs.form && this.$refs.form.clearValidate();
         });
       },
-      // 解析后端返回的 extensionJson(字符串)
-      parseExtension(extensionJson) {
-        if (!extensionJson) {
-          return {};
+      // 打开地图位置标记弹框
+      openMapMarker() {
+        this.$refs.mapMarkerRef.open(this.mapMarkers);
+      },
+      // 地图位置标记确认:调用后端专用接口保存(整段覆盖)
+      async onMapMarkerConfirm(markers) {
+        const list = markers || [];
+        this.mapMarkers = list;
+        // 作业票还没保存时没有 id,先暂存,等作业票保存后补存
+        if (!this.form.id) {
+          this.pendingMarkers = list;
+          this.$message.success(
+            list.length
+              ? '地图位置已标记,保存作业票后生效'
+              : '已清除地图位置标记'
+          );
+          return;
         }
-        if (typeof extensionJson === 'object') {
-          return { ...extensionJson };
+        try {
+          await ticketMapPoint({
+            ticketId: this.form.id,
+            // 没有标记时传 null,后端按空值清除标记
+            mapPoint: buildMapPoint(list)
+          });
+          this.$message.success(
+            list.length ? '地图位置标记成功' : '已清除地图位置标记'
+          );
+        } catch (e) {
+          // 失败提示由响应拦截器统一处理
+        }
+      },
+      // 保存暂存的地图位置标记:新增作业票保存后补存(编辑态标记已即时保存)
+      // 标记保存失败不影响作业票本身的保存/提交,失败提示由响应拦截器统一处理
+      // @param ticketId 作业票 id
+      async flushPendingMarkers(ticketId) {
+        if (!ticketId || !this.pendingMarkers) {
+          return;
         }
         try {
-          const data = JSON.parse(extensionJson);
-          return data && typeof data === 'object' && !Array.isArray(data)
-            ? data
-            : {};
+          await ticketMapPoint({
+            ticketId,
+            mapPoint: buildMapPoint(this.pendingMarkers)
+          });
+          this.pendingMarkers = null;
         } catch (e) {
-          console.error('解析作业票内容失败', e);
-          return {};
+          // 忽略:作业票已保存,标记可稍后重新标记
         }
       },
       cancel() {
@@ -707,6 +773,8 @@
         this.templateName = '';
         this.frontendTemplate = null;
         this.rawExtension = null;
+        this.mapMarkers = [];
+        this.pendingMarkers = null;
         this.detailLoading = false;
         this.submitLoading = false;
         this.newTicketId = '';
@@ -807,7 +875,18 @@
             ? ticketUpdate(params)
             : ticketSave(params);
           request
-            .then(() => {
+            .then(async (res) => {
+              // 新增作业票:保存后补存暂存的地图位置标记
+              if (this.pendingMarkers && !this.form.id) {
+                const ticketId = await this.resolveSavedTicketId(res, params);
+                if (ticketId) {
+                  await this.flushPendingMarkers(ticketId);
+                } else {
+                  this.$message.warning(
+                    '作业票已保存,但未获取到作业票ID,地图位置标记未保存,请在作业票列表中重新标记'
+                  );
+                }
+              }
               this.$message.success(this.isEdit ? '保存成功' : '申请成功');
               this.cancel();
               this.$emit('reload');
@@ -883,6 +962,8 @@
               return;
             }
             this.newTicketId = ticketId;
+            // 新增作业票:补存暂存的地图位置标记
+            await this.flushPendingMarkers(ticketId);
           }
           await ticketSubmit({ id: ticketId, processDefinitionKey });
           this.$message.success('提交成功,已发起审批');
@@ -955,6 +1036,13 @@
     line-height: 20px;
   }
 
+  /* 作业位置标记:缩略图与按钮并排 */
+  .map-marker-field {
+    display: flex;
+    align-items: flex-start;
+    gap: 12px;
+  }
+
   /* 未匹配到前端模板时的原始作业票内容 */
   .raw-extension {
     width: 100%;

+ 414 - 0
src/views/DangerousWorkList/components/mapMarkerDialog.vue

@@ -0,0 +1,414 @@
+<!--
+  标记地图位置弹框(作业票管理用)
+  - 底图来源:地图底图接口 /ehs/mapImage/listEnabled,仅返回启用的底图
+  - 交互:选择底图 -> 在底图上点击放置标记点(再次点击可重新标记)
+  - 每张底图各自独立打点:切换底图不会清除其它底图上的标记
+  - 坐标:标记点按「原图宽高比例」记录(xRate / yRate 取值 0~1),
+          并附带底图 id、图片原始尺寸,保证底图在不同展示尺寸下标记位置一致
+  - 确认后通过 confirm 事件回传全部标记(数组);一张底图都没有标记时回传 null
+-->
+<template>
+  <ele-modal
+    title="标记地图位置"
+    :visible.sync="visible"
+    width="70vw"
+    append-to-body
+    :close-on-click-modal="false"
+    :maxable="true"
+    @close="handleClose"
+  >
+    <div v-loading="loading" class="map-marker-box">
+      <!-- 底图选择 -->
+      <div class="map-toolbar">
+        <span class="map-toolbar-label">地图底图:</span>
+        <el-select
+          v-model="currentMapId"
+          placeholder="请选择底图"
+          style="width: 260px"
+          :disabled="!mapList.length"
+          @change="onMapChange"
+        >
+          <el-option
+            v-for="item in mapList"
+            :key="item.id"
+            :label="item.name || `底图${item.id}`"
+            :value="item.id"
+          >
+            <span>{{ item.name || `底图${item.id}` }}</span>
+            <span v-if="currentMarkers[item.id]" class="map-option-tag">
+              已标记
+            </span>
+          </el-option>
+        </el-select>
+        <span class="map-tip">
+          在底图上点击即可标记作业位置,再次点击可重新标记;每张底图可分别打点
+        </span>
+        <span v-if="markedCount" class="map-count"
+          >已标记 {{ markedCount }} 张底图</span
+        >
+      </div>
+
+      <!-- 底图与标记点 -->
+      <div class="map-stage">
+        <div v-if="!loading && !mapList.length" class="map-empty">
+          暂无启用的地图底图,请先在「地图底图」中配置并启用底图
+        </div>
+        <div
+          v-else
+          class="map-image-wrap"
+          :style="imageBoxStyle"
+          @click="onImageClick"
+        >
+          <img
+            ref="mapImage"
+            :src="currentImageUrl"
+            class="map-image"
+            alt="地图底图"
+            @load="onImageLoad"
+            @error="onImageError"
+          />
+          <div v-if="currentMarker" class="map-pin" :style="pinStyle">
+            <i class="el-icon-location"></i>
+          </div>
+        </div>
+        <div v-if="imageError" class="map-error">
+          底图图片加载失败,请检查图片地址
+        </div>
+      </div>
+
+      <!-- 标记信息 -->
+      <div class="map-info">
+        <template v-if="currentMarker">
+          <span>当前底图:{{ currentMapName || '-' }}</span>
+          <span v-if="pixelText">
+            原图坐标:X {{ pixelText.x }} / Y {{ pixelText.y }}
+          </span>
+          <span>
+            比例坐标:X {{ currentMarker.xRate }} / Y {{ currentMarker.yRate }}
+          </span>
+        </template>
+        <span v-else class="map-info-empty">
+          当前底图尚未标记,请点击底图选择作业位置
+        </span>
+      </div>
+    </div>
+
+    <template v-slot:footer>
+      <el-button size="small" :disabled="!currentMarker" @click="clearMarker">
+        清除当前标记
+      </el-button>
+      <el-button size="small" @click="handleClose">取消</el-button>
+      <el-button size="small" type="primary" @click="confirm">确定</el-button>
+    </template>
+  </ele-modal>
+</template>
+
+<script>
+  import { listEnabled } from '@/api/mapImage';
+  import { resolveMapImageUrl } from '../mapMarker';
+
+  export default {
+    name: 'MapMarkerDialog',
+    props: {
+      // 已标记的底图列表,用于回显与修改
+      // 结构:[{ mapImageId, xRate, yRate, mapImageName, imageUrl, imageWidth, imageHeight }]
+      markers: {
+        type: Array,
+        default: () => []
+      }
+    },
+    data() {
+      return {
+        visible: false,
+        loading: false,
+        // 启用的地图底图列表
+        mapList: [],
+        // 当前选中的底图 id
+        currentMapId: '',
+        // 各底图的标记 { [mapImageId]: 标记 },每张底图互不影响
+        currentMarkers: {},
+        // 底图实际渲染尺寸(像素),用于把标记点定位到图片上
+        imageBox: { width: 0, height: 0 },
+        // 底图图片加载失败
+        imageError: false
+      };
+    },
+    computed: {
+      // 当前底图
+      currentMap() {
+        return (
+          this.mapList.find((item) => item.id === this.currentMapId) || null
+        );
+      },
+      // 当前底图上的标记
+      currentMarker() {
+        return this.currentMarkers[this.currentMapId] || null;
+      },
+      // 当前底图名称
+      currentMapName() {
+        return this.currentMap?.name || this.currentMarker?.mapImageName || '';
+      },
+      // 已标记的底图数量
+      markedCount() {
+        return Object.keys(this.currentMarkers).length;
+      },
+      // 当前底图的图片地址
+      currentImageUrl() {
+        const map = this.currentMap;
+        if (!map) {
+          return '';
+        }
+        return resolveMapImageUrl(map.imageUrl || map.storePath || '');
+      },
+      // 图片容器的尺寸:与底图渲染尺寸保持一致,标记点才能准确对齐
+      imageBoxStyle() {
+        if (!this.imageBox.width) {
+          return {};
+        }
+        return {
+          width: this.imageBox.width + 'px',
+          height: this.imageBox.height + 'px'
+        };
+      },
+      // 标记点位置(相对图片容器的百分比)
+      pinStyle() {
+        if (!this.currentMarker) {
+          return {};
+        }
+        return {
+          left: this.currentMarker.xRate * 100 + '%',
+          top: this.currentMarker.yRate * 100 + '%'
+        };
+      },
+      // 原图坐标(像素),底图未提供原始尺寸时不展示
+      pixelText() {
+        const map = this.currentMap || {};
+        const width = Number(map.imageWidth) || 0;
+        const height = Number(map.imageHeight) || 0;
+        if (!this.currentMarker || !width || !height) {
+          return '';
+        }
+        return {
+          x: Math.round(this.currentMarker.xRate * width),
+          y: Math.round(this.currentMarker.yRate * height)
+        };
+      }
+    },
+    mounted() {
+      // 窗口尺寸变化时底图渲染尺寸会变,需要重新测量,保证标记点不错位
+      window.addEventListener('resize', this.measureImage);
+    },
+    beforeDestroy() {
+      window.removeEventListener('resize', this.measureImage);
+    },
+    methods: {
+      // 打开弹框
+      // @param markers 已标记的底图列表(可选),用于回显与修改
+      open(markers) {
+        this.visible = true;
+        this.imageError = false;
+        this.imageBox = { width: 0, height: 0 };
+        this.currentMarkers = this.toMarkerMap(markers);
+        this.loadMapList();
+      },
+      // 标记数组转成按底图 id 索引的对象
+      toMarkerMap(markers) {
+        const map = {};
+        (markers || []).forEach((item) => {
+          if (
+            !item ||
+            item.mapImageId === undefined ||
+            item.mapImageId === null
+          ) {
+            return;
+          }
+          map[item.mapImageId] = { ...item };
+        });
+        return map;
+      },
+      // 查询启用的地图底图
+      async loadMapList() {
+        this.loading = true;
+        try {
+          const list = await listEnabled();
+          this.mapList = [...(list || [])].sort(
+            (a, b) => Number(a.sortNo || 0) - Number(b.sortNo || 0)
+          );
+          // 优先选中已有标记对应的底图,没有则默认第一张
+          const markedIds = Object.keys(this.currentMarkers);
+          const matched = this.mapList.find((item) =>
+            markedIds.includes(String(item.id))
+          );
+          this.currentMapId = matched
+            ? matched.id
+            : this.mapList.length
+            ? this.mapList[0].id
+            : '';
+        } catch (e) {
+          this.mapList = [];
+          this.currentMapId = '';
+        } finally {
+          this.loading = false;
+          this.$nextTick(this.measureImage);
+        }
+      },
+      // 切换底图:各底图的标记相互独立,切换时不清除已有标记
+      onMapChange() {
+        this.imageError = false;
+        this.imageBox = { width: 0, height: 0 };
+        this.$nextTick(this.measureImage);
+      },
+      // 图片加载完成后测量渲染尺寸
+      onImageLoad() {
+        this.imageError = false;
+        this.$nextTick(this.measureImage);
+      },
+      // 图片加载失败
+      onImageError() {
+        this.imageError = true;
+      },
+      // 测量底图实际渲染尺寸
+      measureImage() {
+        const img = this.$refs.mapImage;
+        if (!img) {
+          return;
+        }
+        const rect = img.getBoundingClientRect();
+        if (!rect.width || !rect.height) {
+          return;
+        }
+        this.imageBox = { width: rect.width, height: rect.height };
+      },
+      // 在底图上点击放置标记点(只影响当前底图)
+      onImageClick(event) {
+        const img = this.$refs.mapImage;
+        const map = this.currentMap;
+        if (!img || !map) {
+          return;
+        }
+        const rect = img.getBoundingClientRect();
+        if (!rect.width || !rect.height) {
+          return;
+        }
+        const xRate = (event.clientX - rect.left) / rect.width;
+        const yRate = (event.clientY - rect.top) / rect.height;
+        if (xRate < 0 || xRate > 1 || yRate < 0 || yRate > 1) {
+          return;
+        }
+        this.$set(this.currentMarkers, map.id, {
+          mapImageId: map.id,
+          xRate: Number(xRate.toFixed(6)),
+          yRate: Number(yRate.toFixed(6)),
+          mapImageName: map.name || '',
+          imageUrl: map.imageUrl || map.storePath || '',
+          imageWidth: Number(map.imageWidth) || 0,
+          imageHeight: Number(map.imageHeight) || 0
+        });
+      },
+      // 清除当前底图的标记
+      clearMarker() {
+        if (!this.currentMapId) {
+          return;
+        }
+        this.$delete(this.currentMarkers, this.currentMapId);
+      },
+      // 确认标记:回传全部底图的标记
+      confirm() {
+        const list = Object.values(this.currentMarkers);
+        this.$emit('confirm', list.length ? list : null);
+        this.visible = false;
+      },
+      // 关闭弹框
+      handleClose() {
+        this.visible = false;
+        this.$emit('close');
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .map-marker-box {
+    min-height: 320px;
+  }
+  .map-toolbar {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    margin-bottom: 10px;
+    flex-wrap: wrap;
+  }
+  .map-toolbar-label {
+    font-size: 13px;
+    color: #606266;
+  }
+  .map-tip {
+    font-size: 12px;
+    color: #909399;
+  }
+  .map-count {
+    font-size: 12px;
+    color: #67c23a;
+  }
+  /* 下拉项上的「已标记」标记 */
+  .map-option-tag {
+    float: right;
+    color: #67c23a;
+    font-size: 12px;
+  }
+  /* 底图展示区:超出时纵向滚动 */
+  .map-stage {
+    position: relative;
+    max-height: 60vh;
+    overflow: auto;
+    padding: 8px;
+    border: 1px solid #ebeef5;
+    border-radius: 4px;
+    background: #f6f7f9;
+    text-align: center;
+  }
+  .map-image-wrap {
+    position: relative;
+    display: inline-block;
+    line-height: 0;
+    cursor: crosshair;
+  }
+  .map-image {
+    display: block;
+    max-width: 100%;
+    max-height: 58vh;
+  }
+  /* 标记点:锚点在标记位置的正下方 */
+  .map-pin {
+    position: absolute;
+    transform: translate(-50%, -100%);
+    color: #f56c6c;
+    font-size: 26px;
+    line-height: 1;
+    pointer-events: none;
+    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35));
+  }
+  .map-empty,
+  .map-error {
+    padding: 60px 0;
+    font-size: 13px;
+    color: #909399;
+    text-align: center;
+  }
+  .map-error {
+    padding: 10px 0;
+    color: #f56c6c;
+  }
+  .map-info {
+    display: flex;
+    align-items: center;
+    gap: 20px;
+    margin-top: 10px;
+    font-size: 13px;
+    color: #606266;
+    flex-wrap: wrap;
+  }
+  .map-info-empty {
+    color: #909399;
+  }
+</style>

+ 150 - 0
src/views/DangerousWorkList/components/mapMarkerPreview.vue

@@ -0,0 +1,150 @@
+<!--
+  作业票「地图位置标记」展示组件
+  - 按底图逐个展示缩略图,并在各自的标记位置绘制标记点
+  - 每张底图的缩略图尺寸按原始宽高比换算,保证标记点与底图严格对齐
+-->
+<template>
+  <div class="map-marker-preview">
+    <div v-if="!markers.length" class="map-preview-empty">未标记地图位置</div>
+    <div
+      v-for="item in markers"
+      :key="item.mapImageId"
+      class="map-preview-item"
+    >
+      <div class="map-preview-box" :style="getBoxStyle(item)">
+        <img
+          :src="resolveUrl(item.imageUrl)"
+          class="map-preview-image"
+          alt="地图底图"
+          @load="onImageLoad(item, $event)"
+          @error="onImageError(item)"
+        />
+        <div
+          v-if="!errors[item.mapImageId]"
+          class="map-pin"
+          :style="getPinStyle(item)"
+        >
+          <i class="el-icon-location"></i>
+        </div>
+      </div>
+      <div class="map-preview-name">
+        {{ item.mapImageName || `底图${item.mapImageId}` }}
+        <span v-if="errors[item.mapImageId]" class="map-preview-error">
+          (图片加载失败)
+        </span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+  import { resolveMapImageUrl } from '../mapMarker';
+
+  export default {
+    name: 'MapMarkerPreview',
+    props: {
+      // 标记列表 [{ mapImageId, xRate, yRate, mapImageName, imageUrl, ... }]
+      markers: {
+        type: Array,
+        default: () => []
+      },
+      // 缩略图展示宽度(像素)
+      width: {
+        type: Number,
+        default: 240
+      }
+    },
+    data() {
+      return {
+        // 各底图原始尺寸 { [mapImageId]: { width, height } }
+        naturalSizes: {},
+        // 图片加载失败的底图 id
+        errors: {}
+      };
+    },
+    watch: {
+      markers() {
+        this.naturalSizes = {};
+        this.errors = {};
+      }
+    },
+    methods: {
+      // 解析底图图片地址
+      resolveUrl(path) {
+        return resolveMapImageUrl(path);
+      },
+      // 展示容器尺寸:高度按底图宽高比换算,图片铺满容器,标记点位置才不会偏移
+      getBoxStyle(item) {
+        const style = { width: this.width + 'px' };
+        const size = this.naturalSizes[item.mapImageId];
+        if (size?.width && size?.height) {
+          style.height =
+            Math.round((this.width * size.height) / size.width) + 'px';
+        }
+        return style;
+      },
+      // 标记点位置(相对容器的百分比)
+      getPinStyle(item) {
+        return {
+          left: item.xRate * 100 + '%',
+          top: item.yRate * 100 + '%'
+        };
+      },
+      // 图片加载完成后读取原始尺寸
+      onImageLoad(item, event) {
+        this.$set(this.errors, item.mapImageId, false);
+        this.$set(this.naturalSizes, item.mapImageId, {
+          width: event.target.naturalWidth || 0,
+          height: event.target.naturalHeight || 0
+        });
+      },
+      // 图片加载失败
+      onImageError(item) {
+        this.$set(this.errors, item.mapImageId, true);
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .map-marker-preview {
+    display: flex;
+    gap: 16px;
+    flex-wrap: wrap;
+  }
+  .map-preview-empty {
+    font-size: 13px;
+    color: #c0c4cc;
+    line-height: 20px;
+  }
+  .map-preview-box {
+    position: relative;
+    overflow: hidden;
+    border: 1px solid #ebeef5;
+    border-radius: 4px;
+    background: #f6f7f9;
+  }
+  .map-preview-image {
+    display: block;
+    width: 100%;
+    height: 100%;
+  }
+  /* 标记点:锚点在标记位置的正下方 */
+  .map-pin {
+    position: absolute;
+    transform: translate(-50%, -100%);
+    color: #f56c6c;
+    font-size: 20px;
+    line-height: 1;
+    pointer-events: none;
+    filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35));
+  }
+  .map-preview-name {
+    margin-top: 6px;
+    font-size: 12px;
+    color: #909399;
+  }
+  .map-preview-error {
+    color: #f56c6c;
+  }
+</style>

+ 142 - 0
src/views/DangerousWorkList/mapMarker.js

@@ -0,0 +1,142 @@
+/**
+ * 作业票「地图位置标记」数据处理
+ *
+ * 标记结果由后端专用接口保存(PUT /ehs/hazardousWorkTicket/mapPoint),
+ * 后端把 mapPoint 内容原样存到作业票的 map_point 字段,内容由前端组织:
+ * {
+ *   markers: [
+ *     {
+ *       mapImageId,    底图 id
+ *       xRate, yRate,  标记位置,相对底图原图宽高的比例(0~1)
+ *       mapImageName,  底图名称
+ *       imageUrl,      底图图片地址
+ *       imageWidth,    底图原始宽度(像素)
+ *       imageHeight,   底图原始高度(像素)
+ *     }
+ *   ]
+ * }
+ *
+ * 一张作业票可在多张底图上分别打点,每个底图一个标记点,互不影响。
+ *
+ * 使用比例坐标而非像素坐标:底图在不同位置(弹框大图、票面缩略图)展示时
+ * 尺寸不一致,按比例记录才能保证标记点始终落在同一位置。
+ */
+import { getImageUrl } from '@/utils/file';
+
+/**
+ * 解析后端返回的作业票内容(extensionJson 字符串)
+ * @param extensionJson 作业票内容,字符串或对象
+ * @returns 作业票内容对象
+ */
+export function parseExtension(extensionJson) {
+  if (!extensionJson) {
+    return {};
+  }
+  if (typeof extensionJson === 'object') {
+    return { ...extensionJson };
+  }
+  try {
+    const data = JSON.parse(extensionJson);
+    return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
+  } catch (e) {
+    console.error('解析作业票内容失败', e);
+    return {};
+  }
+}
+
+/**
+ * 解析作业票上的地图位置标记
+ *
+ * 兼容后端 map_point 字段的多种返回形式:
+ *  - { markers: [...] }(前端定义的标准结构)
+ *  - [...](直接是标记数组)
+ *  - { mapImageId, xRate, yRate }(单个标记对象)
+ *  - JSON 字符串(后端按字符串存储时)
+ *
+ * @param source 作业票详情中的 mapPoint 字段
+ * @returns 标记数组,无标记时返回空数组
+ */
+export function parseMapMarkers(source) {
+  let data = source;
+  if (typeof data === 'string') {
+    if (!data.trim()) {
+      return [];
+    }
+    try {
+      data = JSON.parse(data);
+    } catch (e) {
+      console.error('解析地图位置标记失败', e);
+      return [];
+    }
+  }
+  if (!data || typeof data !== 'object') {
+    return [];
+  }
+  // 标准结构:{ markers: [...] }
+  const list = Array.isArray(data) ? data : data.markers;
+  if (!Array.isArray(list)) {
+    // 单个标记对象
+    return data.mapImageId === undefined ? [] : [normalizeMarker(data)];
+  }
+  return list.map((item) => normalizeMarker(item)).filter((item) => !!item);
+}
+
+/**
+ * 规范化单个标记数据
+ * @param marker 原始标记
+ * @returns 规范化后的标记,缺少底图 id 或坐标时返回 null
+ */
+function normalizeMarker(marker) {
+  if (!marker || typeof marker !== 'object') {
+    return null;
+  }
+  const mapImageId = marker.mapImageId;
+  if (mapImageId === undefined || mapImageId === null || mapImageId === '') {
+    return null;
+  }
+  const xRate = Number(marker.xRate);
+  const yRate = Number(marker.yRate);
+  if (Number.isNaN(xRate) || Number.isNaN(yRate)) {
+    return null;
+  }
+  return {
+    mapImageId,
+    xRate,
+    yRate,
+    mapImageName: marker.mapImageName || '',
+    imageUrl: marker.imageUrl || '',
+    imageWidth: Number(marker.imageWidth) || 0,
+    imageHeight: Number(marker.imageHeight) || 0
+  };
+}
+
+/**
+ * 组装提交给后端的 mapPoint 内容
+ * @param markers 标记数组
+ * @returns mapPoint 内容;没有标记时返回 null(后端按空值清除标记)
+ */
+export function buildMapPoint(markers) {
+  const list = (markers || [])
+    .map((item) => normalizeMarker(item))
+    .filter(Boolean);
+  if (!list.length) {
+    return null;
+  }
+  return { markers: list };
+}
+
+/**
+ * 解析底图图片地址
+ * 绝对地址直接使用,相对路径按统一文件服务地址拼接
+ * @param path 图片地址或文件存储路径
+ */
+export function resolveMapImageUrl(path) {
+  const url = String(path || '').trim();
+  if (!url) {
+    return '';
+  }
+  if (/^(https?:)?\/\//i.test(url) || url.startsWith('data:')) {
+    return url;
+  }
+  return getImageUrl(url);
+}

+ 69 - 3
src/views/DangerousWorkList/workTicketDialog.vue

@@ -77,6 +77,13 @@
                   @click="handleEdit(row)"
                   >编辑</el-link
                 >
+                <el-link
+                  type="primary"
+                  :underline="false"
+                  :disabled="markingId === row.id"
+                  @click="handleMapMarker(row)"
+                  >标记地图位置</el-link
+                >
                 <el-link
                   v-if="canSubmit(row)"
                   type="success"
@@ -117,6 +124,13 @@
     >
       <flow v-if="flowVisible" :id="currentProcessInstanceId" />
     </ele-modal>
+
+    <!-- 标记地图位置(按作业票逐条标记,结果随作业票内容保存) -->
+    <map-marker-dialog
+      ref="mapMarkerRef"
+      @confirm="onMapMarkerConfirm"
+      @close="markerTarget = null"
+    />
   </div>
 </template>
 
@@ -126,11 +140,13 @@
   import dictEnum from '@/enum/dict';
   import add from './add.vue';
   import flow from '@/views/bpm/processInstance/detail.vue';
+  import mapMarkerDialog from './components/mapMarkerDialog.vue';
   import {
     ticketPage,
     ticketRemove,
     ticketSubmit,
-    ticketGetById
+    ticketGetById,
+    ticketMapPoint
   } from '@/api/dangerousWork';
   import {
     TICKET_STATUS,
@@ -143,13 +159,14 @@
     getProcessInstanceId
   } from './constants';
   import { buildWorkTypeOptions, resolveWorkTypeLabel } from './workTypes';
+  import { parseMapMarkers, buildMapPoint } from './mapMarker';
 
   // 仅待提交(草稿)、已驳回状态的作业票可以删除
   const DELETE_TIP = '仅待提交(草稿)或已驳回状态的作业票可以删除';
 
   export default {
     mixins: [tabMixins, dictMixins],
-    components: { add, flow },
+    components: { add, flow, mapMarkerDialog },
     data() {
       return {
         visible: false,
@@ -159,6 +176,10 @@
         selectedRows: [],
         // 正在提交审批的作业票 id
         submittingId: null,
+        // 正在打开地图位置标记弹框的作业票 id
+        markingId: null,
+        // 当前标记地图位置的作业票(确认后写回该票)
+        markerTarget: null,
         // 审批流程弹窗
         flowVisible: false,
         // 当前查看的流程实例 id
@@ -235,7 +256,7 @@
           {
             columnKey: 'action',
             label: '操作',
-            width: 220,
+            width: 280,
             align: 'center',
             slot: 'action'
           }
@@ -322,6 +343,51 @@
         }
         this.$refs.addRef.openTicket(row, this.work, 'edit');
       },
+      // 标记地图位置:按作业票逐条标记,每张底图可独立打点
+      // 不做状态限制:所有作业票都可以标记地图位置
+      async handleMapMarker(row) {
+        this.markingId = row.id;
+        let markers = [];
+        try {
+          // 列表行数据不含地图标记,已标记的位置需要查详情
+          const detail = await ticketGetById(row.id, {
+            showErrorToast: false
+          });
+          markers = parseMapMarkers(detail?.mapPoint ?? detail?.map_point);
+        } catch (e) {
+          this.$message.warning('查询作业票详情失败,将按未标记状态打开');
+        } finally {
+          this.markingId = null;
+        }
+        this.markerTarget = row;
+        this.$refs.mapMarkerRef.open(markers);
+      },
+      // 地图位置标记确认:调用后端专用接口保存(整段覆盖)
+      async onMapMarkerConfirm(markers) {
+        const row = this.markerTarget;
+        this.markerTarget = null;
+        if (!row) {
+          return;
+        }
+        this.markingId = row.id;
+        try {
+          await ticketMapPoint({
+            ticketId: row.id,
+            // 没有标记时传 null,后端按空值清除标记
+            mapPoint: buildMapPoint(markers)
+          });
+          this.$message.success(
+            markers && markers.length
+              ? '地图位置标记成功'
+              : '已清除地图位置标记'
+          );
+          this.reloadTable();
+        } catch (e) {
+          // 失败提示由响应拦截器统一处理
+        } finally {
+          this.markingId = null;
+        }
+      },
       // 提交作业票并发起审批流:审批流编码按作业类型取
       // 待提交(草稿)与已驳回状态都允许提交,已驳回的为重新提交
       handleSubmit(row) {