Jelajahi Sumber

用vue-konva重构电子围栏编辑器

louhangfei 2 tahun lalu
induk
melakukan
5503321b22

+ 1 - 0
package.json

@@ -66,6 +66,7 @@
     "url-join": "5.0.0",
     "vue": "3.3.4",
     "vue-hooks-plus": "1.8.6",
+    "vue-konva": "3.0.2",
     "vue-router": "4.1.2",
     "vue-types": "4.1.1",
     "vuedraggable": "4.1.0",

+ 14 - 2
pnpm-lock.yaml

@@ -39,7 +39,7 @@ dependencies:
     specifier: 2.19.0
     version: 2.19.0
   canvg:
-    specifier: ^4.0.1
+    specifier: 4.0.1
     version: 4.0.1
   cropperjs:
     specifier: 1.5.12
@@ -75,7 +75,7 @@ dependencies:
     specifier: 1.1.0
     version: 1.1.0
   mpegts.js:
-    specifier: ^1.7.3
+    specifier: 1.7.3
     version: 1.7.3
   nprogress:
     specifier: 0.2.0
@@ -110,6 +110,9 @@ dependencies:
   vue-hooks-plus:
     specifier: 1.8.6
     version: 1.8.6(vue@3.3.4)
+  vue-konva:
+    specifier: 3.0.2
+    version: 3.0.2(konva@9.3.0)
   vue-router:
     specifier: 4.1.2
     version: 4.1.2(vue@3.3.4)
@@ -8267,6 +8270,15 @@ packages:
       vue: 3.3.4
     dev: false
 
+  /vue-konva@3.0.2(konva@9.3.0):
+    resolution: {integrity: sha512-FNWKtPPVDihNuQcq7F4GzuVPkPaEfg8lnWezpRqgiC2VetdvONOiYOFD800jNL5kt/lEYnSOYvhqqdTPbv2c8w==}
+    engines: {node: '>= 4.0.0', npm: '>= 3.0.0'}
+    peerDependencies:
+      konva: '>7'
+    dependencies:
+      konva: 9.3.0
+    dev: false
+
   /vue-router@4.1.2(vue@3.3.4):
     resolution: {integrity: sha512-5BP1qXFncVRwgV/XnqzsKApdMjQPqWIpoUBdL1ynz8HyLxIX/UDAx7Ql2BjmA5CXT/p61JfZvkpiFWFpaqcfag==}
     peerDependencies:

+ 2 - 0
src/main.ts

@@ -4,6 +4,7 @@ import './styles/index.scss';
 import 'element-plus/theme-chalk/display.css';
 import 'element-plus/theme-chalk/dark/css-vars.css';
 import 'nprogress/nprogress.css';
+import VueKonva from 'vue-konva';
 
 import { createApp } from 'vue';
 import App from './App.vue';
@@ -14,6 +15,7 @@ import { setupElement, setupDirectives, setupCustomComponents } from '@/plugins'
 
 async function bootstrap() {
   const app = createApp(App);
+  app.use(VueKonva);
 
   // 全局完整引入 element 组件
   setupElement(app);

+ 39 - 34
src/views/cameras/preview/components/CameraViewSetting/CameraViewSetting.vue

@@ -11,32 +11,38 @@
         :is-edit="isEdit"
       />
     </div>
-    <div class="cameraViewOverflow" :style="{ width: domWidth + 'px', height: domHeight + 'px' }">
-      <div
-        class="cameraViewSettingWrapper"
-        :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px', scale: scale }"
-      >
-        <FenceEditor ref="fenceEditorRef" />
-        <div class="cameraVideo">
-          <CameraLiveVideo />
-        </div>
+    <div
+      class="cameraViewSettingWrapper"
+      :style="{ width: domWidth + 'px', height: domHeight + 'px' }"
+    >
+      <div class="fenceEditorWrapper">
+        <FenceEditor
+          ref="fenceEditorRef"
+          :dom-width="domWidth"
+          :canvas-size="{ width: canvasWidth, height: canvasHeight }"
+          :line-points="fenceStore.serverFencePoints || []"
+        />
       </div>
-      <div
-        class="presetAddWrapper"
-        :class="{ hidePresetControlCls: isEdit }"
-        v-if="!!cameraDetailStore.detail?.isPtz"
-      >
-        <CameraDirectionControl />
-        <ElButton
-          type="primary"
-          @click="handleAddPreset"
-          size="small"
-          style="margin-top: 20px; width: 100px"
-          >添加预置位</ElButton
-        >
-        <AddPresetModal v-if="addPresetModalVisible" @close="handleClose" @ok="handleAddPresetOk" />
+
+      <div class="cameraVideo">
+        <CameraLiveVideo />
       </div>
     </div>
+    <div
+      class="presetAddWrapper"
+      :class="{ hidePresetControlCls: isEdit }"
+      v-if="!!cameraDetailStore.detail?.isPtz"
+    >
+      <CameraDirectionControl />
+      <ElButton
+        type="primary"
+        @click="handleAddPreset"
+        size="small"
+        style="margin-top: 20px; width: 100px"
+        >添加预置位</ElButton
+      >
+      <AddPresetModal v-if="addPresetModalVisible" @close="handleClose" @ok="handleAddPresetOk" />
+    </div>
   </div>
   <div class="cameraParamsSettingWrapper">
     <div class="cameraParamsSetting">
@@ -48,7 +54,7 @@
 <script lang="ts" setup>
   import { computed, ref, watchEffect } from 'vue';
   import FenceToolbar from '../FenceToolbar/FenceToolbar.vue';
-  import FenceEditor from '../FenceEditor/FenceEditor.vue';
+  import FenceEditor from '../FenceEditorV2/FenceEditor.vue';
   import CameraLiveVideo from '../CameraLiveVideo/CameraLiveVideo.vue';
   import ViewWindowSetting from '../ViewWindowSetting/ViewWindowSetting.vue';
   import PresetSelect from '../PresetSelect/PresetSelect.vue';
@@ -152,17 +158,10 @@
         fenceEditorRef.value?.clear();
         return;
       }
-      const rawLinePoints = points.map((x) => {
-        const points: number[] = [];
-        x.forEach((line) => {
-          points.push(line[0], line[1]);
-        });
-        return points;
-      });
-      if (!rawLinePoints) return;
+
       /** 先清空原有的 */
       fenceEditorRef.value?.clear();
-      fenceEditorRef.value?.createLines(rawLinePoints);
+      // fenceEditorRef.value?.createLines(rawLinePoints);
       fenceEditorRef.value?.setEditMode();
       isEdit.value = true;
       return;
@@ -181,7 +180,6 @@
   .cameraViewSettingWrapper {
     position: relative;
     border: 1px solid #ccc;
-    transform-origin: left top;
   }
   .cameraViewOverflow {
     overflow: hidden;
@@ -190,6 +188,9 @@
 
   .cameraVideo {
     position: absolute;
+    top: 0;
+    left: 0;
+    z-index: 8;
     background: #ccc;
     width: 100%;
     height: 100%;
@@ -230,4 +231,8 @@
   .hidePresetControlCls {
     display: none;
   }
+  .fenceEditorWrapper {
+    position: relative;
+    z-index: 9;
+  }
 </style>

+ 0 - 15
src/views/cameras/preview/components/FenceEditor/FenceEditor.vue

@@ -655,14 +655,6 @@
     return gropuPoints;
   };
 
-  const initStageByJSON = (param: { width: number; height: number }) => {
-    stage?.setAttrs({ width: param.width, height: param.height });
-  };
-
-  const toRawObject = () => {
-    return stage?.toObject();
-  };
-
   /** 退出编辑模式 */
   const exitEditMode = () => {
     setCurrentGroup(null);
@@ -678,20 +670,13 @@
     layer?.removeChildren();
   };
 
-  const setScale = (scale: number) => {
-    stage?.setAttr('scaleX', scale);
-  };
-
   defineExpose({
     remove: removeCurrent,
     toObject,
-    toRawObject,
     createLines,
-    initStageByJSON,
     exitEditMode,
     setEditMode,
     clear,
-    setScale,
   });
 </script>
 

+ 258 - 0
src/views/cameras/preview/components/FenceEditorV2/FenceEditor.vue

@@ -0,0 +1,258 @@
+<template>
+  <div class="overflowWrapper" :style="{ width: props.domWidth + 'px', height: domHeight + 'px' }">
+    <div
+      class="scaleWrapper"
+      :style="{
+        scale: scale,
+        width: props.canvasSize.width + 'px',
+        height: props.canvasSize.height + 'px',
+      }"
+    >
+      <v-stage
+        :config="configKonva"
+        @mouse-down="handleStageMouseDown"
+        @mouse-move="handleStageMouseMove"
+        ref="stageRef"
+      >
+        <v-layer>
+          <FenceItem
+            :fenceGroups="fenceGroups"
+            :draggable="!drawingGroupId"
+            @select-group="handleSelectGroup"
+            :is-edit="isEdit"
+          />
+        </v-layer>
+      </v-stage>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup>
+  import Konva from 'konva';
+  import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
+  import FenceItem from './FenceItem.vue';
+  import { createCircleConfigItem, createGroupConfig } from './utils';
+  import { FenceGroup } from './types';
+  import { ElMessage } from 'element-plus';
+  import { GROUP_NAME } from './constants';
+
+  const props = defineProps<{
+    /** 电子围栏的坐标 */
+    linePoints: [number, number][][];
+    /** 画布的大小 */
+    canvasSize: { width: number; height: number };
+    /** dom的真实尺寸 */
+    domWidth: number;
+  }>();
+
+  const scale = computed(() => {
+    return props.domWidth / props.canvasSize.width;
+  });
+
+  const stageRef = ref();
+  const isEdit = ref(false);
+
+  const fenceGroups = ref<FenceGroup[]>([]);
+
+  /** 当前正在画的多边形的groupId */
+  const drawingGroupId = ref('');
+  /** 当前选中的多边形groupId,点击、拖拽、画线都会给它赋值 */
+  const currentGroupId = ref('');
+
+  watch(
+    () => props.linePoints,
+    (newLinePoints) => {
+      const configs: FenceGroup[] =
+        newLinePoints.map((points) => {
+          const flattenedPoints = points.reduce((total, next) => {
+            return [...total, ...next];
+          }, [] as number[]);
+          return createGroupConfig(flattenedPoints, scale.value);
+        }) || [];
+      fenceGroups.value = configs;
+    },
+    {
+      immediate: true,
+    },
+  );
+
+  onMounted(() => {
+    /** 取消默认的右键 */
+    document.oncontextmenu = function () {
+      return false;
+    };
+  });
+
+  onUnmounted(() => {
+    /** 取消默认的右键 */
+    document.oncontextmenu = function () {
+      return true;
+    };
+  });
+
+  const configKonva = computed(() => {
+    return props.canvasSize;
+  });
+
+  const canvasRatio = computed(() => {
+    const size = props.canvasSize;
+    if (!size) return 1;
+    return size.height / size.width;
+  });
+
+  const domHeight = computed(() => {
+    return props.domWidth * canvasRatio.value;
+  });
+
+  const handleStageMouseDown = (e) => {
+    if (!isEdit.value) return;
+    /**
+     * parent存在,说明点击的不是stage
+     * !drawingGroupId,说明当前不处于绘制多边形中
+     * 这两种情况,都不能执行stage的点击事件,要执行点击对象的默认事件
+     */
+    if (e.target.parent && !drawingGroupId.value) return;
+    const stage = e.currentTarget as Konva.Stage;
+    // 获取当前鼠标相对舞台的位置
+    const mousePosition = stage.getPointerPosition();
+    if (!mousePosition?.x || !mousePosition?.y) return;
+    const point = [mousePosition.x, mousePosition.y] as [number, number];
+
+    /** 如果还没开始画线,那么增加第一个点 */
+    if (!drawingGroupId.value) {
+      const groupConfig = createGroupConfig(point, scale.value);
+      drawingGroupId.value = groupConfig.uid;
+      groupConfig._temp.points = point;
+      fenceGroups.value.push(groupConfig);
+    } else {
+      /** 右键点击,取消最后一个点 */
+      if (e.evt.button === 2) {
+        /** 否则就追加点 */
+        const groupConfig = fenceGroups.value.find((x) => x.uid === drawingGroupId.value);
+        if (!groupConfig) {
+          console.error('drawingGroupId无效', drawingGroupId.value);
+          return;
+        }
+        if ((groupConfig._temp.points.length || 0) <= 4) {
+          ElMessage({
+            message: '顶点数必须大于2个!',
+            type: 'warning',
+            center: true,
+            duration: 1000,
+          });
+          groupConfig.lineConfig.points = [];
+          groupConfig.circleConfigs = [];
+        } else {
+          groupConfig.lineConfig.points = groupConfig._temp.points;
+        }
+        drawingGroupId.value = '';
+        groupConfig._temp.points = [];
+        return;
+      }
+      /** 否则就追加点 */
+      const groupConfig = fenceGroups.value.find((x) => x.uid === drawingGroupId.value);
+      if (!groupConfig) {
+        console.error('drawingGroupId无效', drawingGroupId.value);
+        return;
+      }
+      const tempPoints = groupConfig._temp?.points || [];
+      const finalPoints = [...tempPoints, ...point];
+      groupConfig.lineConfig.points = finalPoints;
+      groupConfig._temp.points = finalPoints;
+
+      const circleConfig = createCircleConfigItem(
+        point,
+        groupConfig.circleConfigs.length,
+        scale.value,
+      );
+      groupConfig.circleConfigs.push(circleConfig);
+    }
+  };
+
+  const handleStageMouseMove = (e) => {
+    if (!isEdit.value) return;
+    const stage = e.currentTarget as Konva.Stage;
+    /** 获取当前鼠标的坐标 */
+    const mousePosition = stage.getPointerPosition();
+    if (!mousePosition?.x || !mousePosition?.y) return;
+    const newPoint = [mousePosition.x, mousePosition.y];
+    if (drawingGroupId.value) {
+      const groupConfig: FenceGroup | undefined = fenceGroups.value.find(
+        (x) => x.uid === drawingGroupId.value,
+      );
+      if (!groupConfig) {
+        console.error('drawingGroupId无效', drawingGroupId.value);
+        return;
+      }
+
+      /** 如果正在画线,那么替换最后一个点 */
+      const initialPoints = groupConfig.lineConfig.points as number[];
+      if (groupConfig._temp.points.length > 0) {
+        groupConfig.lineConfig.points = [...groupConfig._temp.points, ...newPoint];
+      } else {
+        groupConfig._temp.points = initialPoints;
+      }
+    }
+  };
+
+  const handleSelectGroup = (groupId: string) => {
+    currentGroupId.value = groupId;
+  };
+
+  /** 清空所有元素 */
+  const clear = () => {
+    fenceGroups.value = [];
+  };
+
+  /** 删除当前选中的group项 */
+  const remove = () => {
+    fenceGroups.value = fenceGroups.value.filter((x) => x.uid !== currentGroupId.value);
+  };
+
+  /** 导出为json格式 */
+  const toObject = () => {
+    const stage = stageRef.value.getStage();
+    const fenceGroups = stage?.find('.' + GROUP_NAME);
+    const gropuPoints = fenceGroups?.map((item) => {
+      const groupX = item.x();
+      const groupY = item.y();
+
+      const line = (item as Konva.Group).findOne((x: any) => x.className === 'Line') as Konva.Line;
+      const points = line?.points();
+      const newPoints: number[][] = [];
+      /** 存到后端的时候,只给点的坐标信息,不会给group的位置信息,所以要将点的坐标加上group的位移,才是之后点的最终坐标 */
+      for (let i = 0; i < points.length; i += 2) {
+        newPoints.push([Math.floor(points[i] + groupX), Math.floor(points[i + 1] + groupY)]);
+      }
+      return newPoints;
+    });
+    return gropuPoints;
+  };
+
+  /** 退出编辑模式 */
+  const exitEditMode = () => {
+    currentGroupId.value = '';
+    isEdit.value = false;
+  };
+  /** 进入编辑模式 */
+  const setEditMode = () => {
+    isEdit.value = true;
+  };
+
+  defineExpose({
+    clear,
+    remove,
+    toObject,
+    exitEditMode,
+    setEditMode,
+  });
+</script>
+
+<style scoped>
+  .scaleWrapper {
+    transform-origin: left top;
+  }
+  .overflowWrapper {
+    overflow: hidden;
+    border: 1px solid #ccc;
+  }
+</style>

+ 55 - 0
src/views/cameras/preview/components/FenceEditorV2/FenceItem.vue

@@ -0,0 +1,55 @@
+<!-- eslint-disable vue/no-use-v-if-with-v-for -->
+<template>
+  <v-group
+    v-for="group in props.fenceGroups"
+    :key="group.uid"
+    :groupId="group.uid"
+    :draggable="props.draggable && props.isEdit"
+    :name="group.name"
+    @mouse-down="handleGroupMouseDown"
+  >
+    <v-line :config="group.lineConfig" />
+    <v-circle
+      v-if="props.isEdit"
+      v-for="circleConfig in group.circleConfigs"
+      :config="circleConfig"
+      :key="circleConfig"
+      @mouse-down="handleCircleMouseDown"
+      @drag-move="handleCircleDragMove(circleConfig, $event)"
+    />
+  </v-group>
+</template>
+<script lang="ts" setup>
+  import { FenceCircleConfig, FenceGroup } from './types';
+
+  const props = defineProps<{
+    fenceGroups: FenceGroup[];
+    draggable: boolean;
+    isEdit: boolean;
+  }>();
+
+  const emits = defineEmits<{ (e: 'selectGroup', groupId: string): unknown }>();
+
+  const handleCircleDragMove = (circleConfig: FenceCircleConfig, e) => {
+    console.log('circle move', e);
+    console.log('circle move circleConfig', circleConfig);
+    const lineAttrs = e.target.parent.find('Line')[0].attrs;
+    const { x, y, idx } = e.target.attrs;
+    lineAttrs.points[idx * 2] = x;
+    lineAttrs.points[idx * 2 + 1] = y;
+    circleConfig.x = x;
+    circleConfig.y = y;
+  };
+
+  const handleCircleMouseDown = (e: { cancelBubble: boolean }) => {
+    /** 阻止冒泡 */
+    e.cancelBubble = true;
+  };
+
+  const handleGroupMouseDown = (e) => {
+    if (!props.isEdit) return;
+    e.target.parent.moveToTop();
+    emits('selectGroup', e.target.parent.attrs.groupId);
+  };
+</script>
+<style scoped></style>

+ 17 - 0
src/views/cameras/preview/components/FenceEditorV2/constants.ts

@@ -0,0 +1,17 @@
+export const defaultLineStyle = {
+  stroke: '#52FFDA',
+  strokeWidth: 3,
+  closed: true,
+  // fill: '#ff0000',
+};
+
+export const defaultCircleStyle = {
+  /** 圆的半径 */
+  radius: 4,
+  /** 点击区域 */
+  hitStrokeWidth: 10,
+  fill: '#52FFDA',
+  draggable: true,
+};
+
+export const GROUP_NAME = 'fenceGroup';

+ 17 - 0
src/views/cameras/preview/components/FenceEditorV2/types.ts

@@ -0,0 +1,17 @@
+import Konva from 'konva';
+
+export type FenceLineConfig = Konva.LineConfig;
+export interface FenceCircleConfig extends Konva.CircleConfig {
+  uid: string;
+}
+
+export interface FenceGroup {
+  lineConfig: FenceLineConfig;
+  /** 临时存放点坐标 */
+  _temp: {
+    points: number[];
+  };
+  circleConfigs: FenceCircleConfig[];
+  uid: string;
+  name: string;
+}

+ 41 - 0
src/views/cameras/preview/components/FenceEditorV2/utils.ts

@@ -0,0 +1,41 @@
+import { GROUP_NAME, defaultCircleStyle, defaultLineStyle } from './constants';
+import { uid } from 'uid';
+import { FenceGroup } from './types';
+
+export const getCircleConfig = (points: number[], scale: number) => {
+  const circlePoints = [];
+  for (let i = 0; i < points.length - 1; i += 2) {
+    circlePoints.push([points[i], points[i + 1]]);
+  }
+  return circlePoints.map((point, idx) => {
+    return createCircleConfigItem(point as [number, number], idx, scale);
+  });
+};
+
+export const createCircleConfigItem = (point: [number, number], idx: number, scale: number) => {
+  return {
+    ...defaultCircleStyle,
+    radius: defaultCircleStyle.radius / scale,
+    hitStrokeWidth: defaultCircleStyle.hitStrokeWidth / scale,
+    x: point[0],
+    y: point[1],
+    uid: uid(),
+    idx,
+  };
+};
+
+export const createGroupConfig = (points: number[], scale: number): FenceGroup => {
+  const lineConfig = {
+    ...defaultLineStyle,
+    strokeWidth: defaultLineStyle.strokeWidth / scale,
+    points: points,
+  };
+  const circleConfigs = getCircleConfig(points, scale);
+  return {
+    lineConfig,
+    name: GROUP_NAME,
+    circleConfigs,
+    uid: uid(),
+    _temp: { points: [] },
+  };
+};