bill 2 rokov pred
rodič
commit
27b6d02ef1

+ 1 - 0
package.json

@@ -22,6 +22,7 @@
     "sass": "^1.62.0",
     "sass-loader": "^13.2.2",
     "stateshot": "^1.3.5",
+    "vconsole": "^3.15.0",
     "vue": "^3.2.47",
     "vue-cropper": "1.0.2",
     "vue-i18n": "^9.2.2",

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1 - 1
server/test/SS-t-P1d6CwREny2/attach/sceneStore


+ 14 - 6
src/components/base/components/slide/index.vue

@@ -1,7 +1,11 @@
 <template>
     <div class="ui-slide" :class="{'stop-animation': stopAmimation}" v-if="items.length">
         <Gate :index="extendIndex" ref="gate">
-            <GateContent v-for="(item, i) in extendItems" @mousedown.stop.prevent="mousedownHandler">
+            <GateContent
+                v-for="(item, i) in extendItems"
+                @mousedown.stop.prevent="mousedownHandler"
+                @touchstart.stop.prevent="mousedownHandler"
+            >
                 <slot :raw="item" :active="items[index]" :index="getIndex(i)" />
             </GateContent>
         </Gate>
@@ -85,14 +89,14 @@ const mousedownHandler = ev => {
         .split(",")
     const startX = Number(matrix[4]);
     const start = {
-        x: ev.pageX,
-        y: ev.pageY
+        x: ev.pageX || ev.touches[0].pageX,
+        y: ev.pageY || ev.touches[0].pageY
     }
     let move
     const moveHandler = (ev) => {
         move = {
-            x: ev.pageX - start.x,
-            y: ev.pageY - start.y
+            x: (ev.pageX || ev.touches[0].pageX) - start.x,
+            y: (ev.pageY || ev.touches[0].pageY) - start.y
         }
         matrix[4] = startX + move.x
         dom.style.transform = `matrix(${matrix.join(",")})`
@@ -100,10 +104,12 @@ const mousedownHandler = ev => {
     const upHandler = ev => {
         document.documentElement.removeEventListener("mousemove", moveHandler)
         document.documentElement.removeEventListener("mouseup", upHandler)
+        document.documentElement.removeEventListener("touchmove", moveHandler)
+        document.documentElement.removeEventListener("touchend", upHandler)
         stopAmimation.value = false
         const endTime = new Date().getTime()
         const isFast = endTime - startTime < 200
-        const limen = width / (isFast ? 8 : 3)
+        const limen = isFast ? 50 : width / 3
 
         if (move.x < -limen) {
             nextHandler()
@@ -114,6 +120,8 @@ const mousedownHandler = ev => {
     }
     document.documentElement.addEventListener("mousemove", moveHandler)
     document.documentElement.addEventListener("mouseup", upHandler)
+    document.documentElement.addEventListener("touchmove", moveHandler)
+    document.documentElement.addEventListener("touchend", upHandler)
 }
 
 let prevent = false

+ 1 - 1
src/components/fill-slide/index.vue

@@ -63,10 +63,10 @@ defineEmits<{
   width: 100%;
   height: 100%;
   object-fit: cover;
+  border-radius: 4px;
 }
 
 .header {
-  height: 10%;
   width: 100%;
   position: relative;
 }

+ 3 - 2
src/components/photos/header.vue

@@ -2,7 +2,7 @@
   <div class="photos-header">
     <div>
       <ui-icon type="close" ctrl style="margin-right: 10px" @click="router.back" />
-      <span>照片管理</span>
+      <span>{{ title }}</span>
     </div>
     <span class="center" v-if="count">
       已选择 {{ count }} 张
@@ -17,7 +17,7 @@
 import UiIcon from "@/components/base/components/icon/index.vue";
 import {router} from '@/router'
 
-defineProps<{ count: number }>()
+defineProps<{ count?: number, title: string }>()
 </script>
 
 <style lang="scss" scoped>
@@ -32,6 +32,7 @@ defineProps<{ count: number }>()
     position: absolute;
     left: 0;
     right: 0;
+    white-space: nowrap;
     pointer-events: none;
     height: 100%;
     display: flex;

+ 19 - 12
src/components/photos/index.vue

@@ -7,18 +7,20 @@
           class="photo"
           @click="selectMode ? changeSelects(photo, !selects.includes(photo)) : $emit('update:active', photo)"
       >
-        <img :src="getStaticFile(photo.url)" v-if="!$slots.default" />
-        <slot :data="photo"  v-else/>
-        <ui-input
-          width="24px"
-          height="24px"
-          v-if="selectMode"
-          type="checkbox"
-          :modelValue="selects.includes(photo)"
-          @update:modelValue="selected => changeSelects(photo, selected)"
-          @click.stop
-          class="photo-select"
-        />
+        <div class="img-layout">
+          <img :src="getStaticFile(photo.url)" />
+          <ui-input
+              width="24px"
+              height="24px"
+              v-if="selectMode"
+              type="checkbox"
+              :modelValue="selects.includes(photo)"
+              @update:modelValue="selected => changeSelects(photo, selected)"
+              @click.stop
+              class="photo-select"
+          />
+        </div>
+        <slot :data="photo" />
       </div>
     </div>
   </div>
@@ -78,8 +80,13 @@ const changeSelects = (item: Item, selected: boolean) => {
   bottom: 16px;
   z-index: 1;
 }
+
+.img-layout {
+  position: relative;
+}
 .photo img {
   width: 100%;
+  border-radius: 4px;
 }
 </style>
 

+ 5 - 0
src/graphic/Layer.js

@@ -72,6 +72,11 @@ export default class Layer {
     this.canvas.addEventListener("mousedown", this.onMouseDown.bind(this));
     this.canvas.addEventListener("mousemove", this.onMouseMove.bind(this));
     this.canvas.addEventListener("mouseup", this.onMouseUp.bind(this));
+
+    this.canvas.addEventListener("touchstart", this.onMouseDown.bind(this));
+    this.canvas.addEventListener("touchmove", this.onMouseMove.bind(this));
+    this.canvas.addEventListener("touchend", this.onMouseUp.bind(this));
+
     this.canvas.addEventListener("mousewheel", this.onWheel.bind(this));
     this.canvas.addEventListener("DOMMouseScroll", this.onWheel.bind(this));
     this.canvas.addEventListener("resize", this.reSize.bind(this));

+ 200 - 0
src/hook/useHand.ts

@@ -0,0 +1,200 @@
+import {mathUtil} from "@/graphic/Util/MathUtil";
+import {computed, onUnmounted, ref, Ref, watchEffect} from "vue";
+import {getPostionByTarget} from "@/components/base/utils";
+// @ts-ignore
+import matruces from '@/utils/matruces'
+
+
+type Point = { x: number, y: number }
+type PointStore = [Point] | [Point, Point]
+
+enum Mode {
+  move,
+  scale,
+  angle
+}
+export const HandMode = {
+  MoveAndScale: [Mode.move, Mode.scale],
+  AngleAndScale: [Mode.angle, Mode.scale],
+  Move: [Mode.move],
+  Scale: [Mode.scale],
+  Angle: [Mode.angle]
+}
+
+export const useHand = (
+  targetRef: Ref<HTMLElement>,
+  mode:  Mode[],
+  callback = () => {},
+  oldMatrix = matruces.translateMatrix(0,0,0)
+) => {
+  const parent = document.documentElement
+  const matrix = ref(oldMatrix)
+  let translate: Point;
+  let scale: {center: Point, scale: number}
+  let angle: number
+  let startPos: PointStore
+  let currentPos: PointStore
+
+  const getScrollPos = (dom: HTMLElement) => {
+    let x = 0, y = 0;
+    while (dom && dom !== parent) {
+      x += dom.scrollLeft
+      y += dom.scrollTop
+      dom = dom.offsetParent as HTMLElement
+    }
+    return {x, y}
+  }
+  const getPointByEvent = (ev: TouchEvent | MouseEvent): PointStore => {
+
+    if (ev instanceof TouchEvent) {
+      const point1 = {
+        x: ev.touches[0].pageX,
+        y: ev.touches[0].pageY
+      }
+      return ev.touches.length > 1
+        ? [
+            point1,
+            {
+              x: ev.touches[1].pageX,
+              y: ev.touches[1].pageY
+            }
+          ]
+        : [point1]
+    } else {
+      return [{ x: ev.pageX, y: ev.pageY }]
+    }
+  }
+
+  let targetPosition, pageCenter, domCenter, pageStart;
+  const start = (ev: TouchEvent | MouseEvent) => {
+    const scrollPos = getScrollPos(targetRef.value)
+    startPos = getPointByEvent(ev)
+    targetPosition = getPostionByTarget(targetRef.value, parent)
+    domCenter = {
+      x: targetRef.value.offsetWidth / 2,
+      y: targetRef.value.offsetHeight / 2
+    }
+    pageStart = {
+      x: targetPosition.x - scrollPos.x,
+      y: targetPosition.y - scrollPos.y
+    }
+    pageCenter = {
+      x: pageStart.x + domCenter.x,
+      y: pageStart.y + domCenter.y
+    }
+
+    if (ev instanceof TouchEvent) {
+      parent.addEventListener("touchmove", move)
+      parent.addEventListener("touchend", end)
+    } else {
+      parent.addEventListener("mousemove", move)
+      parent.addEventListener("mouseup", end)
+    }
+
+    ev.stopPropagation()
+    ev.preventDefault()
+  }
+  const move = (ev: MouseEvent | TouchEvent) => {
+    if ((currentPos = getPointByEvent(ev)).length != startPos.length) {
+      return startPos = currentPos
+    }
+
+    if (startPos.length !== 2) {
+      if (mode.includes(Mode.move)) {
+        translate = {
+          x: currentPos[0].x - startPos[0].x,
+          y: currentPos[0].y - startPos[0].y
+        }
+      } else if (mode.includes(Mode.angle)) {
+        const angleAngle = mathUtil.Angle(pageCenter, startPos[0], currentPos[0]) * (Math.PI / 180)
+        const isClock = mathUtil.isClockwise([pageCenter, startPos[0], currentPos[0]])
+        angle = isClock ? -angleAngle : angleAngle
+      }
+    } else if (mode.includes(Mode.scale)) {
+      const center = scale?.center ||  {
+        x: (startPos[0].x - pageStart.x + startPos[1].x - pageStart.x) / 2,
+        y: (startPos[0].y - pageStart.y + startPos[1].y - pageStart.y) / 2,
+      }
+      scale = {
+        scale: mathUtil.getDistance(...currentPos) / mathUtil.getDistance(...startPos),
+        center
+      }
+    }
+    updateMatrix()
+    startPos = currentPos
+  }
+  const end = (ev: MouseEvent | TouchEvent) => {
+    callback()
+    translate = scale = angle = null;
+    if (ev instanceof TouchEvent) {
+      parent.removeEventListener("touchmove", move)
+      parent.removeEventListener("touchend", end)
+    } else {
+      parent.removeEventListener("mousemove", move)
+      parent.removeEventListener("mouseup", end)
+    }
+  }
+
+  const updateMatrix = () => {
+    let currentMatrix
+    if (translate) {
+      currentMatrix = matruces.translateMatrix(
+        translate.x / matrix.value[0],
+        translate.y / matrix.value[0],
+        0
+      )
+      translate = null
+    } else if (scale) {
+      const offset = {
+        x: (scale.center.x - domCenter.x) / (matrix.value[0] * scale.scale),
+        y: (scale.center.y - domCenter.y) / (matrix.value[0] * scale.scale)
+      }
+      currentMatrix = matruces.multiplyMatrices(
+        matruces.multiplyMatrices(
+          matruces.translateMatrix(offset.x, offset.y, 0),
+          matruces.scaleMatrix(scale.scale, scale.scale, 1)
+        ),
+        matruces.translateMatrix(-offset.x, -offset.y, 0)
+      )
+      scale = null
+    } else if (angle) {
+      currentMatrix = matruces.rotateZMatrix(angle)
+      angle = null
+    }
+    if (currentMatrix) {
+      matrix.value = matruces.multiplyMatrices(
+        matrix.value,
+        currentMatrix
+      )
+    }
+  }
+
+  const stop = watchEffect((onCleanup) => {
+    if (targetRef.value) {
+      targetRef.value.addEventListener("mousedown", start)
+      targetRef.value.addEventListener("touchstart", start)
+    }
+    onCleanup(() => {
+      if (targetRef.value) {
+        targetRef.value.removeEventListener("mousedown", start)
+        targetRef.value.removeEventListener("touchstart", start)
+      }
+      parent.removeEventListener("mousemove", move)
+      parent.removeEventListener("mouseup", end)
+      parent.removeEventListener("touchmove", move)
+      parent.removeEventListener("touchend", end)
+    })
+  })
+
+  const cssMatrix = computed<string>(
+    () => matruces.matrixArrayToCssMatrix(matrix.value)
+  )
+
+  onUnmounted(stop)
+
+  return {
+    stop,
+    matrix,
+    cssMatrix
+  }
+}

+ 7 - 0
src/main.ts

@@ -7,7 +7,9 @@ import { setupI18n } from "@/lang";
 import { router, setupRouter } from "@/router";
 import appConfig from "./appConfig";
 import { currentApp, setCurrentApp } from "@/store/app";
+import { os } from '@/utils'
 import App from "./main.vue";
+import VConsole from 'vconsole';
 
 const app = createApp(App);
 
@@ -21,4 +23,9 @@ app.use(Components);
 
 app.mount("#app");
 
+if (import.meta.env.DEV) {
+  if (!os.isPc) {
+    new VConsole();
+  }
+}
 export default app;

+ 6 - 3
src/store/roadPhotos.ts

@@ -1,5 +1,6 @@
 import {ref} from "vue";
 import {PhotoRaw} from "@/store/photos";
+import matruces from "@/utils/matruces";
 
 export type RoadPhotoTable = {
   arrivalTime: string,
@@ -7,9 +8,10 @@ export type RoadPhotoTable = {
   conditions: string,
   location: string,
   illustrate: string,
-  other: string,
+  date: number,
   url?: string,
-  compassAngle: number
+  compassAngle: number[]
+  imageTransform: number[]
 
 }
 export type RoadPhoto = {
@@ -34,7 +36,8 @@ export const getDefaultTable = (road): RoadPhotoTable => {
     weather: "晴天",
     conditions: "普通",
     location: "",
-    compassAngle: 0,
+    imageTransform: matruces.translateMatrix(0, 0, 0),
+    compassAngle: matruces.translateMatrix(0, 0, 0),
     illustrate: "说明:绘图比例为1:215,单位为米。车辆甲为小轿车,无车号。车辆乙为小轿车,无车号。选取道路边缘线为基准线,井盖为基准点。",
     other: "",
   }

+ 7 - 0
src/store/sync.ts

@@ -21,6 +21,13 @@ axios.get("/attach/sceneStore")
       roadPhotos.value = data.data.roadPhotos || []
     }
 
+    roadPhotos.value.map(data => {
+      data.url = data.photoUrl
+    })
+    accidentPhotos.value.map(data => {
+      data.url = data.photoUrl
+    })
+
     syncSceneStore()
   })
 

+ 287 - 0
src/utils/matruces.js

@@ -0,0 +1,287 @@
+var MDN = MDN || {};
+
+MDN.matrixArrayToCssMatrix = function (array) {
+  return "matrix3d(" + array.join(',') + ")";
+}
+
+MDN.multiplyPoint = function (matrix, point) {
+
+  var x = point[0], y = point[1], z = point[2], w = point[3];
+
+  var c1r1 = matrix[ 0], c2r1 = matrix[ 1], c3r1 = matrix[ 2], c4r1 = matrix[ 3],
+    c1r2 = matrix[ 4], c2r2 = matrix[ 5], c3r2 = matrix[ 6], c4r2 = matrix[ 7],
+    c1r3 = matrix[ 8], c2r3 = matrix[ 9], c3r3 = matrix[10], c4r3 = matrix[11],
+    c1r4 = matrix[12], c2r4 = matrix[13], c3r4 = matrix[14], c4r4 = matrix[15];
+
+  return [
+    x*c1r1 + y*c1r2 + z*c1r3 + w*c1r4,
+    x*c2r1 + y*c2r2 + z*c2r3 + w*c2r4,
+    x*c3r1 + y*c3r2 + z*c3r3 + w*c3r4,
+    x*c4r1 + y*c4r2 + z*c4r3 + w*c4r4
+  ];
+}
+
+MDN.multiplyMatrices = function (a, b) {
+
+  // TODO - Simplify for explanation
+  // currently taken from https://github.com/toji/gl-matrix/blob/master/src/gl-matrix/mat4.js#L306-L337
+
+  var result = [];
+
+  var a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3],
+    a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7],
+    a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11],
+    a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15];
+
+  // Cache only the current line of the second matrix
+  var b0  = b[0], b1 = b[1], b2 = b[2], b3 = b[3];
+  result[0] = b0*a00 + b1*a10 + b2*a20 + b3*a30;
+  result[1] = b0*a01 + b1*a11 + b2*a21 + b3*a31;
+  result[2] = b0*a02 + b1*a12 + b2*a22 + b3*a32;
+  result[3] = b0*a03 + b1*a13 + b2*a23 + b3*a33;
+
+  b0 = b[4]; b1 = b[5]; b2 = b[6]; b3 = b[7];
+  result[4] = b0*a00 + b1*a10 + b2*a20 + b3*a30;
+  result[5] = b0*a01 + b1*a11 + b2*a21 + b3*a31;
+  result[6] = b0*a02 + b1*a12 + b2*a22 + b3*a32;
+  result[7] = b0*a03 + b1*a13 + b2*a23 + b3*a33;
+
+  b0 = b[8]; b1 = b[9]; b2 = b[10]; b3 = b[11];
+  result[8] = b0*a00 + b1*a10 + b2*a20 + b3*a30;
+  result[9] = b0*a01 + b1*a11 + b2*a21 + b3*a31;
+  result[10] = b0*a02 + b1*a12 + b2*a22 + b3*a32;
+  result[11] = b0*a03 + b1*a13 + b2*a23 + b3*a33;
+
+  b0 = b[12]; b1 = b[13]; b2 = b[14]; b3 = b[15];
+  result[12] = b0*a00 + b1*a10 + b2*a20 + b3*a30;
+  result[13] = b0*a01 + b1*a11 + b2*a21 + b3*a31;
+  result[14] = b0*a02 + b1*a12 + b2*a22 + b3*a32;
+  result[15] = b0*a03 + b1*a13 + b2*a23 + b3*a33;
+
+  return result;
+}
+
+MDN.multiplyArrayOfMatrices = function (matrices) {
+
+  var inputMatrix = matrices[0];
+
+  for(var i=1; i < matrices.length; i++) {
+    inputMatrix = MDN.multiplyMatrices(inputMatrix, matrices[i]);
+  }
+
+  return inputMatrix;
+}
+
+MDN.normalMatrix = function (matrix) {
+
+  /*
+    This function takes the inverse and then transpose of the provided
+    4x4 matrix. The result is a 3x3 matrix. Essentially the translation
+    part of the matrix gets removed.
+
+    https://github.com/toji/gl-matrix
+  */
+
+  var a00 = matrix[0], a01 = matrix[1], a02 = matrix[2], a03 = matrix[3],
+    a10 = matrix[4], a11 = matrix[5], a12 = matrix[6], a13 = matrix[7],
+    a20 = matrix[8], a21 = matrix[9], a22 = matrix[10], a23 = matrix[11],
+    a30 = matrix[12], a31 = matrix[13], a32 = matrix[14], a33 = matrix[15],
+
+    b00 = a00 * a11 - a01 * a10,
+    b01 = a00 * a12 - a02 * a10,
+    b02 = a00 * a13 - a03 * a10,
+    b03 = a01 * a12 - a02 * a11,
+    b04 = a01 * a13 - a03 * a11,
+    b05 = a02 * a13 - a03 * a12,
+    b06 = a20 * a31 - a21 * a30,
+    b07 = a20 * a32 - a22 * a30,
+    b08 = a20 * a33 - a23 * a30,
+    b09 = a21 * a32 - a22 * a31,
+    b10 = a21 * a33 - a23 * a31,
+    b11 = a22 * a33 - a23 * a32,
+
+    // Calculate the determinant
+    det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
+
+  if (!det) {
+    return null;
+  }
+  det = 1.0 / det;
+
+  var result = []
+
+  result[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det;
+  result[1] = (a12 * b08 - a10 * b11 - a13 * b07) * det;
+  result[2] = (a10 * b10 - a11 * b08 + a13 * b06) * det;
+
+  result[3] = (a02 * b10 - a01 * b11 - a03 * b09) * det;
+  result[4] = (a00 * b11 - a02 * b08 + a03 * b07) * det;
+  result[5] = (a01 * b08 - a00 * b10 - a03 * b06) * det;
+
+  result[6] = (a31 * b05 - a32 * b04 + a33 * b03) * det;
+  result[7] = (a32 * b02 - a30 * b05 - a33 * b01) * det;
+  result[8] = (a30 * b04 - a31 * b02 + a33 * b00) * det;
+
+  return result;
+}
+
+MDN.rotateXMatrix = function (a) {
+
+  var cos = Math.cos;
+  var sin = Math.sin;
+
+  return [
+    1,       0,        0,     0,
+    0,  cos(a),  -sin(a),     0,
+    0,  sin(a),   cos(a),     0,
+    0,       0,        0,     1
+  ];
+}
+
+MDN.rotateYMatrix = function (a) {
+
+  var cos = Math.cos;
+  var sin = Math.sin;
+
+  return [
+    cos(a),   0, sin(a),   0,
+    0,   1,      0,   0,
+    -sin(a),   0, cos(a),   0,
+    0,   0,      0,   1
+  ];
+}
+
+MDN.rotateZMatrix = function (a) {
+
+  var cos = Math.cos;
+  var sin = Math.sin;
+
+  return [
+    cos(a), -sin(a),    0,    0,
+    sin(a),  cos(a),    0,    0,
+    0,       0,    1,    0,
+    0,       0,    0,    1
+  ];
+}
+
+MDN.translateMatrix = function (x, y, z) {
+  return [
+    1,    0,    0,   0,
+    0,    1,    0,   0,
+    0,    0,    1,   0,
+    x,    y,    z,   1
+  ];
+}
+
+MDN.scaleMatrix = function (w, h, d) {
+  return [
+    w,    0,    0,   0,
+    0,    h,    0,   0,
+    0,    0,    d,   0,
+    0,    0,    0,   1
+  ];
+}
+
+MDN.perspectiveMatrix = function (fieldOfViewInRadians, aspectRatio, near, far) {
+
+  // Construct a perspective matrix
+
+  /*
+     Field of view - the angle in radians of what's in view along the Y axis
+     Aspect Ratio - the ratio of the canvas, typically canvas.width / canvas.height
+     Near - Anything before this point in the Z direction gets clipped (resultside of the clip space)
+     Far - Anything after this point in the Z direction gets clipped (outside of the clip space)
+  */
+
+  var f = 1.0 / Math.tan(fieldOfViewInRadians / 2);
+  var rangeInv = 1 / (near - far);
+
+  return [
+    f / aspectRatio, 0,                          0,   0,
+    0,               f,                          0,   0,
+    0,               0,    (near + far) * rangeInv,  -1,
+    0,               0,  near * far * rangeInv * 2,   0
+  ];
+}
+
+MDN.orthographicMatrix = function(left, right, bottom, top, near, far) {
+
+  // Each of the parameters represents the plane of the bounding box
+
+  var lr = 1 / (left - right);
+  var bt = 1 / (bottom - top);
+  var nf = 1 / (near - far);
+
+  var row4col1 = (left + right) * lr;
+  var row4col2 = (top + bottom) * bt;
+  var row4col3 = (far + near) * nf;
+
+  return [
+    -2 * lr,        0,        0, 0,
+    0,  -2 * bt,        0, 0,
+    0,        0,   2 * nf, 0,
+    row4col1, row4col2, row4col3, 1
+  ];
+}
+
+MDN.normalize = function( vector ) {
+
+  // A utility function to make a vector have a length of 1
+
+  var length = Math.sqrt(
+    vector[0] * vector[0] +
+    vector[1] * vector[1] +
+    vector[2] * vector[2]
+  )
+
+  return [
+    vector[0] / length,
+    vector[1] / length,
+    vector[2] / length
+  ]
+}
+
+MDN.invertMatrix = function( matrix ) {
+
+  // Adapted from: https://github.com/mrdoob/three.js/blob/master/src/math/Matrix4.js
+
+  // Performance note: Try not to allocate memory during a loop. This is done here
+  // for the ease of understanding the code samples.
+  var result = [];
+
+  var n11 = matrix[0], n12 = matrix[4], n13 = matrix[ 8], n14 = matrix[12];
+  var n21 = matrix[1], n22 = matrix[5], n23 = matrix[ 9], n24 = matrix[13];
+  var n31 = matrix[2], n32 = matrix[6], n33 = matrix[10], n34 = matrix[14];
+  var n41 = matrix[3], n42 = matrix[7], n43 = matrix[11], n44 = matrix[15];
+
+  result[ 0] = n23 * n34 * n42 - n24 * n33 * n42 + n24 * n32 * n43 - n22 * n34 * n43 - n23 * n32 * n44 + n22 * n33 * n44;
+  result[ 4] = n14 * n33 * n42 - n13 * n34 * n42 - n14 * n32 * n43 + n12 * n34 * n43 + n13 * n32 * n44 - n12 * n33 * n44;
+  result[ 8] = n13 * n24 * n42 - n14 * n23 * n42 + n14 * n22 * n43 - n12 * n24 * n43 - n13 * n22 * n44 + n12 * n23 * n44;
+  result[12] = n14 * n23 * n32 - n13 * n24 * n32 - n14 * n22 * n33 + n12 * n24 * n33 + n13 * n22 * n34 - n12 * n23 * n34;
+  result[ 1] = n24 * n33 * n41 - n23 * n34 * n41 - n24 * n31 * n43 + n21 * n34 * n43 + n23 * n31 * n44 - n21 * n33 * n44;
+  result[ 5] = n13 * n34 * n41 - n14 * n33 * n41 + n14 * n31 * n43 - n11 * n34 * n43 - n13 * n31 * n44 + n11 * n33 * n44;
+  result[ 9] = n14 * n23 * n41 - n13 * n24 * n41 - n14 * n21 * n43 + n11 * n24 * n43 + n13 * n21 * n44 - n11 * n23 * n44;
+  result[13] = n13 * n24 * n31 - n14 * n23 * n31 + n14 * n21 * n33 - n11 * n24 * n33 - n13 * n21 * n34 + n11 * n23 * n34;
+  result[ 2] = n22 * n34 * n41 - n24 * n32 * n41 + n24 * n31 * n42 - n21 * n34 * n42 - n22 * n31 * n44 + n21 * n32 * n44;
+  result[ 6] = n14 * n32 * n41 - n12 * n34 * n41 - n14 * n31 * n42 + n11 * n34 * n42 + n12 * n31 * n44 - n11 * n32 * n44;
+  result[10] = n12 * n24 * n41 - n14 * n22 * n41 + n14 * n21 * n42 - n11 * n24 * n42 - n12 * n21 * n44 + n11 * n22 * n44;
+  result[14] = n14 * n22 * n31 - n12 * n24 * n31 - n14 * n21 * n32 + n11 * n24 * n32 + n12 * n21 * n34 - n11 * n22 * n34;
+  result[ 3] = n23 * n32 * n41 - n22 * n33 * n41 - n23 * n31 * n42 + n21 * n33 * n42 + n22 * n31 * n43 - n21 * n32 * n43;
+  result[ 7] = n12 * n33 * n41 - n13 * n32 * n41 + n13 * n31 * n42 - n11 * n33 * n42 - n12 * n31 * n43 + n11 * n32 * n43;
+  result[11] = n13 * n22 * n41 - n12 * n23 * n41 - n13 * n21 * n42 + n11 * n23 * n42 + n12 * n21 * n43 - n11 * n22 * n43;
+  result[15] = n12 * n23 * n31 - n13 * n22 * n31 + n13 * n21 * n32 - n11 * n23 * n32 - n12 * n21 * n33 + n11 * n22 * n33;
+
+  var determinant = n11 * result[0] + n21 * result[4] + n31 * result[8] + n41 * result[12];
+
+  if ( determinant === 0 ) {
+    throw new Error("Can't invert matrix, determinant is 0");
+  }
+
+  for( var i=0; i < result.length; i++ ) {
+    result[i] /= determinant;
+  }
+
+  return result;
+}
+
+export default MDN

+ 104 - 49
src/views/accidents/index.vue

@@ -1,37 +1,51 @@
 <template>
   <MainPanel>
     <template v-slot:header>
-      <ui-icon type="close" ctrl style="margin-right: 10px" @click="router.back" />
-      标注照片({{accidentPhotos.length}})
+      <Header :count="selects.length" :title="`标注照片(${sortPhotos.length})`">
+        <ui-button
+            :type="selectMode ? 'primary' : 'normal'"
+            @click="selectMode = !selectMode"
+            width="96px"
+            style="margin-right: 16px"
+        >
+          {{ selectMode ? '取消' : '选择' }}
+        </ui-button>
+        <ui-button
+            v-if="!selectMode"
+            type="primary"
+            @click="router.push({name: writeRouteName.photos})"
+            width="96px"
+        >
+          新增
+        </ui-button>
+      </Header>
     </template>
 
-    <div class="photos-layout">
+    <div class="type-photos-layout">
       <div class="type-photos" v-for="typePhoto in typePhotos" :key="typePhoto.type">
-        <p>{{ typePhoto.type }}</p>
-        <div class="photos" >
-          <div v-for="photo in typePhoto.photos" :key="photo.id" class="photo" @click="active = photo">
-            <p>{{ photo.title || '默认标题' }}</p>
-            <img :src="getStaticFile(photo.photoUrl)"  />
-          </div>
-        </div>
+        <p class="type-title">{{ typePhoto.type }}</p>
+        <Photos
+            class="type-photos-content"
+            v-model:active="active"
+            v-model:selects="selects"
+            :select-mode="selectMode"
+            :data="typePhoto.photos"
+        >
+          <template v-slot="{data}">
+            <p>{{ data.title || '默认标题' }}</p>
+          </template>
+        </Photos>
       </div>
     </div>
+
+    <ButtonPane class="del fun-ctrl" @click="delSelects" v-if="selects.length">
+      <ui-icon type="close" class="icon" />
+    </ButtonPane>
   </MainPanel>
 
   <FillSlide :data="sortPhotos" v-model:active="active" @quit="active = null" v-if="active">
-    <template v-slot:header>
-      <div class="btns">
-        <ui-button width="100px" @click="gotoDraw">修改</ui-button>
-      </div>
-    </template>
     <template v-slot:foot>
-      <div class="foot">
-        <ui-icon
-            type="close"
-            @click="delPhoto"
-            ctrl
-        />
-      </div>
+      <ActionMenus class="menus" :menus="menus" dire="row" />
     </template>
   </FillSlide>
 </template>
@@ -39,13 +53,16 @@
 <script setup lang="ts">
 import MainPanel from '@/components/main-panel/index.vue'
 import FillSlide from '@/components/fill-slide/index.vue'
+import ActionMenus from "@/components/group-button/index.vue";
 import {types, accidentPhotos, AccidentPhoto} from '@/store/accidentPhotos'
-import {getStaticFile} from "@/dbo/main";
 import UiIcon from "@/components/base/components/icon/index.vue";
 import {router, writeRouteName} from '@/router'
-import {computed, ref} from "vue";
+import {computed, ref, watchEffect} from "vue";
 import {Mode} from '@/views/graphic/menus'
 import UiButton from "@/components/base/components/button/index.vue";
+import Photos from "@/components/photos/index.vue";
+import Header from "@/components/photos/header.vue";
+import ButtonPane from "@/components/button-pane/index.vue";
 
 const sortPhotos = computed(() => {
   const photos = [...accidentPhotos.value]
@@ -61,13 +78,40 @@ const typePhotos = computed(() =>
     }))
     .filter(data => data.photos.length)
 )
+const selectMode = ref(false)
+const selects = ref<AccidentPhoto[]>([])
 const active = ref<AccidentPhoto>()
-const delPhoto = () => {
-  const index = accidentPhotos.value.indexOf(active.value)
+const menus = [
+  {
+    key: "road",
+    text: "修改",
+    onClick: () => gotoDraw()
+  },
+  {
+    key: "del",
+    text: "删除",
+    onClick: () => delPhoto()
+  }
+]
+
+watchEffect(() => {
+  if (!selectMode.value) {
+    selects.value = []
+  }
+})
+
+const delPhoto = (accidentPhoto = active.value) => {
+  const index = accidentPhotos.value.indexOf(accidentPhoto)
   if (~index) {
     accidentPhotos.value.splice(index, 1)
   }
 }
+const delSelects = () => {
+  while (selects.value.length) {
+    delPhoto(selects.value[0])
+    selects.value.shift()
+  }
+}
 
 const gotoDraw = () => {
   router.push({
@@ -79,42 +123,53 @@ const gotoDraw = () => {
 </script>
 
 <style scoped lang="scss">
-.photos-layout {
+.type-photos-layout {
   position: absolute;
   top: calc(var(--header-top) + var(--editor-head-height));
   left: 0;
   right: 0;
   bottom: 0;
   overflow-y: auto;
-}
-.type-photos p {
-  color: #000;
+  background: #2E2E2E;
+  padding: 25px 0;
 }
 
-.photos {
-  display: grid;
-  grid-gap: 10px;
-  padding: 10px;
-  //grid-template-rows: auto;
-  grid-template-columns: repeat(5, 1fr);
+.type-photos-content {
+  position: static;
+  overflow: initial;
+  background: none;
+  p {
+    color: #fff;
+    font-size: 14px;
+    margin-top: 8px;
+  }
 }
-.photo img {
-  width: 100%;
+
+
+.menus {
+  left: 50%;
+  transform: translateX(-50%);
+  bottom: var(--boundMargin);
 }
+.fun-ctrl {
+  color: #fff;
+  font-size: 20px;
+  transition: color .3s ease;
 
+  .icon {
+    position: absolute;
+    transform: translateX(-50%);
+  }
+}
 
-.btns {
-  display: flex;
-  align-items: center;
-  height: 100%;
-  justify-content: center;
+.del {
+  left: 50%;
+  transform: translateX(-50%);
+  bottom: var(--boundMargin);
 }
 
-.foot {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #fff;
-  height: 100%;
+.type-title {
+  padding: 0 24px 2px;
+  font-size: 16px;
 }
 </style>

+ 28 - 35
src/views/photos/index.vue

@@ -1,7 +1,7 @@
 <template>
   <MainPanel>
     <template v-slot:header>
-      <Header :count="selects.length">
+      <Header :count="selects.length" title="照片管理">
         <ui-button type="primary" @click="selectMode = !selectMode" width="96px">
           {{ selectMode ? '取消' : '选择' }}
         </ui-button>
@@ -23,24 +23,8 @@
   </MainPanel>
 
   <FillSlide :data="sortPhotos" v-model:active="active" @quit="active = null" v-if="active">
-    <template v-slot:header>
-      <div class="btns">
-        <ui-button width="100px" @click="gotoDraw(Mode.Road)" class="first-btn">
-          现场绘图
-        </ui-button>
-        <ui-button width="100px" @click="gotoDraw(Mode.Photo)">
-          事故照片
-        </ui-button>
-      </div>
-    </template>
     <template v-slot:foot>
-      <div class="foot">
-        <ui-icon
-          type="close"
-          @click="delPhoto"
-          ctrl
-        />
-      </div>
+      <ActionMenus class="menus" :menus="menus" dire="row" />
     </template>
   </FillSlide>
 </template>
@@ -57,11 +41,29 @@ import {computed, ref, watchEffect} from "vue";
 import {Mode} from '@/views/graphic/menus'
 import UiButton from "@/components/base/components/button/index.vue";
 import Photos from '@/components/photos'
+import ActionMenus from "@/components/group-button/index.vue";
 
 const sortPhotos = computed(() => photos.value.reverse())
 const active = ref<PhotoRaw>()
 const selectMode = ref(false)
 const selects = ref<PhotoRaw[]>([])
+const menus = [
+  {
+    key: "road",
+    text: "现场绘图",
+    onClick: () => gotoDraw(Mode.Road)
+  },
+  {
+    key: "accident",
+    text: "照片标注",
+    onClick: () => gotoDraw(Mode.Photo)
+  },
+  {
+    key: "del",
+    text: "删除",
+    onClick: () => delPhoto()
+  }
+]
 
 watchEffect(() => {
   if (!selectMode.value) {
@@ -83,7 +85,10 @@ const delSelects = () => {
 }
 
 const gotoDraw = (mode: Mode) => {
-  router.push({ name: writeRouteName.graphic, params: {mode, id: active.value.id, action: 'add'} })
+  router.push({
+    name: writeRouteName.graphic,
+    params: {mode, id: active.value.id, action: 'add'}
+  })
 }
 
 </script>
@@ -111,22 +116,10 @@ const gotoDraw = (mode: Mode) => {
   bottom: var(--boundMargin);
 }
 
-.btns {
-  display: flex;
-  align-items: center;
-  height: 100%;
-  justify-content: center;
-
-  .first-btn {
-    margin-right: 10px;
-  }
+.menus {
+  left: 50%;
+  transform: translateX(-50%);
+  bottom: var(--boundMargin);
 }
 
-.foot {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #fff;
-  height: 100%;
-}
 </style>

+ 90 - 54
src/views/roads/index.vue

@@ -1,30 +1,45 @@
 <template>
   <MainPanel>
     <template v-slot:header>
-      <ui-icon type="close" ctrl style="margin-right: 10px" @click="router.back" />
-      现场图管理({{sortPhotos.length}})
+      <Header :count="selects.length" :title="`现场图管理(${sortPhotos.length})`">
+        <ui-button
+            :type="selectMode ? 'primary' : 'normal'"
+            @click="selectMode = !selectMode"
+            width="96px"
+            style="margin-right: 16px"
+        >
+          {{ selectMode ? '取消' : '选择' }}
+        </ui-button>
+        <ui-button
+            v-if="!selectMode"
+            type="primary"
+            @click="router.push({name: writeRouteName.photos})"
+            width="96px"
+        >
+          新增
+        </ui-button>
+      </Header>
     </template>
 
-    <div class="photos-layout">
-      <div class="photos">
-        <div v-for="photo in sortPhotos" :key="photo.id" class="photo" @click="active = photo">
-          <img :src="getStaticFile(photo.photoUrl)"  />
-          <p>{{ photo.title || '默认标题' }}</p>
-        </div>
-      </div>
-    </div>
+    <Photos
+        v-model:active="active"
+        v-model:selects="selects"
+        :select-mode="selectMode"
+        :data="sortPhotos"
+    >
+      <template v-slot="{data}">
+        <p>{{ data.title || '默认标题' }}</p>
+      </template>
+    </Photos>
+
+    <ButtonPane class="del fun-ctrl" @click="delSelects" v-if="selects.length">
+      <ui-icon type="close" class="icon" />
+    </ButtonPane>
   </MainPanel>
 
   <FillSlide :data="sortPhotos" v-model:active="active" @quit="active = null" v-if="active">
-    <template v-slot:header>
-      <div class="btns">
-        <ui-button width="100px" @click="gotoDraw()">修改</ui-button>
-      </div>
-    </template>
     <template v-slot:foot>
-      <div class="foot">
-        <ui-icon type="close" @click="delPhoto" ctrl />
-      </div>
+      <ActionMenus class="menus" :menus="menus" dire="row" />
     </template>
   </FillSlide>
 </template>
@@ -33,22 +48,52 @@
 import MainPanel from '@/components/main-panel/index.vue'
 import FillSlide from '@/components/fill-slide/index.vue'
 import {roadPhotos, RoadPhoto} from '@/store/roadPhotos'
-import {getStaticFile} from "@/dbo/main";
-import UiIcon from "@/components/base/components/icon/index.vue";
+import ActionMenus from "@/components/group-button/index.vue";
 import {router, writeRouteName} from '@/router'
-import {computed, ref} from "vue";
+import {computed, ref, watchEffect} from "vue";
 import {Mode} from '@/views/graphic/menus'
 import UiButton from "@/components/base/components/button/index.vue";
+import Header from "@/components/photos/header.vue";
+import Photos from "@/components/photos/index.vue";
+import ButtonPane from "@/components/button-pane/index.vue";
+import UiIcon from "@/components/base/components/icon/index.vue";
+
 
 const sortPhotos = computed(() => roadPhotos.value.reverse())
 const active = ref<RoadPhoto>()
-const delPhoto = () => {
-  const index = roadPhotos.value.indexOf(active.value)
+const selectMode = ref(false)
+const selects = ref<RoadPhoto[]>([])
+const menus = [
+  {
+    key: "road",
+    text: "修改",
+    onClick: () => gotoDraw()
+  },
+  {
+    key: "del",
+    text: "删除",
+    onClick: () => delPhoto()
+  }
+]
+
+watchEffect(() => {
+  if (!selectMode.value) {
+    selects.value = []
+  }
+})
+
+const delPhoto = (roadPhoto = active.value) => {
+  const index = roadPhotos.value.indexOf(roadPhoto)
   if (~index) {
     roadPhotos.value.splice(index, 1)
   }
 }
-
+const delSelects = () => {
+  while (selects.value.length) {
+    delPhoto(selects.value[0])
+    selects.value.shift()
+  }
+}
 const gotoDraw = () => {
   router.push({
     name: writeRouteName.graphic,
@@ -59,41 +104,32 @@ const gotoDraw = () => {
 </script>
 
 <style scoped lang="scss">
-.photos-layout {
-  position: absolute;
-  top: calc(var(--header-top) + var(--editor-head-height));
-  left: 0;
-  right: 0;
-  bottom: 0;
-}
-.photos {
-  display: grid;
-  grid-gap: 10px;
-  padding: 10px;
-  //grid-template-rows: auto;
-  grid-template-columns: repeat(5, 1fr);
-}
-.photo img {
-  width: 100%;
-}
-
 .photo p {
-  color: #000;
+  color: #fff;
+  font-size: 14px;
+  margin-top: 8px;
 }
 
+.menus {
+  left: 50%;
+  transform: translateX(-50%);
+  bottom: var(--boundMargin);
+}
+.fun-ctrl {
+  color: #fff;
+  font-size: 20px;
+  transition: color .3s ease;
 
-.btns {
-  display: flex;
-  align-items: center;
-  height: 100%;
-  justify-content: center;
+  .icon {
+    position: absolute;
+    transform: translateX(-50%);
+  }
 }
 
-.foot {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #fff;
-  height: 100%;
+.del {
+  left: 50%;
+  transform: translateX(-50%);
+  bottom: var(--boundMargin);
 }
+
 </style>

+ 243 - 149
src/views/roads/tabulation.vue

@@ -1,194 +1,235 @@
 <template>
-  <div class="layout" v-if="roadPhoto">
-    <ui-button @click="downLayoutImage">下载</ui-button>
-    <ui-button @click="history.undo" type="primary" :class="{disabled: !history.state.hasUndo}">撤销</ui-button>
-    <ui-button @click="history.redo" type="primary" :class="{disabled: !history.state.hasRedo}">恢复</ui-button>
-    <ui-button type="primary" @click="roadPhoto.table = history.value">保存</ui-button>
-    <div class="content" ref="layoutRef">
-      <h2>{{ roadPhoto.title || "默认名称" }}</h2>
-      <table>
-        <tr>
-          <td class="label" width="150">到达事故现场时间</td>
-          <td class="value">
-            <ui-input
-                type="text"
-                @input="input"
-                v-model="history.value.arrivalTime"
-                @blur="history.push"
-            />
-          </td>
-          <td class="label" width="100">天气</td>
-          <td class="value" width="80">
-            <ui-input
-                type="text"
-                @input="input"
-                v-model="history.value.weather"
-                @blur="history.push"
-            />
-          </td>
-          <td class="label" width="100">路面性质</td>
-          <td class="value" width="150">
-            <ui-input
-                type="text"
-                @input="input"
-                v-model="history.value.conditions"
-                @blur="history.push"
-            />
-          </td>
-        </tr>
-        <tr>
-          <td class="label">事故发生地点</td>
-          <td class="value" colspan="5">
-            <ui-input
-                type="text"
-                @input="input"
-                v-model="history.value.location"
-                @blur="history.push"
-            />
-          </td>
-        </tr>
-        <tr>
-          <td class="image" colspan="6">
-            <div>
-              <img :src="getStaticFile(roadPhoto.photoUrl)" @blur="history.push" class="photo" />
-              <img
-                  src="/static/compass.png"
-                  :style="{transform: `rotateZ(${history.value.compassAngle}deg)`}"
-                  class="compass"
-                  @mousedown="downHandler"
+  <MainPanel>
+    <template v-slot:header>
+      <Header  title="现场绘图 | 制表">
+        <ui-button
+            type="primary"
+            @click="saveHandler"
+            width="96px"
+        >
+          保存
+        </ui-button>
+      </Header>
+    </template>
+
+    <div class="tab-layout" v-if="roadPhoto">
+      <div class="content" ref="layoutRef">
+        <table>
+          <tr>
+            <td class="value title" colspan="6" height="64">
+              <span v-if="downMode">{{roadPhoto.title}}</span>
+              <ui-input
+                  v-else
+                  type="text"
+                  @input="input"
+                  v-model="roadPhoto.title"
+                  @blur="history.push"
+              />
+            </td>
+          </tr>
+          <tr>
+            <td class="label" width="150" height="64">到达事故现场时间</td>
+            <td class="value">
+              <span v-if="downMode">{{history.value.arrivalTime}}</span>
+              <ui-input
+                  v-else
+                  type="text"
+                  @input="input"
+                  v-model="history.value.arrivalTime"
+                  @blur="history.push"
+              />
+            </td>
+            <td class="label" width="100">天气</td>
+            <td class="value" width="80">
+              <span v-if="downMode">{{history.value.weather}}</span>
+              <ui-input
+                  v-else
+                  type="text"
+                  @input="input"
+                  v-model="history.value.weather"
+                  @blur="history.push"
+              />
+            </td>
+            <td class="label" width="100">路面性质</td>
+            <td class="value" width="150">
+              <span v-if="downMode">{{history.value.conditions}}</span>
+              <ui-input
+                  v-else
+                  type="text"
+                  @input="input"
+                  v-model="history.value.conditions"
+                  @blur="history.push"
+              />
+            </td>
+          </tr>
+          <tr>
+            <td class="label"  height="64">事故发生地点</td>
+            <td class="value" colspan="5">
+              <span v-if="downMode">{{history.value.location}}</span>
+              <ui-input
+                  v-else
+                  type="text"
+                  @input="input"
+                  v-model="history.value.location"
+                  @blur="history.push"
               />
-            </div>
-          </td>
-        </tr>
-        <tr>
-          <td class="value" colspan="6">
-            <ui-input
-                type="text"
-                @input="input"
-                v-model="history.value.illustrate"
-                @blur="history.push"
-            />
-          </td>
-        </tr>
-        <tr>
-          <td class="value" colspan="6">
-            <ui-input
-                type="text"
-                @input="input"
-                v-model="history.value.other"
-                @blur="history.push"
-            />
-          </td>
-        </tr>
-      </table>
-      <div class="signatures">
-        <p class="signature">绘图员:</p>
-        <p class="signature">当事人签名:</p>
-        <p class="signature">勘察员:</p>
-        <p class="signature">见证人签名:</p>
+            </td>
+          </tr>
+          <tr>
+            <td class="image" colspan="6" height="360">
+              <div class="photo-layout">
+                <img
+                    :src="getStaticFile(roadPhoto.photoUrl)"
+                    @blur="history.push"
+                    class="photo"
+                    :style="{transform: photoCSSMatrix}"
+                    ref="photoRef"
+                />
+                <img
+                    src="/static/compass.png"
+                    class="compass"
+                    :style="{transform: compassCSSMatrix}"
+                    ref="compassRef"
+                />
+<!--                :style="{transform: `rotateZ(${history.value.compassAngle}deg)`}"-->
+<!--                @mousedown="downHandler"-->
+              </div>
+            </td>
+          </tr>
+          <tr>
+            <td class="value" colspan="6" height="64">
+              <span v-if="downMode">{{history.value.illustrate}}</span>
+              <ui-input
+                  v-else
+                  type="text"
+                  @input="input"
+                  v-model="history.value.illustrate"
+                  @blur="history.push"
+              />
+            </td>
+          </tr>
+          <tr >
+            <td class="value date" colspan="6" height="48">
+              {{ formatDate(new Date(), "yyyy年MM月dd日hh时mm分") }}
+            </td>
+          </tr>
+        </table>
+        <div class="signatures">
+          <p class="signature">绘图员:</p>
+          <p class="signature">当事人签名:</p>
+          <p class="signature">勘察员:</p>
+          <p class="signature">见证人签名:</p>
+        </div>
       </div>
     </div>
-  </div>
+  </MainPanel>
 </template>
 
 <script setup lang="ts">
 import { router, writeRouteName } from '@/router'
-import {computed, ref} from "vue";
+import { formatDate } from "@/utils";
+import {computed, nextTick, ref} from "vue";
 import { useHistory } from '@/hook/useHistory'
 import {roadPhotos, RoadPhoto, getDefaultTable} from "@/store/roadPhotos";
 import {getStaticFile} from "@/dbo/main";
 import html2canvas from 'html2canvas'
 import UiButton from "@/components/base/components/button/index.vue";
 import UiInput from "@/components/base/components/input/index.vue";
-import { mathUtil } from '@/graphic/Util/MathUtil'
-import {getPostionByTarget} from "@/components/base/utils";
+import {HandMode, useHand} from '@/hook/useHand'
+import Header from "@/components/photos/header.vue";
+import MainPanel from "@/components/main-panel/index.vue";
 
 const roadPhoto = computed<RoadPhoto>(() => {
-  const route = router.currentRoute.value;
-  const params = route.params
-  let data
-  if (route.name !== writeRouteName.tabulation) {
-    return null
-  } else if (!params.id || !(data = roadPhotos.value.find(data => data.id === params.id))) {
+  let route, params, data
+  if (((route = router.currentRoute.value).name === writeRouteName.tabulation)
+    && (params = route.params).id
+    && (data = roadPhotos.value.find(data => data.id === params.id))) {
+    return data
+  } else {
     router.back();
-    return null;
   }
-  return data
 })
 const history = computed(
   () => roadPhoto.value && useHistory(getDefaultTable(roadPhoto.value))
 )
+const input = () => history.value.state.hasRedo = false
 
-const input = () => {
-  history.value.state.hasRedo = false
-}
-
-const downHandler = (ev: MouseEvent) => {
-  const target = (ev.target as HTMLImageElement)
-  const page = getPostionByTarget(target, document.documentElement)
-  const start = { x: page.x + target.offsetWidth / 2, y: page.y }
-  const center = { x: page.x + target.offsetWidth / 2, y: page.y + target.offsetHeight / 2 }
-  let angle
-  const moveHandler = (ev: MouseEvent) => {
-    const move = {
-      x: ev.pageX,
-      y: ev.pageY
-    }
-    angle = mathUtil.Angle(center, start, move)
-    angle = move.x < start.x ? -angle : angle
-    target.style.transform = `rotateZ(${angle}deg)`
-    ev.stopPropagation();
-    ev.preventDefault();
-  }
-  const upHandler = (ev:MouseEvent) => {
-    document.documentElement.removeEventListener("mousemove", moveHandler)
-    document.documentElement.removeEventListener("mouseup", upHandler);
-    ev.stopPropagation();
-    ev.preventDefault();
-    history.value.value.compassAngle = angle
+const compassRef = ref<HTMLImageElement>()
+const { cssMatrix: compassCSSMatrix, matrix: compassMatrix } = useHand(
+  compassRef,
+  HandMode.Angle,
+  () => {
+    history.value.value.compassAngle = compassMatrix.value
     history.value.push()
-  }
-  document.documentElement.addEventListener("mousemove", moveHandler)
-  document.documentElement.addEventListener("mouseup", upHandler)
+  },
+  history.value.value.compassAngle
+)
+const photoRef = ref<HTMLImageElement>()
+const { cssMatrix: photoCSSMatrix, matrix: photoMatrix } = useHand(
+  photoRef,
+  HandMode.MoveAndScale,
+  () => {
+    history.value.value.imageTransform = photoMatrix.value
+    history.value.push()
+  },
+  history.value.value.imageTransform
+)
 
-  ev.stopPropagation();
-  ev.preventDefault();
-}
 
+
+
+const downMode = ref(false)
 const layoutRef = ref<HTMLDivElement>()
 const downLayoutImage = async () => {
+  downMode.value = true
+  await nextTick()
   const canvas = await html2canvas(layoutRef.value)
+  downMode.value = false
   const blob = await new Promise<Blob>(resolve => canvas.toBlob(resolve, "image/jpeg", 0.95))
   window.open(URL.createObjectURL(blob))
 }
+const saveHandler = () => {
+  roadPhoto.value.table = history.value.value
+  downLayoutImage()
+}
+
 </script>
 
 <style lang="scss" scoped>
-.layout {
+.tab-layout {
+  position: absolute;
+  top: calc(var(--header-top) + var(--editor-head-height));
+  bottom: 0;
   overflow-y: auto;
-  height: 100%;
+  left: 0;
+  right: 0;
+  color: #000;
+  font-size: 16px;
 }
 
 .content {
   box-sizing: content-box;
   width: 980px;
-  height: 1300px;
-  padding: 20px;
-  padding-bottom: 60px;
+  padding: 20px 20px 60px;
   margin: 0 auto;
 }
 
 .image {
   position: relative;
-  div {
+  .photo-layout {
     position: absolute;
-    left: 0;
-    right: 0;
-    bottom: 0;
-    top: 0;
+    left: 1px;
+    right: 1px;
+    bottom: 1px;
+    top: 1px;
+    overflow: hidden;
+    display: flex;
+    justify-content: center;
+
     .photo {
       max-width: 100%;
       max-height: 100%;
+      align-items: center;
     }
     .compass {
       position: absolute;
@@ -202,22 +243,54 @@ const downLayoutImage = async () => {
 .content table {
   width: 980px;
   height: 800px;
-  border: 2px solid #000;
   border-collapse: collapse;
 
-  td:not(:last-child) {
-    border-right: 2px solid #000;
+
+  tr:not(:first-child) {
+    &:nth-child(2) td {
+      border-top: 2px solid #000;
+    }
+
+    td:first-child {
+      border-left: 2px solid #000;
+    }
+    td {
+      border-right: 2px solid #000;
+      border-bottom: 2px solid #000;
+    }
   }
-  tr:not(:last-child) td {
-    border-bottom: 2px solid #000;
+
+  .label {
+    text-align: center;
   }
 
   .value {
     height: 43px;
+    background-color: #D4E8FF;
+  }
+
+  .title {
+    padding-bottom: 16px;
+    position: relative;
+
+    &:after {
+      content: "";
+      position: absolute;
+      bottom: 0;
+      left: 0;
+      right: 0;
+      height: 8px;
+      background-color: #fff;
+    }
+  }
+
+  .date {
+    text-align: right;
   }
 }
 
 .signatures {
+  margin-top: 13px;
   display: flex;
 
   .signature {
@@ -233,11 +306,32 @@ const downLayoutImage = async () => {
   input,
   .ui-input {
     width: 100%;
-    height: 100%;
-    padding: 0 !important;
+    height: 32px !important;
     outline: none !important;
-    border: none !important;
     color: #000 !important;
+    border: 1px #000 dotted !important;
+    font-size: 16px !important;
+    line-height: 32px !important;
+    vertical-align: middle !important;
+  }
+}
+
+.title {
+  span {
+    display: block;
+    font-size: 32px !important;
+    height: 48px !important;
+    font-weight: bold;
+    text-align: center;
+    line-height: 48px !important;
+  }
+  input,
+  .ui-input {
+    font-size: 32px !important;
+    height: 48px !important;
+    font-weight: bold;
+    text-align: center;
+    line-height: 48px !important;
   }
 }
 </style>

+ 32 - 0
yarn.lock

@@ -14,6 +14,13 @@
   dependencies:
     regenerator-runtime "^0.13.11"
 
+"@babel/runtime@^7.17.2":
+  version "7.21.5"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200"
+  integrity sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==
+  dependencies:
+    regenerator-runtime "^0.13.11"
+
 "@esbuild/android-arm64@0.17.16":
   version "0.17.16"
   resolved "http://192.168.0.47:4873/@esbuild/android-arm64/-/android-arm64-0.17.16.tgz#7b18cab5f4d93e878306196eed26b6d960c12576"
@@ -566,6 +573,16 @@ coordtransform@^2.1.2:
   resolved "http://192.168.0.47:4873/coordtransform/-/coordtransform-2.1.2.tgz#090349cd62b1dc05fae47b291bf9f0b7ce827f66"
   integrity sha512-0xLJApBlrUP+clyLJWIaqg4GXE5JTbAJb5d/CDMqebIksAMMze8eAyO6YfHEIxWJ+c42mXoMHBzWTeUrG7RFhw==
 
+copy-text-to-clipboard@^3.0.1:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/copy-text-to-clipboard/-/copy-text-to-clipboard-3.1.0.tgz#6bf40deef0a51ac6858efb0d76ded2c6d6a15059"
+  integrity sha512-PFM6BnjLnOON/lB3ta/Jg7Ywsv+l9kQGD4TWDCSlRBGmqnnTM5MrDkhAFgw+8HZt0wW6Q2BBE4cmy9sq+s9Qng==
+
+core-js@^3.11.0:
+  version "3.30.2"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.30.2.tgz#6528abfda65e5ad728143ea23f7a14f0dcf503fc"
+  integrity sha512-uBJiDmwqsbJCWHAwjrx3cvjbMXP7xD72Dmsn5LOJpiRmE3WbBbN5rCqQ2Qh6Ek6/eOrjlWngEynBWo4VxerQhg==
+
 core-js@^3.6.0, core-js@^3.8.3:
   version "3.30.0"
   resolved "http://192.168.0.47:4873/core-js/-/core-js-3.30.0.tgz#64ac6f83bc7a49fd42807327051701d4b1478dea"
@@ -972,6 +989,11 @@ muggle-string@^0.2.2:
   resolved "http://192.168.0.47:4873/muggle-string/-/muggle-string-0.2.2.tgz#786aa53fea1652c61c6a59e1f839292b262bc72a"
   integrity sha512-YVE1mIJ4VpUMqZObFndk9CJu6DBJR/GB13p3tXuNbwD4XExaI5EOuRl6BHeIDxIqXZVxSfAC+y6U1Z/IxCfKUg==
 
+mutation-observer@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/mutation-observer/-/mutation-observer-1.0.3.tgz#42e9222b101bca82e5ba9d5a7acf4a14c0f263d0"
+  integrity sha512-M/O/4rF2h776hV7qGMZUH3utZLO/jK7p8rnNgGkjKUw8zCGjRQPxB8z6+5l8+VjRUQ3dNYu4vjqXYLr+U8ZVNA==
+
 nanoid@^3.3.4:
   version "3.3.6"
   resolved "http://192.168.0.47:4873/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
@@ -1297,6 +1319,16 @@ vary@~1.1.2:
   resolved "http://192.168.0.47:4873/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
   integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
 
+vconsole@^3.15.0:
+  version "3.15.0"
+  resolved "https://registry.yarnpkg.com/vconsole/-/vconsole-3.15.0.tgz#2383482b0a4106204090046ec128071284e04a90"
+  integrity sha512-8hq7wabPcRucSWQyN7/1tthMawP9JPvM95zgtMHpPknMMMCKj+abpoK7P7oKK4B0qw58C24Mdvo9+raUdpHyVQ==
+  dependencies:
+    "@babel/runtime" "^7.17.2"
+    copy-text-to-clipboard "^3.0.1"
+    core-js "^3.11.0"
+    mutation-observer "^1.0.3"
+
 vite@^4.2.0:
   version "4.2.1"
   resolved "http://192.168.0.47:4873/vite/-/vite-4.2.1.tgz#6c2eb337b0dfd80a9ded5922163b94949d7fc254"