Browse Source

-m 'feat: 裁切图编辑'

zhangjie 9 months ago
parent
commit
0c9d5c0fa8

+ 41 - 1
src/render/ap/base.ts

@@ -1,6 +1,11 @@
 import { request } from "@/utils/request";
 import { ExamParams } from "./types/common";
-import { SubjectItem } from "./types/base";
+import {
+  SubjectItem,
+  UploadSheetParams,
+  UploadSliceParams,
+  UploadFileResult,
+} from "./types/base";
 
 export const subjectList = (data: ExamParams): Promise<SubjectItem[]> =>
   request({
@@ -14,3 +19,38 @@ export const examList = (): Promise<Exam[]> =>
     method: "post",
     data,
   });
+
+// 原图上传
+export const uploadSheet = (
+  data: UploadSheetParams
+): Promise<UploadFileResult> => {
+  const formData = new FormData();
+  for (const key in data) {
+    if (Object.prototype.hasOwnProperty.call(data, key)) {
+      const val = data[key];
+      formData.append(key, val);
+    }
+  }
+  return request({
+    url: "/api/scan/batch/sheet/upload",
+    method: "post",
+    data: formData,
+  });
+};
+// 裁切图上传
+export const uploadSlice = (
+  data: UploadSliceParams
+): Promise<UploadFileResult> => {
+  const formData = new FormData();
+  for (const key in data) {
+    if (Object.prototype.hasOwnProperty.call(data, key)) {
+      const val = data[key];
+      formData.append(key, val);
+    }
+  }
+  return request({
+    url: "/api/scan/batch/slice/upload",
+    method: "post",
+    data: formData,
+  });
+};

+ 17 - 0
src/render/ap/types/base.ts

@@ -9,3 +9,20 @@ export interface SubjectItem {
 //   name: string;
 //   mode: string;
 // }
+
+export interface UploadSheetParams {
+  batchId: number;
+  examNumber: string;
+  paperNumber: number;
+  pageIndex: number;
+  file: File;
+  md5: string;
+}
+export interface UploadSliceParams extends UploadSheetParams {
+  index: number;
+}
+
+export interface UploadFileResult {
+  uri: string;
+  updateTime: number;
+}

BIN
src/render/assets/imgs/paper.jpg


+ 460 - 0
src/render/components/ElementResize/index.vue

@@ -0,0 +1,460 @@
+<template>
+  <div
+    ref="elRef"
+    v-ele-move-directive.prevent.stop="{
+      moveStart,
+      moveElement,
+      moveStop: moveElementOver,
+    }"
+    :class="classes"
+    :style="styles"
+  >
+    <slot></slot>
+    <div class="resize-control">
+      <template v-for="(control, index) in controlPoints" :key="index">
+        <div
+          v-ele-move-directive.prevent.stop="{
+            moveElement: control.movePoint,
+            moveStop: control.movePointOver,
+          }"
+          :class="control.classes"
+        ></div>
+      </template>
+      <div class="control-line control-line-left"></div>
+      <div class="control-line control-line-right"></div>
+      <div class="control-line control-line-top"></div>
+      <div class="control-line control-line-bottom"></div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { reactive, ref, computed, onMounted, onBeforeMount } from "vue";
+import { vEleMoveDirective } from "../../directives/eleMove";
+import { objModifyAssign } from "../../utils/tool";
+
+import {
+  defaultActive,
+  defaultFitParent,
+  SizeData,
+  ActionType,
+  FitParentItem,
+  ContronItem,
+  PositionData,
+} from "./types";
+
+defineOptions({
+  name: "ElementResize",
+});
+
+const emit = defineEmits<{
+  (event: "update:modelValue", data: SizeData): void;
+  (event: "change", data: SizeData): void;
+  (event: "resizeOver", data: SizeData): void;
+  (event: "onClick"): void;
+}>();
+
+interface Props {
+  modelValue: SizeData;
+  active?: ActionType[];
+  move?: boolean;
+  minWidth?: number;
+  maxWidth?: number;
+  minHeight?: number;
+  maxHeight?: number;
+  fitParent?: FitParentItem[];
+  isCompact?: boolean;
+}
+const props = withDefaults(defineProps<Props>(), {
+  active: () => [...defaultActive],
+  move: true,
+  minWidth: 30,
+  maxWidth: 0,
+  minHeight: 30,
+  maxHeight: 0,
+  fitParent: () => [...defaultFitParent],
+  isCompact: false,
+});
+
+const sizePosOrigin = reactive({ x: 0, y: 0, w: 0, h: 0 });
+const sizePos = reactive({ x: 0, y: 0, w: 0, h: 0 });
+const offsetTopOrigin = ref(0);
+const lastSizePos = reactive({ x: 0, y: 0, w: 0, h: 0 });
+const initOver = ref(false);
+const controlPoints = ref<ContronItem[]>([]);
+const parentNodeSize = reactive({ w: 0, h: 0 });
+const elRef = ref();
+
+const styles = computed(() => {
+  return initOver.value
+    ? {
+        left: `${sizePos.x}px`,
+        top: `${sizePos.y}px`,
+        width: `${sizePos.w}px`,
+        height: `${sizePos.h}px`,
+        zIndex: props.modelValue.zindex || "auto",
+        position: "absolute",
+      }
+    : {};
+});
+
+const classes = computed(() => {
+  return [
+    "element-resize",
+    {
+      "element-resize-move": props.move,
+      "element-resize-init": initOver.value,
+      "element-resize-compact": props.isCompact,
+    },
+  ];
+});
+
+const fitParentTypeWidth = computed(() => {
+  return props.fitParent.includes("w");
+});
+const fitParentTypeHeight = computed(() => {
+  return props.fitParent.includes("h");
+});
+
+function initControlPoints() {
+  const actions = {
+    l: moveLeftPoint,
+    r: moveRightPoint,
+    t: moveTopPoint,
+    b: moveBottomPoint,
+    lt: moveLeftTopPoint,
+    rt: moveRightTopPoint,
+    lb: moveLeftBottomPoint,
+    rb: moveRightBottomPoint,
+  };
+  controlPoints.value = props.active.map((type) => {
+    return {
+      classes: ["control-point", `control-point-${type}`],
+      movePoint: actions[type],
+      movePointOver: moveOver,
+    };
+  });
+}
+
+function initSize() {
+  const elDom = elRef.value as HTMLElement;
+  const resizeDom = elDom.firstElementChild as Element;
+
+  objModifyAssign(sizePos, props.modelValue);
+  objModifyAssign(lastSizePos, props.modelValue);
+  objModifyAssign(sizePosOrigin, props.modelValue);
+  initOver.value = true;
+}
+
+function fetchValidSizePos(
+  sizeData: SizeData,
+  actionType: ActionType | "move"
+) {
+  if (sizeData.w <= props.minWidth) {
+    sizeData.w = props.minWidth;
+  }
+  if (props.maxWidth !== 0 && sizeData.w >= props.maxWidth) {
+    sizeData.w = props.maxWidth;
+  }
+
+  if (sizeData.h <= props.minHeight) {
+    sizeData.h = props.minHeight;
+  }
+  if (props.maxHeight !== 0 && sizeData.h >= props.maxHeight) {
+    sizeData.h = props.maxHeight;
+  }
+
+  if (!props.fitParent.length) {
+    objModifyAssign(lastSizePos, sizeData);
+    return sizeData;
+  }
+
+  // 不同的定位方式,计算方式有差异
+  const elDom = elRef.value as HTMLElement;
+  const elParentDom = elDom.offsetParent as HTMLElement;
+  parentNodeSize.w = elParentDom.offsetWidth;
+  parentNodeSize.h = elParentDom.offsetHeight;
+
+  if (fitParentTypeWidth.value) {
+    if (sizeData.x <= 0) {
+      sizeData.x = 0;
+      if (actionType.includes("l")) sizeData.w = lastSizePos.w;
+    }
+
+    if (sizeData.x + sizeData.w > parentNodeSize.w) {
+      sizeData.x = lastSizePos.x;
+      sizeData.w = parentNodeSize.w - sizeData.x;
+    }
+  }
+
+  if (fitParentTypeHeight.value) {
+    if (sizeData.y <= 0) {
+      sizeData.y = 0;
+      if (actionType.includes("t")) sizeData.h = lastSizePos.h;
+    }
+    if (sizeData.y + sizeData.h > parentNodeSize.h) {
+      sizeData.y = lastSizePos.y;
+      sizeData.h = parentNodeSize.h - sizeData.y;
+    }
+  }
+
+  objModifyAssign(lastSizePos, sizeData);
+  return sizeData;
+}
+
+function getLeftSize(left: number) {
+  return {
+    w: -left + sizePosOrigin.w,
+    x: left + sizePosOrigin.x,
+  };
+}
+function getRightSize(left: number) {
+  return {
+    w: left + sizePosOrigin.w,
+  };
+}
+function getTopSize(top: number) {
+  return {
+    h: -top + sizePosOrigin.h,
+    y: top + sizePosOrigin.y,
+  };
+}
+function getBottomSize(top: number) {
+  return {
+    h: top + sizePosOrigin.h,
+  };
+}
+
+function moveLeftPoint({ left }: Pick<PositionData, "left">) {
+  const sp = { ...sizePos, ...getLeftSize(left) };
+  objModifyAssign(sizePos, fetchValidSizePos(sp, "l"));
+  emitChange();
+}
+function moveRightPoint({ left }: Pick<PositionData, "left">) {
+  const sp = { ...sizePos, ...getRightSize(left) };
+  objModifyAssign(sizePos, fetchValidSizePos(sp, "r"));
+  emitChange();
+}
+
+function moveTopPoint({ top }: Pick<PositionData, "top">) {
+  const sp = { ...sizePos, ...getTopSize(top) };
+  objModifyAssign(sizePos, fetchValidSizePos(sp, "t"));
+  emitChange();
+}
+
+function moveBottomPoint({ top }: Pick<PositionData, "top">) {
+  const sp = { ...sizePos, ...getBottomSize(top) };
+  objModifyAssign(sizePos, fetchValidSizePos(sp, "b"));
+  emitChange();
+}
+
+function moveLeftTopPoint({ left, top }: PositionData) {
+  const sp = {
+    ...sizePos,
+    ...getLeftSize(left),
+    ...getTopSize(top),
+  };
+  objModifyAssign(sizePos, fetchValidSizePos(sp, "lt"));
+  emitChange();
+}
+
+function moveRightTopPoint({ left, top }: PositionData) {
+  const sp = {
+    ...sizePos,
+    ...getRightSize(left),
+    ...getTopSize(top),
+  };
+  objModifyAssign(sizePos, fetchValidSizePos(sp, "rt"));
+  emitChange();
+}
+
+function moveLeftBottomPoint({ left, top }: PositionData) {
+  const sp = {
+    ...sizePos,
+    ...getLeftSize(left),
+    ...getBottomSize(top),
+  };
+  objModifyAssign(sizePos, fetchValidSizePos(sp, "lb"));
+  emitChange();
+}
+
+function moveRightBottomPoint({ left, top }: PositionData) {
+  const sp = {
+    ...sizePos,
+    ...getRightSize(left),
+    ...getBottomSize(top),
+  };
+  objModifyAssign(sizePos, fetchValidSizePos(sp, "rb"));
+  emitChange();
+}
+
+function moveOver() {
+  objModifyAssign(sizePosOrigin, sizePos);
+  objModifyAssign(lastSizePos, sizePos);
+  emitChange();
+  emit("resizeOver", sizePos);
+}
+
+function moveStart() {
+  emit("onClick");
+}
+
+function moveElement({ left, top }: PositionData) {
+  if (!props.move) return;
+
+  const sp = {
+    ...sizePos,
+    ...{
+      x: left + sizePosOrigin.x,
+      y: top + sizePosOrigin.y,
+    },
+  };
+  objModifyAssign(sizePos, fetchValidSizePos(sp, "move"));
+  emitChange();
+}
+
+function moveElementOver() {
+  if (!props.move) return;
+  moveOver();
+}
+
+function emitChange() {
+  emit("update:modelValue", sizePos);
+  emit("change", sizePos);
+}
+
+onMounted(() => {
+  initSize();
+});
+onBeforeMount(() => {
+  initControlPoints();
+});
+</script>
+
+<style lang="less" scoped>
+.element-resize {
+  position: static;
+  z-index: auto;
+  background: transparent;
+  box-sizing: content-box;
+
+  &-move {
+    cursor: move;
+  }
+
+  &-init {
+    > div:first-child {
+      width: 100% !important;
+      height: 100% !important;
+      position: relative !important;
+      top: 0 !important;
+      left: 0 !important;
+      overflow: hidden;
+    }
+  }
+  .control-point {
+    position: absolute;
+    width: 12px;
+    height: 12px;
+    background: #f53f3f;
+    z-index: 99;
+    &-l {
+      left: 0;
+      top: 50%;
+      width: 12px;
+      height: 12px;
+      margin-top: -6px;
+      border-radius: 0;
+      cursor: w-resize;
+    }
+    &-lt {
+      left: 0;
+      top: 0;
+      cursor: nw-resize;
+    }
+    &-lb {
+      left: 0;
+      bottom: 0;
+      cursor: sw-resize;
+    }
+    &-r {
+      right: 0;
+      top: 50%;
+      margin-top: -6px;
+      cursor: e-resize;
+    }
+    &-rt {
+      right: 0;
+      top: 0;
+      cursor: ne-resize;
+    }
+    &-rb {
+      right: 0;
+      bottom: 0;
+      cursor: se-resize;
+    }
+    &-t {
+      left: 50%;
+      top: 0;
+      margin-left: -6px;
+      cursor: n-resize;
+    }
+    &-b {
+      left: 50%;
+      bottom: 0;
+      margin-left: -6px;
+      cursor: s-resize;
+    }
+  }
+  .control-line {
+    position: absolute;
+    z-index: 98;
+
+    &-left {
+      height: 100%;
+      left: 0;
+      top: 0;
+      border-left: 4px solid #f53f3f;
+    }
+    &-right {
+      height: 100%;
+      right: 0;
+      top: 0;
+      border-left: 4px solid #f53f3f;
+    }
+    &-top {
+      width: 100%;
+      left: 0;
+      top: 0;
+      border-top: 4px solid #f53f3f;
+    }
+    &-bottom {
+      width: 100%;
+      left: 0;
+      bottom: 0;
+      border-top: 4px solid #f53f3f;
+    }
+  }
+
+  &-compact {
+    .control-line {
+      &-left {
+        left: 0;
+        border-left: 1px dashed #bbb;
+      }
+      &-right {
+        right: 0;
+        border-left: 1px dashed #bbb;
+      }
+      &-top {
+        top: 0;
+        border-top: 1px dashed #bbb;
+      }
+      &-bottom {
+        bottom: 0;
+        border-top: 1px dashed #bbb;
+      }
+    }
+  }
+}
+</style>

+ 41 - 0
src/render/components/ElementResize/types.ts

@@ -0,0 +1,41 @@
+export const defaultActive = [
+  'r',
+  'rb',
+  'b',
+  'lb',
+  'l',
+  'lt',
+  't',
+  'rt',
+] as const;
+export type ActionType = (typeof defaultActive)[number];
+export const defaultFitParent = ['w', 'h'] as const;
+export type FitParentItem = (typeof defaultFitParent)[number];
+
+export interface SizeData {
+  x: number;
+  y: number;
+  w: number;
+  h: number;
+  zindex?: number;
+}
+
+export interface PositionData {
+  left: number;
+  top: number;
+}
+
+export interface ContronItem {
+  classes: string[];
+  movePoint: ({ left, top }: PositionData) => void;
+  movePointOver: (e: MouseEvent) => void;
+}
+
+export interface TransformFitParam extends SizeData {
+  id: string;
+}
+
+export type TransformFitFunction = (
+  data: TransformFitParam,
+  actionType: ActionType | 'move'
+) => SizeData;

+ 237 - 0
src/render/components/SliceImage/CutImageDialog.vue

@@ -0,0 +1,237 @@
+<template>
+  <a-modal
+    v-model:open="visible"
+    width="100%"
+    :footer="false"
+    :closable="false"
+    :maskClosable="false"
+    wrapClassName="cut-image-dialog full-modal"
+  >
+    <div ref="imgContainRef" class="cut-image">
+      <div v-if="visible" class="cut-image-body" :style="imageStyle">
+        <img ref="imgRef" :src="sheetUrl" alt="原图" @load="initImageSize" />
+
+        <element-resize
+          v-if="selection.w"
+          v-model="selection"
+          class="element-resize-act"
+          :active="['r', 'rb', 'b', 'lb', 'l', 'lt', 't', 'rt']"
+        >
+          <div class="image-selection" :style="selectionStyle"></div>
+        </element-resize>
+      </div>
+
+      <div class="cut-image-action">
+        <div class="cut-close" @click="close"><CloseOutlined /></div>
+        <div class="cut-save" @click="confirm"><SaveOutlined /></div>
+      </div>
+    </div>
+  </a-modal>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from "vue";
+import { SaveOutlined, CloseOutlined } from "@ant-design/icons-vue";
+import useModal from "@/hooks/useModal";
+import { objAssign } from "@/utils/tool";
+
+import ElementResize from "../ElementResize/index.vue";
+
+defineOptions({
+  name: "CutImageDialog",
+});
+
+/* modal */
+const { visible, open, close } = useModal();
+defineExpose({ open, close });
+
+open();
+
+const props = defineProps<{
+  sheetUrl: string;
+  sliceSelection?: AreaSize;
+}>();
+
+const emit = defineEmits(["confirm"]);
+
+const initSelection = {
+  w: 0,
+  h: 0,
+  x: 0,
+  y: 0,
+};
+
+const originImgRef = ref();
+const curCroppper = ref();
+const showCanvas = ref(false);
+const selection = ref({
+  ...initSelection,
+});
+
+const selectionStyle = computed(() => {
+  return {
+    width: `${selection.value.w}px`,
+    height: `${selection.value.h}px`,
+    top: `${selection.value.y}px`,
+    left: `${selection.value.x}px`,
+  };
+});
+
+const imageSize = ref({
+  width: 0,
+  height: 0,
+  left: 0,
+  top: 0,
+});
+const imageStyle = computed(() => {
+  return {
+    width: `${imageSize.value.width}px`,
+    height: `${imageSize.value.height}px`,
+    top: `${imageSize.value.top}px`,
+    left: `${imageSize.value.left}px`,
+  };
+});
+
+const imgContainRef = ref();
+const imgRef = ref();
+
+function initImageSize() {
+  const imgDom = imgRef.value as HTMLImageElement;
+  const elDom = imgContainRef.value as HTMLDivElement;
+
+  const imgSize = getImageSizePos({
+    win: {
+      width: elDom.clientWidth,
+      height: elDom.clientHeight,
+    },
+    img: {
+      width: imgDom.naturalWidth,
+      height: imgDom.naturalHeight,
+    },
+    rotate: 0,
+  });
+  imageSize.value = objAssign(imageSize.value, imgSize);
+
+  if (!props.sliceSelection) return;
+  const rate = imgDom.naturalWidth / imageSize.value.width;
+  selection.value = {
+    x: props.sliceSelection.x / rate,
+    y: props.sliceSelection.y / rate,
+    w: props.sliceSelection.w / rate,
+    h: props.sliceSelection.h / rate,
+  };
+}
+
+interface AreaSize {
+  width: number;
+  height: number;
+}
+function getImageSizePos({
+  win,
+  img,
+  rotate,
+}: {
+  win: AreaSize;
+  img: AreaSize;
+  rotate: number;
+}) {
+  const imageSize = {
+    width: 0,
+    height: 0,
+    top: 0,
+    left: 0,
+  };
+  const isHorizontal = !!(rotate % 180);
+
+  const rateWin = isHorizontal
+    ? win.height / win.width
+    : win.width / win.height;
+  const hwin = isHorizontal
+    ? {
+        width: win.height,
+        height: win.width,
+      }
+    : win;
+
+  const rateImg = img.width / img.height;
+
+  if (rateImg <= rateWin) {
+    imageSize.height = Math.min(hwin.height, img.height);
+    imageSize.width = Math.floor((imageSize.height * img.width) / img.height);
+  } else {
+    imageSize.width = Math.min(hwin.width, img.width);
+    imageSize.height = Math.floor((imageSize.width * img.height) / img.width);
+  }
+  imageSize.left = (win.width - imageSize.width) / 2;
+  imageSize.top = (win.height - imageSize.height) / 2;
+  return imageSize;
+}
+
+async function confirm() {
+  const imgDom = imgRef.value as HTMLImageElement;
+  const rate = imageSize.value.width / imgDom.naturalWidth;
+
+  const selectionArea: AreaSize = {
+    x: selection.value.x / rate,
+    y: selection.value.y / rate,
+    w: selection.value.w / rate,
+    h: selection.value.h / rate,
+  };
+
+  const file = await getSliceImage(imgDom, selectionArea).catch((e) => {
+    console.error(e);
+  });
+  if (!file) return;
+
+  console.log(file);
+}
+
+function getSliceImage(
+  imgDom: HTMLImageElement,
+  area: AreaSize
+): Promise<File> {
+  return new Promise((resolve, reject) => {
+    const canvas = document.createElement("canvas");
+    const ctx = canvas.getContext("2d");
+    if (!ctx) return reject(new Error("不支持canvas"));
+
+    canvas.width = area.w;
+    canvas.height = area.h;
+
+    ctx.drawImage(
+      imgDom,
+      area.x,
+      area.y,
+      area.w,
+      area.h,
+      0,
+      0,
+      canvas.width,
+      canvas.height
+    );
+
+    canvas.toBlob((blob) => {
+      if (blob) {
+        resolve(new File([blob], "slice.png", { type: "image/png" }));
+      } else {
+        reject(new Error("构建文件失败"));
+      }
+    });
+  });
+}
+
+// init
+watch(
+  () => visible.value,
+  (val) => {
+    if (!val) {
+      selection.value = {
+        ...initSelection,
+      };
+    }
+  },
+  {
+    immediate: true,
+  }
+);
+</script>

+ 73 - 0
src/render/components/SliceImage/index.vue

@@ -0,0 +1,73 @@
+<template>
+  <div class="slice-image">
+    <div
+      v-for="item in studentSlice.papers"
+      :key="item.number"
+      class="image-paper"
+    >
+      <div
+        v-for="(page, pindex) in item.pages"
+        :key="pindex"
+        class="image-page"
+      >
+        <h3 class="image-page-title">
+          {{ getPageTitle(item.number, pindex) }}
+        </h3>
+        <div
+          v-for="(url, sindex) in page.sliceUri"
+          :key="sindex"
+          class="image-item"
+        >
+          <img :src="url" :alt="sindex + 1" />
+          <div class="image-action">
+            <a-button class="image-change">
+              <template #icon><PictureFilled /></template>
+            </a-button>
+            <a-button
+              class="image-slice"
+              @click="onEditSlice(item.number, pindex, sindex)"
+            >
+              <template #icon><NumberOutlined /></template>
+            </a-button>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- CutImageDialog -->
+  <CutImageDialog
+    ref="cutImageDialogRef"
+    :sheet-url="sheetUrl"
+    :slice-selection="curSliceSelection"
+  />
+</template>
+
+<script setup lang="ts">
+import { NumberOutlined, PictureFilled } from "@ant-design/icons-vue";
+import CutImageDialog from "./CutImageDialog.vue";
+import { ref } from "vue";
+
+defineOptions({
+  name: "SliceImage",
+});
+
+const props = defineProps<{
+  sheetUrl: string;
+  studentSlice: StudentSliceImgs;
+}>();
+
+const curSliceSelection = ref<AreaSize>();
+
+function getPageTitle(paperNumber, pageIndex) {
+  return `卡${paperNumber}${pageIndex === 0 ? "正面" : "反面"}`;
+}
+
+const cutImageDialogRef = ref();
+function onEditSlice(paperNumber: number, pageIndex: number, index: number) {
+  // TODO:get slice selection
+  curSliceSelection.value = undefined;
+
+  cutImageDialogRef.value?.open();
+}
+</script>

+ 16 - 0
src/render/styles/antui-reset.less

@@ -42,6 +42,22 @@
   }
 }
 
+.full-modal {
+  .ant-modal {
+    max-width: 100%;
+    top: 0;
+    padding-bottom: 0;
+    margin: 0;
+  }
+  .ant-modal-content {
+    display: flex;
+    flex-direction: column;
+    height: calc(100vh);
+  }
+  .ant-modal-body {
+    flex: 1;
+  }
+}
 // ant-collapse
 .ant-collapse {
   border-radius: 0;

+ 70 - 0
src/render/styles/pages.less

@@ -925,3 +925,73 @@
     }
   }
 }
+
+.cut-image-dialog {
+  .ant-modal-content {
+    border-radius: 0;
+    background-color: transparent;
+  }
+  .ant-modal-body {
+    overflow: hidden;
+    padding: 0;
+  }
+  .cut-image {
+    position: relative;
+    height: 100%;
+
+    &-body {
+      position: absolute;
+      top: 0;
+      left: 0;
+      z-index: 8;
+      > img {
+        display: block;
+        width: 100%;
+        height: 100%;
+      }
+    }
+
+    .image-selection {
+      position: absolute;
+    }
+
+    .cut-close {
+      position: absolute;
+      width: 48px;
+      height: 48px;
+      top: 20px;
+      right: 20px;
+      z-index: 9;
+      background: rgba(89, 89, 89, 0.8);
+      border-radius: 24px;
+      text-align: center;
+      line-height: 48px;
+      font-size: 20px;
+      color: #fff;
+      cursor: pointer;
+      &:hover {
+        opacity: 0.8;
+      }
+    }
+
+    .cut-save {
+      position: absolute;
+      width: 48px;
+      height: 48px;
+      bottom: 20px;
+      right: 20px;
+      z-index: 9;
+      background: linear-gradient(180deg, #3196ff 0%, #165dff 100%);
+      border-radius: 24px;
+      border: 1px solid #4080ff;
+      text-align: center;
+      line-height: 46px;
+      font-size: 20px;
+      color: #fff;
+      cursor: pointer;
+      &:hover {
+        opacity: 0.8;
+      }
+    }
+  }
+}

+ 17 - 0
types/app.d.ts

@@ -11,3 +11,20 @@ interface PageBaseParams {
   pageNumber: number;
   pageSize: number;
 }
+// SliceImage
+interface StudentSliceImgs {
+  id: number;
+  papers: Array<{
+    number: number;
+    pages: Array<{
+      sliceUri: string[];
+    }>;
+  }>;
+}
+
+interface AreaSize {
+  w: number;
+  h: number;
+  y: number;
+  x: number;
+}