Kaynağa Gözat

小地图中台模块demo完成

楼航飞 2 yıl önce
ebeveyn
işleme
6516a5edac

+ 3 - 2
.env.development

@@ -15,7 +15,7 @@ VITE_DROP_CONSOLE = true
 
 # 跨域代理,可以配置多个,请注意不要换行
 #VITE_PROXY = [["/appApi","http://localhost:8001"],["/upload","http://localhost:8001/upload"]]
-VITE_PROXY=[["/temp","http://172.16.23.144:8800"]]
+VITE_PROXY=[["/temp","http://172.16.23.144:8800"],["/upload","http://172.16.23.144:8086"]]
 
 # API 接口地址
 VITE_GLOB_API_URL = 
@@ -23,7 +23,8 @@ VITE_GLOB_API_URL =
 VITE_GLOB_UPLOAD_URL= http://172.16.23.144:8086
 
 # 图片前缀地址
-VITE_GLOB_IMG_URL= http://172.16.23.144:8086
+VITE_GLOB_IMG_URL = //172.16.23.144/tiancangstatic
+
 
 # 接口前缀
 VITE_GLOB_API_URL_PREFIX = /api

+ 36 - 0
mock/login/info.ts

@@ -993,4 +993,40 @@ export default [
       return resultSuccess(info);
     },
   },
+
+  {
+    url: '/api/common/queryTentantList',
+    timeout: 1000,
+    method: 'get',
+    response: () => {
+      return resultSuccess([
+        {
+          tenantId: 1,
+          tenantCode: 'comac',
+          tenantName: '上飞公司',
+        },
+      ]);
+    },
+  },
+  {
+    url: '/api/common/initData',
+    timeout: 1000,
+    method: 'get',
+    response: () => {
+      return resultSuccess({
+        isEnableCode: false,
+      });
+    },
+  },
+  {
+    url: '/api/login/auth',
+    timeout: 1000,
+    method: 'post',
+    response: () => {
+      return resultSuccess({
+        satoken: 'ddb094c3-731e-4d11-8ca9-a4b0ba751a8d',
+        tenantId: '4510784fbc3c4ca59238666e5c75c2f8498702880',
+      });
+    },
+  },
 ];

+ 4 - 1
package.json

@@ -32,10 +32,12 @@
   },
   "dependencies": {
     "@element-plus/icons-vue": "2.0.9",
+    "@types/fabric": "^5.3.6",
     "@vicons/antd": "0.12.0",
     "@vicons/ionicons5": "0.12.0",
     "@vueup/vue-quill": "1.0.0-beta.8",
     "@vueuse/core": "8.9.4",
+    "@vueuse/router": "^10.6.1",
     "@wangeditor/editor": "5.1.23",
     "@wangeditor/editor-for-vue": "5.1.12",
     "axios": "0.27.2",
@@ -54,6 +56,7 @@
     "print-js": "1.6.0",
     "qrcode": "1.5.1",
     "qs": "6.11.0",
+    "url-join": "^5.0.0",
     "vue": "3.3.4",
     "vue-router": "4.1.2",
     "vue-types": "4.1.1",
@@ -142,4 +145,4 @@
       ]
     }
   }
-}
+}

+ 58 - 3
pnpm-lock.yaml

@@ -8,6 +8,9 @@ dependencies:
   '@element-plus/icons-vue':
     specifier: 2.0.9
     version: 2.0.9(vue@3.3.4)
+  '@types/fabric':
+    specifier: ^5.3.6
+    version: 5.3.6
   '@vicons/antd':
     specifier: 0.12.0
     version: 0.12.0
@@ -20,6 +23,9 @@ dependencies:
   '@vueuse/core':
     specifier: 8.9.4
     version: 8.9.4(vue@3.3.4)
+  '@vueuse/router':
+    specifier: ^10.6.1
+    version: 10.6.1(vue-router@4.1.2)(vue@3.3.4)
   '@wangeditor/editor':
     specifier: 5.1.23
     version: 5.1.23
@@ -74,6 +80,9 @@ dependencies:
   qs:
     specifier: 6.11.0
     version: 6.11.0
+  url-join:
+    specifier: ^5.0.0
+    version: 5.0.0
   vue:
     specifier: 3.3.4
     version: 3.3.4
@@ -1028,6 +1037,10 @@ packages:
     resolution: {integrity: sha512-UfnOK1pIxO7P+EgPRZXD9jMpimd8QEFcEZ5R67R1UhGbv4zghU5+NE7U8M8G9H5Jc8FI51rqDWQs6FtUfq2e/Q==}
     dev: false
 
+  /@types/fabric@5.3.6:
+    resolution: {integrity: sha512-nTP5I68SsGnanIHxCoBX83ghscw9M9DI27iSDcd0Z+cpiQ5cZByH0nzkm4itDR/LgAy253q7B93xHgyOh2+hFQ==}
+    dev: false
+
   /@types/intro.js@3.0.2:
     resolution: {integrity: sha512-kow8REgIIG42atN9vAaIdpEqVzj6WzV9m0PII8oce+an4Lc3eyfQF32/FbabbGmfWuF7TceTdd+gh74kOrXkPw==}
     dev: true
@@ -1452,7 +1465,7 @@ packages:
       '@types/web-bluetooth': 0.0.15
       '@vueuse/metadata': 9.2.0
       '@vueuse/shared': 9.2.0(vue@3.3.4)
-      vue-demi: 0.13.11(vue@3.3.4)
+      vue-demi: 0.14.6(vue@3.3.4)
     transitivePeerDependencies:
       - '@vue/composition-api'
       - vue
@@ -1466,6 +1479,28 @@ packages:
     resolution: {integrity: sha512-exN4KE6iquxDCdt72BgEhb3tlOpECtD61AUdXnUqBTIUCl70x1Ar/QXo3bYcvxmdMS2/peQyfeTzBjRTpvL5xw==}
     dev: false
 
+  /@vueuse/router@10.6.1(vue-router@4.1.2)(vue@3.3.4):
+    resolution: {integrity: sha512-9EMf30SownmxYKC3h36tl8uALSBGFkz3Dc2n+lQgJuyOYPIO/VotzDdkBxuEoHYKaCWw2JQa/S1q+t8NR26PvQ==}
+    peerDependencies:
+      vue-router: '>=4.0.0-rc.1'
+    dependencies:
+      '@vueuse/shared': 10.6.1(vue@3.3.4)
+      vue-demi: 0.14.6(vue@3.3.4)
+      vue-router: 4.1.2(vue@3.3.4)
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+    dev: false
+
+  /@vueuse/shared@10.6.1(vue@3.3.4):
+    resolution: {integrity: sha512-TECVDTIedFlL0NUfHWncf3zF9Gc4VfdxfQc8JFwoVZQmxpONhLxFrlm0eHQeidHj4rdTPL3KXJa0TZCk1wnc5Q==}
+    dependencies:
+      vue-demi: 0.14.6(vue@3.3.4)
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+    dev: false
+
   /@vueuse/shared@8.9.4(vue@3.3.4):
     resolution: {integrity: sha512-wt+T30c4K6dGRMVqPddexEVLa28YwxW5OFIPmzUHICjphfAuBFTTdDoyqREZNDOFJZ44ARH1WWQNCUK8koJ+Ag==}
     peerDependencies:
@@ -1478,13 +1513,13 @@ packages:
         optional: true
     dependencies:
       vue: 3.3.4
-      vue-demi: 0.13.11(vue@3.3.4)
+      vue-demi: 0.14.6(vue@3.3.4)
     dev: false
 
   /@vueuse/shared@9.2.0(vue@3.3.4):
     resolution: {integrity: sha512-NnRp/noSWuXW0dKhZK5D0YLrDi0nmZ18UeEgwXQq7Ul5TTP93lcNnKjrHtd68j2xFB/l59yPGFlCryL692bnrA==}
     dependencies:
-      vue-demi: 0.13.11(vue@3.3.4)
+      vue-demi: 0.14.6(vue@3.3.4)
     transitivePeerDependencies:
       - '@vue/composition-api'
       - vue
@@ -6750,6 +6785,11 @@ packages:
       punycode: 2.1.1
     dev: true
 
+  /url-join@5.0.0:
+    resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==}
+    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+    dev: false
+
   /url-parse@1.5.10:
     resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
     requiresBuild: true
@@ -6913,6 +6953,21 @@ packages:
       vue: 3.3.4
     dev: false
 
+  /vue-demi@0.14.6(vue@3.3.4):
+    resolution: {integrity: sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==}
+    engines: {node: '>=12'}
+    hasBin: true
+    requiresBuild: true
+    peerDependencies:
+      '@vue/composition-api': ^1.0.0-rc.1
+      vue: ^3.0.0-0 || ^2.6.0
+    peerDependenciesMeta:
+      '@vue/composition-api':
+        optional: true
+    dependencies:
+      vue: 3.3.4
+    dev: false
+
   /vue-eslint-parser@8.3.0(eslint@8.20.0):
     resolution: {integrity: sha512-dzHGG3+sYwSf6zFBa0Gi9ZDshD7+ad14DGOdTLjruRVgZXe2J+DcZ9iUhyR48z5g1PqRa20yt3Njna/veLJL/g==}
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}

+ 63 - 0
src/api/scene/scene.ts

@@ -1,4 +1,5 @@
 import { http } from '@/utils/http/axios';
+import qs from 'querystring';
 
 /** 工位信息 */
 export type WorkSpaceInfoItem = {
@@ -86,6 +87,68 @@ export const getShopSpaceList = () => {
   );
 };
 
+interface LayoutResp {
+  /** 创建时间 */
+  createdAt: string;
+  /** 自增主键 */
+  id: number;
+  /** 页面布局json	 */
+  layout: string;
+  /** 目标id: 对应公司ID/车间ID */
+  targetId: number;
+  viewType: ViewType;
+}
+
+/** 查询地图布局 */
+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',
+    },
+  );
+};
+/** 查询车间小地图布局 */
+export const getWorkshopMiniMapLayoutApi = (workshopId: string) => {
+  return getLayoutApi({ viewType: ViewType.minMap, workshopId }).then((res) => res[0]);
+};
+
+enum ViewType {
+  safety = 1,
+  minMap = 2,
+}
+
+interface UpdateViewLayoutParam {
+  layout: string;
+  targetId: string;
+  viewType: ViewType;
+}
+
+/** 更新-新增小地图页面布局 */
+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',
+    },
+  );
+};
+/** 更新-新增小地图页面布局 */
+export const updateMinMapViewLayoutApi = (
+  param: Pick<UpdateViewLayoutParam, 'layout' | 'targetId'>,
+) => {
+  return updateLayoutApi({ viewType: ViewType.minMap, ...param });
+};
+
 export type CameraItem = {
   /** 相机名称 */
   name: string;

BIN
src/assets/camera/camera-active.png


BIN
src/assets/camera/camera.png


+ 8 - 0
src/assets/declare.d.ts

@@ -0,0 +1,8 @@
+declare module '*.svg' {
+  const src: string;
+  export default src;
+}
+declare module '*.png' {
+  const src: string;
+  export default src;
+}

+ 1 - 1
src/settings/projectSetting.ts

@@ -24,7 +24,7 @@ const setting = {
     //背景色
     bgColor: '#fff',
     //是否显示
-    show: true,
+    show: false,
     //固定多标签
     fixed: true,
   },

+ 9 - 0
src/utils/safeParse.ts

@@ -0,0 +1,9 @@
+export default function safeParse(str: string) {
+  let json;
+  try {
+    json = JSON.parse(str);
+  } catch (err) {
+    console.error(err);
+  }
+  return json;
+}

+ 193 - 0
src/views/map-config/mini-map/MapBase/CameraMap.ts

@@ -0,0 +1,193 @@
+import { fabric } from 'fabric';
+import cameraActiveImg from '@/assets/camera/camera-active.png';
+import cameraImg from '@/assets/camera/camera.png';
+import { MapData } from './types';
+
+export interface CameraImage extends fabric.Image {
+  cameraId: string;
+}
+
+type Canvas = fabric.Canvas;
+
+const isCanvas = (canvas: Canvas | null): canvas is Canvas => {
+  return Boolean(canvas);
+};
+
+const imageControlStyle = {
+  borderColor: 'gray', // 边框颜色
+  cornerColor: '#ccc', // 控制角颜色
+  cornerSize: 4, // 控制角大小
+  transparentCorners: true, // 控制角填充色透明
+  hasBorders: false,
+};
+
+type OnSelect = (image: CameraImage) => unknown;
+
+class CameraMap {
+  public canvas: fabric.Canvas | null = null;
+  private onSelect: OnSelect;
+
+  constructor(param: { canvasId: string; onSelect: OnSelect }) {
+    this.canvas = new fabric.Canvas(param.canvasId);
+    this.addListener();
+    this.onSelect = param.onSelect;
+  }
+
+  /** 监听点击事件 */
+  private addListener() {
+    const canvas = this.canvas;
+    if (!isCanvas(canvas)) return;
+
+    canvas.on('mouse:down', (options) => {
+      const target = options.target as CameraImage;
+      const cameraId = target?.cameraId;
+      console.log('当前选中的id是', cameraId);
+      console.log(options.target);
+      if (!cameraId || !target) return;
+
+      canvas.forEachObject((object) => {
+        if (object === options.target) return;
+        (object as CameraImage).setSrc(cameraImg, () => {
+          canvas.renderAll();
+        });
+      });
+      target.setSrc(cameraActiveImg, () => {
+        canvas.renderAll();
+      });
+      if (target.type === 'image') {
+        this.onSelect(target);
+      }
+    });
+  }
+
+  /** 上传背景图 */
+  public uploadBg(imgUrl: string) {
+    const canvas = this.canvas;
+    if (!isCanvas(canvas)) return;
+    fabric.Image.fromURL(imgUrl, (img) => {
+      console.log('image', img);
+      this.canvas!.setWidth(img.width!);
+      this.canvas!.setHeight(img.height!);
+      img.lockScalingX = true;
+      img.lockScalingY = true;
+      // 设置背景图
+      canvas.setBackgroundImage(img, this.canvas!.renderAll.bind(this.canvas));
+    });
+  }
+
+  /** 将所有的摄像头都设置为非激活状态 */
+  private setAllCameraUnActive() {
+    const canvas = this.canvas;
+    if (!isCanvas(canvas)) return;
+    canvas.forEachObject((object) => {
+      (object as fabric.Image).setSrc(cameraImg, () => {
+        canvas.renderAll();
+      });
+    });
+  }
+
+  private getRandomPosition() {
+    return 100 + Math.floor(Math.random() * 30);
+  }
+
+  /** 增加一个摄像头 */
+  public addCamera(cameraId: string): Promise<CameraImage> {
+    const canvas = this.canvas;
+    if (!isCanvas(canvas)) return Promise.reject();
+    return new Promise((resolve) => {
+      fabric.Image.fromURL(cameraActiveImg, (cImg) => {
+        const cameraImg: CameraImage = cImg as unknown as CameraImage;
+        this.setAllCameraUnActive();
+        cameraImg.set({
+          ...imageControlStyle,
+          left: this.getRandomPosition(),
+          top: this.getRandomPosition(),
+          cameraId,
+        });
+        canvas.add(cameraImg);
+        resolve(cameraImg);
+      });
+    });
+  }
+
+  /** 删除一个摄像头 */
+  public removeCamera(cameraImage: CameraImage) {
+    const canvas = this.canvas;
+    if (!isCanvas(canvas)) return;
+    console.log('removeCamera', cameraImage);
+    canvas.remove(cameraImage);
+  }
+
+  /** 导出JSON格式 */
+  public toJSON() {
+    const canvas = this.canvas;
+    if (!isCanvas(canvas)) return;
+    const initialJSON = canvas.toJSON(['cameraId']);
+    /** toJSON返回值的类型它写错了,应该是有backgroundImage的 */
+    const { src, type, version, width, height, left, top, angle } =
+      (initialJSON as any).backgroundImage || {};
+
+    const newObjects = initialJSON.objects.map((item) => {
+      return {
+        type: item.type,
+        width: item.width,
+        height: item.height,
+        left: item.left,
+        top: item.top,
+        angle: item.angle,
+        cameraId: (item as CameraImage).cameraId,
+      };
+    });
+    const newJson = {
+      version: initialJSON.version,
+      backgroundImage: { src, type, version, width, height, left, top, angle },
+      objects: newObjects,
+    };
+    return newJson;
+  }
+
+  /** 从json中加载 */
+  public loadFromJSON(json: MapData): Promise<void> {
+    const canvas = this.canvas;
+    if (!isCanvas(canvas)) return Promise.reject();
+    return new Promise((resolve) => {
+      const { width, height } = json.backgroundImage;
+      canvas.setWidth(width);
+      canvas.setHeight(height);
+      const objects = json.objects.map((item) => {
+        /** 在预览模式下所有的摄像头都不可选 */
+        return {
+          ...item,
+          src: cameraImg,
+          ...imageControlStyle,
+        };
+      });
+      canvas.loadFromJSON({ ...json, objects }, () => {
+        resolve();
+      });
+    });
+  }
+
+  /** 更新摄像头的渲染 */
+  public renderCamera() {
+    const canvas = this.canvas;
+    if (!isCanvas(canvas)) return;
+    canvas.renderAll();
+  }
+
+  public clear() {
+    this.canvas?.clear();
+  }
+
+  /** 是否已经存在这个cameraId */
+  public hasCamera(cameraId: string) {
+    const canvas = this.canvas;
+    if (!isCanvas(canvas)) return;
+    const cameraIds = canvas
+      .toJSON(['cameraId'])
+      .objects.map((item) => (item as CameraImage).cameraId);
+    return cameraIds.includes(cameraId);
+  }
+}
+
+export default CameraMap;

+ 42 - 0
src/views/map-config/mini-map/MapBase/mapData.json

@@ -0,0 +1,42 @@
+{
+  "version": "5.3.0",
+  "backgroundImage": {
+    "src": "http://127.0.0.1:5174/src/assets/img/canvasBg.png?t=1700735801636",
+    "type": "image",
+    "version": "5.3.0",
+    "width": 800,
+    "height": 409,
+    "left": 0,
+    "top": 0,
+    "angle": 0
+  },
+  "objects": [
+    {
+      "type": "image",
+      "width": 30,
+      "height": 19,
+      "left": 34.77,
+      "top": 15.88,
+      "angle": 37.64,
+      "cameraId": "1"
+    },
+    {
+      "type": "image",
+      "width": 30,
+      "height": 19,
+      "left": 18.52,
+      "top": 362.26,
+      "angle": 331.41,
+      "cameraId": "2"
+    },
+    {
+      "type": "image",
+      "width": 30,
+      "height": 20,
+      "left": 339.99,
+      "top": 368.21,
+      "angle": 317.8,
+      "cameraId": "3"
+    }
+  ]
+}

+ 23 - 0
src/views/map-config/mini-map/MapBase/types.ts

@@ -0,0 +1,23 @@
+export interface MapData {
+  version: string;
+  backgroundImage: {
+    src: string;
+    type: string;
+    width: number;
+    height: number;
+    left: number;
+    top: number;
+    angle: number;
+  };
+  objects: CameraImgObject[];
+}
+
+export interface CameraImgObject {
+  type: string;
+  width: number;
+  height: number;
+  left: number;
+  top: number;
+  angle: number;
+  cameraId: string;
+}

+ 207 - 49
src/views/map-config/mini-map/MiniMapConfig.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="page">
-    <div class="flex pt-10">
-      <div class="flex items-center ml-20">
+    <div class="flex">
+      <div class="flex items-center">
         <span>场景:</span>
         <el-tree-select
           v-model="selectedShop"
@@ -11,72 +11,122 @@
           @change="changeShop"
         />
       </div>
-      <div class="flex ml-20 items-center">
-        <span>上传图片:</span>
-        <el-upload
-          class="avatar-uploader flex justify-center items-center"
-          action="https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15"
-          :show-file-list="false"
-          :on-success="handleAvatarSuccess"
-          :before-upload="beforeAvatarUpload"
-        >
-          <img v-if="bgImgUrl" :src="bgImgUrl" alt="" />
-          <div v-else class="flex flex-col justify-center items-center">
-            <el-icon size="30" color="#c0c4cc"><Plus /></el-icon>
-            <span>上传背景图片</span>
-          </div>
-        </el-upload>
-      </div>
     </div>
-    <div class="paint-tool mt-10 flex">
+    <div class="paint-tool flex">
       <div class="camera-list">
-        <span class="label-text flex">相机列表:</span>
+        <div>
+          <span class="label-text flex">相机列表:</span>
+          <ElInput
+            style="margin: 10px; width: 230px"
+            placeholder="输入相机id或工位"
+            v-model="searchKey"
+            :suffix-icon="Search"
+          />
+        </div>
         <el-scrollbar style="height: calc(100% - 50px)">
           <div
-            v-for="item in shopCameraList"
+            v-for="item in filterShopCameraList"
             :key="item.code"
             class="camera-item flex justify-start"
+            :class="{ isAdded: isAddedToMap(item.code) }"
+            @click="handleAddCamera(item.code)"
           >
             <span class="camera-id">{{ item.name }}</span>
             <span>{{ item.workSpaceName }}</span>
           </div>
         </el-scrollbar>
       </div>
-      <div class="draw-container"></div>
+      <div class="draw-container">
+        <div style="display: flex; margin-bottom: 20px">
+          <div>
+            默认摄像头:
+            <ElSelect v-model="defaultCameraId">
+              <ElOption
+                :key="item.value"
+                :label="item.value"
+                :value="item.value"
+                v-for="item in cameraOptions"
+              />
+            </ElSelect>
+          </div>
+
+          <el-upload
+            class="avatar-uploader flex justify-center items-center"
+            action="/temp/api/layout/uploadPicture"
+            :show-file-list="false"
+            :on-success="handleAvatarSuccess"
+            :with-credentials="true"
+            name="file"
+            :data="{ workshopId: selectedShopId }"
+          >
+            <el-button style="font-size: 12px">+ 更换/上传背景图片</el-button>
+          </el-upload>
+
+          <el-button
+            @click="handleSave"
+            style="margin-left: 40px"
+            type="primary"
+            :disabled="!selectedShopId"
+            >保存布局</el-button
+          >
+        </div>
+        <div>
+          <div style="height: 20px; margin-bottom: 10px">
+            <div v-if="selectedCamera">
+              已选中相机<span>: {{ selectedCamera?.cameraId }}</span>
+              <span style="margin-left: 20px">
+                工位:{{ selectedCameraDetail?.workSpaceName }}
+              </span>
+            </div>
+          </div>
+        </div>
+        <div style="overflow: auto">
+          <canvas
+            width="400"
+            height="400"
+            id="mapEditCanvas"
+            style="border: 1px solid #ccc"
+          ></canvas>
+        </div>
+      </div>
     </div>
   </div>
 </template>
 
 <script setup lang="ts">
-  import { onMounted } from 'vue';
-  import { Plus } from '@element-plus/icons-vue';
   import useMiniMap from './use-mini-map';
   import { storeToRefs } from 'pinia';
-  import { ElMessage } from 'element-plus';
+  import { ElMessage, ElInput } from 'element-plus';
+  import urlJoin from 'url-join';
+  import { onMounted, ref, toRaw } from 'vue';
+  import CameraMap, { CameraImage } from './MapBase/CameraMap';
+  import { ElSelect, ElOption } from 'element-plus';
+  import { updateMinMapViewLayoutApi } from '@/api/scene/scene';
+  import { onUnmounted } from 'vue';
+  import { useGlobSetting } from '@/hooks/setting';
+  import { computed } from 'vue';
+  import { Search } from '@element-plus/icons-vue';
 
   const miniMap = useMiniMap();
-  const { selectedShop, bgImgUrl, scenesTree, shopCameraList } = storeToRefs(miniMap);
-  const { getScenesTree, getShowCameras } = miniMap;
-
-  const beforeAvatarUpload = (rawFile) => {
-    console.log('图片信息', rawFile);
-    const render = new FileReader();
-    render.onload = (e) => {
-      const img = document.createElement('img');
-      bgImgUrl.value = img.src = e.target?.result as string;
-      img.onload = () => {
-        console.log('width====', img.width);
-        console.log('height====', img.height);
-      };
-    };
-    render.readAsDataURL(rawFile);
-  };
+  const globSetting = useGlobSetting();
+  const { selectedShop, scenesTree, shopCameraList, selectedShopId } = storeToRefs(miniMap);
+  const { getScenesTree, getShowCameras, getMapLayout } = miniMap;
+
+  let map: CameraMap;
+  const selectedCamera = ref<CameraImage | null>(null);
+  const cameraOptions = ref<{ label: string; value: string }[]>([]);
+  const defaultCameraId = ref('');
+  const searchKey = ref('');
 
-  const handleAvatarSuccess = () => {};
+  const handleAvatarSuccess = (e) => {
+    const imgPath = e.data;
+    const imgUrl = urlJoin(globSetting.imgUrl!, imgPath);
+    map.uploadBg(imgUrl);
+  };
 
   const changeShop = (newVal) => {
     const info = JSON.parse(newVal);
-
+    console.log('info', info);
     if (info.parentId || info.parentId == 0) {
       ElMessage({
         message: '该场景暂无相机',
@@ -85,29 +135,131 @@
       return;
     }
     getShowCameras();
+    getMapLayout().then((res) => {
+      if (!res) {
+        map.clear();
+        return;
+      }
+      defaultCameraId.value = res.defaultCameraId;
+      map.loadFromJSON(res).then(() => {
+        mapJSONToOptions();
+        if (!defaultCameraId.value) {
+          defaultCameraId.value = cameraOptions.value[0]?.value;
+        }
+      });
+    });
   };
 
   onMounted(() => {
     getScenesTree(2);
+    map = new CameraMap({ canvasId: 'mapEditCanvas', onSelect: onSelectCamera });
+  });
+
+  const keyupListener = (e) => {
+    const keyCode = e.code;
+    if (keyCode === 'Delete' || keyCode === 'Backspace') {
+      if (selectedCamera.value) {
+        handleDeleteCamera();
+      }
+    }
+  };
+
+  onMounted(() => {
+    document.addEventListener('keyup', keyupListener);
+  });
+
+  onUnmounted(() => {
+    document.removeEventListener('keyup', keyupListener);
+  });
+
+  const selectedCameraDetail = computed(() => {
+    return shopCameraList.value.find((item) => item.code === selectedCamera.value?.cameraId);
+  });
+
+  const filterShopCameraList = computed(() => {
+    const k = searchKey.value.trim();
+    if (!k) return shopCameraList.value;
+    return shopCameraList.value.filter((x) => x.code?.includes(k) || x.workSpaceName?.includes(k));
   });
+
+  /** 摄像机是否已添加到底图 */
+  const isAddedToMap = (cameraId: string): boolean => {
+    return cameraOptions.value.some((x) => x.value === cameraId);
+  };
+
+  const handleAddCamera = (cameraId: string) => {
+    if (map.hasCamera(cameraId)) {
+      ElMessage.warning({ message: '相机已添加' });
+      return;
+    }
+    map.addCamera(cameraId).then((cameraImg) => {
+      onSelectCamera(cameraImg);
+      mapJSONToOptions();
+      if (!defaultCameraId.value) {
+        defaultCameraId.value = cameraOptions.value[0]?.value;
+      }
+    });
+  };
+
+  const onSelectCamera = (cameraImg: CameraImage) => {
+    console.log('onSelectCamera', cameraImg);
+    selectedCamera.value = cameraImg;
+  };
+
+  const mapJSONToOptions = () => {
+    const objects = map.toJSON()?.objects || [];
+    console.log('objects', objects);
+    cameraOptions.value = objects?.map((x) => ({
+      label: x.cameraId,
+      value: x.cameraId,
+    }));
+  };
+
+  const handleSave = () => {
+    const json = map.toJSON();
+    console.log('save json', json);
+    if (!json?.backgroundImage) {
+      ElMessage.error('背景图片未添加');
+      return;
+    }
+    const layout = JSON.stringify({ ...json, defaultCameraId: defaultCameraId.value });
+    updateMinMapViewLayoutApi({ layout, targetId: selectedShopId.value }).then((res) => {
+      console.log('updateMinMapViewLayoutApi', res);
+      ElMessage.success('保存成功');
+    });
+  };
+
+  const handleDeleteCamera = () => {
+    if (!selectedCamera.value) return;
+    map.removeCamera(toRaw(selectedCamera.value!));
+    /** 如果删除的是默认选中的摄像头,那么先清空默认的摄像头再 */
+    mapJSONToOptions();
+    if (selectedCamera.value.cameraId === defaultCameraId.value) {
+      defaultCameraId.value = cameraOptions.value[0]?.value;
+    }
+    selectedCamera.value = null;
+  };
 </script>
 
 <style scoped>
   .page {
     margin: 10px 0;
-    padding-bottom: 10px;
+    padding: 10px;
     background-color: #ffffff;
   }
 
   .avatar-uploader {
-    width: 100px;
-    height: 100px;
-    border: 1px solid #c0c4cc;
+    /* width: 120px; */
+    /* height: 30px; */
+    /* border: 1px solid #eee; */
+    border-radius: 4px;
+    margin-left: 30px;
   }
 
   .paint-tool {
-    height: calc(100vh - 248px - 5rem);
+    height: calc(100vh - 200px);
     margin: 0 10px;
+    margin-top: 20px;
     border: 1px solid #c0c4cc;
   }
 
@@ -118,12 +270,13 @@
   .label-text {
     font-size: 14px;
     font-weight: 600;
-    margin: 10px 0 20px 10px;
+    margin: 10px 0 5px 10px;
   }
   .camera-item {
     padding: 10px 0 10px 10px;
     margin: 0 8px 1px 8px;
     border: 1px solid #c0c4cc;
+    cursor: pointer;
   }
   .camera-id {
     width: 130px;
@@ -131,8 +284,13 @@
 
   .draw-container {
     width: calc(100% - 300px);
+    margin: 20px;
   }
 
   :deep(.avatar-uploader .el-upload) {
   }
+
+  .isAdded {
+    color: #409eff;
+  }
 </style>

+ 24 - 3
src/views/map-config/mini-map/use-mini-map.ts

@@ -1,7 +1,8 @@
-import { ref, toRefs } from 'vue';
+import { computed, ref, toRefs } from 'vue';
 import useSceneInfos from '@/hooks/useSceneInfos';
-import { CameraItem } from '@/api/scene/scene';
+import { CameraItem, getWorkshopMiniMapLayoutApi } from '@/api/scene/scene';
 import { defineStore } from 'pinia';
+import safeParse from '@/utils/safeParse';
 
 type ShopMapCamera = CameraItem & {
   /** 相机是否已经设置 0-false 1-true */
@@ -20,6 +21,10 @@ export const useMiniMap = defineStore('mini-map', () => {
 
   const shopCameraList = ref<ShopMapCamera[]>([]);
 
+  const selectedShopId = computed(() => {
+    return safeParse(selectedShop.value)?.id;
+  });
+
   const getShowCameras = () => {
     shopCameraList.value = [];
     getCameraList(JSON.parse(selectedShop.value).id).then((res) => {
@@ -33,7 +38,23 @@ export const useMiniMap = defineStore('mini-map', () => {
     });
   };
 
-  return { selectedShop, bgImgUrl, scenesTree, shopCameraList, getScenesTree, getShowCameras };
+  const getMapLayout = () => {
+    return getWorkshopMiniMapLayoutApi(JSON.parse(selectedShop.value).id).then((res) => {
+      const layoutJSON = res?.layout ? JSON.parse(res.layout) : null;
+      return layoutJSON;
+    });
+  };
+
+  return {
+    selectedShop,
+    bgImgUrl,
+    scenesTree,
+    shopCameraList,
+    getScenesTree,
+    getShowCameras,
+    getMapLayout,
+    selectedShopId,
+  };
 });
 
 export default useMiniMap;