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

feat(goods): 优化多规格商品编辑交互

- 新增与编辑页改为横向规格标签切换,并在当前规格卡片内维护价格、库存及图片
- 统一规格一与主商品图片、封面的同步和提交校正,避免无效封面路径入库
- 调整商品列表内容区及规格卡片布局,改善移动端展示
shizhongqi пре 1 дан
родитељ
комит
bd0226b550
3 измењених фајлова са 770 додато и 285 уклоњено
  1. 375 138
      hdApp/src/admin/goods/add.vue
  2. 394 146
      hdApp/src/admin/goods/detail.vue
  3. 1 1
      hdApp/src/admin/goods/manage.vue

+ 375 - 138
hdApp/src/admin/goods/add.vue

@@ -1,7 +1,7 @@
 <!--
   花店后台新增商品
   谁用:hdApp 管理端创建花束
-  解决问题:单规格写主记录;多规格主记录承载首规格(specName/price/stock),其余规格提交 specList 由后端拆子行
+  解决问题:单规格写主记录;开启多规格时隐藏卡片外主图/封面,改在规格卡片内编辑(规格1回写主记录),其余规格提交 specList 由后端拆子行;多规格用可横滑 tab 切换卡片编辑
 -->
 <template>
   <view class="app-content">
@@ -9,7 +9,121 @@
     <form @submit="commitForm">
       <view class="module-com input-line-wrap">
 
-        <view class="module-com input-line-wrap goods-wrap">
+        <tui-list-cell class="line-cell" :hover="false">
+          <view class="tui-title required">名称</view>
+          <input v-model="form.name" placeholder-class="phcolor" class="tui-input" name="name" placeholder="请输入商品名称" maxlength="50" type="text" />
+        </tui-list-cell>
+
+        <view class="spec-switch-card">
+          <view class="spec-switch-left">
+            <view class="spec-switch-icon">▦</view>
+            <view>
+              <view class="spec-switch-title">启用多规格</view>
+              <view class="spec-switch-desc">开启后可设置商品多规格属性</view>
+            </view>
+          </view>
+          <view class="spec-switch-right">
+            <switch :checked="form.specEnabled == 1" @change="toggleSpecs" color="#09C567" />
+            <text>{{ form.specEnabled == 1 ? '已开启' : '未开启' }}</text>
+          </view>
+        </view>
+
+        <view v-if="form.specEnabled == 1" class="spec-wrap">
+          <view class="spec-card">
+            <view class="spec-card-head">
+              <view class="spec-card-head-left">
+                <view class="spec-card-title">规格</view>
+                <!-- 新增规格放标题旁,避免卡片下方占一整行 -->
+                <button class="spec-add-btn" @click.stop.prevent="addSpec">新增规格</button>
+              </view>
+              <button v-if="form.specList.length > 1" class="spec-remove" @click.stop.prevent="removeSpec(specTabIndex)">删除</button>
+            </view>
+            <!-- 规格 tab:每新增一组出现一项,超出屏宽后可左右滑动 -->
+            <scroll-view
+              class="spec-tab-scroll"
+              scroll-x
+              :scroll-into-view="specTabScrollId"
+              :scroll-with-animation="true"
+              :show-scrollbar="false"
+            >
+              <view class="spec-tab-list">
+                <view
+                  v-for="(spec, index) in form.specList"
+                  :id="specTabDomId(index)"
+                  :key="index"
+                  :class="specTabIndex == index ? 'spec-tab-item active' : 'spec-tab-item'"
+                  @click.stop.prevent="selectSpecTab(index)"
+                >
+                  <text class="spec-tab-text">{{ specTabLabel(index, spec) }}</text>
+                </view>
+              </view>
+            </scroll-view>
+            <view v-if="currentSpec" class="spec-card-body">
+              <tui-list-cell class="line-cell spec-line" :hover="false">
+                <view class="tui-title required">{{ currentSpecTitle }}名称</view>
+                <input :value="currentSpec.specName" @input="setSpecField(specTabIndex, 'specName', $event.detail.value)" placeholder-class="phcolor" class="tui-input" placeholder="请输入规格名称" maxlength="50" type="text" />
+              </tui-list-cell>
+              <!-- 多规格时图片/封面改在卡片内编辑;规格1对应主记录,其余规格不传则沿用主图 -->
+              <view class="spec-image-wrap">
+                <view class="module-tit">
+                  <span>图片</span>
+                  <span class="app-color-3">{{ specTabIndex == 0 ? '(最多5张)' : '(不传则使用主商品图片,最多5张)' }}</span>
+                </view>
+                <view class="module-det">
+                  <htz-image-upload :key="specTabIndex" :max="5" :compress="true" :value="currentSpec.currentImgData || []" :headers="headers"
+                  @input="setSpecField(specTabIndex, 'currentImgData', $event)"
+                  @uploadSuccess="specImgUploadSuccess($event, specTabIndex)" @imgDelete="specImgDeleteFn($event, specTabIndex)" :action="getLoginInfo.imgUploadApi">
+                  </htz-image-upload>
+                </view>
+                <view v-if="currentSpec.shopImg && currentSpec.shopImg.length > 0" class="spec-cover-row">
+                  <view class="spec-cover-title">封面</view>
+                  <view class="spec-cover-btns">
+                    <button class="admin-button-com" :class="[(currentSpec.shortCover == item) ? '' : 'default']"
+                    v-for="(item, imgIndex) in currentSpec.shopImg" :key="imgIndex"
+                    @click.stop.prevent="selSpecCoverFn(specTabIndex, imgIndex)">第{{ imgIndex + 1 }}张</button>
+                  </view>
+                </view>
+              </view>
+              <tui-list-cell class="line-cell spec-line" :hover="false">
+                <view class="tui-title">价格类型</view>
+                <view class="btn-group">
+                  <button class="admin-button-com middle" :class="[currentSpec.priceType == 1 ? '' : 'default']" @click.stop.prevent="setSpecPriceType(specTabIndex, '1')">有价格</button>
+                  <button class="admin-button-com middle" :class="[currentSpec.priceType == 0 ? '' : 'default']" @click.stop.prevent="setSpecPriceType(specTabIndex, '0')">无价格</button>
+                </view>
+              </tui-list-cell>
+              <tui-list-cell v-if="currentSpec.priceType == 1" class="line-cell spec-line" :hover="false">
+                <view class="tui-title required">价格</view>
+                <input :value="currentSpec.price" @input="setSpecField(specTabIndex, 'price', $event.detail.value)" placeholder-class="phcolor" class="tui-input" placeholder="请输入价格" maxlength="10" type="digit" />
+              </tui-list-cell>
+              <tui-list-cell class="line-cell spec-line" :hover="false">
+                <view class="tui-title">库存</view>
+                <view class="btn-group">
+                  <button class="admin-button-com middle" :class="[currentSpec.stockSet == 0 ? '' : 'default']" @click.stop.prevent="setSpecStockSet(specTabIndex, '0')">管够</button>
+                  <button class="admin-button-com middle" :class="[currentSpec.stockSet == 1 ? '' : 'default']" @click.stop.prevent="setSpecStockSet(specTabIndex, '1')">自定义</button>
+                </view>
+              </tui-list-cell>
+              <tui-list-cell v-if="currentSpec.stockSet == 1" class="line-cell spec-line" :hover="false">
+                <view class="tui-title">库存</view>
+                <input :value="currentSpec.stock" @input="setSpecField(specTabIndex, 'stock', $event.detail.value)" placeholder-class="phcolor" class="tui-input" placeholder="请输入库存数量" maxlength="10" type="number" />
+              </tui-list-cell>
+              <tui-list-cell class="line-cell spec-line" :hover="false">
+                <view class="tui-title">用花支数</view>
+                <input :value="currentSpec.flowerNum" @input="setSpecField(specTabIndex, 'flowerNum', $event.detail.value)" placeholder-class="phcolor" class="tui-input" placeholder="用于判断花束大小" maxlength="5" type="number" />
+              </tui-list-cell>
+              <tui-list-cell class="line-cell spec-line" :hover="false">
+                <view class="tui-title">重量</view>
+                <input :value="currentSpec.weight" @input="setSpecField(specTabIndex, 'weight', $event.detail.value)" placeholder-class="phcolor" class="tui-input" placeholder="公斤,可输入小数" maxlength="10" type="digit" />
+              </tui-list-cell>
+              <tui-list-cell class="line-cell spec-line" :hover="false">
+                <view class="tui-title">已售数量</view>
+                <input :value="currentSpec.sold" @input="setSpecField(specTabIndex, 'sold', $event.detail.value)" placeholder-class="phcolor" class="tui-input" placeholder="客户端展示:已售数量+实售数量" maxlength="10" type="number" />
+              </tui-list-cell>
+            </view>
+          </view>
+        </view>
+
+        <!-- 开启多规格后主图/封面改在规格卡片内编辑,避免与规格1重复 -->
+        <view v-if="form.specEnabled != 1" class="module-com input-line-wrap goods-wrap">
           <view class="module-tit">
             <span>图片</span>
             <span class="app-color-3">(最多5张)</span>
@@ -21,7 +135,7 @@
           </view>
         </view>
 
-        <tui-list-cell class="line-cell category-wrap" :hover="false">
+        <tui-list-cell v-if="form.specEnabled != 1" class="line-cell category-wrap" :hover="false">
           <view class="tui-title">封面</view>
           <view class="tui-input">
             <block v-if="coverImgData.length > 0">
@@ -48,92 +162,6 @@
           </view>
         </tui-list-cell>
 
-        <tui-list-cell class="line-cell" :hover="false">
-          <view class="tui-title required">名称</view>
-          <input v-model="form.name" placeholder-class="phcolor" class="tui-input" name="name" placeholder="请输入商品名称" maxlength="50" type="text" />
-        </tui-list-cell>
-        <view class="spec-switch-card">
-          <view class="spec-switch-left">
-            <view class="spec-switch-icon">▦</view>
-            <view>
-              <view class="spec-switch-title">启用多规格</view>
-              <view class="spec-switch-desc">开启后可设置商品多规格属性</view>
-            </view>
-          </view>
-          <view class="spec-switch-right">
-            <switch :checked="form.specEnabled == 1" @change="toggleSpecs" color="#09C567" />
-            <text>{{ form.specEnabled == 1 ? '已开启' : '未开启' }}</text>
-          </view>
-        </view>
-
-        <view v-if="form.specEnabled == 1" class="spec-wrap">
-          <view v-for="(spec,index) in form.specList" :key="index" class="spec-card">
-            <view class="spec-card-head">
-              <view class="spec-card-title">规格组名称{{ index + 1 }}</view>
-              <button v-if="form.specList.length > 1" class="spec-remove" @click.stop.prevent="removeSpec(index)">删除</button>
-            </view>
-            <tui-list-cell class="line-cell spec-line" :hover="false">
-              <view class="tui-title required">规格组名称{{ index + 1 }}</view>
-              <input :value="spec.specName" @input="setSpecField(index, 'specName', $event.detail.value)" placeholder-class="phcolor" class="tui-input" placeholder="请输入规格名称" maxlength="50" type="text" />
-            </tui-list-cell>
-            <view class="spec-image-wrap">
-              <view class="module-tit">
-                <span>图片</span>
-                <span class="app-color-3">(不传则使用主商品图片,最多5张)</span>
-              </view>
-              <view class="module-det">
-                <htz-image-upload :max="5" :compress="true" :value="spec.currentImgData || []" :headers="headers"
-                @input="setSpecField(index, 'currentImgData', $event)"
-                @uploadSuccess="specImgUploadSuccess($event, index)" @imgDelete="specImgDeleteFn($event, index)" :action="getLoginInfo.imgUploadApi">
-                </htz-image-upload>
-              </view>
-              <view v-if="spec.shopImg && spec.shopImg.length > 0" class="spec-cover-row">
-                <view class="spec-cover-title">封面</view>
-                <view class="spec-cover-btns">
-                  <button class="admin-button-com" :class="[(spec.shortCover == item) ? '' : 'default']"
-                  v-for="(item, imgIndex) in spec.shopImg" :key="imgIndex"
-                  @click.stop.prevent="selSpecCoverFn(index, imgIndex)">第{{ imgIndex + 1 }}张</button>
-                </view>
-              </view>
-            </view>
-            <tui-list-cell class="line-cell spec-line" :hover="false">
-              <view class="tui-title">价格类型</view>
-              <view class="btn-group">
-                <button class="admin-button-com middle" :class="[spec.priceType == 1 ? '' : 'default']" @click.stop.prevent="setSpecPriceType(index, '1')">有价格</button>
-                <button class="admin-button-com middle" :class="[spec.priceType == 0 ? '' : 'default']" @click.stop.prevent="setSpecPriceType(index, '0')">无价格</button>
-              </view>
-            </tui-list-cell>
-            <tui-list-cell v-if="spec.priceType == 1" class="line-cell spec-line" :hover="false">
-              <view class="tui-title required">价格</view>
-              <input :value="spec.price" @input="setSpecField(index, 'price', $event.detail.value)" placeholder-class="phcolor" class="tui-input" placeholder="请输入价格" maxlength="10" type="digit" />
-            </tui-list-cell>
-            <tui-list-cell class="line-cell spec-line" :hover="false">
-              <view class="tui-title">库存</view>
-              <view class="btn-group">
-                <button class="admin-button-com middle" :class="[spec.stockSet == 0 ? '' : 'default']" @click.stop.prevent="setSpecStockSet(index, '0')">管够</button>
-                <button class="admin-button-com middle" :class="[spec.stockSet == 1 ? '' : 'default']" @click.stop.prevent="setSpecStockSet(index, '1')">自定义</button>
-              </view>
-            </tui-list-cell>
-            <tui-list-cell v-if="spec.stockSet == 1" class="line-cell spec-line" :hover="false">
-              <view class="tui-title">库存</view>
-              <input :value="spec.stock" @input="setSpecField(index, 'stock', $event.detail.value)" placeholder-class="phcolor" class="tui-input" placeholder="请输入库存数量" maxlength="10" type="number" />
-            </tui-list-cell>
-            <tui-list-cell class="line-cell spec-line" :hover="false">
-              <view class="tui-title">用花支数</view>
-              <input :value="spec.flowerNum" @input="setSpecField(index, 'flowerNum', $event.detail.value)" placeholder-class="phcolor" class="tui-input" placeholder="用于判断花束大小" maxlength="5" type="number" />
-            </tui-list-cell>
-            <tui-list-cell class="line-cell spec-line" :hover="false">
-              <view class="tui-title">重量</view>
-              <input :value="spec.weight" @input="setSpecField(index, 'weight', $event.detail.value)" placeholder-class="phcolor" class="tui-input" placeholder="公斤,可输入小数" maxlength="10" type="digit" />
-            </tui-list-cell>
-            <tui-list-cell class="line-cell spec-line" :hover="false">
-              <view class="tui-title">已售数量</view>
-              <input :value="spec.sold" @input="setSpecField(index, 'sold', $event.detail.value)" placeholder-class="phcolor" class="tui-input" placeholder="客户端展示:已售数量+实售数量" maxlength="10" type="number" />
-            </tui-list-cell>
-          </view>
-          <button class="spec-add-btn" @click.stop.prevent="addSpec">新增规格组</button>
-        </view>
-
         <tui-list-cell v-if="form.specEnabled != 1" class="line-cell" :hover="false">
           <view class="tui-title">价格类型</view>
           <view class="btn-group">
@@ -250,14 +278,18 @@ export default {
         shopImg: [],
         briefContent: "",
         status: 1,
-        cover: '1',
+        // 封面存图片短路径;旧值 '1' 表示第1张,不是路径,不能当默认入库
+        cover: '',
         shortCover: "", // 添加shortCover字段,与detail.vue保持一致
         setItemNum: 0,
         useCaseIdList: [], specEnabled: 0, specList: [],
       },
       categoryData:[], useCaseData: [],
       currentImgData:[],
-      firstCategorySelected: false
+      firstCategorySelected: false,
+      // 当前展示的规格 tab 下标;仅前端展示用,不随表单提交
+      specTabIndex: 0,
+      specTabScrollId: 'specTab0'
     };
   },
   onLoad() {
@@ -271,6 +303,26 @@ export default {
         return [];
       }
       return this.form.shopImg;
+    },
+    /**
+     * 当前 tab 对应的规格对象,供卡片内容绑定
+     */
+    currentSpec() {
+      const list = this.form.specList || [];
+      if (!list.length) {
+        return null;
+      }
+      const index = this.specTabIndex;
+      if (index < 0 || index >= list.length) {
+        return list[0];
+      }
+      return list[index];
+    },
+    /**
+     * 卡片标题用「规格N」,与 tab 序号保持一致
+     */
+    currentSpecTitle() {
+      return '规格' + (this.specTabIndex + 1);
     }
   },
   methods: {
@@ -314,6 +366,24 @@ export default {
         console.error('图片删除失败:', '短路径为空');
       }
     },
+    /**
+     * 从图片列表解析真正的封面短路径。
+     * 旧表单默认 cover='1' 表示「第1张」,不是路径;只有 preferred 真在 shopImg 里才采用。
+     * @param {Array} shopImg 短路径图片列表
+     * @param {*} preferredCover 用户选中的封面或历史 cover
+     * @returns {string} 有效短路径,没有图则空字符串
+     */
+    resolveCoverPath(shopImg, preferredCover) {
+      const list = Array.isArray(shopImg) ? shopImg : [];
+      if (!list.length) {
+        return '';
+      }
+      const preferred = preferredCover == null ? '' : String(preferredCover);
+      if (preferred && list.indexOf(preferred) !== -1) {
+        return preferred;
+      }
+      return list[0] || '';
+    },
     // 规范化图片路径:去除域名与查询参数,保留以/开头的相对路径
     normalizeImgPath(url){
       if (!url || typeof url !== 'string') return ''
@@ -362,7 +432,8 @@ export default {
     createSpecData(data = {}) {
       const shopImg = Array.isArray(data.shortImgList) ? data.shortImgList : (Array.isArray(data.shopImg) ? data.shopImg : []);
       const currentImgData = Array.isArray(data.currentImgData) ? data.currentImgData : (Array.isArray(data.smallImgList) ? data.smallImgList : []);
-      const shortCover = data.shortCover || (shopImg.includes(data.cover) ? data.cover : '') || shopImg[0] || '';
+      // 封面必须是图片列表中的路径,避免把历史占位值 '1' 当成封面
+      const shortCover = this.resolveCoverPath(shopImg, data.shortCover || data.cover);
       return {
         id: data.id || 0,
         specName: data.specName || '',
@@ -379,8 +450,70 @@ export default {
         shortCover
       }
     },
-    addSpec() { this.form.specList.push(this.createSpecData()) },
-    removeSpec(index) { this.form.specList.splice(index,1) },
+    /**
+     * 生成规格 tab 的 DOM id,供 scroll-into-view 定位(必须以字母开头)
+     * @param {number} index 规格下标
+     * @returns {string}
+     */
+    specTabDomId(index) {
+      return 'specTab' + index;
+    },
+    /**
+     * tab 文案:已填规格名则展示名称,便于多规格辨认;否则回退为「规格N」
+     * @param {number} index 规格下标
+     * @param {Object} spec 规格对象
+     * @returns {string}
+     */
+    specTabLabel(index, spec) {
+      const name = spec && spec.specName ? String(spec.specName).trim() : '';
+      if (name) {
+        return name;
+      }
+      return '规格' + (index + 1);
+    },
+    /**
+     * 横向滚动定位到当前 tab。先清空再赋值,避免 scroll-into-view 同值不触发
+     */
+    scrollToSpecTab() {
+      this.specTabScrollId = '';
+      this.$nextTick(() => {
+        this.specTabScrollId = this.specTabDomId(this.specTabIndex);
+      });
+    },
+    /**
+     * 点击 tab 切换当前规格卡片
+     * @param {number} index 目标规格下标
+     */
+    selectSpecTab(index) {
+      this.specTabIndex = index;
+      this.scrollToSpecTab();
+    },
+    /**
+     * 新增规格组后切到新 tab,让用户直接编辑
+     */
+    addSpec() {
+      this.form.specList.push(this.createSpecData());
+      this.specTabIndex = this.form.specList.length - 1;
+      this.scrollToSpecTab();
+    },
+    /**
+     * 删除指定规格。删当前项后校正下标,避免越界或指向错位的规格
+     * @param {number} index 要删除的规格下标
+     */
+    removeSpec(index) {
+      if (this.form.specList.length <= 1) {
+        return;
+      }
+      this.form.specList.splice(index, 1);
+      const lastIndex = this.form.specList.length - 1;
+      // 删的是当前或更前的项时,下标要跟着列表左移
+      if (this.specTabIndex > lastIndex) {
+        this.specTabIndex = lastIndex;
+      } else if (this.specTabIndex > index) {
+        this.specTabIndex = this.specTabIndex - 1;
+      }
+      this.scrollToSpecTab();
+    },
     setSpecField(index, field, value) {
       const spec = this.form.specList[index] || this.createSpecData();
       const nextSpec = { ...spec, [field]: value };
@@ -392,7 +525,8 @@ export default {
       if (uploadRes.code == 1) {
         const currentImgData = [...(spec.currentImgData || []), uploadRes.data.smallUrl];
         const shopImg = [...(spec.shopImg || []), uploadRes.data.shortUrl];
-        const shortCover = spec.shortCover || uploadRes.data.shortUrl;
+        // 已有合法封面则保留;占位值 '1' 不在列表里,会回落到刚上传的图
+        const shortCover = this.resolveCoverPath(shopImg, spec.shortCover || spec.cover);
         this.$set(this.form.specList, index, {
           ...spec,
           currentImgData,
@@ -409,7 +543,7 @@ export default {
       const shopImg = [...(spec.shopImg || [])];
       const shortDelPath = (shopImg[imgIndex] || this.normalizeImgPath(res.del)).replace(/^\//, '');
       shopImg.splice(imgIndex, 1);
-      const shortCover = shopImg.includes(spec.shortCover) ? spec.shortCover : (shopImg[0] || '');
+      const shortCover = this.resolveCoverPath(shopImg, spec.shortCover || spec.cover);
       this.$set(this.form.specList, index, {
         ...spec,
         currentImgData,
@@ -430,11 +564,57 @@ export default {
         shortCover
       });
     },
+    /**
+     * 开启多规格时把当前主图/封面灌进规格1,避免卡片内上传区空白
+     */
+    syncFormImagesToFirstSpec() {
+      if (this.form.specEnabled != 1 || !this.form.specList || !this.form.specList.length) {
+        return;
+      }
+      const firstSpec = this.form.specList[0];
+      const shopImg = Array.isArray(this.form.shopImg) ? this.form.shopImg.slice() : [];
+      const currentImgData = Array.isArray(this.currentImgData) ? this.currentImgData.slice() : [];
+      const cover = this.resolveCoverPath(shopImg, this.form.shortCover || this.form.cover);
+      this.$set(this.form.specList, 0, {
+        ...firstSpec,
+        shopImg,
+        currentImgData,
+        cover,
+        shortCover: cover
+      });
+    },
+    /**
+     * 多规格时规格1在卡片内编辑图片/封面,关闭多规格或提交前把规格1回写到主表单
+     */
+    syncFirstSpecImagesToForm() {
+      if (this.form.specEnabled != 1) {
+        return;
+      }
+      const firstSpec = (this.form.specList && this.form.specList[0]) || null;
+      if (!firstSpec) {
+        return;
+      }
+      this.form.shopImg = Array.isArray(firstSpec.shopImg) ? firstSpec.shopImg.slice() : [];
+      this.currentImgData = Array.isArray(firstSpec.currentImgData) ? firstSpec.currentImgData.slice() : [];
+      const cover = this.resolveCoverPath(this.form.shopImg, firstSpec.shortCover || firstSpec.cover);
+      this.form.cover = cover;
+      this.form.shortCover = cover;
+    },
     toggleSpecs(e) {
-      this.form.specEnabled = e.detail.value ? 1 : 0;
-      if(this.form.specEnabled && !this.form.specList.length) {
-        this.form.specList.push(this.createSpecData(this.form))
+      const enabled = e.detail.value ? 1 : 0;
+      // 关闭前先把规格1图片回写主表单,此时 specEnabled 仍为 1
+      if (!enabled) {
+        this.syncFirstSpecImagesToForm();
+      }
+      this.form.specEnabled = enabled;
+      if (this.form.specEnabled) {
+        if (!this.form.specList.length) {
+          this.form.specList.push(this.createSpecData(this.form));
+        }
+        this.syncFormImagesToFirstSpec();
       }
+      this.specTabIndex = 0;
+      this.scrollToSpecTab();
     },
     setSpecPriceType(index, type) {
       const spec = this.form.specList[index] || this.createSpecData();
@@ -522,12 +702,16 @@ export default {
       }
       for (let i = 0; i < this.form.specList.length; i++) {
         const spec = this.form.specList[i];
-        const name = `规格${i + 1}`;
+        const name = `规格${i + 1}`;
         if (!spec.specName || !String(spec.specName).trim()) {
+          this.specTabIndex = i;
+          this.scrollToSpecTab();
           return `请输入${name}名称`;
         }
         const numErr = this.validateGoodsNumberFields(spec, name);
         if (numErr) {
+          this.specTabIndex = i;
+          this.scrollToSpecTab();
           return numErr;
         }
       }
@@ -542,7 +726,7 @@ export default {
         formData.specName = '';
         return formData;
       }
-      formData.specList = formData.specList.map(item => {
+      formData.specList = formData.specList.map((item, index) => {
         const spec = this.createSpecData(item);
         spec.priceType = parseInt(spec.priceType);
         spec.price = spec.priceType === 1 ? parseFloat(spec.price || 0) : 0;
@@ -552,7 +736,13 @@ export default {
         spec.weight = parseFloat(spec.weight || 0);
         spec.sold = parseInt(spec.sold || 0);
         spec.shopImg = Array.isArray(spec.shopImg) ? spec.shopImg : [];
-        spec.cover = spec.shopImg.length > 0 ? (spec.shortCover || spec.cover || spec.shopImg[0]) : '';
+        spec.cover = this.resolveCoverPath(spec.shopImg, spec.shortCover || spec.cover);
+        // 规格1即主记录:把卡片内编辑的图片/封面回写到主表单字段
+        if (index == 0) {
+          formData.shopImg = spec.shopImg.slice();
+          formData.cover = spec.cover;
+          formData.shortCover = spec.cover;
+        }
         delete spec.currentImgData;
         delete spec.shortCover;
         return spec;
@@ -581,7 +771,7 @@ export default {
         
         // 如果是第一个选中的分类且是"花束",则将分类名称添加到商品名称中
         if (!this.firstCategorySelected) {
-          this.form.name = item.categoryName + (this.form.name || "");
+          this.form.name = this.form.name == "" ? item.categoryName : this.form.name;
           this.firstCategorySelected = true;
         }
       } else {
@@ -632,34 +822,26 @@ export default {
         this.$msg("请选择分类");
         return false;
       }
+      // 多规格时图片在规格1卡片内,先回写再校验主记录是否有图
+      if (this.form.specEnabled == 1) {
+        this.syncFirstSpecImagesToForm();
+      }
       if (this.$util.isEmpty(this.form.shopImg)) {
         this.$msg("请上传图片");
         return false;
       }
 
-      // 如果没有选择封面,默认使用第一张图片
-      if (!this.form.shortCover && this.form.shopImg.length > 0) {
-        this.form.cover = this.form.shopImg[0];
-        this.form.shortCover = this.form.shopImg[0];
-      }
+      // 封面必须是已上传图片路径;占位值 '1' 视为未选,回落到第一张
+      const cover = this.resolveCoverPath(this.form.shopImg, this.form.shortCover || this.form.cover);
+      this.form.cover = cover;
+      this.form.shortCover = cover;
       
       that.$util.confirmModal({content:'确认添加?'},() => {
         uni.showLoading({mask:true})
         let formData = JSON.parse(JSON.stringify(this.form));
         
-        // 修复封面处理逻辑,与detail.vue保持一致
-        if (this.form.shopImg.length > 0) {
-          // 找到当前封面在图片列表中的索引
-          const coverIndex = this.form.shopImg.findIndex(img => img === this.form.shortCover);
-          if (coverIndex !== -1) {
-            formData.cover = this.form.shopImg[coverIndex];
-          } else {
-            // 如果找不到封面,使用第一张图片
-            formData.cover = this.form.shopImg[0];
-          }
-        } else {
-          formData.cover = '';
-        }
+        // 提交前再按图片列表校正封面,避免把序号占位值写入 xhGoods.cover
+        formData.cover = this.resolveCoverPath(this.form.shopImg, this.form.shortCover || this.form.cover);
         formData = this.normalizeSpecSubmitData(formData);
         if(formData.stockSet === '0'){
           formData.stock = 9999;
@@ -834,35 +1016,99 @@ export default {
   }
 }
 .spec-wrap {
-  padding: 0 0 20upx;
+  padding: 0 20upx 20upx;
 }
 .spec-card {
-  margin-bottom: 24upx;
+  margin-bottom: 20upx;
   background: #ffffff;
-  border-top: 1px solid #eeeeee;
-  border-bottom: 1px solid #eeeeee;
+  border: 4px solid #e8e8e8;
+  border-radius: 16upx;
+  overflow: hidden;
 }
 .spec-card-head {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  padding: 20upx 30upx 0;
+  padding: 20upx 24upx 12upx;
+}
+.spec-card-head-left {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  min-width: 0;
 }
 .spec-card-title {
   font-size: 30upx;
   color: #222222;
   font-weight: 700;
+  flex-shrink: 0;
 }
 .spec-remove {
-  margin: 0;
+  margin: 0 0 0 16upx;
   padding: 0 20upx;
-  height: 54upx;
-  line-height: 54upx;
+  height: 52upx;
+  line-height: 52upx;
   border: 1px solid #eeeeee;
   border-radius: 8upx;
   background: #ffffff;
   color: #999999;
   font-size: 24upx;
+  flex-shrink: 0;
+}
+.spec-add-btn {
+  margin: 0 0 0 16upx;
+  padding: 0 20upx;
+  height: 52upx;
+  line-height: 52upx;
+  border-radius: 8upx;
+  background: #09C567;
+  color: #ffffff;
+  font-size: 24upx;
+  flex-shrink: 0;
+  margin-left: 40upx;
+}
+.spec-add-btn::after,
+.spec-remove::after {
+  border: none;
+}
+.spec-tab-scroll {
+  width: 100%;
+  height: 80upx;
+  white-space: nowrap;
+  border-bottom: 1px solid #f0f0f0;
+}
+.spec-tab-list {
+  display: inline-flex;
+  flex-direction: row;
+  align-items: center;
+  height: 80upx;
+  padding: 0 12upx;
+}
+.spec-tab-item {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 80upx;
+  padding: 0 28upx;
+  margin-right: 8upx;
+  font-size: 28upx;
+  color: #666666;
+  flex-shrink: 0;
+  max-width: 240upx;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-sizing: border-box;
+}
+.spec-tab-item.active {
+  color: #09C567;
+  font-weight: 700;
+  border-bottom: 4upx solid #09C567;
+}
+.spec-tab-text {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
 }
 .spec-line {
   margin-top: 0;
@@ -898,15 +1144,6 @@ export default {
     margin-bottom: 16upx;
   }
 }
-.spec-add-btn {
-  margin: 20upx 24upx 0;
-  height: 88upx;
-  line-height: 88upx;
-  border-radius: 8upx;
-  background: #09C567;
-  color: #ffffff;
-  font-size: 30upx;
-}
 
 /* 确保所有选中的按钮都使用相同的颜色 */
 .admin-button-com:not(.default) {

+ 394 - 146
hdApp/src/admin/goods/detail.vue

@@ -1,7 +1,7 @@
 <!--
   花店后台编辑商品
   谁用:hdApp 管理端修改花束
-  解决问题:详情 specList 首项即主记录自身;提交时把首规格名称回写主表单 specName
+  解决问题:详情 specList 首项即主记录自身;开启多规格时隐藏卡片外主图/封面,改在规格卡片内编辑(规格1回写主记录);提交时把首规格名称回写主表单 specName;多规格用可横滑 tab 切换卡片编辑
 -->
 <template>
   <view class="app-content">
@@ -9,7 +9,135 @@
     <form @submit="formSubmit">
       <view class="module-com input-line-wrap">
 
-        <view class="module-com input-line-wrap goods-wrap">
+        <tui-list-cell class="line-cell" :hover="false">
+          <view class="tui-title required">名称</view>
+          <input v-model="form.name" placeholder-class="phcolor" class="tui-input" name="name" placeholder="请输入"
+            maxlength="50" type="text" />
+        </tui-list-cell>
+
+        <view class="spec-switch-card">
+          <view class="spec-switch-left">
+            <view class="spec-switch-icon">▦</view>
+            <view>
+              <view class="spec-switch-title">启用多规格</view>
+              <view class="spec-switch-desc">开启后可设置商品多规格属性</view>
+            </view>
+          </view>
+          <view class="spec-switch-right">
+            <switch :checked="form.specEnabled == 1" @change="toggleSpecs" color="#09C567" />
+            <text>{{ form.specEnabled == 1 ? '已开启' : '未开启' }}</text>
+          </view>
+        </view>
+
+        <view v-if="form.specEnabled == 1" class="spec-wrap">
+          <view class="spec-card">
+            <view class="spec-card-head">
+              <view class="spec-card-head-left">
+                <view class="spec-card-title">规格</view>
+                <!-- 新增规格放标题旁,避免卡片下方占一整行 -->
+                <button class="spec-add-btn" @click.stop.prevent="addSpec">新增规格</button>
+              </view>
+              <button v-if="form.specList.length > 1" class="spec-remove"
+                @click.stop.prevent="removeSpec(specTabIndex)">删除</button>
+            </view>
+            <!-- 规格 tab:每新增一组出现一项,超出屏宽后可左右滑动 -->
+            <scroll-view
+              class="spec-tab-scroll"
+              scroll-x
+              :scroll-into-view="specTabScrollId"
+              :scroll-with-animation="true"
+              :show-scrollbar="false"
+            >
+              <view class="spec-tab-list">
+                <view
+                  v-for="(spec, index) in form.specList"
+                  :id="specTabDomId(index)"
+                  :key="index"
+                  :class="specTabIndex == index ? 'spec-tab-item active' : 'spec-tab-item'"
+                  @click.stop.prevent="selectSpecTab(index)"
+                >
+                  <text class="spec-tab-text">{{ specTabLabel(index, spec) }}</text>
+                </view>
+              </view>
+            </scroll-view>
+            <view v-if="currentSpec" class="spec-card-body">
+              <tui-list-cell class="line-cell spec-line" :hover="false">
+                <view class="tui-title required">{{ currentSpecTitle }}名称</view>
+                <input :value="currentSpec.specName" @input="setSpecField(specTabIndex, 'specName', $event.detail.value)"
+                  placeholder-class="phcolor" class="tui-input" placeholder="请输入规格名称" maxlength="50" type="text" />
+              </tui-list-cell>
+              <!-- 多规格时图片/封面改在卡片内编辑;规格1对应主记录,其余规格不传则沿用主图 -->
+              <view class="spec-image-wrap">
+                <view class="module-tit">
+                  <span>图片</span>
+                  <span class="app-color-3">{{ specTabIndex == 0 ? '(最多5张)' : '(不传则使用主商品图片,最多5张)' }}</span>
+                </view>
+                <view class="module-det">
+                  <htz-image-upload :key="specTabIndex" :max="5" :compress="true" :value="currentSpec.currentImgData || []" :headers="headers"
+                    @input="setSpecField(specTabIndex, 'currentImgData', $event)"
+                    @uploadSuccess="specImgUploadSuccess($event, specTabIndex)" @imgDelete="specImgDeleteFn($event, specTabIndex)"
+                    :action="getLoginInfo.imgUploadApi">
+                  </htz-image-upload>
+                </view>
+                <view v-if="currentSpec.shopImg && currentSpec.shopImg.length > 0" class="spec-cover-row">
+                  <view class="spec-cover-title">封面</view>
+                  <view class="spec-cover-btns">
+                    <button class="admin-button-com" :class="[(currentSpec.shortCover == item) ? '' : 'default']"
+                      v-for="(item, imgIndex) in currentSpec.shopImg" :key="imgIndex"
+                      @click.stop.prevent="selSpecCoverFn(specTabIndex, imgIndex)">第{{ imgIndex + 1 }}张</button>
+                  </view>
+                </view>
+              </view>
+              <tui-list-cell class="line-cell spec-line" :hover="false">
+                <view class="tui-title">价格类型</view>
+                <view class="btn-group">
+                  <button class="admin-button-com middle" :class="[currentSpec.priceType == 1 ? '' : 'default']"
+                    @click.stop.prevent="setSpecPriceType(specTabIndex, '1')">有价格</button>
+                  <button class="admin-button-com middle" :class="[currentSpec.priceType == 0 ? '' : 'default']"
+                    @click.stop.prevent="setSpecPriceType(specTabIndex, '0')">无价格</button>
+                </view>
+              </tui-list-cell>
+              <tui-list-cell v-if="currentSpec.priceType == 1" class="line-cell spec-line" :hover="false">
+                <view class="tui-title required">价格</view>
+                <input :value="currentSpec.price" @input="setSpecField(specTabIndex, 'price', $event.detail.value)"
+                  placeholder-class="phcolor" class="tui-input" placeholder="请输入价格" maxlength="10" type="digit" />
+              </tui-list-cell>
+              <tui-list-cell class="line-cell spec-line" :hover="false">
+                <view class="tui-title">库存</view>
+                <view class="btn-group">
+                  <button class="admin-button-com middle" :class="[currentSpec.stockSet == 0 ? '' : 'default']"
+                    @click.stop.prevent="setSpecStockSet(specTabIndex, '0')">管够</button>
+                  <button class="admin-button-com middle" :class="[currentSpec.stockSet == 1 ? '' : 'default']"
+                    @click.stop.prevent="setSpecStockSet(specTabIndex, '1')">自定义</button>
+                </view>
+              </tui-list-cell>
+              <tui-list-cell v-if="currentSpec.stockSet == 1" class="line-cell spec-line" :hover="false">
+                <view class="tui-title">库存</view>
+                <input :value="currentSpec.stock" @input="setSpecField(specTabIndex, 'stock', $event.detail.value)"
+                  placeholder-class="phcolor" class="tui-input" placeholder="请输入库存数量" maxlength="10" type="number" />
+              </tui-list-cell>
+              <tui-list-cell class="line-cell spec-line" :hover="false">
+                <view class="tui-title">用花支数</view>
+                <input :value="currentSpec.flowerNum" @input="setSpecField(specTabIndex, 'flowerNum', $event.detail.value)"
+                  placeholder-class="phcolor" class="tui-input" placeholder="用于判断花束大小" maxlength="5" type="number" />
+              </tui-list-cell>
+              <tui-list-cell class="line-cell spec-line" :hover="false">
+                <view class="tui-title">重量</view>
+                <input :value="currentSpec.weight" @input="setSpecField(specTabIndex, 'weight', $event.detail.value)"
+                  placeholder-class="phcolor" class="tui-input" placeholder="公斤,可输入小数" maxlength="10" type="digit" />
+              </tui-list-cell>
+              <tui-list-cell class="line-cell spec-line" :hover="false">
+                <view class="tui-title">已售数量</view>
+                <input :value="currentSpec.sold" @input="setSpecField(specTabIndex, 'sold', $event.detail.value)"
+                  placeholder-class="phcolor" class="tui-input" placeholder="客户端展示:已售数量+实售数量" maxlength="10"
+                  type="number" />
+              </tui-list-cell>
+            </view>
+          </view>
+        </view>
+
+        <!-- 开启多规格后主图/封面改在规格卡片内编辑,避免与规格1重复 -->
+        <view v-if="form.specEnabled != 1" class="module-com input-line-wrap goods-wrap">
           <view class="module-tit">
             <span>图片</span>
             <span class="app-color-3">(最多5张)</span>
@@ -21,7 +149,7 @@
           </view>
         </view>
 
-        <tui-list-cell class="line-cell category-wrap" :hover="false">
+        <tui-list-cell v-if="form.specEnabled != 1" class="line-cell category-wrap" :hover="false">
           <view class="tui-title required">封面</view>
           <view class="tui-input">
             <block v-if="coverImgData.length > 0">
@@ -54,106 +182,6 @@
           </view>
         </tui-list-cell>
 
-        <tui-list-cell class="line-cell" :hover="false">
-          <view class="tui-title required">名称</view>
-          <input v-model="form.name" placeholder-class="phcolor" class="tui-input" name="name" placeholder="请输入"
-            maxlength="50" type="text" />
-        </tui-list-cell>
-        <view class="spec-switch-card">
-          <view class="spec-switch-left">
-            <view class="spec-switch-icon">▦</view>
-            <view>
-              <view class="spec-switch-title">启用多规格</view>
-              <view class="spec-switch-desc">开启后可设置商品多规格属性</view>
-            </view>
-          </view>
-          <view class="spec-switch-right">
-            <switch :checked="form.specEnabled == 1" @change="toggleSpecs" color="#09C567" />
-            <text>{{ form.specEnabled == 1 ? '已开启' : '未开启' }}</text>
-          </view>
-        </view>
-
-        <view v-if="form.specEnabled == 1" class="spec-wrap">
-          <view v-for="(spec, index) in form.specList" :key="spec.id || index" class="spec-card">
-            <view class="spec-card-head">
-              <view class="spec-card-title">规格组名称{{ index + 1 }}</view>
-              <button v-if="form.specList.length > 1" class="spec-remove"
-                @click.stop.prevent="removeSpec(index)">删除</button>
-            </view>
-            <tui-list-cell class="line-cell spec-line" :hover="false">
-              <view class="tui-title required">规格组名称{{ index + 1 }}</view>
-              <input :value="spec.specName" @input="setSpecField(index, 'specName', $event.detail.value)"
-                placeholder-class="phcolor" class="tui-input" placeholder="请输入规格名称" maxlength="50" type="text" />
-            </tui-list-cell>
-            <view class="spec-image-wrap">
-              <view class="module-tit">
-                <span>图片</span>
-                <span class="app-color-3">(不传则使用主商品图片,最多5张)</span>
-              </view>
-              <view class="module-det">
-                <htz-image-upload :max="5" :compress="true" :value="spec.currentImgData || []" :headers="headers"
-                  @input="setSpecField(index, 'currentImgData', $event)"
-                  @uploadSuccess="specImgUploadSuccess($event, index)" @imgDelete="specImgDeleteFn($event, index)"
-                  :action="getLoginInfo.imgUploadApi">
-                </htz-image-upload>
-              </view>
-              <view v-if="spec.shopImg && spec.shopImg.length > 0" class="spec-cover-row">
-                <view class="spec-cover-title">封面</view>
-                <view class="spec-cover-btns">
-                  <button class="admin-button-com" :class="[(spec.shortCover == item) ? '' : 'default']"
-                    v-for="(item, imgIndex) in spec.shopImg" :key="imgIndex"
-                    @click.stop.prevent="selSpecCoverFn(index, imgIndex)">第{{ imgIndex + 1 }}张</button>
-                </view>
-              </view>
-            </view>
-            <tui-list-cell class="line-cell spec-line" :hover="false">
-              <view class="tui-title">价格类型</view>
-              <view class="btn-group">
-                <button class="admin-button-com middle" :class="[spec.priceType == 1 ? '' : 'default']"
-                  @click.stop.prevent="setSpecPriceType(index, '1')">有价格</button>
-                <button class="admin-button-com middle" :class="[spec.priceType == 0 ? '' : 'default']"
-                  @click.stop.prevent="setSpecPriceType(index, '0')">无价格</button>
-              </view>
-            </tui-list-cell>
-            <tui-list-cell v-if="spec.priceType == 1" class="line-cell spec-line" :hover="false">
-              <view class="tui-title required">价格</view>
-              <input :value="spec.price" @input="setSpecField(index, 'price', $event.detail.value)"
-                placeholder-class="phcolor" class="tui-input" placeholder="请输入价格" maxlength="10" type="digit" />
-            </tui-list-cell>
-            <tui-list-cell class="line-cell spec-line" :hover="false">
-              <view class="tui-title">库存</view>
-              <view class="btn-group">
-                <button class="admin-button-com middle" :class="[spec.stockSet == 0 ? '' : 'default']"
-                  @click.stop.prevent="setSpecStockSet(index, '0')">管够</button>
-                <button class="admin-button-com middle" :class="[spec.stockSet == 1 ? '' : 'default']"
-                  @click.stop.prevent="setSpecStockSet(index, '1')">自定义</button>
-              </view>
-            </tui-list-cell>
-            <tui-list-cell v-if="spec.stockSet == 1" class="line-cell spec-line" :hover="false">
-              <view class="tui-title">库存</view>
-              <input :value="spec.stock" @input="setSpecField(index, 'stock', $event.detail.value)"
-                placeholder-class="phcolor" class="tui-input" placeholder="请输入库存数量" maxlength="10" type="number" />
-            </tui-list-cell>
-            <tui-list-cell class="line-cell spec-line" :hover="false">
-              <view class="tui-title">用花支数</view>
-              <input :value="spec.flowerNum" @input="setSpecField(index, 'flowerNum', $event.detail.value)"
-                placeholder-class="phcolor" class="tui-input" placeholder="用于判断花束大小" maxlength="5" type="number" />
-            </tui-list-cell>
-            <tui-list-cell class="line-cell spec-line" :hover="false">
-              <view class="tui-title">重量</view>
-              <input :value="spec.weight" @input="setSpecField(index, 'weight', $event.detail.value)"
-                placeholder-class="phcolor" class="tui-input" placeholder="公斤,可输入小数" maxlength="10" type="digit" />
-            </tui-list-cell>
-            <tui-list-cell class="line-cell spec-line" :hover="false">
-              <view class="tui-title">已售数量</view>
-              <input :value="spec.sold" @input="setSpecField(index, 'sold', $event.detail.value)"
-                placeholder-class="phcolor" class="tui-input" placeholder="客户端展示:已售数量+实售数量" maxlength="10"
-                type="number" />
-            </tui-list-cell>
-          </view>
-          <button class="spec-add-btn" @click.stop.prevent="addSpec">新增规格组</button>
-        </view>
-
         <tui-list-cell v-if="form.specEnabled != 1" class="line-cell" :hover="false">
           <view class="tui-title">价格类型</view>
           <view class="btn-group">
@@ -306,7 +334,10 @@ export default {
       },
       categoryData: [], useCaseData: [],
       categoryIds: [],
-      sn: 0
+      sn: 0,
+      // 当前展示的规格 tab 下标;仅前端展示用,不随表单提交
+      specTabIndex: 0,
+      specTabScrollId: 'specTab0'
     };
   },
   onLoad() {
@@ -330,6 +361,26 @@ export default {
     soldBlurHandler() {
       return this.createBlurHandler('sold');
     },
+    /**
+     * 当前 tab 对应的规格对象,供卡片内容绑定
+     */
+    currentSpec() {
+      const list = this.form.specList || [];
+      if (!list.length) {
+        return null;
+      }
+      const index = this.specTabIndex;
+      if (index < 0 || index >= list.length) {
+        return list[0];
+      }
+      return list[index];
+    },
+    /**
+     * 卡片标题用「规格N」,与 tab 序号保持一致
+     */
+    currentSpecTitle() {
+      return '规格' + (this.specTabIndex + 1);
+    },
   },
   methods: {
     init() { },
@@ -357,6 +408,24 @@ export default {
         return ''
       }
     },
+    /**
+     * 从图片列表解析真正的封面短路径。
+     * 旧表单默认 cover='1' 表示「第1张」,不是路径;只有 preferred 真在 shopImg 里才采用。
+     * @param {Array} shopImg 短路径图片列表
+     * @param {*} preferredCover 用户选中的封面或历史 cover
+     * @returns {string} 有效短路径,没有图则空字符串
+     */
+    resolveCoverPath(shopImg, preferredCover) {
+      const list = Array.isArray(shopImg) ? shopImg : [];
+      if (!list.length) {
+        return '';
+      }
+      const preferred = preferredCover == null ? '' : String(preferredCover);
+      if (preferred && list.indexOf(preferred) !== -1) {
+        return preferred;
+      }
+      return list[0] || '';
+    },
     imgDeleteFn(res) {
       const index = res.index
       // 先记录对应的短路径(无域名、无参数)
@@ -449,6 +518,8 @@ export default {
         this.form.specEnabled = Number(res.data.specEnabled || res.data.goodsStyle || 0);
         // 详情 specList 首项即主记录自身(id=主商品id),createSpecData 直接兼容
         this.form.specList = (res.data.specList || []).map(item => this.createSpecData(item));
+        this.specTabIndex = 0;
+        this.scrollToSpecTab();
 
         // 设置价格类型
         if (res.data.priceType == "0") {
@@ -486,6 +557,8 @@ export default {
           this.form.cover = this.form.shopImg[0];
           this.form.shortCover = this.form.shopImg[0];
         }
+        // 多规格时规格1在卡片内编辑主图,需把主记录图片灌进首规格
+        this.syncFormImagesToFirstSpec()
         getClass().then(res => {
           if (res.code && res.code == 1) {
             this.categoryData = res.data
@@ -518,7 +591,8 @@ export default {
     createSpecData(data = {}) {
       const shopImg = Array.isArray(data.shortImgList) ? data.shortImgList : (Array.isArray(data.shopImg) ? data.shopImg : []);
       const currentImgData = Array.isArray(data.currentImgData) ? data.currentImgData : (Array.isArray(data.smallImgList) ? data.smallImgList : []);
-      const shortCover = data.shortCover || (shopImg.includes(data.cover) ? data.cover : '') || shopImg[0] || '';
+      // 封面必须是图片列表中的路径,避免把历史占位值 '1' 当成封面
+      const shortCover = this.resolveCoverPath(shopImg, data.shortCover || data.cover);
       return {
         id: data.id || 0,
         specName: data.specName || '',
@@ -535,8 +609,70 @@ export default {
         shortCover
       }
     },
-    addSpec() { this.form.specList.push(this.createSpecData()) },
-    removeSpec(index) { this.form.specList.splice(index, 1) },
+    /**
+     * 生成规格 tab 的 DOM id,供 scroll-into-view 定位(必须以字母开头)
+     * @param {number} index 规格下标
+     * @returns {string}
+     */
+    specTabDomId(index) {
+      return 'specTab' + index;
+    },
+    /**
+     * tab 文案:已填规格名则展示名称,便于多规格辨认;否则回退为「规格N」
+     * @param {number} index 规格下标
+     * @param {Object} spec 规格对象
+     * @returns {string}
+     */
+    specTabLabel(index, spec) {
+      const name = spec && spec.specName ? String(spec.specName).trim() : '';
+      if (name) {
+        return name;
+      }
+      return '规格' + (index + 1);
+    },
+    /**
+     * 横向滚动定位到当前 tab。先清空再赋值,避免 scroll-into-view 同值不触发
+     */
+    scrollToSpecTab() {
+      this.specTabScrollId = '';
+      this.$nextTick(() => {
+        this.specTabScrollId = this.specTabDomId(this.specTabIndex);
+      });
+    },
+    /**
+     * 点击 tab 切换当前规格卡片
+     * @param {number} index 目标规格下标
+     */
+    selectSpecTab(index) {
+      this.specTabIndex = index;
+      this.scrollToSpecTab();
+    },
+    /**
+     * 新增规格组后切到新 tab,让用户直接编辑
+     */
+    addSpec() {
+      this.form.specList.push(this.createSpecData());
+      this.specTabIndex = this.form.specList.length - 1;
+      this.scrollToSpecTab();
+    },
+    /**
+     * 删除指定规格。删当前项后校正下标,避免越界或指向错位的规格
+     * @param {number} index 要删除的规格下标
+     */
+    removeSpec(index) {
+      if (this.form.specList.length <= 1) {
+        return;
+      }
+      this.form.specList.splice(index, 1);
+      const lastIndex = this.form.specList.length - 1;
+      // 删的是当前或更前的项时,下标要跟着列表左移
+      if (this.specTabIndex > lastIndex) {
+        this.specTabIndex = lastIndex;
+      } else if (this.specTabIndex > index) {
+        this.specTabIndex = this.specTabIndex - 1;
+      }
+      this.scrollToSpecTab();
+    },
     setSpecField(index, field, value) {
       const spec = this.form.specList[index] || this.createSpecData();
       const nextSpec = { ...spec, [field]: value };
@@ -548,7 +684,7 @@ export default {
       if (uploadRes.code == 1) {
         const currentImgData = [...(spec.currentImgData || []), uploadRes.data.smallUrl];
         const shopImg = [...(spec.shopImg || []), uploadRes.data.shortUrl];
-        const shortCover = spec.shortCover || uploadRes.data.shortUrl;
+        const shortCover = this.resolveCoverPath(shopImg, spec.shortCover || spec.cover);
         this.$set(this.form.specList, index, {
           ...spec,
           currentImgData,
@@ -565,7 +701,7 @@ export default {
       const shopImg = [...(spec.shopImg || [])];
       const shortDelPath = (shopImg[imgIndex] || this.normalizeImgPath(res.del)).replace(/^\//, '');
       shopImg.splice(imgIndex, 1);
-      const shortCover = shopImg.includes(spec.shortCover) ? spec.shortCover : (shopImg[0] || '');
+      const shortCover = this.resolveCoverPath(shopImg, spec.shortCover || spec.cover);
       this.$set(this.form.specList, index, {
         ...spec,
         currentImgData,
@@ -586,11 +722,57 @@ export default {
         shortCover
       });
     },
+    /**
+     * 开启多规格时把当前主图/封面灌进规格1,避免卡片内上传区空白
+     */
+    syncFormImagesToFirstSpec() {
+      if (this.form.specEnabled != 1 || !this.form.specList || !this.form.specList.length) {
+        return;
+      }
+      const firstSpec = this.form.specList[0];
+      const shopImg = Array.isArray(this.form.shopImg) ? this.form.shopImg.slice() : [];
+      const currentImgData = Array.isArray(this.currentImgData) ? this.currentImgData.slice() : [];
+      const cover = this.resolveCoverPath(shopImg, this.form.shortCover || this.form.cover);
+      this.$set(this.form.specList, 0, {
+        ...firstSpec,
+        shopImg,
+        currentImgData,
+        cover,
+        shortCover: cover
+      });
+    },
+    /**
+     * 多规格时规格1在卡片内编辑图片/封面,关闭多规格或提交前把规格1回写到主表单
+     */
+    syncFirstSpecImagesToForm() {
+      if (this.form.specEnabled != 1) {
+        return;
+      }
+      const firstSpec = (this.form.specList && this.form.specList[0]) || null;
+      if (!firstSpec) {
+        return;
+      }
+      this.form.shopImg = Array.isArray(firstSpec.shopImg) ? firstSpec.shopImg.slice() : [];
+      this.currentImgData = Array.isArray(firstSpec.currentImgData) ? firstSpec.currentImgData.slice() : [];
+      const cover = this.resolveCoverPath(this.form.shopImg, firstSpec.shortCover || firstSpec.cover);
+      this.form.cover = cover;
+      this.form.shortCover = cover;
+    },
     toggleSpecs(e) {
-      this.form.specEnabled = e.detail.value ? 1 : 0;
-      if (this.form.specEnabled && !this.form.specList.length) {
-        this.form.specList.push(this.createSpecData(this.form))
+      const enabled = e.detail.value ? 1 : 0;
+      // 关闭前先把规格1图片回写主表单,此时 specEnabled 仍为 1
+      if (!enabled) {
+        this.syncFirstSpecImagesToForm();
       }
+      this.form.specEnabled = enabled;
+      if (this.form.specEnabled) {
+        if (!this.form.specList.length) {
+          this.form.specList.push(this.createSpecData(this.form));
+        }
+        this.syncFormImagesToFirstSpec();
+      }
+      this.specTabIndex = 0;
+      this.scrollToSpecTab();
     },
     setSpecPriceType(index, type) {
       const spec = this.form.specList[index] || this.createSpecData();
@@ -674,16 +856,20 @@ export default {
         return '';
       }
       if (!this.form.specList.length) {
-        return '请至少添加一个规格';
+        return '请至少添加一个规格';
       }
       for (let i = 0; i < this.form.specList.length; i++) {
         const spec = this.form.specList[i];
-        const name = `规格${i + 1}`;
+        const name = `规格${i + 1}`;
         if (!spec.specName || !String(spec.specName).trim()) {
+          this.specTabIndex = i;
+          this.scrollToSpecTab();
           return `请输入${name}名称`;
         }
         const numErr = this.validateGoodsNumberFields(spec, name);
         if (numErr) {
+          this.specTabIndex = i;
+          this.scrollToSpecTab();
           return numErr;
         }
       }
@@ -698,7 +884,7 @@ export default {
         formData.specName = '';
         return formData;
       }
-      formData.specList = formData.specList.map(item => {
+      formData.specList = formData.specList.map((item, index) => {
         const spec = this.createSpecData(item);
         spec.priceType = parseInt(spec.priceType);
         spec.price = spec.priceType === 1 ? parseFloat(spec.price || 0) : 0;
@@ -708,7 +894,13 @@ export default {
         spec.weight = parseFloat(spec.weight || 0);
         spec.sold = parseInt(spec.sold || 0);
         spec.shopImg = Array.isArray(spec.shopImg) ? spec.shopImg : [];
-        spec.cover = spec.shopImg.length > 0 ? (spec.shortCover || spec.cover || spec.shopImg[0]) : '';
+        spec.cover = this.resolveCoverPath(spec.shopImg, spec.shortCover || spec.cover);
+        // 规格1即主记录:把卡片内编辑的图片/封面回写到主表单字段
+        if (index == 0) {
+          formData.shopImg = spec.shopImg.slice();
+          formData.cover = spec.cover;
+          formData.shortCover = spec.cover;
+        }
         delete spec.currentImgData;
         delete spec.shortCover;
         return spec;
@@ -778,25 +970,18 @@ export default {
       //   return false
       // }
 
+      // 多规格时图片在规格1卡片内,先回写再校验主记录是否有图
+      if (this.form.specEnabled == 1) {
+        this.syncFirstSpecImagesToForm();
+      }
       if (this.$util.isEmpty(this.form.shopImg)) {
         this.$msg("请上传图片")
         return false
       }
 
       let form = JSON.parse(JSON.stringify(this.form))
-      // 修复封面处理逻辑
-      if (this.form.shopImg.length > 0) {
-        // 找到当前封面在图片列表中的索引
-        const coverIndex = this.form.shopImg.findIndex(img => img === this.form.shortCover);
-        if (coverIndex !== -1) {
-          form.cover = this.form.shopImg[coverIndex];
-        } else {
-          // 如果找不到封面,使用第一张图片
-          form.cover = this.form.shopImg[0];
-        }
-      } else {
-        form.cover = '';
-      }
+      // 提交前按图片列表校正封面,避免把序号占位值写入 xhGoods.cover
+      form.cover = this.resolveCoverPath(this.form.shopImg, this.form.shortCover || this.form.cover);
       form = this.normalizeSpecSubmitData(form)
       updateGoodsData({ ...form }).then(res => {
         //this.removeFromSaveDirect()
@@ -1042,39 +1227,111 @@ export default {
 }
 
 .spec-wrap {
-  padding: 0 0 20upx;
+  padding: 0 20upx 20upx;
 }
 
 .spec-card {
-  margin-bottom: 24upx;
+  margin-bottom: 20upx;
   background: #ffffff;
-  border-top: 1px solid #eeeeee;
-  border-bottom: 1px solid #eeeeee;
+  border: 3px solid #e8e8e8;
+  border-radius: 16upx;
+  overflow: hidden;
 }
 
 .spec-card-head {
   display: flex;
   align-items: center;
   justify-content: space-between;
-  padding: 20upx 30upx 0;
+  padding: 20upx 24upx 12upx;
+}
+
+.spec-card-head-left {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  min-width: 0;
 }
 
 .spec-card-title {
   font-size: 30upx;
   color: #222222;
   font-weight: 700;
+  flex-shrink: 0;
 }
 
 .spec-remove {
-  margin: 0;
+  margin: 0 0 0 16upx;
   padding: 0 20upx;
-  height: 54upx;
-  line-height: 54upx;
+  height: 52upx;
+  line-height: 52upx;
   border: 1px solid #eeeeee;
   border-radius: 8upx;
   background: #ffffff;
   color: #999999;
   font-size: 24upx;
+  flex-shrink: 0;
+}
+
+.spec-add-btn {
+  margin: 0 0 0 16upx;
+  padding: 0 20upx;
+  height: 52upx;
+  line-height: 52upx;
+  border-radius: 8upx;
+  background: #09C567;
+  color: #ffffff;
+  font-size: 24upx;
+  flex-shrink: 0;
+  margin-left: 40upx;
+}
+
+.spec-add-btn::after,
+.spec-remove::after {
+  border: none;
+}
+
+.spec-tab-scroll {
+  width: 100%;
+  height: 80upx;
+  white-space: nowrap;
+  border-bottom: 1px solid #f0f0f0;
+}
+
+.spec-tab-list {
+  display: inline-flex;
+  flex-direction: row;
+  align-items: center;
+  height: 80upx;
+  padding: 0 12upx;
+}
+
+.spec-tab-item {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 80upx;
+  padding: 0 28upx;
+  margin-right: 8upx;
+  font-size: 28upx;
+  color: #666666;
+  flex-shrink: 0;
+  max-width: 240upx;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-sizing: border-box;
+}
+
+.spec-tab-item.active {
+  color: #09C567;
+  font-weight: 700;
+  border-bottom: 4upx solid #09C567;
+}
+
+.spec-tab-text {
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
 }
 
 .spec-line {
@@ -1119,15 +1376,6 @@ export default {
   }
 }
 
-.spec-add-btn {
-  margin: 20upx 24upx 0;
-  height: 88upx;
-  line-height: 88upx;
-  border-radius: 8upx;
-  background: #09C567;
-  color: #ffffff;
-  font-size: 30upx;
-}
 
 /* 确保所有选中的按钮都使用相同的颜色 */
 .admin-button-com:not(.default) {

+ 1 - 1
hdApp/src/admin/goods/manage.vue

@@ -1605,7 +1605,7 @@ page {
   min-width: 0;
   width: 100%;
   box-sizing: border-box;
-  padding-right: 18upx;
+  padding: 0 12upx 0;
 }
 
 .right-box {