소스 검색

feat(cfc-backend): consignee + purchase field module (migration, entity, mapper, service, controller)

User 2 달 전
부모
커밋
5a6ba08c55
18개의 변경된 파일801개의 추가작업 그리고 74개의 파일을 삭제
  1. 42 0
      .anchored-summary.md
  2. 44 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  3. 49 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPurchaseFieldController.java
  4. 47 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/ConsigneeController.java
  5. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/PurchaseFieldController.java
  6. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java
  7. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductOrderDTO.java
  8. 0 4
      cfc-backend/src/main/java/com/etotem/cfc/dto/UpdateUserDTO.java
  9. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Consignee.java
  10. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java
  11. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductPurchaseField.java
  12. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ConsigneeMapper.java
  13. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductPurchaseFieldMapper.java
  14. 99 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ConsigneeService.java
  15. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  16. 136 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductPurchaseFieldService.java
  17. 7 0
      cfc-frontend/pages/profile/components/ProfileMenu.vue
  18. 242 70
      cfc-frontend/pages/shop/address-edit/address-edit.vue

+ 42 - 0
.anchored-summary.md

@@ -0,0 +1,42 @@
+# AI Mascot IP Display (浠宝/福宝) — Session Summary
+
+## Goal
+- Complete and verify the mascot IP形象 system (浠宝/福宝) across all touchpoints: database, backend API, login/registration, profile settings, AI chat display, and floating avatar
+
+## Progress
+### Done
+- ✅ Investigated all mascot-related files and verified completion state
+- ✅ Fixed hardcoded typing indicator in `chat.vue` (`🤖` → `{{ mascotIcon || '🤖' }}`)
+- ✅ Added dynamic CSS class `mascot-{code}` to chat nav bar
+- ✅ Added mascot-specific nav gradient: `.mascot-xibao` (warm orange), `.mascot-fubao` (golden)
+- ✅ Added mascot-specific avatar styling (gradient + colored border)
+- ✅ Added welcome icon float animation with per-mascot delays
+- ✅ Added mascot selection to `user-edit.vue` (registration flow):
+  - Mascot card UI (浠宝/福宝 with gradient icons)
+  - `selectMascot(code)` method
+  - `form.mascot` field in data
+  - `mascot` passed in all API calls (saveUserInfo, directRegister, updateUserInfo)
+  - mascot saved to `uni.getStorageSync('userInfo')` on navigation
+  - Card styles with active state and gradient backgrounds
+- ✅ Verified other mascot components:
+  - `MascotEnum.java` ✅ (XIBAO/FUBAO with code, gender, persona)
+  - `User.java:95` ✅ (`private String mascot;` field)
+  - `AIChatController.java` ✅ (injects mascot_name/mascot_gender/mascot_persona into Dify)
+  - `UserMascotController.java` ✅ (POST set/get)
+  - `profile.vue` ✅ (mascot-selector with cards)
+  - `AIFloatingAvatar.vue` ✅ (reads from storage)
+  - `chat.vue` ✅ (reads from userInfo onLoad, mascot-specific styling)
+
+### Remaining
+- 🔄 Verify all diagnostics clean
+- 🔄 (Optional) Test full mascot flow: login → user-edit mascot selection → profile → AI chat
+
+## Key Decisions
+- Added mascot selection to `user-edit.vue` during registration (not login.vue), since WeChat login is one-tap
+- Used gradient/card UI matching profile.vue style for consistency
+- Saved mascot to `userInfo` storage in `navigateToHome()` to ensure chat.vue reads it immediately
+
+## Critical Context
+- Pre-existing LSP warnings (unused `e` variables, string concatenation) — NOT introduced by edits
+- 小程序 limits: no optional chaining, no CSS Grid — all CSS uses flexbox
+- chat.vue has 4 pre-existing `var` declarations — kept as-is for legacy compatibility

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -4241,6 +4241,50 @@ try {
         } catch (Exception e) {
             log.warn("创建family_relationships表可能已存在: {}", e.getMessage());
         }
+
+        // 迁移42: 创建收货人表(含购买所需特殊信息)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS consignees (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "user_id BIGINT NOT NULL COMMENT '所属用户ID', " +
+                    "name VARCHAR(100) NOT NULL COMMENT '收货人姓名', " +
+                    "phone VARCHAR(20) COMMENT '手机号', " +
+                    "id_card VARCHAR(20) COMMENT '身份证号', " +
+                    "hand_signature TEXT COMMENT '手签名(base64图片或URL)', " +
+                    "ethnicity VARCHAR(20) COMMENT '民族', " +
+                    "blood_type VARCHAR(20) COMMENT '血型', " +
+                    "address VARCHAR(500) COMMENT '收货地址', " +
+                    "is_default TINYINT(1) DEFAULT 0 COMMENT '是否默认收货人', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "INDEX idx_user_id (user_id)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收货人信息(含购买所需特殊信息)'");
+            log.info("已创建consignees表");
+        } catch (Exception e) {
+            log.warn("创建consignees表可能已存在: {}", e.getMessage());
+        }
+
+        // 迁移42: 创建商品购买信息字段配置表
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS product_purchase_fields (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "product_id BIGINT NOT NULL COMMENT '商品ID', " +
+                    "field_key VARCHAR(50) NOT NULL COMMENT '字段标识', " +
+                    "field_name VARCHAR(100) NOT NULL COMMENT '显示名', " +
+                    "is_required TINYINT(1) DEFAULT 1 COMMENT '是否必填', " +
+                    "sort_order INT DEFAULT 0 COMMENT '排序', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "INDEX idx_product_id (product_id), " +
+                    "UNIQUE KEY uk_product_field (product_id, field_key)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品购买信息字段配置'");
+            log.info("已创建product_purchase_fields表");
+        } catch (Exception e) {
+            log.warn("创建product_purchase_fields表可能已存在: {}", e.getMessage());
+        }
+
+        // 迁移42: product_orders表添加consignee_id和purchase_info字段
+        ensureColumn("product_orders", "consignee_id", "BIGINT COMMENT '收货人ID'");
+        ensureColumn("product_orders", "purchase_info", "TEXT COMMENT '购买信息JSON: {id_card, hand_signature, ...}'");
     }
 
     private void insertSysConfigSeed(String key, String value, String desc) {

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPurchaseFieldController.java

@@ -0,0 +1,49 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ProductPurchaseField;
+import com.etotem.cfc.service.ProductPurchaseFieldService;
+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.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/product/purchase_fields")
+public class AdminPurchaseFieldController {
+
+    @Resource
+    private ProductPurchaseFieldService purchaseFieldService;
+
+    @PostMapping("/predefined")
+    public Result<List<Map<String, String>>> predefined() {
+        List<Map<String, String>> list = new ArrayList<>();
+        for (Map.Entry<String, String> entry : ProductPurchaseFieldService.PREDEFINED_FIELDS.entrySet()) {
+            Map<String, String> item = new LinkedHashMap<>();
+            item.put("fieldKey", entry.getKey());
+            item.put("fieldName", entry.getValue());
+            list.add(item);
+        }
+        return Result.success(list);
+    }
+
+    @PostMapping("/list")
+    public Result<List<ProductPurchaseField>> list(@RequestBody Map<String, Object> params) {
+        Long productId = Long.parseLong(params.get("productId").toString());
+        return purchaseFieldService.listByProduct(productId);
+    }
+
+    @PostMapping("/save")
+    public Result<Void> save(@RequestBody Map<String, Object> params) {
+        Long productId = Long.parseLong(params.get("productId").toString());
+        @SuppressWarnings("unchecked")
+        List<ProductPurchaseField> fields = (List<ProductPurchaseField>) params.get("fields");
+        return purchaseFieldService.save(productId, fields);
+    }
+}

+ 47 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ConsigneeController.java

@@ -0,0 +1,47 @@
+package com.etotem.cfc.controller.product;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Consignee;
+import com.etotem.cfc.service.ConsigneeService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+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/consignee")
+public class ConsigneeController {
+
+    @Resource
+    private ConsigneeService consigneeService;
+
+    @PostMapping("/list")
+    public Result<List<Consignee>> list(@RequestAttribute("userId") Long userId) {
+        return consigneeService.list(userId);
+    }
+
+    @PostMapping("/detail")
+    public Result<Consignee> detail(@RequestBody Map<String, Object> params) {
+        Long id = Long.parseLong(params.get("id").toString());
+        return consigneeService.detail(id);
+    }
+
+    @PostMapping("/save")
+    public Result<Long> save(@RequestBody Consignee consignee,
+                             @RequestAttribute("userId") Long userId) {
+        consignee.setUserId(userId);
+        return consigneeService.save(consignee);
+    }
+
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params,
+                                 @RequestAttribute("userId") Long userId) {
+        Long id = Long.parseLong(params.get("id").toString());
+        return consigneeService.delete(id, userId);
+    }
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/product/PurchaseFieldController.java

@@ -0,0 +1,29 @@
+package com.etotem.cfc.controller.product;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ProductPurchaseField;
+import com.etotem.cfc.service.ProductPurchaseFieldService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+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/product/purchase_fields")
+public class PurchaseFieldController {
+
+    @Resource
+    private ProductPurchaseFieldService purchaseFieldService;
+
+    @PostMapping("/required")
+    public Result<List<Map<String, Object>>> required(@RequestBody Map<String, Object> params,
+                                                      @RequestAttribute("userId") Long userId) {
+        Long productId = Long.parseLong(params.get("productId").toString());
+        return purchaseFieldService.getRequiredFields(productId, userId);
+    }
+}

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.dto;
 import lombok.Data;
 
 import java.util.List;
+import java.util.Map;
 
 @Data
 public class CreateProductOrderDTO {
@@ -16,6 +17,8 @@ public class CreateProductOrderDTO {
     private Long couponId;
     private Integer deliveryMethod = 1; // 1=快递 2=自提
     private Integer pointsUsed; // 积分抵扣数量
+    private Long consigneeId;         // 收货人ID
+    private Map<String, String> purchaseInfo; // 购买信息(字段key→值)
 
     // Explicit getters needed due to Lombok issues
     public List<OrderItemVO> getItems() { return items; }
@@ -28,4 +31,6 @@ public class CreateProductOrderDTO {
     public String getAddressSnapshot() { return addressSnapshot; }
     public Integer getDeliveryMethod() { return deliveryMethod; }
     public Integer getPointsUsed() { return pointsUsed; }
+    public Long getConsigneeId() { return consigneeId; }
+    public Map<String, String> getPurchaseInfo() { return purchaseInfo; }
 }

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductOrderDTO.java

@@ -30,6 +30,8 @@ public class ProductOrderDTO {
     private String transactionId;
     private String coverImage;
     private String addressSnapshot;
+    private Long consigneeId;
+    private String purchaseInfo;
     private Date paidAt;
     private Date createdAt;
     private Date updatedAt;
@@ -59,6 +61,8 @@ public class ProductOrderDTO {
         d.transactionId = o.getTransactionId();
         d.coverImage = o.getCoverImage();
         d.addressSnapshot = o.getAddressSnapshot();
+        d.consigneeId = o.getConsigneeId();
+        d.purchaseInfo = o.getPurchaseInfo();
         d.paidAt = o.getPaidAt();
         d.createdAt = o.getCreatedAt();
         d.updatedAt = o.getUpdatedAt();

+ 0 - 4
cfc-backend/src/main/java/com/etotem/cfc/dto/UpdateUserDTO.java

@@ -27,10 +27,6 @@ public class UpdateUserDTO {
     // 家长身份
     private String familyRole;
 
-<<<<<<< Updated upstream
-    // AI助手形象: xibao/fubao
-=======
     // AI助手形象: xibao(浠宝)/fubao(福宝)
->>>>>>> Stashed changes
     private String mascot;
 }

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Consignee.java

@@ -0,0 +1,39 @@
+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("consignees")
+public class Consignee implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long userId;
+
+    private String name;
+
+    private String phone;
+
+    private String idCard;
+
+    private String handSignature;
+
+    private String ethnicity;
+
+    private String bloodType;
+
+    private String address;
+
+    private Integer isDefault;
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java

@@ -54,6 +54,10 @@ public class ProductOrder implements Serializable {
     private Integer pointsUsed;        // 使用的积分数量
     private Integer pointsCost;         // 积分抵扣金额(分)
     private Integer moneyAmount;       // 现金支付金额(分)
+    // 购买信息采集
+    private Long consigneeId;          // 收货人ID
+    private String purchaseInfo;       // 购买信息快照(JSON)
+
     private Date createdAt;
     private Date updatedAt;
 
@@ -77,4 +81,6 @@ public class ProductOrder implements Serializable {
     public void setPointsUsed(Integer pointsUsed) { this.pointsUsed = pointsUsed; }
     public void setPointsCost(Integer pointsCost) { this.pointsCost = pointsCost; }
     public void setMoneyAmount(Integer moneyAmount) { this.moneyAmount = moneyAmount; }
+    public void setConsigneeId(Long consigneeId) { this.consigneeId = consigneeId; }
+    public void setPurchaseInfo(String purchaseInfo) { this.purchaseInfo = purchaseInfo; }
 }

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ProductPurchaseField.java

@@ -0,0 +1,29 @@
+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("product_purchase_fields")
+public class ProductPurchaseField implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long productId;
+
+    private String fieldKey;
+
+    private String fieldName;
+
+    private Integer isRequired;
+
+    private Integer sortOrder;
+
+    private Date createdAt;
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ConsigneeMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.Consignee;
+
+public interface ConsigneeMapper extends BaseMapper<Consignee> {
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductPurchaseFieldMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ProductPurchaseField;
+
+public interface ProductPurchaseFieldMapper extends BaseMapper<ProductPurchaseField> {
+}

+ 99 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ConsigneeService.java

@@ -0,0 +1,99 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Consignee;
+import com.etotem.cfc.mapper.ConsigneeMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.List;
+
+@Service
+public class ConsigneeService {
+
+    @Resource
+    private ConsigneeMapper consigneeMapper;
+
+    public Result<List<Consignee>> list(Long userId) {
+        List<Consignee> list = consigneeMapper.selectList(
+                new LambdaQueryWrapper<Consignee>()
+                        .eq(Consignee::getUserId, userId)
+                        .orderByDesc(Consignee::getIsDefault)
+                        .orderByDesc(Consignee::getCreatedAt));
+        return Result.success(list);
+    }
+
+    public Result<Consignee> detail(Long id) {
+        Consignee c = consigneeMapper.selectById(id);
+        if (c == null) {
+            return Result.error("收货人不存在");
+        }
+        return Result.success(c);
+    }
+
+    @Transactional
+    public Result<Long> save(Consignee consignee) {
+        if (consignee.getName() == null || consignee.getName().trim().isEmpty()) {
+            return Result.error("收货人姓名不能为空");
+        }
+        if (consignee.getIsDefault() == null) {
+            consignee.setIsDefault(0);
+        }
+        if (consignee.getId() != null) {
+            // update
+            Consignee existing = consigneeMapper.selectById(consignee.getId());
+            if (existing == null) {
+                return Result.error("收货人不存在");
+            }
+            // if setting as default, clear other defaults first
+            if (consignee.getIsDefault() == 1) {
+                clearDefault(existing.getUserId());
+            }
+            consigneeMapper.updateById(consignee);
+            return Result.success(consignee.getId());
+        } else {
+            // create
+            if (consignee.getUserId() == null) {
+                return Result.error("用户ID不能为空");
+            }
+            if (consignee.getIsDefault() == 1) {
+                clearDefault(consignee.getUserId());
+            }
+            consigneeMapper.insert(consignee);
+            return Result.success(consignee.getId());
+        }
+    }
+
+    @Transactional
+    public Result<String> delete(Long id, Long userId) {
+        Consignee existing = consigneeMapper.selectById(id);
+        if (existing == null) {
+            return Result.error("收货人不存在");
+        }
+        if (!existing.getUserId().equals(userId)) {
+            return Result.error("无权删除该收货人");
+        }
+        consigneeMapper.deleteById(id);
+        return Result.success("删除成功");
+    }
+
+    public Consignee getDefault(Long userId) {
+        List<Consignee> list = consigneeMapper.selectList(
+                new LambdaQueryWrapper<Consignee>()
+                        .eq(Consignee::getUserId, userId)
+                        .eq(Consignee::getIsDefault, 1)
+                        .last("LIMIT 1"));
+        return list.isEmpty() ? null : list.get(0);
+    }
+
+    private void clearDefault(Long userId) {
+        Consignee def = new Consignee();
+        def.setIsDefault(0);
+        consigneeMapper.update(def,
+                new LambdaQueryWrapper<Consignee>()
+                        .eq(Consignee::getUserId, userId)
+                        .eq(Consignee::getIsDefault, 1));
+    }
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -26,6 +26,7 @@ import java.util.stream.Collectors;
 import com.etotem.cfc.dto.ChildInfoDTO;
 import com.etotem.cfc.service.EnergyService;
 import com.etotem.cfc.service.UserService;
+import com.alibaba.fastjson.JSON;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -107,6 +108,10 @@ public class ProductOrderService {
         order.setDiscountAmount(dto.getDiscountAmount());
         order.setCouponId(dto.getCouponId());
         order.setAddressSnapshot(dto.getAddressSnapshot());
+        order.setConsigneeId(dto.getConsigneeId());
+        if (dto.getPurchaseInfo() != null && !dto.getPurchaseInfo().isEmpty()) {
+            order.setPurchaseInfo(JSON.toJSONString(dto.getPurchaseInfo()));
+        }
         order.setDeliveryMethod(dto.getDeliveryMethod() != null ? dto.getDeliveryMethod() : 1);
         // Generate pickup code for self-pickup orders
         if (order.getDeliveryMethod() != null && order.getDeliveryMethod() == 2) {
@@ -169,6 +174,10 @@ public class ProductOrderService {
         order.setDiscountAmount(dto.getDiscountAmount());
         order.setCouponId(dto.getCouponId());
         order.setAddressSnapshot(dto.getAddressSnapshot());
+        order.setConsigneeId(dto.getConsigneeId());
+        if (dto.getPurchaseInfo() != null && !dto.getPurchaseInfo().isEmpty()) {
+            order.setPurchaseInfo(JSON.toJSONString(dto.getPurchaseInfo()));
+        }
         order.setCancelAt(new Date(System.currentTimeMillis() + 30 * 60 * 1000));
         order.setCreatedAt(new Date());
         order.setUpdatedAt(new Date());

+ 136 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductPurchaseFieldService.java

@@ -0,0 +1,136 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Consignee;
+import com.etotem.cfc.entity.ProductPurchaseField;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ProductPurchaseFieldMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Service
+public class ProductPurchaseFieldService {
+
+    @Resource
+    private ProductPurchaseFieldMapper purchaseFieldMapper;
+
+    @Resource
+    private ConsigneeService consigneeService;
+
+    @Resource
+    private UserMapper userMapper;
+
+    /**
+     * 预定义字段列表,供管理端选择
+     */
+    public static final Map<String, String> PREDEFINED_FIELDS = new LinkedHashMap<>();
+
+    static {
+        PREDEFINED_FIELDS.put("name", "收货人姓名");
+        PREDEFINED_FIELDS.put("phone", "手机号");
+        PREDEFINED_FIELDS.put("id_card", "身份证号");
+        PREDEFINED_FIELDS.put("hand_signature", "手签名");
+        PREDEFINED_FIELDS.put("ethnicity", "民族");
+        PREDEFINED_FIELDS.put("blood_type", "血型");
+    }
+
+    public Result<List<ProductPurchaseField>> listByProduct(Long productId) {
+        List<ProductPurchaseField> list = purchaseFieldMapper.selectList(
+                new LambdaQueryWrapper<ProductPurchaseField>()
+                        .eq(ProductPurchaseField::getProductId, productId)
+                        .orderByAsc(ProductPurchaseField::getSortOrder));
+        return Result.success(list);
+    }
+
+    @Transactional
+    public Result<Void> save(Long productId, List<ProductPurchaseField> fields) {
+        // delete existing configs
+        purchaseFieldMapper.delete(
+                new LambdaQueryWrapper<ProductPurchaseField>()
+                        .eq(ProductPurchaseField::getProductId, productId));
+        // insert new configs
+        if (fields != null && !fields.isEmpty()) {
+            for (int i = 0; i < fields.size(); i++) {
+                ProductPurchaseField f = fields.get(i);
+                f.setId(null);
+                f.setProductId(productId);
+                if (f.getSortOrder() == null) {
+                    f.setSortOrder(i);
+                }
+                if (f.getIsRequired() == null) {
+                    f.setIsRequired(1);
+                }
+                purchaseFieldMapper.insert(f);
+            }
+        }
+        return Result.success(null);
+    }
+
+    /**
+     * 获取用户购买某商品时需要的购买信息
+     * 返回每个字段及当前用户信息中已有的值
+     */
+    public Result<List<Map<String, Object>>> getRequiredFields(Long productId, Long userId) {
+        List<ProductPurchaseField> configs = purchaseFieldMapper.selectList(
+                new LambdaQueryWrapper<ProductPurchaseField>()
+                        .eq(ProductPurchaseField::getProductId, productId)
+                        .eq(ProductPurchaseField::getIsRequired, 1)
+                        .orderByAsc(ProductPurchaseField::getSortOrder));
+
+        if (configs.isEmpty()) {
+            return Result.success(Collections.emptyList());
+        }
+
+        // get user info for prefilling
+        User user = userMapper.selectById(userId);
+        // get default consignee
+        Consignee defaultConsignee = consigneeService.getDefault(userId);
+
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (ProductPurchaseField cfg : configs) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("fieldKey", cfg.getFieldKey());
+            item.put("fieldName", cfg.getFieldName());
+            item.put("isRequired", cfg.getIsRequired());
+            // try to prefill from default consignee, then from user profile
+            String value = getFieldValueFromConsignee(cfg.getFieldKey(), defaultConsignee);
+            if (value == null) {
+                value = getFieldValueFromUser(cfg.getFieldKey(), user);
+            }
+            item.put("prefillValue", value != null ? value : "");
+            result.add(item);
+        }
+        return Result.success(result);
+    }
+
+    private String getFieldValueFromConsignee(String fieldKey, Consignee c) {
+        if (c == null) return null;
+        switch (fieldKey) {
+            case "name": return c.getName();
+            case "phone": return c.getPhone();
+            case "id_card": return c.getIdCard();
+            case "hand_signature": return c.getHandSignature();
+            case "ethnicity": return c.getEthnicity();
+            case "blood_type": return c.getBloodType();
+            default: return null;
+        }
+    }
+
+    private String getFieldValueFromUser(String fieldKey, User user) {
+        if (user == null) return null;
+        switch (fieldKey) {
+            case "name": return user.getRealName();
+            case "phone": return user.getPhone();
+            case "id_card": return user.getIdCard();
+            case "ethnicity": return user.getEthnicity();
+            case "blood_type": return user.getBloodType();
+            default: return null;
+        }
+    }
+}

+ 7 - 0
cfc-frontend/pages/profile/components/ProfileMenu.vue

@@ -46,6 +46,10 @@
           <text>🛒 订单管理</text>
           <text class="arrow">›</text>
         </view>
+        <view class="menu-item" @click="goToAddresses">
+          <text>📍 我的地址</text>
+          <text class="arrow">›</text>
+        </view>
         <view class="menu-item" @click="goToAfterSales">
           <text>🔄 售后记录</text>
           <text class="arrow">›</text>
@@ -165,6 +169,9 @@ export default {
     goToOrders() {
       uni.navigateTo({ url: '/pages/shop/order-list/order-list' })
     },
+    goToAddresses() {
+      uni.navigateTo({ url: '/pages/shop/address/address' })
+    },
     goToAfterSales() {
       uni.navigateTo({ url: '/pages/shop/after-sales/after-sales' })
     },

+ 242 - 70
cfc-frontend/pages/shop/address-edit/address-edit.vue

@@ -25,12 +25,12 @@
         />
       </view>
 
-      <!-- 省市区街道 (4级地址选择) -->
-      <view class="form-row address-row" @click="showAddressPicker">
+      <!-- 区域选择 (省市区三级联动) -->
+      <view class="form-row address-row" @click="openRegionPicker">
         <text class="form-label">所在地区</text>
         <view class="address-value-wrap">
           <text v-if="form.fullAddress" class="address-value">{{ form.fullAddress }}</text>
-          <text v-else class="address-placeholder">请选择省市区街道</text>
+          <text v-else class="address-placeholder">请选择省市区</text>
           <text class="address-arrow">›</text>
         </view>
       </view>
@@ -57,6 +57,40 @@
         </view>
       </view>
 
+      <!-- 区域选择弹窗 -->
+      <view class="region-mask" v-if="showRegionPicker" @click="cancelRegion">
+        <view class="region-picker" @click.stop>
+          <view class="region-header">
+            <text class="region-header-btn" @click="cancelRegion">取消</text>
+            <text class="region-header-title">选择地区</text>
+            <text class="region-header-btn region-confirm" @click="confirmRegion">确定</text>
+          </view>
+          <view class="region-breadcrumb">
+            <text
+              v-for="(name, i) in regionBreadcrumb"
+              :key="i"
+              :class="['crumb-item', i === currentRegionLevel ? 'crumb-active' : '']"
+              @click="jumpRegionLevel(i)"
+            >{{ name || '请选择' }}</text>
+            <text class="crumb-arrow" v-if="currentRegionLevel < 2">›</text>
+          </view>
+          <scroll-view class="region-list" scroll-y>
+            <view
+              v-for="item in currentRegionItems"
+              :key="item.id || item.code"
+              :class="['region-item', isRegionSelected(item) ? 'region-item-selected' : '']"
+              @click="selectRegionItem(item)"
+            >
+              <text class="region-item-text">{{ getRegionName(item) }}</text>
+              <text class="region-item-check" v-if="isRegionSelected(item)">✓</text>
+            </view>
+            <view v-if="regionLoading" class="region-loading">
+              <text>加载中...</text>
+            </view>
+          </scroll-view>
+        </view>
+      </view>
+
       <!-- Save Button -->
       <button
         :class="['save-btn', saving ? 'disabled' : '']"
@@ -86,7 +120,17 @@ export default {
         street: '',
         isDefault: 0,
         fullAddress: ''
-      }
+      },
+      // 区域选择弹窗
+      showRegionPicker: false,
+      currentRegionLevel: 0, // 0=province, 1=city, 2=district
+      regionLoading: false,
+      provinceList: [],
+      cityList: [],
+      districtList: [],
+      tempProvince: null,
+      tempCity: null,
+      tempDistrict: null
     }
   },
   onLoad(options) {
@@ -98,6 +142,21 @@ export default {
       uni.setNavigationBarTitle({ title: '新增地址' })
     }
   },
+  computed: {
+    regionBreadcrumb() {
+      var names = []
+      names.push(this.tempProvince ? this.getRegionName(this.tempProvince) : '省份')
+      names.push(this.tempCity ? this.getRegionName(this.tempCity) : '城市')
+      names.push(this.tempDistrict ? this.getRegionName(this.tempDistrict) : '区县')
+      return names
+    },
+    currentRegionItems() {
+      if (this.currentRegionLevel === 0) return this.provinceList
+      if (this.currentRegionLevel === 1) return this.cityList
+      if (this.currentRegionLevel === 2) return this.districtList
+      return []
+    }
+  },
   methods: {
     loadAddress() {
       var that = this
@@ -124,11 +183,18 @@ export default {
         }
       })
     },
-    showAddressPicker() {
-      // Use multi-column picker for region selection
+    // ===== 区域选择弹窗 =====
+    openRegionPicker() {
+      this.tempProvince = this.form.province ? { name: this.form.province, id: null } : null
+      this.tempCity = this.form.city ? { name: this.form.city, id: null } : null
+      this.tempDistrict = this.form.district ? { name: this.form.district, id: null } : null
+      this.currentRegionLevel = 0
+      this.showRegionPicker = true
+      this.loadRegionProvinces()
+    },
+    loadRegionProvinces() {
       var that = this
-      // Load provinces first
-      uni.showLoading({ title: '加载地址...' })
+      that.regionLoading = true
       uni.request({
         url: config.api('/api/region/provinces'),
         method: 'POST',
@@ -137,107 +203,115 @@ export default {
           'Authorization': 'Bearer ' + uni.getStorageSync('token')
         },
         success: function(res) {
-          uni.hideLoading()
+          that.regionLoading = false
           if (res.data && res.data.code === 200) {
-            var provinces = res.data.data || []
-            var provinceList = provinces.map(function(p) { return p.name || p.province })
-            uni.showActionSheet({
-              itemList: provinceList,
-              success: function(pRes) {
-                var selectedProvince = provinces[pRes.tapIndex]
-                that.form.province = selectedProvince.name || selectedProvince.province
-                var provinceId = selectedProvince.id || selectedProvince.code
-                that.loadCities(provinceId)
-              }
-            })
-          } else {
-            // Fallback: simple text input for full address
-            that.showSimpleAddressInput()
+            that.provinceList = res.data.data || []
           }
         },
         fail: function() {
-          uni.hideLoading()
-          that.showSimpleAddressInput()
+          that.regionLoading = false
         }
       })
     },
-    loadCities(provinceId) {
+    selectRegionItem(item) {
+      if (this.currentRegionLevel === 0) {
+        this.tempProvince = item
+        this.tempCity = null
+        this.tempDistrict = null
+        this.cityList = []
+        this.districtList = []
+        this.currentRegionLevel = 1
+        this.loadRegionCities(this.getRegionId(item))
+      } else if (this.currentRegionLevel === 1) {
+        this.tempCity = item
+        this.tempDistrict = null
+        this.districtList = []
+        this.currentRegionLevel = 2
+        this.loadRegionDistricts(this.getRegionId(item))
+      } else if (this.currentRegionLevel === 2) {
+        this.tempDistrict = item
+        this.confirmRegion()
+      }
+    },
+    loadRegionCities(parentId) {
+      if (!parentId) return
       var that = this
-      uni.showLoading({ title: '加载城市...' })
+      that.regionLoading = true
       uni.request({
         url: config.api('/api/region/cities'),
         method: 'POST',
-        data: { parentId: provinceId },
+        data: { parentId: parentId },
         header: {
           'Content-Type': 'application/json',
           'Authorization': 'Bearer ' + uni.getStorageSync('token')
         },
         success: function(res) {
-          uni.hideLoading()
+          that.regionLoading = false
           if (res.data && res.data.code === 200) {
-            var cities = res.data.data || []
-            var cityList = cities.map(function(c) { return c.name || c.city })
-            uni.showActionSheet({
-              itemList: cityList,
-              success: function(cRes) {
-                var selectedCity = cities[cRes.tapIndex]
-                that.form.city = selectedCity.name || selectedCity.city
-                var cityId = selectedCity.id || selectedCity.code
-                that.loadDistricts(cityId)
-              }
-            })
+            that.cityList = res.data.data || []
           }
         },
         fail: function() {
-          uni.hideLoading()
+          that.regionLoading = false
         }
       })
     },
-    loadDistricts(cityId) {
+    loadRegionDistricts(parentId) {
+      if (!parentId) return
       var that = this
-      uni.showLoading({ title: '加载区县...' })
+      that.regionLoading = true
       uni.request({
         url: config.api('/api/region/districts'),
         method: 'POST',
-        data: { parentId: cityId },
+        data: { parentId: parentId },
         header: {
           'Content-Type': 'application/json',
           'Authorization': 'Bearer ' + uni.getStorageSync('token')
         },
         success: function(res) {
-          uni.hideLoading()
+          that.regionLoading = false
           if (res.data && res.data.code === 200) {
-            var districts = res.data.data || []
-            var districtList = districts.map(function(d) { return d.name || d.district })
-            uni.showActionSheet({
-              itemList: districtList,
-              success: function(dRes) {
-                var selectedDistrict = districts[dRes.tapIndex]
-                that.form.district = selectedDistrict.name || selectedDistrict.district
-                that.updateFullAddress()
-              }
-            })
+            that.districtList = res.data.data || []
           }
         },
         fail: function() {
-          uni.hideLoading()
+          that.regionLoading = false
         }
       })
     },
-    showSimpleAddressInput() {
-      var that = this
-      uni.showModal({
-        title: '请输入地址',
-        content: '请输入省市区街道信息',
-        editable: true,
-        placeholderText: '如: 北京市朝阳区XX街道',
-        success: function(res) {
-          if (res.confirm && res.content) {
-            that.form.fullAddress = res.content
-            that.form.province = res.content
-          }
-        }
-      })
+    confirmRegion() {
+      if (this.tempProvince) {
+        this.form.province = this.getRegionName(this.tempProvince)
+      }
+      if (this.tempCity) {
+        this.form.city = this.getRegionName(this.tempCity)
+      }
+      if (this.tempDistrict) {
+        this.form.district = this.getRegionName(this.tempDistrict)
+      }
+      this.updateFullAddress()
+      this.showRegionPicker = false
+    },
+    cancelRegion() {
+      this.showRegionPicker = false
+    },
+    jumpRegionLevel(level) {
+      if (level < this.currentRegionLevel) {
+        this.currentRegionLevel = level
+      }
+    },
+    getRegionName(item) {
+      return item && (item.name || item.province || item.city || item.district || '')
+    },
+    getRegionId(item) {
+      return item && (item.id || item.code)
+    },
+    isRegionSelected(item) {
+      var name = this.getRegionName(item)
+      if (this.currentRegionLevel === 0) return this.tempProvince && this.getRegionName(this.tempProvince) === name
+      if (this.currentRegionLevel === 1) return this.tempCity && this.getRegionName(this.tempCity) === name
+      if (this.currentRegionLevel === 2) return this.tempDistrict && this.getRegionName(this.tempDistrict) === name
+      return false
     },
     updateFullAddress() {
       this.form.fullAddress = (this.form.province || '') + (this.form.city || '') + (this.form.district || '') + (this.form.street || '')
@@ -413,4 +487,102 @@ export default {
   background: #ddd;
   color: #999;
 }
+
+/* ===== 区域选择弹窗 ===== */
+.region-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 999;
+  display: flex;
+  align-items: flex-end;
+}
+.region-picker {
+  background: #fff;
+  border-radius: 20rpx 20rpx 0 0;
+  width: 100%;
+  max-height: 70vh;
+  display: flex;
+  flex-direction: column;
+}
+.region-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 24rpx 30rpx;
+  border-bottom: 1rpx solid #f0f0f0;
+}
+.region-header-btn {
+  font-size: 28rpx;
+  color: #999;
+}
+.region-confirm {
+  color: #F97316;
+  font-weight: bold;
+}
+.region-header-title {
+  font-size: 30rpx;
+  font-weight: bold;
+  color: #333;
+}
+.region-breadcrumb {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 30rpx;
+  border-bottom: 1rpx solid #f5f5f5;
+}
+.crumb-item {
+  font-size: 26rpx;
+  color: #999;
+  padding: 8rpx 12rpx;
+  border-radius: 8rpx;
+}
+.crumb-item.crumb-active {
+  color: #F97316;
+  font-weight: bold;
+}
+.crumb-arrow {
+  font-size: 24rpx;
+  color: #ccc;
+  margin: 0 8rpx;
+}
+.region-list {
+  max-height: 50vh;
+  overflow-y: auto;
+}
+.region-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 28rpx 30rpx;
+  border-bottom: 1rpx solid #f5f5f5;
+}
+.region-item:active {
+  background: #FFF7ED;
+}
+.region-item-text {
+  font-size: 28rpx;
+  color: #333;
+}
+.region-item-check {
+  font-size: 28rpx;
+  color: #F97316;
+  font-weight: bold;
+}
+.region-item-selected {
+  background: #FFF7ED;
+}
+.region-item-selected .region-item-text {
+  color: #F97316;
+  font-weight: bold;
+}
+.region-loading {
+  padding: 40rpx;
+  text-align: center;
+  font-size: 26rpx;
+  color: #999;
+}
 </style>