Forráskód Böngészése

feat: 小地图自定义模块升级

sunhongyao341504 2 éve
szülő
commit
4f08c66889

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 156 - 505
mock/login/info.ts


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 265 - 1063
mock/login/routers.ts


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 165 - 1055
pnpm-lock.yaml


+ 12 - 22
src/api/scene/scene.ts

@@ -133,16 +133,11 @@ interface LayoutResp {
 
 /** 查询地图布局 */
 export const getLayoutApi = (param: { workshopId: string; viewType: ViewType }) => {
-  return http.request<LayoutResp[]>(
-    {
-      url: `/api/layout/getWorkshopLayout?viewType=${param.viewType}&workshopId=${param.workshopId}`,
-      method: 'get',
-      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-    },
-    {
-      urlPrefix: 'temp',
-    },
-  );
+  return http.request<LayoutResp[]>({
+    url: `/layout/getWorkshopLayout?viewType=${param.viewType}&workshopId=${param.workshopId}`,
+    method: 'get',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+  });
 };
 /** 查询车间小地图布局 */
 export const getWorkshopMiniMapLayoutApi = (workshopId: string) => {
@@ -162,17 +157,12 @@ interface UpdateViewLayoutParam {
 
 /** 更新-新增小地图页面布局 */
 export const updateLayoutApi = (data: UpdateViewLayoutParam) => {
-  return http.request<LayoutResp[]>(
-    {
-      url: `/api/layout/updateViewLayout`,
-      method: 'post',
-      data: new URLSearchParams(data as any).toString(),
-      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
-    },
-    {
-      urlPrefix: 'temp',
-    },
-  );
+  return http.request<LayoutResp[]>({
+    url: `/layout/updateViewLayout`,
+    method: 'post',
+    data: new URLSearchParams(data as any).toString(),
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+  });
 };
 /** 更新-新增小地图页面布局 */
 export const updateMinMapViewLayoutApi = (
@@ -198,7 +188,7 @@ export type WorkSpaceCameraRelative = WorkSpaceInfoItem & { cameraList: CameraIt
 
 export const getCamerasByWorkSpace = (params: { workshopId: number }) => {
   return http.request<WorkSpaceCameraRelative[]>({
-    url: '/api/workshop/getWorkspaceCameraList',
+    url: '/workshop/getWorkspaceCameraList',
     method: 'get',
     params,
     headers: { 'Content-Type': 'application/x-www-form-urlencoded' },

BIN
src/assets/camera/cameraImg.png


+ 151 - 0
src/directives/moveable.ts

@@ -0,0 +1,151 @@
+import { ObjectDirective } from 'vue';
+
+export const bindMoveTool = (el: HTMLElement, binding) => {
+  const initScale = binding.value ? binding.value : 1;
+  const targetkey = binding.arg ? Number(binding.arg) : 0;
+  const transInfo = {
+    zoom: initScale,
+    toMove: false,
+    toScale: false,
+    transOriginX: 50,
+    transOriginY: 50,
+    translateX: 0,
+    translateY: 0,
+    distance: 1,
+    mouseInit: { x: 0, y: 0 },
+    curMousePos: { x: 0, y: 0 },
+  };
+
+  const setBgStyle = () => {
+    el.setAttribute(
+      'style',
+      `transform-origin: ${transInfo.transOriginX}% ${transInfo.transOriginY}%; transform: translate(${transInfo.translateX}px, ${transInfo.translateY}px)  scale(${transInfo.zoom})`,
+    );
+  };
+
+  const calcTransOrigin = (mouseX: number, mouseY: number) => {
+    const mapBgInfo = el.getBoundingClientRect();
+    if (transInfo.zoom !== initScale) {
+      transInfo.translateX += (1 - 1 / transInfo.zoom) * (mouseX - transInfo.mouseInit.x);
+      transInfo.translateY += (1 - 1 / transInfo.zoom) * (mouseY - transInfo.mouseInit.y);
+    }
+    transInfo.transOriginX = ((mouseX - mapBgInfo!.left!) / mapBgInfo!.width!) * 100;
+    transInfo.transOriginY = ((mouseY - mapBgInfo!.top!) / mapBgInfo!.height!) * 100;
+    transInfo.mouseInit = { x: mouseX, y: mouseY };
+  };
+
+  const handleScale = (e) => {
+    if (e.ctrlKey) {
+      e.preventDefault();
+      calcTransOrigin(e.clientX, e.clientY);
+      if (e.wheelDelta > 0) {
+        transInfo.zoom += 0.2;
+      } else if (e.wheelDelta < 0) {
+        transInfo.zoom -= 0.2;
+      }
+      setBgStyle();
+    }
+  };
+
+  const handleMouseDown = (e) => {
+    if (e.button === targetkey) {
+      transInfo.toMove = true;
+      transInfo.curMousePos = { x: e.clientX, y: e.clientY };
+    }
+  };
+
+  const handleMouseMove = (e) => {
+    if (transInfo.toMove) {
+      transInfo.translateX += e.clientX - transInfo.curMousePos.x;
+      transInfo.translateY += e.clientY - transInfo.curMousePos.y;
+      transInfo.mouseInit = {
+        x: transInfo.mouseInit.x + (e.clientX - transInfo.curMousePos.x),
+        y: transInfo.mouseInit.y + (e.clientY - transInfo.curMousePos.y),
+      };
+    }
+    transInfo.curMousePos = { x: e.clientX, y: e.clientY };
+    setBgStyle();
+  };
+
+  const handleMouseUp = () => {
+    transInfo.toMove = false;
+    transInfo.curMousePos = { x: 0, y: 0 };
+  };
+
+  // const calcMobileTransOrigin = (mouseX: number, mouseY: number) => {
+  //   const mapBgInfo = el.getBoundingClientRect();
+  //   if (transInfo.zoom !== initScale) {
+  //     transInfo.translateX += (1 - 1 / transInfo.zoom) * (mouseX - transInfo.mouseInit.x);
+  //     transInfo.translateY += (1 - 1 / transInfo.zoom) * (mouseY - transInfo.mouseInit.y);
+  //   }
+  //   transInfo.transOriginX = ((mouseX - mapBgInfo!.top!) / mapBgInfo!.height!) * 100;
+  //   transInfo.transOriginY = (1 - (mouseY - mapBgInfo!.left!) / mapBgInfo!.width!) * 100;
+  //   transInfo.mouseInit = { x: mouseX, y: mouseY };
+  // };
+
+  // const handleTouchStart = (e) => {
+  //   const touches = e.touches;
+  //   if (touches.length == 1) {
+  //     transInfo.toMove = true;
+  //     transInfo.toScale = false;
+  //     transInfo.curMousePos = { x: touches[0].pageY, y: touches[0].pageX };
+  //   } else if (touches.length == 2) {
+  //     transInfo.toMove = false;
+  //     transInfo.toScale = true;
+  //     // 计算中点
+  //     const midX = (touches[0].pageY + touches[1].pageY) / 2;
+  //     const midY = (touches[0].pageX + touches[1].pageX) / 2;
+  //     calcMobileTransOrigin(midX, midY);
+  //     // 计算距离
+  //     const disX = Math.abs(touches[0].pageY - touches[1].pageY);
+  //     const disY = Math.abs(touches[0].pageX - touches[1].pageX);
+  //     transInfo.distance = Math.sqrt(disX * disX + disY * disY);
+  //   }
+  // };
+
+  // const handleTouchMove = (e) => {
+  //   const touches = e.touches;
+  //   if (touches.length == 1 && transInfo.toMove) {
+  //     transInfo.toMove = true;
+  //     transInfo.toScale = false;
+  //     transInfo.translateX += touches[0].pageY - transInfo.curMousePos.x;
+  //     transInfo.translateY -= touches[0].pageX - transInfo.curMousePos.y;
+  //     transInfo.mouseInit = {
+  //       x: transInfo.mouseInit.x + (touches[0].pageY - transInfo.curMousePos.x),
+  //       y: transInfo.mouseInit.y - (touches[0].pageX - transInfo.curMousePos.y),
+  //     };
+  //     transInfo.curMousePos = { x: touches[0].pageY, y: touches[0].pageX };
+  //     setBgStyle();
+  //   } else if (touches.length == 2 && transInfo.toScale) {
+  //     e.preventDefault();
+  //     transInfo.toMove = false;
+  //     transInfo.toScale = true;
+  //     // 计算距离
+  //     const disX = Math.abs(touches[0].pageY - touches[1].pageY);
+  //     const disY = Math.abs(touches[0].pageX - touches[1].pageX);
+  //     const curDistance = Math.sqrt(disX * disX + disY * disY);
+  //     transInfo.zoom = transInfo.zoom * (curDistance / transInfo.distance);
+  //     setBgStyle();
+  //     transInfo.distance = curDistance;
+  //   }
+  // };
+
+  // const handleTouchEnd = () => {
+  //   transInfo.toMove = false;
+  //   transInfo.toScale = false;
+  //   transInfo.distance = 1;
+  //   transInfo.curMousePos = { x: 0, y: 0 };
+  // };
+
+  setBgStyle();
+  el.addEventListener('mousewheel', handleScale);
+  el.addEventListener('mousedown', handleMouseDown);
+  el.addEventListener('mousemove', handleMouseMove);
+  el.addEventListener('mouseup', handleMouseUp);
+};
+
+export const moveable: ObjectDirective = {
+  mounted(el: HTMLElement, binding) {
+    bindMoveTool(el, binding);
+  },
+};

+ 2 - 0
src/plugins/directives.ts

@@ -2,6 +2,7 @@ import { App } from 'vue';
 
 import { permission } from '@/directives/permission';
 import { scrollBar } from '@/directives/scrollBar';
+import { moveable } from '@/directives/moveable';
 
 /**
  * 注册全局自定义指令
@@ -10,4 +11,5 @@ import { scrollBar } from '@/directives/scrollBar';
 export function setupDirectives(app: App) {
   app.directive('permission', permission); // 权限控制指令(演示)
   app.directive('scrollBar', scrollBar); // 滚动条
+  app.directive('moveable', moveable); // 缩放拖动
 }

+ 1 - 1
src/views/cameras/preview/components/ToolbarIcon/ToolbarIcon.vue

@@ -4,7 +4,7 @@
 <script lang="ts" setup>
   const props = defineProps<{ active: boolean; src: string }>();
 </script>
-<style scoped>
+<style scoped lang="scss">
   .toolbarIcon {
     width: 24px;
     height: 24px;

+ 64 - 194
src/views/map-config/mini-map/MiniMapConfig.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="page">
+  <div class="page" @click="handleWholeClick">
     <div class="page-head flex items-center">
       <el-icon size="20"><ArrowLeft /></el-icon>
       <div class="head-opt flex-1 flex justify-between items-center">
@@ -17,6 +17,7 @@
           />
         </div>
         <div class="flex">
+          <el-button @click="mapEditor.toJson">tojson</el-button>
           <el-upload
             class="avatar-uploader flex justify-center items-center"
             action="/temp/api/layout/uploadPicture"
@@ -60,10 +61,10 @@
           <div
             v-for="item in filterShopCameraList"
             :key="item.code"
-            class="camera-item flex justify-start"
+            class="camera-item flex justify-start items-center"
             :class="{
-              isAdded: isAddedToMap(item.code),
-              isActive: item.code === selectedCamera?.cameraId,
+              isAdded: isAddedCamera(item.code),
+              isActive: item.code === activeCameraId,
             }"
             @click="handleAddCamera(item.code)"
           >
@@ -81,52 +82,21 @@
           </div>
         </el-scrollbar>
       </div>
-      <div class="draw-container">
-        <div style="overflow: auto">
-          <canvas width="400" height="400" id="mapEditCanvas"></canvas>
-        </div>
+      <div ref="drawContainer" id="drawContainer" class="draw-container">
+        <div id="editContainer" v-moveable:1></div>
         <el-upload
           v-if="!hasBg"
           class="upload-icon flex justify-center items-center"
-          action="/temp/api/layout/uploadPicture"
+          action="/api/layout/uploadPicture"
           :show-file-list="false"
+          :before-upload="handleBeforeUpload"
           :on-success="handleAvatarSuccess"
           :with-credentials="true"
           name="file"
-          :data="{ workshopId: selectedShopCode }"
+          :data="{ workshopId: selectedShopDetail?.id }"
         >
           <img src="~@/assets/images/img-upload.png" />
         </el-upload>
-        <div>
-          <div style="height: 20px; margin-bottom: 10px">
-            <div v-if="selectedCamera">
-              <SelectedCameraToolbar
-                @render-map="renderMap"
-                :selected-camera="selectedCamera!"
-                :key="selectedPositionHash"
-              />
-            </div>
-          </div>
-        </div>
-        <div style="overflow: auto">
-          <canvas width="400" height="400" id="mapEditCanvas"></canvas>
-        </div>
-        <div style="overflow: auto; position: relative">
-          <canvas
-            width="400"
-            height="400"
-            id="mapEditCanvas"
-            style="border: 1px solid #ccc"
-          ></canvas>
-          <ContextMenu
-            :visible="menuVisible"
-            :position="mousePosition"
-            :set-default="handleSetDefault"
-          />
-          <DefaultCameraIcon :position="favPosition" />
-        </div>
-
-        <CameraPreview :json="cameraJSON" />
       </div>
     </div>
   </div>
@@ -136,93 +106,43 @@
   import useMiniMap from './use-mini-map';
   import { storeToRefs } from 'pinia';
   import { ElMessage, ElInput } from 'element-plus';
-  import urlJoin from 'url-join';
   import { onMounted, ref } from 'vue';
-  import useCameraMap from './MapBase/useCameraMap';
   import { updateMinMapViewLayoutApi } from '@/api/scene/scene';
-  import { onUnmounted, watchEffect } from 'vue';
-  import { useGlobSetting } from '@/hooks/setting';
   import { computed } from 'vue';
-  import ContextMenu from './MapBase/ContextMenu.vue';
-  import DefaultCameraIcon from './MapBase/DefaultCameraIcon.vue';
-  import { CameraImage } from './MapBase/types';
-  import { createSelectedPositionHash, getFavPositionByCamera } from './MapBase/utils';
-  import SelectedCameraToolbar from './components/SelectedCameraToolbar.vue';
-  import CameraPreview from './MapBase/CameraPreview.vue';
   import { Search, Refresh } from '@element-plus/icons-vue';
+  import useMapEditor from './hooks/useMapEditor';
 
+  const mapEditor = useMapEditor();
+  const { activeCameraId, addedCameras, bgImgUrl } = mapEditor;
   const miniMap = useMiniMap();
-  const globSetting = useGlobSetting();
   const { scenesTree, shopCameraList, selectedShopCode, selectedShopDetail } = storeToRefs(miniMap);
   const { getScenesTree, getShowCameras, getMapLayout } = miniMap;
-  const selectedPositionHash = ref('');
-  const allObjects = ref<CameraImage[]>([]);
-
-  const onSelectCamera = () => {
-    menuVisible.value = false;
-  };
 
-  const handleRightClick = (e) => {
-    mousePosition.value = { left: e.pointer.x, top: e.pointer.y };
-    menuVisible.value = true;
-    rightSelectedCamera.value = e.target;
-  };
+  const drawContainer = ref<HTMLDivElement>();
 
-  const handleMoving = (e) => {
-    const target = e.transform.target;
-    selectedPositionHash.value = createSelectedPositionHash(target);
-    if (target?.cameraId === defaultCamera.value?.cameraId) {
-      favPosition.value = getFavPositionByCamera(target);
-    }
-  };
+  const searchKey = ref('');
+  // 是否已有背景图
+  const hasBg = ref(false);
 
-  const handleRoating = (e) => {
-    const target = e.transform.target;
-    selectedPositionHash.value = createSelectedPositionHash(target);
-    if (target.cameraId === defaultCamera.value?.cameraId) {
-      favPosition.value = getFavPositionByCamera(target);
+  const handleBeforeUpload = () => {
+    if (!selectedShopCode.value) {
+      ElMessage.error({
+        message: '请先选择车间',
+      });
+      return false;
     }
   };
 
-  const handleSetSelectedCameara = (target: CameraImage | null) => {
-    selectedCamera.value = target;
-  };
-
-  const handleObjectsAdded = () => {
-    allObjects.value = map.getObjects();
+  /** 判断相机是否已经添加 */
+  const isAddedCamera = (cameraId: string) => {
+    const index = addedCameras.value.findIndex((item) => item === cameraId);
+    return index >= 0;
   };
 
-  const map = useCameraMap({
-    onSelect: onSelectCamera,
-    onRightClick: handleRightClick,
-    onMoving: handleMoving,
-    onRotating: handleRoating,
-    setSelectedCamera: handleSetSelectedCameara,
-    onObjectsAdded: handleObjectsAdded,
-  });
-
-  const selectedCamera = ref<CameraImage | null>();
-  /** 右键选中的摄像机 */
-  const rightSelectedCamera = ref<CameraImage | null>(null);
-  const defaultCamera = ref<CameraImage | null>();
-  const searchKey = ref('');
-  // 是否已有背景图
-  const hasBg = ref(false);
-
-  const mousePosition = ref<{ left: number; top: number }>({ left: 0, top: 0 });
-  const menuVisible = ref(false);
-  const favPosition = ref<{ left: number; top: number } | null>(null);
-
   const handleAvatarSuccess = (e) => {
-    const imgPath = e.data;
-    const imgUrl = urlJoin(globSetting.imgUrl!, imgPath);
+    bgImgUrl.value = e.data;
+    mapEditor.addBg();
     hasBg.value = true;
-    map.uploadBg(imgUrl);
-  };
-
-  const renderMap = () => {
-    map.renderCamera();
-    favPosition.value = getFavPositionByCamera(defaultCamera.value);
   };
 
   const changeShop = (code: string) => {
@@ -230,52 +150,35 @@
     hasBg.value = false;
   };
 
-  const cameraJSON = ref();
+  const mapJSON = ref();
 
   const getShopContent = (code: string) => {
     getShowCameras(code);
     getMapLayout(code).then((res) => {
       if (!res) {
-        map.clear();
-        defaultCamera.value = null;
         return;
       }
-      cameraJSON.value = res;
-
-      map.loadFromJSON(res).then(() => {
-        console.log('loadFromJSON', res);
-        if (res.defaultCameraId) {
-          defaultCamera.value = map.getCameraById(res.defaultCameraId);
-          console.log('defaultCamera', defaultCamera.value);
-        } else {
-          defaultCamera.value = map.getObjects()?.[0] as CameraImage;
-        }
-        renderMap();
-      });
+      mapJSON.value = res;
+      console.log(mapJSON);
     });
   };
 
+  const handleWholeClick = (e) => {
+    if (e.button === 0) {
+      mapEditor.destoryOptBlock();
+    }
+  };
+
   onMounted(() => {
     getScenesTree({ level: 2, valueKey: 'code', labelKey: 'name', disabled: true });
+    mapEditor.initContainer({
+      container: 'editContainer',
+      width: drawContainer.value!.clientWidth,
+      height: drawContainer.value!.clientHeight,
+    });
     if (selectedShopCode.value) {
       getShopContent(selectedShopCode.value);
     }
-    map.createMap('mapEditCanvas');
-  });
-
-  const keyupListener = (e) => {
-    const keyCode = e.code;
-    if (keyCode === 'Delete') {
-      handleDeleteCamera();
-    }
-  };
-
-  onMounted(() => {
-    document.addEventListener('keyup', keyupListener);
-  });
-
-  onUnmounted(() => {
-    document.removeEventListener('keyup', keyupListener);
   });
 
   const filterShopCameraList = computed(() => {
@@ -284,32 +187,18 @@
     return shopCameraList.value.filter((x) => x.code?.includes(k) || x.workSpaceName?.includes(k));
   });
 
-  /** 摄像机是否已添加到底图 */
-  const isAddedToMap = (cameraId: string): boolean => {
-    return !!allObjects.value.find((item) => item.cameraId === cameraId);
-  };
-
   const handleAddCamera = (cameraId: string) => {
-    if (!hasBg.value) return;
-    if (map.hasCamera(cameraId)) {
-      ElMessage.warning({ message: '相机已添加' });
+    if (!hasBg.value) {
+      ElMessage.warning({
+        message: '请先添加背景图片',
+      });
       return;
     }
-    map.addCamera(cameraId).then((cameraImg) => {
-      if (!defaultCamera.value?.cameraId) {
-        defaultCamera.value = cameraImg;
-      }
-    });
+    mapEditor.addCamera(cameraId);
   };
 
   const handleSave = () => {
-    const json = map.toJSON();
-    console.log('save json', json);
-    if (!json?.backgroundImage) {
-      ElMessage.error('背景图片未添加');
-      return;
-    }
-    const layout = JSON.stringify({ ...json, defaultCameraId: defaultCamera.value?.cameraId });
+    const layout = mapEditor.toJson();
     updateMinMapViewLayoutApi({ layout, targetId: String(selectedShopDetail.value?.id) }).then(
       (res) => {
         console.log('updateMinMapViewLayoutApi', res);
@@ -317,35 +206,9 @@
       },
     );
   };
-
-  const handleDeleteCamera = () => {
-    if (!selectedCamera.value) return;
-    /** 如果删除的是默认选中的摄像头,那么先清空默认的摄像头再 */
-    if (selectedCamera.value?.cameraId === defaultCamera.value?.cameraId) {
-      defaultCamera.value = map.getObjects()?.[0];
-    }
-    map.removeActiveCamera();
-  };
-
-  const handleSetDefault = () => {
-    const cameraId = rightSelectedCamera.value?.cameraId;
-    if (!cameraId) return;
-    defaultCamera.value = rightSelectedCamera.value;
-
-    /** 选择完成后隐藏 */
-    menuVisible.value = false;
-  };
-
-  watchEffect(() => {
-    if (!defaultCamera.value) {
-      favPosition.value = null;
-      return;
-    }
-    favPosition.value = getFavPositionByCamera(defaultCamera.value);
-  });
 </script>
 
-<style scoped>
+<style scoped lang="scss">
   .page {
   }
   .page-head {
@@ -395,10 +258,23 @@
   .camera-item {
     height: 32px;
     font-size: 14px;
+    padding-left: 8px;
     font-weight: 400;
     color: #404040;
     line-height: 14px;
     cursor: pointer;
+
+    &:hover {
+      background-color: #e6f7ff;
+      color: #1890ff;
+    }
+  }
+  .isAdded {
+    color: #1890ff;
+  }
+  .isActive {
+    background-color: #e6f7ff;
+    color: #1890ff;
   }
   .camera-item-disabled {
     color: #c6c6c6;
@@ -417,6 +293,7 @@
     position: relative;
     width: calc(100% - 300px);
     margin: 20px;
+    overflow: hidden;
   }
 
   :deep(.search-put .el-input__wrapper) {
@@ -437,12 +314,5 @@
     text-align: center;
     font-weight: 400;
   }
-
-  .isAdded {
-    color: #409eff;
-  }
-  .isActive {
-    background-color: #eee;
-  }
 </style>
 ./MapBase/useCameraMap ./MapBase/CameraMapBak

+ 52 - 0
src/views/map-config/mini-map/components/CameraOptBar.vue

@@ -0,0 +1,52 @@
+<template>
+  <div class="opt-container">
+    <div class="opt-item" :class="{ disabled: props.disabled }" @click="setCamera">
+      <span>设为默认相机</span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  const props = defineProps({
+    disabled: { type: Boolean, default: () => true },
+    onSetDefault: { type: Function },
+  });
+
+  const setCamera = () => {
+    if (!props.disabled) {
+      props.onSetDefault!();
+    }
+  };
+</script>
+
+<style scoped lang="scss">
+  .opt-container {
+    width: 160px;
+    padding: 10px;
+    border-radius: 5px;
+    background-color: #ffffff;
+    box-shadow: 5px 5px 5px #a3a5a5;
+  }
+
+  .opt-item {
+    height: 30px;
+    font-size: 14px;
+    color: #404040;
+    display: flex;
+    justify-content: flex-start;
+    align-items: center;
+    padding-left: 8px;
+    border-radius: 3px;
+    cursor: pointer;
+
+    &:hover {
+      background-color: #f1f2f5;
+    }
+  }
+
+  .disabled {
+    background-color: #f1f2f5;
+    color: #bcbdc0;
+    cursor: not-allowed;
+  }
+</style>

+ 78 - 0
src/views/map-config/mini-map/components/DefaultTip.vue

@@ -0,0 +1,78 @@
+<template>
+  <div class="tip-content">
+    <span>默认摄像头</span>
+    <div v-if="props.position === TipPositionEnum.TOP" class="bottom-narrow"></div>
+    <div v-else-if="props.position === TipPositionEnum.BOTTOM" class="top-narrow"></div>
+    <div v-else-if="props.position === TipPositionEnum.RIGHT" class="left-narrow"></div>
+    <div v-else class="right-narrow"></div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { TipPositionEnum } from '../type';
+
+  const props = withDefaults(defineProps<{ position: TipPositionEnum }>(), {
+    position: TipPositionEnum.TOP,
+  });
+</script>
+
+<style scoped>
+  .tip-content {
+    position: relative;
+    width: 86px;
+    height: 35px;
+    background-color: #3c3c3d;
+    border-radius: 8px;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    font-size: 14px;
+    font-weight: 400;
+    color: #ffffff;
+    margin: 5px;
+  }
+
+  .bottom-narrow {
+    position: absolute;
+    top: 100%;
+    left: 50%;
+    border-top: 5px solid #3c3c3d;
+    border-right: 5px solid transparent;
+    border-bottom: 5px solid transparent;
+    border-left: 5px solid transparent;
+    transform: translateX(-50%);
+  }
+
+  .top-narrow {
+    position: absolute;
+    top: 0%;
+    left: 50%;
+    border-bottom: 5px solid #3c3c3d;
+    border-right: 5px solid transparent;
+    border-top: 5px solid transparent;
+    border-left: 5px solid transparent;
+    transform: translate(-50%, -100%);
+  }
+
+  .left-narrow {
+    position: absolute;
+    top: 50%;
+    left: 0%;
+    border-right: 5px solid #3c3c3d;
+    border-bottom: 5px solid transparent;
+    border-top: 5px solid transparent;
+    border-left: 5px solid transparent;
+    transform: translate(-100%, -50%);
+  }
+
+  .right-narrow {
+    position: absolute;
+    top: 50%;
+    right: 0%;
+    border-left: 5px solid #3c3c3d;
+    border-bottom: 5px solid transparent;
+    border-top: 5px solid transparent;
+    border-right: 5px solid transparent;
+    transform: translate(100%, -50%);
+  }
+</style>

+ 404 - 0
src/views/map-config/mini-map/hooks/useMapEditor.ts

@@ -0,0 +1,404 @@
+import { computed, h, onBeforeUnmount, onMounted, ref, render } from 'vue';
+import Konva from 'konva';
+import cameraImg from '@/assets/camera/cameraImg.png';
+import favoritesImg from '@/assets/camera/favorites.png';
+import OptBar from '../components/CameraOptBar.vue';
+import DefaultTip from '../components/DefaultTip.vue';
+import { TipPositionEnum } from '../type';
+import { ElMessage } from 'element-plus';
+import { useGlobSetting } from '@/hooks/setting';
+import urlJoin from 'url-join';
+
+export function useMapEditor() {
+  let initWidth; // 默认宽度
+  let initHeight; // 默认高度
+  let stage: Konva.Stage | null = null;
+  let layer: Konva.Layer | null = null;
+  let defaultIcon: Konva.Image | null = null; // 默认相机的图标shape
+  const addedCameras = ref<string[]>([]); // 已添加相机列表
+  const activeGroup = ref<Konva.Group | null>(null); // transformer激活的相机
+  const defaultCameraId = ref(''); // 默认相机的ID
+  let optBlock: HTMLDivElement | null = null; // 鼠标右击弹出的选项组
+  let defaultTip: HTMLDivElement | null = null; // 默认相机悬浮tip
+  let isTransform = false; // 是否再变换中
+  const activeCameraId = computed(() => activeGroup.value?.id()); // 当前选中相机ID
+  const bgImgUrl = ref<string>('');
+
+  const globSetting = useGlobSetting();
+
+  /** 容器初始化 */
+  const initContainer = (opt: Konva.StageConfig) => {
+    initWidth = opt.width || 0;
+    initHeight = opt.height || 0;
+    stage = new Konva.Stage(opt);
+    stage.on('click tap', handleStageClick);
+    layer = new Konva.Layer();
+    stage.add(layer);
+    addDefaultIcon();
+  };
+
+  /** 初始生成默认相机的图标shape,但不可见 */
+  const addDefaultIcon = () => {
+    const favImg = new Image();
+    favImg.onload = () => {
+      defaultIcon = new Konva.Image({
+        x: 18,
+        y: -16,
+        width: 16,
+        height: 16,
+        image: favImg,
+        id: 'defaultIcon',
+        visible: false,
+        rotation: 0,
+      });
+      bindBaseEvt(defaultIcon);
+      layer?.add(defaultIcon);
+      layer?.batchDraw();
+    };
+    favImg.src = favoritesImg;
+  };
+
+  /** 更换背景图时根据图片大小重置容器宽高 */
+  const resizeContainer = (width, height) => {
+    const newWidth = width > initWidth ? width : initWidth;
+    const newHeight = height > initHeight ? height : initHeight;
+    stage?.width(newWidth);
+    stage?.height(newHeight);
+  };
+
+  /** 添加背景 */
+  const addBg = () => {
+    const imgUrl = urlJoin(globSetting.imgUrl!, bgImgUrl.value);
+    const bgNode = layer?.find('#bgImg')[0] as Konva.Image;
+    const bgImg = new Image();
+    bgImg.onload = () => {
+      // 判断是否已有背景
+      if (!bgNode) {
+        const mapBg = new Konva.Image({
+          x: 0,
+          y: 0,
+          image: bgImg,
+          width: bgImg.width,
+          height: bgImg.height,
+          id: 'bgImg',
+        });
+        layer?.add(mapBg);
+        mapBg.moveToBottom();
+      } else {
+        bgNode.width(bgImg.width);
+        bgNode.height(bgImg.height);
+        bgNode.image(bgImg);
+      }
+      resizeContainer(bgImg.width, bgImg.height);
+      layer?.batchDraw();
+    };
+    bgImg.src = imgUrl;
+  };
+
+  /** 变更需要激活transform的相机 */
+  const attachTransformer = (group: Konva.Group): Konva.Transformer => {
+    activeGroup.value = group;
+    stage!.find('Transformer')[0]?.destroy(); // 清除现有transformer
+    const id = group.id();
+    const tr = new Konva.Transformer({
+      keepRatio: true,
+      rotateAnchorOffset: 30,
+      rotationSnaps: [0, 45, 90, 135, 180, 225, 270, 315],
+      enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
+      id: 'tr_' + id,
+    });
+    tr.nodes([group]);
+    layer?.add(tr);
+    layer?.draw();
+
+    group.on('dragstart', handleDragStart);
+    group.on('dragstart', handleDragEnd);
+
+    return tr;
+  };
+
+  /** 添加相机 */
+  const addCamera = (id: string) => {
+    const group = new Konva.Group({
+      x: 50,
+      y: 50,
+      id,
+      draggable: true,
+      name: 'group',
+    });
+    const camImg = new Image();
+    camImg.onload = () => {
+      const cameraIcon = new Konva.Image({
+        width: 52,
+        height: 37,
+        image: camImg,
+        name: 'image',
+      });
+      group.add(cameraIcon);
+      layer?.add(group);
+      bindBaseEvt(cameraIcon);
+      const tr = attachTransformer(group); // 添加的相机默认激活transformer
+
+      addedCameras.value.push(id);
+      // 如果是唯一相机,设置为默认相机
+      if (addedCameras.value.length === 1) {
+        defaultIcon?.show();
+        setDefaultCamera(group, tr);
+        tr.forceUpdate();
+      }
+    };
+    camImg.src = cameraImg;
+  };
+
+  /** 变更默认相机 */
+  const setDefaultCamera = (node: Konva.Group, tr?: Konva.Transformer) => {
+    defaultIcon?.moveTo(node);
+    tr?.forceUpdate();
+    defaultCameraId.value = node.id();
+  };
+
+  /** 创建右键选项组 */
+  const createOptBlock = (node: Konva.Group, x: number, y: number) => {
+    const id = node.id();
+    optBlock = document.createElement('div') as HTMLDivElement;
+    optBlock.setAttribute('style', `position: absolute; left: ${x}px; top: ${y}px;`);
+    const optBar = h(OptBar, {
+      disabled: id === defaultCameraId.value,
+      onSetDefault: () => {
+        const tr = layer?.find(`#tr_${id}`)[0] as Konva.Transformer;
+        setDefaultCamera(node, tr);
+        destoryOptBlock();
+      },
+    });
+    render(optBar, optBlock);
+    const parentEl = document.getElementById('drawContainer') as HTMLDivElement;
+    parentEl.append(optBlock);
+  };
+
+  /** 删除右键选项组 */
+  const destoryOptBlock = () => {
+    optBlock?.remove();
+    optBlock = null;
+  };
+
+  /** 创建默认tip */
+  const createDefaultTip = (x: number, y: number, pos: TipPositionEnum) => {
+    if (isTransform) {
+      return;
+    }
+    defaultTip = document.createElement('div') as HTMLDivElement;
+    defaultTip.setAttribute('style', `position: absolute; left: ${x}px; top: ${y}px;`);
+    const tipInstance = h(DefaultTip, { position: pos });
+    render(tipInstance, defaultTip);
+    const parentEl = document.getElementById('drawContainer') as HTMLDivElement;
+    parentEl.append(defaultTip);
+  };
+
+  /** 删除默认tip */
+  const destoryDefaultTip = () => {
+    defaultTip?.remove();
+    defaultTip = null;
+  };
+
+  /** 删除相机 */
+  const deleteCamera = () => {
+    // 判断是否为默认相机,默认相机不允许删除
+    if (activeGroup.value?.id() === defaultCameraId.value) {
+      ElMessage.error({
+        message: '无法删除默认相机',
+      });
+      return;
+    }
+    const index = addedCameras.value.findIndex((item) => item === activeGroup.value?.id());
+    index >= 0 && addedCameras.value.splice(index, 1);
+    activeGroup.value?.destroy();
+    stage!.find('Transformer')[0]?.destroy();
+    layer?.draw();
+  };
+
+  /** 鼠标悬浮事件 */
+  const handleMouseOver = (e) => {
+    // 禁用浏览器默认鼠标事件
+    document.oncontextmenu = () => {
+      return false;
+    };
+    const group = e.target.parent;
+    // 如果悬浮的相机是默认相机,弹出默认tip
+    if (group.id() === defaultCameraId.value) {
+      let pos = TipPositionEnum.TOP;
+      const tipPosition = defaultIcon?.absolutePosition();
+      let x = Number(tipPosition?.x.toFixed(2)) || 0;
+      let y = Number(tipPosition?.y.toFixed(2)) || 0;
+      const angle = group.rotation() >= 0 ? group.rotation() : group.rotation() + 360;
+      if (angle >= 30) {
+        if (angle <= 150) {
+          pos = TipPositionEnum.RIGHT;
+          x += 26;
+          y -= 17;
+        } else if (angle <= 210) {
+          pos = TipPositionEnum.BOTTOM;
+          y += 26;
+          x -= 50;
+        } else {
+          pos = TipPositionEnum.LEFT;
+          x -= 121;
+          y -= 25;
+        }
+      } else {
+        y -= 61;
+        x -= 43;
+      }
+      createDefaultTip(x, y, pos);
+    }
+  };
+
+  /** 鼠标离开事件 */
+  const handleMouseLeave = () => {
+    // 恢复浏览器默认事件
+    document.oncontextmenu = () => {
+      return true;
+    };
+    defaultTip && destoryDefaultTip();
+  };
+
+  /** 开始拖拽事件 */
+  const handleDragStart = () => {
+    isTransform = true;
+    destoryDefaultTip();
+    destoryOptBlock();
+  };
+
+  /** 结束拖拽事件 */
+  const handleDragEnd = () => {
+    isTransform = false;
+  };
+
+  /** 全局点击事件 */
+  const handleStageClick = (e) => {
+    // 点击舞台取消现有激活的transformer
+    if (e.target === stage) {
+      stage!.find('Transformer')[0].destroy();
+      layer!.draw();
+      return;
+    }
+
+    // 判断点击对象是否为相机
+    if (!e.target.hasName('image')) {
+      return;
+    }
+    const parent = e.target.parent;
+    if (!parent.hasName('group')) {
+      return;
+    }
+    const group = e.target.parent;
+    attachTransformer(group);
+    // 判断是否为右键点击
+    if (e.evt.button === 2) {
+      createOptBlock(group, e.evt.offsetX + 20, e.evt.offsetY);
+    }
+  };
+
+  /** 键盘点击事件 */
+  const handleKeyDown = (e) => {
+    // 删除键
+    if (e.keyCode === 46 || e.code === 'Delete') {
+      deleteCamera();
+    }
+  };
+
+  // 基础监听事件绑定
+  const bindBaseEvt = (node: Konva.Node) => {
+    // node.on('transform', handleDragStart);
+    // node.on('transformend', handleDragEnd);
+    node.on('mouseover', handleMouseOver);
+    node.on('mouseleave', handleMouseLeave);
+  };
+
+  /** 输出布局json */
+  const toJson = () => {
+    const json = stage!.toJSON();
+    const cameras = JSON.parse(json)
+      .children[0].children.filter((node) => node.className === 'Group')
+      .map((item) => {
+        return {
+          cameraId: item.attrs.id,
+          rotation: Number((item.attrs.rotation | 0).toFixed(2)),
+          x: Math.round(item.attrs.x | 0),
+          y: Math.round(item.attrs.y | 0),
+          scaleX: Number((item.attrs.scaleX | 1).toFixed(1)),
+          scaleY: Number((item.attrs.scaleY | 1).toFixed(1)),
+        };
+      });
+    const layout = {
+      bgImg: bgImgUrl.value,
+      defaultCameraId: defaultCameraId.value,
+      cameraList: cameras,
+    };
+
+    return JSON.stringify(layout);
+  };
+
+  /** 导入布局json */
+  const createMap = (json) => {
+    const layout = JSON.parse(json);
+    bgImgUrl.value = layout.bgImg;
+    addBg();
+    layout.cameraList.forEach((camera) => {
+      const group = new Konva.Group({
+        x: camera.x,
+        y: camera.y,
+        id: camera.cameraId,
+        rotation: camera.rotation,
+        scaleX: camera.scaleX,
+        scaleY: camera.scaleY,
+        draggable: true,
+        name: 'group',
+      });
+      const camImg = new Image();
+      camImg.onload = () => {
+        const cameraIcon = new Konva.Image({
+          width: 52,
+          height: 37,
+          image: camImg,
+          name: 'image',
+        });
+        group.add(cameraIcon);
+        layer?.add(group);
+        bindBaseEvt(cameraIcon);
+        addedCameras.value.push(camera.cameraId);
+
+        if (camera.cameraId === layout.defaultCameraId) {
+          setDefaultCamera(group);
+        }
+
+        if (addedCameras.value.length === layout.cameraList.length) {
+          layer?.batchDraw();
+        }
+      };
+      camImg.src = cameraImg;
+    });
+  };
+
+  onMounted(() => {
+    window.addEventListener('keydown', handleKeyDown);
+  });
+
+  onBeforeUnmount(() => {
+    window.removeEventListener('keydown', handleKeyDown);
+  });
+
+  return {
+    defaultCameraId,
+    activeCameraId,
+    addedCameras,
+    bgImgUrl,
+    initContainer,
+    addBg,
+    addCamera,
+    destoryOptBlock,
+    toJson,
+    createMap,
+  };
+}
+
+export default useMapEditor;

+ 6 - 0
src/views/map-config/mini-map/type.ts

@@ -0,0 +1,6 @@
+export enum TipPositionEnum {
+  TOP = 'top',
+  RIGHT = 'right',
+  BOTTOM = 'bottom',
+  LEFT = 'left',
+}

+ 2 - 7
src/views/system/role/columns.ts

@@ -1,5 +1,6 @@
 import { h } from 'vue';
 import { BasicColumn } from '@/components/Table';
+import { roleTypeNames } from './types';
 
 export const columns: BasicColumn[] = [
   {
@@ -10,17 +11,11 @@ export const columns: BasicColumn[] = [
     label: '角色类型',
     prop: 'role_type',
     render(record) {
-      let type = '管理员';
-      if (record.row.role_type === 1) {
-        type = '超级管理员';
-      } else if (record.row.role_type === 3) {
-        type = '用户';
-      }
       return h(
         'span',
         {},
         {
-          default: () => type,
+          default: () => roleTypeNames[record.row.role_type],
         },
       );
     },

+ 12 - 0
src/views/system/role/types/index.ts

@@ -16,3 +16,15 @@ export interface userFormParamsType {
   permissionList: { workshopCode: string; permissionId: any }[];
   permissionKeys?: number[];
 }
+
+export enum RoleTypeEnum {
+  SUPER = 1,
+  ADMIN = 2,
+  USER = 3,
+}
+
+export const roleTypeNames = {
+  [RoleTypeEnum.SUPER]: '超级管理员',
+  [RoleTypeEnum.ADMIN]: '管理员',
+  [RoleTypeEnum.USER]: '用户',
+};