Răsfoiți Sursa

feat: 选择图片区域

zhangjie 3 zile în urmă
părinte
comite
8d114267aa

+ 2 - 0
.eslintrc.js

@@ -53,6 +53,8 @@ module.exports = {
     // '@typescript-eslint/no-unused-vars': 0,
     '@typescript-eslint/no-empty-function': 0,
     '@typescript-eslint/no-explicit-any': 0,
+    '@typescript-eslint/no-non-null-assertion': 0,
+    'no-await-in-loop': 0,
     'import/extensions': [
       2,
       'ignorePackages',

+ 2 - 0
components.d.ts

@@ -10,6 +10,7 @@ export {};
 declare module '@vue/runtime-core' {
   export interface GlobalComponents {
     Chart: typeof import('./src/components/chart/index.vue')['default'];
+    Cropper: typeof import('./src/components/select-img-area/Cropper.vue')['default'];
     ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb'];
     ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem'];
     ElButton: typeof import('element-plus/es')['ElButton'];
@@ -61,6 +62,7 @@ declare module '@vue/runtime-core' {
     RouterView: typeof import('vue-router')['RouterView'];
     SelectCourse: typeof import('./src/components/select-course/index.vue')['default'];
     SelectExam: typeof import('./src/components/select-exam/index.vue')['default'];
+    SelectImgArea: typeof import('./src/components/select-img-area/index.vue')['default'];
     SelectRangeDatetime: typeof import('./src/components/select-range-datetime/index.vue')['default'];
     SelectRangeTime: typeof import('./src/components/select-range-time/index.vue')['default'];
     SelectSubject: typeof import('./src/components/select-subject/index.vue')['default'];

+ 1 - 7
pnpm-lock.yaml

@@ -2646,12 +2646,6 @@ packages:
     resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
     dev: true
 
-  /end-of-stream/1.4.4:
-    resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
-    dependencies:
-      once: 1.4.0
-    dev: true
-
   /end-of-stream/1.4.5:
     resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
     dependencies:
@@ -5707,7 +5701,7 @@ packages:
   /pump/3.0.2:
     resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==}
     dependencies:
-      end-of-stream: 1.4.4
+      end-of-stream: 1.4.5
       once: 1.4.0
     dev: true
 

+ 12 - 0
src/assets/style/home.scss

@@ -72,6 +72,10 @@
       padding-left: 16px;
       border-radius: 6px;
 
+      &:hover {
+        background-color: var(--el-color-primary-light-7);
+      }
+
       &.is-active {
         background: var(--color-primary);
         box-shadow: 0px 4px 8px #7fc3ff;
@@ -87,6 +91,14 @@
         }
       }
 
+      .el-sub-menu__title {
+        border-radius: 6px;
+
+        &:hover {
+          background-color: var(--el-color-primary-light-7);
+        }
+      }
+
       .el-menu-item {
         padding-left: 44px !important;
       }

+ 526 - 0
src/components/select-img-area/Cropper.vue

@@ -0,0 +1,526 @@
+<template>
+  <div ref="containerRef" class="cropper-container">
+    <img
+      ref="imageRef"
+      :src="props.src"
+      alt="cropper image"
+      @load="handleImageLoad"
+      @mousedown="handleImageMouseDown"
+    />
+
+    <!-- 选择框 -->
+    <div
+      v-if="selection.visible"
+      class="selection-box"
+      :style="selectionStyle"
+      @mousedown="handleSelectionMouseDown"
+      @dblclick="handleDoubleClick"
+    >
+      <!-- 八个控制点 -->
+      <div
+        v-for="(handle, index) in handles"
+        :key="index"
+        :class="['resize-handle', handle.position]"
+        @mousedown="handleResizeMouseDown($event, handle.position)"
+      ></div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, computed, onMounted, onUnmounted } from 'vue';
+
+  interface SelectionData {
+    x: number;
+    y: number;
+    width: number;
+    height: number;
+    visible: boolean;
+  }
+
+  interface HandlePosition {
+    position: string;
+  }
+
+  defineOptions({
+    name: 'Cropper',
+  });
+
+  const props = withDefaults(
+    defineProps<{
+      src: string;
+    }>(),
+    {
+      src: '',
+    }
+  );
+
+  const emit = defineEmits<{
+    crop: [data: { x: number; y: number; width: number; height: number }];
+  }>();
+
+  const containerRef = ref<HTMLDivElement>();
+  const imageRef = ref<HTMLImageElement>();
+
+  // 选择框数据
+  const selection = ref<SelectionData>({
+    x: 0,
+    y: 0,
+    width: 0,
+    height: 0,
+    visible: false,
+  });
+
+  // 图片尺寸和位置
+  const imageRect = ref({
+    left: 0,
+    top: 0,
+    width: 0,
+    height: 0,
+  });
+
+  // 拖拽状态
+  const dragState = ref({
+    isDragging: false,
+    isResizing: false,
+    resizeDirection: '',
+    startX: 0,
+    startY: 0,
+    startSelection: { x: 0, y: 0, width: 0, height: 0 },
+  });
+
+  // 控制点配置
+  const handles: HandlePosition[] = [
+    { position: 'nw' }, // 左上
+    { position: 'n' }, // 上
+    { position: 'ne' }, // 右上
+    { position: 'e' }, // 右
+    { position: 'se' }, // 右下
+    { position: 's' }, // 下
+    { position: 'sw' }, // 左下
+    { position: 'w' }, // 左
+  ];
+
+  // 选择框样式
+  const selectionStyle = computed(() => ({
+    left: `${selection.value.x}px`,
+    top: `${selection.value.y}px`,
+    width: `${selection.value.width}px`,
+    height: `${selection.value.height}px`,
+  }));
+
+  // 图片加载完成
+  function handleImageLoad() {
+    updateImageRect();
+  }
+
+  // 更新图片位置和尺寸
+  function updateImageRect() {
+    if (!imageRef.value || !containerRef.value) return;
+
+    const containerRect = containerRef.value.getBoundingClientRect();
+    const imgRect = imageRef.value.getBoundingClientRect();
+
+    imageRect.value = {
+      left: imgRect.left - containerRect.left,
+      top: imgRect.top - containerRect.top,
+      width: imgRect.width,
+      height: imgRect.height,
+    };
+  }
+
+  // 图片鼠标按下事件
+  function handleImageMouseDown(event: MouseEvent) {
+    if (dragState.value.isDragging || dragState.value.isResizing) return;
+
+    event.preventDefault();
+
+    const rect = containerRef.value!.getBoundingClientRect();
+    const x = event.clientX - rect.left;
+    const y = event.clientY - rect.top;
+
+    // 开始新的选择
+    selection.value = {
+      x,
+      y,
+      width: 0,
+      height: 0,
+      visible: true,
+    };
+
+    dragState.value = {
+      isDragging: true,
+      isResizing: false,
+      resizeDirection: '',
+      startX: x,
+      startY: y,
+      startSelection: { ...selection.value },
+    };
+
+    document.addEventListener('mousemove', handleMouseMove);
+    document.addEventListener('mouseup', handleMouseUp);
+  }
+
+  // 选择框鼠标按下事件(移动)
+  function handleSelectionMouseDown(event: MouseEvent) {
+    if (event.target !== event.currentTarget) return; // 避免控制点事件冒泡
+
+    event.preventDefault();
+    event.stopPropagation();
+
+    const rect = containerRef.value!.getBoundingClientRect();
+    const x = event.clientX - rect.left;
+    const y = event.clientY - rect.top;
+
+    dragState.value = {
+      isDragging: true,
+      isResizing: false,
+      resizeDirection: '',
+      startX: x,
+      startY: y,
+      startSelection: { ...selection.value },
+    };
+
+    document.addEventListener('mousemove', handleMouseMove);
+    document.addEventListener('mouseup', handleMouseUp);
+  }
+
+  // 控制点鼠标按下事件(调整大小)
+  function handleResizeMouseDown(event: MouseEvent, direction: string) {
+    event.preventDefault();
+    event.stopPropagation();
+
+    const rect = containerRef.value!.getBoundingClientRect();
+    const x = event.clientX - rect.left;
+    const y = event.clientY - rect.top;
+
+    dragState.value = {
+      isDragging: false,
+      isResizing: true,
+      resizeDirection: direction,
+      startX: x,
+      startY: y,
+      startSelection: { ...selection.value },
+    };
+
+    document.addEventListener('mousemove', handleMouseMove);
+    document.addEventListener('mouseup', handleMouseUp);
+  }
+
+  // 鼠标移动事件
+  function handleMouseMove(event: MouseEvent) {
+    if (!dragState.value.isDragging && !dragState.value.isResizing) return;
+
+    const rect = containerRef.value!.getBoundingClientRect();
+    const currentX = event.clientX - rect.left;
+    const currentY = event.clientY - rect.top;
+
+    if (dragState.value.isDragging && !dragState.value.isResizing) {
+      // 创建新选择或移动选择框
+      if (dragState.value.startSelection.width === 0) {
+        // 创建新选择
+        const width = Math.abs(currentX - dragState.value.startX);
+        const height = Math.abs(currentY - dragState.value.startY);
+        const x = Math.min(currentX, dragState.value.startX);
+        const y = Math.min(currentY, dragState.value.startY);
+
+        selection.value = {
+          x: Math.max(
+            imageRect.value.left,
+            Math.min(x, imageRect.value.left + imageRect.value.width - width)
+          ),
+          y: Math.max(
+            imageRect.value.top,
+            Math.min(y, imageRect.value.top + imageRect.value.height - height)
+          ),
+          width: Math.min(width, imageRect.value.width),
+          height: Math.min(height, imageRect.value.height),
+          visible: true,
+        };
+      } else {
+        // 移动选择框
+        const deltaX = currentX - dragState.value.startX;
+        const deltaY = currentY - dragState.value.startY;
+
+        let newX = dragState.value.startSelection.x + deltaX;
+        let newY = dragState.value.startSelection.y + deltaY;
+
+        // 边界限制
+        newX = Math.max(
+          imageRect.value.left,
+          Math.min(
+            newX,
+            imageRect.value.left + imageRect.value.width - selection.value.width
+          )
+        );
+        newY = Math.max(
+          imageRect.value.top,
+          Math.min(
+            newY,
+            imageRect.value.top +
+              imageRect.value.height -
+              selection.value.height
+          )
+        );
+
+        selection.value.x = newX;
+        selection.value.y = newY;
+      }
+    } else if (dragState.value.isResizing) {
+      // 调整选择框大小
+      resizeSelection(currentX, currentY);
+    }
+  }
+
+  // 调整选择框大小
+  function resizeSelection(currentX: number, currentY: number) {
+    const { startSelection, resizeDirection, startX, startY } = dragState.value;
+    const deltaX = currentX - startX;
+    const deltaY = currentY - startY;
+
+    let newX = startSelection.x;
+    let newY = startSelection.y;
+    let newWidth = startSelection.width;
+    let newHeight = startSelection.height;
+
+    // 根据拖拽方向调整尺寸
+    if (resizeDirection.includes('n')) {
+      newY = startSelection.y + deltaY;
+      newHeight = startSelection.height - deltaY;
+    }
+    if (resizeDirection.includes('s')) {
+      newHeight = startSelection.height + deltaY;
+    }
+    if (resizeDirection.includes('w')) {
+      newX = startSelection.x + deltaX;
+      newWidth = startSelection.width - deltaX;
+    }
+    if (resizeDirection.includes('e')) {
+      newWidth = startSelection.width + deltaX;
+    }
+
+    // 最小尺寸限制
+    const minSize = 10;
+    if (newWidth < minSize) {
+      if (resizeDirection.includes('w')) {
+        newX = startSelection.x + startSelection.width - minSize;
+      }
+      newWidth = minSize;
+    }
+    if (newHeight < minSize) {
+      if (resizeDirection.includes('n')) {
+        newY = startSelection.y + startSelection.height - minSize;
+      }
+      newHeight = minSize;
+    }
+
+    // 边界限制
+    newX = Math.max(
+      imageRect.value.left,
+      Math.min(newX, imageRect.value.left + imageRect.value.width - newWidth)
+    );
+    newY = Math.max(
+      imageRect.value.top,
+      Math.min(newY, imageRect.value.top + imageRect.value.height - newHeight)
+    );
+    newWidth = Math.min(
+      newWidth,
+      imageRect.value.left + imageRect.value.width - newX
+    );
+    newHeight = Math.min(
+      newHeight,
+      imageRect.value.top + imageRect.value.height - newY
+    );
+
+    selection.value.x = newX;
+    selection.value.y = newY;
+    selection.value.width = newWidth;
+    selection.value.height = newHeight;
+  }
+
+  // 鼠标抬起事件
+  function handleMouseUp() {
+    dragState.value.isDragging = false;
+    dragState.value.isResizing = false;
+
+    document.removeEventListener('mousemove', handleMouseMove);
+    document.removeEventListener('mouseup', handleMouseUp);
+  }
+
+  // 双击事件
+  function handleDoubleClick() {
+    if (!selection.value.visible || !imageRef.value) return;
+
+    // 计算相对于显示图片的坐标
+    const relativeX = selection.value.x - imageRect.value.left;
+    const relativeY = selection.value.y - imageRect.value.top;
+
+    // 获取图片的原始尺寸和显示尺寸
+    const originalWidth = imageRef.value.naturalWidth;
+    const originalHeight = imageRef.value.naturalHeight;
+    const displayWidth = imageRect.value.width;
+    const displayHeight = imageRect.value.height;
+
+    // 计算缩放比例
+    const scaleX = originalWidth / displayWidth;
+    const scaleY = originalHeight / displayHeight;
+
+    // 转换为原始图片尺寸的坐标
+    const originalX = Math.round(relativeX * scaleX);
+    const originalY = Math.round(relativeY * scaleY);
+    const originalSelectionWidth = Math.round(selection.value.width * scaleX);
+    const originalSelectionHeight = Math.round(selection.value.height * scaleY);
+
+    // 计算相对于原图尺寸的相对值(0-1之间),保留4位小数
+    const relativeXRatio = parseFloat((originalX / originalWidth).toFixed(4));
+    const relativeYRatio = parseFloat((originalY / originalHeight).toFixed(4));
+    const relativeWidthRatio = parseFloat(
+      (originalSelectionWidth / originalWidth).toFixed(4)
+    );
+    const relativeHeightRatio = parseFloat(
+      (originalSelectionHeight / originalHeight).toFixed(4)
+    );
+
+    // 发送裁剪数据(相对于原图尺寸的相对值)
+    emit('crop', {
+      x: relativeXRatio,
+      y: relativeYRatio,
+      width: relativeWidthRatio,
+      height: relativeHeightRatio,
+    });
+
+    // 清空选择
+    clearSelection();
+  }
+
+  // 清空选择
+  function clearSelection() {
+    selection.value.visible = false;
+    selection.value.x = 0;
+    selection.value.y = 0;
+    selection.value.width = 0;
+    selection.value.height = 0;
+  }
+
+  // 暴露方法
+  defineExpose({
+    clearSelection,
+  });
+
+  // 监听窗口大小变化
+  function handleResize() {
+    updateImageRect();
+  }
+
+  onMounted(() => {
+    window.addEventListener('resize', handleResize);
+  });
+
+  onUnmounted(() => {
+    window.removeEventListener('resize', handleResize);
+    document.removeEventListener('mousemove', handleMouseMove);
+    document.removeEventListener('mouseup', handleMouseUp);
+  });
+</script>
+
+<style lang="scss" scoped>
+  .cropper-container {
+    position: relative;
+    display: inline-block;
+    user-select: none;
+
+    img {
+      display: block;
+      max-width: 100%;
+      max-height: 100%;
+      cursor: crosshair;
+    }
+  }
+
+  .selection-box {
+    position: absolute;
+    border: 2px dashed #409eff;
+    background: rgba(64, 158, 255, 0.2);
+    cursor: move;
+    box-sizing: border-box;
+
+    &:hover {
+      border-color: #66b1ff;
+      background: rgba(102, 177, 255, 0.3);
+    }
+  }
+
+  .resize-handle {
+    position: absolute;
+    background: #409eff;
+    border: 1px solid #fff;
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+
+    &:hover {
+      background: #66b1ff;
+      transform: scale(1.2);
+    }
+
+    // 左上角
+    &.nw {
+      top: -4px;
+      left: -4px;
+      cursor: nw-resize;
+    }
+
+    // 上边中点
+    &.n {
+      top: -4px;
+      left: 50%;
+      transform: translateX(-50%);
+      cursor: n-resize;
+    }
+
+    // 右上角
+    &.ne {
+      top: -4px;
+      right: -4px;
+      cursor: ne-resize;
+    }
+
+    // 右边中点
+    &.e {
+      top: 50%;
+      right: -4px;
+      transform: translateY(-50%);
+      cursor: e-resize;
+    }
+
+    // 右下角
+    &.se {
+      bottom: -4px;
+      right: -4px;
+      cursor: se-resize;
+    }
+
+    // 下边中点
+    &.s {
+      bottom: -4px;
+      left: 50%;
+      transform: translateX(-50%);
+      cursor: s-resize;
+    }
+
+    // 左下角
+    &.sw {
+      bottom: -4px;
+      left: -4px;
+      cursor: sw-resize;
+    }
+
+    // 左边中点
+    &.w {
+      top: 50%;
+      left: -4px;
+      transform: translateY(-50%);
+      cursor: w-resize;
+    }
+  }
+</style>

+ 444 - 0
src/components/select-img-area/index.vue

@@ -0,0 +1,444 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    title="图片区域选择"
+    fullscreen
+    :close-on-click-modal="false"
+    :close-on-press-escape="false"
+    append-to-body
+    class="select-img-area-dialog"
+    @open="handleOpen"
+    @close="handleClose"
+  >
+    <div class="select-img-area-container">
+      <!-- 左侧图片展示区域 -->
+      <div class="left-panel">
+        <div class="image-head">
+          <!-- 图片菜单 -->
+          <div class="image-menu">
+            <el-button
+              v-for="(img, index) in imgList"
+              :key="index"
+              :type="currentImageIndex === index ? 'primary' : 'default'"
+              @click="switchImage(index)"
+            >
+              {{ index + 1 }}
+            </el-button>
+          </div>
+          <div>
+            <el-button @click="onSelectFullArea">选择整图</el-button>
+          </div>
+        </div>
+
+        <!-- 图片展示区域 -->
+        <div class="image-container">
+          <Cropper :src="currentImage" @crop="handleCrop" />
+        </div>
+      </div>
+
+      <!-- 右侧预览区域 -->
+      <div class="right-panel">
+        <div class="preview-title">框选区域预览</div>
+
+        <!-- 预览区域 -->
+        <div class="preview-container">
+          <div
+            v-for="(area, index) in coverAreas"
+            :key="index"
+            class="preview-item"
+          >
+            <div class="preview-image-wrapper">
+              <img :src="area.splitUrl" :alt="index" />
+              <el-button
+                type="danger"
+                size="small"
+                circle
+                class="delete-btn"
+                @click="deleteCoverArea(index)"
+              >
+                <el-icon><Delete /></el-icon>
+              </el-button>
+            </div>
+            <!-- <div class="area-info">
+              区域 {{ area.i + 1 }}: ({{ area.x }}, {{ area.y }})
+              {{ area.w }}×{{ area.h }}
+            </div> -->
+          </div>
+        </div>
+
+        <!-- 底部按钮 -->
+        <div class="bottom-buttons">
+          <el-button type="danger" @click="clearAll">清除</el-button>
+          <el-button type="primary" @click="save">保存</el-button>
+          <el-button @click="close">退出</el-button>
+        </div>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+  import { ref, computed } from 'vue';
+  import { Delete } from '@element-plus/icons-vue';
+  import { ElMessage } from 'element-plus';
+  import type { CoverArea } from '@/api/types/common';
+  import Cropper from './Cropper.vue';
+
+  defineOptions({
+    name: 'SelectImgArea',
+  });
+
+  const props = withDefaults(
+    defineProps<{
+      imgList: string[];
+      modelValue: CoverArea[];
+    }>(),
+    {
+      imgList: () => [],
+      modelValue: () => [],
+    }
+  );
+
+  interface CoverAreaItem extends CoverArea {
+    splitUrl: string;
+  }
+
+  const emit = defineEmits(['update:modelValue', 'save', 'close']);
+
+  // 弹窗控制
+  const visible = ref(false);
+
+  // 图片相关
+  const currentImageIndex = ref(0);
+  const coverAreas = ref<CoverAreaItem[]>([]);
+
+  // 计算当前图片
+  const currentImage = computed(() => {
+    return props.imgList[currentImageIndex.value] || '';
+  });
+
+  // 处理裁剪事件
+  async function handleCrop(data: {
+    x: number;
+    y: number;
+    width: number;
+    height: number;
+  }) {
+    const { x, y, width, height } = data;
+    const newArea: CoverAreaItem = {
+      i: currentImageIndex.value,
+      x,
+      y,
+      w: width,
+      h: height,
+      splitUrl: '',
+    };
+    newArea.splitUrl = await generatePreview(newArea);
+
+    // 添加新的框选区域
+    coverAreas.value.push(newArea);
+  }
+
+  // 生成预览图
+  async function generatePreview(area: CoverAreaItem) {
+    try {
+      const canvas = document.createElement('canvas');
+      const ctx = canvas.getContext('2d');
+      const img = new Image();
+
+      // 设置 crossOrigin 属性以避免 CORS 污染
+      img.crossOrigin = 'anonymous';
+      img.src = props.imgList[area.i];
+
+      await new Promise((resolve, reject) => {
+        img.onload = resolve;
+        img.onerror = reject;
+      });
+
+      canvas.width = area.w * img.width;
+      canvas.height = area.h * img.height;
+
+      // 使用drawImage的9参数版本直接绘制指定区域
+      // drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
+      ctx?.drawImage(
+        img,
+        area.x * img.width,
+        area.y * img.height,
+        area.w * img.width,
+        area.h * img.height,
+        0,
+        0,
+        area.w * img.width,
+        area.h * img.height
+      );
+
+      const blob = await new Promise<Blob | null>((resolve) => {
+        canvas.toBlob(resolve, 'image/png');
+      });
+
+      if (!blob) {
+        throw new Error('Failed to create blob from canvas');
+      }
+
+      return URL.createObjectURL(blob);
+    } catch (error) {
+      console.warn(
+        'Failed to generate preview with canvas, falling back to original image:',
+        error
+      );
+      // 如果 canvas 导出失败,返回原图片 URL 作为备选方案
+      return '';
+    }
+  }
+
+  const onSelectFullArea = async () => {
+    const hasFullArea = coverAreas.value.some(
+      (area) =>
+        area.i === currentImageIndex.value &&
+        area.x === 0 &&
+        area.y === 0 &&
+        area.w === 1 &&
+        area.h === 1
+    );
+    if (hasFullArea) {
+      ElMessage({
+        message: '已添加全图框选区域',
+        type: 'warning',
+      });
+      return;
+    }
+    // 添加新的框选区域
+    coverAreas.value.push({
+      i: currentImageIndex.value,
+      x: 0,
+      y: 0,
+      w: 1,
+      h: 1,
+      splitUrl: currentImage.value,
+    });
+  };
+
+  // 切换图片
+  const switchImage = async (index: number) => {
+    currentImageIndex.value = index;
+  };
+
+  // 删除框选区域
+  const deleteCoverArea = (index: number) => {
+    coverAreas.value.splice(index, 1);
+    // 重新分配索引
+    coverAreas.value.forEach((area, i) => {
+      area.i = i;
+    });
+  };
+
+  // 清除所有框选区域
+  const clearAll = () => {
+    coverAreas.value = [];
+  };
+
+  // 关闭弹窗
+  const close = () => {
+    visible.value = false;
+    emit('close');
+  };
+
+  // 保存
+  const save = () => {
+    const areas = coverAreas.value.map((area) => {
+      if (area.x === 0 && area.y === 0 && area.w === 1 && area.h === 1) {
+        return {
+          i: area.i,
+        };
+      }
+      return {
+        i: area.i,
+        x: area.x,
+        y: area.y,
+        w: area.w,
+        h: area.h,
+      };
+    });
+    emit('update:modelValue', areas);
+    emit('save', areas);
+    close();
+  };
+
+  // 打开弹窗
+  const open = () => {
+    visible.value = true;
+  };
+
+  // 弹窗打开事件
+  const handleOpen = async () => {
+    const areas = props.modelValue || [];
+    for (const area of areas) {
+      if (Object.prototype.hasOwnProperty.call(area, 'x')) {
+        const splitUrl = await generatePreview(area);
+        coverAreas.value.push({
+          ...area,
+          splitUrl,
+        });
+      } else {
+        coverAreas.value.push({
+          ...area,
+          x: 0,
+          y: 0,
+          w: 1,
+          h: 1,
+          splitUrl: props.imgList[area.i],
+        });
+      }
+    }
+    currentImageIndex.value = 0;
+  };
+
+  // 弹窗关闭事件
+  const handleClose = () => {
+    coverAreas.value = [];
+  };
+
+  // 暴露方法
+  defineExpose({
+    open,
+    close,
+  });
+</script>
+
+<style lang="scss">
+  .select-img-area-dialog {
+    .el-dialog__header {
+      display: none;
+    }
+    .el-dialog__body {
+      padding: 0;
+    }
+  }
+</style>
+
+<style lang="scss" scoped>
+  .select-img-area-container {
+    display: flex;
+    height: 100vh;
+    gap: 20px;
+    padding: 15px 20px;
+  }
+
+  .left-panel {
+    flex: 2;
+    display: flex;
+    flex-direction: column;
+    .image-head {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      margin-bottom: 10px;
+    }
+
+    .image-menu {
+      flex: 2;
+      display: flex;
+      flex-wrap: wrap;
+    }
+
+    .image-container {
+      flex: 1;
+      border: 1px solid #dcdfe6;
+      border-radius: 4px;
+      overflow: hidden;
+      background: #f5f7fa;
+      min-height: 400px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+
+      img {
+        max-width: 100%;
+        max-height: 100%;
+        width: auto;
+        height: auto;
+        display: block;
+      }
+
+      :deep(cropper-canvas) {
+        height: 100%;
+        width: 100%;
+      }
+    }
+  }
+
+  .right-panel {
+    width: 400px;
+    flex-grow: 0;
+    flex-shrink: 0;
+    display: flex;
+    flex-direction: column;
+    border: 1px solid #dcdfe6;
+    border-radius: 4px;
+    padding: 16px;
+
+    .preview-title {
+      font-size: 16px;
+      font-weight: 500;
+      margin-bottom: 16px;
+      color: #303133;
+    }
+
+    .preview-container {
+      flex: 1;
+      overflow-y: auto;
+
+      .preview-item {
+        margin-bottom: 16px;
+
+        .preview-image-wrapper {
+          position: relative;
+          display: inline-block;
+          border: 1px solid #dcdfe6;
+          border-radius: 4px;
+          overflow: hidden;
+
+          img {
+            display: block;
+            width: 100%;
+            height: auto;
+          }
+
+          .delete-btn {
+            position: absolute;
+            top: 4px;
+            right: 4px;
+            width: 24px;
+            height: 24px;
+            line-height: 24px;
+            text-align: center;
+
+            .el-icon {
+              margin: 0;
+            }
+          }
+        }
+
+        .area-info {
+          margin-top: 8px;
+          font-size: 12px;
+          color: #606266;
+        }
+      }
+    }
+
+    .bottom-buttons {
+      display: flex;
+      gap: 12px;
+      justify-content: flex-end;
+      margin-top: 16px;
+      padding-top: 16px;
+      border-top: 1px solid #ebeef5;
+
+      .el-button {
+        margin: 0;
+        flex: 2;
+      }
+    }
+  }
+</style>

+ 1 - 0
src/main.ts

@@ -6,6 +6,7 @@ import store from './store';
 import App from './App.vue';
 import '@/assets/style/element.scss';
 import '@/assets/style/index.scss';
+
 import '@/api/interceptor';
 
 const app = createApp(App);

+ 43 - 12
src/views/system/comp-test/comp-test.vue

@@ -89,12 +89,12 @@
 
     <section class="component-section">
       <h2 class="section-title">SelectTask Component</h2>
-      <SelectExam
+      <!-- <SelectExam
         v-model="selectedTask"
         placeholder="Select a task"
         clearable
         @change="handleTaskChange"
-      />
+      /> -->
       <p class="section-description"
         >Selected Task ID: {{ selectedTask }}. (Note: Options are mocked or need
         API integration)</p
@@ -116,6 +116,18 @@
       >
     </section>
 
+    <section class="component-section">
+      <h2 class="section-title">SelectImgArea Component</h2>
+      <el-button @click="openSelector">选择图片区域</el-button>
+
+      <SelectImgArea
+        ref="imgAreaRef"
+        v-model="selectedAreas"
+        :img-list="imgList"
+        @close="handleImgAreaClose"
+      />
+    </section>
+
     <section class="component-section">
       <h2 class="section-title">Footer Component</h2>
       <Footer />
@@ -135,9 +147,12 @@
   // SvgIcon is globally registered
   // SelectRangeDatetime is globally registered
   // SelectRangeTime is globally registered
-  import SelectExam from '@/components/select-exam/index.vue';
+  // import SelectExam from '@/components/select-exam/index.vue';
   import SelectTeaching from '@/components/select-teaching/index.vue';
+  import SelectImgArea from '@/components/select-img-area/index.vue';
+
   import Footer from '@/components/footer/index.vue';
+  import { CoverArea } from '@/api/types/common';
 
   defineOptions({
     name: 'CompTest',
@@ -193,15 +208,15 @@
   };
 
   // For SelectTask
-  const selectedTask = ref<number | null>(null);
-  const handleTaskChange = (task: any) => {
-    console.log('Task changed:', task);
-    if (task) {
-      ElMessage.info(`Task selected: ${task.label} (ID: ${task.value})`);
-    } else {
-      ElMessage.info('Task cleared');
-    }
-  };
+  // const selectedTask = ref<number | null>(null);
+  // const handleTaskChange = (task: any) => {
+  //   console.log('Task changed:', task);
+  //   if (task) {
+  //     ElMessage.info(`Task selected: ${task.label} (ID: ${task.value})`);
+  //   } else {
+  //     ElMessage.info('Task cleared');
+  //   }
+  // };
 
   // For SelectTeaching
   const selectedTeaching = ref<number | null>(null);
@@ -215,6 +230,22 @@
       ElMessage.info('Teaching point cleared');
     }
   };
+
+  // For SelectImgArea
+  const imgAreaRef = ref();
+  const selectedAreas = ref<CoverArea[]>([]);
+  const imgList = ref<string[]>([
+    'http://localhost:8150/static/001.jpeg',
+    'http://localhost:8150/static/002.jpeg',
+  ]);
+
+  const openSelector = () => {
+    imgAreaRef.value.open();
+  };
+
+  const handleImgAreaClose = () => {
+    console.log('ImgArea closed:', selectedAreas.value);
+  };
 </script>
 
 <style scoped>

BIN
static/001.jpeg


BIN
static/002.jpeg