bill преди 1 година
родител
ревизия
05341c2883
променени са 40 файла, в които са добавени 1873 реда и са изтрити 204 реда
  1. 1 0
      package.json
  2. 19 0
      pnpm-lock.yaml
  3. 2 1
      src/app/criminal/routeConfig.ts
  4. 2 1
      src/app/fire/routeConfig.ts
  5. 2 1
      src/app/xmfire/routeConfig.ts
  6. 12 0
      src/components/dialog/type.ts
  7. 24 14
      src/helper/mount.ts
  8. 2 1
      src/request/config.ts
  9. 12 3
      src/request/urls.ts
  10. 8 2
      src/router/config.ts
  11. 2 1
      src/router/routeName.ts
  12. 1 1
      src/store/permission.ts
  13. 30 1
      src/store/scene.ts
  14. 34 0
      src/store/statistics.ts
  15. 1 0
      src/store/user.ts
  16. 52 3
      src/util/image-rotate.ts
  17. 44 4
      src/util/index.ts
  18. 551 0
      src/util/mt4.ts
  19. 1 0
      src/view/case/draw/board/editCAD/Controls/UIControl.js
  20. 4 1
      src/view/case/draw/board/index.d.ts
  21. 45 57
      src/view/case/draw/board/index.js
  22. 1 0
      src/view/case/draw/board/shape.js
  23. 8 1
      src/view/case/draw/board/useBoard.ts
  24. 28 0
      src/view/case/draw/edit-shape/image.vue
  25. 2 0
      src/view/case/draw/edit-shape/index.ts
  26. 4 5
      src/view/case/draw/edit-shape/label.vue
  27. 2 2
      src/view/case/draw/edit-shape/tag.vue
  28. 2 2
      src/view/case/draw/edit-shape/title.vue
  29. 70 18
      src/view/case/draw/editEshapeTable.vue
  30. 2 1
      src/view/case/draw/eshape.vue
  31. 23 9
      src/view/case/draw/index.vue
  32. 85 37
      src/view/case/draw/slider.vue
  33. 3 0
      src/view/home/index.vue
  34. 169 0
      src/view/statistics/index.vue
  35. 273 0
      src/view/statistics/statisticsInject.ts
  36. 115 35
      src/view/system/imageCropper.vue
  37. 91 0
      src/view/vrmodel/downloadLog.vue
  38. 10 0
      src/view/vrmodel/quisk.ts
  39. 7 3
      src/view/vrmodel/sceneContent.vue
  40. 129 0
      src/view/vrmodel/sceneDownload.vue

+ 1 - 0
package.json

@@ -14,6 +14,7 @@
     "@element-plus/icons-vue": "^2.1.0",
     "@types/qs": "^6.9.7",
     "axios": "^1.4.0",
+    "echarts": "^5.4.3",
     "element-plus": "^2.3.8",
     "js-base64": "^3.7.5",
     "mime": "^3.0.0",

+ 19 - 0
pnpm-lock.yaml

@@ -7,6 +7,7 @@ specifiers:
   '@types/qs': ^6.9.7
   '@vitejs/plugin-vue': ^4.2.3
   axios: ^1.4.0
+  echarts: ^5.4.3
   element-plus: ^2.3.8
   js-base64: ^3.7.5
   mime: ^3.0.0
@@ -27,6 +28,7 @@ dependencies:
   '@element-plus/icons-vue': 2.1.0_vue@3.3.4
   '@types/qs': 6.9.7
   axios: 1.4.0
+  echarts: 5.4.3
   element-plus: 2.3.8_vue@3.3.4
   js-base64: 3.7.5
   mime: 3.0.0
@@ -643,6 +645,13 @@ packages:
     engines: {node: '>=0.4.0'}
     dev: false
 
+  /echarts/5.4.3:
+    resolution: {integrity: sha512-mYKxLxhzy6zyTi/FaEbJMOZU1ULGEQHaeIeuMR5L+JnJTpz+YR03mnnpBhbR4+UYJAgiXgpyTVLffPAjOTLkZA==}
+    dependencies:
+      tslib: 2.3.0
+      zrender: 5.4.4
+    dev: false
+
   /element-plus/2.3.8_vue@3.3.4:
     resolution: {integrity: sha512-yHQR0/tG2LvPkpGUt7Te/hPmP2XW/BytBNUbx+EFO54VnGCOE3upmQcVffNp1PLgwg9sthYDXontUWpnpmLPJw==}
     peerDependencies:
@@ -991,6 +1000,10 @@ packages:
     dependencies:
       is-number: 7.0.0
 
+  /tslib/2.3.0:
+    resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
+    dev: false
+
   /typescript/5.1.6:
     resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==}
     engines: {node: '>=14.17'}
@@ -1135,3 +1148,9 @@ packages:
   /yallist/4.0.0:
     resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
     dev: true
+
+  /zrender/5.4.4:
+    resolution: {integrity: sha512-0VxCNJ7AGOMCWeHVyTrGzUgrK4asT4ml9PEkeGirAkKNYXYzoPJCLvmyfdoOXcjTHPs10OZVMfD1Rwg16AZyYw==}
+    dependencies:
+      tslib: 2.3.0
+    dev: false

+ 2 - 1
src/app/criminal/routeConfig.ts

@@ -7,7 +7,8 @@ export const CriminalRouteName = {
 } as const;
 
 export const menuRouteNames = [
-  // CriminalRouteName.home,
+  // CriminalRouteName.statistics,
+  CriminalRouteName.downloadLog,
   CriminalRouteName.vrmodel,
   CriminalRouteName.camera,
   CriminalRouteName.example,

+ 2 - 1
src/app/fire/routeConfig.ts

@@ -8,7 +8,8 @@ export const FireRouteName = {
 } as const;
 
 export const menuRouteNames = [
-  // FireRouteName.home,
+  FireRouteName.statistics,
+  FireRouteName.downloadLog,
   FireRouteName.vrmodel,
   FireRouteName.camera,
   FireRouteName.dispatch,

+ 2 - 1
src/app/xmfire/routeConfig.ts

@@ -8,7 +8,8 @@ export const FireRouteName = {
 } as const;
 
 export const menuRouteNames = [
-  // FireRouteName.home,
+  FireRouteName.statistics,
+  FireRouteName.downloadLog,
   FireRouteName.vrmodel,
   FireRouteName.camera,
   FireRouteName.dispatch,

+ 12 - 0
src/components/dialog/type.ts

@@ -9,3 +9,15 @@ export type DialogProps = {
   showDelete?: boolean;
   cornerClose?: boolean;
 };
+
+export const dialogPropsKeys: (keyof DialogProps)[] = [
+  "show",
+  "title",
+  "hideFloor",
+  "enterText",
+  "width",
+  "power",
+  "showClose",
+  "showDelete",
+  "cornerClose",
+];

+ 24 - 14
src/helper/mount.ts

@@ -1,7 +1,7 @@
 import { router } from "@/router";
 import { createVNode, reactive, render, watch, watchEffect } from "vue";
 import Locale from "@/config/locale.vue";
-import type { App, VNode } from "vue";
+import type { App, Ref, VNode } from "vue";
 
 export type MountContext<P = any> = {
   props?: P;
@@ -80,12 +80,12 @@ export const mountComponent = <P>(
 };
 
 import Dialog from "@/components/dialog/index.vue";
-import { DialogProps } from "@/components/dialog/type";
+import { DialogProps, dialogPropsKeys } from "@/components/dialog/type";
 
 export type QuiskExpose = {
   submit?: () => void;
   quit?: () => void;
-};
+} & Partial<{ [key in keyof DialogProps]?: Ref<DialogProps[key]> }>;
 
 export const quiskMountFactory =
   <P>(comp: ComponentConstructor<P>, dprops: DialogProps) =>
@@ -94,6 +94,7 @@ export const quiskMountFactory =
     dRef?: (expose: { quit: () => void; submit: () => void }) => void
   ): Promise<T> => {
     let ref: QuiskExpose;
+
     return new Promise((resolve) => {
       const api = {
         onQuit: async () => {
@@ -116,17 +117,26 @@ export const quiskMountFactory =
         },
       };
 
-      const destroy = mountComponent(
-        Dialog,
-        { ...dprops, ref: undefined, show: true, ...api },
-        {
-          default: () =>
-            createVNode(comp, {
-              ...props,
-              ref: (v: any) => (ref = v),
-            }),
-        }
-      );
+      const layoutProps = reactive({
+        ...dprops,
+        ref: undefined,
+        show: true,
+        ...api,
+      });
+      const destroy = mountComponent(Dialog, layoutProps, {
+        default: () =>
+          createVNode(comp, {
+            ...props,
+            ref: (v: any) => {
+              for (const key in v) {
+                if (dialogPropsKeys.includes(key as any)) {
+                  layoutProps[key] = v[key];
+                }
+              }
+              ref = v;
+            },
+          }),
+      });
 
       dRef &&
         dRef({

+ 2 - 1
src/request/config.ts

@@ -4,6 +4,7 @@ import {
   getAttachListByPsw,
   getCode,
   getCompanyList,
+  getDownloadProcess,
   getFireList,
   getMessageList,
   getModelSceneList,
@@ -62,4 +63,4 @@ export const successCode = [0, "000000", 200];
 // baseURL
 export const baseURL = import.meta.env.DEV ? "/api" : "";
 
-export const notOpenUrls: string[] = [uploadModel];
+export const notOpenUrls: string[] = [uploadModel, getDownloadProcess];

+ 12 - 3
src/request/urls.ts

@@ -68,6 +68,14 @@ export const delScene = "/fusion/scene/deleteNum";
 export const checkGenMeshScene = "/fusion/scene/sceneDetail";
 export const genMeshSceneByCloud = "/fusion/scene/buildSceneObj";
 
+// 统计
+export const sceneStatistics = `/fusion/data/sceneGroupByDept`;
+export const caseStatistics = `/fusion/data/projectGroupByDept`;
+export const cameraTypeStatistics = `/fusion/data/cameraGroupType`;
+export const caseTimeStatistics = `/fusion/data/FireTrend`;
+export const casePlaceStatistics = `/fusion/data/FirePlaceTrend`;
+export const caseReasonStatistics = `/fusion/data/FireReasonTrend`;
+
 // 获取模型场景列表
 export const getModelSceneList = `/fusion/model/list`;
 export const updateModelScene = `/fusion/model/updateTitle`;
@@ -197,11 +205,12 @@ export const uploadAttachImage = "/web/fireProject/uploadImage";
 /** ------------------------------------------ */
 
 // 下载校验
-export const checkHasDownload = "/web/scene/checkDownload/";
+export const checkHasDownload = "/fusion/sceneDownLog/checkDownLoad";
 // 下载获取进度条
-export const getDownloadProcess = "/web/scene/downloadProcess";
+export const getDownloadProcess = "/fusion/sceneDownLog/downloadProcess";
 // 下载
-export const downloadScene = "/web/scene/downloadScene/";
+export const downloadScene = "/fusion/sceneDownLog/downScene";
+export const downloadSceneList = "/fusion/sceneDownLog/list";
 // 带看相关接口
 export const offLine = "/web/fireProject/offLine"; //{roomId}
 export const onLine = "/web/fireProject/onLine"; //{roomId}

+ 8 - 2
src/router/config.ts

@@ -43,12 +43,18 @@ export const routes: Routes = [
     children: [
       ...system,
       {
-        name: RouteName.home,
+        name: RouteName.statistics,
         path: "home",
-        component: () => import("@/view/home/index.vue"),
+        component: () => import("@/view/statistics/index.vue"),
         meta: { title: "首页", icon: "iconfire_home" },
       },
       {
+        name: RouteName.downloadLog,
+        path: "download-log",
+        component: () => import("@/view/vrmodel/downloadLog.vue"),
+        meta: { title: "下载记录查询", icon: "iconfire_home" },
+      },
+      {
         name: RouteName.vrmodel,
         path: "vrmodel",
         component: () => import("@/view/vrmodel/index.vue"),

+ 2 - 1
src/router/routeName.ts

@@ -1,9 +1,10 @@
 export const RouteName = {
+  downloadLog: "downloadLog",
   login: "login",
   register: "register",
   forget: "forget",
   viewLayout: "viewLayout",
-  home: "home",
+  statistics: "statistics",
   vrmodel: "scene",
   camera: "camera",
   caseFile: "caseFile",

+ 1 - 1
src/store/permission.ts

@@ -19,7 +19,7 @@ changSaveLocal("permission", () => permission.value);
  * @param routeNames 所有路由
  */
 export const getPermissionRoutes = (routeNames: string[]) => {
-  console.log(permission.value);
+  console.error(permission.value);
   return routeNames
     .filter((routeName) =>
       permission.value.some((p) => p.resourceKey === routeName)

+ 30 - 1
src/store/scene.ts

@@ -7,6 +7,7 @@ import {
   checkGenMeshScene,
   delScene,
   deleteModel,
+  downloadSceneList,
   genMeshSceneByCloud,
   getModelRunProgress,
   getModelSceneList,
@@ -26,7 +27,7 @@ interface BaseScene {
 // 只有当location 为4 时,才能生成obj
 export enum LocationEnum {
   Scene_Location_Slam, //slam\n" +
-  Scene_Location_SFM, //sfm\n" +
+  Scene_Location_SFM, //sfm\n" +F
   Scene_Location_SFMAI, //SFM + AI\n" +
   Scene_Location_MutiFloor, //多楼层\n" +
   Scene_Location_PointCloud, //点云\n" +
@@ -157,6 +158,34 @@ export const getScenePagging = async (params: ScenePaggingParams) => {
 export const delQuoteScene = (scene: QuoteScene) =>
   axios.get(delScene, { params: { num: scene.num } });
 
+export type QueryDownloadQuoteSceneParams = PaggingReq<{
+  deptId: string;
+  userName: string;
+  nickName: string;
+  createTime: string;
+  sceneTitle: string;
+  sceneNum: string;
+  snCode: string;
+}>;
+
+export type DownloadQuoteSceneLog = {
+  id: number;
+  sceneNum: string;
+  sceneTitle: string;
+  userName: string;
+  nickName: string;
+  snCode: string;
+  deptLevelStr: string;
+  deptName: string;
+  deptId: string;
+  createTime: string;
+};
+export const getDownloadQuoteScene = async (
+  params: QueryDownloadQuoteSceneParams
+) =>
+  (await axios.post(downloadSceneList, params))
+    .data as PaggingRes<DownloadQuoteSceneLog>;
+
 export const genMeshScene = async (scene: QuoteScene) => {
   const res = (await axios.post(checkGenMeshScene, { id: scene.id })).data;
   if (res?.buildObjStatus === 2) {

+ 34 - 0
src/store/statistics.ts

@@ -0,0 +1,34 @@
+import {
+  axios,
+  cameraTypeStatistics,
+  casePlaceStatistics,
+  caseReasonStatistics,
+  caseStatistics,
+  caseTimeStatistics,
+  sceneStatistics,
+} from "@/request";
+
+export type StatisticsParams = {
+  startTime: string;
+  endTime: string;
+};
+export type StatisticsItem = { groupKey: string; dataCount: number };
+export type StatisticsItems = StatisticsItem[];
+
+export const getSceneStatistics = async (params: StatisticsParams) =>
+  (await axios.post<StatisticsItems>(sceneStatistics, params)).data;
+
+export const getCaseStatistics = async (params: StatisticsParams) =>
+  (await axios.post<StatisticsItems>(caseStatistics, params)).data;
+
+export const getCameraTypeStatistics = async (params: StatisticsParams) =>
+  (await axios.post<StatisticsItems>(cameraTypeStatistics, params)).data;
+
+export const getCaseTimeStatistics = async (params: StatisticsParams) =>
+  (await axios.post<StatisticsItems>(caseTimeStatistics, params)).data;
+
+export const getCasePlaceStatistics = async (params: StatisticsParams) =>
+  (await axios.post<StatisticsItems>(casePlaceStatistics, params)).data;
+
+export const getCaseReasonStatistics = async (params: StatisticsParams) =>
+  (await axios.post<StatisticsItems>(caseReasonStatistics, params)).data;

+ 1 - 0
src/store/user.ts

@@ -27,6 +27,7 @@ export type UserInfo = {
   deptId: string;
   deptName: string;
   id: string;
+  deptLevel: number;
   departmentId: string;
   cameraSns: string[];
   status: 1 | 0;

+ 52 - 3
src/util/image-rotate.ts

@@ -39,15 +39,19 @@ export const imageRotate = async (
   );
 };
 
+export const loadImage = async (blob: Blob) => {
+  const img = new Image();
+  img.src = URL.createObjectURL(blob);
+  return new Promise<HTMLImageElement>((resolve) => (img.onload = () => resolve(img)));
+};
+
 export const fixImageSize = async (
   blob: Blob,
   max: number,
   min: number,
   scale = true
 ) => {
-  const img = new Image();
-  img.src = URL.createObjectURL(blob);
-  await new Promise((resolve) => (img.onload = resolve));
+  const img = await loadImage(blob);
 
   let width = img.width;
   let height = img.height;
@@ -72,6 +76,7 @@ export const fixImageSize = async (
   let size = width > height ? width : height;
   size = size > min ? size : min;
 
+  console.log(size, width, height);
   const $canvas = document.createElement("canvas");
   $canvas.width = size;
   $canvas.height = size;
@@ -86,3 +91,47 @@ export const fixImageSize = async (
   );
   return newBlob;
 };
+
+export const coverImageSize = async (
+  blob: Blob,
+  coverWidth: number,
+  coverHeight: number,
+  scale = true
+) => {
+  const img = await loadImage(blob);
+
+  let width = img.width,
+    useWidth;
+  let height = img.height,
+    useHeight;
+
+  const proportion = coverWidth / coverHeight;
+  const cProportion = width / height;
+
+  if (cProportion > proportion) {
+    useWidth = width;
+    useHeight = width / proportion;
+  } else if (cProportion < proportion) {
+    // h偏大
+    useWidth = height * proportion;
+    useHeight = height;
+  }
+
+  const $canvas = document.createElement("canvas");
+  $canvas.width = useWidth;
+  $canvas.height = useHeight;
+  const ctx = $canvas.getContext("2d")!;
+  ctx.rect(0, 0, useWidth, useHeight);
+  ctx.fillStyle = "#fff";
+  ctx.fill();
+  ctx.drawImage(img, (useWidth - width) / 2, (useHeight - height) / 2, width, height);
+
+  const newBlob = await new Promise<Blob | null>((resolve) =>
+    $canvas.toBlob(resolve, "png")
+  );
+  return {
+    blob: newBlob!,
+    width: useWidth,
+    height: useHeight,
+  };
+};

+ 44 - 4
src/util/index.ts

@@ -1,4 +1,5 @@
 import { Base64 } from "js-base64";
+import { positionTransform } from "./mt4";
 
 export const dateFormat = (date: Date, fmt: string) => {
   var o: any = {
@@ -11,7 +12,10 @@ export const dateFormat = (date: Date, fmt: string) => {
     S: date.getMilliseconds(), //毫秒
   };
   if (/(y+)/.test(fmt)) {
-    fmt = fmt.replace(RegExp.$1, (date.getFullYear() + "").substr(4 - RegExp.$1.length));
+    fmt = fmt.replace(
+      RegExp.$1,
+      (date.getFullYear() + "").substr(4 - RegExp.$1.length)
+    );
   }
   for (var k in o) {
     if (new RegExp("(" + k + ")").test(fmt)) {
@@ -37,7 +41,10 @@ export const copyText = (text: string) => {
 };
 
 // 防抖
-export const debounce = <T extends (...args: any) => any>(fn: T, delay: number = 160) => {
+export const debounce = <T extends (...args: any) => any>(
+  fn: T,
+  delay: number = 160
+) => {
   let timeout: any;
 
   return function <This>(this: This, ...args: Parameters<T>) {
@@ -169,7 +176,10 @@ export function encodePwd(str: string, strv = "") {
   if (strv) {
     const strv1 = strv.substring(0, NUM);
     const strv2 = strv.substring(NUM);
-    return [front + str2 + middle + str1 + end, front + strv2 + middle + strv1 + end];
+    return [
+      front + str2 + middle + str1 + end,
+      front + strv2 + middle + strv1 + end,
+    ];
   }
 
   return front + str2 + middle + str1 + end;
@@ -234,7 +244,10 @@ export const drawImage = (
 ) => {
   let dWidth = bg_w / imgWidth; // canvas与图片的宽度比例
   let dHeight = bg_h / imgHeight; // canvas与图片的高度比例
-  if ((imgWidth > bg_w && imgHeight > bg_h) || (imgWidth < bg_w && imgHeight < bg_h)) {
+  if (
+    (imgWidth > bg_w && imgHeight > bg_h) ||
+    (imgWidth < bg_w && imgHeight < bg_h)
+  ) {
     if (dWidth > dHeight) {
       ctx.drawImage(
         imgPath,
@@ -313,3 +326,30 @@ export const strToParams = (str: string) => {
 
   return result;
 };
+
+export const getDomMatrix = (dom: HTMLElement) => {
+  const str = getComputedStyle(dom, null).getPropertyValue("transform");
+  const matrix2d = str
+    .substring(7, str.length - 2)
+    .split(", ")
+    .map(Number);
+
+  return [
+    matrix2d[0],
+    matrix2d[1],
+    0,
+    0,
+    matrix2d[2],
+    matrix2d[3],
+    0,
+    0,
+    0,
+    0,
+    1,
+    0,
+    matrix2d[4] + dom.offsetWidth / 2,
+    matrix2d[5] + dom.offsetHeight / 2,
+    0,
+    1,
+  ];
+};

+ 551 - 0
src/util/mt4.ts

@@ -0,0 +1,551 @@
+type NumArr = number[];
+export const identity = () => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
+
+// 转置矩阵
+export const transpose = (m: number[]) => {
+  return [
+    m[0],
+    m[4],
+    m[8],
+    m[12],
+    m[1],
+    m[5],
+    m[9],
+    m[13],
+    m[2],
+    m[6],
+    m[10],
+    m[14],
+    m[3],
+    m[7],
+    m[11],
+    m[15],
+  ];
+};
+
+export const orthographic = (
+  left: number,
+  right: number,
+  bottom: number,
+  top: number,
+  near: number,
+  far: number
+) => {
+  const dst = new Float32Array(16);
+
+  dst[0] = 2 / (right - left);
+  dst[1] = 0;
+  dst[2] = 0;
+  dst[3] = 0;
+  dst[4] = 0;
+  dst[5] = 2 / (top - bottom);
+  dst[6] = 0;
+  dst[7] = 0;
+  dst[8] = 0;
+  dst[9] = 0;
+  dst[10] = 2 / (near - far);
+  dst[11] = 0;
+  dst[12] = (left + right) / (left - right);
+  dst[13] = (bottom + top) / (bottom - top);
+  dst[14] = (near + far) / (near - far);
+  dst[15] = 1;
+
+  return dst;
+};
+
+export const getFrustumArgumentsOnMatrix = (projectionMatrix: NumArr) => {
+  const inverseProjectionMatrix = inverse(projectionMatrix);
+  const ltn = positionTransform([-1, 1, -1], inverseProjectionMatrix);
+  const rbn = positionTransform([1, -1, -1], inverseProjectionMatrix);
+  const ccf = positionTransform([0, 0, 1], inverseProjectionMatrix);
+
+  const [left, top, near] = ltn;
+  const [right, bottom] = rbn;
+  const far = ccf[2];
+
+  return {
+    left,
+    top,
+    right,
+    bottom,
+    near,
+    far,
+  };
+};
+
+export const frustum = (
+  left: number,
+  right: number,
+  bottom: number,
+  top: number,
+  near: number,
+  far: number
+) => {
+  const dst = new Float32Array(16);
+
+  var dx = right - left;
+  var dy = top - bottom;
+  var dz = far - near;
+
+  dst[0] = (2 * near) / dx;
+  dst[1] = 0;
+  dst[2] = 0;
+  dst[3] = 0;
+  dst[4] = 0;
+  dst[5] = (2 * near) / dy;
+  dst[6] = 0;
+  dst[7] = 0;
+  dst[8] = (left + right) / dx;
+  dst[9] = (top + bottom) / dy;
+  dst[10] = -(far + near) / dz;
+  dst[11] = -1;
+  dst[12] = 0;
+  dst[13] = 0;
+  dst[14] = (-2 * near * far) / dz;
+  dst[15] = 0;
+
+  return dst;
+};
+
+export const makeZToWMatrix = (fudgeFactor: number) => [
+  1,
+  0,
+  0,
+  0,
+  0,
+  1,
+  0,
+  0,
+  0,
+  0,
+  1,
+  fudgeFactor,
+  0,
+  0,
+  0,
+  1,
+];
+
+export const translate = (tx: number, ty: number, tz: number) => [
+  1,
+  0,
+  0,
+  0,
+  0,
+  1,
+  0,
+  0,
+  0,
+  0,
+  1,
+  0,
+  tx,
+  ty,
+  tz,
+  1,
+];
+
+export const rotateX = (angleInRadians: number) => {
+  const s = Math.sin(angleInRadians);
+  const c = Math.cos(angleInRadians);
+
+  return [1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1];
+};
+
+export const rotateY = (angleInRadians: number) => {
+  const s = Math.sin(angleInRadians);
+  const c = Math.cos(angleInRadians);
+
+  return [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1];
+};
+
+export const rotateZ = (angleInRadians: number) => {
+  const s = Math.sin(angleInRadians);
+  const c = Math.cos(angleInRadians);
+
+  return [c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
+};
+
+export const scale = (sx: number, sy: number, sz: number) => [
+  sx,
+  0,
+  0,
+  0,
+  0,
+  sy,
+  0,
+  0,
+  0,
+  0,
+  sz,
+  0,
+  0,
+  0,
+  0,
+  1,
+];
+
+// 正交
+export const projection = (width: number, height: number, depth: number) => [
+  2 / width,
+  0,
+  0,
+  0,
+  0,
+  -2 / height,
+  0,
+  0,
+  0,
+  0,
+  2 / depth,
+  0,
+  -1,
+  1,
+  0,
+  1,
+];
+
+// 根据三维bound转化,正交形式
+export const orthogonal = (
+  left: number,
+  right: number,
+  top: number,
+  bottom: number,
+  near: number,
+  far: number
+) => [
+  2 / (right - left),
+  0,
+  0,
+  0,
+  0,
+  2 / (bottom - top),
+  0,
+  0,
+  0,
+  0,
+  2 / (far - near),
+  0,
+  (left + right) / (left - right),
+  (top + bottom) / (top - bottom),
+  (far + near) / (near - far),
+  1,
+];
+
+export const perspective = (
+  l: number,
+  r: number,
+  t: number,
+  b: number,
+  n: number,
+  f: number
+) => [
+  (2 * n) / (r - l),
+  0,
+  0,
+  0,
+  0,
+  (2 * n) / (b - t),
+  0,
+  0,
+  (l + r) / (l - r),
+  (b + t) / (t - b),
+  2 / (f - n),
+  1,
+  0,
+  0,
+  -(f + n) / (n - f),
+  0,
+];
+
+// 正中间投影 l r 和 t b对称
+export const straightPerspective = (w: number, h: number, n: number, f: number) => {
+  return [
+    (2 * n) / w,
+    0,
+    0,
+    0,
+    0,
+    (-2 * n) / h,
+    0,
+    0,
+    0,
+    0,
+    2 / (f - n),
+    1,
+    0,
+    0,
+    -(f + n) / (n - f),
+    0,
+  ];
+};
+
+/**
+ * @param fieldOfViewInRadians 可视角度
+ * @param aspect w / h 比例
+ * @param near 近面
+ * @param far 远面
+ */
+export const straightPerspective1 = (
+  fieldOfViewInRadians: number,
+  aspect: number,
+  near: number,
+  far: number
+) => {
+  // const a = Math.atan((Math.PI  - fieldOfViewInRadians) / 2)
+
+  // return [
+  //   a/aspect, 0, 0,           0,
+  //   0,  -a, 0,           0,
+  //   0,   0, 1/(far-near),     1,
+  //   0,   0, -(near)/(far-near), 0
+  // ]
+  var f = Math.tan(Math.PI * 0.5 - 0.5 * fieldOfViewInRadians);
+  var rangeInv = 1.0 / (near - far);
+
+  return [
+    f / aspect,
+    0,
+    0,
+    0,
+    0,
+    f,
+    0,
+    0,
+    0,
+    0,
+    (near + far) * rangeInv,
+    -1,
+    0,
+    0,
+    near * far * rangeInv * 2,
+    0,
+  ];
+};
+
+export const multiply = (...matrixs: NumArr[]): NumArr => {
+  if (matrixs.length === 1) {
+    return matrixs[0];
+  }
+
+  const radio = 4;
+  const count = radio * radio;
+  const result: number[] = [];
+
+  for (let i = 0; i < count; i++) {
+    const row = Math.floor(i / radio);
+    const column = i % radio;
+
+    let currentResult = 0;
+    for (let offset = 0; offset < radio; offset++) {
+      const rowIndex = row * radio + offset;
+      const columnIndex = column + offset * radio;
+      currentResult += matrixs[1][rowIndex] * matrixs[0][columnIndex];
+    }
+    result[i] = currentResult;
+  }
+
+  if (matrixs.length === 2) {
+    return result;
+  } else {
+    return multiply(result, ...matrixs.slice(2));
+  }
+};
+
+export const positionTransform = (pos: NumArr, matrix: number[]) => {
+  const radio = 4;
+  const w =
+    pos[0] * matrix[3] +
+    pos[1] * matrix[radio + 3] +
+    pos[2] * matrix[radio * 2 + 3] +
+    matrix[radio * 3 + 3];
+  return [
+    (pos[0] * matrix[0] +
+      pos[1] * matrix[radio] +
+      pos[2] * matrix[radio * 2] +
+      matrix[radio * 3]) /
+      w,
+    (pos[0] * matrix[1] +
+      pos[1] * matrix[radio + 1] +
+      pos[2] * matrix[radio * 2 + 1] +
+      matrix[radio * 3 + 1]) /
+      w,
+    (pos[0] * matrix[2] +
+      pos[1] * matrix[radio + 2] +
+      pos[2] * matrix[radio * 2 + 2] +
+      matrix[radio * 3 + 2]) /
+      w,
+  ];
+};
+
+export const addVectors = (a: NumArr, b: NumArr, v: NumArr = []) => {
+  v[0] = a[0] + b[0];
+  v[1] = a[1] + b[1];
+  v[2] = a[2] + b[2];
+  return v;
+};
+
+export const subtractVectors = (a: NumArr, b: NumArr) => [
+  a[0] - b[0],
+  a[1] - b[1],
+  a[2] - b[2],
+];
+
+export const scaleVector = (a: NumArr, b: number) => [a[0] * b, a[1] * b, a[2] * b];
+
+export const normalVector = (v: NumArr, cv: NumArr = []) => {
+  const [x, y, z] = v;
+  const len = Math.sqrt(x * x + y * y + z * z);
+  if (len > 0) {
+    cv[0] = x / len;
+    cv[1] = y / len;
+    cv[2] = z / len;
+  } else {
+    cv[0] = 0;
+    cv[1] = 0;
+    cv[2] = 0;
+  }
+  return cv;
+};
+
+// 向量叉乘,叉乘结果向量必然同事垂直两个向量
+export const cross = (a: NumArr, b: NumArr) => [
+  a[1] * b[2] - a[2] * b[1],
+  a[2] * b[0] - a[0] * b[2],
+  a[0] * b[1] - a[1] * b[0],
+];
+
+// 对准一个目标(实际上是制作一个矩阵,将target扭转到cameraPosition)
+export const lookAt = (cameraPosition: number[], target: number[], up: number[]) => {
+  // camera是正对-z轴的 所以需要反向
+  const zAxis = normalVector(subtractVectors(cameraPosition, target));
+  // 相对于zAxis做出x朝向,注意顺序不能反,因为三维有两个垂直朝向,通过叉乘通过顺序确认
+  const xAxis = normalVector(cross(up, zAxis));
+  const yAxis = normalVector(cross(zAxis, xAxis));
+
+  return [...xAxis, 0, ...yAxis, 0, ...zAxis, 0, ...cameraPosition, 1];
+};
+
+export const dot = (v1: NumArr, v2: NumArr) =>
+  v1[0] * v2[0] + v1[1] * v2[1] + v1[2] * v2[2];
+
+export const inverse = (m: NumArr) => {
+  var m00 = m[0 * 4 + 0];
+  var m01 = m[0 * 4 + 1];
+  var m02 = m[0 * 4 + 2];
+  var m03 = m[0 * 4 + 3];
+  var m10 = m[1 * 4 + 0];
+  var m11 = m[1 * 4 + 1];
+  var m12 = m[1 * 4 + 2];
+  var m13 = m[1 * 4 + 3];
+  var m20 = m[2 * 4 + 0];
+  var m21 = m[2 * 4 + 1];
+  var m22 = m[2 * 4 + 2];
+  var m23 = m[2 * 4 + 3];
+  var m30 = m[3 * 4 + 0];
+  var m31 = m[3 * 4 + 1];
+  var m32 = m[3 * 4 + 2];
+  var m33 = m[3 * 4 + 3];
+  var tmp_0 = m22 * m33;
+  var tmp_1 = m32 * m23;
+  var tmp_2 = m12 * m33;
+  var tmp_3 = m32 * m13;
+  var tmp_4 = m12 * m23;
+  var tmp_5 = m22 * m13;
+  var tmp_6 = m02 * m33;
+  var tmp_7 = m32 * m03;
+  var tmp_8 = m02 * m23;
+  var tmp_9 = m22 * m03;
+  var tmp_10 = m02 * m13;
+  var tmp_11 = m12 * m03;
+  var tmp_12 = m20 * m31;
+  var tmp_13 = m30 * m21;
+  var tmp_14 = m10 * m31;
+  var tmp_15 = m30 * m11;
+  var tmp_16 = m10 * m21;
+  var tmp_17 = m20 * m11;
+  var tmp_18 = m00 * m31;
+  var tmp_19 = m30 * m01;
+  var tmp_20 = m00 * m21;
+  var tmp_21 = m20 * m01;
+  var tmp_22 = m00 * m11;
+  var tmp_23 = m10 * m01;
+
+  var t0 =
+    tmp_0 * m11 + tmp_3 * m21 + tmp_4 * m31 - (tmp_1 * m11 + tmp_2 * m21 + tmp_5 * m31);
+  var t1 =
+    tmp_1 * m01 + tmp_6 * m21 + tmp_9 * m31 - (tmp_0 * m01 + tmp_7 * m21 + tmp_8 * m31);
+  var t2 =
+    tmp_2 * m01 + tmp_7 * m11 + tmp_10 * m31 - (tmp_3 * m01 + tmp_6 * m11 + tmp_11 * m31);
+  var t3 =
+    tmp_5 * m01 + tmp_8 * m11 + tmp_11 * m21 - (tmp_4 * m01 + tmp_9 * m11 + tmp_10 * m21);
+
+  var d = 1.0 / (m00 * t0 + m10 * t1 + m20 * t2 + m30 * t3);
+
+  return [
+    d * t0,
+    d * t1,
+    d * t2,
+    d * t3,
+    d *
+      (tmp_1 * m10 +
+        tmp_2 * m20 +
+        tmp_5 * m30 -
+        (tmp_0 * m10 + tmp_3 * m20 + tmp_4 * m30)),
+    d *
+      (tmp_0 * m00 +
+        tmp_7 * m20 +
+        tmp_8 * m30 -
+        (tmp_1 * m00 + tmp_6 * m20 + tmp_9 * m30)),
+    d *
+      (tmp_3 * m00 +
+        tmp_6 * m10 +
+        tmp_11 * m30 -
+        (tmp_2 * m00 + tmp_7 * m10 + tmp_10 * m30)),
+    d *
+      (tmp_4 * m00 +
+        tmp_9 * m10 +
+        tmp_10 * m20 -
+        (tmp_5 * m00 + tmp_8 * m10 + tmp_11 * m20)),
+    d *
+      (tmp_12 * m13 +
+        tmp_15 * m23 +
+        tmp_16 * m33 -
+        (tmp_13 * m13 + tmp_14 * m23 + tmp_17 * m33)),
+    d *
+      (tmp_13 * m03 +
+        tmp_18 * m23 +
+        tmp_21 * m33 -
+        (tmp_12 * m03 + tmp_19 * m23 + tmp_20 * m33)),
+    d *
+      (tmp_14 * m03 +
+        tmp_19 * m13 +
+        tmp_22 * m33 -
+        (tmp_15 * m03 + tmp_18 * m13 + tmp_23 * m33)),
+    d *
+      (tmp_17 * m03 +
+        tmp_20 * m13 +
+        tmp_23 * m23 -
+        (tmp_16 * m03 + tmp_21 * m13 + tmp_22 * m23)),
+    d *
+      (tmp_14 * m22 +
+        tmp_17 * m32 +
+        tmp_13 * m12 -
+        (tmp_16 * m32 + tmp_12 * m12 + tmp_15 * m22)),
+    d *
+      (tmp_20 * m32 +
+        tmp_12 * m02 +
+        tmp_19 * m22 -
+        (tmp_18 * m22 + tmp_21 * m32 + tmp_13 * m02)),
+    d *
+      (tmp_18 * m12 +
+        tmp_23 * m32 +
+        tmp_15 * m02 -
+        (tmp_22 * m32 + tmp_14 * m02 + tmp_19 * m12)),
+    d *
+      (tmp_22 * m22 +
+        tmp_16 * m02 +
+        tmp_21 * m12 -
+        (tmp_20 * m12 + tmp_23 * m22 + tmp_17 * m02)),
+  ];
+};

+ 1 - 0
src/view/case/draw/board/editCAD/Controls/UIControl.js

@@ -23,6 +23,7 @@ export default class UIControl {
     this.layer = layer;
     this.bus = mitt();
     this.selectUI = null;
+    this.appendData = null;
 
     // this.bus.emit('')
   }

+ 4 - 1
src/view/case/draw/board/index.d.ts

@@ -20,11 +20,13 @@ import {
   compass,
   title,
   bgImage,
+  customImage,
 } from "./shape";
 
 type Metas = typeof fMetas;
 export type ShapeType =
   | typeof brokenLine
+  | typeof customImage
   | typeof text
   | typeof table
   | typeof rect
@@ -71,7 +73,7 @@ export type Board = {
     forwardDisabled: boolean;
   }>;
   unSelectShape(): void;
-  readyAddShape(type: MetaShapeType, onFinish?: () => void): () => void;
+  readyAddShape(type: MetaShapeType, data: any, onFinish?: () => void): () => void;
   back(): void;
   forward(): void;
   viewInit(): void;
@@ -105,6 +107,7 @@ export {
   cigarette,
   fireoint,
   footPrint,
+  customImage,
   shoePrint,
   fingerPrint,
   corpse,

+ 45 - 57
src/view/case/draw/board/index.js

@@ -1,5 +1,5 @@
 import mitt from "mitt";
-import { text, table, compass, title, bgImage } from "./shape";
+import { text, table, compass, title, bgImage, customImage } from "./shape";
 import Layer from "./editCAD/Layer";
 import { history } from "./editCAD/History/History.js";
 import { uploadFile } from "@/store/system";
@@ -21,66 +21,46 @@ export const create = async (store, canvas) => {
 
   const layer = new Layer();
   await layer.start(canvas, store);
+  const defaultData = {
+    color: "#000",
+    text: "",
+    fontSize: 12,
+    scale: 1,
+    rotate: 0,
+    content: [
+      { width: 160, height: 60, value: "", colIndex: 0, rowIndex: 0 },
+      { width: 160, height: 60, value: "", colIndex: 1, rowIndex: 0 },
+    ],
+  };
+
   layer.uiControl.bus.on("showAttribute", ({ type, value: data }) => {
-    data = data || {
-      color: "#000",
-      fontSize: 12,
-    };
+    data =
+      data && typeof data === "object"
+        ? Object.assign({ ...defaultData }, data)
+        : { ...defaultData };
+
+    const method = Object.fromEntries(
+      Object.keys(data).map((key) => [
+        `set${key.slice(0, 1).toUpperCase() + key.slice(1)}`,
+        (value) => {
+          update({ [key]: value });
+        },
+      ])
+    );
 
     const shape = {
-      data: { type, color: data.color, fontSize: data.fontSize },
-      setColor: (ncolor) => {
-        shape.data.color = ncolor;
-        update({ color });
-      },
-      setFontSize: (fontSize) => {
-        shape.data.fontSize = fontSize;
-        update({ fontSize });
-      },
-
+      data: { type, ...data },
+      ...method,
       delete: () => {
         layer.uiControl.clearUI();
         layer.uiControl.setAttributes(type, "delete");
       },
     };
     const update = (newData) => {
-      layer.uiControl.setAttributes(type, "update", newData);
+      Object.assign(shape.data, newData);
+      console.log("set", newData);
+      layer.uiControl.setAttributes(type, "update", { ...newData, version: 2 });
     };
-    switch (type) {
-      case table: {
-        data = data || [
-          { width: 160, height: 60, value: "", colIndex: 0, rowIndex: 0 },
-          { width: 160, height: 60, value: "", colIndex: 1, rowIndex: 0 },
-        ];
-        shape.data.content = data;
-        shape.setContent = (newData) => {
-          shape.data.content = newData;
-          update(newData);
-        };
-        break;
-      }
-      case title:
-      case text: {
-        data = data || "";
-        shape.data.text = data;
-        shape.setText = (newData) => {
-          shape.data.text = newData;
-          console.error(newData);
-          update(newData);
-        };
-        break;
-      }
-      case compass: {
-        data = data || 0;
-        shape.data.rotate = data;
-        shape.setRotate = (newData) => {
-          shape.data.rotate = newData;
-          update(newData);
-        };
-      }
-    }
-
-    console.log(shape);
     refs.bus.emit("selectShape", shape);
   });
   layer.uiControl.bus.on("hideAttribute", () => {
@@ -144,14 +124,22 @@ export const create = async (store, canvas) => {
     initHistory() {
       history.init();
     },
-    readyAddShape(shapeType, onFine) {
+    readyAddShape(shapeType, data, onFine) {
       layer.uiControl.selectUI = shapeType;
-      layer.uiControl.updateEventNameForSelectUI();
-      const finePack = () => {
-        layer.uiControl.bus.off("hideUI", finePack);
+      if (customImage === shapeType) {
+        layer.uiControl.setAttributes(shapeType, "upload", {
+          url: data,
+          version: 2,
+        });
         onFine();
-      };
-      layer.uiControl.bus.on("hideUI", finePack);
+      } else {
+        layer.uiControl.updateEventNameForSelectUI();
+        const finePack = () => {
+          layer.uiControl.bus.off("hideUI", finePack);
+          onFine();
+        };
+        layer.uiControl.bus.on("hideUI", finePack);
+      }
     },
     back() {
       history.handleUndo();

+ 1 - 0
src/view/case/draw/board/shape.js

@@ -35,6 +35,7 @@ export const theBlood = "BloodStain";
 export const title = "Title";
 export const bgImage = "BgImage";
 export const compass = "Compass";
+export const customImage = "CustomImage";
 
 export const labels = [brokenLine, text, table, rect, circular, arrow, icon];
 

+ 8 - 1
src/view/case/draw/board/useBoard.ts

@@ -93,6 +93,7 @@ export type BoardState = {
   forwardDisabled: boolean;
   selectShape: BoardShape | null;
   addShape: MetaShapeType | null;
+  addData: any;
 };
 export const useBoard = (props: Ref<BoardProps | null>) => {
   const board = ref<Board>();
@@ -101,6 +102,7 @@ export const useBoard = (props: Ref<BoardProps | null>) => {
     forwardDisabled: true,
     selectShape: null,
     addShape: null,
+    addData: null,
   });
 
   watchEffect(async (onCleanup) => {
@@ -174,11 +176,16 @@ export const useBoard = (props: Ref<BoardProps | null>) => {
     if (board.value && state.value.addShape) {
       const cleaup = board.value.readyAddShape(
         state.value.addShape,
-        () => (state.value.addShape = null)
+        state.value.addData || null,
+        () => {
+          state.value.addShape = null;
+          state.value.addData = null;
+        }
       );
       const keyupHandler = (ev: KeyboardEvent) => {
         if (ev.key === "Escape") {
           state.value.addShape = null;
+          state.value.addData = null;
           cleaup();
         }
       };

+ 28 - 0
src/view/case/draw/edit-shape/image.vue

@@ -0,0 +1,28 @@
+<template>
+  <el-form-item label="旋转:">
+    <el-slider style="width: 100px" v-model="rotate" :min="0" :max="360" />
+  </el-form-item>
+  <el-form-item label="缩放:">
+    <el-slider style="width: 100px" v-model="scale" :min="0.5" :step="0.01" :max="5" />
+  </el-form-item>
+</template>
+<script setup lang="ts">
+import { ref, watchEffect } from "vue";
+import { BoardShape } from "../board";
+
+const props = defineProps<{ shape: BoardShape }>();
+const emit = defineEmits<{
+  (e: "delete"): void;
+  (e: "blur"): void;
+  (e: "inputIng", ing: boolean): void;
+}>();
+
+const rotate = ref<number>(props.shape.data.rotate);
+const scale = ref<number>(props.shape.data.scale);
+watchEffect(() => {
+  props.shape.setRotate(rotate);
+});
+watchEffect(() => {
+  props.shape.setScale(scale);
+});
+</script>

+ 2 - 0
src/view/case/draw/edit-shape/index.ts

@@ -1,4 +1,5 @@
 import { markRaw, reactive } from "vue";
+import { images } from "../board/useBoard";
 
 const componentLoads = import.meta.glob("./*.vue");
 
@@ -6,6 +7,7 @@ export const components: { [key in string]: any } = reactive({});
 
 const map = {
   label: ["Circle", "Rectangle", "Wall"],
+  image: images,
 };
 
 Object.entries(componentLoads).map(([name, fn]) => {

+ 4 - 5
src/view/case/draw/edit-shape/label.vue

@@ -4,7 +4,7 @@
   </el-form-item>
 </template>
 <script setup lang="ts">
-import { ref } from "vue";
+import { ref, watchEffect } from "vue";
 import { BoardShape } from "../board";
 import { ElColorPicker } from "element-plus";
 
@@ -12,8 +12,7 @@ const props = defineProps<{ shape: BoardShape }>();
 const emit = defineEmits<{ (e: "blur"): void }>();
 const value = ref<string>(props.shape.data.color || "#000000");
 
-const setColor = (color: string) => {
-  value.value = color;
-  props.shape.setRotate(color);
-};
+watchEffect(() => {
+  props.shape.setColor(value.value);
+});
 </script>

+ 2 - 2
src/view/case/draw/edit-shape/tag.vue

@@ -7,7 +7,6 @@
       :maxlength="50"
       style="width: 200px"
       v-model="text"
-      @change="shape.setText(text)"
     >
       <template #append>
         <el-button type="primary" @click="$emit('blur')">确定</el-button>
@@ -47,5 +46,6 @@ for (let i = fontSizeRange[0]; i <= fontSizeRange[1]; i++) {
 
 const text = ref(props.shape.data.text);
 const fontSize = ref(props.shape.data.fontSize);
-// watchEffect(() => props.shape.setFontSize(fontSize.value));
+watchEffect(() => props.shape.setFontSize(fontSize.value));
+watchEffect(() => props.shape.setText(text.value));
 </script>

+ 2 - 2
src/view/case/draw/edit-shape/title.vue

@@ -6,7 +6,6 @@
       :maxlength="500"
       style="width: 220px"
       v-model="value"
-      @change="shape.setText(value)"
     >
       <template #append>
         <el-button type="primary" @click="$emit('blur')">确定</el-button>
@@ -15,7 +14,7 @@
   </el-form-item>
 </template>
 <script setup lang="ts">
-import { ref } from "vue";
+import { ref, watchEffect } from "vue";
 import { BoardShape } from "../board";
 
 const props = defineProps<{ shape: BoardShape }>();
@@ -26,4 +25,5 @@ const emit = defineEmits<{
 }>();
 
 const value = ref(props.shape.data.text);
+watchEffect(() => props.shape.setText(value.value));
 </script>

+ 70 - 18
src/view/case/draw/editEshapeTable.vue

@@ -34,9 +34,9 @@
         <th
           class="sel-th del-col"
           @click="select = { row: rowIndex }"
-          width="100px"
           :class="{ active: rowIndex === select.row }"
         >
+          <div style="width: 100px"></div>
           <el-button type="primary" plain size="small" @click="delRow(rowIndex)">
             <el-icon><Minus /></el-icon>
           </el-button>
@@ -44,7 +44,18 @@
       </tr>
     </table>
   </div>
-  <div class="setter"></div>
+  <div class="setter" v-if="selectValue">
+    <el-form-item :label="selectValue.label">
+      <el-input-number
+        :modelValue="selectValue.value"
+        @update:modelValue="changeSelectValue"
+        :min="24"
+        :max="1000"
+        size="small"
+        controls-position="right"
+      />
+    </el-form-item>
+  </div>
   <div class="add-row-layout">
     <el-button type="primary" @click="addRow">
       <el-icon><Plus /></el-icon> 行
@@ -57,7 +68,7 @@
 
 <script setup lang="ts">
 import { ElMessage } from "element-plus";
-import { computed, ref, watchEffect } from "vue";
+import { computed, nextTick, ref, watchEffect } from "vue";
 import { QuiskExpose } from "@/helper/mount";
 
 export type EshapeTableContent = {
@@ -74,6 +85,34 @@ const inputPos = ref<[number, number] | null>(null);
 const inputRef = ref<HTMLInputElement>();
 const select = ref<{ col?: number; row?: number }>({});
 
+const selectValue = computed(() => {
+  if (Object.keys(select.value).length === 0) return;
+  const isCol = "col" in select.value;
+  const item = bindContent.value.find((item) =>
+    isCol ? item.colIndex === select.value.col : item.rowIndex === select.value.row
+  )!;
+  if (item) {
+    return isCol
+      ? { label: "列宽", value: item.width }
+      : { label: "行高", value: item.height };
+  }
+});
+
+const changeSelectValue = (val: number) => {
+  const isCol = "col" in select.value;
+  bindContent.value
+    .filter((item) =>
+      isCol ? item.colIndex === select.value.col : item.rowIndex === select.value.row
+    )
+    .forEach((item) => {
+      if (isCol) {
+        item.width = val;
+      } else {
+        item.height = val;
+      }
+    });
+};
+
 const getBound = (rowIndex, colIndex) => {
   const item = bindContent.value.find(
     (item) => item.rowIndex === rowIndex && item.colIndex === colIndex
@@ -82,6 +121,8 @@ const getBound = (rowIndex, colIndex) => {
   if (item && item.width && item.height) {
     bound[0] = item.width;
     bound[1] = item.height;
+  } else if (colIndex === 0) {
+    bound[0] = 90;
   }
 
   return {
@@ -145,6 +186,7 @@ const addRow = () => {
       value: "",
     });
   }
+  nextTick(updateTableContent);
 };
 const addCloumn = () => {
   const colSize = rows.value[0].length;
@@ -158,6 +200,7 @@ const addCloumn = () => {
       value: "",
     });
   }
+  nextTick(updateTableContent);
   // for (let i = 0; i < colSize; i++) {
   //   bindContent.value.push({
   //     width: 0,
@@ -186,23 +229,26 @@ const rows = computed(() => {
 });
 
 const tableRef = ref<HTMLTableElement>();
-defineExpose<QuiskExpose>({
-  submit() {
-    const dom = tableRef.value!;
-    const rows = Array.from(dom.querySelectorAll(".row"));
-    for (let i = 0; i < rows.length; i++) {
-      const cols = Array.from(rows[i].querySelectorAll(".col"));
+const updateTableContent = () => {
+  const dom = tableRef.value!;
+  const rows = Array.from(dom.querySelectorAll(".row"));
+  for (let i = 0; i < rows.length; i++) {
+    const cols = Array.from(rows[i].querySelectorAll(".col"));
 
-      for (let j = 0; j < cols.length; j++) {
-        const col = cols[j] as HTMLElement;
-        console.log(bindContent, i, j);
-        const item = bindContent.value.find(
-          (item) => item.rowIndex === i && item.colIndex === j
-        )!;
-        item.width = col.offsetWidth;
-        item.height = col.offsetHeight;
-      }
+    for (let j = 0; j < cols.length; j++) {
+      const col = cols[j] as HTMLElement;
+      const item = bindContent.value.find(
+        (item) => item.rowIndex === i && item.colIndex === j
+      )!;
+      item.width = col.offsetWidth;
+      item.height = col.offsetHeight;
     }
+  }
+};
+
+defineExpose<QuiskExpose>({
+  submit() {
+    updateTableContent();
     return bindContent.value;
   },
 });
@@ -278,4 +324,10 @@ defineExpose<QuiskExpose>({
   overflow: auto;
   text-align: center;
 }
+.setter {
+  text-align: center;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
 </style>

+ 2 - 1
src/view/case/draw/eshape.vue

@@ -94,11 +94,12 @@ onUnmounted(() =>
 
 .def-shape-edit-form {
   max-width: 500px;
-  overflow: hidden;
   display: flex;
   flex-wrap: wrap;
   align-items: center;
   .el-form-item {
+    margin-left: 10px;
+    margin-right: 10px;
     flex: 0 0 auto;
     display: flex;
     align-items: center;

+ 23 - 9
src/view/case/draw/index.vue

@@ -19,15 +19,18 @@
       <div class="df-sider">
         <Slider
           :type="props.type"
-          v-model:add-shape="state.addShape"
+          :add-shape="state.addShape"
+          @update:add-shape="updateAddShape"
           @track-image="trackImage"
           @selectImage="setBackImage"
           v-if="props"
         />
       </div>
       <div class="df-content">
-        <div class="df-board">
-          <canvas ref="dom" />
+        <div class="df-content-layout">
+          <div class="df-board">
+            <canvas ref="dom" />
+          </div>
         </div>
       </div>
     </div>
@@ -51,6 +54,7 @@ import {
   TitleShapeData,
   saveCaseFileImageInfo,
 } from "@/store/caseFile";
+import { uploadFile } from "@/store/system";
 
 const dom = ref<HTMLCanvasElement>();
 const props = computed(() => {
@@ -74,6 +78,13 @@ const setBackImage = (blob: Blob) => {
   board.value!.setImage(URL.createObjectURL(blob));
 };
 
+const updateAddShape = async (s, d) => {
+  if (d) {
+    state.value.addData = await uploadFile(d);
+  }
+  state.value.addShape = s;
+};
+
 const trackImage = async () => {
   const data =
     props.value!.type === BoardType.scene
@@ -170,22 +181,25 @@ const exportHandler = async () => {
 
 .df-content {
   flex: 1;
-  display: flex;
+  display: grid;
   align-items: center;
   justify-content: center;
   height: 100%;
+  overflow: auto;
 }
 
-.df-board {
+.df-content-layout {
   --w: 297px;
   --h: 210px;
   --padding: 20px;
-  --calc: 3.5;
+  --calc: 3.1;
+  border: calc(var(--padding) * var(--calc)) solid #fff;
+}
 
+.df-board {
   border: 1px solid #000;
-  outline: calc(var(--padding) * var(--calc)) solid #fff;
-  width: calc((var(--w) - var(--padding)) * var(--calc));
-  height: calc((var(--h) - var(--padding)) * var(--calc));
+  width: calc(var(--w) * var(--calc));
+  height: calc(var(--h) * var(--calc));
   box-sizing: border-box;
   canvas {
     background: #fff;

+ 85 - 37
src/view/case/draw/slider.vue

@@ -10,47 +10,80 @@
         :limit="1"
         :show-file-list="false"
         :http-request="() => {}"
-        :disabled="!!percentage"
-        :before-upload="upload"
-        :accept="accept"
-        :file-list="fileList"
+        :disabled="!!cover.percentage"
+        :before-upload="cover.upload"
+        :accept="cover.accept"
+        :file-list="cover.fileList"
       >
         <el-button
           ghost
           type="primary"
-          :class="{ dispable: percentage }"
+          :class="{ dispable: cover.percentage }"
           :style="{ width: '100%' }"
         >
-          {{ percentage ? "文件上传中" : "上传" + fileDesc[type] }}
+          {{ cover.percentage ? "文件上传中" : "上传" + fileDesc[type] }}
         </el-button>
       </el-upload>
     </div>
-    <template v-for="typeShapes in typesShapes">
-      <h3>{{ typeShapes.name }}</h3>
-      <div class="df-shape-layout">
-        <div
-          v-for="label in typeShapes.shapes"
-          :key="label"
-          class="df-slide-shape"
-          @click="emit('update:addShape', label)"
-          :class="{ active: addShape === label }"
-        >
-          <img :src="shapes[label]" />
-          <p>{{ metas[label].desc }}</p>
-        </div>
+    <h3>标注</h3>
+    <div class="df-shape-layout">
+      <div
+        v-for="label in labels"
+        :key="label"
+        class="df-slide-shape"
+        @click="emit('update:addShape', label)"
+        :class="{ active: addShape === label }"
+      >
+        <img :src="shapes[label]" />
+        <p>{{ metas[label].desc }}</p>
+      </div>
+    </div>
+
+    <h3>图例</h3>
+    <el-upload
+      :multiple="false"
+      :limit="1"
+      :show-file-list="false"
+      :http-request="() => {}"
+      :disabled="!!imageLabel.percentage"
+      :before-upload="imageLabel.upload"
+      :accept="imageLabel.accept"
+      :file-list="imageLabel.fileList"
+    >
+      <el-button
+        ghost
+        type="primary"
+        :class="{ dispable: imageLabel.percentage }"
+        :style="{ width: '100%' }"
+      >
+        {{ imageLabel.percentage ? "文件上传中" : "上传图例" }}
+      </el-button>
+    </el-upload>
+    <div class="df-shape-layout">
+      <div
+        v-for="label in images"
+        :key="label"
+        class="df-slide-shape"
+        @click="emit('update:addShape', label)"
+        :class="{ active: addShape === label }"
+      >
+        <img :src="shapes[label]" />
+        <p>{{ metas[label].desc }}</p>
       </div>
-    </template>
+    </div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { metas, labels, images, shapes, MetaShapeType } from "./board";
+import { metas, labels, images, shapes, MetaShapeType, customImage } from "./board";
 import { BoardType } from "@/store/caseFile";
 import { maxFileSize } from "@/constant/caseFile";
 import { useUpload } from "@/hook/upload";
-import { computed, watchEffect } from "vue";
+import { reactive, watchEffect } from "vue";
 import { imageCropper } from "@/view/system/quisk";
-import { fixImageSize } from "@/util/image-rotate";
+import { coverImageSize } from "@/util/image-rotate";
+import { uploadFile } from "@/store/system";
+import { ElMessage } from "element-plus";
 
 defineProps<{
   type: BoardType;
@@ -61,28 +94,43 @@ const fileDesc = {
   [BoardType.scene]: "户型图",
   [BoardType.map]: "方位图",
 };
-const typesShapes = [
-  { name: "标注", shapes: labels },
-  { name: "图例", shapes: images },
-];
-
 const emit = defineEmits<{
-  (e: "update:addShape", val: MetaShapeType | null): void;
+  (e: "update:addShape", val: MetaShapeType | null, data?: any): void;
   (e: "trackImage"): void;
   (e: "selectImage", val: Blob): void;
 }>();
 
-const { percentage, upload, file, fileList, removeFile, accept } = useUpload({
-  maxSize: maxFileSize,
-  formats: [".jpg", ".png"],
-});
+const cover = reactive(
+  useUpload({
+    maxSize: maxFileSize,
+    formats: [".jpg", ".png"],
+  })
+);
 
 watchEffect(async () => {
-  if (file.value) {
-    const newFile = (await fixImageSize(file.value, 500, 500, false)) || file.value;
-    const data = await imageCropper({ img: newFile, fixed: [500, 500] });
+  if (cover.file) {
+    const coverImage = (await coverImageSize(cover.file, 540, 390, false)) || cover.file;
+    const data = await imageCropper({
+      img: coverImage.blob,
+      fixed: [coverImage.width, coverImage.height],
+    });
     data && emit("selectImage", data);
-    removeFile();
+    cover.removeFile();
+  }
+});
+
+const imageLabel = reactive(
+  useUpload({
+    maxSize: maxFileSize,
+    formats: [".jpg", ".png"],
+  })
+);
+
+watchEffect(async () => {
+  if (imageLabel.file) {
+    emit("update:addShape", customImage, imageLabel.file);
+    imageLabel.removeFile();
+    ElMessage.info("请前往右边画板选择为止单击添加图例");
   }
 });
 </script>

+ 3 - 0
src/view/home/index.vue

@@ -14,6 +14,9 @@
 <script setup lang="ts">
 import comHead from "@/components/head/index.vue";
 import { appConstant } from "@/app";
+import * as echarts from 'echarts';
+
+
 </script>
 
 <style lang="scss" scoped>

+ 169 - 0
src/view/statistics/index.vue

@@ -0,0 +1,169 @@
+<template>
+  <com-head :options="[{ name: '首页', value: '1' }]" class="frame-head">
+    <el-form label-width="84px">
+      <el-form-item label="统计区间:" style="grid-area: 1/1/2/2">
+        <p style="margin-top: -6px">{{ range }}</p>
+      </el-form-item>
+      <el-form-item label="统计区间:" style="grid-area: 1/2/2/4">
+        <el-date-picker
+          type="daterange"
+          unlink-panels
+          v-model="params"
+          placeholder="请选择"
+          style="width: 400px"
+        />
+      </el-form-item>
+    </el-form>
+  </com-head>
+  <div class="statistics-layer">
+    <div v-for="(config, ndx) in statisticsConfigs" class="statistics-item">
+      <div class="statistics-content">
+        <p class="title">{{ config.title }}</p>
+        <div class="graphics" :ref="(dom: any) => updateDom(ndx, dom)"></div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import comHead from "@/components/head/index.vue";
+import {
+  markRaw,
+  nextTick,
+  onMounted,
+  onUnmounted,
+  reactive,
+  ref,
+  watch,
+  watchEffect,
+} from "vue";
+import {
+  statisticsConfigs,
+  echarts,
+  EChartsType,
+  ConfigItem,
+  updateParams,
+} from "./statisticsInject";
+import { dateFormat } from "@/util";
+import { user } from "@/store/user";
+
+const prevYearDate = new Date();
+prevYearDate.setFullYear(prevYearDate.getFullYear() - 1);
+const params = ref<Date[]>([prevYearDate, new Date()]);
+const range = [
+  "全部火调队伍数据",
+  "总队及下级队伍数据",
+  "支队及下级队伍数据",
+  "当前队伍数据",
+][user.value.info.deptLevel];
+
+watchEffect(() => {
+  updateParams({
+    startTime: dateFormat(params.value[0], "yyyy-MM-dd"),
+    endTime: dateFormat(params.value[1], "yyyy-MM-dd"),
+  });
+});
+
+const doms = statisticsConfigs.map(() => ref<HTMLDivElement>());
+const updateDom = (ndx: number, dom: HTMLDivElement) => {
+  nextTick(() => (doms[ndx].value = dom));
+};
+
+const charts = reactive([]) as (EChartsType | null)[];
+doms.forEach((dom, ndx) => {
+  watchEffect((onCleanup) => {
+    if (dom.value) {
+      const chart = echarts.init(dom.value);
+      markRaw(chart);
+      charts[ndx] = chart;
+      onCleanup(() => {
+        echarts.dispose(charts[ndx]!);
+        charts[ndx] = null;
+      });
+    }
+  });
+});
+
+type WData = [ConfigItem[], (EChartsType | null)[]];
+watch(
+  () => [statisticsConfigs, charts] as WData,
+  (nInstalls) => {
+    const [nConfigs, nCharts] = nInstalls;
+    for (let ndx = 0; ndx < nCharts.length; ndx++) {
+      if (!nCharts[ndx]) continue;
+      nCharts[ndx]!.setOption(nConfigs[ndx].data);
+    }
+  },
+  { deep: true }
+);
+
+const resize = () => {
+  for (const chart of charts) {
+    chart?.resize();
+  }
+};
+
+onMounted(() => window.addEventListener("resize", resize));
+onUnmounted(() => window.removeEventListener("resize", resize));
+</script>
+
+<style lang="scss" scoped>
+.statistics-layer {
+  flex: 1;
+  background-color: #fff;
+  margin-top: 8px;
+  overflow-y: auto;
+  padding: 20px;
+}
+
+.statistics-item {
+  float: left;
+  width: calc(50% - 5px);
+  padding-top: 35%;
+  position: relative;
+  margin-bottom: 10px;
+
+  .statistics-content {
+    position: absolute;
+    left: 0;
+    top: 0;
+    right: 0;
+    bottom: 0;
+  }
+
+  &:nth-child(2n - 1) {
+    margin-right: 5px;
+  }
+  &:nth-child(2n) {
+    margin-left: 5px;
+  }
+}
+
+.statistics-content {
+  display: flex;
+  flex-direction: column;
+
+  .title {
+    flex: 0 0 auto;
+    line-height: 1.5;
+    font-size: 18px;
+    margin-bottom: 8px;
+    padding-left: 20px;
+    position: relative;
+
+    &::before {
+      content: "";
+      width: 4px;
+      position: absolute;
+      top: 0;
+      bottom: 0;
+      left: 0;
+      background-color: var(--primaryColor);
+    }
+  }
+
+  .graphics {
+    flex: 1;
+  }
+}
+</style>

+ 273 - 0
src/view/statistics/statisticsInject.ts

@@ -0,0 +1,273 @@
+import * as uecharts from "echarts";
+import { reactive } from "vue";
+import {
+  StatisticsParams,
+  getSceneStatistics,
+  getCaseStatistics,
+  getCameraTypeStatistics,
+  getCaseTimeStatistics,
+  getCasePlaceStatistics,
+  getCaseReasonStatistics,
+} from "@/store/statistics";
+
+export const echarts = uecharts;
+export type { EChartsType } from "echarts";
+
+export type ConfigItem = {
+  title: string;
+  data: any;
+};
+
+export const statisticsConfigs: ConfigItem[] = reactive([
+  {
+    title: "火灾场景数据采集统计",
+    data: {
+      tooltip: {
+        trigger: "item",
+      },
+      legend: {
+        orient: "vertical",
+        left: "right",
+        type: "scroll",
+      },
+      series: [
+        {
+          itemStyle: {
+            borderColor: "#fff",
+            borderWidth: 3,
+          },
+          name: "火灾场景数据采集统计",
+          type: "pie",
+          label: {
+            formatter: "{c}个",
+            rich: {
+              time: {
+                fontSize: 10,
+                color: "#999",
+              },
+            },
+          },
+          radius: "50%",
+          center: ["40%", "50%"],
+          data: [],
+        },
+      ],
+    },
+  },
+  {
+    title: "采集设备类型统计",
+    data: {
+      tooltip: {
+        trigger: "axis",
+      },
+      xAxis: {
+        type: "category",
+        data: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
+      },
+      yAxis: {
+        type: "value",
+      },
+      series: [
+        {
+          label: {
+            show: true,
+          },
+          data: [120, 200, 150, 80, 70, 110, 130],
+          type: "bar",
+        },
+      ],
+    },
+  },
+  {
+    title: "火灾场景数据采集统计",
+    data: {
+      tooltip: {
+        trigger: "item",
+      },
+      legend: {
+        orient: "vertical",
+        left: "right",
+        type: "scroll",
+        // top: "center",
+      },
+      series: [
+        {
+          itemStyle: {
+            borderColor: "#fff",
+            borderWidth: 3,
+          },
+          name: "火灾场景数据采集统计",
+          type: "pie",
+          label: {
+            formatter: "{d}%",
+            rich: {
+              time: {
+                fontSize: 10,
+                color: "#999",
+              },
+            },
+          },
+          radius: "50%",
+          center: ["40%", "50%"],
+          data: [],
+        },
+      ],
+    },
+  },
+  {
+    title: "火灾趋势分析",
+    data: {
+      tooltip: {
+        trigger: "axis",
+      },
+      xAxis: {
+        type: "category",
+        data: [],
+      },
+      yAxis: {
+        type: "value",
+      },
+      series: [
+        {
+          label: {
+            show: true,
+          },
+          data: [],
+          type: "line",
+        },
+      ],
+    },
+  },
+  {
+    title: "起火场所统计",
+    data: {
+      tooltip: {
+        trigger: "axis",
+      },
+      xAxis: {
+        type: "category",
+        data: [],
+      },
+      yAxis: {
+        type: "value",
+      },
+      series: [
+        {
+          label: {
+            show: true,
+          },
+          data: [],
+          type: "bar",
+        },
+      ],
+    },
+  },
+  {
+    title: "火灾原因统计",
+    data: {
+      tooltip: {
+        trigger: "axis",
+      },
+      xAxis: {
+        type: "category",
+        data: [],
+      },
+      yAxis: {
+        type: "value",
+      },
+      series: [
+        {
+          label: {
+            show: true,
+          },
+          data: [],
+          type: "bar",
+        },
+      ],
+    },
+  },
+]);
+
+const numRotate = 8;
+const numSlide = 20;
+export const updateParams = async (params: StatisticsParams) => {
+  const statisticsItemsArray = await Promise.all([
+    getSceneStatistics(params),
+    getCameraTypeStatistics(params),
+    getCaseStatistics(params),
+    getCaseTimeStatistics(params),
+    getCasePlaceStatistics(params),
+    getCaseReasonStatistics(params),
+  ]);
+
+  const radiusNds = [0, 2];
+
+  for (let ndx = 0; ndx < statisticsConfigs.length; ndx++) {
+    let items = statisticsItemsArray[ndx];
+
+    if (radiusNds.includes(ndx)) {
+      statisticsConfigs[ndx].data.series![0].data = items.map((item) => ({
+        value: item.dataCount,
+        name: item.groupKey,
+      }));
+      console.log(statisticsConfigs[ndx].data);
+    } else {
+      statisticsConfigs[ndx].data.xAxis.data = items.map(
+        (item) => item.groupKey
+      );
+      statisticsConfigs[ndx].data.xAxis.axisLabel = {
+        interval: "auto",
+        rotate: 0,
+      };
+      statisticsConfigs[ndx].data.dataZoom = [
+        {
+          id: 1,
+          type: "inside",
+          start: 0,
+          end: 100,
+          disabled: true,
+          zoomLock: true,
+          show: false,
+        },
+        {
+          id: 2,
+          start: 0,
+          end: 100,
+          disabled: true,
+          zoomLock: true,
+          show: false,
+        },
+      ];
+
+      if (items.length > numRotate && items.length < numSlide) {
+        statisticsConfigs[ndx].data.xAxis.axisLabel = {
+          interval: 0,
+          rotate: 30,
+        };
+      } else if (items.length >= numSlide) {
+        statisticsConfigs[ndx].data.dataZoom = [
+          {
+            id: 1,
+            type: "inside",
+            start: 0,
+            end: 100,
+            disabled: false,
+            zoomLock: false,
+            show: true,
+          },
+          {
+            id: 2,
+            start: 0,
+            end: 100,
+            disabled: false,
+            zoomLock: false,
+            show: true,
+          },
+        ];
+      }
+      statisticsConfigs[ndx].data.series![0].data = items.map(
+        (item) => item.dataCount
+      );
+    }
+  }
+};

+ 115 - 35
src/view/system/imageCropper.vue

@@ -1,23 +1,25 @@
 <template>
-  <div
-    class="vue-crop-layout"
-    :style="{ height: fixed[1] + 40 + 'px', width: fixed[0] + 40 + 'px' }"
-  >
-    <VueCropper
-      ref="cropperRef"
-      :img="url"
-      :outputSize="1"
-      canScale
-      autoCrop
-      :autoCropWidth="fixed[0]"
-      :autoCropHeight="fixed[1]"
-      centerBox
-      :fixed="!!fixed"
-      :fixedNumber="fixed"
-      fixedBox
-      :canMoveBox="false"
-      @realTime="realTimeHandler"
-    />
+  <div>
+    <div
+      class="vue-crop-layout"
+      ref="layoutRef"
+      :style="{ width: sWidth + 'px', height: sHeight + 'px' }"
+    >
+      <VueCropper
+        class="cropper-cls"
+        ref="cropperRef"
+        :img="url"
+        :outputSize="1"
+        canScale
+        autoCrop
+        centerBox
+        :fixed="!!fixed"
+        :fixedNumber="fixed"
+      />
+    </div>
+    <div class="control">
+      <el-button type="primary" @click="cropperRef.rotateRight()"> 旋转 </el-button>
+    </div>
   </div>
 </template>
 
@@ -26,6 +28,8 @@ import "vue-cropper/dist/index.css";
 import { ref, computed } from "vue";
 import { VueCropper } from "vue-cropper";
 import { QuiskExpose } from "@/helper/mount";
+import { getDomMatrix } from "@/util";
+import { inverse, multiply, positionTransform, rotateZ, translate } from "@/util/mt4";
 
 type CropperProps = {
   img: Blob | string;
@@ -33,34 +37,110 @@ type CropperProps = {
 };
 const props = defineProps<CropperProps>();
 
-const url = computed(() =>
-  typeof props.img === "string" ? props.img : URL.createObjectURL(props.img)
-);
-const scale = ref(1);
+// 样式控制
+const sWidth = 500;
+const sHeight = (props.fixed[1] / props.fixed[0]) * sWidth;
+
+const realImage = new Image();
+const url = computed(() => {
+  const url = typeof props.img === "string" ? props.img : URL.createObjectURL(props.img);
+  realImage.src = url;
+  return url;
+});
+const layoutRef = ref<HTMLDivElement>();
 const cropperRef = ref<any>();
-const realTimeHandler = (data: any) => {
-  const transform = data.img.transform as string;
-  if (transform) {
-    const result = transform.match(/scale\((\d+(\.\d+)?)\)/);
-    if (result) {
-      scale.value = Number(result[1]);
-    }
+
+const getDrawInfo = () => {
+  const imgDom = layoutRef.value?.querySelector(".cropper-box-canvas") as HTMLElement;
+  const cropDom = layoutRef.value?.querySelector(".cropper-crop-box") as HTMLElement;
+
+  const imgMatrix = getDomMatrix(imgDom);
+  const cropMatrix = getDomMatrix(cropDom);
+  const cropSize = [cropperRef.value.cropW, cropperRef.value.cropH];
+
+  // 屏幕位置
+  const cropBox = [
+    positionTransform([-cropSize[0] / 2, -cropSize[1] / 2, 0], cropMatrix),
+    positionTransform([cropSize[0] / 2, cropSize[1] / 2, 0], cropMatrix),
+  ];
+
+  const scale = [
+    realImage.width / imgDom.offsetWidth,
+    realImage.height / imgDom.offsetHeight,
+  ];
+
+  const invImageMatrix = inverse(imgMatrix);
+  const lt = positionTransform(cropBox[0], invImageMatrix);
+  const rb = positionTransform(cropBox[1], invImageMatrix);
+  const imgBound = [
+    lt[0] * scale[0] + realImage.width / 2,
+    lt[1] * scale[1] + realImage.height / 2,
+    rb[0] * scale[0] + realImage.width / 2,
+    rb[1] * scale[1] + realImage.height / 2,
+  ];
+
+  const realBound = {
+    left: Math.round(imgBound[0]),
+    top: Math.round(imgBound[1]),
+    right: Math.round(imgBound[2]),
+    bottom: Math.round(imgBound[3]),
+  };
+  // 旋转过
+  if (realBound.left > realBound.right) {
+    [realBound.left, realBound.right] = [realBound.right, realBound.left];
+  }
+  if (realBound.top > realBound.bottom) {
+    [realBound.top, realBound.bottom] = [realBound.bottom, realBound.top];
   }
+
+  return {
+    ...realBound,
+    rotate: (cropperRef.value.rotate * Math.PI) / 2,
+  };
+};
+
+const clipImage = () => {
+  const data = getDrawInfo();
+  const canvas = document.createElement("canvas");
+  const ctx = canvas.getContext("2d")!;
+  const w = data.right - data.left;
+  const h = data.bottom - data.top;
+
+  const boxMatrix = multiply(
+    translate(-w / 2, -h / 2, 0),
+    rotateZ(data.rotate),
+    translate(w / 2, h / 2, 0)
+  );
+  const start = positionTransform([0, 0, 0], boxMatrix);
+  const end = positionTransform([w, h, 0], boxMatrix);
+  const cw = (canvas.width = Math.abs(end[0] - start[0]));
+  const ch = (canvas.height = Math.abs(end[1] - start[1]));
+  ctx.translate(cw / 2, ch / 2);
+  ctx.rotate(data.rotate);
+  ctx.drawImage(realImage, data.left, data.top, w, h, -w / 2, -h / 2, w, h);
+
+  return new Promise<Blob | null>((resolve) => canvas.toBlob(resolve));
 };
 
 defineExpose<QuiskExpose>({
-  submit: () => new Promise((resolve) => cropperRef.value.getCropBlob(resolve)),
+  submit: clipImage,
 });
 </script>
 
 <style lang="scss" scoped>
 .vue-crop-layout {
-  height: 300px;
+  width: 100%;
+}
+.control {
+  margin-top: 20px;
+  text-align: center;
 }
 </style>
 
-<style>
-.vue-crop-layout .cropper-view-box {
-  outline-color: var(--el-color-primary);
+<style lang="scss">
+.vue-crop-layout {
+  .cropper-view-box {
+    outline-color: var(--el-color-primary);
+  }
 }
 </style>

+ 91 - 0
src/view/vrmodel/downloadLog.vue

@@ -0,0 +1,91 @@
+<template>
+  <com-head :options="[{ name: '下载记录查询', value: '2' }]" showCtrl>
+    <el-form label-width="84px">
+      <el-form-item label="所属架构:">
+        <com-company v-model="state.query.deptId" />
+      </el-form-item>
+      <el-form-item label="用户姓名:">
+        <el-input v-model="state.query.nickName" placeholder="请输入"></el-input>
+      </el-form-item>
+      <el-form-item label="用户账号:">
+        <el-input v-model="state.query.userName" placeholder="请输入手机号"></el-input>
+      </el-form-item>
+      <el-form-item label="下载时间:">
+        <el-date-picker
+          type="date"
+          v-model="state.query.createTime"
+          placeholder="请选择"
+          style="width: 100%"
+        />
+      </el-form-item>
+      <el-form-item label="场景标题:">
+        <el-input v-model="state.query.sceneTitle" placeholder="请输入"></el-input>
+      </el-form-item>
+      <el-form-item label="场景码:">
+        <el-input v-model="state.query.sceneNum" placeholder="请输入"></el-input>
+      </el-form-item>
+      <el-form-item label="SN码:">
+        <el-input v-model="state.query.snCode" placeholder="请输入"></el-input>
+      </el-form-item>
+
+      <el-form-item class="searh-btns" style="grid-area: 1/4/4/5">
+        <el-button type="primary" @click="refresh">查询</el-button>
+        <el-button type="primary" plain @click="queryReset">重置</el-button>
+      </el-form-item>
+    </el-form>
+  </com-head>
+
+  <div class="body-layer" style="padding-top: 8px">
+    <el-table
+      class="user-table"
+      :data="state.table.rows"
+      style="width: 100%; max-height: 480px"
+      size="large"
+    >
+      <el-table-column label="序号" width="70" v-slot:default="{ $index }">
+        <div style="text-align: center">
+          {{ state.pag.size * (state.pag.currentPage - 1) + $index + 1 }}
+        </div>
+      </el-table-column>
+      <el-table-column label="组织名称" prop="deptName"></el-table-column>
+      <el-table-column label="组织类型" prop="deptLevelStr"></el-table-column>
+      <el-table-column label="用户姓名" prop="nickName"></el-table-column>
+      <el-table-column label="用户账号" prop="userName"></el-table-column>
+      <el-table-column label="下载时间" prop="createTime"></el-table-column>
+      <el-table-column label="场景标题" prop="sceneTitle"></el-table-column>
+      <el-table-column label="场景码" prop="sceneNum"></el-table-column>
+      <el-table-column label="SN码" prop="snCode"></el-table-column>
+    </el-table>
+
+    <com-pagination
+      @size-change="changPageSize"
+      @current-change="changPageCurrent"
+      :current-page="state.pag.currentPage"
+      :page-size="state.pag.size"
+      :total="state.pag.total"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { usePagging } from "@/hook/pagging";
+import comHead from "@/components/head/index.vue";
+import comCompany from "@/components/company-select/index.vue";
+import comPagination from "@/components/pagination/index.vue";
+import { getDownloadQuoteScene } from "@/store/scene";
+
+const { state, queryReset, refresh, changPageCurrent, changPageSize } = usePagging({
+  get: getDownloadQuoteScene,
+  paramsTemlate: {
+    nickName: "",
+    deptId: "",
+    userName: "",
+    createTime: "",
+    sceneTitle: "",
+    sceneNum: "",
+    snCode: "",
+  },
+});
+</script>
+
+<style scoped lang="scss"></style>

+ 10 - 0
src/view/vrmodel/quisk.ts

@@ -1,7 +1,17 @@
+import { QuoteScene } from "@/store/scene";
 import EditModel from "./editModel.vue";
+import SceneDownload from "./sceneDownload.vue";
 import { quiskMountFactory } from "@/helper/mount";
 
 export const editModelScene = quiskMountFactory(EditModel, {
   title: "编辑模型",
   width: 500,
 });
+
+export type SceneDpwnloadProps = { scene: QuoteScene };
+
+export const sceneDownload = quiskMountFactory(SceneDownload, {
+  title: "场景离线包下载",
+  width: 500,
+  hideFloor: true,
+});

+ 7 - 3
src/view/vrmodel/sceneContent.vue

@@ -67,14 +67,14 @@
       >
         删除
       </span>
-      <!-- <span
+      <span
         class="oper-span"
+        @click="sceneDownloadHandler(row)"
         v-pdpath="['download']"
-        @click="sceneDownload({ scene: row })"
         v-if="row.num"
       >
         下载
-      </span> -->
+      </span>
     </el-table-column>
   </el-table>
 </template>
@@ -92,6 +92,7 @@ import { ScenePagging } from "./pagging";
 import { QuoteSceneStatusDesc } from "@/constant/scene";
 import { OpenType, openSceneUrl } from "../case/help";
 import { confirm } from "@/helper/message";
+import { sceneDownload } from "./quisk";
 
 const props = defineProps<{ pagging: ScenePagging }>();
 const delSceneHandler = async (scene: QuoteScene) => {
@@ -100,4 +101,7 @@ const delSceneHandler = async (scene: QuoteScene) => {
     props.pagging.refresh();
   }
 };
+const sceneDownloadHandler = (scene: QuoteScene) => {
+  sceneDownload({ scene });
+};
 </script>

+ 129 - 0
src/view/vrmodel/sceneDownload.vue

@@ -0,0 +1,129 @@
+<template>
+  <!-- hideFloor: state === State.package -->
+  <div>
+    <div class="title">
+      {{ stateTitle[state] }}
+    </div>
+
+    <div v-if="state === State.package">
+      <div
+        class="text"
+        style="display: flex; justify-content: space-between; margin-top: 15px"
+      >
+        <span>{{ filename }}</span>
+        <span>{{ percent }}%</span>
+      </div>
+      <div>
+        <el-slider :disabled="true" v-model="percent" :show-tooltip="false" />
+      </div>
+    </div>
+    <div v-else-if="state === State.readDown">
+      <span>正在下载中……</span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, onUnmounted, ref } from "vue";
+import saveAs from "@/util/file-serve";
+import { checkHasDownload, getDownloadProcess, downloadScene, axios } from "@/request";
+import { ElMessage } from "element-plus";
+import { QuoteScene, SceneType } from "@/store/scene";
+import { QuiskExpose } from "@/helper/mount";
+
+const props = defineProps<{ scene: QuoteScene }>();
+console.log(props);
+enum State {
+  uncreate,
+  package,
+  readDown,
+}
+const getState = (type: number) => {
+  const stateTypes = [
+    { codes: [0, 2], state: State.uncreate },
+    { codes: [1], state: State.package },
+    { codes: [3], state: State.readDown },
+  ];
+  return (
+    stateTypes.find((stateType) => stateType.codes.includes(type))?.state ||
+    State.uncreate
+  );
+};
+
+const show = ref(false);
+const state = ref<State>(State.uncreate);
+const count = ref<number>(0);
+const filename = ref<string>(props.scene.title + ".zip");
+const downloadURL = ref<string>();
+const percent = ref(0);
+
+const stateTitle = {
+  [State.uncreate]: "下载场景离线数据包,可在本地运行查看。",
+  [State.package]: "正在打包场景离线数据",
+  [State.readDown]: filename.value,
+};
+
+const params = {
+  num: props.scene.num,
+  isObj: Number(![SceneType.SWSS, SceneType.SWYDSS].includes(props.scene.type)),
+};
+// 初始化
+const initial = async () => {
+  const res = await axios.get(checkHasDownload, { params });
+  state.value = getState(res.data.downloadStatus);
+  count.value = res.data.count;
+  downloadURL.value = res.data.downloadUrl;
+
+  if (state.value === State.uncreate) {
+    const downRes = await axios.get(downloadScene, { params });
+    state.value = getState(downRes.data.downloadStatus);
+    // const unCountFlag =
+    //   count.value == 0 ||(await axios.get(downloadScene, { params })).data.downloadStatus !== 1;
+    if (state.value === State.uncreate) {
+      ElMessage.error("下载失败,请联系管理员");
+      throw "暂无剩余下载次数";
+    }
+  }
+
+  show.value = true;
+  console.log(state.value === State.readDown);
+  if (state.value === State.package) {
+    refreshPercent();
+  } else {
+    downloadURL.value = res.data.downloadUrl;
+    download();
+  }
+};
+
+// 下载
+const download = () => {
+  if (!downloadURL.value) {
+    ElMessage.error("下载链接未生成,请稍等!");
+    throw "下载链接未生成,请稍等!";
+  } else {
+    saveAs(downloadURL.value, filename.value);
+  }
+};
+
+// 进度请求
+let timer: any;
+const refreshPercent = async () => {
+  const res = await axios.get(getDownloadProcess, { params });
+
+  percent.value = parseInt(res.data.percent);
+  downloadURL.value = res.data.url;
+  if (downloadURL.value) {
+    state.value = State.readDown;
+    download();
+  } else {
+    timer = setTimeout(refreshPercent, 1000);
+  }
+};
+
+onUnmounted(() => clearTimeout(timer));
+onMounted(initial);
+
+defineExpose<QuiskExpose>({
+  submit: download
+});
+</script>