Преглед изворни кода

Merge branch 'main' into fix

xingyu пре 4 месеци
родитељ
комит
9b09ba4483

+ 188 - 53
apps/web-antd/src/adapter/component/index.ts

@@ -3,6 +3,8 @@
  * 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
  */
 
+/* eslint-disable vue/one-component-per-file */
+
 import type {
   UploadChangeParam,
   UploadFile,
@@ -24,12 +26,17 @@ import {
   watch,
 } from 'vue';
 
-import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
+import {
+  ApiComponent,
+  globalShareState,
+  IconPicker,
+  VCropper,
+} from '@vben/common-ui';
 import { IconifyIcon } from '@vben/icons';
 import { $t } from '@vben/locales';
 import { isEmpty } from '@vben/utils';
 
-import { message, notification } from 'ant-design-vue';
+import { message, Modal, notification } from 'ant-design-vue';
 
 const AutoComplete = defineAsyncComponent(
   () => import('ant-design-vue/es/auto-complete'),
@@ -119,6 +126,33 @@ const withDefaultPlaceholder = <T extends Component>(
 };
 
 const withPreviewUpload = () => {
+  // 检查是否为图片文件的辅助函数
+  const isImageFile = (file: UploadFile): boolean => {
+    const imageExtensions = new Set([
+      'bmp',
+      'gif',
+      'jpeg',
+      'jpg',
+      'png',
+      'svg',
+      'webp',
+    ]);
+    if (file.url) {
+      try {
+        const pathname = new URL(file.url, 'http://localhost').pathname;
+        const ext = pathname.split('.').pop()?.toLowerCase();
+        return ext ? imageExtensions.has(ext) : false;
+      } catch {
+        const ext = file.url?.split('.').pop()?.toLowerCase();
+        return ext ? imageExtensions.has(ext) : false;
+      }
+    }
+    if (!file.type) {
+      const ext = file.name?.split('.').pop()?.toLowerCase();
+      return ext ? imageExtensions.has(ext) : false;
+    }
+    return file.type.startsWith('image/');
+  };
   // 创建默认的上传按钮插槽
   const createDefaultSlotsWithUpload = (
     listType: string,
@@ -153,27 +187,6 @@ const withPreviewUpload = () => {
     visible: Ref<boolean>,
     fileList: Ref<UploadProps['fileList']>,
   ) => {
-    // 检查是否为图片文件的辅助函数
-    const isImageFile = (file: UploadFile): boolean => {
-      const imageExtensions = new Set([
-        'bmp',
-        'gif',
-        'jpeg',
-        'jpg',
-        'png',
-        'webp',
-      ]);
-      if (file.url) {
-        const ext = file.url?.split('.').pop()?.toLowerCase();
-        return ext ? imageExtensions.has(ext) : false;
-      }
-      if (!file.type) {
-        const ext = file.name?.split('.').pop()?.toLowerCase();
-        return ext ? imageExtensions.has(ext) : false;
-      }
-      return file.type.startsWith('image/');
-    };
-
     // 如果当前文件不是图片,直接打开
     if (!isImageFile(file)) {
       if (file.url) {
@@ -259,6 +272,107 @@ const withPreviewUpload = () => {
 
     render(h(PreviewWrapper), container);
   };
+
+  // 图片裁剪操作
+  const cropImage = (file: File, aspectRatio: string | undefined) => {
+    return new Promise((resolve, reject) => {
+      const container: HTMLElement | null = document.createElement('div');
+      document.body.append(container);
+
+      // 用于追踪组件是否已卸载
+      let isUnmounted = false;
+      let objectUrl: null | string = null;
+
+      const open = ref<boolean>(true);
+      const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
+
+      const closeModal = () => {
+        open.value = false;
+        // 延迟清理,确保动画完成
+        setTimeout(() => {
+          if (!isUnmounted && container) {
+            if (objectUrl) {
+              URL.revokeObjectURL(objectUrl);
+            }
+            isUnmounted = true;
+            render(null, container);
+            container.remove();
+          }
+        }, 300);
+      };
+
+      const CropperWrapper = {
+        setup() {
+          return () => {
+            if (isUnmounted) return null;
+            if (!objectUrl) {
+              objectUrl = URL.createObjectURL(file);
+            }
+            return h(
+              Modal,
+              {
+                open: open.value,
+                title: $t('ui.crop.title'),
+                centered: true,
+                width: 548,
+                keyboard: false,
+                maskClosable: false,
+                closable: false,
+                cancelText: $t('common.cancel'),
+                okText: $t('ui.crop.confirm'),
+                destroyOnClose: true,
+                onOk: async () => {
+                  const cropper = cropperRef.value;
+                  if (!cropper) {
+                    reject(new Error('Cropper not found'));
+                    closeModal();
+                    return;
+                  }
+                  try {
+                    const dataUrl = await cropper.getCropImage();
+                    resolve(dataUrl);
+                  } catch {
+                    reject(new Error($t('ui.crop.errorTip')));
+                  } finally {
+                    closeModal();
+                  }
+                },
+                onCancel() {
+                  resolve('');
+                  closeModal();
+                },
+              },
+              () =>
+                h(VCropper, {
+                  ref: (ref: any) => (cropperRef.value = ref),
+                  img: objectUrl as string,
+                  aspectRatio,
+                }),
+            );
+          };
+        },
+      };
+
+      render(h(CropperWrapper), container);
+    });
+  };
+
+  const base64ToBlob = (base64: Base64URLString) => {
+    try {
+      const [typeStr, encodeStr] = base64.split(',');
+      if (!typeStr || !encodeStr) return;
+      const mime = typeStr.match(/:(.*?);/)?.[1];
+      const raw = window.atob(encodeStr);
+      const rawLength = raw.length;
+      const uInt8Array = new Uint8Array(rawLength);
+      for (let i = 0; i < rawLength; ++i) {
+        uInt8Array[i] = raw.codePointAt(i) as number;
+      }
+      return new Blob([uInt8Array], { type: mime });
+    } catch {
+      return undefined;
+    }
+  };
   return defineComponent({
     name: Upload.name,
     emits: ['update:modelValue'],
@@ -276,16 +390,50 @@ const withPreviewUpload = () => {
         attrs?.fileList || attrs?.['file-list'] || [],
       );
 
-      const handleBeforeUpload = (file: UploadFile) => {
+      const handleBeforeUpload = async (
+        file: UploadFile,
+        originFileList: Array<File>,
+      ) => {
         if (attrs.maxSize && (file.size || 0) / 1024 / 1024 > attrs.maxSize) {
           message.error($t('ui.formRules.sizeLimit', [attrs.maxSize]));
           file.status = 'removed';
           return false;
         }
+        // 多选或者非图片不唤起裁剪框
+        if (
+          attrs.crop &&
+          !attrs.multiple &&
+          originFileList[0] &&
+          isImageFile(file)
+        ) {
+          file.status = 'removed';
+          // antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取
+          const base64 = await cropImage(originFileList[0], attrs.aspectRatio);
+          return new Promise((resolve, reject) => {
+            if (!base64) {
+              return reject(new Error($t('ui.crop.cancel')));
+            }
+            const blob = base64ToBlob(base64 as string);
+            if (!blob) {
+              return reject(new Error($t('ui.crop.errorTip')));
+            }
+            resolve(blob);
+          });
+        }
+
         return attrs.beforeUpload?.(file) ?? true;
       };
 
-      const handleChange = async (event: UploadChangeParam) => {
+      const handleChange = (event: UploadChangeParam) => {
+        try {
+          // 行内写法 handleChange: (event) => {}
+          attrs.handleChange?.(event);
+          // template写法 @handle-change="(event) => {}"
+          attrs.onHandleChange?.(event);
+        } catch (error) {
+          // Avoid breaking internal v-model sync on user handler errors
+          console.error(error);
+        }
         fileList.value = event.fileList.filter(
           (file) => file.status !== 'removed',
         );
@@ -375,6 +523,7 @@ async function initComponentAdapter() {
     // 如果你的组件体积比较大,可以使用异步加载
     // Button: () =>
     // import('xxx').then((res) => res.Button),
+
     ApiCascader: withDefaultPlaceholder(ApiComponent, 'select', {
       component: Cascader,
       fieldNames: { label: 'label', value: 'value', children: 'children' },
@@ -382,34 +531,20 @@ async function initComponentAdapter() {
       modelPropName: 'value',
       visibleEvent: 'onVisibleChange',
     }),
-    ApiSelect: withDefaultPlaceholder(
-      {
-        ...ApiComponent,
-        name: 'ApiSelect',
-      },
-      'select',
-      {
-        component: Select,
-        loadingSlot: 'suffixIcon',
-        visibleEvent: 'onDropdownVisibleChange',
-        modelPropName: 'value',
-      },
-    ),
-    ApiTreeSelect: withDefaultPlaceholder(
-      {
-        ...ApiComponent,
-        name: 'ApiTreeSelect',
-      },
-      'select',
-      {
-        component: TreeSelect,
-        fieldNames: { label: 'label', value: 'value', children: 'children' },
-        loadingSlot: 'suffixIcon',
-        modelPropName: 'value',
-        optionsPropName: 'treeData',
-        visibleEvent: 'onVisibleChange',
-      },
-    ),
+    ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
+      component: Select,
+      loadingSlot: 'suffixIcon',
+      modelPropName: 'value',
+      visibleEvent: 'onVisibleChange',
+    }),
+    ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
+      component: TreeSelect,
+      fieldNames: { label: 'label', value: 'value', children: 'children' },
+      loadingSlot: 'suffixIcon',
+      modelPropName: 'value',
+      optionsPropName: 'treeData',
+      visibleEvent: 'onVisibleChange',
+    }),
     AutoComplete,
     Cascader,
     Checkbox,

+ 8 - 7
apps/web-tdesign/src/app.vue

@@ -1,7 +1,7 @@
 <script lang="ts" setup>
 import type { GlobalConfigProvider } from 'tdesign-vue-next';
 
-import { onMounted } from 'vue';
+import { watch } from 'vue';
 
 import { usePreferences } from '@vben/preferences';
 
@@ -12,12 +12,13 @@ import zhConfig from 'tdesign-vue-next/es/locale/zh_CN';
 defineOptions({ name: 'App' });
 const { isDark } = usePreferences();
 
-onMounted(() => {
-  document.documentElement.setAttribute(
-    'theme-mode',
-    isDark.value ? 'dark' : '',
-  );
-});
+watch(
+  () => isDark.value,
+  (dark) => {
+    document.documentElement.setAttribute('theme-mode', dark ? 'dark' : '');
+  },
+  { immediate: true },
+);
 
 const customConfig: GlobalConfigProvider = {
   // 可以在此处定义更多自定义配置,具体可配置内容参看 API 文档

+ 3 - 3
apps/web-tdesign/src/views/demos/tdesign/index.vue

@@ -38,7 +38,7 @@ function notify(type: NotificationType) {
     description="支持多语言,主题功能集成切换等"
     title="TDesign Vue组件使用演示"
   >
-    <Card class="mb-5" title="按钮">
+    <Card class="!mb-5" title="按钮">
       <Space>
         <Button>Default</Button>
         <Button theme="primary"> Primary </Button>
@@ -46,7 +46,7 @@ function notify(type: NotificationType) {
         <Button theme="danger"> Error </Button>
       </Space>
     </Card>
-    <Card class="mb-5" title="Message">
+    <Card class="!mb-5" title="Message">
       <Space>
         <Button @click="info"> 信息 </Button>
         <Button theme="danger" @click="error"> 错误 </Button>
@@ -55,7 +55,7 @@ function notify(type: NotificationType) {
       </Space>
     </Card>
 
-    <Card class="mb-5" title="Notification">
+    <Card class="!mb-5" title="Notification">
       <Space>
         <Button @click="notify('info')"> 信息 </Button>
         <Button theme="danger" @click="notify('error')"> 错误 </Button>

+ 2 - 2
internal/vite-config/src/config/index.ts

@@ -1,4 +1,4 @@
-import type { DefineConfig } from '../typing';
+import type { DefineConfig, VbenViteConfig } from '../typing';
 
 import { existsSync } from 'node:fs';
 import { join } from 'node:path';
@@ -12,7 +12,7 @@ export * from './library';
 function defineConfig(
   userConfigPromise?: DefineConfig,
   type: 'application' | 'auto' | 'library' = 'auto',
-) {
+): VbenViteConfig {
   let projectType = type;
 
   // 根据包是否存在 index.html,自动判断类型

+ 9 - 1
internal/vite-config/src/typing.ts

@@ -1,5 +1,10 @@
 import type { PluginVisualizerOptions } from 'rollup-plugin-visualizer';
-import type { ConfigEnv, PluginOption, UserConfig } from 'vite';
+import type {
+  ConfigEnv,
+  PluginOption,
+  UserConfig,
+  UserConfigFnPromise,
+} from 'vite';
 import type { PluginOptions } from 'vite-plugin-dts';
 import type { Options as PwaPluginOptions } from 'vite-plugin-pwa';
 
@@ -327,6 +332,8 @@ type DefineLibraryOptions = (config?: ConfigEnv) => Promise<{
  */
 type DefineConfig = DefineApplicationOptions | DefineLibraryOptions;
 
+type VbenViteConfig = Promise<UserConfig> | UserConfig | UserConfigFnPromise;
+
 export type {
   ApplicationPluginOptions,
   ArchiverPluginOptions,
@@ -340,4 +347,5 @@ export type {
   LibraryPluginOptions,
   NitroMockPluginOptions,
   PrintPluginOptions,
+  VbenViteConfig,
 };

+ 2 - 4
packages/@core/composables/src/__tests__/use-sortable.test.ts

@@ -29,10 +29,8 @@ describe('useSortable', () => {
     await initializeSortable();
 
     // Import sortablejs to access the mocked create function
-    const Sortable = await import(
-      // @ts-expect-error - no types
-      'sortablejs/modular/sortable.complete.esm.js'
-    );
+    const Sortable =
+      await import('sortablejs/modular/sortable.complete.esm.js');
 
     // Verify that Sortable.create was called with the correct parameters
     expect(Sortable.default.create).toHaveBeenCalledTimes(1);

+ 3 - 2
packages/@core/preferences/src/preferences.ts

@@ -63,8 +63,9 @@ class PreferenceManager {
 
   /**
    * 初始化偏好设置
-   * @param namespace - 命名空间,用于隔离不同应用的配置
-   * @param overrides - 要覆盖的偏好设置
+   * @param options - 初始化配置项
+   * @param options.namespace - 命名空间,用于隔离不同应用的配置
+   * @param options.overrides - 要覆盖的偏好设置
    */
   initPreferences = async ({ namespace, overrides }: InitialOptions) => {
     // 防止重复初始化

+ 1 - 1
packages/@core/preferences/src/use-preferences.ts

@@ -136,7 +136,7 @@ function usePreferences() {
   });
 
   /**
-   * @zh_CN 登录注册页面布局是否为
+   * @zh_CN 登录注册页面布局是否为
    */
   const authPanelRight = computed(() => {
     return appPreferences.value.authPageLayout === 'panel-right';

+ 9 - 8
packages/@core/ui-kit/layout-ui/src/vben-layout.vue

@@ -403,13 +403,10 @@ watch(
 );
 
 {
-  const mouseMove = () => {
-    mouseY.value > headerWrapperHeight.value
-      ? (headerIsHidden.value = true)
-      : (headerIsHidden.value = false);
-  };
+  const HEADER_TRIGGER_DISTANCE = 12;
+
   watch(
-    [() => props.headerMode, () => mouseY.value],
+    [() => props.headerMode, () => mouseY.value, () => headerIsHidden.value],
     () => {
       if (!isHeaderAutoMode.value || isMixedNav.value || isFullContent.value) {
         if (props.headerMode !== 'auto-scroll') {
@@ -417,8 +414,12 @@ watch(
         }
         return;
       }
-      headerIsHidden.value = true;
-      mouseMove();
+
+      const isInTriggerZone = mouseY.value <= HEADER_TRIGGER_DISTANCE;
+      const isInHeaderZone =
+        !headerIsHidden.value && mouseY.value <= headerWrapperHeight.value;
+
+      headerIsHidden.value = !(isInTriggerZone || isInHeaderZone);
     },
     {
       immediate: true,

+ 956 - 0
packages/effects/common-ui/src/components/cropper/cropper.vue

@@ -0,0 +1,956 @@
+<script setup lang="ts">
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+
+// 定义组件参数
+const props = defineProps<{
+  /** 裁剪比例 格式如 '1:1', '16:9', '3:4' 等(非必填) */
+  aspectRatio?: string;
+  /** 容器高度(默认400) */
+  height?: number;
+  /** 图片地址 */
+  img: string;
+  /** 容器宽度(默认500) */
+  width?: number;
+}>();
+
+const CROPPER_CONSTANTS = {
+  MIN_WIDTH: 60 as const,
+  MIN_HEIGHT: 60 as const,
+  DEFAULT_WIDTH: 500 as const,
+  DEFAULT_HEIGHT: 400 as const,
+  PADDING_RATIO: 0.1 as const,
+  MAX_PADDING: 50 as const,
+} as const;
+
+type Point = [number, number]; // [clientX, clientY]
+type Dimension = [number, number, number, number]; // [top, right, bottom, left]
+
+// 拖拽点类型
+type DragAction =
+  | 'bottom'
+  | 'bottom-left'
+  | 'bottom-right'
+  | 'left'
+  | 'move'
+  | 'right'
+  | 'top'
+  | 'top-left'
+  | 'top-right';
+
+// DOM 引用
+const containerRef = ref<HTMLDivElement | null>(null);
+const bgImageRef = ref<HTMLImageElement | null>(null);
+const maskRef = ref<HTMLDivElement | null>(null);
+const maskViewRef = ref<HTMLDivElement | null>(null);
+const cropperRef = ref<HTMLDivElement | null>(null);
+const cropperViewRef = ref<HTMLDivElement | null>(null);
+
+// 响应式数据
+const isCropperVisible = ref<boolean>(false);
+const validAspectRatio = ref<null | number>(null); // 有效比例值(null表示无固定比例)
+const containerWidth = ref<number>(
+  props.width ?? CROPPER_CONSTANTS.DEFAULT_WIDTH,
+);
+const containerHeight = ref<number>(
+  props.height ?? CROPPER_CONSTANTS.DEFAULT_HEIGHT,
+);
+
+// 裁剪区域尺寸(top, right, bottom, left)
+const currentDimension = ref<Dimension>([50, 50, 50, 50]);
+const initDimension = ref<Dimension>([50, 50, 50, 50]);
+
+// 拖拽状态
+const dragging = ref<boolean>(false);
+const startPoint = ref<Point>([0, 0]);
+const startDimension = ref<Dimension>([0, 0, 0, 0]);
+const direction = ref<Dimension>([0, 0, 0, 0]);
+const moving = ref<boolean>(false);
+
+/**
+ * 计算图片的适配尺寸,保证完整显示且不超过最大宽高限制
+ */
+const calculateImageFitSize = () => {
+  if (!bgImageRef.value) return;
+
+  // 获取图片原始尺寸
+  const imgWidth = bgImageRef.value.naturalWidth;
+  const imgHeight = bgImageRef.value.naturalHeight;
+
+  if (imgWidth === 0 || imgHeight === 0) return;
+
+  // 计算缩放比例(使用传入的width/height,默认500/400)
+  const widthRatio =
+    (props.width ?? CROPPER_CONSTANTS.DEFAULT_WIDTH) / imgWidth;
+  const heightRatio =
+    (props.height ?? CROPPER_CONSTANTS.DEFAULT_HEIGHT) / imgHeight;
+  const scaleRatio = Math.min(widthRatio, heightRatio, 1); // 不放大图片,只缩小
+
+  // 计算适配后的容器尺寸
+  const fitWidth = Math.floor(imgWidth * scaleRatio);
+  const fitHeight = Math.floor(imgHeight * scaleRatio);
+
+  containerWidth.value = fitWidth;
+  containerHeight.value = fitHeight;
+
+  // 重置裁剪框初始尺寸(基于新的容器尺寸)
+  const padding = Math.min(
+    CROPPER_CONSTANTS.MAX_PADDING,
+    Math.floor(fitWidth * CROPPER_CONSTANTS.PADDING_RATIO),
+    Math.floor(fitHeight * CROPPER_CONSTANTS.PADDING_RATIO),
+  );
+
+  initDimension.value = [padding, padding, padding, padding];
+  currentDimension.value = [padding, padding, padding, padding];
+};
+
+/**
+ * 验证并解析比例字符串
+ * @returns {number|null} 比例值 (width/height),解析失败返回null
+ */
+const parseAndValidateAspectRatio = (): null | number => {
+  // 如果未传入比例参数,直接返回null
+  if (!props.aspectRatio) {
+    return null;
+  }
+
+  // 验证比例格式
+  const ratioRegex = /^[1-9]\d*:[1-9]\d*$/;
+  if (!ratioRegex.test(props.aspectRatio)) {
+    console.warn('裁剪比例格式错误,应为 "数字:数字" 格式,如 "16:9"');
+    return null;
+  }
+
+  // 解析比例
+  const [width, height] = props.aspectRatio.split(':').map(Number);
+
+  // 验证解析结果有效性
+  if (Number.isNaN(width) || Number.isNaN(height) || !width || !height) {
+    console.warn('裁剪比例解析失败,宽高必须为正整数');
+    return null;
+  }
+
+  return width / height;
+};
+
+/**
+ * 设置裁剪区域尺寸
+ * @param {Dimension} dimension - [top, right, bottom, left]
+ */
+const setDimension = (dimension: Dimension) => {
+  currentDimension.value = [...dimension];
+  if (maskViewRef.value) {
+    maskViewRef.value.style.clipPath = `inset(${dimension[0]}px ${dimension[1]}px ${dimension[2]}px ${dimension[3]}px)`;
+  }
+};
+
+/**
+ * 调整裁剪区域至指定比例
+ */
+const adjustCropperToAspectRatio = () => {
+  if (!cropperRef.value) return;
+
+  // 验证并解析比例
+  validAspectRatio.value = parseAndValidateAspectRatio();
+
+  // 如果无有效比例,使用初始尺寸,不强制固定比例
+  if (validAspectRatio.value === null) {
+    setDimension(initDimension.value);
+    return;
+  }
+
+  // 有有效比例,按比例调整裁剪框
+  const ratio = validAspectRatio.value;
+  const containerWidthVal = containerWidth.value;
+  const containerHeightVal = containerHeight.value;
+
+  // 根据比例计算裁剪框尺寸
+  let newHeight: number, newWidth: number;
+
+  // 先按宽度优先计算
+  newWidth = containerWidthVal;
+  newHeight = newWidth / ratio;
+
+  // 如果高度超出容器,按高度优先计算
+  if (newHeight > containerHeightVal) {
+    newHeight = containerHeightVal;
+    newWidth = newHeight * ratio;
+  }
+
+  // 居中显示
+  const leftRight = (containerWidthVal - newWidth) / 2;
+  const topBottom = (containerHeightVal - newHeight) / 2;
+
+  const newDimension: Dimension = [topBottom, leftRight, topBottom, leftRight];
+
+  setDimension(newDimension);
+};
+
+/**
+ * 创建裁剪器
+ */
+const createCropper = () => {
+  // 计算图片适配尺寸
+  calculateImageFitSize();
+
+  isCropperVisible.value = true;
+  adjustCropperToAspectRatio();
+};
+
+/**
+ * 处理鼠标按下事件
+ * @param {MouseEvent} e - 鼠标事件
+ * @param {DragAction} action - 操作类型
+ */
+const handleMouseDown = (e: MouseEvent, action: DragAction) => {
+  dragging.value = true;
+  startPoint.value = [e.clientX, e.clientY];
+  startDimension.value = [...currentDimension.value];
+  direction.value = [0, 0, 0, 0];
+  moving.value = false;
+
+  // 处理移动
+  if (action === 'move') {
+    direction.value[0] = 1;
+    direction.value[2] = -1;
+    direction.value[3] = 1;
+    direction.value[1] = -1;
+    moving.value = true;
+    return;
+  }
+
+  // 处理拖拽方向
+  switch (action) {
+    case 'bottom': {
+      direction.value[2] = -1;
+      break;
+    }
+    case 'bottom-left': {
+      direction.value[2] = -1;
+      direction.value[3] = 1;
+      break;
+    }
+    case 'bottom-right': {
+      direction.value[2] = -1;
+      direction.value[1] = -1;
+      break;
+    }
+    case 'left': {
+      direction.value[3] = 1;
+      break;
+    }
+    case 'right': {
+      direction.value[1] = -1;
+      break;
+    }
+    case 'top': {
+      direction.value[0] = 1;
+      break;
+    }
+    case 'top-left': {
+      direction.value[0] = 1;
+      direction.value[3] = 1;
+      break;
+    }
+    case 'top-right': {
+      direction.value[0] = 1;
+      direction.value[1] = -1;
+      break;
+    }
+  }
+};
+
+/**
+ * 处理鼠标移动事件
+ * @param {MouseEvent} e - 鼠标事件
+ */
+const handleMouseMove = (e: MouseEvent) => {
+  if (!dragging.value || !cropperRef.value) return;
+
+  const { clientX, clientY } = e;
+  const diffX = clientX - startPoint.value[0];
+  const diffY = clientY - startPoint.value[1];
+
+  // 处理移动裁剪框
+  if (moving.value) {
+    handleMoveCropBox(diffX, diffY);
+    return;
+  }
+
+  // 无有效比例
+  if (validAspectRatio.value === null) {
+    handleFreeAspectResize(diffX, diffY);
+  } else {
+    handleFixedAspectResize(diffX, diffY);
+  }
+};
+
+const handleMoveCropBox = (diffX: number, diffY: number) => {
+  const newDimension = [...startDimension.value] as Dimension;
+
+  // 计算临时偏移后的位置
+  const tempTop = startDimension.value[0] + diffY;
+  const tempLeft = startDimension.value[3] + diffX;
+
+  // 计算裁剪框的固定尺寸
+  const cropWidth =
+    containerWidth.value - startDimension.value[3] - startDimension.value[1];
+  const cropHeight =
+    containerHeight.value - startDimension.value[0] - startDimension.value[2];
+
+  // 边界限制:确保裁剪框完全在容器内,且尺寸不变
+  // 顶部边界:top >= 0,且 bottom = 容器高度 - top - 裁剪高度 >= 0
+  newDimension[0] = Math.max(
+    0,
+    Math.min(tempTop, containerHeight.value - cropHeight),
+  );
+  // 底部边界:bottom = 容器高度 - top - 裁剪高度(由top推导,无需额外计算)
+  newDimension[2] = containerHeight.value - newDimension[0] - cropHeight;
+  // 左侧边界:left >= 0,且 right = 容器宽度 - left - 裁剪宽度 >= 0
+  newDimension[3] = Math.max(
+    0,
+    Math.min(tempLeft, containerWidth.value - cropWidth),
+  );
+  // 右侧边界:right = 容器宽度 - left - 裁剪宽度(由left推导,无需额外计算)
+  newDimension[1] = containerWidth.value - newDimension[3] - cropWidth;
+
+  // 强制保证尺寸不变(兜底)
+  const finalWidth = containerWidth.value - newDimension[3] - newDimension[1];
+  const finalHeight = containerHeight.value - newDimension[0] - newDimension[2];
+
+  if (finalWidth !== cropWidth) {
+    newDimension[1] = containerWidth.value - newDimension[3] - cropWidth;
+  }
+
+  if (finalHeight !== cropHeight) {
+    newDimension[2] = containerHeight.value - newDimension[0] - cropHeight;
+  }
+
+  // 更新裁剪区域(仅位置变化,尺寸/比例完全不变)
+  setDimension(newDimension);
+};
+
+const handleFreeAspectResize = (diffX: number, diffY: number) => {
+  const cropperWidth = containerWidth.value;
+  const cropperHeight = containerHeight.value;
+  const currentDimensionNew: Dimension = [0, 0, 0, 0];
+
+  // 计算新的尺寸,确保不小于最小值
+  currentDimensionNew[0] = Math.min(
+    Math.max(startDimension.value[0] + direction.value[0] * diffY, 0),
+    cropperHeight - CROPPER_CONSTANTS.MIN_HEIGHT,
+  );
+
+  currentDimensionNew[1] = Math.min(
+    Math.max(startDimension.value[1] + direction.value[1] * diffX, 0),
+    cropperWidth - CROPPER_CONSTANTS.MIN_WIDTH,
+  );
+
+  currentDimensionNew[2] = Math.min(
+    Math.max(startDimension.value[2] + direction.value[2] * diffY, 0),
+    cropperHeight - CROPPER_CONSTANTS.MIN_HEIGHT,
+  );
+
+  currentDimensionNew[3] = Math.min(
+    Math.max(startDimension.value[3] + direction.value[3] * diffX, 0),
+    cropperWidth - CROPPER_CONSTANTS.MIN_WIDTH,
+  );
+
+  // 确保裁剪区域宽度和高度不小于最小值
+  const newWidth =
+    cropperWidth - currentDimensionNew[3] - currentDimensionNew[1];
+  const newHeight =
+    cropperHeight - currentDimensionNew[0] - currentDimensionNew[2];
+
+  if (newWidth < CROPPER_CONSTANTS.MIN_WIDTH) {
+    if (direction.value[3] === 1) {
+      currentDimensionNew[3] =
+        cropperWidth - currentDimensionNew[1] - CROPPER_CONSTANTS.MIN_WIDTH;
+    } else {
+      currentDimensionNew[1] =
+        cropperWidth - currentDimensionNew[3] - CROPPER_CONSTANTS.MIN_WIDTH;
+    }
+  }
+
+  if (newHeight < CROPPER_CONSTANTS.MIN_HEIGHT) {
+    if (direction.value[0] === 1) {
+      currentDimensionNew[0] =
+        cropperHeight - currentDimensionNew[2] - CROPPER_CONSTANTS.MIN_HEIGHT;
+    } else {
+      currentDimensionNew[2] =
+        cropperHeight - currentDimensionNew[0] - CROPPER_CONSTANTS.MIN_HEIGHT;
+    }
+  }
+
+  setDimension(currentDimensionNew);
+};
+
+const handleFixedAspectResize = (diffX: number, diffY: number) => {
+  if (validAspectRatio.value === null) return;
+  const cropperWidth = containerWidth.value;
+  const cropperHeight = containerHeight.value;
+  // 有有效比例 - 固定比例裁剪
+  const ratio = validAspectRatio.value;
+  const currentWidth =
+    cropperWidth - startDimension.value[3] - startDimension.value[1];
+  const currentHeight =
+    cropperHeight - startDimension.value[0] - startDimension.value[2];
+
+  let newHeight: number, newWidth: number;
+  let widthChange = 0;
+  let heightChange = 0;
+
+  // 计算宽度/高度变化量
+  if (direction.value[3] === 1) widthChange = -diffX;
+  else if (direction.value[1] === -1) widthChange = diffX;
+
+  if (direction.value[0] === 1) heightChange = -diffY;
+  else if (direction.value[2] === -1) heightChange = diffY;
+
+  const isCornerDrag =
+    (direction.value[3] === 1 || direction.value[1] === -1) &&
+    (direction.value[0] === 1 || direction.value[2] === -1);
+
+  // 计算新尺寸
+  if (isCornerDrag) {
+    if (Math.abs(widthChange) > Math.abs(heightChange)) {
+      newWidth = Math.max(
+        CROPPER_CONSTANTS.MIN_WIDTH,
+        currentWidth + widthChange,
+      );
+      newHeight = newWidth / ratio;
+    } else {
+      newHeight = Math.max(
+        CROPPER_CONSTANTS.MIN_HEIGHT,
+        currentHeight + heightChange,
+      );
+      newWidth = newHeight * ratio;
+    }
+  } else {
+    if (direction.value[3] === 1 || direction.value[1] === -1) {
+      newWidth = Math.max(
+        CROPPER_CONSTANTS.MIN_WIDTH,
+        currentWidth + widthChange,
+      );
+      newHeight = newWidth / ratio;
+    } else {
+      newHeight = Math.max(
+        CROPPER_CONSTANTS.MIN_HEIGHT,
+        currentHeight + heightChange,
+      );
+      newWidth = newHeight * ratio;
+    }
+  }
+
+  // 限制最大尺寸
+  const maxWidth = cropperWidth;
+  const maxHeight = cropperHeight;
+
+  if (newWidth > maxWidth) {
+    newWidth = maxWidth;
+    newHeight = newWidth / ratio;
+  }
+
+  if (newHeight > maxHeight) {
+    newHeight = maxHeight;
+    newWidth = newHeight * ratio;
+  }
+
+  // 计算新的位置
+  let newLeft = startDimension.value[3];
+  let newTop = startDimension.value[0];
+  let newRight = startDimension.value[1];
+  let newBottom = startDimension.value[2];
+
+  // 根据拖拽方向调整位置
+  if (direction.value[3] === 1) {
+    newLeft = cropperWidth - newWidth - startDimension.value[1];
+  } else if (direction.value[1] === -1) {
+    newRight = cropperWidth - newWidth - startDimension.value[3];
+  } else if (!isCornerDrag) {
+    // 居中调整
+    const currentHorizontalCenter = startDimension.value[3] + currentWidth / 2;
+    newLeft = Math.max(
+      0,
+      Math.min(cropperWidth - newWidth, currentHorizontalCenter - newWidth / 2),
+    );
+    newRight = cropperWidth - newWidth - newLeft;
+  }
+
+  if (direction.value[0] === 1) {
+    newTop = cropperHeight - newHeight - startDimension.value[2];
+  } else if (direction.value[2] === -1) {
+    newBottom = cropperHeight - newHeight - startDimension.value[0];
+  } else if (!isCornerDrag) {
+    // 居中调整
+    const currentVerticalCenter = startDimension.value[0] + currentHeight / 2;
+    newTop = Math.max(
+      0,
+      Math.min(
+        cropperHeight - newHeight,
+        currentVerticalCenter - newHeight / 2,
+      ),
+    );
+    newBottom = cropperHeight - newHeight - newTop;
+  }
+
+  // 边界检查
+  newLeft = Math.max(0, newLeft);
+  newTop = Math.max(0, newTop);
+  newRight = Math.max(0, newRight);
+  newBottom = Math.max(0, newBottom);
+
+  const newDimension: Dimension = [newTop, newRight, newBottom, newLeft];
+  setDimension(newDimension);
+};
+
+/**
+ * 处理鼠标抬起事件
+ */
+const handleMouseUp = () => {
+  dragging.value = false;
+  moving.value = false;
+  direction.value = [0, 0, 0, 0];
+};
+
+/**
+ * 处理图片加载完成
+ */
+const handleImageLoad = () => {
+  createCropper();
+};
+
+/**
+ * 裁剪图片
+ * @param {'image/jpeg' | 'image/png'} format - 输出图片格式
+ * @param {number} quality - 压缩质量(0-1)
+ * @param {number} targetWidth - 目标宽度(可选,不传则为原始裁剪宽度)
+ * @param {number} targetHeight - 目标高度(可选,不传则为原始裁剪高度)
+ */
+const getCropImage = async (
+  format: 'image/jpeg' | 'image/png' = 'image/jpeg',
+  quality: number = 0.92,
+  targetWidth?: number,
+  targetHeight?: number,
+): Promise<string | undefined> => {
+  if (!props.img || !bgImageRef.value || !containerRef.value) return;
+
+  // 创建临时图片对象获取原始尺寸
+  const tempImg = new Image();
+  // Only set crossOrigin for cross-origin URLs that need CORS
+  if (props.img.startsWith('http://') || props.img.startsWith('https://')) {
+    try {
+      const url = new URL(props.img);
+      if (url.origin !== location.origin) {
+        tempImg.crossOrigin = 'anonymous';
+      }
+    } catch {
+      // Invalid URL, proceed without crossOrigin
+    }
+  }
+
+  // 等待临时图片加载完成
+  await new Promise<void>((resolve, reject) => {
+    const timeout = setTimeout(() => {
+      tempImg.removeEventListener('load', handleLoad);
+      tempImg.removeEventListener('error', handleError);
+      reject(new Error('图片加载超时'));
+    }, 10_000);
+    const handleLoad = () => {
+      clearTimeout(timeout);
+      tempImg.removeEventListener('load', handleLoad);
+      tempImg.removeEventListener('error', handleError);
+      resolve();
+    };
+
+    const handleError = (err: ErrorEvent) => {
+      clearTimeout(timeout);
+      tempImg.removeEventListener('load', handleLoad);
+      tempImg.removeEventListener('error', handleError);
+      reject(new Error(`图片加载失败: ${err.message}`));
+    };
+
+    tempImg.addEventListener('load', handleLoad);
+    tempImg.addEventListener('error', handleError);
+
+    tempImg.src = props.img;
+  });
+
+  const containerRect = containerRef.value.getBoundingClientRect();
+  const imgRect = bgImageRef.value.getBoundingClientRect();
+
+  // 1. 计算图片在容器内的渲染参数
+  const containerWidth = containerRect.width;
+  const containerHeight = containerRect.height;
+  const renderedImgWidth = imgRect.width;
+  const renderedImgHeight = imgRect.height;
+  const imgOffsetX = (containerWidth - renderedImgWidth) / 2;
+  const imgOffsetY = (containerHeight - renderedImgHeight) / 2;
+
+  // 2. 计算裁剪框在容器内的实际坐标
+  const [cropTop, cropRight, cropBottom, cropLeft] = currentDimension.value;
+  const cropBoxWidth = containerWidth - cropLeft - cropRight;
+  const cropBoxHeight = containerHeight - cropTop - cropBottom;
+
+  // 3. 将裁剪框坐标转换为图片上的坐标(考虑图片偏移)
+  const cropOnImgX = cropLeft - imgOffsetX;
+  const cropOnImgY = cropTop - imgOffsetY;
+
+  // 4. 计算渲染图片到原始图片的缩放比例(关键:保留原始像素)
+  const scaleX = tempImg.width / renderedImgWidth;
+  const scaleY = tempImg.height / renderedImgHeight;
+
+  // 5. 映射到原始图片的裁剪区域(精确到原始像素)
+  const originalCropX = Math.max(0, Math.floor(cropOnImgX * scaleX));
+  const originalCropY = Math.max(0, Math.floor(cropOnImgY * scaleY));
+  const originalCropWidth = Math.min(
+    Math.floor(cropBoxWidth * scaleX),
+    tempImg.width - originalCropX,
+  );
+  const originalCropHeight = Math.min(
+    Math.floor(cropBoxHeight * scaleY),
+    tempImg.height - originalCropY,
+  );
+
+  // 6. 处理高清屏适配(关键:解决Retina屏模糊)
+  const dpr = window.devicePixelRatio || 1;
+
+  // 最终画布尺寸(优先使用原始裁剪尺寸,或目标尺寸)
+  const finalWidth = targetWidth || originalCropWidth;
+  const finalHeight = targetHeight || originalCropHeight;
+
+  // 创建画布(乘以设备像素比,保证高清)
+  const canvas = document.createElement('canvas');
+  const ctx = canvas.getContext('2d');
+  if (!ctx) return;
+
+  // 画布物理尺寸(适配高清屏)
+  canvas.width = finalWidth * dpr;
+  canvas.height = finalHeight * dpr;
+
+  // 画布显示尺寸(视觉尺寸)
+  canvas.style.width = `${finalWidth}px`;
+  canvas.style.height = `${finalHeight}px`;
+
+  // 缩放上下文(适配DPR)
+  ctx.scale(dpr, dpr);
+
+  // 7. 绘制裁剪后的图片(使用原始像素绘制,保证清晰度)
+  ctx.drawImage(
+    tempImg,
+    originalCropX, // 原始图片裁剪起始X(精确像素)
+    originalCropY, // 原始图片裁剪起始Y(精确像素)
+    originalCropWidth, // 原始图片裁剪宽度(精确像素)
+    originalCropHeight, // 原始图片裁剪高度(精确像素)
+    0, // 画布绘制起始X
+    0, // 画布绘制起始Y
+    finalWidth, // 画布绘制宽度(目标尺寸)
+    finalHeight, // 画布绘制高度(目标尺寸)
+  );
+
+  // 8. 导出图片(指定质量,平衡清晰度和体积)
+  return canvas.toDataURL(format, quality);
+};
+
+// 监听比例变化,重新调整裁剪框
+watch(() => props.aspectRatio, adjustCropperToAspectRatio);
+
+// 监听width/height变化,重新计算尺寸
+watch([() => props.width, () => props.height], () => {
+  calculateImageFitSize();
+  adjustCropperToAspectRatio();
+});
+
+// 组件挂载时注册全局事件
+onMounted(() => {
+  document.addEventListener('mousemove', handleMouseMove);
+  document.addEventListener('mouseup', handleMouseUp);
+
+  // 如果图片已经加载完成,手动触发创建裁剪器
+  if (
+    bgImageRef.value &&
+    bgImageRef.value.complete &&
+    bgImageRef.value.naturalWidth > 0
+  ) {
+    createCropper();
+  }
+});
+
+// 组件卸载时清理
+onUnmounted(() => {
+  document.removeEventListener('mousemove', handleMouseMove);
+  document.removeEventListener('mouseup', handleMouseUp);
+});
+
+defineExpose({ getCropImage });
+</script>
+
+<template>
+  <div
+    :style="{
+      width: `${width || CROPPER_CONSTANTS.DEFAULT_WIDTH}px`,
+      height: `${height || CROPPER_CONSTANTS.DEFAULT_HEIGHT}px`,
+    }"
+    class="cropper-action-wrapper"
+  >
+    <div
+      ref="containerRef"
+      class="cropper-container"
+      :style="{
+        width: `${containerWidth}px`,
+        height: `${containerHeight}px`,
+      }"
+    >
+      <!-- 原图展示 - 自适应尺寸 -->
+      <img
+        ref="bgImageRef"
+        class="cropper-image"
+        :src="img"
+        @load="handleImageLoad"
+        :style="{
+          maxWidth: '100%',
+          maxHeight: '100%',
+          objectFit: 'contain',
+        }"
+        alt="裁剪原图"
+      />
+
+      <!-- 遮罩层 -->
+      <div
+        ref="maskRef"
+        class="cropper-mask"
+        :style="{
+          display: isCropperVisible ? 'block' : 'none',
+          width: '100%',
+          height: '100%',
+        }"
+      >
+        <div
+          ref="maskViewRef"
+          class="cropper-mask-view"
+          :style="{
+            backgroundImage: `url(${img})`,
+            backgroundSize: 'contain',
+            backgroundPosition: 'center',
+            backgroundRepeat: 'no-repeat',
+            clipPath: `inset(${currentDimension[0]}px ${currentDimension[1]}px ${currentDimension[2]}px ${currentDimension[3]}px)`,
+            width: '100%',
+            height: '100%',
+          }"
+        ></div>
+      </div>
+
+      <!-- 裁剪框 -->
+      <div
+        ref="cropperRef"
+        class="cropper-box"
+        :style="{
+          display: isCropperVisible ? 'block' : 'none',
+          width: '100%',
+          height: '100%',
+        }"
+      >
+        <div
+          ref="cropperViewRef"
+          class="cropper-view"
+          :style="{
+            inset: `${currentDimension[0]}px ${currentDimension[1]}px ${currentDimension[2]}px ${currentDimension[3]}px`,
+          }"
+        >
+          <!-- 裁剪框辅助线-->
+          <span class="cropper-dashed-h"></span>
+          <span class="cropper-dashed-v"></span>
+
+          <!-- 裁剪框拖拽区域 -->
+          <span
+            class="cropper-move-area"
+            @mousedown="handleMouseDown($event, 'move')"
+          ></span>
+
+          <!-- 边框线 -->
+          <span class="cropper-line-e"></span>
+          <span class="cropper-line-n"></span>
+          <span class="cropper-line-w"></span>
+          <span class="cropper-line-s"></span>
+
+          <!-- 边角拖拽点 -->
+          <span
+            class="cropper-point cropper-point-ne"
+            @mousedown="handleMouseDown($event, 'top-right')"
+          >
+            <span class="cropper-point-inner"></span>
+          </span>
+          <span
+            class="cropper-point cropper-point-nw"
+            @mousedown="handleMouseDown($event, 'top-left')"
+          >
+            <span class="cropper-point-inner"></span>
+          </span>
+          <span
+            class="cropper-point cropper-point-sw"
+            @mousedown="handleMouseDown($event, 'bottom-left')"
+          >
+            <span class="cropper-point-inner"></span>
+          </span>
+          <span
+            class="cropper-point cropper-point-se"
+            @mousedown="handleMouseDown($event, 'bottom-right')"
+          >
+            <span class="cropper-point-inner"></span>
+          </span>
+
+          <!-- 边中点拖拽点 -->
+          <span
+            class="cropper-point cropper-point-e"
+            @mousedown="handleMouseDown($event, 'right')"
+          >
+            <span class="cropper-point-inner"></span>
+          </span>
+          <span
+            class="cropper-point cropper-point-n"
+            @mousedown="handleMouseDown($event, 'top')"
+          >
+            <span class="cropper-point-inner"></span>
+          </span>
+          <span
+            class="cropper-point cropper-point-w"
+            @mousedown="handleMouseDown($event, 'left')"
+          >
+            <span class="cropper-point-inner"></span>
+          </span>
+          <span
+            class="cropper-point cropper-point-s"
+            @mousedown="handleMouseDown($event, 'bottom')"
+          >
+            <span class="cropper-point-inner"></span>
+          </span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.cropper-action-wrapper {
+  @apply box-border flex items-center justify-center;
+  /* 马赛克背景 */
+  background-image:
+    linear-gradient(45deg, #ccc 25%, transparent 25%),
+    linear-gradient(-45deg, #ccc 25%, transparent 25%),
+    linear-gradient(45deg, transparent 75%, #ccc 75%),
+    linear-gradient(-45deg, transparent 75%, #ccc 75%);
+  background-size: 20px 20px;
+  background-position:
+    0 0,
+    0 10px,
+    10px -10px,
+    -10px 0;
+  background-color: transparent;
+}
+
+.cropper-container {
+  @apply relative;
+}
+
+.cropper-image {
+  @apply block;
+}
+
+/* 遮罩层 */
+.cropper-mask {
+  @apply absolute left-0 top-0 bg-black/50;
+}
+
+.cropper-mask-view {
+  @apply absolute left-0 top-0;
+}
+
+/* 裁剪框 */
+.cropper-box {
+  @apply absolute left-0 top-0 z-10;
+}
+
+.cropper-view {
+  @apply absolute bottom-0 left-0 right-0 top-0 select-none outline outline-1 outline-blue-500;
+}
+
+/* 裁剪框辅助线 */
+.cropper-dashed-h {
+  @apply absolute left-0 top-1/3 block h-1/3 w-full border-b border-t border-dashed border-gray-200/50;
+}
+
+.cropper-dashed-v {
+  @apply absolute left-1/3 top-0 block h-full w-1/3 border-l border-r border-dashed border-gray-200/50;
+}
+
+/* 裁剪框拖拽区域 */
+.cropper-move-area {
+  @apply absolute left-0 top-0 block h-full w-full cursor-move bg-white/10;
+}
+
+/* 边框拖拽线 */
+.cropper-line-e,
+.cropper-line-n,
+.cropper-line-w,
+.cropper-line-s {
+  @apply absolute block bg-blue-500/10;
+}
+
+.cropper-line-e {
+  @apply right-[-3px] top-0 h-full w-1;
+}
+
+.cropper-line-n {
+  @apply left-0 top-[-3px] h-1 w-full;
+}
+
+.cropper-line-w {
+  @apply left-[-3px] top-0 h-full w-1;
+}
+
+.cropper-line-s {
+  @apply bottom-[-3px] left-0 h-1 w-full;
+}
+
+/* 拖拽点 */
+.cropper-point {
+  @apply absolute flex h-2 w-2 items-center justify-center bg-blue-500;
+}
+
+.cropper-point-inner {
+  @apply block h-1.5 w-1.5 bg-white;
+}
+
+/* 边角拖拽点位置和光标 */
+.cropper-point-ne {
+  @apply right-[-5px] top-[-5px] cursor-ne-resize;
+}
+
+.cropper-point-nw {
+  @apply left-[-5px] top-[-5px] cursor-nw-resize;
+}
+
+.cropper-point-sw {
+  @apply bottom-[-5px] left-[-5px] cursor-sw-resize;
+}
+
+.cropper-point-se {
+  @apply bottom-[-5px] right-[-5px] cursor-se-resize;
+}
+
+/* 边中点拖拽点位置和光标 */
+.cropper-point-e {
+  @apply right-[-5px] top-1/2 -mt-1 cursor-e-resize;
+}
+
+.cropper-point-n {
+  @apply left-1/2 top-[-5px] -ml-1 cursor-n-resize;
+}
+
+.cropper-point-w {
+  @apply left-[-5px] top-1/2 -mt-1 cursor-w-resize;
+}
+
+.cropper-point-s {
+  @apply bottom-[-5px] left-1/2 -ml-1 cursor-s-resize;
+}
+</style>

+ 1 - 0
packages/effects/common-ui/src/components/cropper/index.ts

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

+ 1 - 0
packages/effects/common-ui/src/components/index.ts

@@ -2,6 +2,7 @@ export * from './api-component';
 export * from './captcha';
 export * from './col-page';
 export * from './count-to';
+export * from './cropper';
 export * from './ellipsis-text';
 export * from './icon-picker';
 export * from './json-viewer';

+ 13 - 2
packages/effects/layouts/src/basic/layout.vue

@@ -14,7 +14,7 @@ import {
   updatePreferences,
   usePreferences,
 } from '@vben/preferences';
-import { useAccessStore } from '@vben/stores';
+import { useAccessStore, useTabbarStore, useTimezoneStore } from '@vben/stores';
 import { cloneDeep, mapTree } from '@vben/utils';
 
 import { VbenAdminLayout } from '@vben-core/layout-ui';
@@ -52,6 +52,7 @@ const {
   theme,
 } = usePreferences();
 const accessStore = useAccessStore();
+const timezoneStore = useTimezoneStore();
 const { refresh } = useRefresh();
 
 const sidebarTheme = computed(() => {
@@ -187,9 +188,19 @@ watch(
   },
 );
 
+const tabbarStore = useTabbarStore();
+
+function refreshAll() {
+  tabbarStore.cachedTabs.clear();
+  refresh();
+}
+
 // 语言更新后,刷新页面
 // i18n.global.locale会在preference.app.locale变更之后才会更新,因此watchpreference.app.locale是不合适的,刷新页面时可能语言配置尚未完全加载完成
-watch(i18n.global.locale, refresh, { flush: 'post' });
+watch(i18n.global.locale, refreshAll, { flush: 'post' });
+
+// 时区更新后,刷新页面
+watch(() => timezoneStore.timezone, refreshAll, { flush: 'post' });
 
 const slots: SetupContext['slots'] = useSlots();
 const headerSlots = computed(() => {

+ 6 - 0
packages/locales/src/langs/en-US/ui.json

@@ -54,6 +54,12 @@
     "copy": "Copy",
     "copied": "Copied"
   },
+  "crop": {
+    "title": "Image Cropping",
+    "confirm": "Crop",
+    "cancel": "Cancel cropping",
+    "errorTip": "Cropping error"
+  },
   "fallback": {
     "pageNotFound": "Oops! Page Not Found",
     "pageNotFoundDesc": "Sorry, we couldn't find the page you were looking for.",

+ 6 - 0
packages/locales/src/langs/zh-CN/ui.json

@@ -54,6 +54,12 @@
     "copy": "复制",
     "copied": "已复制"
   },
+  "crop": {
+    "title": "图片裁剪",
+    "confirm": "裁剪",
+    "cancel": "取消裁剪",
+    "errorTip": "裁剪错误"
+  },
   "fallback": {
     "pageNotFound": "哎呀!未找到页面",
     "pageNotFoundDesc": "抱歉,我们无法找到您要找的页面。",

+ 173 - 34
playground/src/adapter/component/index.ts

@@ -3,6 +3,8 @@
  * 可用于 vben-form、vben-modal、vben-drawer 等组件使用,
  */
 
+/* eslint-disable vue/one-component-per-file */
+
 import type {
   UploadChangeParam,
   UploadFile,
@@ -24,12 +26,17 @@ import {
   watch,
 } from 'vue';
 
-import { ApiComponent, globalShareState, IconPicker } from '@vben/common-ui';
+import {
+  ApiComponent,
+  globalShareState,
+  IconPicker,
+  VCropper,
+} from '@vben/common-ui';
 import { IconifyIcon } from '@vben/icons';
 import { $t } from '@vben/locales';
 import { isEmpty } from '@vben/utils';
 
-import { message, notification } from 'ant-design-vue';
+import { message, Modal, notification } from 'ant-design-vue';
 
 const AutoComplete = defineAsyncComponent(
   () => import('ant-design-vue/es/auto-complete'),
@@ -99,7 +106,6 @@ const withDefaultPlaceholder = <T extends Component>(
         $t(`ui.placeholder.${type}`);
       // 透传组件暴露的方法
       const innerRef = ref();
-      // const publicApi: Recordable<any> = {};
       expose(
         new Proxy(
           {},
@@ -109,14 +115,6 @@ const withDefaultPlaceholder = <T extends Component>(
           },
         ),
       );
-      // const instance = getCurrentInstance();
-      // instance?.proxy?.$nextTick(() => {
-      //   for (const key in innerRef.value) {
-      //     if (typeof innerRef.value[key] === 'function') {
-      //       publicApi[key] = innerRef.value[key];
-      //     }
-      //   }
-      // });
       return () =>
         h(
           component,
@@ -128,6 +126,33 @@ const withDefaultPlaceholder = <T extends Component>(
 };
 
 const withPreviewUpload = () => {
+  // 检查是否为图片文件的辅助函数
+  const isImageFile = (file: UploadFile): boolean => {
+    const imageExtensions = new Set([
+      'bmp',
+      'gif',
+      'jpeg',
+      'jpg',
+      'png',
+      'svg',
+      'webp',
+    ]);
+    if (file.url) {
+      try {
+        const pathname = new URL(file.url, 'http://localhost').pathname;
+        const ext = pathname.split('.').pop()?.toLowerCase();
+        return ext ? imageExtensions.has(ext) : false;
+      } catch {
+        const ext = file.url?.split('.').pop()?.toLowerCase();
+        return ext ? imageExtensions.has(ext) : false;
+      }
+    }
+    if (!file.type) {
+      const ext = file.name?.split('.').pop()?.toLowerCase();
+      return ext ? imageExtensions.has(ext) : false;
+    }
+    return file.type.startsWith('image/');
+  };
   // 创建默认的上传按钮插槽
   const createDefaultSlotsWithUpload = (
     listType: string,
@@ -162,27 +187,6 @@ const withPreviewUpload = () => {
     visible: Ref<boolean>,
     fileList: Ref<UploadProps['fileList']>,
   ) => {
-    // 检查是否为图片文件的辅助函数
-    const isImageFile = (file: UploadFile): boolean => {
-      const imageExtensions = new Set([
-        'bmp',
-        'gif',
-        'jpeg',
-        'jpg',
-        'png',
-        'webp',
-      ]);
-      if (file.url) {
-        const ext = file.url?.split('.').pop()?.toLowerCase();
-        return ext ? imageExtensions.has(ext) : false;
-      }
-      if (!file.type) {
-        const ext = file.name?.split('.').pop()?.toLowerCase();
-        return ext ? imageExtensions.has(ext) : false;
-      }
-      return file.type.startsWith('image/');
-    };
-
     // 如果当前文件不是图片,直接打开
     if (!isImageFile(file)) {
       if (file.url) {
@@ -268,6 +272,107 @@ const withPreviewUpload = () => {
 
     render(h(PreviewWrapper), container);
   };
+
+  // 图片裁剪操作
+  const cropImage = (file: File, aspectRatio: string | undefined) => {
+    return new Promise((resolve, reject) => {
+      const container: HTMLElement | null = document.createElement('div');
+      document.body.append(container);
+
+      // 用于追踪组件是否已卸载
+      let isUnmounted = false;
+      let objectUrl: null | string = null;
+
+      const open = ref<boolean>(true);
+      const cropperRef = ref<InstanceType<typeof VCropper> | null>(null);
+
+      const closeModal = () => {
+        open.value = false;
+        // 延迟清理,确保动画完成
+        setTimeout(() => {
+          if (!isUnmounted && container) {
+            if (objectUrl) {
+              URL.revokeObjectURL(objectUrl);
+            }
+            isUnmounted = true;
+            render(null, container);
+            container.remove();
+          }
+        }, 300);
+      };
+
+      const CropperWrapper = {
+        setup() {
+          return () => {
+            if (isUnmounted) return null;
+            if (!objectUrl) {
+              objectUrl = URL.createObjectURL(file);
+            }
+            return h(
+              Modal,
+              {
+                open: open.value,
+                title: $t('ui.crop.title'),
+                centered: true,
+                width: 548,
+                keyboard: false,
+                maskClosable: false,
+                closable: false,
+                cancelText: $t('common.cancel'),
+                okText: $t('ui.crop.confirm'),
+                destroyOnClose: true,
+                onOk: async () => {
+                  const cropper = cropperRef.value;
+                  if (!cropper) {
+                    reject(new Error('Cropper not found'));
+                    closeModal();
+                    return;
+                  }
+                  try {
+                    const dataUrl = await cropper.getCropImage();
+                    resolve(dataUrl);
+                  } catch {
+                    reject(new Error($t('ui.crop.errorTip')));
+                  } finally {
+                    closeModal();
+                  }
+                },
+                onCancel() {
+                  resolve('');
+                  closeModal();
+                },
+              },
+              () =>
+                h(VCropper, {
+                  ref: (ref: any) => (cropperRef.value = ref),
+                  img: objectUrl as string,
+                  aspectRatio,
+                }),
+            );
+          };
+        },
+      };
+
+      render(h(CropperWrapper), container);
+    });
+  };
+
+  const base64ToBlob = (base64: Base64URLString) => {
+    try {
+      const [typeStr, encodeStr] = base64.split(',');
+      if (!typeStr || !encodeStr) return;
+      const mime = typeStr.match(/:(.*?);/)?.[1];
+      const raw = window.atob(encodeStr);
+      const rawLength = raw.length;
+      const uInt8Array = new Uint8Array(rawLength);
+      for (let i = 0; i < rawLength; ++i) {
+        uInt8Array[i] = raw.codePointAt(i) as number;
+      }
+      return new Blob([uInt8Array], { type: mime });
+    } catch {
+      return undefined;
+    }
+  };
   return defineComponent({
     name: Upload.name,
     emits: ['update:modelValue'],
@@ -285,16 +390,50 @@ const withPreviewUpload = () => {
         attrs?.fileList || attrs?.['file-list'] || [],
       );
 
-      const handleBeforeUpload = (file: UploadFile) => {
+      const handleBeforeUpload = async (
+        file: UploadFile,
+        originFileList: Array<File>,
+      ) => {
         if (attrs.maxSize && (file.size || 0) / 1024 / 1024 > attrs.maxSize) {
           message.error($t('ui.formRules.sizeLimit', [attrs.maxSize]));
           file.status = 'removed';
           return false;
         }
+        // 多选或者非图片不唤起裁剪框
+        if (
+          attrs.crop &&
+          !attrs.multiple &&
+          originFileList[0] &&
+          isImageFile(file)
+        ) {
+          file.status = 'removed';
+          // antd Upload组件问题 file参数获取的是UploadFile类型对象无法取到File类型 所以通过originFileList[0]获取
+          const base64 = await cropImage(originFileList[0], attrs.aspectRatio);
+          return new Promise((resolve, reject) => {
+            if (!base64) {
+              return reject(new Error($t('ui.crop.cancel')));
+            }
+            const blob = base64ToBlob(base64 as string);
+            if (!blob) {
+              return reject(new Error($t('ui.crop.errorTip')));
+            }
+            resolve(blob);
+          });
+        }
+
         return attrs.beforeUpload?.(file) ?? true;
       };
 
-      const handleChange = async (event: UploadChangeParam) => {
+      const handleChange = (event: UploadChangeParam) => {
+        try {
+          // 行内写法 handleChange: (event) => {}
+          attrs.handleChange?.(event);
+          // template写法 @handle-change="(event) => {}"
+          attrs.onHandleChange?.(event);
+        } catch (error) {
+          // Avoid breaking internal v-model sync on user handler errors
+          console.error(error);
+        }
         fileList.value = event.fileList.filter(
           (file) => file.status !== 'removed',
         );

+ 4 - 0
playground/src/locales/langs/en-US/examples.json

@@ -23,6 +23,7 @@
     "upload-error": "Partial file upload failed",
     "upload-urls": "Urls after file upload",
     "file": "file",
+    "crop-image": "Crop image",
     "upload-image": "Click to upload image"
   },
   "vxeTable": {
@@ -75,5 +76,8 @@
   },
   "function": {
     "contentMenu": "Content Menu"
+  },
+  "cropper": {
+    "title": "Cropper"
   }
 }

+ 4 - 0
playground/src/locales/langs/zh-CN/examples.json

@@ -26,6 +26,7 @@
     "upload-error": "部分文件上传失败",
     "upload-urls": "文件上传后的网址",
     "file": "文件",
+    "crop-image": "裁剪图片",
     "upload-image": "点击上传图片"
   },
   "vxeTable": {
@@ -75,5 +76,8 @@
   },
   "function": {
     "contentMenu": "上下文菜单"
+  },
+  "cropper": {
+    "title": "图片裁剪"
   }
 }

+ 2 - 2
playground/src/router/guard.ts

@@ -108,9 +108,9 @@ function setupAccessGuard(router: Router) {
     let redirectPath: string;
     if (from.query.redirect) {
       redirectPath = from.query.redirect as string;
-    } else if (to.path === preferences.app.defaultHomePath) {
+    } else if (to.fullPath === preferences.app.defaultHomePath) {
       redirectPath = preferences.app.defaultHomePath;
-    } else if (userInfo.homePath && to.path === userInfo.homePath) {
+    } else if (userInfo.homePath && to.fullPath === userInfo.homePath) {
       redirectPath = userInfo.homePath;
     } else {
       redirectPath = to.fullPath;

+ 9 - 0
playground/src/router/routes/modules/examples.ts

@@ -337,6 +337,15 @@ const routes: RouteRecordRaw[] = [
           title: $t('examples.function.contentMenu'),
         },
       },
+      {
+        name: 'CropperDemo',
+        path: '/examples/cropper',
+        component: () => import('#/views/examples/cropper/index.vue'),
+        meta: {
+          icon: 'mdi:crop',
+          title: $t('examples.cropper.title'),
+        },
+      },
     ],
   },
 ];

+ 138 - 0
playground/src/views/examples/cropper/index.vue

@@ -0,0 +1,138 @@
+<script lang="ts" setup>
+import type { UploadChangeParam } from 'ant-design-vue';
+
+import { ref } from 'vue';
+
+import { Page, VCropper } from '@vben/common-ui';
+
+import { Button, Card, Select, Upload } from 'ant-design-vue';
+
+const options = [
+  { label: '1:1', value: '1:1' },
+  { label: '16:9', value: '16:9' },
+  { label: '不限制', value: '' },
+];
+
+const cropperRef = ref<InstanceType<typeof VCropper>>();
+
+const cropLoading = ref(false);
+const validAspectRatio = ref<string | undefined>('1:1');
+const imgUrl = ref('');
+const cropperImg = ref();
+
+const selectImgFile = (event: UploadChangeParam) => {
+  const file = event.fileList[0]?.originFileObj;
+  if (!file) return;
+
+  if (!file.type.startsWith('image/')) {
+    console.error('请上传图片文件');
+    return;
+  }
+
+  const reader = new FileReader();
+  reader.addEventListener('load', (e) => {
+    imgUrl.value = e.target?.result as string;
+  });
+  reader.addEventListener('error', () => {
+    console.error('Failed to read file');
+  });
+
+  reader.readAsDataURL(file);
+};
+
+const cropImage = async () => {
+  if (!cropperRef.value) return;
+  cropLoading.value = true;
+  try {
+    cropperImg.value = await cropperRef.value.getCropImage();
+  } catch (error) {
+    console.error('图片裁剪失败:', error);
+  } finally {
+    cropLoading.value = false;
+  }
+};
+
+/**
+ * 下载图片
+ */
+const downloadImage = () => {
+  if (!cropperImg.value) return;
+
+  const link = document.createElement('a');
+  link.download = `cropped-image-${Date.now()}.png`;
+  link.href = cropperImg.value;
+  link.click();
+};
+</script>
+<template>
+  <Page
+    title="VCropper 图片裁剪"
+    description="VCropper是一个图片裁剪组件,提供基础的图片裁剪功能。"
+  >
+    <Card>
+      <div class="image-cropper-container">
+        <div class="cropper-ratio-display">
+          <label class="ratio-label">当前裁剪比例:</label>
+          <Select
+            class="w-24"
+            v-model:value="validAspectRatio"
+            :options="options"
+          />
+          <Upload
+            :max-count="1"
+            :show-upload-list="false"
+            :before-upload="() => false"
+            @change="selectImgFile"
+          >
+            <Button>上传图片</Button>
+          </Upload>
+        </div>
+
+        <div v-if="imgUrl" class="cropper-main-wrapper">
+          <VCropper
+            ref="cropperRef"
+            :img="imgUrl"
+            :aspect-ratio="validAspectRatio"
+            :width="600"
+            :height="600"
+          />
+
+          <!-- 操作按钮组 -->
+          <div class="cropper-btn-group">
+            <Button :loading="cropLoading" @click="cropImage" type="primary">
+              裁剪
+            </Button>
+            <Button v-if="cropperImg" @click="downloadImage" danger>
+              下载图片
+            </Button>
+          </div>
+
+          <!-- 裁剪预览 -->
+          <img
+            v-if="cropperImg"
+            class="h-full w-80"
+            :src="cropperImg"
+            alt="裁剪预览"
+          />
+        </div>
+      </div>
+    </Card>
+  </Page>
+</template>
+<style scoped>
+/* 比例展示区域 */
+.cropper-ratio-display {
+  @apply my-2.5 flex items-center justify-start gap-4;
+}
+
+.ratio-label {
+  @apply text-sm font-medium;
+}
+/* 主裁剪区域 */
+.cropper-main-wrapper {
+  @apply flex items-center gap-4;
+}
+.cropper-btn-group {
+  @apply flex flex-col gap-2;
+}
+</style>

+ 49 - 0
playground/src/views/examples/form/basic.vue

@@ -348,6 +348,15 @@ const [BaseForm, baseFormApi] = useVbenForm({
         showUploadList: true,
         // 上传列表的内建样式,支持四种基本样式 text, picture, picture-card 和 picture-circle
         listType: 'picture-card',
+        // onChange事件已被重写,如需自定义请在此基础上扩展
+        handleChange: ({ file }: { file: UploadFile }) => {
+          const { name, status } = file;
+          if (status === 'done') {
+            message.success(`${name} ${$t('examples.form.upload-success')}`);
+          } else if (status === 'error') {
+            message.error(`${name} ${$t('examples.form.upload-fail')}`);
+          }
+        },
       },
       fieldName: 'files',
       label: $t('examples.form.file'),
@@ -358,6 +367,28 @@ const [BaseForm, baseFormApi] = useVbenForm({
       },
       rules: 'selectRequired',
     },
+    {
+      component: 'Upload',
+      componentProps: {
+        accept: '.png,.jpg,.jpeg',
+        customRequest: upload_file,
+        maxCount: 1,
+        maxSize: 2,
+        listType: 'picture-card',
+        // 是否启用图片裁剪(多选或者非图片不唤起裁剪框)
+        crop: true,
+        // 裁剪比例
+        aspectRatio: '1:1',
+      },
+      fieldName: 'cropImage',
+      label: $t('examples.form.crop-image'),
+      renderComponentContent: () => {
+        return {
+          default: () => $t('examples.form.upload-image'),
+        };
+      },
+      rules: 'selectRequired',
+    },
   ],
   // 大屏一行显示3个,中屏一行显示2个,小屏一行显示1个
   wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
@@ -365,13 +396,20 @@ const [BaseForm, baseFormApi] = useVbenForm({
 
 function onSubmit(values: Record<string, any>) {
   const files = toRaw(values.files) as UploadFile[];
+  const cropImage = (toRaw(values.cropImage) ?? []) as UploadFile[];
   const doneFiles = files.filter((file) => file.status === 'done');
   const failedFiles = files.filter((file) => file.status !== 'done');
+  const doneCrop = cropImage.filter((file) => file.status === 'done');
+  const failedCrop = cropImage.filter((file) => file.status !== 'done');
 
   const msg = [
     ...doneFiles.map((file) => file.response?.url || file.url),
     ...failedFiles.map((file) => file.name),
   ].join(', ');
+  const msgCrop = [
+    ...doneCrop.map((file) => file.response?.url || file.url),
+    ...failedCrop.map((file) => file.name),
+  ].join(', ');
 
   if (failedFiles.length === 0) {
     message.success({
@@ -383,8 +421,19 @@ function onSubmit(values: Record<string, any>) {
     });
     return;
   }
+  if (doneCrop.length > 0 && failedCrop.length === 0) {
+    message.success({
+      content: `${$t('examples.form.upload-urls')}: ${msgCrop}`,
+    });
+  } else if (failedCrop.length > 0) {
+    message.error({
+      content: `${$t('examples.form.upload-error')}: ${msgCrop}`,
+    });
+    return;
+  }
   // 如果需要可提交前替换为需要的urls
   values.files = doneFiles.map((file) => file.response?.url || file.url);
+  values.cropImage = doneCrop.map((file) => file.response?.url || file.url);
   message.success({
     content: `form values: ${JSON.stringify(values)}`,
   });