2026-06-04-phase1-product-system.md 115 KB

Phase 1: 商品系统 — 实现计划

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: 在 Phase 0 角色模型基础上,搭建完整的商品发布、浏览、购买、订单管理、系统配置能力,为 Phase 2 会员体系提供商品基础。

Architecture: 3 个新实体(Product/ProductOrder/SysConfig)+ 3 个新 Controller 包(product/ + config/)+ 5 个新小程序页面 + 3 个新 Web 管理页面。服务商可发布商品(需审核),用户可在发现/商城浏览并下单,管理员可在 Web 端审核/管理商品和订单。商品价格支持原价+会员价,等值商品选择留给 Phase 2。

Tech Stack: Java 1.8 + Spring Boot 2.7.18 + MyBatis-Plus + uni-app Vue 2 + Element UI

Spec reference: docs/superpowers/specs/2026-06-02-care-family-club-design.md (溪福俱乐部 §3.2-3.6, §9.2, §10.1, §11.1)

依赖关系:

  • 依赖 Phase 0 的 User.vendorType / vendorStatus(服务商必须为 approved 才能发布商品)
  • 依赖 Phase 0 的 TabBar 改版(商城/发现 Tab 已就位)
  • 为 Phase 2 提供 Product 基础(等值商品选择依赖此 Phase)

Key constraints:

  • 所有实体使用 java.util.Dateimplements Serializable
  • 所有 Controller 使用 @RequestMapping("/api/...") + @PostMapping
  • DI 通过 @Resource
  • 不建索引、不建约束、不写自定义 SQL(DDL 全部在 DatabaseInitializer)
  • Vue 2 Options API,uni-app 标签,rpx 单位,禁止可选链 ?.
  • 支付走测试模式:直接"已支付",无真实微信支付(Phase 3+ 再接入)
  • 商品发布需审核流程:draft → pending → approved → on_shelf/off_shelf

任务结构总览

任务组 范围 任务数
T1: 数据库初始化 Product/Order/SysConfig 3 张表 DDL + 商品种子数据 1
T2: Product 实体+Mapper+Service 商品 CRUD + 列表查询 4
T3: ProductOrder 实体+Mapper+Service 订单创建/支付/状态流转 3
T4: SysConfig 实体+Mapper+Service 系统配置 CRUD 2
T5: 后端 API Controller ProductController / ProductOrderController / SysConfigController / ProductVendorController 4
T6: 前端 API 封装 cfc-frontend/utils/api.js 新增商品/订单/配置 API 1
T7: 商城页迭代 shop/index.vue 从占位改为商品列表(含筛选) 1
T8: 发现页迭代 discover/index.vue 从占位改为活动+课程列表 1
T9: 商品详情页 discover/product-detail.vue(可复用商城/发现) 1
T10: 订单列表/详情页 shop/order-list.vue + shop/order-detail.vue 2
T11: 服务商中心扩展 vendor/center.vue 添加商品管理/订单管理入口 + 新增 vendor/products.vue + vendor/orders.vue 3
T12: Web 商品管理 admin/ProductManage.vue + api/product.js + router + Layout 4
T13: Web 订单管理 admin/OrderManage.vue 1
T14: Web 系统配置 admin/SysConfig.vue 1
T15: 验证 后端编译 + 前端 lint + 端到端测试 1
总计 30 任务

T1: 数据库初始化 — Product/Order/SysConfig 三表 DDL + 商品种子数据

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

  • [ ] Step 1: 在 DatabaseInitializer 末尾("数据库迁移完成" 日志前)追加 3 张表 DDL

找到 runMigrations() 方法结束位置("数据库迁移完成" log 之前),添加:

// ==================== Phase 1: 溪福俱乐部商品系统 ====================

// 商品表
jdbcTemplate.execute(
    "CREATE TABLE IF NOT EXISTS products (" +
    "  id BIGINT AUTO_INCREMENT PRIMARY KEY," +
    "  name VARCHAR(200) NOT NULL," +
    "  description VARCHAR(1000)," +
    "  intro TEXT," +
    "  cover_image VARCHAR(500)," +
    "  images TEXT," +
    "  price DECIMAL(10,2) NOT NULL DEFAULT 0," +
    "  stock INT NOT NULL DEFAULT 0," +
    "  sales_count INT NOT NULL DEFAULT 0," +
    "  product_type VARCHAR(32) NOT NULL COMMENT 'activity/course/physical/digital/service'," +
    "  vendor_id BIGINT," +
    "  vendor_name VARCHAR(100)," +
    "  status VARCHAR(32) NOT NULL DEFAULT 'draft' COMMENT 'draft/pending/approved/rejected/on_shelf/off_shelf'," +
    "  reject_reason VARCHAR(500)," +
    "  member_price DECIMAL(10,2)," +
    "  member_eligible VARCHAR(32) DEFAULT 'none' COMMENT 'all/budget/none'," +
    "  domain VARCHAR(32) COMMENT 'action/mind/body/wisdom/wealth'," +
    "  external_source VARCHAR(64)," +
    "  external_id VARCHAR(128)," +
    "  external_data TEXT," +
    "  created_at DATETIME," +
    "  updated_at DATETIME" +
    ")"
);

// 订单表
jdbcTemplate.execute(
    "CREATE TABLE IF NOT EXISTS product_orders (" +
    "  id BIGINT AUTO_INCREMENT PRIMARY KEY," +
    "  order_no VARCHAR(64) NOT NULL," +
    "  product_id BIGINT NOT NULL," +
    "  product_name VARCHAR(200)," +
    "  product_type VARCHAR(32)," +
    "  buyer_id BIGINT NOT NULL," +
    "  family_id BIGINT," +
    "  quantity INT NOT NULL DEFAULT 1," +
    "  unit_price DECIMAL(10,2) NOT NULL," +
    "  total_amount DECIMAL(10,2) NOT NULL," +
    "  status VARCHAR(32) NOT NULL DEFAULT 'pending' COMMENT 'pending/paid/completed/cancelled/refunded'," +
    "  payment_method VARCHAR(32) COMMENT 'wechat/points/membership'," +
    "  remark VARCHAR(500)," +
    "  paid_at DATETIME," +
    "  created_at DATETIME," +
    "  updated_at DATETIME" +
    ")"
);

// 系统配置表
jdbcTemplate.execute(
    "CREATE TABLE IF NOT EXISTS sys_config (" +
    "  id BIGINT AUTO_INCREMENT PRIMARY KEY," +
    "  config_key VARCHAR(64) NOT NULL," +
    "  config_value TEXT," +
    "  description VARCHAR(500)," +
    "  updated_at DATETIME" +
    ")"
);

// SysConfig 种子数据
insertSysConfigSeed("points_exchange_rate", "0.01", "积分兑换汇率(1积分=0.01元)");
insertSysConfigSeed("points_deduct_cap", "0.5", "积分抵扣上限(50%)");
insertSysConfigSeed("member_fee_standard", "1314", "标准会员年费(元)");
insertSysConfigSeed("member_fee_premium", "3650", "高级会员年费(元)");
insertSysConfigSeed("member_eligible_budget", "1314", "等值商品选择预算上限(元)");
insertSysConfigSeed("renewal_discount_rate", "0.85", "续费折扣率");
insertSysConfigSeed("member_discount_standard", "0.9", "标准会员折扣率");
insertSysConfigSeed("member_discount_premium", "0.8", "高级会员折扣率");

// Product 种子数据(5 个示例商品,覆盖全部 5 种 productType)
// 注意:vendor_id 设为 NULL(平台官方商品,无服务商归属)
insertProductSeed("亲子关系工作坊", "线下半日工作坊,重建家庭对话模式", "activity", 388.00, 1L, "action", "all");
insertProductSeed("情绪管理21天训练营", "21天视频课程,识别并管理情绪", "course", 199.00, 1L, "mind", "all");
insertProductSeed("肠道菌群检测套装", "家用检测 + 报告解读", "physical", 599.00, 1L, "body", "budget");
insertProductSeed("儿童多模态认知测评", "在线测评 + 个性化建议", "digital", 99.00, 1L, "wisdom", "none");
insertProductSeed("成长规划师1对1咨询", "60分钟专业规划", "service", 500.00, 1L, "wealth", "budget");
  • Step 2: 在 DatabaseInitializer 类中添加私有方法 insertSysConfigSeed 和 insertProductSeed

在类的末尾(最后一个方法之后)添加:

private void insertSysConfigSeed(String key, String value, String desc) {
    Integer count = jdbcTemplate.queryForObject(
        "SELECT COUNT(*) FROM sys_config WHERE config_key = ?", Integer.class, key);
    if (count == null || count == 0) {
        jdbcTemplate.update(
            "INSERT INTO sys_config (config_key, config_value, description, updated_at) VALUES (?, ?, ?, NOW())",
            key, value, desc);
    }
}

private void insertProductSeed(String name, String desc, String type,
                                java.math.BigDecimal price, Long vendorId,
                                String domain, String memberEligible) {
    Integer count = jdbcTemplate.queryForObject(
        "SELECT COUNT(*) FROM products WHERE name = ? AND product_type = ?", Integer.class, name, type);
    if (count == null || count == 0) {
        jdbcTemplate.update(
            "INSERT INTO products (name, description, price, product_type, vendor_id, vendor_name, status, domain, member_eligible, created_at, updated_at) " +
            "VALUES (?, ?, ?, ?, ?, '平台官方', 'on_shelf', ?, ?, NOW(), NOW())",
            name, desc, price, type, vendorId, domain, memberEligible);
    }
}
  • [ ] Step 3: 验证编译

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok(无错误)

  • [ ] Step 4: 启动后端验证表创建成功

    # 查找现有后端进程
    ps aux | grep "cfc-backend\|spring-boot" | grep -v grep
    
    # 如果进程存在,kill 后重启
    pkill -f "cfc-backend" 2>/dev/null || true
    sleep 2
    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn spring-boot:run &
    sleep 30  # 等待启动
    
    # 检查表是否创建(用 mysql 客户端或 REST)
    curl -s -X POST http://localhost:8080/api/product/list -H "Content-Type: application/json" -d '{"page":1,"size":10}'
    

Expected: 返回 JSON 含 code: 200,5 个商品数据(虽然 ProductController 还没实现,会报 404,这是正常的)

  • [ ] Step 5: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
    git commit -m "feat(phase1): 商品/订单/系统配置 3 张表 DDL + 种子数据"
    

T2: Product 实体 + Mapper + Service

Task 2.1: Product 实体类

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java

  • [ ] Step 1: 创建 Product.java

    package com.etotem.cfc.entity;
    
    import com.baomidou.mybatisplus.annotation.IdType;
    import com.baomidou.mybatisplus.annotation.TableId;
    import com.baomidou.mybatisplus.annotation.TableName;
    import lombok.Data;
    
    import java.io.Serializable;
    import java.math.BigDecimal;
    import java.util.Date;
    
    @Data
    @TableName("products")
    public class Product implements Serializable {
    
    @TableId(type = IdType.AUTO)
    private Long id;
    
    private String name;
    private String description;
    private String intro;
    private String coverImage;
    private String images;
    private BigDecimal price;
    private Integer stock;
    private Integer salesCount;
    private String productType;  // activity/course/physical/digital/service
    private Long vendorId;
    private String vendorName;
    private String status;  // draft/pending/approved/rejected/on_shelf/off_shelf
    private String rejectReason;
    private BigDecimal memberPrice;
    private String memberEligible;  // all/budget/none
    private String domain;  // action/mind/body/wisdom/wealth
    private String externalSource;
    private String externalId;
    private String externalData;
    private Date createdAt;
    private Date updatedAt;
    }
    
  • [ ] Step 2: 验证编译

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok


Task 2.2: Product Mapper

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductMapper.java

  • [ ] Step 1: 创建 ProductMapper.java

    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.Product;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface ProductMapper extends BaseMapper<Product> {
    }
    
  • [ ] Step 2: 验证编译

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok


Task 2.3: Product DTO 类

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/dto/ProductListQueryDTO.java

  • [ ] Step 1: 创建 ProductDTO.java

    package com.etotem.cfc.dto;
    
    import com.etotem.cfc.entity.Product;
    import lombok.Data;
    
    import java.math.BigDecimal;
    import java.util.Date;
    
    @Data
    public class ProductDTO {
    private Long id;
    private String name;
    private String description;
    private String intro;
    private String coverImage;
    private String images;
    private BigDecimal price;
    private BigDecimal memberPrice;
    private Integer stock;
    private Integer salesCount;
    private String productType;
    private Long vendorId;
    private String vendorName;
    private String status;
    private String domain;
    private String memberEligible;
    private Date createdAt;
    private Date updatedAt;
    
    public static ProductDTO from(Product p) {
        if (p == null) return null;
        ProductDTO d = new ProductDTO();
        d.id = p.getId();
        d.name = p.getName();
        d.description = p.getDescription();
        d.intro = p.getIntro();
        d.coverImage = p.getCoverImage();
        d.images = p.getImages();
        d.price = p.getPrice();
        d.memberPrice = p.getMemberPrice();
        d.stock = p.getStock();
        d.salesCount = p.getSalesCount();
        d.productType = p.getProductType();
        d.vendorId = p.getVendorId();
        d.vendorName = p.getVendorName();
        d.status = p.getStatus();
        d.domain = p.getDomain();
        d.memberEligible = p.getMemberEligible();
        d.createdAt = p.getCreatedAt();
        d.updatedAt = p.getUpdatedAt();
        return d;
    }
    }
    
  • [ ] Step 2: 创建 ProductListQueryDTO.java

    package com.etotem.cfc.dto;
    
    import lombok.Data;
    
    @Data
    public class ProductListQueryDTO {
    private Integer page = 1;
    private Integer size = 10;
    private String productType;  // 可选筛选
    private String domain;        // 可选筛选
    private Long vendorId;        // 可选筛选(查询某服务商的商品)
    private String keyword;       // 可选搜索关键字
    }
    
  • [ ] Step 3: 验证编译

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok


Task 2.4: ProductService

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

  • [ ] Step 1: 创建 ProductService.java

    package com.etotem.cfc.service;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.baomidou.mybatisplus.core.metadata.IPage;
    import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.dto.ProductDTO;
    import com.etotem.cfc.dto.ProductListQueryDTO;
    import com.etotem.cfc.entity.Product;
    import com.etotem.cfc.entity.User;
    import com.etotem.cfc.mapper.ProductMapper;
    import com.etotem.cfc.mapper.UserMapper;
    import org.springframework.stereotype.Service;
    
    import javax.annotation.Resource;
    import java.util.Date;
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
    import java.util.stream.Collectors;
    
    @Service
    public class ProductService {
    
    @Resource
    private ProductMapper productMapper;
    
    @Resource
    private UserMapper userMapper;
    
    /**
     * 商品列表(公开 - 仅显示已上架商品)
     */
    public Result<Map<String, Object>> list(ProductListQueryDTO query) {
        Page<Product> page = new Page<>(query.getPage(), query.getSize());
        LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
            .eq(Product::getStatus, "on_shelf")
            .orderByDesc(Product::getCreatedAt);
        if (query.getProductType() != null && !query.getProductType().isEmpty()) {
            wrapper.eq(Product::getProductType, query.getProductType());
        }
        if (query.getDomain() != null && !query.getDomain().isEmpty()) {
            wrapper.eq(Product::getDomain, query.getDomain());
        }
        if (query.getKeyword() != null && !query.getKeyword().isEmpty()) {
            wrapper.like(Product::getName, query.getKeyword());
        }
        IPage<Product> result = productMapper.selectPage(page, wrapper);
        List<ProductDTO> records = result.getRecords().stream()
            .map(ProductDTO::from)
            .collect(Collectors.toList());
        Map<String, Object> data = new HashMap<>();
        data.put("records", records);
        data.put("total", result.getTotal());
        data.put("page", result.getCurrent());
        data.put("size", result.getSize());
        return Result.success(data);
    }
    
    /**
     * 商品详情(公开)
     */
    public Result<ProductDTO> detail(Long id) {
        Product product = productMapper.selectById(id);
        if (product == null) {
            return Result.error("商品不存在");
        }
        if (!"on_shelf".equals(product.getStatus()) && !"approved".equals(product.getStatus())) {
            // 非上架商品,仅创建者和管理员可见
            return Result.error("商品未上架");
        }
        return Result.success(ProductDTO.from(product));
    }
    
    /**
     * 服务商发布商品
     */
    public Result<ProductDTO> create(Product product, Long vendorId) {
        if (vendorId == null) {
            return Result.error("请先登录");
        }
        User vendor = userMapper.selectById(vendorId);
        if (vendor == null || !"approved".equals(vendor.getVendorStatus())) {
            return Result.error("仅审核通过的服务商可发布商品");
        }
        product.setVendorId(vendorId);
        product.setVendorName(vendor.getNickname() != null ? vendor.getNickname() : vendor.getRealName());
        product.setStatus("pending");  // 待管理员审核
        product.setCreatedAt(new Date());
        product.setUpdatedAt(new Date());
        productMapper.insert(product);
        return Result.success(ProductDTO.from(product));
    }
    
    /**
     * 服务商更新商品
     */
    public Result<ProductDTO> update(Product product, Long vendorId) {
        Product existing = productMapper.selectById(product.getId());
        if (existing == null) {
            return Result.error("商品不存在");
        }
        if (!existing.getVendorId().equals(vendorId)) {
            return Result.error("无权修改此商品");
        }
        // 服务商更新后回到 pending 状态待审核
        product.setStatus("pending");
        product.setUpdatedAt(new Date());
        productMapper.updateById(product);
        return Result.success(ProductDTO.from(productMapper.selectById(product.getId())));
    }
    
    /**
     * 服务商查询自己的商品(含各状态)
     */
    public Result<List<ProductDTO>> myProducts(Long vendorId) {
        List<Product> list = productMapper.selectList(
            new LambdaQueryWrapper<Product>()
                .eq(Product::getVendorId, vendorId)
                .orderByDesc(Product::getCreatedAt)
        );
        List<ProductDTO> dtos = list.stream().map(ProductDTO::from).collect(Collectors.toList());
        return Result.success(dtos);
    }
    
    /**
     * 服务商上架/下架商品
     */
    public Result<String> shelve(Long productId, Long vendorId, String action) {
        Product product = productMapper.selectById(productId);
        if (product == null) {
            return Result.error("商品不存在");
        }
        if (!product.getVendorId().equals(vendorId)) {
            return Result.error("无权操作");
        }
        if (!"approved".equals(product.getStatus())) {
            return Result.error("商品未通过审核,无法上下架");
        }
        if ("shelve".equals(action)) {
            product.setStatus("on_shelf");
        } else if ("unshelve".equals(action)) {
            product.setStatus("off_shelf");
        } else {
            return Result.error("操作类型错误");
        }
        product.setUpdatedAt(new Date());
        productMapper.updateById(product);
        return Result.success("操作成功");
    }
    
    /**
     * 管理员审核商品
     */
    public Result<String> review(Long productId, String action, String rejectReason) {
        Product product = productMapper.selectById(productId);
        if (product == null) {
            return Result.error("商品不存在");
        }
        if (!"pending".equals(product.getStatus())) {
            return Result.error("仅待审核商品可审核");
        }
        if ("approve".equals(action)) {
            product.setStatus("approved");
            product.setRejectReason(null);
        } else if ("reject".equals(action)) {
            product.setStatus("rejected");
            product.setRejectReason(rejectReason != null ? rejectReason : "不符合规范");
        } else {
            return Result.error("操作类型错误");
        }
        product.setUpdatedAt(new Date());
        productMapper.updateById(product);
        return Result.success("操作成功");
    }
    
    /**
     * 管理员查询待审核商品
     */
    public Result<List<ProductDTO>> pendingReview() {
        List<Product> list = productMapper.selectList(
            new LambdaQueryWrapper<Product>()
                .eq(Product::getStatus, "pending")
                .orderByAsc(Product::getCreatedAt)
        );
        List<ProductDTO> dtos = list.stream().map(ProductDTO::from).collect(Collectors.toList());
        return Result.success(dtos);
    }
    
    /**
     * 管理员查询所有商品
     */
    public Result<List<ProductDTO>> adminList(String status) {
        LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
            .orderByDesc(Product::getCreatedAt);
        if (status != null && !status.isEmpty()) {
            wrapper.eq(Product::getStatus, status);
        }
        List<Product> list = productMapper.selectList(wrapper);
        List<ProductDTO> dtos = list.stream().map(ProductDTO::from).collect(Collectors.toList());
        return Result.success(dtos);
    }
    
    /**
     * 减少库存(订单创建时调用)
     */
    public boolean decreaseStock(Long productId, Integer quantity) {
        Product product = productMapper.selectById(productId);
        if (product == null || product.getStock() < quantity) {
            return false;
        }
        product.setStock(product.getStock() - quantity);
        product.setSalesCount((product.getSalesCount() != null ? product.getSalesCount() : 0) + quantity);
        product.setUpdatedAt(new Date());
        productMapper.updateById(product);
        return true;
    }
    }
    
  • [ ] Step 2: 验证编译

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok

  • [ ] Step 3: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductMapper.java cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java cfc-backend/src/main/java/com/etotem/cfc/dto/ProductListQueryDTO.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
    git commit -m "feat(phase1): Product 实体/Mapper/Service 实现"
    

T3: ProductOrder 实体 + Mapper + Service

Task 3.1: ProductOrder 实体 + Mapper + DTO

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductOrderMapper.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/dto/ProductOrderDTO.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java

  • [ ] Step 1: 创建 ProductOrder.java

    package com.etotem.cfc.entity;
    
    import com.baomidou.mybatisplus.annotation.IdType;
    import com.baomidou.mybatisplus.annotation.TableId;
    import com.baomidou.mybatisplus.annotation.TableName;
    import lombok.Data;
    
    import java.io.Serializable;
    import java.math.BigDecimal;
    import java.util.Date;
    
    @Data
    @TableName("product_orders")
    public class ProductOrder implements Serializable {
    
    @TableId(type = IdType.AUTO)
    private Long id;
    
    private String orderNo;
    private Long productId;
    private String productName;
    private String productType;
    private Long buyerId;
    private Long familyId;
    private Integer quantity;
    private BigDecimal unitPrice;
    private BigDecimal totalAmount;
    private String status;  // pending/paid/completed/cancelled/refunded
    private String paymentMethod;  // wechat/points/membership
    private String remark;
    private Date paidAt;
    private Date createdAt;
    private Date updatedAt;
    }
    
  • [ ] Step 2: 创建 ProductOrderMapper.java

    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.ProductOrder;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface ProductOrderMapper extends BaseMapper<ProductOrder> {
    }
    
  • [ ] Step 3: 创建 ProductOrderDTO.java

    package com.etotem.cfc.dto;
    
    import com.etotem.cfc.entity.ProductOrder;
    import lombok.Data;
    
    import java.math.BigDecimal;
    import java.util.Date;
    
    @Data
    public class ProductOrderDTO {
    private Long id;
    private String orderNo;
    private Long productId;
    private String productName;
    private String productType;
    private Long buyerId;
    private String buyerName;
    private Long familyId;
    private Integer quantity;
    private BigDecimal unitPrice;
    private BigDecimal totalAmount;
    private String status;
    private String paymentMethod;
    private String remark;
    private Date paidAt;
    private Date createdAt;
    
    public static ProductOrderDTO from(ProductOrder o) {
        if (o == null) return null;
        ProductOrderDTO d = new ProductOrderDTO();
        d.id = o.getId();
        d.orderNo = o.getOrderNo();
        d.productId = o.getProductId();
        d.productName = o.getProductName();
        d.productType = o.getProductType();
        d.buyerId = o.getBuyerId();
        d.familyId = o.getFamilyId();
        d.quantity = o.getQuantity();
        d.unitPrice = o.getUnitPrice();
        d.totalAmount = o.getTotalAmount();
        d.status = o.getStatus();
        d.paymentMethod = o.getPaymentMethod();
        d.remark = o.getRemark();
        d.paidAt = o.getPaidAt();
        d.createdAt = o.getCreatedAt();
        return d;
    }
    }
    
  • [ ] Step 4: 创建 CreateProductOrderDTO.java

    package com.etotem.cfc.dto;
    
    import lombok.Data;
    
    @Data
    public class CreateProductOrderDTO {
    private Long productId;
    private Integer quantity = 1;
    private String paymentMethod;  // wechat/points/membership
    private String remark;
    }
    
  • [ ] Step 5: 验证编译

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok


Task 3.2: ProductOrderService

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

  • [ ] Step 1: 创建 ProductOrderService.java

    package com.etotem.cfc.service;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.dto.CreateProductOrderDTO;
    import com.etotem.cfc.dto.ProductOrderDTO;
    import com.etotem.cfc.entity.Product;
    import com.etotem.cfc.entity.ProductOrder;
    import com.etotem.cfc.entity.User;
    import com.etotem.cfc.mapper.ProductMapper;
    import com.etotem.cfc.mapper.ProductOrderMapper;
    import com.etotem.cfc.mapper.UserMapper;
    import org.springframework.stereotype.Service;
    
    import javax.annotation.Resource;
    import java.math.BigDecimal;
    import java.util.Date;
    import java.util.List;
    import java.util.stream.Collectors;
    
    @Service
    public class ProductOrderService {
    
    @Resource
    private ProductOrderMapper orderMapper;
    
    @Resource
    private ProductMapper productMapper;
    
    @Resource
    private UserMapper userMapper;
    
    @Resource
    private ProductService productService;
    
    /**
     * 创建订单
     */
    public Result<ProductOrderDTO> create(CreateProductOrderDTO dto, Long buyerId) {
        if (buyerId == null) {
            return Result.error("请先登录");
        }
        Product product = productMapper.selectById(dto.getProductId());
        if (product == null) {
            return Result.error("商品不存在");
        }
        if (!"on_shelf".equals(product.getStatus())) {
            return Result.error("商品已下架");
        }
        if (product.getStock() != null && product.getStock() < dto.getQuantity()) {
            return Result.error("库存不足");
        }
    
        User buyer = userMapper.selectById(buyerId);
        ProductOrder order = new ProductOrder();
        order.setOrderNo(generateOrderNo());
        order.setProductId(product.getId());
        order.setProductName(product.getName());
        order.setProductType(product.getProductType());
        order.setBuyerId(buyerId);
        order.setFamilyId(buyer != null ? buyer.getFamilyId() : null);
        order.setQuantity(dto.getQuantity());
        order.setUnitPrice(product.getPrice());
        order.setTotalAmount(product.getPrice().multiply(BigDecimal.valueOf(dto.getQuantity())));
        order.setStatus("pending");
        order.setPaymentMethod(dto.getPaymentMethod() != null ? dto.getPaymentMethod() : "wechat");
        order.setRemark(dto.getRemark());
        order.setCreatedAt(new Date());
        order.setUpdatedAt(new Date());
        orderMapper.insert(order);
        return Result.success(ProductOrderDTO.from(order));
    }
    
    /**
     * 支付订单(测试模式:直接标记为已支付)
     */
    public Result<ProductOrderDTO> pay(String orderNo, Long userId) {
        ProductOrder order = orderMapper.selectOne(
            new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
        if (order == null) {
            return Result.error("订单不存在");
        }
        if (!order.getBuyerId().equals(userId)) {
            return Result.error("无权操作此订单");
        }
        if (!"pending".equals(order.getStatus())) {
            return Result.error("订单状态异常");
        }
        // 减少库存
        boolean stockOk = productService.decreaseStock(order.getProductId(), order.getQuantity());
        if (!stockOk) {
            return Result.error("库存不足");
        }
        order.setStatus("paid");
        order.setPaidAt(new Date());
        order.setUpdatedAt(new Date());
        orderMapper.updateById(order);
        return Result.success(ProductOrderDTO.from(order));
    }
    
    /**
     * 取消订单
     */
    public Result<String> cancel(String orderNo, Long userId) {
        ProductOrder order = orderMapper.selectOne(
            new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
        if (order == null) {
            return Result.error("订单不存在");
        }
        if (!order.getBuyerId().equals(userId)) {
            return Result.error("无权操作此订单");
        }
        if (!"pending".equals(order.getStatus())) {
            return Result.error("仅待支付订单可取消");
        }
        order.setStatus("cancelled");
        order.setUpdatedAt(new Date());
        orderMapper.updateById(order);
        return Result.success("订单已取消");
    }
    
    /**
     * 买家查询自己的订单
     */
    public Result<List<ProductOrderDTO>> myOrders(Long buyerId, String status) {
        LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<ProductOrder>()
            .eq(ProductOrder::getBuyerId, buyerId)
            .orderByDesc(ProductOrder::getCreatedAt);
        if (status != null && !status.isEmpty()) {
            wrapper.eq(ProductOrder::getStatus, status);
        }
        List<ProductOrder> list = orderMapper.selectList(wrapper);
        return Result.success(list.stream().map(ProductOrderDTO::from).collect(Collectors.toList()));
    }
    
    /**
     * 订单详情
     */
    public Result<ProductOrderDTO> detail(String orderNo, Long userId) {
        ProductOrder order = orderMapper.selectOne(
            new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
        if (order == null) {
            return Result.error("订单不存在");
        }
        // 仅买家/卖家/管理员可见
        Product product = productMapper.selectById(order.getProductId());
        boolean isBuyer = order.getBuyerId().equals(userId);
        boolean isVendor = product != null && product.getVendorId() != null && product.getVendorId().equals(userId);
        if (!isBuyer && !isVendor) {
            return Result.error("无权查看");
        }
        return Result.success(ProductOrderDTO.from(order));
    }
    
    /**
     * 服务商查询收到的订单
     */
    public Result<List<ProductOrderDTO>> vendorOrders(Long vendorId, String status) {
        // 找出该服务商的所有商品 ID
        List<Product> products = productMapper.selectList(
            new LambdaQueryWrapper<Product>().eq(Product::getVendorId, vendorId));
        if (products.isEmpty()) {
            return Result.success(java.util.Collections.emptyList());
        }
        List<Long> productIds = products.stream().map(Product::getId).collect(Collectors.toList());
        LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<ProductOrder>()
            .in(ProductOrder::getProductId, productIds)
            .orderByDesc(ProductOrder::getCreatedAt);
        if (status != null && !status.isEmpty()) {
            wrapper.eq(ProductOrder::getStatus, status);
        }
        List<ProductOrder> list = orderMapper.selectList(wrapper);
        return Result.success(list.stream().map(ProductOrderDTO::from).collect(Collectors.toList()));
    }
    
    /**
     * 管理员查询所有订单
     */
    public Result<List<ProductOrderDTO>> adminList(String status) {
        LambdaQueryWrapper<ProductOrder> wrapper = new LambdaQueryWrapper<ProductOrder>()
            .orderByDesc(ProductOrder::getCreatedAt);
        if (status != null && !status.isEmpty()) {
            wrapper.eq(ProductOrder::getStatus, status);
        }
        List<ProductOrder> list = orderMapper.selectList(wrapper);
        return Result.success(list.stream().map(ProductOrderDTO::from).collect(Collectors.toList()));
    }
    
    /**
     * 服务商确认订单完成
     */
    public Result<String> confirm(String orderNo, Long vendorId) {
        ProductOrder order = orderMapper.selectOne(
            new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
        if (order == null) {
            return Result.error("订单不存在");
        }
        Product product = productMapper.selectById(order.getProductId());
        if (product == null || !product.getVendorId().equals(vendorId)) {
            return Result.error("无权操作");
        }
        if (!"paid".equals(order.getStatus())) {
            return Result.error("仅已支付订单可确认完成");
        }
        order.setStatus("completed");
        order.setUpdatedAt(new Date());
        orderMapper.updateById(order);
        return Result.success("订单已完成");
    }
    
    private String generateOrderNo() {
        return "PO" + System.currentTimeMillis();
    }
    }
    
  • [ ] Step 2: 验证编译

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok

  • [ ] Step 3: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductOrderMapper.java cfc-backend/src/main/java/com/etotem/cfc/dto/ProductOrderDTO.java cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
    git commit -m "feat(phase1): ProductOrder 实体/Mapper/Service 实现"
    

T4: SysConfig 实体 + Mapper + Service

Task 4.1: SysConfig 实体 + Mapper + DTO

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/entity/SysConfig.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/mapper/SysConfigMapper.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/dto/SysConfigDTO.java

  • [ ] Step 1: 创建 SysConfig.java

    package com.etotem.cfc.entity;
    
    import com.baomidou.mybatisplus.annotation.IdType;
    import com.baomidou.mybatisplus.annotation.TableId;
    import com.baomidou.mybatisplus.annotation.TableName;
    import lombok.Data;
    
    import java.io.Serializable;
    import java.util.Date;
    
    @Data
    @TableName("sys_config")
    public class SysConfig implements Serializable {
    
    @TableId(type = IdType.AUTO)
    private Long id;
    
    private String configKey;
    private String configValue;
    private String description;
    private Date updatedAt;
    }
    
  • [ ] Step 2: 创建 SysConfigMapper.java

    package com.etotem.cfc.mapper;
    
    import com.baomidou.mybatisplus.core.mapper.BaseMapper;
    import com.etotem.cfc.entity.SysConfig;
    import org.apache.ibatis.annotations.Mapper;
    
    @Mapper
    public interface SysConfigMapper extends BaseMapper<SysConfig> {
    }
    
  • [ ] Step 3: 创建 SysConfigDTO.java

    package com.etotem.cfc.dto;
    
    import com.etotem.cfc.entity.SysConfig;
    import lombok.Data;
    
    import java.util.Date;
    
    @Data
    public class SysConfigDTO {
    private Long id;
    private String configKey;
    private String configValue;
    private String description;
    private Date updatedAt;
    
    public static SysConfigDTO from(SysConfig c) {
        if (c == null) return null;
        SysConfigDTO d = new SysConfigDTO();
        d.id = c.getId();
        d.configKey = c.getConfigKey();
        d.configValue = c.getConfigValue();
        d.description = c.getDescription();
        d.updatedAt = c.getUpdatedAt();
        return d;
    }
    }
    
  • [ ] Step 4: 验证编译

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok


Task 4.2: SysConfigService

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/service/SysConfigService.java

  • [ ] Step 1: 创建 SysConfigService.java

    package com.etotem.cfc.service;
    
    import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.dto.SysConfigDTO;
    import com.etotem.cfc.entity.SysConfig;
    import com.etotem.cfc.mapper.SysConfigMapper;
    import org.springframework.stereotype.Service;
    
    import javax.annotation.Resource;
    import java.util.Date;
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
    import java.util.stream.Collectors;
    
    @Service
    public class SysConfigService {
    
    @Resource
    private SysConfigMapper configMapper;
    
    /**
     * 获取所有配置(key-value 形式)
     */
    public Result<Map<String, String>> getAllAsMap() {
        List<SysConfig> list = configMapper.selectList(null);
        Map<String, String> map = new HashMap<>();
        for (SysConfig c : list) {
            map.put(c.getConfigKey(), c.getConfigValue());
        }
        return Result.success(map);
    }
    
    /**
     * 获取所有配置(详细列表)
     */
    public Result<List<SysConfigDTO>> listAll() {
        List<SysConfig> list = configMapper.selectList(
            new LambdaQueryWrapper<SysConfig>().orderByAsc(SysConfig::getConfigKey));
        return Result.success(list.stream().map(SysConfigDTO::from).collect(Collectors.toList()));
    }
    
    /**
     * 获取单个配置
     */
    public Result<SysConfigDTO> get(String key) {
        SysConfig config = configMapper.selectOne(
            new LambdaQueryWrapper<SysConfig>().eq(SysConfig::getConfigKey, key));
        if (config == null) {
            return Result.error("配置不存在");
        }
        return Result.success(SysConfigDTO.from(config));
    }
    
    /**
     * 更新配置
     */
    public Result<String> update(String key, String value) {
        SysConfig config = configMapper.selectOne(
            new LambdaQueryWrapper<SysConfig>().eq(SysConfig::getConfigKey, key));
        if (config == null) {
            return Result.error("配置不存在");
        }
        config.setConfigValue(value);
        config.setUpdatedAt(new Date());
        configMapper.updateById(config);
        return Result.success("更新成功");
    }
    
    /**
     * 内部方法:获取配置值(供其他 Service 调用)
     */
    public String getValue(String key) {
        SysConfig config = configMapper.selectOne(
            new LambdaQueryWrapper<SysConfig>().eq(SysConfig::getConfigKey, key));
        return config != null ? config.getConfigValue() : null;
    }
    }
    
  • [ ] Step 2: 验证编译

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok

  • [ ] Step 3: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-backend/src/main/java/com/etotem/cfc/entity/SysConfig.java cfc-backend/src/main/java/com/etotem/cfc/mapper/SysConfigMapper.java cfc-backend/src/main/java/com/etotem/cfc/dto/SysConfigDTO.java cfc-backend/src/main/java/com/etotem/cfc/service/SysConfigService.java
    git commit -m "feat(phase1): SysConfig 实体/Mapper/Service 实现"
    

T5: 后端 API Controller

Task 5.1: ProductController(公开 API + 服务商 API)

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java

  • [ ] Step 1: 创建 ProductController.java

    package com.etotem.cfc.controller.product;
    
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.dto.ProductDTO;
    import com.etotem.cfc.dto.ProductListQueryDTO;
    import com.etotem.cfc.entity.Product;
    import com.etotem.cfc.service.ProductService;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestHeader;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.annotation.Resource;
    import java.util.List;
    import java.util.Map;
    
    @RestController
    @RequestMapping("/api/product")
    public class ProductController {
    
    @Resource
    private ProductService productService;
    
    /**
     * 商品列表(公开)
     */
    @PostMapping("/list")
    public Result<Map<String, Object>> list(@RequestBody ProductListQueryDTO query) {
        return productService.list(query);
    }
    
    /**
     * 商品详情(公开)
     */
    @PostMapping("/detail")
    public Result<ProductDTO> detail(@RequestBody Map<String, Object> params) {
        Long id = Long.parseLong(params.get("id").toString());
        return productService.detail(id);
    }
    
    /**
     * 服务商发布商品
     */
    @PostMapping("/create")
    public Result<ProductDTO> create(@RequestBody Product product,
                                    @RequestHeader(value = "X-User-Id", required = false) Long userId) {
        return productService.create(product, userId);
    }
    
    /**
     * 服务商更新商品
     */
    @PostMapping("/update")
    public Result<ProductDTO> update(@RequestBody Product product,
                                    @RequestHeader(value = "X-User-Id", required = false) Long userId) {
        return productService.update(product, userId);
    }
    
    /**
     * 服务商查询自己的商品
     */
    @PostMapping("/my")
    public Result<List<ProductDTO>> myProducts(@RequestHeader(value = "X-User-Id", required = false) Long userId) {
        return productService.myProducts(userId);
    }
    
    /**
     * 服务商上架/下架商品
     */
    @PostMapping("/shelve")
    public Result<String> shelve(@RequestBody Map<String, Object> params,
                                @RequestHeader(value = "X-User-Id", required = false) Long userId) {
        Long productId = Long.parseLong(params.get("productId").toString());
        String action = (String) params.get("action");  // shelve / unshelve
        return productService.shelve(productId, userId, action);
    }
    }
    
  • [ ] Step 2: 验证编译

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok


Task 5.2: ProductOrderController

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductOrderController.java

  • [ ] Step 1: 创建 ProductOrderController.java

    package com.etotem.cfc.controller.product;
    
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.dto.CreateProductOrderDTO;
    import com.etotem.cfc.dto.ProductOrderDTO;
    import com.etotem.cfc.service.ProductOrderService;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestHeader;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.annotation.Resource;
    import java.util.List;
    import java.util.Map;
    
    @RestController
    @RequestMapping("/api/product/order")
    public class ProductOrderController {
    
    @Resource
    private ProductOrderService orderService;
    
    /**
     * 创建订单
     */
    @PostMapping("/create")
    public Result<ProductOrderDTO> create(@RequestBody CreateProductOrderDTO dto,
                                         @RequestHeader(value = "X-User-Id", required = false) Long userId) {
        return orderService.create(dto, userId);
    }
    
    /**
     * 支付订单(测试模式)
     */
    @PostMapping("/pay")
    public Result<ProductOrderDTO> pay(@RequestBody Map<String, Object> params,
                                      @RequestHeader(value = "X-User-Id", required = false) Long userId) {
        String orderNo = (String) params.get("orderNo");
        return orderService.pay(orderNo, userId);
    }
    
    /**
     * 取消订单
     */
    @PostMapping("/cancel")
    public Result<String> cancel(@RequestBody Map<String, Object> params,
                                @RequestHeader(value = "X-User-Id", required = false) Long userId) {
        String orderNo = (String) params.get("orderNo");
        return orderService.cancel(orderNo, userId);
    }
    
    /**
     * 我的订单
     */
    @PostMapping("/my")
    public Result<List<ProductOrderDTO>> myOrders(@RequestBody Map<String, Object> params,
                                                 @RequestHeader(value = "X-User-Id", required = false) Long userId) {
        String status = (String) params.get("status");
        return orderService.myOrders(userId, status);
    }
    
    /**
     * 订单详情
     */
    @PostMapping("/detail")
    public Result<ProductOrderDTO> detail(@RequestBody Map<String, Object> params,
                                        @RequestHeader(value = "X-User-Id", required = false) Long userId) {
        String orderNo = (String) params.get("orderNo");
        return orderService.detail(orderNo, userId);
    }
    
    /**
     * 服务商查询收到的订单
     */
    @PostMapping("/vendor")
    public Result<List<ProductOrderDTO>> vendorOrders(@RequestBody Map<String, Object> params,
                                                     @RequestHeader(value = "X-User-Id", required = false) Long userId) {
        String status = (String) params.get("status");
        return orderService.vendorOrders(userId, status);
    }
    
    /**
     * 服务商确认订单完成
     */
    @PostMapping("/confirm")
    public Result<String> confirm(@RequestBody Map<String, Object> params,
                                 @RequestHeader(value = "X-User-Id", required = false) Long userId) {
        String orderNo = (String) params.get("orderNo");
        return orderService.confirm(orderNo, userId);
    }
    }
    
  • [ ] Step 2: 验证编译

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok


Task 5.3: SysConfigController(管理员 API)

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/controller/config/SysConfigController.java

  • [ ] Step 1: 创建 SysConfigController.java

    package com.etotem.cfc.controller.config;
    
    import com.etotem.cfc.common.Result;
    import com.etotem.cfc.dto.SysConfigDTO;
    import com.etotem.cfc.service.SysConfigService;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import javax.annotation.Resource;
    import java.util.List;
    import java.util.Map;
    
    @RestController
    @RequestMapping("/api/admin/config")
    public class SysConfigController {
    
    @Resource
    private SysConfigService configService;
    
    /**
     * 管理员获取所有配置
     */
    @PostMapping("/list")
    public Result<List<SysConfigDTO>> list() {
        return configService.listAll();
    }
    
    /**
     * 管理员更新配置
     */
    @PostMapping("/update")
    public Result<String> update(@RequestBody Map<String, Object> params) {
        String key = (String) params.get("configKey");
        String value = (String) params.get("configValue");
        return configService.update(key, value);
    }
    
    /**
     * 内部使用:获取所有配置(key-value 形式)
     */
    @PostMapping("/all")
    public Result<Map<String, String>> getAllAsMap() {
        return configService.getAllAsMap();
    }
    }
    
  • [ ] Step 2: 验证编译

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok


Task 5.4: AdminController 扩展 — 商品审核端点

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java

  • [ ] Step 1: 找到 AdminController 类,在最后添加商品/订单审核端点

在类的最后一个方法之后(} 闭合大括号之前)添加:

    @Resource
    private com.etotem.cfc.service.ProductService productService;

    @Resource
    private com.etotem.cfc.service.ProductOrderService productOrderService;

    @PostMapping("/product/pending")
    public Result<List<com.etotem.cfc.dto.ProductDTO>> productPending() {
        return productService.pendingReview();
    }

    @PostMapping("/product/list")
    public Result<List<com.etotem.cfc.dto.ProductDTO>> productList(@RequestBody Map<String, Object> params) {
        String status = (String) params.get("status");
        return productService.adminList(status);
    }

    @PostMapping("/product/review")
    public Result<String> productReview(@RequestBody Map<String, Object> params) {
        Long productId = Long.parseLong(params.get("productId").toString());
        String action = (String) params.get("action");  // approve / reject
        String rejectReason = (String) params.get("rejectReason");
        return productService.review(productId, action, rejectReason);
    }

    @PostMapping("/order/list")
    public Result<List<com.etotem.cfc.dto.ProductOrderDTO>> orderList(@RequestBody Map<String, Object> params) {
        String status = (String) params.get("status");
        return productOrderService.adminList(status);
    }
  • [ ] Step 2: 验证编译

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok

  • [ ] Step 3: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductController.java cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductOrderController.java cfc-backend/src/main/java/com/etotem/cfc/controller/config/SysConfigController.java cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminController.java
    git commit -m "feat(phase1): Product/ProductOrder/SysConfig/Admin 后端 Controller"
    

T6: 前端 API 封装

Files:

  • Modify: cfc-frontend/utils/api.js

  • [ ] Step 1: 在 api.js 末尾追加商品/订单/系统配置 API

打开 cfc-frontend/utils/api.js,在 module.exports 之前找到合适位置(推荐放在 vendorApply 等 vendor API 附近),添加:

// ==================== Phase 1: 商品系统 ====================

// 商品列表(公开)
const productList = (params) => request('/api/product/list', 'POST', params);

// 商品详情(公开)
const productDetail = (params) => request('/api/product/detail', 'POST', params);

// 服务商发布商品
const productCreate = (params) => request('/api/product/create', 'POST', params);

// 服务商更新商品
const productUpdate = (params) => request('/api/product/update', 'POST', params);

// 服务商查询自己的商品
const productMy = (params) => request('/api/product/my', 'POST', params);

// 服务商上架/下架商品
const productShelve = (params) => request('/api/product/shelve', 'POST', params);

// 创建订单
const productOrderCreate = (params) => request('/api/product/order/create', 'POST', params);

// 支付订单
const productOrderPay = (params) => request('/api/product/order/pay', 'POST', params);

// 取消订单
const productOrderCancel = (params) => request('/api/product/order/cancel', 'POST', params);

// 我的订单
const productOrderMy = (params) => request('/api/product/order/my', 'POST', params);

// 订单详情
const productOrderDetail = (params) => request('/api/product/order/detail', 'POST', params);

// 服务商查询收到的订单
const productOrderVendor = (params) => request('/api/product/order/vendor', 'POST', params);

// 服务商确认订单完成
const productOrderConfirm = (params) => request('/api/product/order/confirm', 'POST', params);

// 管理员:商品待审核列表
const adminProductPending = (params) => request('/api/admin/product/pending', 'POST', params);

// 管理员:商品列表
const adminProductList = (params) => request('/api/admin/product/list', 'POST', params);

// 管理员:商品审核
const adminProductReview = (params) => request('/api/admin/product/review', 'POST', params);

// 管理员:订单列表
const adminOrderList = (params) => request('/api/admin/order/list', 'POST', params);

// 管理员:系统配置列表
const adminConfigList = (params) => request('/api/admin/config/list', 'POST', params);

// 管理员:更新系统配置
const adminConfigUpdate = (params) => request('/api/admin/config/update', 'POST', params);
  • Step 2: 找到 module.exports,添加这些函数的导出

module.exports 对象中追加:

  productList, productDetail, productCreate, productUpdate, productMy, productShelve,
  productOrderCreate, productOrderPay, productOrderCancel, productOrderMy, productOrderDetail, productOrderVendor, productOrderConfirm,
  adminProductPending, adminProductList, adminProductReview, adminOrderList,
  adminConfigList, adminConfigUpdate,
  • [ ] Step 3: 验证前端能 import 这些 API

    cd /mnt/e/workspace/dan/zxyj/cfc-frontend && rtk grep "productList\|adminProductPending" -n 2>&1
    

Expected: 输出显示这些函数已经在 api.js 中定义和导出

  • [ ] Step 4: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-frontend/utils/api.js
    git commit -m "feat(phase1): 前端 api.js 新增商品/订单/系统配置 API"
    

T7: 商城页迭代 — 从占位改为商品列表

Files:

  • Modify: cfc-frontend/pages/shop/index.vue

  • [ ] Step 1: 完整重写 pages/shop/index.vue

用以下代码完整覆盖 cfc-frontend/pages/shop/index.vue

<template>
  <view class="shop-page">
    <view class="header">
      <view class="title">溪福商城</view>
      <view class="subtitle">五行五维 · 选择属于你的能量</view>
    </view>

    <!-- 五行维度筛选 -->
    <scroll-view class="domain-tabs" scroll-x>
      <view v-for="d in domainOptions" :key="d.value"
            :class="['tab', currentDomain === d.value ? 'active' : '']"
            @click="onDomainChange(d.value)">
        <text>{{ d.label }}</text>
      </view>
    </scroll-view>

    <!-- 商品类型筛选 -->
    <view class="type-filter">
      <text v-for="t in typeOptions" :key="t.value"
            :class="['filter-chip', currentType === t.value ? 'active' : '']"
            @click="onTypeChange(t.value)">{{ t.label }}</text>
    </view>

    <!-- 商品网格 -->
    <view class="product-grid">
      <view v-for="p in products" :key="p.id" class="product-card" @click="goDetail(p.id)">
        <image v-if="p.coverImage" :src="p.coverImage" mode="aspectFill" class="cover" />
        <view v-else class="cover-placeholder">{{ domainEmoji(p.domain) }}</view>
        <view class="info">
          <view class="name">{{ p.name }}</view>
          <view class="meta">
            <text class="price">¥{{ p.price }}</text>
            <text v-if="p.memberPrice" class="member-price">会员 ¥{{ p.memberPrice }}</text>
          </view>
          <view class="footer">
            <text class="vendor">{{ p.vendorName || '平台官方' }}</text>
            <text class="sales">已售 {{ p.salesCount || 0 }}</text>
          </view>
        </view>
      </view>
    </view>

    <view v-if="!products.length && !loading" class="empty">暂无商品</view>
  </view>
</template>

<script>
import { productList } from '@/utils/api.js';

export default {
  data() {
    return {
      products: [],
      loading: false,
      currentDomain: '',
      currentType: '',
      domainOptions: [
        { label: '全部', value: '' },
        { label: '🌿 行·木', value: 'action' },
        { label: '🔥 心·火', value: 'mind' },
        { label: '🌏 身·土', value: 'body' },
        { label: '⚔️ 智·金', value: 'wisdom' },
        { label: '💧 富·水', value: 'wealth' }
      ],
      typeOptions: [
        { label: '全部', value: '' },
        { label: '活动', value: 'activity' },
        { label: '课程', value: 'course' },
        { label: '实物', value: 'physical' },
        { label: '数字', value: 'digital' },
        { label: '服务', value: 'service' }
      ]
    };
  },
  onShow() {
    this.loadProducts();
  },
  methods: {
    async loadProducts() {
      this.loading = true;
      try {
        const res = await productList({
          page: 1,
          size: 50,
          productType: this.currentType,
          domain: this.currentDomain
        });
        if (res.code === 200) {
          this.products = res.data.records || [];
        }
      } catch (e) {
        console.error('loadProducts failed', e);
      }
      this.loading = false;
    },
    onDomainChange(value) {
      this.currentDomain = value;
      this.loadProducts();
    },
    onTypeChange(value) {
      this.currentType = value;
      this.loadProducts();
    },
    goDetail(id) {
      uni.navigateTo({ url: '/pages/discover/product-detail?id=' + id });
    },
    domainEmoji(domain) {
      const map = { action: '🌿', mind: '🔥', body: '🌏', wisdom: '⚔️', wealth: '💧' };
      return map[domain] || '✨';
    }
  }
};
</script>

<style scoped>
.shop-page {
  min-height: 100vh;
  background: #F8F4ED;
  padding: 20rpx 30rpx;
}
.header {
  text-align: center;
  margin: 20rpx 0 30rpx;
}
.title {
  font-size: 40rpx;
  font-weight: bold;
  color: #6B4423;
}
.subtitle {
  font-size: 24rpx;
  color: #8B7355;
  margin-top: 8rpx;
}
.domain-tabs {
  white-space: nowrap;
  margin-bottom: 20rpx;
}
.tab {
  display: inline-block;
  padding: 16rpx 28rpx;
  margin-right: 16rpx;
  background: #FFFFFF;
  border-radius: 30rpx;
  font-size: 26rpx;
  color: #6B4423;
  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
}
.tab.active {
  background: linear-gradient(135deg, #FFB88C, #DE6262);
  color: #FFFFFF;
}
.type-filter {
  display: flex;
  flex-wrap: wrap;
  margin-bottom: 24rpx;
}
.filter-chip {
  padding: 12rpx 24rpx;
  margin: 8rpx 12rpx 8rpx 0;
  background: #FFFFFF;
  border-radius: 24rpx;
  font-size: 24rpx;
  color: #6B4423;
}
.filter-chip.active {
  background: #FFD700;
  color: #6B4423;
  font-weight: bold;
}
.product-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20rpx;
}
.product-card {
  background: #FFFFFF;
  border-radius: 20rpx;
  overflow: hidden;
  box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
}
.cover {
  width: 100%;
  height: 280rpx;
  display: block;
}
.cover-placeholder {
  width: 100%;
  height: 280rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 80rpx;
  background: linear-gradient(135deg, #FFE5B4, #FFB88C);
}
.info {
  padding: 16rpx;
}
.name {
  font-size: 28rpx;
  color: #3C2A1E;
  font-weight: 500;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  min-height: 76rpx;
}
.meta {
  display: flex;
  align-items: baseline;
  margin-top: 10rpx;
}
.price {
  font-size: 32rpx;
  color: #DE6262;
  font-weight: bold;
}
.member-price {
  font-size: 22rpx;
  color: #FF8C00;
  margin-left: 12rpx;
  background: #FFF3D6;
  padding: 2rpx 10rpx;
  border-radius: 12rpx;
}
.footer {
  display: flex;
  justify-content: space-between;
  margin-top: 8rpx;
  font-size: 22rpx;
  color: #8B7355;
}
.empty {
  text-align: center;
  padding: 100rpx 0;
  color: #8B7355;
  font-size: 28rpx;
}
</style>
  • [ ] Step 2: 验证文件保存成功

    rtk wc -l /mnt/e/workspace/dan/zxyj/cfc-frontend/pages/shop/index.vue
    

Expected: 输出行数(应该 > 100)

  • [ ] Step 3: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-frontend/pages/shop/index.vue
    git commit -m "feat(phase1): 商城页 - 从占位改为商品列表(五行筛选+类型筛选)"
    

T8: 发现页迭代

Files:

  • Modify: cfc-frontend/pages/discover/index.vue

  • [ ] Step 1: 完整重写 pages/discover/index.vue

用以下代码完整覆盖 cfc-frontend/pages/discover/index.vue

<template>
  <view class="discover-page">
    <view class="header">
      <view class="title">溪福发现</view>
      <view class="subtitle">活动 · 课程 · 探索</view>
    </view>

    <!-- 类型切换 -->
    <view class="type-tabs">
      <text v-for="t in typeOptions" :key="t.value"
            :class="['tab', currentType === t.value ? 'active' : '']"
            @click="onTypeChange(t.value)">{{ t.label }}</text>
    </view>

    <!-- 列表 -->
    <view class="discover-list">
      <view v-for="p in products" :key="p.id" class="card" @click="goDetail(p.id)">
        <view class="card-left">
          <view class="card-cover">{{ domainEmoji(p.domain) }}</view>
        </view>
        <view class="card-right">
          <view class="card-title">{{ p.name }}</view>
          <view class="card-desc">{{ p.description }}</view>
          <view class="card-meta">
            <text class="card-price">¥{{ p.price }}</text>
            <text class="card-vendor">{{ p.vendorName || '平台官方' }}</text>
          </view>
        </view>
      </view>
    </view>

    <view v-if="!products.length && !loading" class="empty">暂无内容</view>
  </view>
</template>

<script>
import { productList } from '@/utils/api.js';

export default {
  data() {
    return {
      products: [],
      loading: false,
      currentType: '',
      typeOptions: [
        { label: '推荐', value: '' },
        { label: '活动', value: 'activity' },
        { label: '课程', value: 'course' }
      ]
    };
  },
  onShow() {
    this.loadProducts();
  },
  methods: {
    async loadProducts() {
      this.loading = true;
      try {
        const res = await productList({
          page: 1,
          size: 30,
          productType: this.currentType
        });
        if (res.code === 200) {
          this.products = res.data.records || [];
        }
      } catch (e) {
        console.error('loadProducts failed', e);
      }
      this.loading = false;
    },
    onTypeChange(value) {
      this.currentType = value;
      this.loadProducts();
    },
    goDetail(id) {
      uni.navigateTo({ url: '/pages/discover/product-detail?id=' + id });
    },
    domainEmoji(domain) {
      const map = { action: '🌿', mind: '🔥', body: '🌏', wisdom: '⚔️', wealth: '💧' };
      return map[domain] || '✨';
    }
  }
};
</script>

<style scoped>
.discover-page {
  min-height: 100vh;
  background: #F8F4ED;
  padding: 20rpx 30rpx;
}
.header {
  text-align: center;
  margin: 20rpx 0 30rpx;
}
.title {
  font-size: 40rpx;
  font-weight: bold;
  color: #6B4423;
}
.subtitle {
  font-size: 24rpx;
  color: #8B7355;
  margin-top: 8rpx;
}
.type-tabs {
  display: flex;
  margin-bottom: 24rpx;
}
.tab {
  flex: 1;
  text-align: center;
  padding: 16rpx 0;
  background: #FFFFFF;
  margin: 0 8rpx;
  border-radius: 20rpx;
  font-size: 26rpx;
  color: #6B4423;
}
.tab.active {
  background: linear-gradient(135deg, #FFB88C, #DE6262);
  color: #FFFFFF;
}
.discover-list {
  display: flex;
  flex-direction: column;
  gap: 20rpx;
}
.card {
  display: flex;
  background: #FFFFFF;
  border-radius: 20rpx;
  overflow: hidden;
  box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
  padding: 20rpx;
}
.card-left {
  flex-shrink: 0;
}
.card-cover {
  width: 160rpx;
  height: 160rpx;
  border-radius: 16rpx;
  background: linear-gradient(135deg, #FFE5B4, #FFB88C);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 60rpx;
}
.card-right {
  flex: 1;
  margin-left: 20rpx;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}
.card-title {
  font-size: 30rpx;
  font-weight: 500;
  color: #3C2A1E;
  display: -webkit-box;
  -webkit-line-clamp: 1;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.card-desc {
  font-size: 24rpx;
  color: #8B7355;
  margin-top: 6rpx;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.card-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 10rpx;
}
.card-price {
  font-size: 30rpx;
  color: #DE6262;
  font-weight: bold;
}
.card-vendor {
  font-size: 22rpx;
  color: #8B7355;
}
.empty {
  text-align: center;
  padding: 100rpx 0;
  color: #8B7355;
  font-size: 28rpx;
}
</style>
  • [ ] Step 2: 验证文件保存成功

    rtk wc -l /mnt/e/workspace/dan/zxyj/cfc-frontend/pages/discover/index.vue
    

Expected: 输出行数(应该 > 100)

  • [ ] Step 3: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-frontend/pages/discover/index.vue
    git commit -m "feat(phase1): 发现页 - 从占位改为活动+课程列表"
    

T9: 商品详情页(公共组件,商城和发现共用)

Files:

  • Create: cfc-frontend/pages/discover/product-detail.vue
  • Modify: cfc-frontend/pages.json(注册新页面)

  • [ ] Step 1: 创建 pages/discover/product-detail.vue

    <template>
    <view class="detail-page">
    <view v-if="product" class="content">
      <view class="cover-section">
        <view v-if="product.coverImage" class="cover">
          <image :src="product.coverImage" mode="aspectFill" class="cover-img" />
        </view>
        <view v-else class="cover cover-placeholder">
          <text class="cover-emoji">{{ domainEmoji(product.domain) }}</text>
        </view>
      </view>
    
      <view class="info-section">
        <view class="name">{{ product.name }}</view>
        <view class="price-row">
          <text class="price">¥{{ product.price }}</text>
          <text v-if="product.memberPrice" class="member-price">会员价 ¥{{ product.memberPrice }}</text>
        </view>
        <view class="vendor">由 {{ product.vendorName || '平台官方' }} 提供</view>
        <view v-if="product.description" class="description">{{ product.description }}</view>
      </view>
    
      <view class="action-bar">
        <button class="btn-buy" @click="onBuy">立即购买</button>
      </view>
    </view>
    
    <view v-else class="loading">加载中...</view>
    </view>
    </template>
    
    <script>
    import { productDetail, productOrderCreate, productOrderPay } from '@/utils/api.js';
    
    export default {
    data() {
    return {
      product: null,
      productId: null
    };
    },
    onLoad(options) {
    this.productId = options.id;
    this.loadDetail();
    },
    methods: {
    async loadDetail() {
      try {
        const res = await productDetail({ id: this.productId });
        if (res.code === 200) {
          this.product = res.data;
        } else {
          uni.showToast({ title: res.message || '加载失败', icon: 'none' });
        }
      } catch (e) {
        console.error('loadDetail failed', e);
      }
    },
    async onBuy() {
      if (!this.product) return;
      uni.showLoading({ title: '创建订单中...' });
      try {
        const createRes = await productOrderCreate({
          productId: this.product.id,
          quantity: 1,
          paymentMethod: 'points'
        });
        if (createRes.code !== 200) {
          uni.hideLoading();
          uni.showToast({ title: createRes.message || '创建订单失败', icon: 'none' });
          return;
        }
        const orderNo = createRes.data.orderNo;
        // 直接支付(测试模式)
        const payRes = await productOrderPay({ orderNo: orderNo });
        uni.hideLoading();
        if (payRes.code === 200) {
          uni.showToast({ title: '购买成功', icon: 'success' });
          setTimeout(() => {
            uni.redirectTo({ url: '/pages/shop/order-detail?orderNo=' + orderNo });
          }, 1000);
        } else {
          uni.showToast({ title: payRes.message || '支付失败', icon: 'none' });
        }
      } catch (e) {
        uni.hideLoading();
        console.error('onBuy failed', e);
        uni.showToast({ title: '网络错误', icon: 'none' });
      }
    },
    domainEmoji(domain) {
      const map = { action: '🌿', mind: '🔥', body: '🌏', wisdom: '⚔️', wealth: '💧' };
      return map[domain] || '✨';
    }
    }
    };
    </script>
    
    <style scoped>
    .detail-page {
    min-height: 100vh;
    background: #F8F4ED;
    padding-bottom: 120rpx;
    }
    .cover-section {
    width: 100%;
    height: 500rpx;
    }
    .cover {
    width: 100%;
    height: 500rpx;
    }
    .cover-img {
    width: 100%;
    height: 100%;
    }
    .cover-placeholder {
    background: linear-gradient(135deg, #FFE5B4, #FFB88C);
    display: flex;
    align-items: center;
    justify-content: center;
    }
    .cover-emoji {
    font-size: 200rpx;
    }
    .info-section {
    background: #FFFFFF;
    padding: 30rpx;
    border-radius: 30rpx 30rpx 0 0;
    margin-top: -30rpx;
    position: relative;
    }
    .name {
    font-size: 36rpx;
    font-weight: bold;
    color: #3C2A1E;
    line-height: 1.4;
    }
    .price-row {
    display: flex;
    align-items: baseline;
    margin-top: 20rpx;
    }
    .price {
    font-size: 44rpx;
    color: #DE6262;
    font-weight: bold;
    }
    .member-price {
    font-size: 26rpx;
    color: #FF8C00;
    margin-left: 16rpx;
    background: #FFF3D6;
    padding: 4rpx 12rpx;
    border-radius: 12rpx;
    }
    .vendor {
    font-size: 24rpx;
    color: #8B7355;
    margin-top: 12rpx;
    }
    .description {
    margin-top: 24rpx;
    font-size: 28rpx;
    color: #3C2A1E;
    line-height: 1.6;
    white-space: pre-wrap;
    }
    .action-bar {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    background: #FFFFFF;
    padding: 20rpx 30rpx;
    box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.08);
    }
    .btn-buy {
    background: linear-gradient(135deg, #FFB88C, #DE6262);
    color: #FFFFFF;
    border: none;
    border-radius: 50rpx;
    font-size: 30rpx;
    font-weight: bold;
    height: 88rpx;
    line-height: 88rpx;
    }
    .loading {
    text-align: center;
    padding: 200rpx 0;
    color: #8B7355;
    font-size: 28rpx;
    }
    </style>
    
  • [ ] Step 2: 在 pages.json 中注册新页面

打开 cfc-frontend/pages.json,找到 pages 数组,在最后添加:

    {
      "path": "pages/discover/product-detail",
      "style": {
        "navigationBarTitleText": "商品详情"
      }
    },
  • [ ] Step 3: 验证文件存在

    rtk test -f /mnt/e/workspace/dan/zxyj/cfc-frontend/pages/discover/product-detail.vue
    rtk grep "product-detail" /mnt/e/workspace/dan/zxyj/cfc-frontend/pages.json
    

Expected: 第二个命令输出包含 "pages/discover/product-detail"

  • [ ] Step 4: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-frontend/pages/discover/product-detail.vue cfc-frontend/pages.json
    git commit -m "feat(phase1): 商品详情页(商城/发现共用)"
    

T10: 订单列表 + 订单详情页

Task 10.1: 订单列表页

Files:

  • Create: cfc-frontend/pages/shop/order-list.vue
  • Modify: cfc-frontend/pages.json

  • [ ] Step 1: 创建 pages/shop/order-list.vue

    <template>
    <view class="order-list-page">
    <view class="tabs">
      <text v-for="t in statusOptions" :key="t.value"
            :class="['tab', currentStatus === t.value ? 'active' : '']"
            @click="onTabChange(t.value)">{{ t.label }}</text>
    </view>
    
    <view v-if="orders.length === 0 && !loading" class="empty">暂无订单</view>
    
    <view class="list">
      <view v-for="o in orders" :key="o.id" class="order-card" @click="goDetail(o.orderNo)">
        <view class="order-header">
          <text class="order-no">订单号:{{ o.orderNo }}</text>
          <text :class="['status', 'status-' + o.status]">{{ statusLabel(o.status) }}</text>
        </view>
        <view class="order-body">
          <view class="product-name">{{ o.productName }}</view>
          <view class="order-meta">
            <text class="quantity">x{{ o.quantity }}</text>
            <text class="amount">¥{{ o.totalAmount }}</text>
          </view>
        </view>
        <view class="order-time">{{ formatTime(o.createdAt) }}</view>
      </view>
    </view>
    </view>
    </template>
    
    <script>
    import { productOrderMy } from '@/utils/api.js';
    
    export default {
    data() {
    return {
      orders: [],
      loading: false,
      currentStatus: '',
      statusOptions: [
        { label: '全部', value: '' },
        { label: '待支付', value: 'pending' },
        { label: '已支付', value: 'paid' },
        { label: '已完成', value: 'completed' },
        { label: '已取消', value: 'cancelled' }
      ]
    };
    },
    onShow() {
    this.loadOrders();
    },
    methods: {
    async loadOrders() {
      this.loading = true;
      try {
        const res = await productOrderMy({ status: this.currentStatus });
        if (res.code === 200) {
          this.orders = res.data || [];
        }
      } catch (e) {
        console.error('loadOrders failed', e);
      }
      this.loading = false;
    },
    onTabChange(value) {
      this.currentStatus = value;
      this.loadOrders();
    },
    goDetail(orderNo) {
      uni.navigateTo({ url: '/pages/shop/order-detail?orderNo=' + orderNo });
    },
    statusLabel(s) {
      const map = { pending: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消', refunded: '已退款' };
      return map[s] || s;
    },
    formatTime(t) {
      if (!t) return '';
      const d = new Date(t);
      return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate() + ' ' + d.getHours() + ':' + d.getMinutes();
    }
    }
    };
    </script>
    
    <style scoped>
    .order-list-page {
    min-height: 100vh;
    background: #F8F4ED;
    padding: 20rpx 30rpx;
    }
    .tabs {
    display: flex;
    background: #FFFFFF;
    border-radius: 20rpx;
    padding: 10rpx;
    margin-bottom: 20rpx;
    box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
    }
    .tab {
    flex: 1;
    text-align: center;
    padding: 16rpx 0;
    font-size: 26rpx;
    color: #6B4423;
    border-radius: 16rpx;
    }
    .tab.active {
    background: linear-gradient(135deg, #FFB88C, #DE6262);
    color: #FFFFFF;
    }
    .empty {
    text-align: center;
    padding: 100rpx 0;
    color: #8B7355;
    font-size: 28rpx;
    }
    .list {
    display: flex;
    flex-direction: column;
    gap: 20rpx;
    }
    .order-card {
    background: #FFFFFF;
    border-radius: 20rpx;
    padding: 24rpx;
    box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
    }
    .order-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding-bottom: 16rpx;
    border-bottom: 1rpx solid #F0E5D8;
    }
    .order-no {
    font-size: 22rpx;
    color: #8B7355;
    }
    .status {
    font-size: 24rpx;
    font-weight: bold;
    }
    .status-pending { color: #FF8C00; }
    .status-paid { color: #4CAF50; }
    .status-completed { color: #2196F3; }
    .status-cancelled { color: #999; }
    .status-refunded { color: #F44336; }
    .order-body {
    padding: 20rpx 0;
    }
    .product-name {
    font-size: 28rpx;
    color: #3C2A1E;
    font-weight: 500;
    }
    .order-meta {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 12rpx;
    }
    .quantity {
    font-size: 24rpx;
    color: #8B7355;
    }
    .amount {
    font-size: 32rpx;
    color: #DE6262;
    font-weight: bold;
    }
    .order-time {
    font-size: 22rpx;
    color: #8B7355;
    text-align: right;
    }
    </style>
    
  • [ ] Step 2: 在 pages.json 中注册新页面

打开 cfc-frontend/pages.json,在 pages 数组最后添加:

    {
      "path": "pages/shop/order-list",
      "style": {
        "navigationBarTitleText": "我的订单"
      }
    },
  • [ ] Step 3: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-frontend/pages/shop/order-list.vue cfc-frontend/pages.json
    git commit -m "feat(phase1): 订单列表页"
    

Task 10.2: 订单详情页

Files:

  • Create: cfc-frontend/pages/shop/order-detail.vue
  • Modify: cfc-frontend/pages.json

  • [ ] Step 1: 创建 pages/shop/order-detail.vue

    <template>
    <view class="order-detail-page">
    <view v-if="order">
      <view class="status-section" :class="'status-' + order.status">
        <text class="status-text">{{ statusLabel(order.status) }}</text>
      </view>
    
      <view class="section">
        <view class="row">
          <text class="label">商品</text>
          <text class="value">{{ order.productName }}</text>
        </view>
        <view class="row">
          <text class="label">数量</text>
          <text class="value">x{{ order.quantity }}</text>
        </view>
        <view class="row">
          <text class="label">单价</text>
          <text class="value">¥{{ order.unitPrice }}</text>
        </view>
        <view class="row">
          <text class="label">订单号</text>
          <text class="value">{{ order.orderNo }}</text>
        </view>
        <view v-if="order.paidAt" class="row">
          <text class="label">支付时间</text>
          <text class="value">{{ formatTime(order.paidAt) }}</text>
        </view>
        <view class="row">
          <text class="label">创建时间</text>
          <text class="value">{{ formatTime(order.createdAt) }}</text>
        </view>
      </view>
    
      <view class="amount-section">
        <text class="amount-label">实付金额</text>
        <text class="amount-value">¥{{ order.totalAmount }}</text>
      </view>
    
      <view v-if="order.status === 'pending'" class="action-bar">
        <button class="btn-cancel" @click="onCancel">取消订单</button>
        <button class="btn-pay" @click="onPay">立即支付</button>
      </view>
    </view>
    
    <view v-else class="loading">加载中...</view>
    </view>
    </template>
    
    <script>
    import { productOrderDetail, productOrderPay, productOrderCancel } from '@/utils/api.js';
    
    export default {
    data() {
    return {
      order: null,
      orderNo: null
    };
    },
    onLoad(options) {
    this.orderNo = options.orderNo;
    this.loadDetail();
    },
    methods: {
    async loadDetail() {
      try {
        const res = await productOrderDetail({ orderNo: this.orderNo });
        if (res.code === 200) {
          this.order = res.data;
        } else {
          uni.showToast({ title: res.message || '加载失败', icon: 'none' });
        }
      } catch (e) {
        console.error('loadDetail failed', e);
      }
    },
    async onPay() {
      uni.showLoading({ title: '支付中...' });
      try {
        const res = await productOrderPay({ orderNo: this.orderNo });
        uni.hideLoading();
        if (res.code === 200) {
          uni.showToast({ title: '支付成功', icon: 'success' });
          this.loadDetail();
        } else {
          uni.showToast({ title: res.message || '支付失败', icon: 'none' });
        }
      } catch (e) {
        uni.hideLoading();
        console.error('onPay failed', e);
      }
    },
    async onCancel() {
      uni.showModal({
        title: '确认取消',
        content: '确定要取消此订单吗?',
        success: async (modalRes) => {
          if (modalRes.confirm) {
            try {
              const res = await productOrderCancel({ orderNo: this.orderNo });
              if (res.code === 200) {
                uni.showToast({ title: '已取消', icon: 'success' });
                this.loadDetail();
              } else {
                uni.showToast({ title: res.message || '取消失败', icon: 'none' });
              }
            } catch (e) {
              console.error('onCancel failed', e);
            }
          }
        }
      });
    },
    statusLabel(s) {
      const map = { pending: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消', refunded: '已退款' };
      return map[s] || s;
    },
    formatTime(t) {
      if (!t) return '';
      const d = new Date(t);
      return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate() + ' ' + d.getHours() + ':' + d.getMinutes();
    }
    }
    };
    </script>
    
    <style scoped>
    .order-detail-page {
    min-height: 100vh;
    background: #F8F4ED;
    padding-bottom: 140rpx;
    }
    .status-section {
    padding: 40rpx 30rpx;
    text-align: center;
    color: #FFFFFF;
    }
    .status-pending { background: linear-gradient(135deg, #FFB88C, #FF8C00); }
    .status-paid { background: linear-gradient(135deg, #81C784, #4CAF50); }
    .status-completed { background: linear-gradient(135deg, #64B5F6, #2196F3); }
    .status-cancelled { background: linear-gradient(135deg, #BDBDBD, #9E9E9E); }
    .status-refunded { background: linear-gradient(135deg, #E57373, #F44336); }
    .status-text {
    font-size: 36rpx;
    font-weight: bold;
    }
    .section {
    background: #FFFFFF;
    margin: 20rpx 30rpx;
    border-radius: 20rpx;
    padding: 30rpx;
    box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
    }
    .row {
    display: flex;
    justify-content: space-between;
    padding: 16rpx 0;
    font-size: 28rpx;
    border-bottom: 1rpx solid #F5F0E8;
    }
    .row:last-child {
    border-bottom: none;
    }
    .label {
    color: #8B7355;
    }
    .value {
    color: #3C2A1E;
    }
    .amount-section {
    background: #FFFFFF;
    margin: 20rpx 30rpx;
    border-radius: 20rpx;
    padding: 30rpx;
    display: flex;
    justify-content: space-between;
    align-items: center;
    box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
    }
    .amount-label {
    font-size: 28rpx;
    color: #6B4423;
    }
    .amount-value {
    font-size: 40rpx;
    color: #DE6262;
    font-weight: bold;
    }
    .action-bar {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    display: flex;
    background: #FFFFFF;
    padding: 20rpx 30rpx;
    gap: 20rpx;
    box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.08);
    }
    .btn-cancel {
    flex: 1;
    background: #FFFFFF;
    color: #6B4423;
    border: 2rpx solid #6B4423;
    border-radius: 50rpx;
    font-size: 30rpx;
    height: 88rpx;
    line-height: 84rpx;
    }
    .btn-pay {
    flex: 2;
    background: linear-gradient(135deg, #FFB88C, #DE6262);
    color: #FFFFFF;
    border: none;
    border-radius: 50rpx;
    font-size: 30rpx;
    font-weight: bold;
    height: 88rpx;
    line-height: 88rpx;
    }
    .loading {
    text-align: center;
    padding: 200rpx 0;
    color: #8B7355;
    font-size: 28rpx;
    }
    </style>
    
  • [ ] Step 2: 在 pages.json 中注册新页面

打开 cfc-frontend/pages.json,在 pages 数组最后添加:

    {
      "path": "pages/shop/order-detail",
      "style": {
        "navigationBarTitleText": "订单详情"
      }
    },
  • [ ] Step 3: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-frontend/pages/shop/order-detail.vue cfc-frontend/pages.json
    git commit -m "feat(phase1): 订单详情页"
    

T11: 服务商中心扩展

Task 11.1: 服务商商品管理页

Files:

  • Create: cfc-frontend/pages/vendor/products.vue
  • Modify: cfc-frontend/pages.json

  • [ ] Step 1: 创建 pages/vendor/products.vue

    <template>
    <view class="vendor-products-page">
    <view class="header">
      <view class="title">我的商品</view>
      <button class="btn-add" @click="onCreate">+ 发布商品</button>
    </view>
    
    <view v-if="!isVendor" class="not-vendor">
      <text>请先入驻成为服务商</text>
      <button @click="goApply">立即入驻</button>
    </view>
    
    <view v-else>
      <view v-if="products.length === 0" class="empty">尚未发布商品</view>
      <view v-else class="list">
        <view v-for="p in products" :key="p.id" class="card">
          <view class="card-header">
            <text class="name">{{ p.name }}</text>
            <text :class="['status-tag', 'status-' + p.status]">{{ statusLabel(p.status) }}</text>
          </view>
          <view class="card-info">
            <text class="price">¥{{ p.price }}</text>
            <text class="stock">库存:{{ p.stock }}</text>
            <text class="sales">已售:{{ p.salesCount || 0 }}</text>
          </view>
          <view v-if="p.rejectReason" class="reject-reason">拒绝原因:{{ p.rejectReason }}</view>
          <view class="actions">
            <button v-if="p.status === 'approved' && p.status !== 'on_shelf'" class="btn-act" @click="onShelve(p, 'shelve')">上架</button>
            <button v-if="p.status === 'on_shelf'" class="btn-act" @click="onShelve(p, 'unshelve')">下架</button>
            <button class="btn-act" @click="onEdit(p)">编辑</button>
          </view>
        </view>
      </view>
    </view>
    </view>
    </template>
    
    <script>
    import { productMy, productShelve, vendorStatus } from '@/utils/api.js';
    
    export default {
    data() {
    return {
      products: [],
      isVendor: false
    };
    },
    onShow() {
    this.checkVendor();
    },
    methods: {
    async checkVendor() {
      try {
        const res = await vendorStatus();
        if (res.code === 200 && res.data && res.data.vendorStatus === 'approved') {
          this.isVendor = true;
          this.loadProducts();
        } else {
          this.isVendor = false;
        }
      } catch (e) {
        console.error('checkVendor failed', e);
      }
    },
    async loadProducts() {
      try {
        const res = await productMy({});
        if (res.code === 200) {
          this.products = res.data || [];
        }
      } catch (e) {
        console.error('loadProducts failed', e);
      }
    },
    onCreate() {
      uni.showModal({
        title: '发布商品',
        content: '商品发布功能:\n1. 名称、价格、库存、类型\n2. 提交后需管理员审核\n\n请通过管理端/Web 端发布,或在后续 Phase 完善。',
        showCancel: false
      });
    },
    onEdit(p) {
      uni.showToast({ title: '编辑功能待实现', icon: 'none' });
    },
    async onShelve(p, action) {
      try {
        const res = await productShelve({ productId: p.id, action: action });
        if (res.code === 200) {
          uni.showToast({ title: action === 'shelve' ? '已上架' : '已下架', icon: 'success' });
          this.loadProducts();
        } else {
          uni.showToast({ title: res.message || '操作失败', icon: 'none' });
        }
      } catch (e) {
        console.error('onShelve failed', e);
      }
    },
    goApply() {
      uni.navigateTo({ url: '/pages/vendor/apply' });
    },
    statusLabel(s) {
      const map = {
        draft: '草稿', pending: '待审核', approved: '已通过', rejected: '已拒绝',
        on_shelf: '已上架', off_shelf: '已下架'
      };
      return map[s] || s;
    }
    }
    };
    </script>
    
    <style scoped>
    .vendor-products-page {
    min-height: 100vh;
    background: #F8F4ED;
    padding: 20rpx 30rpx;
    }
    .header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 30rpx;
    }
    .title {
    font-size: 36rpx;
    font-weight: bold;
    color: #6B4423;
    }
    .btn-add {
    background: linear-gradient(135deg, #FFB88C, #DE6262);
    color: #FFFFFF;
    border: none;
    border-radius: 30rpx;
    font-size: 26rpx;
    padding: 12rpx 24rpx;
    height: auto;
    line-height: 1.4;
    }
    .not-vendor {
    text-align: center;
    padding: 100rpx 0;
    color: #8B7355;
    }
    .not-vendor button {
    margin-top: 30rpx;
    background: linear-gradient(135deg, #FFB88C, #DE6262);
    color: #FFFFFF;
    border: none;
    border-radius: 50rpx;
    width: 60%;
    }
    .empty {
    text-align: center;
    padding: 100rpx 0;
    color: #8B7355;
    font-size: 28rpx;
    }
    .list {
    display: flex;
    flex-direction: column;
    gap: 20rpx;
    }
    .card {
    background: #FFFFFF;
    border-radius: 20rpx;
    padding: 24rpx;
    box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
    }
    .card-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    }
    .name {
    font-size: 30rpx;
    color: #3C2A1E;
    font-weight: 500;
    }
    .status-tag {
    font-size: 22rpx;
    padding: 4rpx 12rpx;
    border-radius: 12rpx;
    }
    .status-pending { background: #FFF3D6; color: #FF8C00; }
    .status-approved { background: #D6F5D6; color: #4CAF50; }
    .status-rejected { background: #FFD6D6; color: #F44336; }
    .status-on_shelf { background: #D6E5F5; color: #2196F3; }
    .status-off_shelf { background: #E0E0E0; color: #757575; }
    .card-info {
    display: flex;
    gap: 20rpx;
    margin-top: 12rpx;
    font-size: 24rpx;
    color: #8B7355;
    }
    .price {
    color: #DE6262;
    font-weight: bold;
    font-size: 28rpx;
    }
    .reject-reason {
    margin-top: 12rpx;
    font-size: 22rpx;
    color: #F44336;
    background: #FFF0F0;
    padding: 8rpx 12rpx;
    border-radius: 8rpx;
    }
    .actions {
    display: flex;
    gap: 16rpx;
    margin-top: 20rpx;
    border-top: 1rpx solid #F0E5D8;
    padding-top: 16rpx;
    }
    .btn-act {
    flex: 1;
    background: #F8F4ED;
    color: #6B4423;
    border: 1rpx solid #E0D5C0;
    border-radius: 20rpx;
    font-size: 24rpx;
    height: 60rpx;
    line-height: 60rpx;
    }
    </style>
    
  • [ ] Step 2: 在 pages.json 中注册新页面

打开 cfc-frontend/pages.json,在 pages 数组最后添加:

    {
      "path": "pages/vendor/products",
      "style": {
        "navigationBarTitleText": "商品管理"
      }
    },
  • [ ] Step 3: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-frontend/pages/vendor/products.vue cfc-frontend/pages.json
    git commit -m "feat(phase1): 服务商商品管理页"
    

Task 11.2: 服务商订单管理页

Files:

  • Create: cfc-frontend/pages/vendor/orders.vue
  • Modify: cfc-frontend/pages.json

  • [ ] Step 1: 创建 pages/vendor/orders.vue

    <template>
    <view class="vendor-orders-page">
    <view class="header">
      <view class="title">收到的订单</view>
    </view>
    
    <view v-if="orders.length === 0 && !loading" class="empty">暂无订单</view>
    
    <view v-else class="list">
      <view v-for="o in orders" :key="o.id" class="card">
        <view class="card-header">
          <text class="order-no">{{ o.orderNo }}</text>
          <text :class="['status', 'status-' + o.status]">{{ statusLabel(o.status) }}</text>
        </view>
        <view class="card-body">
          <view class="product-name">{{ o.productName }}</view>
          <view class="meta">
            <text class="quantity">x{{ o.quantity }}</text>
            <text class="amount">¥{{ o.totalAmount }}</text>
          </view>
        </view>
        <view class="card-actions">
          <button v-if="o.status === 'paid'" class="btn-confirm" @click="onConfirm(o)">确认完成</button>
        </view>
      </view>
    </view>
    </view>
    </template>
    
    <script>
    import { productOrderVendor, productOrderConfirm } from '@/utils/api.js';
    
    export default {
    data() {
    return {
      orders: [],
      loading: false
    };
    },
    onShow() {
    this.loadOrders();
    },
    methods: {
    async loadOrders() {
      this.loading = true;
      try {
        const res = await productOrderVendor({});
        if (res.code === 200) {
          this.orders = res.data || [];
        }
      } catch (e) {
        console.error('loadOrders failed', e);
      }
      this.loading = false;
    },
    async onConfirm(o) {
      try {
        const res = await productOrderConfirm({ orderNo: o.orderNo });
        if (res.code === 200) {
          uni.showToast({ title: '已确认', icon: 'success' });
          this.loadOrders();
        } else {
          uni.showToast({ title: res.message || '操作失败', icon: 'none' });
        }
      } catch (e) {
        console.error('onConfirm failed', e);
      }
    },
    statusLabel(s) {
      const map = { pending: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消', refunded: '已退款' };
      return map[s] || s;
    }
    }
    };
    </script>
    
    <style scoped>
    .vendor-orders-page {
    min-height: 100vh;
    background: #F8F4ED;
    padding: 20rpx 30rpx;
    }
    .header {
    margin-bottom: 30rpx;
    }
    .title {
    font-size: 36rpx;
    font-weight: bold;
    color: #6B4423;
    }
    .empty {
    text-align: center;
    padding: 100rpx 0;
    color: #8B7355;
    font-size: 28rpx;
    }
    .list {
    display: flex;
    flex-direction: column;
    gap: 20rpx;
    }
    .card {
    background: #FFFFFF;
    border-radius: 20rpx;
    padding: 24rpx;
    box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.06);
    }
    .card-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding-bottom: 12rpx;
    border-bottom: 1rpx solid #F0E5D8;
    }
    .order-no {
    font-size: 22rpx;
    color: #8B7355;
    }
    .status {
    font-size: 24rpx;
    font-weight: bold;
    }
    .status-pending { color: #FF8C00; }
    .status-paid { color: #4CAF50; }
    .status-completed { color: #2196F3; }
    .status-cancelled { color: #999; }
    .card-body {
    padding: 16rpx 0;
    }
    .product-name {
    font-size: 28rpx;
    color: #3C2A1E;
    font-weight: 500;
    }
    .meta {
    display: flex;
    justify-content: space-between;
    margin-top: 8rpx;
    }
    .quantity {
    font-size: 24rpx;
    color: #8B7355;
    }
    .amount {
    font-size: 30rpx;
    color: #DE6262;
    font-weight: bold;
    }
    .card-actions {
    border-top: 1rpx solid #F0E5D8;
    padding-top: 16rpx;
    }
    .btn-confirm {
    width: 100%;
    background: linear-gradient(135deg, #FFB88C, #DE6262);
    color: #FFFFFF;
    border: none;
    border-radius: 30rpx;
    font-size: 26rpx;
    height: 70rpx;
    line-height: 70rpx;
    }
    </style>
    
  • [ ] Step 2: 在 pages.json 中注册新页面

打开 cfc-frontend/pages.json,在 pages 数组最后添加:

    {
      "path": "pages/vendor/orders",
      "style": {
        "navigationBarTitleText": "订单管理"
      }
    },
  • [ ] Step 3: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-frontend/pages/vendor/orders.vue cfc-frontend/pages.json
    git commit -m "feat(phase1): 服务商订单管理页"
    

Task 11.3: 服务商中心菜单扩展

Files:

  • Modify: cfc-frontend/pages/vendor/center.vue

  • [ ] Step 1: 找到 vendor/center.vue 中"菜单项"区域

打开 cfc-frontend/pages/vendor/center.vue,找到 menu-items 列表(如 <view class="menu">...</view> 或类似),在每个菜单项 <view class="menu-item"> 之后添加新菜单项。

如果菜单项使用 methods.goXxx,在 methods 中添加:

goProductManage() {
  uni.navigateTo({ url: '/pages/vendor/products' });
},
goOrderManage() {
  uni.navigateTo({ url: '/pages/vendor/orders' });
},

具体位置请根据现有 center.vue 的代码结构进行调整。参考现有代码(已在 Phase 0 实现)。

  • [ ] Step 2: 验证文件被修改

    rtk grep -c "goProductManage\|goOrderManage" /mnt/e/workspace/dan/zxyj/cfc-frontend/pages/vendor/center.vue
    

Expected: 输出 >= 1(说明有新增的菜单项处理函数)

  • [ ] Step 3: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-frontend/pages/vendor/center.vue
    git commit -m "feat(phase1): 服务商中心添加商品管理/订单管理入口"
    

T12: Web 端 — 商品管理

Task 12.1: Web API 封装

Files:

  • Create: cfc-web/src/api/product.js

  • [ ] Step 1: 创建 src/api/product.js

    import request from '@/utils/request';
    
    // 管理员:商品待审核列表
    export const adminProductPending = (params) => request({ url: '/api/admin/product/pending', method: 'post', data: params });
    
    // 管理员:商品列表
    export const adminProductList = (params) => request({ url: '/api/admin/product/list', method: 'post', data: params });
    
    // 管理员:商品审核
    export const adminProductReview = (params) => request({ url: '/api/admin/product/review', method: 'post', data: params });
    
    // 商品列表(公开)
    export const productList = (params) => request({ url: '/api/product/list', method: 'post', data: params });
    
  • [ ] Step 2: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-web/src/api/product.js
    git commit -m "feat(phase1): Web 端 product API 封装"
    

Task 12.2: Web 商品管理页面

Files:

  • Create: cfc-web/src/views/admin/ProductManage.vue

  • [ ] Step 1: 创建 src/views/admin/ProductManage.vue

    <template>
    <div class="product-manage">
    <el-card>
      <div slot="header" class="clearfix">
        <span>商品管理</span>
        <el-select v-model="statusFilter" placeholder="按状态筛选" clearable style="width: 200px; margin-left: 20px;" @change="loadList">
          <el-option label="待审核" value="pending" />
          <el-option label="已通过" value="approved" />
          <el-option label="已上架" value="on_shelf" />
          <el-option label="已下架" value="off_shelf" />
          <el-option label="已拒绝" value="rejected" />
        </el-select>
      </div>
    
      <el-table :data="products" border style="width: 100%">
        <el-table-column prop="id" label="ID" width="80" />
        <el-table-column prop="name" label="商品名称" min-width="200" />
        <el-table-column prop="productType" label="类型" width="100">
          <template slot-scope="scope">
            <el-tag>{{ typeLabel(scope.row.productType) }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="price" label="价格" width="100">
          <template slot-scope="scope">¥{{ scope.row.price }}</template>
        </el-table-column>
        <el-table-column prop="memberPrice" label="会员价" width="100">
          <template slot-scope="scope">
            <span v-if="scope.row.memberPrice">¥{{ scope.row.memberPrice }}</span>
            <span v-else>-</span>
          </template>
        </el-table-column>
        <el-table-column prop="stock" label="库存" width="80" />
        <el-table-column prop="salesCount" label="销量" width="80" />
        <el-table-column prop="vendorName" label="服务商" width="150" />
        <el-table-column prop="status" label="状态" width="120">
          <template slot-scope="scope">
            <el-tag :type="statusTagType(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column label="操作" width="200">
          <template slot-scope="scope">
            <el-button v-if="scope.row.status === 'pending'" size="mini" type="success" @click="onReview(scope.row, 'approve')">通过</el-button>
            <el-button v-if="scope.row.status === 'pending'" size="mini" type="danger" @click="onReview(scope.row, 'reject')">拒绝</el-button>
            <el-button v-if="scope.row.status === 'rejected' && scope.row.rejectReason" size="mini" @click="showRejectReason(scope.row.rejectReason)">查看原因</el-button>
          </template>
        </el-table-column>
      </el-table>
    </el-card>
    
    <el-dialog title="拒绝原因" :visible.sync="rejectDialogVisible" width="500px">
      <el-input v-model="rejectReason" type="textarea" :rows="4" placeholder="请输入拒绝原因" />
      <div slot="footer">
        <el-button @click="rejectDialogVisible = false">取消</el-button>
        <el-button type="primary" @click="confirmReject">确认拒绝</el-button>
      </div>
    </el-dialog>
    </div>
    </template>
    
    <script>
    import { adminProductList, adminProductReview } from '@/api/product';
    
    export default {
    data() {
    return {
      products: [],
      statusFilter: '',
      rejectDialogVisible: false,
      rejectReason: '',
      currentRejectProduct: null
    };
    },
    mounted() {
    this.loadList();
    },
    methods: {
    async loadList() {
      try {
        const res = await adminProductList({ status: this.statusFilter });
        if (res.data && res.data.code === 200) {
          this.products = res.data.data || [];
        }
      } catch (e) {
        this.$message.error('加载失败');
      }
    },
    onReview(product, action) {
      if (action === 'approve') {
        this.$confirm('确认通过此商品审核?', '提示', {
          confirmButtonText: '确认',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(async () => {
          await this.submitReview(product, 'approve', null);
        }).catch(() => {});
      } else {
        this.currentRejectProduct = product;
        this.rejectReason = '';
        this.rejectDialogVisible = true;
      }
    },
    async confirmReject() {
      if (!this.rejectReason.trim()) {
        this.$message.warning('请输入拒绝原因');
        return;
      }
      await this.submitReview(this.currentRejectProduct, 'reject', this.rejectReason);
      this.rejectDialogVisible = false;
    },
    async submitReview(product, action, rejectReason) {
      try {
        const res = await adminProductReview({
          productId: product.id,
          action: action,
          rejectReason: rejectReason
        });
        if (res.data && res.data.code === 200) {
          this.$message.success('操作成功');
          this.loadList();
        } else {
          this.$message.error(res.data.message || '操作失败');
        }
      } catch (e) {
        this.$message.error('操作失败');
      }
    },
    showRejectReason(reason) {
      this.$alert(reason, '拒绝原因', { confirmButtonText: '关闭' });
    },
    typeLabel(type) {
      const map = { activity: '活动', course: '课程', physical: '实物', digital: '数字', service: '服务' };
      return map[type] || type;
    },
    statusLabel(s) {
      const map = { draft: '草稿', pending: '待审核', approved: '已通过', rejected: '已拒绝', on_shelf: '已上架', off_shelf: '已下架' };
      return map[s] || s;
    },
    statusTagType(s) {
      const map = { pending: 'warning', approved: 'success', rejected: 'danger', on_shelf: 'success', off_shelf: 'info' };
      return map[s] || '';
    }
    }
    };
    </script>
    
    <style scoped>
    .product-manage {
    padding: 20px;
    }
    </style>
    
  • [ ] Step 2: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-web/src/views/admin/ProductManage.vue
    git commit -m "feat(phase1): Web 端商品管理页面"
    

Task 12.3: Web 路由 + 侧边栏菜单

Files:

  • Modify: cfc-web/src/router/index.js
  • Modify: cfc-web/src/views/Layout.vue

  • [ ] Step 1: 在 router/index.js 添加商品管理路由

打开 cfc-web/src/router/index.js,找到路由数组(如 adminRoutes 或类似),添加:

{
  path: '/admin/product',
  component: () => import('@/views/admin/ProductManage.vue'),
  meta: { title: '商品管理', icon: 'goods' }
}
  • Step 2: 在 Layout.vue 侧边栏添加菜单项

打开 cfc-web/src/views/Layout.vue,找到菜单列表,在"系统"或"运营"子菜单下添加:

<el-menu-item index="/admin/product">
  <i class="el-icon-goods"></i>
  <span slot="title">商品管理</span>
</el-menu-item>

具体位置请根据现有 Layout.vue 的菜单结构调整。

  • [ ] Step 3: 验证文件修改

    rtk grep "ProductManage\|商品管理" /mnt/e/workspace/dan/zxyj/cfc-web/src/router/index.js
    rtk grep "商品管理" /mnt/e/workspace/dan/zxyj/cfc-web/src/views/Layout.vue
    

Expected: 两个 grep 都有输出

  • [ ] Step 4: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-web/src/router/index.js cfc-web/src/views/Layout.vue
    git commit -m "feat(phase1): Web 端商品管理路由 + 菜单"
    

T13: Web 端 — 订单管理

Files:

  • Create: cfc-web/src/views/admin/OrderManage.vue
  • Modify: cfc-web/src/router/index.js
  • Modify: cfc-web/src/views/Layout.vue

  • [ ] Step 1: 创建 src/views/admin/OrderManage.vue

    <template>
    <div class="order-manage">
    <el-card>
      <div slot="header" class="clearfix">
        <span>订单管理</span>
        <el-select v-model="statusFilter" placeholder="按状态筛选" clearable style="width: 200px; margin-left: 20px;" @change="loadList">
          <el-option label="待支付" value="pending" />
          <el-option label="已支付" value="paid" />
          <el-option label="已完成" value="completed" />
          <el-option label="已取消" value="cancelled" />
        </el-select>
      </div>
    
      <el-table :data="orders" border style="width: 100%">
        <el-table-column prop="orderNo" label="订单号" min-width="200" />
        <el-table-column prop="productName" label="商品" min-width="200" />
        <el-table-column prop="buyerId" label="买家ID" width="100" />
        <el-table-column prop="quantity" label="数量" width="80" />
        <el-table-column prop="totalAmount" label="金额" width="100">
          <template slot-scope="scope">¥{{ scope.row.totalAmount }}</template>
        </el-table-column>
        <el-table-column prop="status" label="状态" width="120">
          <template slot-scope="scope">
            <el-tag :type="statusTagType(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="paymentMethod" label="支付方式" width="120" />
        <el-table-column prop="createdAt" label="创建时间" width="180" />
      </el-table>
    </el-card>
    </div>
    </template>
    
    <script>
    import { adminOrderList } from '@/api/product';
    
    export default {
    data() {
    return {
      orders: [],
      statusFilter: ''
    };
    },
    mounted() {
    this.loadList();
    },
    methods: {
    async loadList() {
      try {
        const res = await adminOrderList({ status: this.statusFilter });
        if (res.data && res.data.code === 200) {
          this.orders = res.data.data || [];
        }
      } catch (e) {
        this.$message.error('加载失败');
      }
    },
    statusLabel(s) {
      const map = { pending: '待支付', paid: '已支付', completed: '已完成', cancelled: '已取消', refunded: '已退款' };
      return map[s] || s;
    },
    statusTagType(s) {
      const map = { pending: 'warning', paid: 'primary', completed: 'success', cancelled: 'info', refunded: 'danger' };
      return map[s] || '';
    }
    }
    };
    </script>
    
    <style scoped>
    .order-manage {
    padding: 20px;
    }
    </style>
    
  • [ ] Step 2: 添加路由

cfc-web/src/router/index.js 中添加:

{
  path: '/admin/order',
  component: () => import('@/views/admin/OrderManage.vue'),
  meta: { title: '订单管理', icon: 'list' }
}
  • Step 3: 添加菜单项

cfc-web/src/views/Layout.vue 菜单中"商品管理"附近添加:

<el-menu-item index="/admin/order">
  <i class="el-icon-list"></i>
  <span slot="title">订单管理</span>
</el-menu-item>
  • [ ] Step 4: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-web/src/views/admin/OrderManage.vue cfc-web/src/router/index.js cfc-web/src/views/Layout.vue
    git commit -m "feat(phase1): Web 端订单管理页面+路由+菜单"
    

T14: Web 端 — 系统配置

Files:

  • Create: cfc-web/src/views/admin/SysConfig.vue
  • Modify: cfc-web/src/router/index.js
  • Modify: cfc-web/src/views/Layout.vue

  • [ ] Step 1: 创建 src/views/admin/SysConfig.vue

    <template>
    <div class="sys-config">
    <el-card>
      <div slot="header">
        <span>系统配置</span>
      </div>
    
      <el-table :data="configs" border style="width: 100%">
        <el-table-column prop="configKey" label="配置键" min-width="200" />
        <el-table-column prop="configValue" label="配置值" min-width="300">
          <template slot-scope="scope">
            <el-input v-model="scope.row.configValue" :disabled="!isEditing[scope.row.id]" size="small" />
          </template>
        </el-table-column>
        <el-table-column prop="description" label="说明" min-width="300" />
        <el-table-column prop="updatedAt" label="更新时间" width="180" />
        <el-table-column label="操作" width="200">
          <template slot-scope="scope">
            <el-button v-if="!isEditing[scope.row.id]" size="mini" @click="startEdit(scope.row)">编辑</el-button>
            <template v-else>
              <el-button size="mini" type="primary" @click="saveConfig(scope.row)">保存</el-button>
              <el-button size="mini" @click="cancelEdit(scope.row)">取消</el-button>
            </template>
          </template>
        </el-table-column>
      </el-table>
    </el-card>
    </div>
    </template>
    
    <script>
    import { adminConfigList, adminConfigUpdate } from '@/api/product';
    
    export default {
    data() {
    return {
      configs: [],
      isEditing: {}
    };
    },
    mounted() {
    this.loadConfigs();
    },
    methods: {
    async loadConfigs() {
      try {
        const res = await adminConfigList({});
        if (res.data && res.data.code === 200) {
          this.configs = res.data.data || [];
        }
      } catch (e) {
        this.$message.error('加载失败');
      }
    },
    startEdit(row) {
      this.$set(this.isEditing, row.id, true);
    },
    cancelEdit(row) {
      this.$set(this.isEditing, row.id, false);
      this.loadConfigs();
    },
    async saveConfig(row) {
      try {
        const res = await adminConfigUpdate({
          configKey: row.configKey,
          configValue: row.configValue
        });
        if (res.data && res.data.code === 200) {
          this.$message.success('保存成功');
          this.$set(this.isEditing, row.id, false);
          this.loadConfigs();
        } else {
          this.$message.error(res.data.message || '保存失败');
        }
      } catch (e) {
        this.$message.error('保存失败');
      }
    }
    }
    };
    </script>
    
    <style scoped>
    .sys-config {
    padding: 20px;
    }
    </style>
    
  • [ ] Step 2: 添加路由

cfc-web/src/router/index.js 中添加:

{
  path: '/admin/config',
  component: () => import('@/views/admin/SysConfig.vue'),
  meta: { title: '系统配置', icon: 'setting' }
}
  • Step 3: 添加菜单项

cfc-web/src/views/Layout.vue 菜单中添加:

<el-menu-item index="/admin/config">
  <i class="el-icon-setting"></i>
  <span slot="title">系统配置</span>
</el-menu-item>
  • [ ] Step 4: 提交

    cd /mnt/e/workspace/dan/zxyj && git add cfc-web/src/views/admin/SysConfig.vue cfc-web/src/router/index.js cfc-web/src/views/Layout.vue
    git commit -m "feat(phase1): Web 端系统配置页面+路由+菜单"
    

T15: 端到端验证

Files: 无(仅运行验证命令)

  • [ ] Step 1: 后端编译验证

    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn clean compile -q
    

Expected: mvn: ok(无错误)

  • [ ] Step 2: 启动后端

    # 杀掉旧进程(如有)
    pkill -f "cfc-backend" 2>/dev/null || true
    sleep 2
    
    # 启动新进程
    cd /mnt/e/workspace/dan/zxyj/cfc-backend && rtk mvn spring-boot:run > /tmp/cfc-backend.log 2>&1 &
    echo "Started, PID: $!"
    
    # 等待启动完成
    sleep 30
    rtk grep "Started" /tmp/cfc-backend.log
    

Expected: 输出包含 "Started CfcApplication"

  • [ ] Step 3: 测试商品列表 API

    curl -s -X POST http://localhost:8080/api/product/list \
    -H "Content-Type: application/json" \
    -d '{"page":1,"size":10}' | rtk python3 -m json.tool
    

Expected: 返回 code: 200,data.records 含 5 个种子商品

  • [ ] Step 4: 测试系统配置 API

    curl -s -X POST http://localhost:8080/api/admin/config/list \
    -H "Content-Type: application/json" -d '{}' | rtk python3 -m json.tool
    

Expected: 返回 code: 200,data 含 8 条配置记录

  • [ ] Step 5: 验证前端文件可解析

    # 检查所有新增的 .vue 文件能被读出
    for f in \
    /mnt/e/workspace/dan/zxyj/cfc-frontend/pages/shop/index.vue \
    /mnt/e/workspace/dan/zxyj/cfc-frontend/pages/discover/index.vue \
    /mnt/e/workspace/dan/zxyj/cfc-frontend/pages/discover/product-detail.vue \
    /mnt/e/workspace/dan/zxyj/cfc-frontend/pages/shop/order-list.vue \
    /mnt/e/workspace/dan/zxyj/cfc-frontend/pages/shop/order-detail.vue \
    /mnt/e/workspace/dan/zxyj/cfc-frontend/pages/vendor/products.vue \
    /mnt/e/workspace/dan/zxyj/cfc-frontend/pages/vendor/orders.vue
    do
    rtk head -1 "$f" > /dev/null && echo "OK: $f" || echo "FAIL: $f"
    done
    

Expected: 全部输出 "OK: ..."

  • [ ] Step 6: 验证 pages.json 注册了所有新页面

    for p in \
    "pages/discover/product-detail" \
    "pages/shop/order-list" \
    "pages/shop/order-detail" \
    "pages/vendor/products" \
    "pages/vendor/orders"
    do
    rtk grep -q "\"$p\"" /mnt/e/workspace/dan/zxyj/cfc-frontend/pages.json && echo "OK: $p" || echo "MISSING: $p"
    done
    

Expected: 全部输出 "OK: ..."

  • [ ] Step 7: 验证 Web 端路由

    for p in "ProductManage" "OrderManage" "SysConfig"; do
    rtk grep -q "$p" /mnt/e/workspace/dan/zxyj/cfc-web/src/router/index.js && echo "OK: $p" || echo "MISSING: $p"
    done
    

Expected: 全部输出 "OK: ..."

  • Step 8: 编写 Phase 1 完成报告

docs/superpowers/plans/2026-06-04-phase1-completion-report.md 写入:

# Phase 1 完成报告

> **日期:** 2026-06-04
> **范围:** 溪福俱乐部 Phase 1 — 商品系统
> **依赖:** Phase 0 角色模型 + TabBar + 服务商入驻

## 交付清单

### 后端 (10 新建 + 2 修改)
- [x] `entity/Product.java` — 商品实体
- [x] `entity/ProductOrder.java` — 订单实体
- [x] `entity/SysConfig.java` — 系统配置实体
- [x] `mapper/ProductMapper.java` / `ProductOrderMapper.java` / `SysConfigMapper.java`
- [x] `dto/ProductDTO.java` / `ProductListQueryDTO.java`
- [x] `dto/ProductOrderDTO.java` / `CreateProductOrderDTO.java`
- [x] `dto/SysConfigDTO.java`
- [x] `service/ProductService.java` — 商品 CRUD/审核/上下架/库存
- [x] `service/ProductOrderService.java` — 订单创建/支付/取消/完成
- [x] `service/SysConfigService.java` — 配置 CRUD
- [x] `controller/product/ProductController.java`
- [x] `controller/product/ProductOrderController.java`
- [x] `controller/config/SysConfigController.java`
- [x] `controller/admin/AdminController.java` — 新增 product/order 审核端点
- [x] `config/DatabaseInitializer.java` — 3 张表 DDL + 商品+配置种子

### 前端 (7 新建 + 1 修改)
- [x] `pages/shop/index.vue` — 商城页(从占位 → 商品列表)
- [x] `pages/discover/index.vue` — 发现页(从占位 → 活动+课程)
- [x] `pages/discover/product-detail.vue` — 商品详情
- [x] `pages/shop/order-list.vue` — 订单列表
- [x] `pages/shop/order-detail.vue` — 订单详情
- [x] `pages/vendor/products.vue` — 服务商商品管理
- [x] `pages/vendor/orders.vue` — 服务商订单管理
- [x] `pages/vendor/center.vue` — 添加商品/订单菜单项
- [x] `utils/api.js` — 新增 19 个商品/订单/配置 API
- [x] `pages.json` — 注册 5 个新页面

### Web 管理端 (3 新建 + 2 修改)
- [x] `api/product.js` — Web API 封装
- [x] `views/admin/ProductManage.vue` — 商品管理
- [x] `views/admin/OrderManage.vue` — 订单管理
- [x] `views/admin/SysConfig.vue` — 系统配置
- [x] `router/index.js` — 3 个新路由
- [x] `views/Layout.vue` — 3 个菜单项

## 测试结果

- [x] 后端编译通过 (`mvn clean compile`)
- [x] 后端启动成功 (Started CfcApplication)
- [x] 商品列表 API 返回 5 条种子数据
- [x] 系统配置 API 返回 8 条配置
- [x] 前端所有新增页面文件可读
- [x] pages.json 注册完整
- [x] Web 端路由注册完整

## 已知限制

- 商品发布功能:Phase 1 仅提供 Web 端/管理端入口;小程序端 `onCreate` 为占位提示(待 Phase 2+ 完善编辑表单)
- 真实微信支付:Phase 1+2 均使用测试模式(直接标记为已支付);Phase 3+ 接入真实支付
- SKU 多规格:未实现(设计 Phase 0-2 暂不支持)

## Phase 2 启动条件

- [x] Product 列表可访问
- [x] 商品已上架(on_shelf)
- [x] 用户可购买(订单流程已通)
- [ ] **Phase 2 计划待编写**
  • [ ] Step 9: 提交

    cd /mnt/e/workspace/dan/zxyj && git add docs/superpowers/plans/2026-06-04-phase1-completion-report.md
    git commit -m "docs(phase1): Phase 1 商品系统完成报告"
    

总结

Phase 1 完成后:

  1. ✅ 用户可在小程序浏览/购买商品
  2. ✅ 服务商可发布商品(需审核)
  3. ✅ 管理员可在 Web 端审核商品/管理订单/配置系统参数
  4. ✅ 5 个种子商品 + 8 个系统配置已就位
  5. ✅ 30 个任务全部完成
  6. ✅ Phase 2 启动条件满足

下一步:编写 Phase 2 计划文档(在 Phase 1 验收通过后开始)