瀏覽代碼

feat: 添加裁剪组件

snihwxf 3 月之前
父節點
當前提交
daf28c61dd

+ 8 - 1
apps/web-baicai/src/api/system/file.ts

@@ -14,8 +14,9 @@ export namespace FileApi {
     path: string;
     directory: string;
     allowSuffix: string;
-    folderId: string;
+    folderId: number;
     file?: any;
+    [key: string]: any;
   }
 
   export interface RecordItem extends BasicRecordItem {
@@ -46,4 +47,10 @@ export namespace FileApi {
 
   export const deleteDetail = (id: number) =>
     requestClient.delete('/file', { data: { id } });
+
+  export const uploadBase64 = (data: BasicRecordItem) =>
+    requestClient.post('/file/upload-base64', data);
+
+  export const getBase64Url = (url: string) =>
+    requestClient.get<string>('/file/base64-url', { params: { code: url } });
 }

+ 63 - 0
apps/web-baicai/src/components/form/components/bc-cropper.vue

@@ -0,0 +1,63 @@
+<script setup lang="ts">
+import type { PropType } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { useVModel } from '@vueuse/core';
+import { Input } from 'ant-design-vue';
+
+import { Icon } from '#/components/icon';
+import { ImageCropper } from '#/components/image-cropper';
+
+const props = defineProps({
+  value: {
+    type: [String, Number] as PropType<number | string>,
+    default: '',
+  },
+  placeholder: {
+    type: String as PropType<string>,
+    default: '请输入',
+  },
+  valueField: {
+    type: String as PropType<string>,
+    default: 'url',
+  },
+});
+const emit = defineEmits(['update:value']);
+const modelValue = useVModel(props, 'value', emit, {
+  defaultValue: props.value,
+  passive: true,
+});
+
+const [ImageCropperModal, imageCropperApi] = useVbenModal({
+  connectedComponent: ImageCropper,
+});
+
+const handleSuccess = (data: any) => {
+  const { valueField } = props;
+  if (modelValue.value !== data[valueField]) {
+    emit('update:value', data[valueField]);
+  }
+};
+
+const handleInput = () => {
+  imageCropperApi
+    .setData({
+      baseData: {
+        url: modelValue.value,
+      },
+    })
+    .open();
+};
+</script>
+
+<template>
+  <div class="w-full">
+    <ImageCropperModal @success="handleSuccess" />
+    <Input v-model:value="modelValue" class="w-full" :placeholder="placeholder">
+      <template #addonAfter>
+        <Icon icon="tabler:crop" @click="handleInput" />
+      </template>
+    </Input>
+  </div>
+</template>

+ 1 - 0
apps/web-baicai/src/components/image-cropper/index.ts

@@ -0,0 +1 @@
+export { default as ImageCropper } from './src/image-cropper.vue';

+ 1181 - 0
apps/web-baicai/src/components/image-cropper/src/image-cropper.vue

@@ -0,0 +1,1181 @@
+<script setup lang="ts">
+import type { Recordable } from '@vben/types';
+
+import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+
+import {
+  useVbenModal,
+  VbenButton,
+  VbenButtonGroup,
+  VbenIconButton,
+} from '@vben/common-ui';
+
+import { Image, Input } from 'ant-design-vue';
+
+import { FileApi } from '#/api';
+import { Icon } from '#/components/icon';
+
+interface Position {
+  x: number;
+  y: number;
+}
+
+interface Size {
+  width: number;
+  height: number;
+}
+
+interface FramePosition {
+  left: number;
+  top: number;
+}
+
+interface AspectRatio {
+  value: number;
+  label: string;
+}
+
+type ResizeHandlePosition =
+  | 'bottom-left'
+  | 'bottom-right'
+  | 'top-left'
+  | 'top-right';
+type MoveDirection = 'down' | 'left' | 'right' | 'up';
+
+const props = defineProps({
+  imageSrc: {
+    type: String,
+    default: '',
+  },
+  backgroundColor: {
+    type: String,
+    default: 'transparent',
+  },
+  minWidth: {
+    type: Number,
+    default: 50,
+  },
+  minHeight: {
+    type: Number,
+    default: 50,
+  },
+  zoomStep: {
+    type: Number,
+    default: 0.1,
+  },
+  moveStep: {
+    type: Number,
+    default: 10,
+  },
+  rotateStep: {
+    type: Number,
+    default: 45,
+  },
+  previewQuality: {
+    type: Number,
+    default: 0.92,
+    validator: (value: number) => value >= 0 && value <= 1,
+  },
+  zoomSpeed: {
+    type: Number,
+    default: 0.002,
+  },
+});
+
+const emit = defineEmits(['success']);
+
+const imageElement = ref<HTMLImageElement | null>(null);
+const cropperWrapper = ref<HTMLElement | null>(null);
+const cropperArea = ref<HTMLElement | null>(null);
+const cropperFrame = ref<HTMLElement | null>(null);
+const fileRef = ref<HTMLElement | null>(null);
+
+const scale = ref<number>(1);
+const minScale = ref<number>(0.1);
+const maxScale = ref<number>(3);
+const rotation = ref<number>(0);
+const flipX = ref<number>(1); // 1: 正常, -1: 水平翻转
+const flipY = ref<number>(1); // 1: 正常, -1: 垂直翻转
+const position = ref<Position>({ x: 0, y: 0 });
+// const isDragging = ref<boolean>(false);
+const isResizing = ref<boolean>(false);
+const isFrameDragging = ref<boolean>(false);
+const startPos = ref<Position>({ x: 0, y: 0 });
+const frameSize = ref<Size>({ width: 200, height: 200 });
+const framePos = ref<FramePosition>({ left: 0, top: 0 });
+const resizeHandlePosition = ref<null | ResizeHandlePosition>(null);
+const startFrameSize = ref<Size>({ width: 0, height: 0 });
+const startFramePos = ref<FramePosition>({ left: 0, top: 0 });
+const aspectRatio = ref<number>(0);
+const previewImage = ref<string>('');
+const state = reactive({
+  sourceImage: '',
+  croppedImage: '',
+  fileName: '',
+});
+
+const aspectRatios = computed<AspectRatio[]>(() => {
+  return [
+    { value: 0, label: '自由' },
+    { value: 1, label: '1:1' },
+    { value: 16 / 9, label: '16:9' },
+    { value: 4 / 3, label: '4:3' },
+    { value: 3 / 4, label: '3:4' },
+    { value: 9 / 16, label: '9:16' },
+  ];
+});
+
+const resizeHandles = computed(() => {
+  return [
+    { position: 'top-left' },
+    { position: 'top-right' },
+    { position: 'bottom-left' },
+    { position: 'bottom-right' },
+  ];
+});
+
+const frameStyle = computed(() => {
+  return {
+    width: `${frameSize.value.width}px`,
+    height: `${frameSize.value.height}px`,
+    left: `${framePos.value.left}px`,
+    top: `${framePos.value.top}px`,
+    cursor: isFrameDragging.value ? 'grabbing' : 'move',
+  };
+});
+
+const imageStyle = computed(() => {
+  return {
+    transform: `scale(${scale.value * flipX.value}, ${scale.value * flipY.value}) rotate(${rotation.value}deg)`,
+    left: `${position.value.x}px`,
+    top: `${position.value.y}px`,
+  };
+});
+
+// 监听裁剪框变化,实时更新预览
+watch(
+  [frameSize, framePos, scale, rotation, flipX, flipY],
+  () => {
+    updatePreview();
+  },
+  { deep: true },
+);
+
+const initCropper = (): void => {
+  if (!imageElement.value || !cropperWrapper.value || !cropperFrame.value)
+    return;
+
+  const wrapperWidth = cropperWrapper.value.clientWidth;
+  const wrapperHeight = cropperWrapper.value.clientHeight;
+
+  // 根据宽高比设置初始裁剪框尺寸
+  let frameHeight: number, frameWidth: number;
+  if (aspectRatio.value > 0) {
+    frameWidth = Math.min(wrapperWidth * 0.6, 400);
+    frameHeight = frameWidth / aspectRatio.value;
+  } else {
+    frameWidth = Math.min(wrapperWidth * 0.6, 400);
+    frameHeight = Math.min(wrapperHeight * 0.6, 400);
+  }
+
+  frameSize.value = {
+    width: Math.max(frameWidth, props.minWidth),
+    height: Math.max(frameHeight, props.minHeight),
+  };
+
+  // 居中裁剪框
+  framePos.value = {
+    left: (wrapperWidth - frameSize.value.width) / 2,
+    top: (wrapperHeight - frameSize.value.height) / 2,
+  };
+
+  // 居中图片
+  centerImage();
+  updatePreview();
+};
+
+const centerImage = (): void => {
+  if (!imageElement.value || !cropperWrapper.value) return;
+
+  const imgWidth = imageElement.value.naturalWidth * scale.value;
+  const imgHeight = imageElement.value.naturalHeight * scale.value;
+
+  position.value = {
+    x: (cropperWrapper.value.clientWidth - imgWidth) / 2,
+    y: (cropperWrapper.value.clientHeight - imgHeight) / 2,
+  };
+};
+
+const updatePreview = (): void => {
+  if (!imageElement.value || !cropperFrame.value || !cropperWrapper.value) {
+    previewImage.value = '';
+    return;
+  }
+
+  const canvas = document.createElement('canvas');
+  const ctx = canvas.getContext('2d');
+  if (!ctx) return;
+
+  // 计算裁剪区域相对于图片的位置
+  const frameRect = cropperFrame.value.getBoundingClientRect();
+  // const wrapperRect = cropperWrapper.value.getBoundingClientRect();
+  const imgRect = imageElement.value.getBoundingClientRect();
+
+  // 计算裁剪区域在图片上的位置和尺寸
+  const scaleRatio = imageElement.value.naturalWidth / imgRect.width;
+  const cropX = (frameRect.left - imgRect.left) * scaleRatio;
+  const cropY = (frameRect.top - imgRect.top) * scaleRatio;
+  const cropWidth = frameRect.width * scaleRatio;
+  const cropHeight = frameRect.height * scaleRatio;
+
+  // 设置画布大小为裁剪框大小
+  canvas.width = cropWidth;
+  canvas.height = cropHeight;
+
+  // 设置背景
+  if (props.backgroundColor === 'transparent') {
+    ctx.clearRect(0, 0, canvas.width, canvas.height);
+  } else {
+    ctx.fillStyle = props.backgroundColor;
+    ctx.fillRect(0, 0, canvas.width, canvas.height);
+  }
+
+  // 绘制裁剪后的图像
+  ctx.save();
+  ctx.translate(canvas.width / 2, canvas.height / 2);
+  ctx.rotate((rotation.value * Math.PI) / 180);
+  ctx.scale(flipX.value, flipY.value);
+  ctx.translate(-canvas.width / 2, -canvas.height / 2);
+  ctx.drawImage(
+    imageElement.value,
+    cropX,
+    cropY,
+    cropWidth,
+    cropHeight,
+    0,
+    0,
+    canvas.width,
+    canvas.height,
+  );
+  ctx.restore();
+
+  // 获取预览图像数据
+  previewImage.value = canvas.toDataURL('image/png', props.previewQuality);
+};
+
+const handleWheel = (e: WheelEvent): void => {
+  e.preventDefault();
+
+  // 计算缩放增量,考虑滚轮方向和速度
+  const delta = -e.deltaY * props.zoomSpeed;
+
+  // 应用缩放,限制在最小和最大值之间
+  const newScale = Math.max(
+    minScale.value,
+    Math.min(maxScale.value, scale.value + delta),
+  );
+
+  // 计算鼠标相对于图片的位置
+  if (imageElement.value && cropperArea.value) {
+    // const imgRect = imageElement.value.getBoundingClientRect();
+    const areaRect = cropperArea.value.getBoundingClientRect();
+
+    // 鼠标在图片上的相对位置 (0-1)
+    // const mouseX = (e.clientX - imgRect.left) / imgRect.width;
+    // const mouseY = (e.clientY - imgRect.top) / imgRect.height;
+
+    // 计算图片中心点到鼠标点的向量
+    const centerX = areaRect.width / 2;
+    const centerY = areaRect.height / 2;
+    const offsetX = position.value.x - centerX;
+    const offsetY = position.value.y - centerY;
+
+    // 调整位置,使鼠标点保持在同一位置
+    position.value = {
+      x: centerX + offsetX * (newScale / scale.value),
+      y: centerY + offsetY * (newScale / scale.value),
+    };
+  }
+
+  scale.value = newScale;
+  updateTransform();
+};
+
+const zoomIn = (): void => {
+  scale.value = Math.min(maxScale.value, scale.value + props.zoomStep);
+  centerImage();
+};
+
+const zoomOut = (): void => {
+  scale.value = Math.max(minScale.value, scale.value - props.zoomStep);
+  centerImage();
+};
+
+const resetZoom = (): void => {
+  scale.value = 1;
+  centerImage();
+};
+
+const moveImage = (direction: MoveDirection): void => {
+  if (!imageElement.value || !cropperWrapper.value) return;
+
+  const moveStep = props.moveStep * scale.value;
+
+  switch (direction) {
+    case 'down': {
+      position.value.y += moveStep;
+      break;
+    }
+    case 'left': {
+      position.value.x -= moveStep;
+      break;
+    }
+    case 'right': {
+      position.value.x += moveStep;
+      break;
+    }
+    case 'up': {
+      position.value.y -= moveStep;
+      break;
+    }
+  }
+
+  // updateTransform();
+  updatePreview();
+};
+
+const rotateLeft = (): void => {
+  rotation.value = (rotation.value - props.rotateStep) % 360;
+  updateTransform();
+};
+
+const rotateRight = (): void => {
+  rotation.value = (rotation.value + props.rotateStep) % 360;
+  updateTransform();
+};
+
+const resetRotation = (): void => {
+  rotation.value = 0;
+  updateTransform();
+};
+
+const flipHorizontal = (): void => {
+  flipX.value *= -1;
+  updateTransform();
+};
+
+const flipVertical = (): void => {
+  flipY.value *= -1;
+  updateTransform();
+};
+
+const resetFlip = (): void => {
+  flipX.value = 1;
+  flipY.value = 1;
+  updateTransform();
+};
+
+const updateTransform = (): void => {
+  centerImage();
+};
+
+const startFrameDrag = (e: MouseEvent): void => {
+  if (e.target !== e.currentTarget) return; // 确保点击的是框架本身,而不是调整手柄
+
+  isFrameDragging.value = true;
+  startPos.value = {
+    x: e.clientX - framePos.value.left,
+    y: e.clientY - framePos.value.top,
+  };
+  document.addEventListener('mousemove', dragFrame);
+  document.addEventListener('mouseup', stopFrameDrag);
+};
+
+const dragFrame = (e: MouseEvent): void => {
+  if (!isFrameDragging.value || !cropperWrapper.value) return;
+
+  const wrapperWidth = cropperWrapper.value.clientWidth;
+  const wrapperHeight = cropperWrapper.value.clientHeight;
+
+  let newLeft = e.clientX - startPos.value.x;
+  let newTop = e.clientY - startPos.value.y;
+
+  // 确保裁剪框不会超出容器
+  newLeft = Math.max(
+    0,
+    Math.min(wrapperWidth - frameSize.value.width, newLeft),
+  );
+  newTop = Math.max(
+    0,
+    Math.min(wrapperHeight - frameSize.value.height, newTop),
+  );
+
+  framePos.value = {
+    left: newLeft,
+    top: newTop,
+  };
+};
+
+const stopFrameDrag = (): void => {
+  isFrameDragging.value = false;
+  document.removeEventListener('mousemove', dragFrame);
+  document.removeEventListener('mouseup', stopFrameDrag);
+};
+
+const startResize = (e: MouseEvent, position: ResizeHandlePosition): void => {
+  e.preventDefault();
+  e.stopPropagation();
+
+  isResizing.value = true;
+  resizeHandlePosition.value = position;
+  startPos.value = { x: e.clientX, y: e.clientY };
+  startFrameSize.value = { ...frameSize.value };
+  startFramePos.value = { ...framePos.value };
+
+  document.addEventListener('mousemove', handleResize);
+  document.addEventListener('mouseup', stopResize);
+};
+
+const handleResize = (e: MouseEvent): void => {
+  if (!isResizing.value || !resizeHandlePosition.value || !cropperWrapper.value)
+    return;
+
+  const deltaX = e.clientX - startPos.value.x;
+  const deltaY = e.clientY - startPos.value.y;
+
+  let newWidth = startFrameSize.value.width;
+  let newHeight = startFrameSize.value.height;
+  let newLeft = startFramePos.value.left;
+  let newTop = startFramePos.value.top;
+
+  const wrapperWidth = cropperWrapper.value.clientWidth;
+  const wrapperHeight = cropperWrapper.value.clientHeight;
+
+  switch (resizeHandlePosition.value) {
+    case 'bottom-left': {
+      newWidth = Math.max(props.minWidth, startFrameSize.value.width - deltaX);
+      newHeight =
+        aspectRatio.value > 0
+          ? newWidth / aspectRatio.value
+          : Math.max(props.minHeight, startFrameSize.value.height + deltaY);
+      newLeft = startFramePos.value.left + deltaX;
+      break;
+    }
+
+    case 'bottom-right': {
+      newWidth = Math.max(props.minWidth, startFrameSize.value.width + deltaX);
+      newHeight =
+        aspectRatio.value > 0
+          ? newWidth / aspectRatio.value
+          : Math.max(props.minHeight, startFrameSize.value.height + deltaY);
+      break;
+    }
+
+    case 'top-left': {
+      newWidth = Math.max(props.minWidth, startFrameSize.value.width - deltaX);
+      newHeight =
+        aspectRatio.value > 0
+          ? newWidth / aspectRatio.value
+          : Math.max(props.minHeight, startFrameSize.value.height - deltaY);
+      newLeft = startFramePos.value.left + deltaX;
+      newTop =
+        aspectRatio.value > 0
+          ? startFramePos.value.top + (startFrameSize.value.height - newHeight)
+          : startFramePos.value.top + deltaY;
+      break;
+    }
+
+    case 'top-right': {
+      newWidth = Math.max(props.minWidth, startFrameSize.value.width + deltaX);
+      newHeight =
+        aspectRatio.value > 0
+          ? newWidth / aspectRatio.value
+          : Math.max(props.minHeight, startFrameSize.value.height - deltaY);
+      newTop =
+        aspectRatio.value > 0
+          ? startFramePos.value.top + (startFrameSize.value.height - newHeight)
+          : startFramePos.value.top + deltaY;
+      break;
+    }
+  }
+
+  // 确保裁剪框不会超出容器
+  if (newLeft < 0) {
+    newWidth += newLeft;
+    newLeft = 0;
+  }
+
+  if (newTop < 0) {
+    newHeight += newTop;
+    newTop = 0;
+  }
+
+  if (newLeft + newWidth > wrapperWidth) {
+    newWidth = wrapperWidth - newLeft;
+    if (aspectRatio.value > 0) {
+      newHeight = newWidth / aspectRatio.value;
+    }
+  }
+
+  if (newTop + newHeight > wrapperHeight) {
+    newHeight = wrapperHeight - newTop;
+    if (aspectRatio.value > 0) {
+      newWidth = newHeight * aspectRatio.value;
+    }
+  }
+
+  // 应用新尺寸和位置
+  frameSize.value = {
+    width: Math.max(props.minWidth, newWidth),
+    height: Math.max(props.minHeight, newHeight),
+  };
+
+  framePos.value = {
+    left: newLeft,
+    top: newTop,
+  };
+};
+
+const stopResize = (): void => {
+  isResizing.value = false;
+  resizeHandlePosition.value = null;
+  document.removeEventListener('mousemove', handleResize);
+  document.removeEventListener('mouseup', stopResize);
+};
+
+const setAspectRatio = (ratio: number): void => {
+  aspectRatio.value = ratio;
+
+  if (ratio > 0 && cropperWrapper.value) {
+    // 保持裁剪框中心位置不变,只调整大小
+    const centerX = framePos.value.left + frameSize.value.width / 2;
+    const centerY = framePos.value.top + frameSize.value.height / 2;
+
+    const newWidth = Math.min(
+      frameSize.value.width,
+      cropperWrapper.value.clientWidth,
+    );
+    const newHeight = newWidth / ratio;
+
+    frameSize.value = {
+      width: newWidth,
+      height: newHeight,
+    };
+
+    framePos.value = {
+      left: centerX - newWidth / 2,
+      top: centerY - newHeight / 2,
+    };
+  }
+};
+
+const handleCrop = async () => {
+  if (!previewImage.value) {
+    updatePreview();
+    return;
+  }
+
+  try {
+    lock();
+    const data = await FileApi.uploadBase64({
+      fileName: state.fileName,
+      base64: previewImage.value,
+      path: '',
+      directory: '',
+      allowSuffix: '',
+      folderId: 0,
+    });
+    emit('success', data);
+    close();
+  } finally {
+    unlock();
+  }
+};
+
+onMounted(() => {
+  if (imageElement.value?.complete) {
+    initCropper();
+  }
+});
+
+onUnmounted(() => {
+  document.removeEventListener('mousemove', dragFrame);
+  document.removeEventListener('mouseup', stopFrameDrag);
+  document.removeEventListener('mousemove', handleResize);
+  document.removeEventListener('mouseup', stopResize);
+});
+
+const handleUpload = () => {
+  fileRef.value?.click();
+};
+
+const reset = () => {
+  state.croppedImage = state.sourceImage;
+  flipX.value = 1;
+  flipY.value = 1;
+  rotation.value = 0;
+  scale.value = 1;
+  updateTransform();
+};
+
+const compProps = reactive({
+  beforeChange: undefined,
+  disabled: false,
+  gap: 0,
+  showIcon: false,
+  // size: 'large',
+  allowClear: false,
+} as Recordable<any>);
+
+const groupButton = reactive({
+  groups: [
+    [
+      { fn: zoomIn, tooltip: '放大', icon: 'si:add-fill' },
+      { fn: zoomOut, tooltip: '缩小', icon: 'si:remove-fill' },
+      { fn: resetZoom, tooltip: '重置缩放', icon: 'ri:reset-left-fill' },
+    ],
+    [
+      { fn: rotateLeft, tooltip: '向左旋转', icon: 'gridicons:undo' },
+      { fn: rotateRight, tooltip: '向右旋转', icon: 'gridicons:redo' },
+      { fn: resetRotation, tooltip: '重置旋转', icon: 'ri:reset-left-fill' },
+    ],
+    [
+      {
+        fn: () => moveImage('left'),
+        tooltip: '左移',
+        icon: 'gridicons:chevron-left',
+      },
+      {
+        fn: () => moveImage('right'),
+        tooltip: '右移',
+        icon: 'gridicons:chevron-right',
+      },
+      {
+        fn: () => moveImage('up'),
+        tooltip: '上移',
+        icon: 'gridicons:chevron-up',
+      },
+      {
+        fn: () => moveImage('down'),
+        tooltip: '下移',
+        icon: 'gridicons:chevron-down',
+      },
+    ],
+    [
+      {
+        fn: flipHorizontal,
+        tooltip: '水平翻转',
+        icon: 'solar:sort-horizontal-bold',
+      },
+      {
+        fn: flipVertical,
+        tooltip: '垂直翻转',
+        icon: 'solar:sort-vertical-bold',
+      },
+      { fn: resetFlip, tooltip: '重置翻转', icon: 'ri:reset-left-fill' },
+    ],
+    [
+      {
+        fn: handleUpload,
+        tooltip: '选择图片',
+        icon: 'icon-park-outline:upload-one',
+      },
+      { fn: reset, tooltip: '重新开始', icon: 'ri:reset-left-fill' },
+    ],
+  ],
+  size: '48',
+});
+
+const handleFileChange = (e: Event): void => {
+  const input = e.target as HTMLInputElement;
+  const file = input.files?.[0];
+  if (!file) return;
+  state.fileName = file.name;
+  const reader = new FileReader();
+  reader.addEventListener('load', (event) => {
+    state.sourceImage = event.target?.result as string;
+    reset();
+  });
+  reader.readAsDataURL(file);
+};
+
+const [Modal, { close, setState, getData, lock, unlock }] = useVbenModal({
+  fullscreenButton: false,
+  draggable: true,
+  closeOnClickModal: false,
+  footer: false,
+  onBeforeClose() {
+    state.sourceImage = '';
+    previewImage.value = '';
+    reset();
+    return true;
+  },
+  onCancel() {
+    close();
+  },
+  onConfirm: async () => {
+    try {
+      lock();
+
+      emit('success');
+      close();
+    } finally {
+      unlock();
+    }
+  },
+  onOpenChange: async (isOpen: boolean) => {
+    if (isOpen) {
+      setState({ loading: true });
+      const data = getData<Record<string, any>>();
+      if (data.baseData?.url) {
+        try {
+          const base64 = await FileApi.getBase64Url(data.baseData.url);
+          state.sourceImage = `data:image/png;base64,${base64}`;
+          reset();
+        } catch {}
+      }
+      setState({ loading: false });
+    }
+  },
+});
+</script>
+
+<template>
+  <Modal class="w-[1000px]" title="裁剪图片">
+    <div class="image-cropper-container">
+      <img style="display: none" :src="state.sourceImage" />
+      <div class="flex">
+        <div class="cropper-wrapper" ref="cropperWrapper">
+          <div
+            class="cropper-area"
+            ref="cropperArea"
+            @wheel.prevent="handleWheel"
+            v-if="state.croppedImage"
+          >
+            <img
+              ref="imageElement"
+              :src="state.croppedImage"
+              @load="initCropper"
+              alt="Image to crop"
+              class="cropper-image"
+              :style="imageStyle"
+            />
+            <div class="cropper-overlay">
+              <div
+                class="cropper-frame"
+                ref="cropperFrame"
+                :style="frameStyle"
+                @mousedown="startFrameDrag"
+              >
+                <div
+                  v-for="handle in resizeHandles"
+                  :key="handle.position"
+                  class="resize-handle"
+                  :class="`resize-handle-${handle.position}`"
+                  @mousedown.stop="
+                    startResize($event, handle.position as ResizeHandlePosition)
+                  "
+                ></div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div class="flex-1">
+          <input
+            type="file"
+            style="display: none"
+            ref="fileRef"
+            @change="handleFileChange"
+          />
+          <div class="ml-2">
+            <div>
+              <div class="preview-image-wrapper">
+                <div class="mr-2 h-[144px]">
+                  <Image
+                    v-if="previewImage"
+                    height="100%"
+                    :src="previewImage"
+                    :preview="false"
+                    style="object-fit: cover"
+                  />
+                </div>
+                <div class="mr-2 h-[72px]">
+                  <Image
+                    v-if="previewImage"
+                    height="100%"
+                    :src="previewImage"
+                    :preview="false"
+                    style="object-fit: cover"
+                  />
+                </div>
+                <div class="mr-2 h-[36px]">
+                  <Image
+                    v-if="previewImage"
+                    height="100%"
+                    :src="previewImage"
+                    :preview="false"
+                    style="object-fit: cover"
+                  />
+                </div>
+                <div class="h-[18px]">
+                  <Image
+                    v-if="previewImage"
+                    height="100%"
+                    :src="previewImage"
+                    :preview="false"
+                    style="object-fit: cover"
+                  />
+                </div>
+              </div>
+            </div>
+            <div class="mt-2">
+              <div class="flex items-center">
+                <div class="w-[70px]">X轴坐标</div>
+                <div class="ml-2 mr-2 flex-1">
+                  <Input v-model:value="framePos.left" suffix="像素" />
+                </div>
+              </div>
+              <div class="mt-2 flex items-center">
+                <div class="w-[70px]">Y轴坐标</div>
+                <div class="ml-2 mr-2 flex-1">
+                  <Input v-model:value="framePos.top" suffix="像素" />
+                </div>
+              </div>
+              <div class="mt-2 flex items-center">
+                <div class="w-[70px]">宽度</div>
+                <div class="ml-2 mr-2 flex-1">
+                  <Input v-model:value="frameSize.width" suffix="像素" />
+                </div>
+              </div>
+              <div class="mt-2 flex items-center">
+                <div class="w-[70px]">高度</div>
+                <div class="ml-2 mr-2 flex-1">
+                  <Input v-model:value="frameSize.height" suffix="像素" />
+                </div>
+              </div>
+              <div class="mt-2 flex items-center">
+                <div class="w-[70px]">旋转</div>
+                <div class="ml-2 mr-2 flex-1">
+                  <Input v-model:value="rotation" suffix="度" />
+                </div>
+              </div>
+              <div class="mt-2 flex items-center">
+                <div class="w-[70px]">左右翻转</div>
+                <div class="ml-2 mr-2 flex-1">
+                  <Input v-model:value="flipX" />
+                </div>
+              </div>
+              <div class="mt-2 flex items-center">
+                <div class="w-[70px]">上下翻转</div>
+                <div class="ml-2 mr-2 flex-1">
+                  <Input v-model:value="flipY" />
+                </div>
+              </div>
+            </div>
+            <VbenButtonGroup v-bind="compProps" border class="mt-2">
+              <VbenButton
+                variant="default"
+                @click="setAspectRatio(ratio.value)"
+                v-for="ratio in aspectRatios"
+                :key="ratio.value"
+              >
+                {{ ratio.label }}
+              </VbenButton>
+            </VbenButtonGroup>
+          </div>
+        </div>
+      </div>
+
+      <div class="cropper-controls mt-4 flex">
+        <VbenButtonGroup
+          v-bind="compProps"
+          border
+          v-for="(group, index) in groupButton.groups"
+          :key="index"
+          :class="index !== 0 ? 'ml-4' : ''"
+        >
+          <VbenIconButton
+            variant="default"
+            @click="but.fn"
+            :tooltip="but.tooltip"
+            v-for="but in group"
+            :key="but.tooltip"
+          >
+            <Icon :icon="but.icon" :size="groupButton.size" />
+          </VbenIconButton>
+        </VbenButtonGroup>
+
+        <VbenButton variant="default" @click="handleCrop" class="ml-4">
+          完成
+        </VbenButton>
+      </div>
+    </div>
+  </Modal>
+</template>
+
+<style scoped>
+.image-cropper-container {
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  max-width: 1000px;
+  height: 100%;
+  margin: 0 auto;
+}
+
+.cropper-wrapper {
+  position: relative;
+  width: 500px;
+  height: 500px;
+  overflow: hidden;
+  background-color: #f0f0f0;
+  background-image:
+    linear-gradient(45deg, #e0e0e0 25%, transparent 25%),
+    linear-gradient(-45deg, #e0e0e0 25%, transparent 25%),
+    linear-gradient(45deg, transparent 75%, #e0e0e0 75%),
+    linear-gradient(-45deg, transparent 75%, #e0e0e0 75%);
+  background-position:
+    0 0,
+    0 10px,
+    10px -10px,
+    -10px 0;
+  background-size: 20px 20px;
+
+  /* border: 1px solid #ddd; */
+}
+
+.cropper-area {
+  position: relative;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  cursor: move;
+}
+
+.cropper-image {
+  position: absolute;
+  max-width: none;
+  transform-origin: center center;
+}
+
+.cropper-overlay {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgb(0 0 0 / 50%);
+}
+
+.cropper-frame {
+  position: absolute;
+  z-index: 10;
+  border: 2px dashed #fff;
+  box-shadow: 0 0 0 9999px rgb(0 0 0 / 50%);
+}
+
+.resize-handle {
+  position: absolute;
+  z-index: 11;
+  width: 12px;
+  height: 12px;
+  background-color: #fff;
+  border: 2px solid #2196f3;
+  border-radius: 50%;
+}
+
+.resize-handle-top-left {
+  top: -6px;
+  left: -6px;
+  cursor: nwse-resize;
+}
+
+.resize-handle-top-right {
+  top: -6px;
+  right: -6px;
+  cursor: nesw-resize;
+}
+
+.resize-handle-bottom-left {
+  bottom: -6px;
+  left: -6px;
+  cursor: nesw-resize;
+}
+
+.resize-handle-bottom-right {
+  right: -6px;
+  bottom: -6px;
+  cursor: nwse-resize;
+}
+
+.cropper-controls {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20px;
+}
+
+.control-group {
+  flex: 1;
+  min-width: 200px;
+}
+
+.control-group h3 {
+  margin-bottom: 10px;
+  font-size: 16px;
+  color: #333;
+}
+
+.zoom-controls {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.zoom-info {
+  font-size: 14px;
+  color: #555;
+}
+
+.zoom-hint {
+  margin-left: 8px;
+  font-size: 12px;
+  color: #888;
+}
+
+.button-group {
+  display: flex;
+  gap: 8px;
+  align-items: center;
+}
+
+.move-controls {
+  display: flex;
+  flex-direction: column;
+  gap: 5px;
+  align-items: center;
+}
+
+.move-horizontal {
+  display: flex;
+  gap: 5px;
+}
+
+.move-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 36px;
+  height: 36px;
+  cursor: pointer;
+  background-color: #f5f5f5;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  transition: all 0.2s;
+}
+
+.move-btn:hover {
+  background-color: #e0e0e0;
+}
+
+.move-btn svg {
+  fill: #333;
+}
+
+.control-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 36px;
+  height: 36px;
+  cursor: pointer;
+  background-color: #f5f5f5;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  transition: all 0.2s;
+}
+
+.control-btn:hover {
+  background-color: #e0e0e0;
+}
+
+.control-btn svg {
+  fill: #333;
+}
+
+.ratio-btn {
+  padding: 6px 12px;
+  cursor: pointer;
+  background-color: #f5f5f5;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  transition: all 0.2s;
+}
+
+.ratio-btn:hover {
+  background-color: #e0e0e0;
+}
+
+.ratio-btn.active {
+  color: white;
+  background-color: #2196f3;
+  border-color: #2196f3;
+}
+
+.preview-container {
+  flex: 1;
+  min-width: 100%;
+}
+
+.preview-image-wrapper {
+  display: flex;
+  border-radius: 4px;
+}
+
+.action-buttons {
+  display: flex;
+  gap: 10px;
+  margin-top: 20px;
+}
+
+.btn-primary {
+  padding: 10px 20px;
+  color: white;
+  cursor: pointer;
+  background-color: #2196f3;
+  border: none;
+  border-radius: 4px;
+  transition: all 0.2s;
+}
+
+.btn-primary:hover {
+  background-color: #0b7dda;
+}
+
+.btn-confirm {
+  padding: 10px 20px;
+  color: white;
+  cursor: pointer;
+  background-color: #4caf50;
+  border: none;
+  border-radius: 4px;
+  transition: all 0.2s;
+}
+
+.btn-confirm:hover {
+  background-color: #388e3c;
+}
+
+.btn-secondary {
+  padding: 10px 20px;
+  color: white;
+  cursor: pointer;
+  background-color: #f44336;
+  border: none;
+  border-radius: 4px;
+  transition: all 0.2s;
+}
+
+.btn-secondary:hover {
+  background-color: #d32f2f;
+}
+</style>

+ 1 - 1
apps/web-baicai/src/views/dashboard/home/index.vue

@@ -16,10 +16,10 @@ import { useUserStore } from '@vben/stores';
 import { ServerApi } from '#/api';
 
 import AnalyticsTrends from './analytics-trends.vue';
-import AnalyticsVisits from './analytics-visits.vue';
 import AnalyticsVisitsData from './analytics-visits-data.vue';
 import AnalyticsVisitsSales from './analytics-visits-sales.vue';
 import AnalyticsVisitsSource from './analytics-visits-source.vue';
+import AnalyticsVisits from './analytics-visits.vue';
 
 const userStore = useUserStore();