瀏覽代碼

Merge branch 'dev' into test

lihaiting 9 小時之前
父節點
當前提交
2398ce7c3b

+ 91 - 0
src/api/ehs/mapImage.js

@@ -0,0 +1,91 @@
+import request from '@/utils/request';
+
+/**
+ * 分页查询地图底图
+ * GET /ehs/mapImage/page
+ * @param params { deptId, enabled, name, pageNum, size, sortName, orderBy }
+ */
+export async function getMapImagePage(params) {
+  const res = await request.get('/ehs/mapImage/page', { params });
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询地图底图详情
+ * GET /ehs/mapImage/getById/{id}
+ */
+export async function getMapImageById(id) {
+  const res = await request.get(`/ehs/mapImage/getById/${id}`);
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 查询启用的地图底图列表(作业票标记地图位置用)
+ * GET /ehs/mapImage/listEnabled
+ */
+export async function getEnabledMapImageList() {
+  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));
+}
+
+/**
+ * 新增地图底图
+ * 图片通过文档库(fileMain)上传后提交:
+ * imageUrl 图片访问地址、storePath 文件存储相对路径、
+ * fileId 文档库主键(后端只做关联,不参与业务)
+ * POST /ehs/mapImage/save
+ */
+export async function saveMapImage(data) {
+  const res = await request.post('/ehs/mapImage/save', data);
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 修改地图底图
+ * PUT /ehs/mapImage/update
+ */
+export async function updateMapImage(data) {
+  const res = await request.put('/ehs/mapImage/update', data);
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 启用/停用地图底图
+ * PUT /ehs/mapImage/changeEnabled?enabled=0|1&id=xxx
+ */
+export async function changeMapImageEnabled(id, enabled) {
+  const res = await request.put('/ehs/mapImage/changeEnabled', null, {
+    params: { id, enabled }
+  });
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}
+
+/**
+ * 删除地图底图(已被作业票标记使用时不允许删除,请改为停用)
+ * DELETE /ehs/mapImage/delete
+ */
+export async function deleteMapImage(ids) {
+  const res = await request.delete('/ehs/mapImage/delete', { data: ids });
+  if (res.data.code == 0) {
+    return res.data.data;
+  }
+  return Promise.reject(new Error(res.data.message));
+}

+ 17 - 0
src/utils/file.js

@@ -23,6 +23,23 @@ export function getImagePath(url) {
   return (match && match[1].split('&')[0]) || '';
 }
 
+// 图片访问地址,兼容完整地址、服务端相对地址和文件存储相对路径
+export function getFileAccessUrl(imageUrl, storePath) {
+  if (imageUrl) {
+    // 完整地址
+    if (/^(https?:)?\/\//i.test(imageUrl) || imageUrl.startsWith('data:')) {
+      return imageUrl;
+    }
+    // 服务端相对地址,如 /api/main/file/getFile?objectName=xxx
+    if (imageUrl.startsWith('/') && imageUrl.includes('?')) {
+      return `${window.location.origin}${imageUrl}`;
+    }
+    // 文件存储相对路径
+    return getImageUrl(imageUrl);
+  }
+  return storePath ? getImageUrl(storePath) : '';
+}
+
 // 下载方法
 export function download(data, name) {
   const a = document.createElement('a');

+ 568 - 0
src/views/ehs/mapImage/components/addOrEditDialog.vue

@@ -0,0 +1,568 @@
+<template>
+  <ele-modal
+    custom-class="ele-dialog-form long-dialog-form"
+    :centered="true"
+    :visible.sync="addOrEditDialogFlag"
+    :title="title"
+    :append-to-body="false"
+    :close-on-click-modal="false"
+    width="800px"
+    :before-close="cancel"
+    :maxable="true"
+    :resizable="true"
+  >
+    <el-form
+      ref="form"
+      :rules="rules"
+      class="el-form-box"
+      :model="form"
+      label-width="90px"
+      v-loading="loading"
+    >
+      <headerTitle title="基本信息"></headerTitle>
+      <el-row :gutter="20" style="margin-bottom: 20px">
+        <el-col :span="12">
+          <el-form-item label="图名称" prop="name">
+            <el-input
+              v-model="form.name"
+              placeholder="如:厂区俯瞰图、一号车间平面图"
+              maxlength="100"
+              show-word-limit
+            ></el-input>
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20" style="margin-bottom: 20px">
+        <el-col :span="12">
+          <el-form-item label="是否启用" prop="enabled">
+            <el-switch
+              v-model="form.enabled"
+              :active-value="1"
+              :inactive-value="0"
+              active-text="启用"
+              inactive-text="停用"
+            ></el-switch>
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20" style="margin-bottom: 20px">
+        <el-col :span="12">
+          <el-form-item label="排序号" prop="sortNo">
+            <el-input-number
+              v-model="form.sortNo"
+              :min="0"
+              :step="1"
+              controls-position="right"
+              style="width: 200px"
+            ></el-input-number>
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20" style="margin-bottom: 20px">
+        <el-col :span="24">
+          <el-form-item label="底图图片" prop="fileIds">
+            <fileMain
+              v-model="form.fileIds"
+              @updateVal="handleFileChange"
+            />
+            <div
+              v-if="dialogType === 'edit' && !presetDoc"
+              class="map-image-tip"
+            >
+              当前底图未匹配到文档库记录,如需替换请重新上传图片
+            </div>
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="24">
+          <el-form-item label="备注" prop="remark">
+            <el-input
+              v-model="form.remark"
+              type="textarea"
+              :rows="3"
+              maxlength="500"
+              show-word-limit
+              placeholder="请输入备注"
+            ></el-input>
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </el-form>
+    <div slot="footer">
+      <el-button type="primary" @click="handleSave" v-click-once>保存</el-button>
+      <el-button @click="cancel">返回</el-button>
+    </div>
+  </ele-modal>
+</template>
+
+<script>
+  import {
+    getMapImageById,
+    saveMapImage,
+    updateMapImage
+  } from '@/api/ehs/mapImage';
+  import {
+    fileGetByIdAPI,
+    filePageAPI,
+    queryIds
+  } from '@/components/addDoc/api/index.js';
+  import { getFileList } from '@/api/system/file/index.js';
+  import { getImageUrl } from '@/utils/file';
+
+  // 允许的底图图片格式
+  const IMAGE_EXTENSIONS = [
+    'jpg',
+    'jpeg',
+    'png',
+    'bmp',
+    'gif',
+    'webp',
+    'tif',
+    'tiff',
+    'svg'
+  ];
+
+  const defForm = {
+    id: null,
+    name: '',
+    imageUrl: '',
+    storePath: '',
+    fileId: null,
+    imageWidth: null,
+    imageHeight: null,
+    enabled: 1,
+    sortNo: 0,
+    remark: '',
+    // 文档库选择的文件id
+    fileIds: []
+  };
+
+  const defOriginImage = {
+    imageUrl: '',
+    storePath: '',
+    fileId: null,
+    imageWidth: null,
+    imageHeight: null
+  };
+
+  export default {
+    data() {
+      return {
+        addOrEditDialogFlag: false,
+        dialogType: '',
+        title: '',
+        loading: false,
+        form: JSON.parse(JSON.stringify(defForm)),
+        // 修改时的原始图片信息,选择新文件后取消可还原
+        originImage: JSON.parse(JSON.stringify(defOriginImage)),
+        // fileMain 对应的原文档id(fileMain 内部靠文档id反查文档列表)
+        presetFileId: null,
+        // 原文档记录
+        presetDoc: null,
+        // 本次选择的文件信息,用于校验图片类型
+        currentFile: null,
+        // 是否重新选择了底图
+        imageChanged: false
+      };
+    },
+    computed: {
+      /* 校验规则,新增时底图必填,修改时可不换图 */
+      rules() {
+        return {
+          name: { required: true, message: '请输入图名称', trigger: 'change' },
+          fileIds:
+            this.dialogType === 'add'
+              ? {
+                  required: true,
+                  type: 'array',
+                  message: '请选择底图图片',
+                  trigger: 'change'
+                }
+              : {}
+        };
+      }
+    },
+    methods: {
+      /* 初始化 */
+      async open(row = {}, type) {
+        this.addOrEditDialogFlag = true;
+        this.dialogType = type;
+        this.title = type === 'add' ? '新增地图底图' : '修改地图底图';
+        this.form = JSON.parse(JSON.stringify(defForm));
+        this.originImage = JSON.parse(JSON.stringify(defOriginImage));
+        this.presetFileId = null;
+        this.presetDoc = null;
+        this.currentFile = null;
+        this.imageChanged = false;
+        if (type === 'add') {
+          this.$nextTick(() => {
+            this.$refs.form?.clearValidate();
+          });
+        } else {
+          await this.getDetails(row.id);
+        }
+      },
+      /* 查询详情 */
+      async getDetails(id) {
+        this.loading = true;
+        try {
+          const res = await getMapImageById(id);
+          this.form = { ...this.form, ...res };
+          this.originImage = {
+            imageUrl: res.imageUrl,
+            storePath: res.storePath,
+            fileId: res.fileId,
+            imageWidth: res.imageWidth,
+            imageHeight: res.imageHeight
+          };
+          // 预置原图,fileMain 才能回显已有底图
+          await this.initPresetFile(res);
+        } finally {
+          this.loading = false;
+        }
+      },
+      /* 回显原图:查出原图对应的文档记录,供 fileMain 回显 */
+      async initPresetFile(res) {
+        let doc = null;
+        // 1. 先按 fileId 查文档库:fileMain 内部用的是同一个接口,能查到就能回显
+        doc = await this.queryDocById(res);
+        // 2. 兜底:按原始文件名反查文档库
+        if (!doc) {
+          doc = await this.findDocByFileName(res);
+        }
+        this.presetDoc = doc;
+        // 查不到文档记录时,用平台文件 id 占位,保证上传控件显示已选文件
+        this.presetFileId = doc ? doc.id : res.fileId || null;
+        this.form.fileIds = this.presetFileId ? [this.presetFileId] : [];
+        // 原图也参与图片类型校验
+        const file = doc?.storagePath?.[0] || null;
+        if (file) {
+          this.currentFile = file;
+        }
+      },
+      /* 按 fileId 查询文档库记录 */
+      async queryDocById(res) {
+        const fileId = res.fileId;
+        if (!fileId) return null;
+        try {
+          const list = (await queryIds({ ids: "'" + fileId + "'" })) || [];
+          if (!list.length) {
+            return null;
+          }
+          return this.pickDoc(list, res);
+        } catch (e) {
+          // 错误信息已由请求拦截器统一提示
+          return null;
+        }
+      },
+      /* 按原始文件名反查文档库中的文档记录 */
+      async findDocByFileName(res) {
+        const names = await this.buildFileNames(res);
+        for (const name of names) {
+          // isQueryAll:跳过权限限制,保证能找到自己上传的文档
+          const list = await this.queryDocPage({ keyword: name, isQueryAll: 1 });
+          const doc = this.pickDoc(list, res);
+          if (doc) {
+            return doc;
+          }
+        }
+        // 兜底:不带权限参数再试一次
+        if (names.length) {
+          const list = await this.queryDocPage({ keyword: names[0] });
+          const doc = this.pickDoc(list, res);
+          if (doc) {
+            return doc;
+          }
+        }
+        return null;
+      },
+      /* 从查询结果中挑出与当前底图匹配的那条文档 */
+      pickDoc(list, res) {
+        if (!list || !list.length) return null;
+        const fileId = res.fileId;
+        const storePath = res.storePath;
+        // 1. 存储路径一致:底图就是这份文档里的这个文件,最可靠
+        const byPath = list.find((item) =>
+          (item.storagePath || []).some((f) => f.storePath === storePath)
+        );
+        if (byPath) return byPath;
+        // 2. 文档里的附件就是底图文件(fileId 存的是平台文件 id 的历史数据)
+        const byFileId = list.find((item) =>
+          (item.storagePath || []).some(
+            (f) => fileId && String(f.id) === String(fileId)
+          )
+        );
+        return byFileId || null;
+      },
+      /* 分页查询文档库 */
+      async queryDocPage(query) {
+        try {
+          const res = await filePageAPI({ pageNum: 1, size: 100, ...query });
+          return res?.list || [];
+        } catch (e) {
+          return [];
+        }
+      },
+      /* 原始文件名候选:访问地址里的 fullfilename、平台文件记录里的文件名称、图名称 */
+      async buildFileNames(res) {
+        const list = [];
+        const push = (val) => {
+          if (val && !list.includes(val)) {
+            list.push(val);
+          }
+        };
+        const urlName = this.getFileNameFromUrl(res.imageUrl);
+        push(urlName);
+        if (urlName.includes('.')) {
+          push(urlName.replace(/\.[^/.]+$/, ''));
+        }
+        // 平台文件记录里带原始文件名,用它再匹配一次
+        const platformFile = await this.findPlatformFile(res);
+        if (platformFile?.name) {
+          push(platformFile.name);
+          if (platformFile.name.includes('.')) {
+            push(platformFile.name.replace(/\.[^/.]+$/, ''));
+          }
+        }
+        // 图名称一般取自文件名,也能用来匹配
+        push(res.name);
+        if (res.name && res.name.includes('.')) {
+          push(res.name.replace(/\.[^/.]+$/, ''));
+        }
+        return list;
+      },
+      /* 从图片访问地址里解析原始文件名 */
+      getFileNameFromUrl(url) {
+        if (!url) return '';
+        const match = url.match(/[?&]fullfilename=([^&]+)/i);
+        if (!match) return '';
+        try {
+          return decodeURIComponent(match[1]);
+        } catch (e) {
+          return match[1];
+        }
+      },
+      /* 查询平台文件记录:平台文件表没有按 id 查询的入参,只能列表后按 id/storePath 精确匹配 */
+      async findPlatformFile(res) {
+        const base = (res.storePath || '').split('/').filter(Boolean).pop() || '';
+        const queries = [];
+        if (base) {
+          queries.push({ name: base, module: 'main' });
+        }
+        if (res.name) {
+          queries.push({ name: res.name, module: 'main' });
+        }
+        if (res.createUserId) {
+          queries.push({ module: 'main', currentUserId: res.createUserId });
+        }
+        queries.push({ module: 'main' });
+        for (const query of queries) {
+          try {
+            const data = await getFileList({ pageNum: 1, size: 200, ...query });
+            const list = data?.list || [];
+            const hit =
+              list.find((item) => item.storePath === res.storePath) ||
+              list.find((item) => String(item.id) === String(res.fileId));
+            if (hit) {
+              return hit;
+            }
+          } catch (e) {
+            // 错误信息已由请求拦截器统一提示
+          }
+        }
+        return null;
+      },
+      /* 拼图片访问地址,带上原始文件名,便于修改时反查文档库 */
+      buildImageUrl(storePath, name) {
+        const url = getImageUrl(storePath);
+        if (!name || url.includes('fullfilename=')) {
+          return url;
+        }
+        return `${url}${
+          url.includes('?') ? '&' : '?'
+        }fullfilename=${encodeURIComponent(name)}`;
+      },
+      /* 选择图片后解析文件信息(url / storePath / id) */
+      async handleFileChange(val) {
+        this.$refs.form?.clearValidate('fileIds');
+        const ids = val || [];
+        if (!ids.length) {
+          // 未选择文件时还原原有图片
+          this.restoreOriginImage();
+          return;
+        }
+        const fileId = ids[ids.length - 1];
+        if (this.isOriginId(fileId)) {
+          // 没有更换图片,保持原有图片(恢复为原文档id,保证再次打开仍能回显)
+          this.form.fileIds = this.presetFileId ? [this.presetFileId] : [];
+          return;
+        }
+        try {
+          const doc = await fileGetByIdAPI(fileId);
+          const file = doc?.storagePath?.[0];
+          if (!file?.storePath) {
+            this.$message.error('未获取到图片文件信息');
+            return;
+          }
+          this.form.storePath = file.storePath;
+          // fileId 存文档库主键:后端只做关联不参与业务,
+          // 用文档主键才能通过 /fm/file/queryIds 原样回显底图
+          this.form.fileId = doc.id || fileId;
+          this.form.imageUrl = this.buildImageUrl(file.storePath, file.name);
+          this.currentFile = file;
+          this.imageChanged = true;
+          // 底图只能有一张,新选的图片替换掉原有图片
+          this.form.fileIds = ids.filter((id) => !this.isOriginId(id));
+          const size = await this.getImageSize(this.form.imageUrl);
+          this.form.imageWidth = size.width;
+          this.form.imageHeight = size.height;
+        } catch (e) {
+          // 错误信息已由请求拦截器统一提示
+        }
+      },
+      /* 是否是原有图片的文档id(fileMain 只输出文档id) */
+      isOriginId(id) {
+        return (
+          !!this.presetFileId && String(id) === String(this.presetFileId)
+        );
+      },
+      /* 是否为图片文件 */
+      isImageFile(file) {
+        if (!file) {
+          return false;
+        }
+        // 文件名与存储路径的扩展名都判断,兼容文件名不含后缀的情况
+        const names = [
+          file.name,
+          file.storePath ? file.storePath.split('/').pop() : ''
+        ];
+        for (const name of names) {
+          const ext = (name.split('.').pop() || '').toLowerCase();
+          if (IMAGE_EXTENSIONS.includes(ext)) {
+            return true;
+          }
+        }
+        const type = (file.type || '').toLowerCase();
+        return type.startsWith('image/');
+      },
+      /* 还原原有图片信息 */
+      restoreOriginImage() {
+        this.form.imageUrl = this.originImage.imageUrl;
+        this.form.storePath = this.originImage.storePath;
+        this.form.fileId = this.originImage.fileId;
+        this.form.imageWidth = this.originImage.imageWidth;
+        this.form.imageHeight = this.originImage.imageHeight;
+        this.form.fileIds = this.presetFileId ? [this.presetFileId] : [];
+        this.currentFile = this.presetDoc?.storagePath?.[0] || null;
+      },
+      /* 获取图片原始宽高(像素) */
+      getImageSize(url) {
+        return new Promise((resolve) => {
+          if (!url) {
+            resolve({ width: null, height: null });
+            return;
+          }
+          const img = new Image();
+          img.onload = () => {
+            resolve({ width: img.naturalWidth, height: img.naturalHeight });
+          };
+          img.onerror = () => {
+            resolve({ width: null, height: null });
+          };
+          img.src = url;
+        });
+      },
+      /* 保存 */
+      handleSave() {
+        // 底图只能有一张
+        if (this.form.fileIds.length > 1) {
+          return this.$message.error('地图底图只能选择一张图片');
+        }
+        if (!this.form.imageUrl) {
+          return this.$message.warning('请选择底图图片');
+        }
+        // 底图必须是图片类型,否则终止保存
+        if (this.currentFile && !this.isImageFile(this.currentFile)) {
+          this.$message.error(
+            '地图底图必须是图片类型,仅支持 jpg/png/bmp 等格式'
+          );
+          // 还原为原有图片,避免非法文件留在表单里
+          this.restoreOriginImage();
+          return;
+        }
+        this.$refs.form.validate(async (valid) => {
+          if (!valid) return this.$message.warning('有必填项未填,请检查');
+          // 未换图但已匹配到文档时,统一按文档主键提交:
+          // 历史数据保存一次后即可稳定回显
+          if (!this.imageChanged && this.presetDoc) {
+            this.form.fileId = this.presetDoc.id;
+            const file = this.presetDoc.storagePath?.[0];
+            if (file?.storePath) {
+              this.form.storePath = file.storePath;
+              this.form.imageUrl = this.buildImageUrl(file.storePath, file.name);
+            }
+          }
+          this.loading = true;
+          try {
+            const payload = {
+              name: this.form.name,
+              imageUrl: this.form.imageUrl,
+              storePath: this.form.storePath,
+              fileId: this.form.fileId,
+              imageWidth: this.form.imageWidth,
+              imageHeight: this.form.imageHeight,
+              enabled: this.form.enabled,
+              sortNo: this.form.sortNo,
+              remark: this.form.remark
+            };
+            if (this.dialogType === 'add') {
+              // 新增:图片已通过文档库上传,此处保存底图记录
+              await saveMapImage(payload);
+            } else {
+              // 修改
+              await updateMapImage({ id: this.form.id, ...payload });
+            }
+            this.$message.success(
+              this.dialogType === 'add' ? '新增成功' : '修改成功'
+            );
+            this.$emit('reload');
+            this.cancel();
+          } catch (e) {
+            // 错误信息已由请求拦截器统一提示
+          } finally {
+            this.loading = false;
+          }
+        });
+      },
+      /* 关闭弹窗 */
+      cancel() {
+        this.$refs.form?.resetFields();
+        this.form = JSON.parse(JSON.stringify(defForm));
+        this.originImage = JSON.parse(JSON.stringify(defOriginImage));
+        this.presetFileId = null;
+        this.presetDoc = null;
+        this.currentFile = null;
+        this.imageChanged = false;
+        this.addOrEditDialogFlag = false;
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  // 表单文案保持一行显示
+  :deep(.el-switch__label),
+  :deep(.el-form-item__label) {
+    white-space: nowrap;
+  }
+  .map-image-tip {
+    color: #909399;
+    font-size: 12px;
+    line-height: 20px;
+  }
+</style>

+ 42 - 0
src/views/ehs/mapImage/components/searchTable.vue

@@ -0,0 +1,42 @@
+<!-- 搜索表单 -->
+<template>
+  <seekPage :seekList="seekList" :maxLength="3" @search="search"></seekPage>
+</template>
+<script>
+  export default {
+    data() {
+      return {};
+    },
+    computed: {
+      // 搜索项配置
+      seekList() {
+        return [
+          {
+            label: '图名称:',
+            value: 'name',
+            type: 'input',
+            placeholder: '请输入'
+          },
+          {
+            label: '状态:',
+            value: 'enabled',
+            type: 'select',
+            placeholder: '请选择状态',
+            planList: [
+              { label: '启用', value: 1 },
+              { label: '停用', value: 0 }
+            ]
+          }
+        ];
+      }
+    },
+    methods: {
+      /* 搜索 */
+      search(e) {
+        this.$emit('search', {
+          ...e
+        });
+      }
+    }
+  };
+</script>

+ 289 - 0
src/views/ehs/mapImage/index.vue

@@ -0,0 +1,289 @@
+<template>
+  <div class="ele-body">
+    <el-card shadow="never">
+      <!-- 搜索表单 -->
+      <search-table @search="reload" />
+      <!-- 数据表格 -->
+      <ele-pro-table
+        ref="table"
+        :columns="columns"
+        :datasource="datasource"
+        :selection.sync="selection"
+        :page-size="20"
+        height="calc(100vh - 265px)"
+        full-height="calc(100vh - 116px)"
+        tool-class="ele-toolbar-form"
+      >
+        <!-- 表头工具栏 -->
+        <template v-slot:toolbar>
+          <el-button
+            size="small"
+            type="primary"
+            icon="el-icon-plus"
+            class="ele-btn-icon"
+            @click="handleAddOrEdit('', 'add')"
+          >
+            新建
+          </el-button>
+          <el-button
+            size="small"
+            type="danger"
+            icon="el-icon-delete"
+            class="ele-btn-icon"
+            @click="allDelBtn"
+            :disabled="selection?.length === 0"
+          >
+            批量删除
+          </el-button>
+        </template>
+        <!-- 底图 -->
+        <template v-slot:imageUrl="{ row }">
+          <el-image
+            v-if="getPreviewUrl(row)"
+            :src="getPreviewUrl(row)"
+            :preview-src-list="[getPreviewUrl(row)]"
+            fit="cover"
+            class="map-image-cell"
+          >
+            <div slot="error" class="map-image-cell-error">
+              <i class="el-icon-picture-outline"></i>
+            </div>
+          </el-image>
+          <span v-else>-</span>
+        </template>
+        <!-- 状态 -->
+        <template v-slot:enabled="{ row }">
+          <el-switch
+            v-model="row.enabled"
+            :active-value="1"
+            :inactive-value="0"
+            @change="handleChangeEnabled(row)"
+          />
+        </template>
+        <!-- 操作 -->
+        <template v-slot:action="{ row }">
+          <el-link
+            type="primary"
+            :underline="false"
+            icon="el-icon-edit"
+            @click="handleAddOrEdit(row, 'update')"
+          >
+            修改
+          </el-link>
+          <el-popconfirm
+            class="ele-action"
+            title="确定要删除此信息吗?"
+            @confirm="remove([row.id])"
+          >
+            <template v-slot:reference>
+              <el-link
+                type="danger"
+                :underline="false"
+                icon="el-icon-delete"
+              >
+                删除
+              </el-link>
+            </template>
+          </el-popconfirm>
+        </template>
+      </ele-pro-table>
+    </el-card>
+
+    <add-or-edit-dialog ref="addOrEditDialogRef" @reload="reload" />
+  </div>
+</template>
+
+<script>
+  import addOrEditDialog from './components/addOrEditDialog.vue';
+  import searchTable from './components/searchTable.vue';
+  import {
+    changeMapImageEnabled,
+    deleteMapImage,
+    getMapImagePage
+  } from '@/api/ehs/mapImage';
+  import { getFileAccessUrl } from '@/utils/file';
+
+  export default {
+    name: 'EhsMapImage',
+    components: {
+      addOrEditDialog,
+      searchTable
+    },
+    data() {
+      return {
+        // 表格选中数据
+        selection: []
+      };
+    },
+    computed: {
+      // 表格列配置
+      columns() {
+        return [
+          {
+            width: 45,
+            type: 'selection',
+            columnKey: 'selection',
+            align: 'center',
+            fixed: 'left'
+          },
+          {
+            width: 60,
+            label: '序号',
+            type: 'index',
+            columnKey: 'index',
+            align: 'center',
+            fixed: 'left'
+          },
+          {
+            prop: 'imageUrl',
+            label: '底图',
+            width: 120,
+            align: 'center',
+            slot: 'imageUrl',
+            showOverflowTooltip: true
+          },
+          {
+            minWidth: 200,
+            prop: 'name',
+            label: '图名称',
+            align: 'center',
+            showOverflowTooltip: true
+          },
+          {
+            width: 90,
+            prop: 'sortNo',
+            label: '排序号',
+            align: 'center'
+          },
+          {
+            width: 150,
+            prop: 'imageWidth',
+            label: '图片尺寸',
+            align: 'center',
+            formatter: (row) => {
+              return row.imageWidth
+                ? `${row.imageWidth} × ${row.imageHeight}`
+                : '-';
+            }
+          },
+          {
+            minWidth: 200,
+            prop: 'remark',
+            label: '备注',
+            align: 'center',
+            showOverflowTooltip: true
+          },
+          {
+            width: 170,
+            prop: 'createTime',
+            label: '创建时间',
+            align: 'center',
+            formatter: (_row, _column, cellValue) => {
+              return this.$util.toDateString(cellValue);
+            }
+          },
+          {
+            width: 100,
+            prop: 'enabled',
+            label: '状态',
+            align: 'center',
+            slot: 'enabled'
+          },
+          {
+            columnKey: 'action',
+            label: '操作',
+            width: 140,
+            align: 'center',
+            resizable: false,
+            slot: 'action',
+            showOverflowTooltip: true,
+            fixed: 'right'
+          }
+        ];
+      }
+    },
+    methods: {
+      /* 表格数据源 */
+      datasource({ page, limit, where, order }) {
+        const params = {
+          pageNum: page,
+          size: limit,
+          ...where
+        };
+        if (order?.sort) {
+          params.sortName = order.sort;
+          params.orderBy = order.order === 'desc' ? 'descending' : 'ascending';
+        }
+        return getMapImagePage(params);
+      },
+      /* 刷新表格 */
+      reload(where = {}) {
+        this.$refs.table.reload({ page: 1, where });
+      },
+      /* 新增、修改 */
+      handleAddOrEdit(row = {}, type) {
+        this.$refs.addOrEditDialogRef.open(row, type);
+      },
+      /* 底图预览地址 */
+      getPreviewUrl(row) {
+        return getFileAccessUrl(row?.imageUrl, row?.storePath);
+      },
+      /* 启用/停用 */
+      handleChangeEnabled(row) {
+        const enabled = row.enabled;
+        changeMapImageEnabled(row.id, enabled)
+          .then(() => {
+            this.$message.success(enabled === 1 ? '已启用' : '已停用');
+          })
+          .catch(() => {
+            // 接口失败时回滚开关状态
+            row.enabled = enabled === 1 ? 0 : 1;
+          });
+      },
+      /* 删除 */
+      remove(ids) {
+        deleteMapImage(ids).then(() => {
+          this.$message.success('删除成功!');
+          this.reload();
+        });
+      },
+      /* 批量删除 */
+      allDelBtn() {
+        if (this.selection.length === 0) return;
+        this.$confirm(
+          '确定要删除选中的地图底图吗?已被作业票标记使用的底图不允许删除,请改为停用。',
+          '提示',
+          {
+            confirmButtonText: '确定',
+            cancelButtonText: '取消',
+            type: 'warning'
+          }
+        )
+          .then(() => {
+            this.remove(this.selection.map((item) => item.id));
+          })
+          .catch(() => {});
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  .map-image-cell {
+    width: 90px;
+    height: 60px;
+    border-radius: 4px;
+    background: #fafafa;
+    display: block;
+    margin: 0 auto;
+  }
+
+  .map-image-cell-error {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 100%;
+    color: #c0c4cc;
+    font-size: 20px;
+  }
+</style>

+ 126 - 22
src/views/system/organization/components/org-user-edit.vue

@@ -46,10 +46,19 @@
               ></el-option>
             </el-select>
           </el-form-item>
-          <el-form-item label="主所属部门:" prop="groupId">
+          <!-- 所属机构仅作展示与级联数据源,不提交后端;新增取机构树一级节点(公司),编辑按详情 groupName 匹配回填 -->
+          <el-form-item label="所属机构:" prop="orgId">
             <org-select
               :data="institutionList"
-              placeholder="请选择所属机构"
+              :value="form.orgId"
+              disabled
+              placeholder="所属机构"
+            />
+          </el-form-item>
+          <el-form-item label="主所属部门:" prop="groupId">
+            <org-select
+              :data="mainDeptTree"
+              placeholder="请选择主所属部门"
               v-model="form.groupId"
               :disabled="!!form.loginName"
               @checkedKeys="checkedKeys"
@@ -59,7 +68,7 @@
           <el-form-item label="隶属部门:">
             <el-cascader
               class="ele-block"
-              :options="organization"
+              :options="affiDeptTree"
               placeholder="请选择隶属部门"
               :props="defaultProps"
               ref="deptRef"
@@ -1187,10 +1196,14 @@
         educationExperienceList: [],
         socialSecurityList: [],
         deptIds: [],
-        deptName: '',
+        // 主所属部门:提交时用 groupId/groupName 承载;
+        // 编辑回填时详情的 deptId/deptName 映射到这两个字段(详情的 groupId/groupName 是机构,只用于所属机构回显)
+        deptNames: '',
         isEnabled: 1,
         groupId: null,
         groupName: null,
+        // 所属机构(仅前端展示/级联用,不提交后端)
+        orgId: null,
         name: '',
         sex: null,
         email: '',
@@ -1217,7 +1230,10 @@
         // 表单验证规则
         rules: {
           groupId: [
-            { required: true, message: '请选择所属机构', trigger: 'blur' }
+            { required: true, message: '请选择主所属部门', trigger: 'blur' }
+          ],
+          orgId: [
+            { required: true, message: '所属机构不能为空', trigger: 'change' }
           ],
           name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
           sex: [{ required: true, message: '请选择性别', trigger: 'blur' }],
@@ -1352,8 +1368,18 @@
           this.form.postId = val;
         }
       },
-      organization() {
-        return this.setList(this.organizationList);
+      // 主所属部门可选项:所属机构节点(仅展示、置灰不可选)+ 其下部门(单选)
+      mainDeptTree() {
+        const node = this.findOrgNode(this.organizationList, this.form.orgId);
+        // 机构节点本身置灰,防止选到公司;历史数据主部门指向机构节点时也能回显名称
+        return node ? this.cloneOrgTree([node], true, this.form.orgId) : [];
+      },
+      // 隶属部门可选项:所属机构下的部门(多选,主所属部门节点禁选)
+      affiDeptTree() {
+        const node = this.findOrgNode(this.organizationList, this.form.orgId);
+        return node?.children
+          ? this.cloneOrgTree(node.children, true, this.form.groupId)
+          : [];
       },
       identityPhotosColumns() {
         return [
@@ -1823,14 +1849,51 @@
 
         this.professionList = res.list || [];
       },
-      setList(list) {
-        return list.map((val) => {
-          val['disabled'] = val.id == this.form.groupId;
-          if (val.children) {
-            val.children = this.setList(val.children);
+      /* 按 id 在机构树中查找节点 */
+      findOrgNode(nodes, id) {
+        for (const node of nodes || []) {
+          if (node.id == id) return node;
+          if (node.children) {
+            const found = this.findOrgNode(node.children, id);
+            if (found) return found;
           }
-          return val;
-        });
+        }
+        return null;
+      },
+      /* 按 id 在机构树中查找节点(返回根到目标的路径数组) */
+      findOrgNodePath(nodes, id, trail = []) {
+        for (const node of nodes || []) {
+          if (node.id == id) return [...trail, node];
+          if (node.children) {
+            const found = this.findOrgNodePath(node.children, id, [
+              ...trail,
+              node
+            ]);
+            if (found) return found;
+          }
+        }
+        return null;
+      },
+      /* 按名称在机构树中查找节点 */
+      findOrgNodeByName(nodes, name) {
+        for (const node of nodes || []) {
+          if (node.name == name) return node;
+          if (node.children) {
+            const found = this.findOrgNodeByName(node.children, name);
+            if (found) return found;
+          }
+        }
+        return null;
+      },
+      /* 深拷贝机构子树;markDisabledId 对应节点置灰(隶属部门不可与主所属部门重复) */
+      cloneOrgTree(nodes, markDisabled, markDisabledId) {
+        return (nodes || []).map((node) => ({
+          ...node,
+          disabled: markDisabled ? node.id == markDisabledId : false,
+          children: node.children
+            ? this.cloneOrgTree(node.children, markDisabled, markDisabledId)
+            : undefined
+        }));
       },
       addIdentityPhotos() {
         this.form.identityPhotos.push({
@@ -1946,8 +2009,17 @@
           // }
           let formData = JSON.parse(JSON.stringify(this.form));
           // 以下字段接口已废弃,提交非空数据会被后端拒绝,改由对应业务模块维护
-          ['skills', 'trainRecords', 'attendanceRecords', 'contracts'].forEach(
-            (key) => {
+          // orgId 为前端展示/级联用的所属机构,不提交后端;
+          // deptId/deptName 是详情返回的原始字段,提交时主所属部门由 groupId/groupName 承载,一并删除
+          [
+            'skills',
+            'trainRecords',
+            'attendanceRecords',
+            'contracts',
+            'orgId',
+            'deptId',
+            'deptName'
+          ].forEach((key) => {
               delete formData[key];
             }
           );
@@ -2007,19 +2079,20 @@
       },
       handleChange() {
         let arr = Array.from(this.$refs.deptRef.getCheckedNodes());
-        this.form.deptName = arr.map((i) => i.label).join('/') || '';
+        this.form.deptNames = arr.map((i) => i.label).join('/') || '';
         this.form.deptIds = this.form.deptIds || [];
       },
-      checkedKeys(value) {
+      checkedKeys() {
+        // 主所属部门名称(提交字段即 groupName)
         this.getLabelName(this.institutionList, (i) => {
           if (i.id == this.form.groupId) {
             return (this.form.groupName = i.name);
           }
         });
-        this.form.deptIds = this.form.deptIds.filter(
+        // 隶属部门里去掉与主所属部门重复的
+        this.form.deptIds = (this.form.deptIds || []).filter(
           (item) => item != this.form.groupId
         );
-        console.log(this.form.groupName);
       },
       /* 搜索直属上级 */
       async searchManager(keyword) {
@@ -2068,7 +2141,32 @@
               listKeys.forEach((key) => {
                 listDefaults[key] = Array.isArray(res[key]) ? res[key] : [];
               });
-              this.form = { ...res, ...listDefaults };
+              // 编辑回填:详情的 groupId/groupName 是机构,只用于所属机构(orgId)回显,不进提交字段;
+              // 提交时 groupId/groupName 的含义是主所属部门,故用详情的 deptId/deptName 映射过来
+              const orgNode = this.findOrgNodeByName(
+                this.organizationList,
+                res.groupName
+              );
+              this.form = {
+                ...res,
+                ...listDefaults,
+                orgId: orgNode ? orgNode.id : null,
+                groupId: res.deptId ?? null,
+                groupName: res.deptName ?? null
+              };
+              if (!orgNode) {
+                this.$message.error(
+                  res.groupName
+                    ? `所属机构「${res.groupName}」在组织架构中未匹配到,请后端补充`
+                    : '所属机构字段接口未返回,请后端补充'
+                );
+              }
+              // 隶属部门 ids:详情若返回逗号分隔字符串则拆成数组,供级联多选回显
+              this.form.deptIds = Array.isArray(res.deptIds)
+                ? res.deptIds
+                : res.deptIds != null && res.deptIds !== ''
+                ? String(res.deptIds).split(',')
+                : [];
               // 教育经历:历史数据只存在主表的毕业院校/学历/专业/毕业时间,
               // 明细为空时用主表字段回填一行,避免打开编辑看不到已有学历信息
               if (!this.form.educationExperienceList.length) {
@@ -2131,7 +2229,13 @@
             // console.log('this.form',this.form)
           } else {
             this.city = null;
-            this.form.groupId = this.organizationId;
+            // 新增:所属机构(orgId)取机构树的一级节点(公司),
+            // 即左树选中节点向上追溯到顶,不是点到哪就显示哪;仅展示/级联用,不提交
+            const orgPath = this.findOrgNodePath(
+              this.organizationList,
+              this.organizationId
+            );
+            this.form.orgId = orgPath ? orgPath[0].id : null;
             this.isUpdate = false;
           }
         } else {

+ 8 - 0
src/views/system/organization/components/org-user-list.vue

@@ -289,7 +289,15 @@
             minWidth: 110
           },
           {
+            // 所属机构(groupName)
             prop: 'groupName',
+            label: '所属机构',
+            showOverflowTooltip: true,
+            minWidth: 110
+          },
+          {
+            // 详情/列表口径一致:groupName 是机构,主所属部门名称在 deptName
+            prop: 'deptName',
             label: '主所属部门',
             showOverflowTooltip: true,
             minWidth: 110