2026-08-15-sku-linked-product-selector.md 30 KB

SKU 关联商品选择器 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 将 SKU 从"规格变体"改为"关联商品选择器"——商品 A 的 SKU 可指向另一个独立商品 B,用户在 A 页面选择规格后查看 B 的信息并购买 B。

Architecture: product_skus 表新增 linked_product_id 和 label 两列。新模式 SKU(linked_product_id IS NOT NULL)的价格/库存/图片从 linked_product 读取,下单时直接购买 linked_product。旧模式 SKU(linked_product_id IS NULL)保持原规格变体行为。管理端新增商品选择器组件,SKU 编辑弹窗按模式切换表单。

Tech Stack: Java 8 + Spring Boot 2.7.18 + MyBatis-Plus / uni-app Vue 2 小程序 / Vue 2 + Element UI Web管理端

Global Constraints

  • 后端接口统一 @PostMapping,禁止 @GetMapping/@PutMapping/@DeleteMapping
  • 响应统一 Result<T>(code/message/data)
  • JWT Bearer Token,@RequestAttribute("userId") 获取用户 ID
  • DI 使用 @Resource,字段名匹配 Bean Name
  • 数据库迁移唯一入口:DatabaseInitializer.runMigrations()
  • 迁移必须幂等(try-catch 包裹 ensureColumn 或异常忽略)
  • schema.sql 必须同步更新(ADD COLUMN 后同步到 CREATE TABLE 语句)
  • 小程序:禁止可选链 ?.(用 &&)、禁止 CSS Grid(用 flexbox)、禁止 :key 表达式(用方法调用)、禁止 new Date(string)(用 parseDate())
  • Vue 2 Options API,禁止 Composition API
  • 存量 SKU 的 linked_product_id = NULL,保持原行为,不迁移

Task 1: 数据库迁移 + 实体字段

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java (runMigrations() 末尾,迁移247)
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java (新增 linkedProductId、label)
  • Modify: cfc-backend/src/main/resources/schema.sql (同步 product_skus CREATE TABLE 新增两列)

Interfaces:

  • Consumes: 无
  • Produces: ProductSku.linkedProductId (Long, nullable), ProductSku.label (String, nullable)

  • [ ] Step 1: 修改实体 ProductSku.java

在 private Integer enabled; 之后、private Date createTime; 之前插入:

    private Long linkedProductId; // 关联商品ID,null=Legacy SKU
    private String label;         // 自定义显示标签,null=使用linked_product.name
  • Step 2: 在 DatabaseInitializer.runMigrations() 添加迁移247

搜索当前最大迁移编号(在文件末尾搜索 // 迁移),在最新迁移块之后、方法结束 } 之前插入:

		// 迁移247: product_skus 添加 linked_product_id 和 label 列(SKU关联商品选择器)
		ensureColumn("product_skus", "linked_product_id", "BIGINT COMMENT '关联商品ID,null=Legacy SKU'");
		ensureColumn("product_skus", "label", "VARCHAR(100) COMMENT '自定义显示标签,null=使用linked_product.name'");
  • Step 3: 同步 schema.sql

搜索 CREATE TABLE IF NOT EXISTS product_skus,在 enabled 列定义之后、create_time 之前插入:

    linked_product_id BIGINT COMMENT '关联商品ID,null=Legacy SKU',
    label VARCHAR(100) COMMENT '自定义显示标签,null=使用linked_product.name',
  • [ ] Step 4: 编译验证

    cd cfc-backend && mvn clean compile -q
    

Expected: BUILD SUCCESS

  • [ ] Step 5: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/entity/ProductSku.java cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
    git commit -m "feat(backend): ProductSku 新增 linkedProductId/label 字段,迁移247"
    

Task 2: ProductSkuService 创建/更新支持 linked SKU

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java (create/update 方法)

Interfaces:

  • Consumes: ProductSku.linkedProductId, ProductSku.label
  • Produces: 兼容创建/更新 linked SKU(不校验 specs/price/stock 等旧字段)

  • [ ] Step 1: 修改 create 方法

当前 create() 方法中,if (sku.getSpecs() == null || sku.getSpecs().trim().isEmpty()) { sku.setSpecs("[]"); } 改为:

  • 如果 sku.getLinkedProductId() != null,跳过 specs 默认值设置(linked SKU 不需要 specs)
  • 如果 sku.getSkuCode() == null || sku.getSkuCode().isEmpty(),仍然生成 SKU 码

    @Transactional
    public ProductSku create(ProductSku sku) {
        log.info("[SKU-CREATE] productId={}, linkedProductId={}", sku.getProductId(), sku.getLinkedProductId());
        if (sku.getLinkedProductId() == null) {
            // Legacy SKU: 需要 specs
            if (sku.getSpecs() == null || sku.getSpecs().trim().isEmpty()) {
                sku.setSpecs("[]");
            }
        }
        sku.setCreateTime(new Date());
        sku.setUpdateTime(new Date());
        if (sku.getEnabled() == null) {
            sku.setEnabled(1);
        }
        if (sku.getSkuCode() == null || sku.getSkuCode().isEmpty()) {
            sku.setSkuCode("SKU-" + sku.getProductId() + "-" + System.currentTimeMillis());
        }
        skuMapper.insert(sku);
        return sku;
    }
    
  • [ ] Step 2: 修改 update 方法

同理,update() 中 if (sku.getSpecs() == null || sku.getSpecs().trim().isEmpty()) 改为只在 linkedProductId 为空时设置默认 specs:

    @Transactional
    public void update(ProductSku sku) {
        if (sku.getLinkedProductId() == null) {
            if (sku.getSpecs() == null || sku.getSpecs().trim().isEmpty()) {
                sku.setSpecs("[]");
            }
        }
        sku.setUpdateTime(new Date());
        skuMapper.updateById(sku);
    }
  • [ ] Step 3: 编译验证

    cd cfc-backend && mvn clean compile -q
    

Expected: BUILD SUCCESS

  • [ ] Step 4: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java
    git commit -m "feat(backend): ProductSkuService 创建/更新支持 linked SKU,跳过 specs 校验"
    

Task 3: specMap 接口扩展返回 linked 商品摘要

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java (specMap 方法)
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java (新增 listLinked 方法)

Interfaces:

  • Consumes: ProductSku.linkedProductId
  • Produces: specMap 返回的 option 中增加 linkedProductId、linked(name/price/stock/image/brief)、isLinked

  • [ ] Step 1: 在 ProductSkuService 新增查询 linked 商品摘要的方法

    /**
     * 查询 linked 商品摘要(用于 specMap 扩展)
     */
    public Map<String, Object> getLinkedProductSummary(Long linkedProductId) {
        if (linkedProductId == null) return null;
        Product product = productMapper.selectById(linkedProductId);
        if (product == null) return null;
        if (product.getEnabled() == null || product.getEnabled() == 0) return null;
        if (product.getDeleted() != null && product.getDeleted() == 1) return null;
        if (product.getStock() != null && product.getStock() == 0) return null;
        Map<String, Object> summary = new HashMap<>();
        summary.put("id", product.getId());
        summary.put("name", product.getName());
        summary.put("price", product.getPrice());
        summary.put("stock", product.getStock());
        summary.put("image", product.getCoverImage());
        summary.put("brief", product.getIntro());
        return summary;
    }
    

需要在 ProductSkuService 注入 ProductMapper:

    @Resource
    private ProductMapper productMapper;
  • Step 2: 修改 ProductController.specMap 方法

在循环构建 option 对象时,对 linked SKU 扩展返回字段。在 for (ProductSku sku : skus) 循环内,构建 option 前增加:

            boolean isLinked = sku.getLinkedProductId() != null;

            // Linked SKU: 跳过库存为0/已删除的关联商品
            if (isLinked) {
                Map<String, Object> linkedSummary = productSkuService.getLinkedProductSummary(sku.getLinkedProductId());
                if (linkedSummary == null) continue; // 库存为0或已下架,不显示
            }

在 option 构建时增加:

            if (isLinked) {
                option.put("linkedProductId", sku.getLinkedProductId());
                option.put("isLinked", true);
                Map<String, Object> linkedSummary = productSkuService.getLinkedProductSummary(sku.getLinkedProductId());
                option.put("linked", linkedSummary);
                // Linked SKU 的 option name 用 label 或 linked product name
                String label = sku.getLabel();
                if (label == null || label.trim().isEmpty()) {
                    label = (String) linkedSummary.get("name");
                }
                option.put("name", label);
            } else {
                option.put("isLinked", false);
            }

注意:option.put("name", value) 当前直接取 specEntry 的 value。对于 linked SKU 需要覆盖 name 为 label/linked.name。

  • [ ] Step 3: 编译验证

    cd cfc-backend && mvn clean compile -q
    

Expected: BUILD SUCCESS

  • [ ] Step 4: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductSkuService.java
    git commit -m "feat(backend): specMap 扩展返回 linked 商品摘要,库存为0/已下架不显示"
    

Task 4: 管理端商品选择器 API

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPickerController.java
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java (新增 listForPicker 方法)

Interfaces:

  • Consumes: 无
  • Produces: POST /api/admin/product/linked/list — 分页 + 分类筛选 + 关键词搜索 + 排除当前商品 + 排除已关联商品

  • [ ] Step 1: 在 ProductService 新增 listForPicker 方法

    /**
     * 管理端关联商品选择器列表
     * @param excludeProductId 排除当前商品ID
     * @param categoryId 分类筛选,null=全部
     * @param keyword 关键词搜索,null=不搜索
     * @param page 页码
     * @param pageSize 每页大小
     * @return Map with list/total/page/pageSize
     */
    public Map<String, Object> listForPicker(Long excludeProductId, Long categoryId, String keyword,
                                              Integer page, Integer pageSize) {
        if (page == null || page < 1) page = 1;
        if (pageSize == null || pageSize < 1) pageSize = 20;
        int offset = (page - 1) * pageSize;
    
        List<Product> list = productMapper.selectForPicker(excludeProductId, categoryId, keyword, offset, pageSize);
        Long total = productMapper.countForPicker(excludeProductId, categoryId, keyword);
    
        Map<String, Object> result = new HashMap<>();
        result.put("list", list);
        result.put("total", total);
        result.put("page", page);
        result.put("pageSize", pageSize);
        return result;
    }
    
  • [ ] Step 2: 在 ProductMapper 新增查询方法

    <!-- ProductMapper.xml -->
    <select id="selectForPicker" resultType="com.etotem.cfc.entity.Product">
        SELECT id, name, price, stock, cover_image AS coverImage, category_id AS categoryId
        FROM products
        WHERE deleted = 0 AND enabled = 1
        <if test="excludeProductId != null">
            AND id != #{excludeProductId}
        </if>
        <if test="categoryId != null">
            AND category_id = #{categoryId}
        </if>
        <if test="keyword != null and keyword != ''">
            AND name LIKE CONCAT('%', #{keyword}, '%')
        </if>
        ORDER BY sort_order, id DESC
        LIMIT #{pageSize} OFFSET #{offset}
    </select>
    
    <select id="countForPicker" resultType="long">
        SELECT COUNT(*) FROM products
        WHERE deleted = 0 AND enabled = 1
        <if test="excludeProductId != null">
            AND id != #{excludeProductId}
        </if>
        <if test="categoryId != null">
            AND category_id = #{categoryId}
        </if>
        <if test="keyword != null and keyword != ''">
            AND name LIKE CONCAT('%', #{keyword}, '%')
        </if>
    </select>
    
  • [ ] Step 3: 创建 AdminProductPickerController

    package com.etotem.cfc.controller.admin;
    
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.service.ProductService;
    import org.springframework.web.bind.annotation.*;
    
    import javax.annotation.Resource;
    import java.util.Map;
    
    @RestController
    @RequestMapping("/api/admin/product")
    public class AdminProductPickerController {
    
    @Resource
    private ProductService productService;
    
    @PostMapping("/linked/list")
    public Result<Map<String, Object>> linkedList(@RequestBody Map<String, Object> params) {
        Long productId = params.get("productId") != null ? Long.valueOf(params.get("productId").toString()) : null;
        Long categoryId = params.get("categoryId") != null ? Long.valueOf(params.get("categoryId").toString()) : null;
        String keyword = (String) params.get("keyword");
        Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
        Integer pageSize = params.get("pageSize") != null ? Integer.valueOf(params.get("pageSize").toString()) : 20;
    
        // 同分类限制:只返回同一分类的商品
        if (categoryId == null && productId != null) {
            // 如果未传 categoryId,从当前商品获取
            com.etotem.cfc.entity.Product product = productService.getById(productId);
            if (product != null) {
                categoryId = product.getCategoryId();
            }
        }
    
        Map<String, Object> result = productService.listForPicker(productId, categoryId, keyword, page, pageSize);
        return Result.success(result);
    }
    }
    
  • [ ] Step 4: 编译验证

    cd cfc-backend && mvn clean compile -q
    

Expected: BUILD SUCCESS

  • [ ] Step 5: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminProductPickerController.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
    git commit -m "feat(backend): 管理端商品选择器 API /api/admin/product/linked/list"
    

Task 5: 购物车/下单 linked 商品转发

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java (buildCartItemDTO linked 处理)
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java (createMultiItem linked 处理)

Interfaces:

  • Consumes: ProductSku.linkedProductId
  • Produces: 用户选 linked SKU 时,加入购物车/下单的 productId = linked_product_id

  • [ ] Step 1: 修改 CartService.buildCartItemDTO

buildCartItemDTO 方法在已有 sku 查询块内,追加 linked 转发逻辑:

        if (cart.getSkuId() != null) {
            ProductSku sku = productSkuService.getById(cart.getSkuId());
            if (sku != null) {
                // Linked SKU: 转发到 linked 商品
                if (sku.getLinkedProductId() != null) {
                    Product linkedProduct = productService.getById(sku.getLinkedProductId());
                    if (linkedProduct != null) {
                        dto.setProductId(linkedProduct.getId());
                        dto.setProductName(linkedProduct.getName());
                        dto.setUnitPrice(linkedProduct.getPrice() != null ? linkedProduct.getPrice() : 0);
                        // 不覆盖 specs 解析(linked SKU 无 specs)
                    }
                } else {
                    // Legacy SKU 逻辑保持原样
                    if (sku.getPrice() != null) {
                        dto.setUnitPrice(sku.getPrice());
                    }
                    if (sku.getSpecs() != null) {
                        // ... 现有 specs 解析逻辑 ...
                    }
                }
            }
        }

注意:当前 CartService.java 的 buildCartItemDTO 方法在之前的 Task 3 中已修改为 if (sku.getPrice() != null) { dto.setUnitPrice(sku.getPrice()); }。需要将这段改为 linked-aware 的版本。

  • Step 2: 修改 ProductOrderService.createMultiItem

在 createMultiItem 方法中,解析 productData 时,如果传了 skuId,查询 SKU 的 linkedProductId:

        // 如果传了 skuId,检查是否为 linked SKU
        if (skuId != null) {
            ProductSku sku = productSkuService.getById(skuId);
            if (sku != null && sku.getLinkedProductId() != null) {
                Product linkedProduct = productService.getById(sku.getLinkedProductId());
                if (linkedProduct != null) {
                    productId = linkedProduct.getId();
                    productName = linkedProduct.getName();
                    unitPrice = linkedProduct.getPrice() != null ? linkedProduct.getPrice() : 0;
                    coverImage = linkedProduct.getCoverImage();
                }
            }
        }
  • [ ] Step 3: 编译验证

    cd cfc-backend && mvn clean compile -q
    

Expected: BUILD SUCCESS

  • [ ] Step 4: 提交

    git add cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
    git commit -m "feat(backend): 购物车/下单 linked SKU 转发到关联商品"
    

Task 6: 管理端 ProductSkuEditor 双模式 + ProductPicker 组件

Files:

  • Create: cfc-web/src/views/admin/components/ProductPicker.vue
  • Modify: cfc-web/src/views/admin/components/ProductSkuEditor.vue
  • Modify: cfc-web/src/api/admin.js (新增 linkedProductList API)

Interfaces:

  • Consumes: POST /api/admin/product/linked/list
  • Produces: 管理端可创建/编辑 linked SKU(选择商品 + 自定义标签)

  • [ ] Step 1: 在 admin.js 新增 linkedProductList API

    export function linkedProductList(params) {
    return request({
    url: '/api/admin/product/linked/list',
    method: 'post',
    data: params
    })
    }
    
  • [ ] Step 2: 创建 ProductPicker.vue 商品选择器组件

    <template>
    <el-dialog :visible.sync="visible" title="选择关联商品" width="600px" @close="handleClose">
    <el-form :inline="true" size="small">
      <el-form-item label="分类">
        <el-select v-model="query.categoryId" placeholder="全部" clearable @change="search">
          <el-option v-for="cat in categories" :key="cat.id" :label="cat.name" :value="cat.id" />
        </el-select>
      </el-form-item>
      <el-form-item label="搜索">
        <el-input v-model="query.keyword" placeholder="商品名称" clearable @keyup.enter.native="search" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="search">搜索</el-button>
      </el-form-item>
    </el-form>
    
    <el-table :data="list" highlight-current-row @current-change="onSelect">
      <el-table-column 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="商品" width="380">
        <template slot-scope="{ row }">
          <div style="display:flex;align-items:center;gap:10px;">
            <img v-if="row.coverImage" :src="row.coverImage" style="width:50px;height:50px;object-fit:cover;border-radius:4px;" />
            <span v-else style="width:50px;height:50px;background:#f0f0f0;display:inline-block;border-radius:4px;"></span>
            <div>
              <div>{{ row.name }}</div>
              <div style="font-size:12px;color:#999;">¥{{ row.price ? (row.price/100).toFixed(2) : '0.00' }}</div>
            </div>
          </div>
        </template>
      </el-table-column>
      <el-table-column prop="stock" label="库存" width="80" />
    </el-table>
    
    <el-pagination
      v-if="total > pageSize"
      :current-page="query.page"
      :page-size="pageSize"
      :total="total"
      layout="prev, pager, next"
      @current-change="onPageChange"
    />
    
    <span slot="footer">
      <el-button @click="visible = false">取消</el-button>
      <el-button type="primary" :disabled="!selectedProduct" @click="confirm">确认选择</el-button>
    </span>
    </el-dialog>
    </template>
    
    <script>
    import { linkedProductList } from '@/api/admin'
    
    export default {
    name: 'ProductPicker',
    props: {
    productId: { type: Number, default: null },
    categoryId: { type: Number, default: null }
    },
    data() {
    return {
      visible: false,
      list: [],
      total: 0,
      pageSize: 20,
      query: { keyword: '', categoryId: null, page: 1 },
      selectedProduct: null,
      categories: []
    }
    },
    computed: {
    selectedId() { return this.selectedProduct ? this.selectedProduct.id : null }
    },
    methods: {
    open() {
      this.visible = true
      this.query.categoryId = this.categoryId
      this.search()
    },
    async search() {
      this.query.page = 1
      await this.load()
    },
    async load() {
      try {
        const res = await linkedProductList({
          productId: this.productId,
          categoryId: this.query.categoryId,
          keyword: this.query.keyword,
          page: this.query.page,
          pageSize: this.pageSize
        })
        if (res.code === 200) {
          this.list = res.data.list || []
          this.total = res.data.total || 0
        }
      } catch (e) {
        this.$message.error('加载商品列表失败')
      }
    },
    onSelect(row) {
      this.selectedProduct = row
    },
    confirm() {
      this.$emit('selected', this.selectedProduct)
      this.visible = false
    },
    onPageChange(page) {
      this.query.page = page
      this.load()
    },
    handleClose() {
      this.selectedProduct = null
    }
    }
    }
    </script>
    
  • [ ] Step 3: 改造 ProductSkuEditor.vue

在 ProductSkuEditor.vue 中:

a) 引入 ProductPicker 组件:

import ProductPicker from './ProductPicker.vue'

export default {
  components: { ProductPicker },
  // ...
}

b) data 中新增 linked SKU 相关字段:

data() {
  return {
    // ... 现有字段
    skuForm: {
      // ... 现有字段
      label: '',
      linkedProductId: null,
      linkedProductName: '',
      linkedProductPrice: 0,
      linkedProductImage: '',
      linkedProductStock: 0
    },
    isLinkedMode: false
  }
}

c) 弹窗中新增模式切换(在顶部添加):

<el-radio-group v-model="isLinkedMode" size="small" style="margin-bottom:12px;">
  <el-radio-button :label="false">规格变体</el-radio-button>
  <el-radio-button :label="true">关联商品</el-radio-button>
</el-radio-group>

d) 关联商品模式表单(isLinkedMode=true 时显示):

<template v-if="isLinkedMode">
  <el-form-item label="显示标签">
    <el-input v-model="skuForm.label" placeholder="默认使用关联商品名称" />
  </el-form-item>
  <el-form-item label="关联商品">
    <el-button @click="openProductPicker">选择商品</el-button>
    <div v-if="skuForm.linkedProductName" style="margin-top:8px;display:flex;align-items:center;gap:10px;padding:8px;background:#f5f7fa;border-radius:4px;">
      <img v-if="skuForm.linkedProductImage" :src="skuForm.linkedProductImage" style="width:50px;height:50px;object-fit:cover;border-radius:4px;" />
      <div>
        <div>{{ skuForm.linkedProductName }}</div>
        <div style="font-size:12px;color:#999;">¥{{ skuForm.linkedProductPrice ? (skuForm.linkedProductPrice/100).toFixed(2) : '0.00' }} | 库存 {{ skuForm.linkedProductStock }}</div>
      </div>
      <el-button size="mini" type="text" @click="clearLinkedProduct">更换</el-button>
    </div>
  </el-form-item>
</template>

e) 规格变体模式保持原表单(isLinkedMode=false 时显示,隐藏 price/stock/ specs 等字段) f) handleSave 方法中,根据 isLinkedMode 构建 payload:

if (this.isLinkedMode) {
  payload.linkedProductId = this.skuForm.linkedProductId
  payload.label = this.skuForm.label || null
} else {
  payload.specs = this.normalizeSpecsToJson(this.skuForm.specs)
  payload.price = Math.round(parseFloat(this.skuForm.price) * 100)
  // ... 其他旧字段
}

g) 新增 openProductPicker 方法:

openProductPicker() {
  this.$refs.productPicker.open()
},
onProductSelected(product) {
  this.skuForm.linkedProductId = product.id
  this.skuForm.linkedProductName = product.name
  this.skuForm.linkedProductPrice = product.price
  this.skuForm.linkedProductImage = product.image || product.coverImage
  this.skuForm.linkedProductStock = product.stock
}
  • [ ] Step 4: 提交

    git add cfc-web/src/views/admin/components/ProductPicker.vue cfc-web/src/views/admin/components/ProductSkuEditor.vue cfc-web/src/api/admin.js
    git commit -m "feat(admin): ProductSkuEditor 双模式(规格变体/关联商品)+ ProductPicker 组件"
    

Task 7: 小程序详情页 linked SKU 展示与购买

Files:

  • Modify: cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

Interfaces:

  • Consumes: specMap 返回的 linked 字段(name/price/stock/image/brief)
  • Produces: 用户选中 linked SKU 时展示简介卡片,下单时 productId = linked.id

  • [ ] Step 1: data 中新增 linked 相关数据

    data() {
    return {
    // ... 现有字段
    linkedProductMap: {},  // { skuId: { name, price, stock, image, brief } }
    selectedLinkedProduct: null  // 当前选中的 linked 商品
    }
    }
    
  • [ ] Step 2: 在收到 specMap 数据后构建 linkedProductMap

在 loadProduct 或 loadSpec 方法中,处理 specMap 返回后:

// 构建 linked 商品映射
var linkedMap = {}
if (specGroups && specGroups.length > 0) {
  for (var i = 0; i < specGroups.length; i++) {
    var options = specGroups[i].options || []
    for (var j = 0; j < options.length; j++) {
      var opt = options[j]
      if (opt.isLinked && opt.linked) {
        linkedMap[opt.skuId] = opt.linked
      }
    }
  }
}
this.linkedProductMap = linkedMap
  • Step 3: 修改 selectSpec 方法,选中 linked SKU 时更新简介卡片

在 selectSpec 方法中,匹配到 SKU 后:

// 检查是否为 linked SKU
var sku = this.matchSku() // 或从 selectedSpecs 推导
var linked = this.linkedProductMap[matchedSkuId]
if (linked) {
  this.selectedLinkedProduct = linked
  this.actualPrice = linked.price
  // 显示 linked 商品简介
} else {
  this.selectedLinkedProduct = null
  // 恢复为父商品价格
}
  • Step 4: 模板中新增 linked 商品简介卡片

在规格选择区下方、购买按钮上方插入:

<!-- Linked 商品简介卡片 -->
<view class="linked-product-card" v-if="selectedLinkedProduct">
  <view class="linked-card-inner" @click="goLinkedProduct(selectedLinkedProduct.id)">
    <image class="linked-image" :src="selectedLinkedProduct.image" mode="aspectFill" />
    <view class="linked-info">
      <text class="linked-name">{{ selectedLinkedProduct.name }}</text>
      <text class="linked-price">¥{{ formatPrice(selectedLinkedProduct.price) }}</text>
      <text class="linked-stock">库存 {{ selectedLinkedProduct.stock }}</text>
      <text class="linked-brief" v-if="selectedLinkedProduct.brief">{{ selectedLinkedProduct.brief }}</text>
    </view>
    <view class="linked-arrow">›</view>
  </view>
</view>
  • [ ] Step 5: 修改 onBuy 和 onAddToCart——选择 linked SKU 时买 linked 商品

    onBuy: function() {
    var userId = uni.getStorageSync('userId')
    if (!userId) {
    uni.navigateTo({ url: '/pages/login/login' })
    return
    }
    var productId = this.product.id
    var productName = this.product.name
    var price = this.actualPrice
    var coverImage = this.product.coverImage
    var skuId = this.selectedSku ? this.selectedSku.id : null
    
    // Linked SKU: 转发到 linked 商品
    if (this.selectedLinkedProduct) {
    productId = this.selectedLinkedProduct.id
    productName = this.selectedLinkedProduct.name
    price = this.selectedLinkedProduct.price
    coverImage = this.selectedLinkedProduct.image
    }
    
    var productData = {
    productId: productId,
    productName: productName,
    coverImage: coverImage,
    // ...
    }
    // 如果有关联 skuId,传递
    if (skuId) {
    productData.skuId = skuId
    }
    // ...
    }
    
  • [ ] Step 6: 添加 goLinkedProduct 方法

    goLinkedProduct: function(productId) {
    uni.navigateTo({
    url: '/pages/discover-detail/product-detail/product-detail?id=' + productId
    })
    }
    
  • [ ] Step 7: 验证小程序约束

确保:

  • 无 ?. 可选链
  • 无 CSS Grid(用 flexbox)
  • 无 :key 表达式(用方法调用)
  • 无 new Date(string)(用 parseDate())

  • [ ] Step 8: 提交

    git add cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
    git commit -m "feat(frontend): 详情页 linked SKU 简介卡片展示 + 购买转发到关联商品"
    

Task 8: 端到端验证

Files:

  • 所有以上修改的文件

验证项:

  • [ ] 后端编译

    cd cfc-backend && mvn clean compile -q
    

    Expected: BUILD SUCCESS

  • [ ] 迁移幂等验证 检查 DatabaseInitializer.java 中迁移247 使用 ensureColumn 方法,自动处理重复列异常。

  • [ ] schema.sql 同步检查 确认 product_skus 的 CREATE TABLE 中包含 linked_product_id 和 label 列。

  • [ ] 管理端路由检查 检查新路由 /api/admin/product/linked/list 是否与其他 Controller 重复:

    grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep linked
    

    Expected: 只有一条匹配

  • [ ] Bean 名冲突检查 确认 AdminProductPickerController 类名不与其他 Controller 重名。

  • [ ] 提交

    git add -A
    git commit -m "chore: 端到端验证 — SKU 关联商品选择器全功能"