Parcourir la source

优化批次管理溯源码及相关表单细节

yawuga il y a 1 mois
Parent
commit
da52ba8fa4
2 fichiers modifiés avec 350 ajouts et 75 suppressions
  1. 6 1
      src/components/ImageUpload/index.vue
  2. 344 74
      src/views/base/batch/index.vue

+ 6 - 1
src/components/ImageUpload/index.vue

@@ -27,7 +27,7 @@
       请上传
       <template v-if="fileSize"> 大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b> </template>
       <template v-if="fileType"> 格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b> </template>
-      的文件
+      的文件<span v-if="tip">,{{ tip }}</span>
     </div>
 
     <el-dialog
@@ -89,6 +89,11 @@ export default {
     drag: {
       type: Boolean,
       default: true
+    },
+    // 追加提示文案
+    tip: {
+      type: String,
+      default: ""
     }
   },
   data() {

+ 344 - 74
src/views/base/batch/index.vue

@@ -80,7 +80,7 @@
       <el-form-item label="状态" prop="status">
         <el-select v-model="queryParams.status" placeholder="请选择状态" clearable>
           <el-option
-            v-for="dict in dict.type.batch_status"
+            v-for="dict in batchStatusOptions"
             :key="dict.value"
             :label="dict.label"
             :value="dict.value"
@@ -194,7 +194,7 @@
 
       <el-table-column label="状态" align="center" prop="status">
         <template slot-scope="scope">
-          <dict-tag :options="dict.type.batch_status" :value="scope.row.status"/>
+          <dict-tag :options="batchStatusOptions" :value="scope.row.status"/>
         </template>
       </el-table-column>
       <!-- <el-table-column label="创建时间" align="center" prop="createdAt" width="180">
@@ -265,12 +265,14 @@
         </el-form-item>
         <el-form-item label="商品规格" prop="productSpec">
           <el-input v-model="form.productSpec" placeholder="请输入商品规格" />
+          <div class="form-tip">示例:礼盒装 1kg(优选大果)、250g/盒</div>
         </el-form-item>
         <el-form-item label="商品图片" prop="productImage">
-          <image-upload v-model="form.productImage"/>
+          <image-upload v-model="form.productImage" :limit="1" :file-size="2" tip="建议使用清晰商品图"/>
         </el-form-item>
         <el-form-item label="商品简介" prop="productDesc">
-          <el-input v-model="form.productDesc" type="textarea" placeholder="请输入内容" />
+          <el-input v-model="form.productDesc" type="textarea" placeholder="请输入内容" maxlength="50" show-word-limit />
+          <div class="form-tip">建议填写商品卖点、口感特点等内容,50字内</div>
         </el-form-item>
 
         <el-form-item label="所属农场" prop="farmId">
@@ -289,12 +291,23 @@
         </el-form-item>
         <el-form-item label="农场所在地" prop="farmRegion">
           <el-input v-model="form.farmRegion" placeholder="请输入农场所在地" />
+          <div class="form-tip">建议按"市·区县"格式填写,如:宿州·萧县</div>
         </el-form-item>
         <el-form-item label="农场图片" prop="farmImage">
-          <image-upload v-model="form.farmImage"/>
+          <image-upload v-model="form.farmImage" :limit="1" :file-size="2" tip="建议使用真实农场场景图"/>
         </el-form-item>
         <el-form-item label="农场简介" prop="farmIntro">
-          <el-input v-model="form.farmIntro" type="textarea" placeholder="请输入内容" />
+          <el-input v-model="form.farmIntro" type="textarea" placeholder="请输入内容" maxlength="50" show-word-limit />
+          <div class="form-tip">建议填写农场特色、种植方式等内容,50字内</div>
+        </el-form-item>
+        <el-form-item label="开始种植时间" prop="startPlantingTime">
+          <el-date-picker clearable
+            v-model="form.startPlantingTime"
+            type="date"
+            value-format="yyyy-MM-dd"
+            placeholder="请选择开始种植日期">
+          </el-date-picker>
+          <div class="form-tip">不得晚于生产/采收日期</div>
         </el-form-item>
         <el-form-item label="生产/采收日期" prop="produceDate">
           <el-date-picker clearable
@@ -303,6 +316,7 @@
             value-format="yyyy-MM-dd"
             placeholder="请选择生产/采收日期">
           </el-date-picker>
+          <div class="form-tip">应晚于开始种植时间,且不晚于包装日期</div>
         </el-form-item>
         <el-form-item label="包装日期" prop="packageDate">
           <el-date-picker clearable
@@ -311,22 +325,13 @@
             value-format="yyyy-MM-dd"
             placeholder="请选择包装日期">
           </el-date-picker>
+          <div class="form-tip">不得早于生产/采收日期</div>
         </el-form-item>
 
-        <el-form-item label="开始种植时间" prop="startPlantingTime">
-          <el-date-picker clearable
-            v-model="form.startPlantingTime"
-            type="date"
-            value-format="yyyy-MM-dd"
-            placeholder="请选择开始种植日期">
-          </el-date-picker>
-        </el-form-item>
-
-
         <el-form-item label="状态" prop="status">
           <el-radio-group v-model="form.status">
             <el-radio
-              v-for="dict in dict.type.batch_status"
+              v-for="dict in batchStatusOptions"
               :key="dict.value"
               :label="dict.value"
             >{{dict.label}}</el-radio>
@@ -391,11 +396,33 @@
             <!-- 主分隔线 -->
             <div class="main-divider"></div>
 
-            <!-- 下半区:批次信息 -->
+            <!-- 下半区:商品信息 + 批次溯源信息(左右分栏) -->
             <div class="label-bottom">
-              <div class="batch-row">批次: {{ qrData.batchNo }}</div>
-              <div class="label-bottom-divider"></div>
-              <div class="origin-row">{{ qrData.origin }}原产</div>
+              <!-- 左侧:商品识别信息 -->
+              <div class="product-section">
+                <div class="product-name">{{ qrData.productName }}</div>
+                <div class="product-spec">{{ qrData.productSpec }}</div>
+                <div class="product-origin">{{ qrData.origin }}原产</div>
+              </div>
+
+              <!-- 中间分隔线 -->
+              <div class="section-divider"></div>
+
+              <!-- 右侧:批次溯源信息 -->
+              <div class="trace-section">
+                <div class="trace-row">
+                  <span class="trace-label">批次号</span>
+                  <span class="trace-value">{{ qrData.batchNo }}</span>
+                </div>
+                <div class="trace-row">
+                  <span class="trace-label">生产/采收日期</span>
+                  <span class="trace-value">{{ qrData.produceDate }}</span>
+                </div>
+                <div class="trace-row">
+                  <span class="trace-label">包装日期</span>
+                  <span class="trace-value">{{ qrData.packageDate }}</span>
+                </div>
+              </div>
             </div>
           </div>
         </div>
@@ -648,7 +675,11 @@ export default {
         brandEn: "JIAYOU HOUYUAN",
         batchNo: "",
         origin: "",
-        qrUrl: ""
+        qrUrl: "",
+        productName: "",
+        productSpec: "",
+        produceDate: "",
+        packageDate: ""
       },
       // 是否显示合格证弹窗
       certVisible: false,
@@ -725,26 +756,70 @@ export default {
       // 表单校验
       rules: {
         productName: [
-          { required: true, message: "商品名称不能为空", trigger: "blur" }
+          { required: true, message: "商品名称不能为空", trigger: "blur" },
+          { min: 2, max: 30, message: "商品名称长度需在 2 到 30 个字符之间", trigger: "blur" }
+        ],
+        productSpec: [
+          { required: true, message: "商品规格不能为空", trigger: "blur" },
+          { min: 2, max: 30, message: "商品规格长度需在 2 到 30 个字符之间", trigger: "blur" }
+        ],
+        productImage: [
+          { required: true, message: "商品图片不能为空", trigger: "change" },
+          { validator: this.validateProductImage, trigger: "change" }
+        ],
+        productDesc: [
+          { required: true, message: "商品简介不能为空", trigger: "blur" },
+          { max: 50, message: "商品简介不能超过 50 个字符", trigger: "blur" }
         ],
         farmName: [
-          { required: true, message: "农场名称不能为空", trigger: "blur" }
+          { required: true, message: "农场名称不能为空", trigger: "blur" },
+          { min: 2, max: 30, message: "农场名称长度需在 2 到 30 个字符之间", trigger: "blur" }
+        ],
+        farmRegion: [
+          { required: true, message: "农场所在地不能为空", trigger: "blur" },
+          { min: 2, max: 50, message: "农场所在地长度需在 2 到 50 个字符之间", trigger: "blur" }
+        ],
+        farmImage: [
+          { required: true, message: "农场图片不能为空", trigger: "change" },
+          { validator: this.validateFarmImage, trigger: "change" }
+        ],
+        farmIntro: [
+          { required: true, message: "农场简介不能为空", trigger: "blur" },
+          { max: 50, message: "农场简介不能超过 50 个字符", trigger: "blur" }
         ],
         farmId: [
-          { required: true, message: "所属农场不能为空", trigger: "blur" }
+          { required: true, message: "所属农场不能为空", trigger: "change" }
         ],
         fieldId: [
-          { required: true, message: "所属地块不能为空", trigger: "blur" }
+          { required: true, message: "所属地块不能为空", trigger: "change" }
         ],
-        farmRegion: [
-          { required: true, message: "农场所在地不能为空", trigger: "blur" }
+        startPlantingTime: [
+          { required: true, message: "开始种植时间不能为空", trigger: "change" },
+          { validator: this.validateStartPlantingTime, trigger: "change" },
+          { validator: this.validateStartToPackage, trigger: "change" }
+        ],
+        produceDate: [
+          { required: true, message: "生产/采收日期不能为空", trigger: "change" },
+          { validator: this.validateProduceDate, trigger: "change" }
+        ],
+        packageDate: [
+          { required: true, message: "包装日期不能为空", trigger: "change" }
         ],
         status: [
           { required: true, message: "状态不能为空", trigger: "change" }
-        ],
+        ]
       }
     }
   },
+  computed: {
+    // 处理字典数据:将"草稿"映射为"待发布",保持 value 不变
+    batchStatusOptions() {
+      return (this.dict.type.batch_status || []).map(item => ({
+        ...item,
+        label: item.label === '草稿' ? '待发布' : item.label
+      }))
+    }
+  },
   created() {
     this.getList()
     this.getDeptTree();
@@ -752,6 +827,102 @@ export default {
   },
   methods: {
 
+    // 日期校验:开始种植时间 <= 生产/采收日期
+    validateStartPlantingTime(rule, value, callback) {
+      if (!value) {
+        // 非必填时先跳过,由必填规则处理
+        callback();
+        return;
+      }
+      if (this.form.produceDate && value > this.form.produceDate) {
+        callback(new Error("开始种植时间不能晚于生产/采收日期"));
+      } else {
+        callback();
+      }
+    },
+
+    // 日期校验:生产/采收日期 <= 包装日期
+    validateProduceDate(rule, value, callback) {
+      if (!value) {
+        // 非必填时先跳过,由必填规则处理
+        callback();
+        return;
+      }
+      if (this.form.packageDate && value > this.form.packageDate) {
+        callback(new Error("生产/采收日期不能晚于包装日期"));
+      } else {
+        callback();
+      }
+    },
+
+    // 日期校验:开始种植时间 <= 包装日期
+    validateStartToPackage(rule, value, callback) {
+      if (!value) {
+        // 非必填时先跳过,由必填规则处理
+        callback();
+        return;
+      }
+      if (this.form.packageDate && value > this.form.packageDate) {
+        callback(new Error("开始种植时间不能晚于包装日期"));
+      } else {
+        callback();
+      }
+    },
+
+    // 商品图片校验:兼容 string 和 array,只能上传 1 张且不能为空
+    validateProductImage(rule, value, callback) {
+      if (!value) {
+        callback(new Error("商品图片不能为空"));
+        return;
+      }
+      // 如果是数组,检查长度
+      if (Array.isArray(value)) {
+        if (value.length === 0) {
+          callback(new Error("商品图片不能为空"));
+        } else if (value.length > 1) {
+          callback(new Error("仅支持上传 1 张商品图片"));
+        } else {
+          callback();
+        }
+      } else if (typeof value === 'string') {
+        // 如果是字符串,检查是否为空字符串
+        if (value.trim() === '') {
+          callback(new Error("商品图片不能为空"));
+        } else {
+          callback();
+        }
+      } else {
+        callback(new Error("商品图片不能为空"));
+      }
+    },
+
+    // 农场图片校验:兼容 string 和 array,只能上传 1 张且不能为空
+    validateFarmImage(rule, value, callback) {
+      if (!value) {
+        callback(new Error("农场图片不能为空"));
+        return;
+      }
+      // 如果是数组,检查长度
+      if (Array.isArray(value)) {
+        if (value.length === 0) {
+          callback(new Error("农场图片不能为空"));
+        } else if (value.length > 1) {
+          callback(new Error("仅支持上传 1 张农场图片"));
+        } else {
+          callback();
+        }
+      } else if (typeof value === 'string') {
+        // 如果是字符串,检查是否为空字符串
+        if (value.trim() === '') {
+          callback(new Error("农场图片不能为空"));
+        } else {
+          callback();
+        }
+      } else {
+        callback(new Error("农场图片不能为空"));
+      }
+    },
+
 
     handleFieldChange(value) {
       // 当用户选择地块时,确保选中的地块属于当前农场
@@ -947,6 +1118,11 @@ export default {
      // const baseUrl = window.location.origin
       const baseUrl = 'https://nxy.gbdfarm.com:9001'
       this.qrData.qrUrl = `${baseUrl}/${row.id}`
+      // 新增商品溯源信息
+      this.qrData.productName = row.productName || ''
+      this.qrData.productSpec = row.productSpec || ''
+      this.qrData.produceDate = row.produceDate ? this.parseTime(row.produceDate, '{y}-{m}-{d}') : ''
+      this.qrData.packageDate = row.packageDate ? this.parseTime(row.packageDate, '{y}-{m}-{d}') : ''
 
       this.qrVisible = true
 
@@ -996,7 +1172,14 @@ export default {
         // 转为图片并下载
         const imgUrl = canvas.toDataURL('image/png')
         const link = document.createElement('a')
-        link.download = `溯源卡片_${this.qrData.batchNo}.png`
+
+        // 安全处理商品名称:去除非法字符,长度限制15字符
+        let productName = this.qrData.productName || ''
+        productName = productName.replace(/[\\/:*?"<>|]/g, '').substring(0, 15)
+        const fileName = productName
+          ? `溯源码_${productName}_${this.qrData.batchNo}.png`
+          : `溯源码_${this.qrData.batchNo}.png`
+        link.download = fileName
         link.href = imgUrl
         link.click()
 
@@ -1536,7 +1719,7 @@ export default {
   height: 100%;
   border: 1px solid #000000;
   border-radius: 4px;
-  padding: 14px 16px 12px;
+  padding: 14px 16px 10px;
   display: flex;
   flex-direction: column;
   box-sizing: border-box;
@@ -1580,10 +1763,13 @@ export default {
 
 /* 上半区 */
 .label-top {
-  flex: 1;
+  flex: 0 0 auto;
   display: flex;
   align-items: center;
   box-sizing: border-box;
+  min-height: 0;
+  padding-bottom: 0;
+  height: 96px;
 }
 
 /* 左侧品牌信息 */
@@ -1593,45 +1779,46 @@ export default {
   flex-direction: column;
   justify-content: center;
   padding-right: 14px;
+  min-height: 0;
 }
 
 .brand-name {
-  font-size: 28px;
+  font-size: 20px;
   font-weight: 900;
   color: #000000;
-  letter-spacing: 8px;
-  margin-bottom: 2px;
+  letter-spacing: 5px;
+  margin-bottom: 4px;
   line-height: 1.1;
   white-space: nowrap;
 }
 
 .brand-en {
-  font-size: 10px;
+  font-size: 8px;
   color: #000000;
-  letter-spacing: 5px;
-  margin-bottom: 10px;
+  letter-spacing: 2.5px;
+  margin-bottom: 6px;
   font-weight: 600;
   white-space: nowrap;
 }
 
 .divider-line {
-  width: 100%;
-  height: 1.5px;
+  width: 90%;
+  height: 1px;
   background: #000000;
-  margin-bottom: 10px;
+  margin-bottom: 6px;
 }
 
 .scan-tip {
-  font-size: 15px;
+  font-size: 11px;
   color: #000000;
-  letter-spacing: 2px;
-  font-weight: 700;
+  letter-spacing: 0.8px;
+  font-weight: 600;
   line-height: 1.3;
 }
 
 /* 右侧二维码 */
 .label-right {
-  width: 120px;
+  width: 88px;
   flex-shrink: 0;
   display: flex;
   align-items: center;
@@ -1639,64 +1826,139 @@ export default {
 }
 
 .qr-code-area {
-  width: 110px;
-  height: 110px;
+  width: 76px;
+  height: 76px;
   display: flex;
   align-items: center;
   justify-content: center;
-  padding: 3px;
+  padding: 2px;
   border: 1px solid #000000;
 }
 
 .qr-code-area canvas,
 #qrCanvas {
-  width: 102px !important;
-  height: 102px !important;
+  width: 72px !important;
+  height: 72px !important;
 }
 
 /* 主分隔线 */
 .main-divider {
   width: 100%;
-  height: 1.5px;
+  height: 1px;
   background: #000000;
-  margin: 10px 0;
+  margin: 8px 0 8px;
+  flex-shrink: 0;
 }
 
-/* 下半区 */
+/* 下半区:左右分栏布局 */
 .label-bottom {
   display: flex;
-  flex-direction: column;
   align-items: center;
-  padding: 0;
+  justify-content: center;
+  padding: 4px 0 6px;
   box-sizing: border-box;
+  width: 100%;
+  flex: 1;
+  min-height: 0;
 }
 
-.batch-row {
-  font-size: 17px;
-  color: #000000;
-  font-weight: 800;
-  letter-spacing: 2px;
-  margin-bottom: 8px;
-  width: 100%;
-  text-align: center;
+/* 左侧商品区 */
+.product-section {
+  flex: 0 0 46%;
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-start;
+  padding-top: 0;
+  padding-right: 14px;
+  box-sizing: border-box;
+}
+
+.product-section .product-name {
+  font-size: 16px;
+  font-weight: 700;
+  color: #000;
+  line-height: 1.25;
+  margin-bottom: 5px;
+  min-height: 20px;
+}
+.product-section .product-spec {
+  font-size: 12px;
+  font-weight: 500;
+  color: rgba(0, 0, 0, 0.55);
+  line-height: 1.35;
+  margin-bottom: 5px;
+  min-height: 16px;
+}
+.product-section .product-origin {
+  font-size: 13px;
+  font-weight: 600;
+  color: rgba(0, 0, 0, 0.85);
   line-height: 1.3;
+  margin-bottom: 0;
+  min-height: 17px;
 }
 
-.label-bottom-divider {
-  width: 100%;
-  height: 1px;
+/* 中间竖向分隔线 */
+.section-divider {
+  width: 1px;
+  height: 100px;
   background: #000000;
-  margin-bottom: 8px;
+  margin: 0 10px;
+  flex-shrink: 0;
+  align-self: flex-start;
+  padding-top: 0;
 }
 
-.origin-row {
-  font-size: 16px;
-  color: #000000;
-  letter-spacing: 4px;
-  font-weight: 700;
-  text-align: center;
-  width: 100%;
+/* 右侧溯源信息区 */
+.trace-section {
+  flex: 0 0 46%;
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-start;
+  padding-top: 0;
+  min-width: 0;
+  padding-left: 0;
+  box-sizing: border-box;
+}
+.trace-section .trace-row {
+  display: flex;
+  align-items: baseline;
+  margin-bottom: 5px;
+  line-height: 1.35;
+}
+.trace-section .trace-row:nth-child(1) {
+  line-height: 1.25;
+  margin-bottom: 5px;
+  min-height: 20px;
+  transform: translateY(2px);
+}
+.trace-section .trace-row:nth-child(2) {
+  line-height: 1.35;
+  margin-top: 0;
+  margin-bottom: 5px;
+  min-height: 16px;
+}
+.trace-section .trace-row:nth-child(3) {
   line-height: 1.3;
+  margin-top: 0;
+  margin-bottom: 0;
+  min-height: 17px;
+}
+.trace-section .trace-label {
+  font-size: 12px;
+  font-weight: 500;
+  color: rgba(0, 0, 0, 0.5);
+  letter-spacing: 0.1px;
+  margin-right: 6px;
+  flex-shrink: 0;
+  white-space: nowrap;
+}
+.trace-section .trace-value {
+  font-size: 13px;
+  font-weight: 600;
+  color: #000;
+  letter-spacing: 0.1px;
+  white-space: nowrap;
 }
 
 .dialog-footer {
@@ -1805,4 +2067,12 @@ export default {
   width: 100%;
   border-style: dashed;
 }
+
+/* 统一表单填写提示样式 */
+.form-tip {
+  font-size: 12px;
+  color: #999;
+  line-height: 1.4;
+  margin-top: 4px;
+}
 </style>