叶静 2 minggu lalu
induk
melakukan
36c515089e

+ 46 - 11
src/app/shop/admin/goods/goods/edit.vue

@@ -112,7 +112,9 @@
               <el-col :span="10">
                 <el-form-item :label="t('modules.goods.mainImage')" prop="image" required>
                   <sa-upload-image v-model="formData.image" :max-count="1" :accept="['jpg', 'jpeg', 'png']"
-                    :max-size="5" :direct-upload="true" :size="100" :placeholder="t('form.uploadMainImage')" />
+                    :max-size="5" :direct-upload="true" :size="100" :placeholder="t('form.uploadMainImage')"
+                    :aspect-ratio="1" :enforce-aspect-ratio="true" />
+                  <div class="form-tip">{{ t('modules.goods.mainImageRatioTip') }}</div>
                 </el-form-item>
 
                 <el-form-item :label="t('modules.goods.carouselImages')" prop="sliderImage" required>
@@ -339,7 +341,7 @@
 </template>
 
 <script setup>
-import { ref, reactive, onMounted, computed } from 'vue';
+import { ref, reactive, onMounted, computed, nextTick } from 'vue';
 import { ElMessage } from 'element-plus';
 import { CircleCheck, CircleCloseFilled, Edit, Loading } from '@element-plus/icons-vue';
 import { useI18n } from 'vue-i18n';
@@ -927,6 +929,11 @@ const continueAction = () => {
 
 // 关闭弹窗
 const closeModal = () => {
+  // 清理表单状态,避免影响列表显示
+  if (!isEdit.value) {
+    resetForm();
+  }
+
   emit('modalCallBack', {
     event: 'confirm',
   });
@@ -934,16 +941,33 @@ const closeModal = () => {
 
 // 重置表单
 const resetForm = () => {
+  // 重置所有表单字段到初始状态
   Object.assign(formData, {
-    title: '',
-    category_ids: [],
-    brand: '',
-    goods_no: '',
-    supplier: '',
-    images: [],
-    white_bg_image: [],
-    detail_images: [],
-    has_spec: true,
+    // 基本信息
+    id: '',
+    cateId: '',
+    storeName: '',
+    keyword: '',
+    itemBrand: '',
+    storeInfo: '',
+    itemNumber: '',
+    price: '',
+    otPrice: '',
+    stock: '',
+    stockThreshold: '',
+    isShow: 0, // 新增默认下架
+    itemSupplier: '',
+    sort: 0,
+    cost: '',
+    vipPrice: '',
+
+    // 图片字段
+    image: [],
+    sliderImage: [],
+    flatPattern: [],
+
+    // 规格字段
+    specType: 1,
     skus: [
       {
         id: 0,
@@ -956,6 +980,17 @@ const resetForm = () => {
     ],
     sku_prices: [],
   });
+
+  // 重置表单验证状态
+  nextTick(() => {
+    basicFormRef.value?.clearValidate();
+    attrFormRef.value?.clearValidate();
+  });
+
+  // 重置其他状态
+  countId.value = 2;
+  isResetSku.value = 0;
+  forceUpdateKey.value++;
 };
 
 // 上一步

+ 37 - 7
src/app/shop/admin/goods/goods/index.vue

@@ -77,11 +77,11 @@
               <el-table-column prop="itemNumber" :label="t('modules.goods.goodsNumber')" min-width="100"
                 align="center"></el-table-column>
               <el-table-column prop="stock" :label="t('form.stock')" min-width="100" align="center"></el-table-column>
-              <el-table-column sortable="custom" prop="sales" :label="t('modules.goods.sales')" min-width="100"
+              <el-table-column sortable="custom" prop="ficti" :label="t('modules.goods.sales')" min-width="100"
                 align="center"></el-table-column>
               <el-table-column :label="t('modules.goods.goodsSupplier')" min-width="120" align="center">
                 <template #default="scope">
-                  <div>{{ scope.row.itemSupplier || t('modules.goods.defaultSupplier') }}</div>
+                  <div>{{ scope.row.itemSupplier }}</div>
                 </template>
               </el-table-column>
               <el-table-column :label="t('common.actions')" min-width="150" fixed="right">
@@ -226,6 +226,11 @@ const handleSearch = (searchParams) => {
 
 // 重置处理
 const handleReset = () => {
+  // 清除排序参数
+  currentSearchParams.value = {
+    ...currentSearchParams.value,
+    sortway: 0 // 重置为默认排序
+  };
   // 由于使用了 v-model,currentSearchParams 会自动清空
   // 直接调用 getData,会自动使用当前的搜索条件和状态
   getData(1, {});
@@ -277,12 +282,18 @@ async function getData(page, searchParams = null, refreshStatusCounts = true) {
       break;
   }
 
-  const { code, data } = await api.goods.list({
+  // 构建最终的请求参数
+  const requestParams = {
     page: pageData.page,
     size: pageData.size,
     ...finalSearchParams,
     ...statusParams,
-  });
+  };
+
+  // 调试:打印请求参数
+  console.log('商品列表请求参数:', requestParams);
+
+  const { code, data } = await api.goods.list(requestParams);
   if (code == '200') {
     table.data = data.list;
     pageData.page = data.pageNum;
@@ -304,9 +315,28 @@ function handlePageChange(page) {
 
 // table 字段排序
 function fieldFilter({ prop, order }) {
-  table.order = order == 'ascending' ? 'asc' : 'desc';
-  table.sort = prop;
-  getData(null, {}, false); // 排序时不需要刷新状态数量
+  // 根据排序字段和方向设置 sortway 参数
+  let sortway = 0; // 默认排序
+
+  if (prop === 'price') {
+    // 价格排序
+    sortway = order === 'ascending' ? 1 : 2; // 1=价格升序, 2=价格降序
+  } else if (prop === 'ficti') {
+    // 销量排序
+    sortway = order === 'ascending' ? 3 : 4; // 3=销量升序, 4=销量降序
+  }
+
+  // 调试:打印排序信息
+  console.log('表格排序:', { prop, order, sortway });
+
+  // 更新当前搜索参数中的 sortway
+  currentSearchParams.value = {
+    ...currentSearchParams.value,
+    sortway
+  };
+
+  // 重新获取数据
+  getData(null, null, false); // 排序时不需要刷新状态数量,使用更新后的搜索参数
 }
 
 // table 批量选择

+ 2 - 2
src/app/shop/admin/goods/goods/select.vue

@@ -80,7 +80,7 @@ const props = defineProps(['modal']);
 
 // 搜索字段配置
 const searchFields = reactive({
-  title: {
+  storeName: {
     type: 'input',
     get label() {
       return t('modules.goods.goodsName');
@@ -94,7 +94,7 @@ const searchFields = reactive({
 
 // 默认搜索值
 const defaultSearchValues = reactive({
-  title: '',
+  storeName: '',
 });
 
 // 当前搜索条件 - 使用 ref 支持双向绑定

+ 30 - 7
src/app/shop/admin/marketing/group/index.vue

@@ -78,14 +78,18 @@
               <el-table-column :label="t('common.actions')" fixed="right">
                 <template #default="scope">
                   <div class="sa-flex">
-                    <el-button link type="primary" @click="editRow(scope.row)">{{ t('common.edit') }}</el-button>
-                    <el-button link class="sa-m-l-12" type="success" @click="setGoods(scope.row)">{{
-                      t('modules.marketing.setGoods') }}</el-button>
+                    <el-button v-if="scope.row.activeState !== 2" link type="primary" @click="editRow(scope.row)">
+                      {{ t('common.edit') }}
+                    </el-button>
+                    <el-button v-if="scope.row.activeState !== 2" link class="sa-m-l-12" type="success"
+                      @click="setGoods(scope.row)">
+                      {{ t('modules.marketing.setGoods') }}
+                    </el-button>
                     <el-popconfirm width="fit-content" :confirm-button-text="t('common.confirm')"
                       :cancel-button-text="t('common.cancel')" :title="t('common.confirmDelete')"
                       @confirm="deleteRow(scope.row.id)">
                       <template #reference>
-                        <el-button link class="sa-m-l-12" type="danger">{{ t('common.delete') }}</el-button>
+                        <el-button link type="danger">{{ t('common.delete') }}</el-button>
                       </template>
                     </el-popconfirm>
                   </div>
@@ -330,16 +334,35 @@ async function updateActivityGoods(activityId, goodsList) {
     // 使用 CRUD 的 edit 方法更新活动商品
     const productIds = goodsList.map((item) => ({ productId: item.id }));
 
-    const { code, message } = await api.group.addActivityProduct(activityId, {
+    const { code, data, message } = await api.group.addActivityProduct(activityId, {
       list: productIds,
     });
 
     if (code === '200') {
-      ElMessage.success(t('message.updateSuccess'));
-      getData(); // 刷新列表
+      // 检查是否有商品冲突
+      if (data && typeof data === 'string' && data.trim() !== '') {
+        // 处理商品冲突情况,直接使用返回的字符串
+        const conflictMessage = t('modules.marketing.goodsConflictError', {
+          ids: data
+        });
+
+        await ElMessageBox.alert(conflictMessage, t('common.warning'), {
+          confirmButtonText: t('common.confirm'),
+          type: 'warning',
+          showClose: false,
+          closeOnClickModal: false,
+          closeOnPressEscape: false,
+        });
+      } else {
+        ElMessage.success(t('message.updateSuccess'));
+        getData(); // 刷新列表
+      }
+    } else {
+      ElMessage.error(message || t('modules.marketing.setGoodsFailed'));
     }
   } catch (error) {
     console.error('设置商品失败:', error);
+    ElMessage.error(t('modules.marketing.setGoodsFailed'));
   }
 }
 

+ 9 - 3
src/locales/en-US/index.json

@@ -28,6 +28,7 @@
     "submitting": "Submitting...",
     "memo": "Memo",
     "tip": "Tip",
+    "warning": "Warning",
     "status": "Status",
     "detail": "Detail",
     "select": "Please select",
@@ -475,7 +476,9 @@
     "currentOrder": "This is the current order",
     "passwordChangeSuccess": "Password changed successfully, please login again",
     "editFeatureNotDeveloped": "Edit feature is under development",
-    "goodsAttributeSaveSuccess": "Product attributes saved successfully"
+    "goodsAttributeSaveSuccess": "Product attributes saved successfully",
+    "confirmBatchDelete": "Are you sure you want to delete the selected goods?",
+    "invalidAspectRatio": "Image aspect ratio does not meet requirements, please upload {ratio} ratio image"
   },
   "placeholders": {
     "inputOrderNo": "Please enter order number",
@@ -1175,7 +1178,8 @@
       "goodsStockRequired": "Please enter goods stock",
       "goodsSupplierRequired": "Please enter goods supplier",
       "goodsImageRequired": "Please upload goods image",
-      "goodsDetailImageRequired": "Please upload goods detail image"
+      "goodsDetailImageRequired": "Please upload goods detail image",
+      "mainImageRatioTip": "Image must be in 1:1 square aspect ratio"
     },
     "order": {
       "orderManagement": "Order Management",
@@ -1591,7 +1595,9 @@
       "startTimeRequired": "Please select start time",
       "endTimeRequired": "Please select end time",
       "groupSizeRequired": "Please select group size",
-      "countdownTimeRequired": "Please select countdown time"
+      "countdownTimeRequired": "Please select countdown time",
+      "goodsConflictError": "Goods with ID {ids} already exist in other activities, failed to add",
+      "setGoodsFailed": "Failed to set goods"
     }
   }
 }

+ 20 - 3
src/locales/zh-CN/index.json

@@ -41,6 +41,7 @@
     "submitting": "提交中...",
     "memo": "备注",
     "tip": "提示",
+    "warning": "警告",
     "status": "状态",
     "online": "上架",
     "offline": "下架",
@@ -365,6 +366,7 @@
     "selectLoginPermission": "请选择登录权限",
     "selectOrderPermission": "请选择下单权限",
     "selectWithdrawPermission": "请选择提现权限",
+    "selectSortBy": "请选择排序方式",
     "inputUserPhone": "请输入用户手机",
     "inputUserNickname": "请输入用户昵称",
     "inputNewPassword": "请输入新密码",
@@ -473,7 +475,10 @@
     "currentOrder": "这是当前订单",
     "passwordChangeSuccess": "密码修改成功,请重新登录",
     "editFeatureNotDeveloped": "编辑功能待开发",
-    "goodsAttributeSaveSuccess": "商品属性保存成功"
+    "goodsAttributeSaveSuccess": "商品属性保存成功",
+    "confirmBatchDelete": "确定要删除选中的商品吗?",
+    "invalidAspectRatio": "图片比例不符合要求,请上传{ratio}比例的图片",
+    "confirmBatchDelete": "确定要删除选中的商品吗?"
   },
   "placeholders": {
     "inputOrderNo": "请输入订单号",
@@ -1176,7 +1181,17 @@
       "goodsStockRequired": "请填写商品库存",
       "goodsSupplierRequired": "请填写商品供应商",
       "goodsImageRequired": "请上传商品主图",
-      "goodsDetailImageRequired": "请上传商品详情图"
+      "goodsDetailImageRequired": "请上传商品详情图",
+      "sortBy": "排序方式",
+      "sortDefault": "默认排序",
+      "sortPriceAsc": "价格升序",
+      "sortPriceDesc": "价格降序",
+      "sortSalesAsc": "销量升序",
+      "sortSalesDesc": "销量降序",
+      "batchOnSale": "批量上架",
+      "batchOffSale": "批量下架",
+      "batchDelete": "批量删除",
+      "mainImageRatioTip": "图片必须为1:1正方形比例"
     },
     "order": {
       "orderManagement": "订单管理",
@@ -1592,7 +1607,9 @@
       "startTimeRequired": "请选择开始时间",
       "endTimeRequired": "请选择结束时间",
       "groupSizeRequired": "请选择成团默认人数",
-      "countdownTimeRequired": "请选择开团倒计时结束"
+      "countdownTimeRequired": "请选择开团倒计时结束",
+      "goodsConflictError": "商品ID为 {ids} 的商品已存在于其他活动中,添加失败",
+      "setGoodsFailed": "设置商品失败"
     }
   }
 }

+ 533 - 526
src/sheep/components/sa-uploader/sa-upload-image.global.vue

@@ -2,55 +2,39 @@
   <div class="sa-upload-image">
     <div class="upload-container sa-flex sa-flex-wrap">
       <!-- 已上传的图片列表 -->
-      <sa-draggable
-        v-if="imageList.length > 0"
-        v-model="imageList"
-        :animation="300"
-        handle=".sortable-drag"
-        item-key="url"
-        @end="handleSort"
-        class="sa-flex sa-flex-wrap"
-      >
+      <sa-draggable v-if="imageList.length > 0" v-model="imageList" :animation="300" handle=".sortable-drag"
+        item-key="url" @end="handleSort" class="sa-flex sa-flex-wrap">
         <template #item="{ element, index }">
-          <div
-            class="image-item mr-2px"
-            :class="{ 'compact-mode': compact }"
-            :style="{ width: size + 'px', height: size + 'px' }"
-          >
-            <el-image
-              :src="element"
-              fit="cover"
-              :preview-src-list="compact ? [] : imageList"
-              :initial-index="compact ? 0 : index"
-              :preview-teleported="true"
+          <div class="image-item mr-2px" :class="{ 'compact-mode': compact }"
+            :style="{ width: size + 'px', height: size + 'px' }">
+            <el-image :src="element" fit="cover" :preview-src-list="compact ? [] : imageList"
+              :initial-index="compact ? 0 : index" :preview-teleported="true"
               @click="compact ? previewImage(element, index) : null"
-              :style="{ cursor: compact ? 'pointer' : 'default' }"
-            >
+              :style="{ cursor: compact ? 'pointer' : 'default' }">
               <template #error>
                 <div class="image-error">
-                  <el-icon><Picture /></el-icon>
+                  <el-icon>
+                    <Picture />
+                  </el-icon>
                 </div>
               </template>
             </el-image>
             <div class="image-mask" :class="{ 'compact-mask': compact }">
               <template v-if="compact">
-                <el-icon @click.stop="removeImage(index)" :title="t('common.delete')" size="12"
-                  ><Delete
-                /></el-icon>
+                <el-icon @click.stop="removeImage(index)" :title="t('common.delete')" size="12">
+                  <Delete />
+                </el-icon>
               </template>
               <template v-else>
-                <el-icon class="sortable-drag" :title="t('common.dragSort')" size="24"
-                  ><Rank
-                /></el-icon>
-                <el-icon
-                  @click="previewImage(element, index)"
-                  :title="t('common.preview')"
-                  size="24"
-                  ><ZoomIn
-                /></el-icon>
-                <el-icon @click="removeImage(index)" :title="t('common.delete')" size="24"
-                  ><Delete
-                /></el-icon>
+                <el-icon class="sortable-drag" :title="t('common.dragSort')" size="24">
+                  <Rank />
+                </el-icon>
+                <el-icon @click="previewImage(element, index)" :title="t('common.preview')" size="24">
+                  <ZoomIn />
+                </el-icon>
+                <el-icon @click="removeImage(index)" :title="t('common.delete')" size="24">
+                  <Delete />
+                </el-icon>
               </template>
             </div>
           </div>
@@ -58,17 +42,10 @@
       </sa-draggable>
 
       <!-- 上传按钮 -->
-      <div
-        v-if="!maxCount || imageList.length < maxCount"
-        class="upload-wrapper"
-        :style="{ width: size + 'px', height: size + 'px' }"
-      >
-        <div
-          class="upload-trigger"
-          :class="{ 'compact-trigger': compact, 'is-uploading': uploading }"
-          :style="{ width: size + 'px', height: size + 'px' }"
-          @click="handleUploadClick"
-        >
+      <div v-if="!maxCount || imageList.length < maxCount" class="upload-wrapper"
+        :style="{ width: size + 'px', height: size + 'px' }">
+        <div class="upload-trigger" :class="{ 'compact-trigger': compact, 'is-uploading': uploading }"
+          :style="{ width: size + 'px', height: size + 'px' }" @click="handleUploadClick">
           <el-icon class="upload-icon" :size="compact ? 16 : 24">
             <Plus />
           </el-icon>
@@ -85,561 +62,591 @@
       </div>
 
       <!-- 隐藏的文件输入框 -->
-      <input
-        ref="fileInputRef"
-        type="file"
-        :accept="acceptString"
-        :multiple="multiple"
-        style="display: none"
-        @change="handleFileSelect"
-      />
+      <input ref="fileInputRef" type="file" :accept="acceptString" :multiple="multiple" style="display: none"
+        @change="handleFileSelect" />
     </div>
 
     <!-- 提示信息 -->
     <div v-if="showTip" class="upload-tip">
       <span v-if="maxCount">{{ t('common.maxUpload', { count: maxCount }) }},</span>
-      <span v-if="accept && accept.length"
-        >{{ t('common.supportFormats', { formats: accept.join('、') }) }},</span
-      >
+      <span v-if="accept && accept.length">{{ t('common.supportFormats', { formats: accept.join('、') }) }},</span>
       <span v-if="maxSize">{{ t('common.maxFileSize', { size: maxSize }) }}</span>
     </div>
 
     <!-- 图片预览弹窗 -->
-    <el-image-viewer
-      v-if="previewVisible"
-      :url-list="previewImageList"
-      :initial-index="previewInitialIndex"
-      :infinite="false"
-      :hide-on-click-modal="true"
-      :teleported="true"
-      :z-index="3000"
-      @close="closePreview"
-    />
+    <el-image-viewer v-if="previewVisible" :url-list="previewImageList" :initial-index="previewInitialIndex"
+      :infinite="false" :hide-on-click-modal="true" :teleported="true" :z-index="3000" @close="closePreview" />
   </div>
 </template>
 
 <script>
-  import { ref, computed, watch } from 'vue';
-  import { ElMessage, ElImageViewer } from 'element-plus';
-  import { Picture, Plus, Rank, ZoomIn, Delete } from '@element-plus/icons-vue';
-  import SaDraggable from 'vuedraggable';
-  import adminApi from '@/app/admin/api';
-
-  export default {
-    name: 'SaUploadImage',
-    components: {
-      Picture,
-      Plus,
-      Rank,
-      ZoomIn,
-      Delete,
-      SaDraggable,
-      ElImageViewer,
-    },
-  };
+import { ref, computed, watch } from 'vue';
+import { ElMessage, ElImageViewer } from 'element-plus';
+import { Picture, Plus, Rank, ZoomIn, Delete } from '@element-plus/icons-vue';
+import SaDraggable from 'vuedraggable';
+import adminApi from '@/app/admin/api';
+
+export default {
+  name: 'SaUploadImage',
+  components: {
+    Picture,
+    Plus,
+    Rank,
+    ZoomIn,
+    Delete,
+    SaDraggable,
+    ElImageViewer,
+  },
+};
 </script>
 
 <script setup>
-  import { useI18n } from 'vue-i18n';
-
-  const { t } = useI18n();
-
-  const props = defineProps({
-    modelValue: {
-      type: Array,
-      default: () => [],
-    },
-    // 是否直传,默认为true
-    directUpload: {
-      type: Boolean,
-      default: true,
-    },
-    // 最大上传数量
-    maxCount: {
-      type: Number,
-      default: 0,
-    },
-    // 支持的文件格式
-    accept: {
-      type: Array,
-      default: () => ['jpg', 'jpeg', 'png'],
-    },
-    // 最大文件大小(MB)
-    maxSize: {
-      type: Number,
-      default: 2,
-    },
-    // 是否支持多选
-    multiple: {
-      type: Boolean,
-      default: true,
-    },
-    // 图片尺寸
-    size: {
-      type: Number,
-      default: 100,
-    },
-    // 占位符文本
-    placeholder: {
-      type: String,
-      default: '',
-    },
-    // 是否显示提示信息
-    showTip: {
-      type: Boolean,
-      default: true,
-    },
-    // 精简模式
-    compact: {
-      type: Boolean,
-      default: false,
-    },
-  });
-
-  const emit = defineEmits(['update:modelValue', 'change']);
-
-  // 文件输入框引用
-  const fileInputRef = ref();
-
-  // 图片列表
-  const imageList = ref([...props.modelValue]);
-
-  // 上传loading状态
-  const uploading = ref(false);
-
-  // 计算接受的文件类型字符串
-  const acceptString = computed(() => {
-    return props.accept.map((type) => `.${type}`).join(',');
-  });
-
-  // 添加一个标志来防止循环更新
-  let isUpdatingFromProps = false;
-  let isUpdatingFromInternal = false;
-
-  // 数组比较函数
-  const arraysEqual = (a, b) => {
-    if (a.length !== b.length) return false;
-    return a.every((val, index) => val === b[index]);
-  };
-
-  // 监听外部数据变化
-  watch(
-    () => props.modelValue,
-    (newValue) => {
-      if (isUpdatingFromInternal) return;
-
-      const currentValue = imageList.value;
-      if (!arraysEqual(newValue, currentValue)) {
-        isUpdatingFromProps = true;
-        imageList.value = [...newValue];
-        setTimeout(() => {
-          isUpdatingFromProps = false;
-        }, 0);
-      }
-    },
-    { deep: true },
-  );
-
-  // 监听内部数据变化
-  watch(
-    imageList,
-    (newValue) => {
-      if (isUpdatingFromProps) return;
-
-      if (!arraysEqual(newValue, props.modelValue)) {
-        isUpdatingFromInternal = true;
-        emit('update:modelValue', [...newValue]);
-        emit('change', [...newValue]);
-        setTimeout(() => {
-          isUpdatingFromInternal = false;
-        }, 0);
-      }
-    },
-    { deep: true },
-  );
-
-  // 处理上传点击
-  const handleUpload = () => {
-    if (props.directUpload) {
-      // 直传模式,直接打开文件选择
-      fileInputRef.value?.click();
-    } else {
-      // 非直传模式,打开文件管理弹窗
-      openFileManager();
+import { useI18n } from 'vue-i18n';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  modelValue: {
+    type: Array,
+    default: () => [],
+  },
+  // 是否直传,默认为true
+  directUpload: {
+    type: Boolean,
+    default: true,
+  },
+  // 最大上传数量
+  maxCount: {
+    type: Number,
+    default: 0,
+  },
+  // 支持的文件格式
+  accept: {
+    type: Array,
+    default: () => ['jpg', 'jpeg', 'png'],
+  },
+  // 最大文件大小(MB)
+  maxSize: {
+    type: Number,
+    default: 2,
+  },
+  // 是否支持多选
+  multiple: {
+    type: Boolean,
+    default: true,
+  },
+  // 图片尺寸
+  size: {
+    type: Number,
+    default: 100,
+  },
+  // 占位符文本
+  placeholder: {
+    type: String,
+    default: '',
+  },
+  // 是否显示提示信息
+  showTip: {
+    type: Boolean,
+    default: true,
+  },
+  // 精简模式
+  compact: {
+    type: Boolean,
+    default: false,
+  },
+  // 图片宽高比例要求 (宽/高)
+  aspectRatio: {
+    type: Number,
+    default: null,
+  },
+  // 是否强制执行宽高比例
+  enforceAspectRatio: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const emit = defineEmits(['update:modelValue', 'change']);
+
+// 文件输入框引用
+const fileInputRef = ref();
+
+// 图片列表
+const imageList = ref([...props.modelValue]);
+
+// 上传loading状态
+const uploading = ref(false);
+
+// 计算接受的文件类型字符串
+const acceptString = computed(() => {
+  return props.accept.map((type) => `.${type}`).join(',');
+});
+
+// 添加一个标志来防止循环更新
+let isUpdatingFromProps = false;
+let isUpdatingFromInternal = false;
+
+// 数组比较函数
+const arraysEqual = (a, b) => {
+  if (a.length !== b.length) return false;
+  return a.every((val, index) => val === b[index]);
+};
+
+// 监听外部数据变化
+watch(
+  () => props.modelValue,
+  (newValue) => {
+    if (isUpdatingFromInternal) return;
+
+    const currentValue = imageList.value;
+    if (!arraysEqual(newValue, currentValue)) {
+      isUpdatingFromProps = true;
+      imageList.value = [...newValue];
+      setTimeout(() => {
+        isUpdatingFromProps = false;
+      }, 0);
     }
-  };
-
-  // 处理点击事件(带上传状态检查)
-  const handleUploadClick = () => {
-    if (!uploading.value) {
-      handleUpload();
+  },
+  { deep: true },
+);
+
+// 监听内部数据变化
+watch(
+  imageList,
+  (newValue) => {
+    if (isUpdatingFromProps) return;
+
+    if (!arraysEqual(newValue, props.modelValue)) {
+      isUpdatingFromInternal = true;
+      emit('update:modelValue', [...newValue]);
+      emit('change', [...newValue]);
+      setTimeout(() => {
+        isUpdatingFromInternal = false;
+      }, 0);
     }
-  };
+  },
+  { deep: true },
+);
+
+// 处理上传点击
+const handleUpload = () => {
+  if (props.directUpload) {
+    // 直传模式,直接打开文件选择
+    fileInputRef.value?.click();
+  } else {
+    // 非直传模式,打开文件管理弹窗
+    openFileManager();
+  }
+};
 
-  // 处理文件选择
-  const handleFileSelect = async (event) => {
-    const files = Array.from(event.target.files);
+// 处理点击事件(带上传状态检查)
+const handleUploadClick = () => {
+  if (!uploading.value) {
+    handleUpload();
+  }
+};
 
-    if (!files.length) return;
+// 处理文件选择
+const handleFileSelect = async (event) => {
+  const files = Array.from(event.target.files);
 
-    // 检查数量限制
-    if (props.maxCount && imageList.value.length + files.length > props.maxCount) {
-      ElMessage.warning(t('message.maxUploadLimit', { count: props.maxCount }));
-      return;
-    }
+  if (!files.length) return;
 
-    // 验证文件
-    const validFiles = [];
-    for (const file of files) {
-      if (validateFile(file)) {
-        validFiles.push(file);
-      }
-    }
-
-    if (validFiles.length === 0) return;
+  // 检查数量限制
+  if (props.maxCount && imageList.value.length + files.length > props.maxCount) {
+    ElMessage.warning(t('message.maxUploadLimit', { count: props.maxCount }));
+    return;
+  }
 
-    // 开始上传,显示loading
-    uploading.value = true;
+  // 验证文件
+  const validFiles = [];
+  for (const file of files) {
+    if (await validateFile(file)) {
+      validFiles.push(file);
+    }
+  }
 
-    // 上传文件
-    try {
-      const uploadPromises = validFiles.map((file) => uploadFile(file));
-      const results = await Promise.all(uploadPromises);
+  if (validFiles.length === 0) return;
 
-      // 添加成功上传的图片
-      results.forEach((result) => {
-        if (result.success) {
-          imageList.value.push(result.url);
-        }
-      });
+  // 开始上传,显示loading
+  uploading.value = true;
 
-      const successCount = results.filter((r) => r.success).length;
-      if (successCount > 0) {
-        ElMessage.success(t('message.uploadSuccess', { count: successCount }));
+  // 上传文件
+  try {
+    const uploadPromises = validFiles.map((file) => uploadFile(file));
+    const results = await Promise.all(uploadPromises);
+
+    // 添加成功上传的图片
+    results.forEach((result) => {
+      if (result.success) {
+        imageList.value.push(result.url);
       }
+    });
 
-      // 如果有失败的,显示失败信息
-      const failedCount = results.length - successCount;
-      if (failedCount > 0) {
-        ElMessage.error(t('message.uploadFailed', { count: failedCount }));
-      }
-    } catch (error) {
-      ElMessage.error(t('message.uploadError') + ': ' + error.message);
-    } finally {
-      // 无论成功还是失败,都关闭loading
-      uploading.value = false;
+    const successCount = results.filter((r) => r.success).length;
+    if (successCount > 0) {
+      ElMessage.success(t('message.uploadSuccess', { count: successCount }));
     }
 
-    // 清空文件输入框
-    event.target.value = '';
-  };
-
-  // 验证文件
-  const validateFile = (file) => {
-    // 检查文件类型
-    const fileExtension = file.name.split('.').pop().toLowerCase();
-    if (!props.accept.includes(fileExtension)) {
-      ElMessage.warning(
-        t('message.unsupportedFormat', {
-          extension: fileExtension,
-          formats: props.accept.join('、'),
-        }),
-      );
-      return false;
+    // 如果有失败的,显示失败信息
+    const failedCount = results.length - successCount;
+    if (failedCount > 0) {
+      ElMessage.error(t('message.uploadFailed', { count: failedCount }));
     }
+  } catch (error) {
+    ElMessage.error(t('message.uploadError') + ': ' + error.message);
+  } finally {
+    // 无论成功还是失败,都关闭loading
+    uploading.value = false;
+  }
+
+  // 清空文件输入框
+  event.target.value = '';
+};
+
+// 验证文件
+const validateFile = async (file) => {
+  // 检查文件类型
+  const fileExtension = file.name.split('.').pop().toLowerCase();
+  if (!props.accept.includes(fileExtension)) {
+    ElMessage.warning(
+      t('message.unsupportedFormat', {
+        extension: fileExtension,
+        formats: props.accept.join('、'),
+      }),
+    );
+    return false;
+  }
 
-    // 检查文件大小
-    const fileSizeMB = file.size / 1024 / 1024;
-    if (props.maxSize && fileSizeMB > props.maxSize) {
-      ElMessage.warning(t('message.fileSizeExceeded', { size: props.maxSize }));
+  // 检查文件大小
+  const fileSizeMB = file.size / 1024 / 1024;
+  if (props.maxSize && fileSizeMB > props.maxSize) {
+    ElMessage.warning(t('message.fileSizeExceeded', { size: props.maxSize }));
+    return false;
+  }
+
+  // 检查图片宽高比例
+  if (props.aspectRatio && props.enforceAspectRatio) {
+    const isValidRatio = await validateImageAspectRatio(file, props.aspectRatio);
+    if (!isValidRatio) {
+      const ratioText = props.aspectRatio === 1 ? '1:1' : `${props.aspectRatio}:1`;
+      ElMessage.warning(t('message.invalidAspectRatio', { ratio: ratioText }));
       return false;
     }
+  }
 
-    return true;
-  };
-
-  // 上传文件
-  const uploadFile = async (file) => {
-    try {
-      var formData = new FormData();
-      formData.append('file', file);
-      const response = await adminApi.file.upload({}, formData);
-      if (response.code == '200') {
-        return {
-          success: true,
-          url: response.data,
-        };
-      } else {
-        throw new Error(response.msg || t('message.uploadFailed'));
-      }
-    } catch (error) {
-      console.error('上传文件失败:', error);
+  return true;
+};
+
+// 验证图片宽高比例
+const validateImageAspectRatio = (file, expectedRatio) => {
+  return new Promise((resolve) => {
+    const img = new Image();
+    const url = URL.createObjectURL(file);
+
+    img.onload = () => {
+      const actualRatio = img.width / img.height;
+      const tolerance = 0.1; // 允许10%的误差
+      const isValid = Math.abs(actualRatio - expectedRatio) <= tolerance;
+      URL.revokeObjectURL(url);
+      resolve(isValid);
+    };
+
+    img.onerror = () => {
+      URL.revokeObjectURL(url);
+      resolve(false);
+    };
+
+    img.src = url;
+  });
+};
+
+// 上传文件
+const uploadFile = async (file) => {
+  try {
+    var formData = new FormData();
+    formData.append('file', file);
+    const response = await adminApi.file.upload({}, formData);
+    if (response.code == '200') {
       return {
-        success: false,
-        error: error.message,
+        success: true,
+        url: response.data,
       };
+    } else {
+      throw new Error(response.msg || t('message.uploadFailed'));
     }
-  };
-
-  // 打开文件管理器(非直传模式)
-  const openFileManager = () => {
-    // 这里可以集成现有的文件管理器组件
-    console.log('打开文件管理器');
-    ElMessage.info('文件管理器功能待实现');
-  };
-
-  // 图片预览状态
-  const previewVisible = ref(false);
-  const previewImageUrl = ref('');
-  const previewImageList = ref([]);
-  const previewInitialIndex = ref(0);
-
-  // 预览图片
-  const previewImage = (url, index = 0) => {
-    previewImageUrl.value = url;
-    previewImageList.value = [...imageList.value];
-    previewInitialIndex.value = index;
-    previewVisible.value = true;
-  };
-
-  // 关闭预览
-  const closePreview = () => {
-    previewVisible.value = false;
-    previewImageUrl.value = '';
-    previewImageList.value = [];
-    previewInitialIndex.value = 0;
-  };
-
-  // 删除图片
-  const removeImage = (index) => {
-    imageList.value.splice(index, 1);
-  };
-
-  // 处理拖拽排序
-  const handleSort = () => {
-    // 拖拽排序后自动触发 watch 更新
-    console.log('图片排序已更新');
-  };
+  } catch (error) {
+    console.error('上传文件失败:', error);
+    return {
+      success: false,
+      error: error.message,
+    };
+  }
+};
+
+// 打开文件管理器(非直传模式)
+const openFileManager = () => {
+  // 这里可以集成现有的文件管理器组件
+  console.log('打开文件管理器');
+  ElMessage.info('文件管理器功能待实现');
+};
+
+// 图片预览状态
+const previewVisible = ref(false);
+const previewImageUrl = ref('');
+const previewImageList = ref([]);
+const previewInitialIndex = ref(0);
+
+// 预览图片
+const previewImage = (url, index = 0) => {
+  previewImageUrl.value = url;
+  previewImageList.value = [...imageList.value];
+  previewInitialIndex.value = index;
+  previewVisible.value = true;
+};
+
+// 关闭预览
+const closePreview = () => {
+  previewVisible.value = false;
+  previewImageUrl.value = '';
+  previewImageList.value = [];
+  previewInitialIndex.value = 0;
+};
+
+// 删除图片
+const removeImage = (index) => {
+  imageList.value.splice(index, 1);
+};
+
+// 处理拖拽排序
+const handleSort = () => {
+  // 拖拽排序后自动触发 watch 更新
+  console.log('图片排序已更新');
+};
 </script>
 
 <style lang="scss" scoped>
-  .sa-upload-image {
-    .upload-container {
-      gap: 8px;
-    }
-
-    .image-item {
-      position: relative;
-      border-radius: 6px;
-      overflow: hidden;
-      border: 1px solid #dcdfe6;
-
-      .el-image {
-        width: 100%;
-        height: 100%;
-      }
-
-      .image-error {
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        width: 100%;
-        height: 100%;
-        background: #f5f7fa;
-        color: #909399;
-      }
-
-      .image-mask {
-        position: absolute;
-        top: 0;
-        left: 0;
-        right: 0;
-        bottom: 0;
-        background: rgba(0, 0, 0, 0.6);
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        gap: 8px;
-        opacity: 0;
-        transition: opacity 0.3s;
-
-        .el-icon {
-          color: white;
-          font-size: 16px;
-          cursor: pointer;
-          padding: 4px;
-          border-radius: 2px;
-          transition: background-color 0.3s;
-
-          &:hover {
-            background: rgba(255, 255, 255, 0.2);
-          }
-
-          &.sortable-drag {
-            cursor: move;
-          }
-        }
-      }
+.sa-upload-image {
+  .upload-container {
+    gap: 8px;
+  }
 
-      &:hover .image-mask {
-        opacity: 1;
-      }
-    }
+  .image-item {
+    position: relative;
+    border-radius: 6px;
+    overflow: hidden;
+    border: 1px solid #dcdfe6;
 
-    .upload-wrapper {
-      position: relative;
-      display: inline-block;
+    .el-image {
+      width: 100%;
+      height: 100%;
     }
 
-    .upload-trigger {
+    .image-error {
       display: flex;
-      flex-direction: column;
       align-items: center;
       justify-content: center;
-      border: 2px dashed #dcdfe6;
-      border-radius: 6px;
-      cursor: pointer;
-      transition: all 0.3s;
-      background: #fafafa;
-
-      &:hover:not(.is-uploading) {
-        border-color: var(--el-color-primary);
-      }
-
-      &.is-uploading {
-        cursor: not-allowed;
-        border-color: var(--el-color-primary);
-        border-style: solid;
-        background: var(--el-color-primary-light-9);
-        animation: uploadingBorder 2s ease-in-out infinite;
-      }
-
-      .upload-icon {
-        font-size: 24px;
-        color: #8c939d;
-        margin-bottom: 4px;
-        transition: all 0.3s;
-      }
-
-      .upload-text {
-        font-size: 12px;
-        color: #8c939d;
-        transition: all 0.3s;
-        text-align: center;
-      }
+      width: 100%;
+      height: 100%;
+      background: #f5f7fa;
+      color: #909399;
     }
 
-    .upload-loading {
+    .image-mask {
       position: absolute;
       top: 0;
       left: 0;
       right: 0;
       bottom: 0;
+      background: rgba(0, 0, 0, 0.6);
       display: flex;
-      flex-direction: column;
       align-items: center;
       justify-content: center;
-      background: rgba(255, 255, 255, 0.9);
-      border-radius: 6px;
-      backdrop-filter: blur(2px);
-
-      .loading-spinner {
-        width: 20px;
-        height: 20px;
-        border: 2px solid #e4e7ed;
-        border-top: 2px solid var(--el-color-primary);
-        border-radius: 50%;
-        animation: spin 1s linear infinite;
-        margin-bottom: 8px;
-      }
+      gap: 8px;
+      opacity: 0;
+      transition: opacity 0.3s;
 
-      .loading-text {
-        font-size: 12px;
-        color: var(--el-color-primary);
-        font-weight: 500;
+      .el-icon {
+        color: white;
+        font-size: 16px;
+        cursor: pointer;
+        padding: 4px;
+        border-radius: 2px;
+        transition: background-color 0.3s;
+
+        &:hover {
+          background: rgba(255, 255, 255, 0.2);
+        }
+
+        &.sortable-drag {
+          cursor: move;
+        }
       }
     }
 
-    @keyframes spin {
-      0% {
-        transform: rotate(0deg);
-      }
-      100% {
-        transform: rotate(360deg);
-      }
+    &:hover .image-mask {
+      opacity: 1;
     }
+  }
 
-    @keyframes uploadingBorder {
-      0% {
-        border-color: var(--el-color-primary);
-        box-shadow: 0 0 0 0 var(--el-color-primary-light-7);
-      }
-      50% {
-        border-color: var(--el-color-primary-light-3);
-        box-shadow: 0 0 0 4px var(--el-color-primary-light-8);
-      }
-      100% {
-        border-color: var(--el-color-primary);
-        box-shadow: 0 0 0 0 var(--el-color-primary-light-7);
-      }
+  .upload-wrapper {
+    position: relative;
+    display: inline-block;
+  }
+
+  .upload-trigger {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    border: 2px dashed #dcdfe6;
+    border-radius: 6px;
+    cursor: pointer;
+    transition: all 0.3s;
+    background: #fafafa;
+
+    &:hover:not(.is-uploading) {
+      border-color: var(--el-color-primary);
+    }
+
+    &.is-uploading {
+      cursor: not-allowed;
+      border-color: var(--el-color-primary);
+      border-style: solid;
+      background: var(--el-color-primary-light-9);
+      animation: uploadingBorder 2s ease-in-out infinite;
+    }
+
+    .upload-icon {
+      font-size: 24px;
+      color: #8c939d;
+      margin-bottom: 4px;
+      transition: all 0.3s;
     }
 
-    .upload-tip {
-      margin-top: 8px;
+    .upload-text {
       font-size: 12px;
-      color: #909399;
-      line-height: 1.4;
+      color: #8c939d;
+      transition: all 0.3s;
+      text-align: center;
     }
+  }
 
-    /* 精简模式样式 */
-    .compact-mode {
-      border: 1px solid #dcdfe6;
-      border-radius: 4px;
+  .upload-loading {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    background: rgba(255, 255, 255, 0.9);
+    border-radius: 6px;
+    backdrop-filter: blur(2px);
+
+    .loading-spinner {
+      width: 20px;
+      height: 20px;
+      border: 2px solid #e4e7ed;
+      border-top: 2px solid var(--el-color-primary);
+      border-radius: 50%;
+      animation: spin 1s linear infinite;
+      margin-bottom: 8px;
+    }
 
-      &:hover {
-        border-color: var(--el-color-primary);
-      }
+    .loading-text {
+      font-size: 12px;
+      color: var(--el-color-primary);
+      font-weight: 500;
     }
+  }
 
-    .compact-mask {
-      background: transparent;
-      pointer-events: none;
+  @keyframes spin {
+    0% {
+      transform: rotate(0deg);
+    }
 
-      .el-icon {
-        position: absolute;
-        top: 2px;
-        right: 2px;
-        background: rgba(0, 0, 0, 0.8);
-        border-radius: 50%;
-        padding: 1px;
-        pointer-events: auto;
-        width: 16px;
-        height: 16px;
-        display: flex;
-        align-items: center;
-        justify-content: center;
-        font-size: 12px;
-      }
+    100% {
+      transform: rotate(360deg);
     }
+  }
 
-    .compact-trigger {
-      border: 1px dashed #dcdfe6;
-      border-radius: 4px;
-      background: #fafafa;
-      transition: all 0.3s;
+  @keyframes uploadingBorder {
+    0% {
+      border-color: var(--el-color-primary);
+      box-shadow: 0 0 0 0 var(--el-color-primary-light-7);
+    }
 
-      &:hover {
-        border-color: var(--el-color-primary);
-        background: var(--el-color-primary-light-9);
-      }
+    50% {
+      border-color: var(--el-color-primary-light-3);
+      box-shadow: 0 0 0 4px var(--el-color-primary-light-8);
+    }
 
-      .upload-icon {
-        color: #8c939d;
-      }
+    100% {
+      border-color: var(--el-color-primary);
+      box-shadow: 0 0 0 0 var(--el-color-primary-light-7);
+    }
+  }
 
-      &:hover .upload-icon {
-        color: var(--el-color-primary);
-      }
+  .upload-tip {
+    margin-top: 8px;
+    font-size: 12px;
+    color: #909399;
+    line-height: 1.4;
+  }
+
+  /* 精简模式样式 */
+  .compact-mode {
+    border: 1px solid #dcdfe6;
+    border-radius: 4px;
+
+    &:hover {
+      border-color: var(--el-color-primary);
+    }
+  }
+
+  .compact-mask {
+    background: transparent;
+    pointer-events: none;
+
+    .el-icon {
+      position: absolute;
+      top: 2px;
+      right: 2px;
+      background: rgba(0, 0, 0, 0.8);
+      border-radius: 50%;
+      padding: 1px;
+      pointer-events: auto;
+      width: 16px;
+      height: 16px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 12px;
+    }
+  }
+
+  .compact-trigger {
+    border: 1px dashed #dcdfe6;
+    border-radius: 4px;
+    background: #fafafa;
+    transition: all 0.3s;
+
+    &:hover {
+      border-color: var(--el-color-primary);
+      background: var(--el-color-primary-light-9);
+    }
+
+    .upload-icon {
+      color: #8c939d;
+    }
+
+    &:hover .upload-icon {
+      color: var(--el-color-primary);
     }
   }
+}
 </style>