Просмотр исходного кода

feat(sku-combo): Task5-7 组合SKU消费点 + 前端多选/组合模式(修改文件)

Task5 后端消费点:
- ProductOrderService.createMultiItem 支持组合SKU(父商品库存不拦截组合行、组成商品/SKU库存校验、手填售价、组合摘要写入skuSpecs)
- 支付成功按组合明细逐项扣减组成SKU/商品库存(deductComboStock),父商品库存不变
- 取消/退款/管理员关闭三处回补改为组合优先(restockComboOrder),不回补父商品
- CartService 组合分支:父商品名+组合摘要+手填售价
- ProductController 商品详情返回 isCombo=true 与 comboItems

Task6 前端 ProductPicker:
- 多选模式(selection列+每行SKU下拉+数量)、crossCategory参数、selected预填回勾、confirm校验与数组输出
- 修正 el-pagination 与 footer 误置于 el-table 内的问题

Task7 前端 ProductSkuEditor:
- SKU编辑器三模式(规格变体/关联商品/商品组合),组合模式支持多商品+数量+手填售价
- 组合回显、comboItems载荷下发、列表「规格/组合」列显示组合摘要
E2E Test Bot 1 неделя назад
Родитель
Сommit
8441936ab5

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java

@@ -5,6 +5,7 @@ import com.etotem.cfc.dto.ProductDTO;
 import com.etotem.cfc.dto.ProductListQueryDTO;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.entity.ProductSkuComboItem;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.PpointService;
@@ -119,6 +120,48 @@ public class ProductController {
             int groupId = 1;
 
             for (ProductSku sku : skus) {
+                // 组合 SKU: 返回 isCombo=true 与 comboItems 摘要
+                if (sku.getComboItems() != null && !sku.getComboItems().isEmpty()) {
+                    String optionName = (sku.getLabel() != null && !sku.getLabel().trim().isEmpty())
+                            ? sku.getLabel() : "商品组合";
+                    String groupName = "商品组合";
+                    if (!nameToGroupId.containsKey(groupName)) {
+                        nameToGroupId.put(groupName, groupId++);
+                        Map<String, Object> grp = new HashMap<>();
+                        grp.put("groupId", nameToGroupId.get(groupName));
+                        grp.put("groupName", groupName);
+                        grp.put("options", new ArrayList<Map<String, Object>>());
+                        groups.add(grp);
+                    }
+                    List<Map<String, Object>> comboList = new ArrayList<>();
+                    for (ProductSkuComboItem ci : sku.getComboItems()) {
+                        Map<String, Object> cm = new HashMap<>();
+                        cm.put("productId", ci.getChildProductId());
+                        cm.put("skuId", ci.getChildSkuId());
+                        cm.put("name", ci.getChildProductName());
+                        cm.put("image", ci.getChildProductImage());
+                        cm.put("price", ci.getChildProductPrice());
+                        cm.put("quantity", ci.getQuantity());
+                        comboList.add(cm);
+                    }
+                    Map<String, Object> option = new HashMap<>();
+                    option.put("id", sku.getId());
+                    option.put("groupId", nameToGroupId.get(groupName));
+                    option.put("name", optionName);
+                    option.put("skuId", sku.getId());
+                    option.put("isCombo", true);
+                    option.put("price", sku.getPrice());
+                    option.put("comboItems", comboList);
+                    for (Map<String, Object> g : groups) {
+                        if (g.get("groupId").equals(nameToGroupId.get(groupName))) {
+                            @SuppressWarnings("unchecked")
+                            List<Map<String, Object>> opts = (List<Map<String, Object>>) g.get("options");
+                            opts.add(option);
+                        }
+                    }
+                    continue;
+                }
+
                 boolean isLinked = sku.getLinkedProductId() != null;
 
                 if (isLinked) {

+ 8 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java

@@ -358,8 +358,14 @@ public class CartService {
         if (cart.getSkuId() != null) {
             ProductSku sku = productSkuService.getById(cart.getSkuId());
             if (sku != null) {
-                // Linked SKU: 转发到关联商品
-                if (sku.getLinkedProductId() != null) {
+                // 组合 SKU: 不替换商品,显示父商品名 + 组合摘要,价格取手填售价
+                if (sku.getComboItems() != null && !sku.getComboItems().isEmpty()) {
+                    if (sku.getPrice() != null) {
+                        dto.setUnitPrice(sku.getPrice());
+                    }
+                    dto.setSpecDesc(productSkuService.buildComboSummary(sku.getComboItems()));
+                } else if (sku.getLinkedProductId() != null) {
+                    // Linked SKU: 转发到关联商品
                     Product linkedProduct = productMapper.selectById(sku.getLinkedProductId());
                     if (linkedProduct != null) {
                         dto.setProductId(linkedProduct.getId());

+ 112 - 29
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -44,6 +44,7 @@ import com.etotem.cfc.mapper.ProductOrderMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.entity.ProductBundleItem;
 import com.etotem.cfc.entity.ProductSku;
+import com.etotem.cfc.entity.ProductSkuComboItem;
 import com.etotem.cfc.mapper.ProductBundleItemMapper;
 import com.etotem.cfc.service.ProductSkuService;
 import com.etotem.cfc.service.AssessmentProductService;
@@ -766,8 +767,10 @@ public class ProductOrderService {
             if (!"on_shelf".equals(product.getStatus())) {
                 return Result.error("商品已下架: " + product.getName());
             }
-            // 实物商品校验库存;虚拟商品/优惠券跳过库存检查
-            if ("physical".equals(product.getProductType())
+            ProductSku itemSku = item.getSkuId() != null ? productSkuService.getById(item.getSkuId()) : null;
+            boolean comboItem = productSkuService.hasComboItems(itemSku);
+            // 实物商品校验库存;虚拟商品/优惠券跳过库存检查(组合行由组成商品库存校验,不拦截父商品)
+            if (!comboItem && "physical".equals(product.getProductType())
                     && product.getStock() != null
                     && product.getStock() < item.getQuantity()) {
                 return Result.error("库存不足: " + product.getName());
@@ -779,28 +782,47 @@ public class ProductOrderService {
                     return Result.error(bundleCheck.getMessage());
                 }
             }
-            int unitPrice = memberPriceResolver.resolveEligibleMemberPrice(product, buyerId);
-            if (item.getSkuId() != null) {
-                ProductSku sku = productSkuService.getById(item.getSkuId());
-                if (sku != null) {
-                    // Linked SKU: 转发到关联商品
-                    if (sku.getLinkedProductId() != null) {
-                        Product linkedProduct = productMapper.selectById(sku.getLinkedProductId());
-                        if (linkedProduct != null) {
-                            product = linkedProduct;
-                            unitPrice = linkedProduct.getPrice() != null ? linkedProduct.getPrice() : 0;
+            // 组合 SKU:校验各组成商品/SKU 库存
+            if (comboItem) {
+                for (ProductSkuComboItem ci : itemSku.getComboItems()) {
+                    int need = (ci.getQuantity() == null ? 1 : ci.getQuantity()) * item.getQuantity();
+                    if (ci.getChildSkuId() != null) {
+                        if (!productSkuService.hasStock(ci.getChildSkuId(), need)) {
+                            return Result.error("组合商品库存不足: " + ci.getChildProductName());
                         }
                     } else {
-                        // Legacy SKU: 用 SKU 价格
-                        if (sku.getMinQuantity() != null && item.getQuantity() < sku.getMinQuantity()) {
-                            return Result.error(product.getName() + " 该规格最低需购买" + sku.getMinQuantity() + "件");
-                        }
-                        if (sku.getPrice() != null) {
-                            unitPrice = sku.getPrice();
+                        Product cp = productMapper.selectById(ci.getChildProductId());
+                        if (cp == null || cp.getStock() == null || cp.getStock() < need) {
+                            return Result.error("组合商品库存不足: "
+                                    + (cp == null ? ci.getChildProductId() : cp.getName()));
                         }
                     }
                 }
             }
+            int unitPrice = memberPriceResolver.resolveEligibleMemberPrice(product, buyerId);
+            if (itemSku != null) {
+                if (comboItem) {
+                    if (itemSku.getPrice() != null) unitPrice = itemSku.getPrice();
+                    if (itemSku.getMinQuantity() != null && item.getQuantity() < itemSku.getMinQuantity()) {
+                        return Result.error(product.getName() + " 该组合最低需购买" + itemSku.getMinQuantity() + "件");
+                    }
+                } else if (itemSku.getLinkedProductId() != null) {
+                    // Linked SKU: 转发到关联商品
+                    Product linkedProduct = productMapper.selectById(itemSku.getLinkedProductId());
+                    if (linkedProduct != null) {
+                        product = linkedProduct;
+                        unitPrice = linkedProduct.getPrice() != null ? linkedProduct.getPrice() : 0;
+                    }
+                } else {
+                    // Legacy SKU: 用 SKU 价格
+                    if (itemSku.getMinQuantity() != null && item.getQuantity() < itemSku.getMinQuantity()) {
+                        return Result.error(product.getName() + " 该规格最低需购买" + itemSku.getMinQuantity() + "件");
+                    }
+                    if (itemSku.getPrice() != null) {
+                        unitPrice = itemSku.getPrice();
+                    }
+                }
+            }
             // 产品级最小购买数量检查(当没有SKU或SKU不限制时)
             if (item.getSkuId() == null && product.getMinQuantity() != null && item.getQuantity() < product.getMinQuantity()) {
                 return Result.error(product.getName() + " 最低需购买" + product.getMinQuantity() + "件");
@@ -821,7 +843,9 @@ public class ProductOrderService {
             itemRecord.setSkuId(item.getSkuId());
             itemRecord.setProductName(product.getName());
             itemRecord.setProductImage(product.getCoverImage());
-            itemRecord.setSkuSpecs(item.getSpecOptionIds());
+            itemRecord.setSkuSpecs(comboItem
+                    ? productSkuService.buildComboSummary(itemSku.getComboItems())
+                    : item.getSpecOptionIds());
             itemRecord.setPrice(java.math.BigDecimal.valueOf(unitPrice));
             itemRecord.setQuantity(item.getQuantity());
             itemRecord.setSubtotal(java.math.BigDecimal.valueOf((long) unitPrice * item.getQuantity()));
@@ -835,6 +859,7 @@ public class ProductOrderService {
         order.setProductId(firstProduct.getId());
         order.setProductName(firstProduct.getName() + "等多件");
         order.setProductType(firstProduct.getProductType());
+        order.setSkuId(firstItem.getSkuId());
         order.setBuyerId(buyerId);
         order.setFamilyId(buyer != null ? buyer.getFamilyId() : null);
         order.setQuantity(totalQuantity);
@@ -1049,7 +1074,10 @@ public class ProductOrderService {
         }
         // 仅对实物商品扣减库存;虚拟商品/优惠券无库存概念
         Product paidProduct = productMapper.selectById(order.getProductId());
-        if (paidProduct != null && "physical".equals(paidProduct.getProductType())) {
+        ProductSku paidSku = order.getSkuId() != null ? productSkuService.getById(order.getSkuId()) : null;
+        if (productSkuService.hasComboItems(paidSku)) {
+            deductComboStock(paidSku, order, orderNo);
+        } else if (paidProduct != null && "physical".equals(paidProduct.getProductType())) {
             boolean stockOk = productService.decreaseStock(order.getProductId(), order.getQuantity());
             if (!stockOk) {
                 log.error("库存扣减失败: orderNo={}, productId={}, quantity={}",
@@ -1258,6 +1286,54 @@ public class ProductOrderService {
         return Result.success("支付成功");
     }
 
+    /**
+     * 组合订单支付成功:按组合明细逐项扣减库存(不扣父商品库存)。
+     * 任一组成 SKU/商品库存不足抛异常,由外层事务整体回滚。
+     */
+    void deductComboStock(ProductSku combo, ProductOrder order, String orderNo) {
+        for (ProductSkuComboItem ci : combo.getComboItems()) {
+            int unit = ci.getQuantity() == null ? 1 : ci.getQuantity();
+            int need = unit * order.getQuantity();
+            if (ci.getChildSkuId() != null) {
+                if (!productSkuService.decreaseStock(ci.getChildSkuId(), need)) {
+                    throw new RuntimeException("组合商品库存不足: skuId=" + ci.getChildSkuId());
+                }
+                inventoryService.recordOutbound(ci.getChildProductId(), ci.getChildSkuId(), need,
+                        "order", order.getId(), null, "组合出库:" + orderNo);
+            } else {
+                if (!productService.decreaseStock(ci.getChildProductId(), need)) {
+                    throw new RuntimeException("组合商品库存不足: productId=" + ci.getChildProductId());
+                }
+                inventoryService.recordOutbound(ci.getChildProductId(), null, need,
+                        "order", order.getId(), null, "组合出库:" + orderNo);
+            }
+        }
+    }
+
+    /**
+     * 组合订单取消/退款:反向回补各组成商品/SKU 库存(不回补父商品)。
+     *
+     * @return true 如果该订单是组合订单且已完成回补;false 走原回补逻辑
+     */
+    boolean restockComboOrder(ProductOrder order) {
+        ProductSku sku = order.getSkuId() != null ? productSkuService.getById(order.getSkuId()) : null;
+        if (!productSkuService.hasComboItems(sku)) return false;
+        for (ProductSkuComboItem ci : sku.getComboItems()) {
+            int unit = ci.getQuantity() == null ? 1 : ci.getQuantity();
+            int need = unit * order.getQuantity();
+            if (ci.getChildSkuId() != null) {
+                productSkuService.increaseStock(ci.getChildSkuId(), need);
+                inventoryService.recordRefundInbound(ci.getChildProductId(), ci.getChildSkuId(), need,
+                        "order", order.getId(), null, "组合退款入库:" + order.getOrderNo());
+            } else {
+                productService.increaseStock(ci.getChildProductId(), need);
+                inventoryService.recordRefundInbound(ci.getChildProductId(), null, need,
+                        "order", order.getId(), null, "组合退款入库:" + order.getOrderNo());
+            }
+        }
+        return true;
+    }
+
     public Result<String> cancel(String orderNo, Long userId) {
         ProductOrder order = orderMapper.selectOne(
             new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
@@ -1277,11 +1353,13 @@ public class ProductOrderService {
         releaseCouponForCancelledOrder(order.getCouponId());
         // F-5:退还下单时抵扣的 CF 值(原路返还到购买人 member 账本,幂等 refId = 商品订单 ID)
         refundOrderPoints(order, "取消订单退还CF值:" + orderNo);
-        // Restore stock on cancel
-        productService.increaseStock(order.getProductId(), order.getQuantity());
-        // 记录退款入库流水
-        inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
-                "order", order.getId(), null, "取消订单:" + orderNo);
+        // Restore stock on cancel(组合订单走组合回补,不回补父商品)
+        if (!restockComboOrder(order)) {
+            productService.increaseStock(order.getProductId(), order.getQuantity());
+            // 记录退款入库流水
+            inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
+                    "order", order.getId(), null, "取消订单:" + orderNo);
+        }
         // 特价订单回滚扣量(与恢复库存并列,失败仅记日志不阻断取消)
         try {
             specialPriceService.releaseQuota(order.getSpecialDealId(), order.getQuantity());
@@ -1726,9 +1804,11 @@ public class ProductOrderService {
         order.setStatus("refunded");
         order.setUpdatedAt(new Date());
         orderMapper.updateById(order);
-        productService.increaseStock(order.getProductId(), order.getQuantity());
-        inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
-                "order", order.getId(), adminId, "退款:" + orderNo);
+        if (!restockComboOrder(order)) {
+            productService.increaseStock(order.getProductId(), order.getQuantity());
+            inventoryService.recordRefundInbound(order.getProductId(), null, order.getQuantity(),
+                    "order", order.getId(), adminId, "退款:" + orderNo);
+        }
         // 退款成功:归还优惠券;CF 兑换来的券同时退还 CF 值
         releaseCouponForCancelledOrder(order.getCouponId());
         // F-5:退还下单时抵扣的 CF 值(原路返还到购买人 member 账本)
@@ -1944,7 +2024,10 @@ public class ProductOrderService {
             return Result.error("该订单无法关闭");
         }
         if ("paid".equals(currentStatus) || "pending_receipt".equals(currentStatus)) {
-            productService.increaseStock(order.getProductId(), order.getQuantity());
+            // 组合订单走组合回补,不回补父商品
+            if (!restockComboOrder(order)) {
+                productService.increaseStock(order.getProductId(), order.getQuantity());
+            }
         }
         order.setStatus("closed");
         order.setUpdatedAt(new Date());

+ 115 - 17
cfc-web/src/views/admin/components/ProductPicker.vue

@@ -1,5 +1,5 @@
 <template>
-  <el-dialog :visible.sync="visible" title="选择关联商品" width="600px" @close="handleClose">
+  <el-dialog :visible.sync="visible" :title="multiple ? '选择组合商品' : '选择关联商品'" width="680px" @close="handleClose">
     <el-form :inline="true" size="small">
       <el-form-item label="搜索">
         <el-input v-model="query.keyword" placeholder="商品名称" clearable @keyup.enter.native="search" class="pp-search-input" />
@@ -9,13 +9,24 @@
       </el-form-item>
     </el-form>
 
-    <el-table :data="list" highlight-current-row @current-change="onSelect" size="small">
-      <el-table-column width="50">
+    <el-table
+      ref="table"
+      :data="list"
+      row-key="id"
+      :selectable="rowSelectable"
+      highlight-current-row
+      size="small"
+      @current-change="onSelect"
+      @selection-change="onSelectionChange"
+    >
+      <el-table-column v-if="multiple" type="selection" width="42" />
+      <el-table-column v-else width="50">
         <template slot-scope="{ row }">
           <el-radio :value="selectedId" :label="row.id" @change="onSelect(row)">&nbsp;</el-radio>
         </template>
       </el-table-column>
-      <el-table-column label="商品" min-width="300">
+
+      <el-table-column label="商品" min-width="240">
         <template slot-scope="{ row }">
           <div class="pp-product-cell">
             <img v-if="row.coverImage" :src="row.coverImage" class="pp-product-cover" />
@@ -23,11 +34,40 @@
             <div>
               <div>{{ row.name }}</div>
               <div class="pp-product-price">¥{{ row.price ? (row.price/100).toFixed(2) : '0.00' }}</div>
+              <div v-if="row.hasSkus && !(row.skus && row.skus.length)" class="pp-no-sku">无可用SKU</div>
             </div>
           </div>
         </template>
       </el-table-column>
-      <el-table-column prop="stock" label="库存" width="80" />
+
+      <el-table-column v-if="multiple" label="规格" min-width="150">
+        <template slot-scope="{ row }">
+          <el-select
+            v-if="row.hasSkus && row.skus && row.skus.length"
+            v-model="row._childSkuId"
+            size="small"
+            placeholder="选择规格"
+            class="pp-sku-select"
+          >
+            <el-option
+              v-for="s in row.skus"
+              :key="s.id"
+              :label="(s.label || '规格') + (s.price != null ? ' ¥' + (s.price/100).toFixed(2) : '')"
+              :value="s.id"
+            />
+          </el-select>
+          <span v-else class="text-muted">-</span>
+        </template>
+      </el-table-column>
+
+      <el-table-column v-if="multiple" label="数量" width="110">
+        <template slot-scope="{ row }">
+          <el-input-number v-model="row._quantity" :min="1" :precision="0" size="mini" :disabled="row.hasSkus && !(row.skus && row.skus.length)" />
+        </template>
+      </el-table-column>
+
+      <el-table-column v-if="!multiple" prop="stock" label="库存" width="80" />
+    </el-table>
 
     <el-pagination
       v-if="total > pageSize"
@@ -41,11 +81,10 @@
 
     <span slot="footer">
       <el-button @click="visible = false">取消</el-button>
-      <el-button type="primary" :disabled="!selectedProduct" @click="confirm">确认选择</el-button>
+      <el-button type="primary" :disabled="!canConfirm" @click="confirm">{{ multiple ? '确认添加' : '确认选择' }}</el-button>
     </span>
   </el-dialog>
-
-</el-table></template>
+</template>
 
 <script>
 import { linkedProductList } from '@/api/admin'
@@ -53,7 +92,10 @@ import { linkedProductList } from '@/api/admin'
 export default {
   name: 'ProductPicker',
   props: {
-    productId: { type: Number, default: null }
+    productId: { type: Number, default: null },
+    crossCategory: { type: Boolean, default: false },
+    multiple: { type: Boolean, default: false },
+    selected: { type: [Object, Array], default: null }
   },
   data() {
     return {
@@ -62,16 +104,24 @@ export default {
       total: 0,
       pageSize: 20,
       query: { keyword: '', page: 1 },
-      selectedProduct: null
+      selectedProduct: null,
+      picked: []
     }
   },
   computed: {
-    selectedId() { return this.selectedProduct ? this.selectedProduct.id : null }
+    selectedId() { return this.selectedProduct ? this.selectedProduct.id : null },
+    canConfirm() {
+      if (this.multiple) return this.picked.length > 0
+      return !!this.selectedProduct
+    }
   },
   methods: {
     open() {
       this.visible = true
-      this.selectedProduct = null
+      this.query.page = 1
+      this.selectedProduct = this.multiple ? null : (this.selected || null)
+      // 多选:用 selected 预填 picked,load() 后对已选行回勾
+      this.picked = this.multiple ? ((this.selected || []).slice()) : []
       this.search()
     },
     async search() {
@@ -84,21 +134,66 @@ export default {
           productId: this.productId,
           keyword: this.query.keyword,
           page: this.query.page,
-          pageSize: this.pageSize
+          pageSize: this.pageSize,
+          crossCategory: this.crossCategory
         })
         if (res.code === 200) {
           this.list = res.data.list || []
           this.total = res.data.total || 0
+          this.list.forEach(row => {
+            this.$set(row, '_childSkuId', null)
+            this.$set(row, '_quantity', 1)
+          })
+          this.$nextTick(() => this.restoreSelection())
         }
       } catch (e) {
         this.$message.error('加载商品列表失败')
       }
     },
+    rowSelectable(row) {
+      return !(row.hasSkus && !(row.skus && row.skus.length))
+    },
+    restoreSelection() {
+      if (!this.multiple || !this.$refs.table) return
+      const preset = this.picked.slice()
+      this.picked = []
+      this.list.forEach(row => {
+        const p = preset.find(x => (x.childProductId || x.id) === row.id)
+        if (p) {
+          this.$set(row, '_childSkuId', p.childSkuId || null)
+          this.$set(row, '_quantity', p.quantity || 1)
+          this.$refs.table.toggleRowSelection(row, true)
+        }
+      })
+    },
+    onSelectionChange(rows) {
+      this.picked = rows || []
+    },
     onSelect(row) {
-      if (row) this.selectedProduct = row
+      if (row && !this.multiple) this.selectedProduct = row
     },
     confirm() {
-      this.$emit('selected', this.selectedProduct)
+      if (this.multiple) {
+        for (const it of this.picked) {
+          if (it.hasSkus && it.skus && it.skus.length && !it._childSkuId) {
+            this.$message.warning('请为「' + it.name + '」选择规格')
+            return
+          }
+        }
+        const result = this.picked.map(it => ({
+          id: it.id,
+          childProductId: it.id,
+          childSkuId: it.hasSkus && it.skus && it.skus.length ? it._childSkuId : null,
+          name: it.name,
+          price: it.price,
+          coverImage: it.coverImage || null,
+          stock: it.stock,
+          quantity: it._quantity || 1
+        }))
+        this.$emit('confirm', result)
+      } else {
+        this.$emit('selected', this.selectedProduct)
+      }
       this.visible = false
     },
     onPageChange(page) {
@@ -107,17 +202,20 @@ export default {
     },
     handleClose() {
       this.selectedProduct = null
+      this.picked = []
     }
   }
 }
 </script>
 
 <style scoped>
-/* == C 批内联清理工具类 == */
 .pp-search-input { width: 200px; }
 .pp-product-cell { display: flex; align-items: center; gap: 10px; }
 .pp-product-cover { width: 50px; height: 50px; object-fit: cover; border-radius: 4px; }
 .pp-product-placeholder { width: 50px; height: 50px; background: #f0f0f0; display: inline-block; border-radius: 4px; }
 .pp-product-price { font-size: 12px; color: #999; }
+.pp-no-sku { font-size: 12px; color: #f56c6c; }
+.pp-sku-select { width: 100%; }
+.text-muted { color: #999; }
 .pp-pagination-right { margin-top: 10px; text-align: right; }
-</style>
+</style>

+ 155 - 16
cfc-web/src/views/admin/components/ProductSkuEditor.vue

@@ -8,9 +8,10 @@
         <div class="adm-table-wrap">
 <el-table :data="effectiveSkus" border stripe size="small" height="100%">
       <el-table-column prop="id" label="ID" width="60" />
-      <el-table-column label="规格" min-width="120">
+      <el-table-column label="规格/组合" min-width="160">
         <template slot-scope="{ row }">
-          <span>{{ parseSpecs(row.specs) }}</span>
+          <span v-if="row.comboItems && row.comboItems.length">组合:{{ formatComboSummary(row.comboItems) }}</span>
+          <span v-else>{{ parseSpecs(row.specs) }}</span>
         </template>
       </el-table-column>
       <el-table-column label="售价(元)" width="100">
@@ -46,13 +47,14 @@
 
     <el-dialog :title="dialogTitle" :visible.sync="dialogVisible" width="500px" @close="resetForm">
       <el-form ref="skuForm" :model="skuForm" :rules="skuRules" label-width="80px">
-        <el-radio-group v-model="isLinkedMode" size="small" class="mb-16">
-          <el-radio-button :label="false">规格变体</el-radio-button>
-          <el-radio-button :label="true">关联商品</el-radio-button>
+        <el-radio-group v-model="skuMode" size="small" class="mb-16">
+          <el-radio-button label="spec">规格变体</el-radio-button>
+          <el-radio-button label="linked">关联商品</el-radio-button>
+          <el-radio-button label="combo">商品组合</el-radio-button>
         </el-radio-group>
 
         <!-- 关联商品模式 -->
-        <template v-if="isLinkedMode">
+        <template v-if="skuMode === 'linked'">
           <el-form-item label="显示标签">
             <el-input v-model="skuForm.label" placeholder="默认使用关联商品名称" />
           </el-form-item>
@@ -70,7 +72,7 @@
         </template>
 
         <!-- 规格变体模式(原有) -->
-        <template v-else>
+        <template v-else-if="skuMode === 'spec'">
         <el-form-item label="规格" prop="specs">
           <el-input v-model="skuForm.specs" placeholder="例如:颜色:红色;尺码:M" />
         </el-form-item>
@@ -102,6 +104,38 @@
         </el-form-item>
         </template>
 
+        <!-- 商品组合模式 -->
+        <template v-else-if="skuMode === 'combo'">
+          <el-form-item label="显示标签">
+            <el-input v-model="skuForm.label" placeholder="默认显示“商品组合”" />
+          </el-form-item>
+          <el-form-item label="组合商品">
+            <el-button size="small" type="primary" plain icon="el-icon-plus" @click="openProductPicker">选择商品</el-button>
+            <div v-if="comboItems.length" class="combo-list">
+              <div v-for="(it, idx) in comboItems" :key="(it.childProductId || it.id) + '-' + (it.childSkuId || '')" class="combo-row">
+                <img v-if="it.coverImage" :src="it.coverImage" class="combo-thumb" />
+                <span v-else class="combo-thumb combo-thumb-placeholder"></span>
+                <div class="combo-main">
+                  <div>{{ it.name }}</div>
+                  <div class="combo-meta">
+                    <span v-if="it.childSkuSpecs">规格:{{ parseSpecs(it.childSkuSpecs) }}</span>
+                    <span>单价 ¥{{ ((it.price || 0)/100).toFixed(2) }}</span>
+                  </div>
+                </div>
+                <el-input-number v-model="it.quantity" :min="1" :precision="0" size="mini" class="combo-qty" />
+                <el-button type="text" class="combo-remove" @click="comboItems.splice(idx, 1)">删除</el-button>
+              </div>
+            </div>
+            <div v-else class="combo-empty">尚未添加组合商品</div>
+          </el-form-item>
+          <el-form-item label="组合售价(元)" prop="price">
+            <el-input-number v-model="skuForm.price" :min="0" :precision="2" class="full-width" />
+          </el-form-item>
+          <el-form-item label="展示库存">
+            <el-input-number v-model="skuForm.stock" :min="0" :precision="0" class="full-width" />
+          </el-form-item>
+        </template>
+
         <el-form-item label="启用">
           <el-switch v-model="skuForm.enabled" :active-value="1" :inactive-value="0" />
         </el-form-item>
@@ -112,7 +146,15 @@
       </span>
     </el-dialog>
 
-    <ProductPicker ref="productPicker" :product-id="productId" @selected="onProductSelected" />
+    <ProductPicker
+      ref="productPicker"
+      :product-id="productId"
+      :cross-category="skuMode === 'combo'"
+      :multiple="skuMode === 'combo'"
+      :selected="comboItems.map(i => ({ childProductId: i.childProductId || i.id, childSkuId: i.childSkuId, quantity: i.quantity }))"
+      @selected="onProductSelected"
+      @confirm="onComboConfirm"
+    />
 
     <!-- 素材库选择器 -->
     <AssetPicker
@@ -127,6 +169,7 @@
 
 <script>
 import { getProductSkuList, createProductSku, updateProductSku, deleteProductSku } from '@/api/admin'
+import { formatComboSummary, normalizeComboPayload } from '@/utils/skuCombo'
 import ProductPicker from './ProductPicker.vue'
 import AssetPicker from '@/components/AssetPicker.vue'
 
@@ -148,7 +191,8 @@ export default {
       assetPickerVisible: false,
       saving: false,
       editingId: null,
-      isLinkedMode: false,
+      skuMode: 'spec',
+      comboItems: [],
       skuForm: {
         specs: '',
         price: 0,
@@ -221,8 +265,18 @@ export default {
     showDialog(sku) {
       if (sku) {
         this.editingId = sku.id
-        this.isLinkedMode = sku.linkedProductId != null
-        if (this.isLinkedMode) {
+        const comboRaw = sku.comboItems || []
+        this.comboItems = comboRaw.map(it => ({
+          childProductId: it.childProductId,
+          childSkuId: it.childSkuId,
+          quantity: it.quantity || 1,
+          name: it.childProductName || ('商品#' + it.childProductId),
+          coverImage: it.childProductImage,
+          price: it.childProductPrice || 0,
+          childSkuSpecs: it.childSkuSpecs
+        }))
+        this.skuMode = comboRaw.length ? 'combo' : (sku.linkedProductId != null ? 'linked' : 'spec')
+        if (this.skuMode === 'linked') {
           this.skuForm = {
             label: sku.label || '',
             linkedProductId: sku.linkedProductId,
@@ -237,7 +291,7 @@ export default {
             stock: 0,
             image: ''
           }
-        } else {
+        } else if (this.skuMode === 'spec') {
           this.skuForm = {
             specs: this.formatSpecsForInput(sku.specs),
             price: sku.price ? (sku.price / 100) : 0,
@@ -252,6 +306,21 @@ export default {
             linkedProductImage: '',
             linkedProductStock: 0
           }
+        } else {
+          this.skuForm = {
+            label: sku.label || '',
+            price: sku.price ? (sku.price / 100) : 0,
+            stock: sku.stock || 0,
+            enabled: sku.enabled !== undefined ? (sku.enabled ? 1 : 0) : 1,
+            specs: '',
+            originalPrice: 0,
+            image: '',
+            linkedProductId: null,
+            linkedProductName: '',
+            linkedProductPrice: 0,
+            linkedProductImage: '',
+            linkedProductStock: 0
+          }
         }
       } else {
         this.editingId = null
@@ -260,7 +329,8 @@ export default {
       this.dialogVisible = true
     },
     resetForm() {
-      this.isLinkedMode = false
+      this.skuMode = 'spec'
+      this.comboItems = []
       this.skuForm = {
         specs: '',
         price: 0,
@@ -393,9 +463,20 @@ export default {
       this.skuForm.linkedProductImage = product.coverImage || product.image || ''
       this.skuForm.linkedProductStock = product.stock
     },
+    onComboConfirm(items) {
+      this.comboItems = (items || []).map(it => ({
+        childProductId: it.childProductId || it.id,
+        childSkuId: it.childSkuId || null,
+        quantity: it.quantity || 1,
+        name: it.name,
+        coverImage: it.coverImage,
+        price: it.price || 0,
+        childSkuSpecs: it.childSkuSpecs || null
+      }))
+    },
     async handleSave() {
       // Linked SKU 模式不校验 specs/price/stock
-      if (this.isLinkedMode) {
+      if (this.skuMode === 'linked') {
         if (!this.skuForm.linkedProductId) {
           this.$message.warning('请选择关联商品')
           return
@@ -406,7 +487,54 @@ export default {
             productId: this.productId,
             linkedProductId: this.skuForm.linkedProductId,
             label: this.skuForm.label || null,
-            enabled: this.skuForm.enabled
+            enabled: this.skuForm.enabled,
+            comboItems: []
+          }
+          if (!this.productId) {
+            payload.id = Date.now()
+            delete payload.productId
+            this.pendingSkus.push(payload)
+            this.dialogVisible = false
+            this.$message.success('添加成功,保存商品时将自动创建SKU')
+            this.saving = false
+            return
+          }
+          let res
+          if (this.editingId) {
+            payload.id = this.editingId
+            delete payload.productId
+            res = await updateProductSku(payload)
+          } else {
+            res = await createProductSku(payload)
+          }
+          if (res.code === 200) {
+            await this.loadSkus()
+            this.$message.success('保存成功')
+            this.dialogVisible = false
+          } else {
+            this.$message.error(res.message || '保存失败')
+          }
+        } catch (e) {
+          this.$message.error('保存失败')
+        } finally {
+          this.saving = false
+        }
+        return
+      }
+      // 商品组合模式:手填售价 + 组合明细(多商品+数量),不校验 specs
+      if (this.skuMode === 'combo') {
+        if (!this.comboItems.length) { this.$message.warning('请至少添加一个组合商品'); return }
+        if (!(this.skuForm.price > 0)) { this.$message.warning('请填写组合售价'); return }
+        this.saving = true
+        try {
+          const payload = {
+            productId: this.productId,
+            comboItems: normalizeComboPayload(this.comboItems),
+            price: Math.round(parseFloat(this.skuForm.price) * 100),
+            stock: this.skuForm.stock || 0,
+            label: this.skuForm.label || null,
+            enabled: this.skuForm.enabled,
+            linkedProductId: null
           }
           if (!this.productId) {
             payload.id = Date.now()
@@ -450,7 +578,9 @@ export default {
             originalPrice: this.skuForm.originalPrice ? Math.round(parseFloat(this.skuForm.originalPrice) * 100) : null,
             stock: this.skuForm.stock,
             image: this.skuForm.image,
-            enabled: this.skuForm.enabled
+            enabled: this.skuForm.enabled,
+            comboItems: [],
+            linkedProductId: null
           }
           // 新建商品时productId为空,暂存到本地
           if (!this.productId) {
@@ -553,4 +683,13 @@ export default {
 .linked-thumb { width: 50px; height: 50px; object-fit: cover; border-radius: 4px; }
 .linked-info { font-size: 12px; color: var(--adm-text-45); }
 .full-width { width: 100%; }
+.combo-list { margin-top: 8px; display: flex; flex-direction: column; gap: 8px; }
+.combo-row { display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--adm-bg-header); border-radius: 4px; }
+.combo-thumb { width: 46px; height: 46px; object-fit: cover; border-radius: 4px; flex-shrink: 0; }
+.combo-thumb-placeholder { background: #f0f0f0; display: inline-block; }
+.combo-main { flex: 1; min-width: 0; font-size: 13px; }
+.combo-meta { font-size: 12px; color: var(--adm-text-45); display: flex; gap: 8px; }
+.combo-qty { width: 90px; }
+.combo-remove { color: #f56c6c; }
+.combo-empty { margin-top: 8px; padding: 12px; text-align: center; color: var(--adm-text-45); background: var(--adm-bg-header); border-radius: 4px; font-size: 12px; }
 </style>