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

Merge remote-tracking branch 'origin/cfclub' into temp-push

# Conflicts:
#	cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
#	cfc-backend/src/main/java/com/etotem/cfc/dto/UpdateUserDTO.java
Sisyphus Agent 2 месяцев назад
Родитель
Сommit
029a61e277
57 измененных файлов с 2634 добавлено и 842 удалено
  1. 42 0
      .anchored-summary.md
  2. 9 0
      cfc-backend/pom.xml
  3. 210 210
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  4. 23 4
      cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java
  5. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java
  6. 49 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminPurchaseFieldController.java
  7. 14 8
      cfc-backend/src/main/java/com/etotem/cfc/controller/cart/CartController.java
  8. 47 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/ConsigneeController.java
  9. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductOrderController.java
  10. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/product/PurchaseFieldController.java
  11. 1 3
      cfc-backend/src/main/java/com/etotem/cfc/dto/CartItemDTO.java
  12. 5 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/CreateProductOrderDTO.java
  13. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductOrderDTO.java
  14. 39 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Consignee.java
  15. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java
  16. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ProductPurchaseField.java
  17. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ConsigneeMapper.java
  18. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ProductPurchaseFieldMapper.java
  19. 10 4
      cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java
  20. 99 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ConsigneeService.java
  21. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java
  22. 136 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductPurchaseFieldService.java
  23. 34 0
      cfc-backend/src/main/resources/schema.sql
  24. 4 0
      cfc-frontend/pages.json
  25. 578 0
      cfc-frontend/pages/activity/create.vue
  26. 352 87
      cfc-frontend/pages/activity/index.vue
  27. 332 170
      cfc-frontend/pages/article-center/index.vue
  28. 81 45
      cfc-frontend/pages/discover/product-detail/product-detail.vue
  29. 7 2
      cfc-frontend/pages/login/login.vue
  30. 1 1
      cfc-frontend/pages/profile/components/ProfileHeader.vue
  31. 7 0
      cfc-frontend/pages/profile/components/ProfileMenu.vue
  32. 55 4
      cfc-frontend/pages/profile/family-members.vue
  33. 242 70
      cfc-frontend/pages/shop/address-edit/address-edit.vue
  34. 1 0
      cfc-frontend/pages/shop/cart/cart.vue
  35. 10 0
      cfc-frontend/pages/shop/payment/payment.vue
  36. 2 2
      cfc-frontend/pages/user-edit/user-edit.vue
  37. 10 0
      cfc-frontend/utils/api.js
  38. 1 1
      cfc-web/.last_build_commit
  39. 70 188
      cfc-web/CHANGELOG.md
  40. 2 2
      cfc-web/package-lock.json
  41. 1 1
      cfc-web/package.json
  42. 3 2
      cfc-web/src/views/GuideFamilyTask.vue
  43. 3 2
      cfc-web/src/views/admin/Activities.vue
  44. 3 2
      cfc-web/src/views/admin/ActivityRegistrationReview.vue
  45. 8 3
      cfc-web/src/views/admin/ArticleManage.vue
  46. 3 2
      cfc-web/src/views/admin/Assessment.vue
  47. 3 2
      cfc-web/src/views/admin/CouponManagement.vue
  48. 5 1
      cfc-web/src/views/admin/Dashboard.vue
  49. 3 3
      cfc-web/src/views/admin/EmotionAlertManage.vue
  50. 3 2
      cfc-web/src/views/admin/Foods.vue
  51. 6 5
      cfc-web/src/views/admin/OrderManage.vue
  52. 3 2
      cfc-web/src/views/admin/PointsLog.vue
  53. 6 5
      cfc-web/src/views/admin/ProductManage.vue
  54. 3 2
      cfc-web/src/views/admin/ServiceContents.vue
  55. 3 2
      cfc-web/src/views/admin/ServiceTypes.vue
  56. 3 3
      cfc-web/src/views/admin/WithdrawAudit.vue
  57. 3 2
      cfc-web/src/views/admin/data-source-config.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

+ 9 - 0
cfc-backend/pom.xml

@@ -32,6 +32,15 @@
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-web</artifactId>
+            <exclusions>
+                <!-- 排除 jul-to-slf4j 桥接器,
+                     避免 Tomcat 内部线程(NioEndpoint$SocketProcessor)通过 DirectJDKLog → JUL → 桥接器 → logback
+                     时因线程类加载器看不到 BOOT-INF/lib/ 而导致 NoClassDefFoundError -->
+                <exclusion>
+                    <groupId>org.slf4j</groupId>
+                    <artifactId>jul-to-slf4j</artifactId>
+                </exclusion>
+            </exclusions>
         </dependency>
 
         <!-- MyBatis-Plus -->

Разница между файлами не показана из-за своего большого размера
+ 210 - 210
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java


+ 23 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java

@@ -31,16 +31,31 @@ public class ActivityController {
     }
 
     @PostMapping("/create")
-    public Result<ActivityDTO> create(@RequestBody Activity activity, @RequestAttribute(value = "userId", required = false) Long userId) {
+    public Result<ActivityDTO> create(@RequestBody Activity activity,
+                                       @RequestAttribute(value = "userId", required = false) Long userId,
+                                       @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"activity_admin".equals(role)) {
+            return Result.error("只有管理员或活动管理员可以创建活动");
+        }
         if (userId != null) activity.setVendorId(userId);
         return activityService.create(activity);
     }
 
     @PostMapping("/update")
-    public Result<ActivityDTO> update(@RequestBody Activity activity) { return activityService.update(activity); }
+    public Result<ActivityDTO> update(@RequestBody Activity activity,
+                                       @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"activity_admin".equals(role)) {
+            return Result.error("只有管理员或活动管理员可以修改活动");
+        }
+        return activityService.update(activity);
+    }
 
     @PostMapping("/publish")
-    public Result<String> publish(@RequestBody Map<String, Object> params) {
+    public Result<String> publish(@RequestBody Map<String, Object> params,
+                                   @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"activity_admin".equals(role)) {
+            return Result.error("只有管理员或活动管理员可以发布活动");
+        }
         Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
         if (id == null) return Result.error("id不能为空");
         try {
@@ -51,7 +66,11 @@ public class ActivityController {
     }
 
     @PostMapping("/end")
-    public Result<String> end(@RequestBody Map<String, Object> params) {
+    public Result<String> end(@RequestBody Map<String, Object> params,
+                               @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"activity_admin".equals(role)) {
+            return Result.error("只有管理员或活动管理员可以结束活动");
+        }
         Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
         if (id == null) return Result.error("id不能为空");
         return activityService.end(id);

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java

@@ -91,6 +91,8 @@ public class UserController {
         if (params.containsKey("highestEducation")) dto.setHighestEducation((String) params.get("highestEducation"));
         if (params.containsKey("maritalStatus")) dto.setMaritalStatus((String) params.get("maritalStatus"));
         if (params.containsKey("familyRole")) dto.setFamilyRole((String) params.get("familyRole"));
+        if (params.containsKey("idCard")) dto.setIdCard((String) params.get("idCard"));
+        if (params.containsKey("mascot")) dto.setMascot((String) params.get("mascot"));
 
         boolean success = userService.updateUserInfo(userId, dto);
         if (success) {

+ 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);
+    }
+}

+ 14 - 8
cfc-backend/src/main/java/com/etotem/cfc/controller/cart/CartController.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.CartItemDTO;
 import com.etotem.cfc.service.CartService;
 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;
@@ -20,8 +21,8 @@ public class CartController {
     private CartService cartService;
 
     @PostMapping("/add")
-    public Result<CartItemDTO> add(@RequestBody Map<String, Object> params) {
-        Long userId = Long.valueOf(params.get("userId").toString());
+    public Result<CartItemDTO> add(@RequestBody Map<String, Object> params,
+                                   @RequestAttribute("userId") Long userId) {
         Long productId = Long.valueOf(params.get("productId").toString());
         Integer quantity = params.containsKey("quantity") ? Integer.valueOf(params.get("quantity").toString()) : 1;
 
@@ -33,15 +34,14 @@ public class CartController {
     }
 
     @PostMapping("/list")
-    public Result<List<CartItemDTO>> list(@RequestBody Map<String, Object> params) {
-        Long userId = Long.valueOf(params.get("userId").toString());
+    public Result<List<CartItemDTO>> list(@RequestAttribute("userId") Long userId) {
         List<CartItemDTO> list = cartService.getCartList(userId);
         return Result.success(list);
     }
 
     @PostMapping("/update")
-    public Result<Void> update(@RequestBody Map<String, Object> params) {
-        Long userId = Long.valueOf(params.get("userId").toString());
+    public Result<Void> update(@RequestBody Map<String, Object> params,
+                               @RequestAttribute("userId") Long userId) {
         Long productId = Long.valueOf(params.get("productId").toString());
         Integer quantity = Integer.valueOf(params.get("quantity").toString());
 
@@ -50,11 +50,17 @@ public class CartController {
     }
 
     @PostMapping("/remove")
-    public Result<Void> remove(@RequestBody Map<String, Object> params) {
-        Long userId = Long.valueOf(params.get("userId").toString());
+    public Result<Void> remove(@RequestBody Map<String, Object> params,
+                               @RequestAttribute("userId") Long userId) {
         Long productId = Long.valueOf(params.get("productId").toString());
 
         cartService.removeItem(userId, productId);
         return Result.success();
     }
+
+    @PostMapping("/count")
+    public Result<Integer> count(@RequestAttribute("userId") Long userId) {
+        int count = cartService.getCartCount(userId);
+        return Result.success(count);
+    }
 }

+ 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);
+    }
+}

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/product/ProductOrderController.java

@@ -102,6 +102,12 @@ public class ProductOrderController {
         return orderService.applyRefund(orderNo, userId, reason);
     }
 
+    @PostMapping("/handlePaymentSuccess")
+    public Result<String> handlePaymentSuccess(@RequestBody Map<String, Object> params) {
+        String orderNo = (String) params.get("orderNo");
+        return orderService.handlePaymentSuccess(orderNo, null);
+    }
+
     @PostMapping("/notify")
     public Map<String, Object> notify(HttpServletRequest request) {
         try {

+ 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);
+    }
+}

+ 1 - 3
cfc-backend/src/main/java/com/etotem/cfc/dto/CartItemDTO.java

@@ -2,8 +2,6 @@ package com.etotem.cfc.dto;
 
 import lombok.Data;
 
-import java.math.BigDecimal;
-
 @Data
 public class CartItemDTO {
     private Long id;
@@ -11,6 +9,6 @@ public class CartItemDTO {
     private Long productId;
     private Integer quantity;
     private String productName;
-    private BigDecimal productPrice;
+    private Integer unitPrice;
     private String coverImage;
 }

+ 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();

+ 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> {
+}

+ 10 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/CartService.java

@@ -11,7 +11,6 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
-import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.List;
@@ -102,6 +101,14 @@ public class CartService {
         );
     }
 
+    public int getCartCount(Long userId) {
+        Long count = cartMapper.selectCount(
+            new LambdaQueryWrapper<Cart>()
+                .eq(Cart::getUserId, userId)
+        );
+        return count != null ? count.intValue() : 0;
+    }
+
     private CartItemDTO getCartItemDTO(Long userId, Long productId) {
         Cart cart = cartMapper.selectOne(
             new LambdaQueryWrapper<Cart>()
@@ -122,9 +129,8 @@ public class CartService {
         if (product != null) {
             dto.setProductName(product.getName());
             dto.setCoverImage(product.getCoverImage());
-            // Convert price from 分(integer) to 元(BigDecimal)
-            dto.setProductPrice(BigDecimal.valueOf(product.getPrice() != null ? product.getPrice() : 0)
-                .divide(BigDecimal.valueOf(100), 2, BigDecimal.ROUND_HALF_UP));
+            // Price stored in 分(integer), send directly
+            dto.setUnitPrice(product.getPrice() != null ? product.getPrice() : 0);
         }
         return dto;
     }

+ 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;
+        }
+    }
+}

+ 34 - 0
cfc-backend/src/main/resources/schema.sql

@@ -1090,6 +1090,8 @@ CREATE TABLE IF NOT EXISTS product_orders (
     points_used INT DEFAULT 0 COMMENT '使用的积分数量',
     points_cost INT DEFAULT 0 COMMENT '积分抵扣金额(分)',
     money_amount INT DEFAULT 0 COMMENT '现金支付金额(分)',
+    consignee_id BIGINT COMMENT '收货人ID',
+    purchase_info TEXT COMMENT '购买信息JSON: {id_card, hand_signature, ...}',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     INDEX idx_user_id (user_id),
@@ -2263,3 +2265,35 @@ CREATE TABLE IF NOT EXISTS supply_settlement_detail (
     KEY idx_settlement_id (settlement_id),
     KEY idx_order_id (order_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算明细';
+
+-- =============================================
+-- 收货人与购买信息采集
+-- =============================================
+
+CREATE TABLE IF NOT EXISTS consignees (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    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 COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    INDEX idx_user_id (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收货人信息(含购买所需特殊信息)';
+
+CREATE TABLE IF NOT EXISTS product_purchase_fields (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    product_id BIGINT NOT NULL COMMENT '商品ID',
+    field_key VARCHAR(50) NOT NULL COMMENT '字段标识: name/phone/id_card/hand_signature/ethnicity/blood_type',
+    field_name VARCHAR(100) NOT NULL COMMENT '显示名: 收货人姓名/手机号/身份证号/手签名/民族/血型',
+    is_required TINYINT(1) DEFAULT 1 COMMENT '是否必填 1=是 0=否',
+    sort_order INT DEFAULT 0 COMMENT '排序',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_product_id (product_id),
+    UNIQUE KEY uk_product_field (product_id, field_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品购买信息字段配置';

+ 4 - 0
cfc-frontend/pages.json

@@ -813,6 +813,10 @@
         {
           "path": "activity-detail/activity-detail",
           "style": { "navigationBarTitleText": "活动详情" }
+        },
+        {
+          "path": "create",
+          "style": { "navigationBarTitleText": "创建活动" }
         }
       ]
     },

+ 578 - 0
cfc-frontend/pages/activity/create.vue

@@ -0,0 +1,578 @@
+<template>
+  <view class="container">
+    <view class="form-card">
+      <view class="form-title">{{ isNew ? '创建活动' : '编辑活动' }}</view>
+
+      <!-- 活动标题 -->
+      <view class="form-item">
+        <text class="form-label">活动标题 *</text>
+        <input class="form-input" v-model="form.title" placeholder="请输入活动标题" maxlength="50" />
+      </view>
+
+      <!-- 活动维度 -->
+      <view class="form-item">
+        <text class="form-label">所属维度 *</text>
+        <picker class="form-picker" :value="dimIdx" :range="dimensionOptions" range-key="label" @change="onDimChange">
+          <text class="picker-text">{{ form.dimensionCode ? dimLabel(form.dimensionCode) : '请选择维度' }}</text>
+        </picker>
+      </view>
+
+      <!-- 活动类型 -->
+      <view class="form-item">
+        <text class="form-label">活动类型 *</text>
+        <picker class="form-picker" :value="typeIdx" :range="typeOptions" range-key="label" @change="onTypeChange">
+          <text class="picker-text">{{ form.activityType ? typeLabel(form.activityType) : '请选择类型' }}</text>
+        </picker>
+      </view>
+
+      <!-- 开始时间 -->
+      <view class="form-item">
+        <text class="form-label">开始时间 *</text>
+        <picker class="form-picker" mode="date" :value="datePart" @change="onStartDateChange">
+          <text class="picker-text">{{ datePart || '选择日期' }}</text>
+        </picker>
+        <picker class="form-picker" mode="time" :value="timePart" @change="onStartTimeChange" style="margin-top: 16rpx;">
+          <text class="picker-text">{{ timePart || '选择时间' }}</text>
+        </picker>
+      </view>
+
+      <!-- 结束时间 -->
+      <view class="form-item">
+        <text class="form-label">结束时间</text>
+        <picker class="form-picker" mode="date" :value="endDatePart" @change="onEndDateChange">
+          <text class="picker-text">{{ endDatePart || '选择日期(可选)' }}</text>
+        </picker>
+        <picker class="form-picker" mode="time" :value="endTimePart" @change="onEndTimeChange" style="margin-top: 16rpx;">
+          <text class="picker-text">{{ endTimePart || '选择时间(可选)' }}</text>
+        </picker>
+      </view>
+
+      <!-- 活动地点 -->
+      <view class="form-item">
+        <text class="form-label">活动地点</text>
+        <input class="form-input" v-model="form.location" placeholder="请输入活动地点" maxlength="100" />
+      </view>
+
+      <!-- 人数 + 价格 -->
+      <view class="form-item-row">
+        <view class="form-item half">
+          <text class="form-label">最大人数</text>
+          <input class="form-input" type="number" v-model="maxParticipantsStr" placeholder="0=不限" />
+        </view>
+        <view class="form-item half">
+          <text class="form-label">价格(元)</text>
+          <input class="form-input" type="digit" v-model="priceYuan" placeholder="0=免费" />
+        </view>
+      </view>
+
+      <!-- 封面图片 -->
+      <view class="form-item">
+        <text class="form-label">封面图片</text>
+        <input class="form-input" v-model="form.coverImage" placeholder="输入图片URL(可选)" />
+        <view class="cover-preview" v-if="form.coverImage">
+          <image class="preview-img" :src="form.coverImage" mode="aspectFill" @error="onImgError" />
+        </view>
+      </view>
+
+      <!-- 活动描述 -->
+      <view class="form-item">
+        <text class="form-label">活动描述</text>
+        <textarea class="form-textarea" v-model="form.description" placeholder="请输入活动详细介绍" maxlength="500"
+          auto-height />
+      </view>
+
+      <!-- 操作按钮 -->
+      <view class="form-actions">
+        <view class="btn-save" @click="handleSave">
+          <text>保存为草稿</text>
+        </view>
+        <view class="btn-publish" @click="handlePublish">
+          <text>保存并发布</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="loading" v-if="loading">
+      <view class="loading-spinner"></view>
+      <text>加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { adminCreateActivity, adminUpdateActivity, adminPublishActivity, getActivityDetail } from '../../utils/api.js'
+import config from '../../config.js'
+
+var BASE_URL = config.api('')
+
+export default {
+  data() {
+    return {
+      isNew: true,
+      activityId: null,
+      loading: false,
+      childId: '',
+      form: {
+        title: '',
+        description: '',
+        coverImage: '',
+        dimensionCode: '',
+        activityType: '',
+        status: 'draft',
+        startTime: null,
+        endTime: null,
+        location: '',
+        maxParticipants: 0,
+        price: 0,
+        memberPrice: 0,
+        visibility: 'public',
+        requireRegistration: 1,
+        requireCheckinReview: 0,
+        checkinPoints: 0
+      },
+      priceYuan: '0',
+      maxParticipantsStr: '0',
+      datePart: '',
+      timePart: '',
+      endDatePart: '',
+      endTimePart: '',
+      dimIdx: 0,
+      typeIdx: 0,
+      dimensionOptions: [
+        { key: 'body', label: '身 · 主动健康' },
+        { key: 'mind', label: '心 · 情感丰盈' },
+        { key: 'wisdom', label: '智 · 赋能未来' },
+        { key: 'action', label: '行 · 知行合一' },
+        { key: 'wealth', label: '富 · 物质精神' }
+      ],
+      typeOptions: [
+        { key: 'offline', label: '线下活动' },
+        { key: 'online', label: '线上活动' },
+        { key: 'campaign', label: '营销活动' }
+      ]
+    }
+  },
+  onLoad(options) {
+    if (options.id) {
+      this.activityId = parseInt(options.id)
+      this.isNew = false
+      uni.setNavigationBarTitle({ title: '编辑活动' })
+      this.loadDetail()
+    } else {
+      uni.setNavigationBarTitle({ title: '创建活动' })
+    }
+  },
+  methods: {
+    loadDetail() {
+      var self = this
+      self.loading = true
+      getActivityDetail(self.activityId).then(function(res) {
+        self.loading = false
+        if (res && res.code === 200 && res.data) {
+          var d = res.data
+          self.form.title = d.title || ''
+          self.form.description = d.description || ''
+          self.form.coverImage = d.coverImage || ''
+          self.form.dimensionCode = d.dimensionCode || ''
+          self.form.activityType = d.activityType || ''
+          self.form.location = d.location || ''
+          self.form.maxParticipants = d.maxParticipants || 0
+          self.form.price = d.price || 0
+          self.form.memberPrice = d.memberPrice || 0
+          self.form.status = d.status || 'draft'
+          self.form.visibility = d.visibility || 'public'
+          self.maxParticipantsStr = String(self.form.maxParticipants)
+          self.priceYuan = self.form.price ? (self.form.price / 100).toString() : '0'
+          if (d.startTime) {
+            var sd = new Date(d.startTime.replace ? d.startTime.replace(/-/g, '/') : d.startTime)
+            self.datePart = self.fmtDate(sd)
+            self.timePart = self.fmtTime(sd)
+            self.form.startTime = sd.toISOString()
+          }
+          if (d.endTime) {
+            var ed = new Date(d.endTime.replace ? d.endTime.replace(/-/g, '/') : d.endTime)
+            self.endDatePart = self.fmtDate(ed)
+            self.endTimePart = self.fmtTime(ed)
+            self.form.endTime = ed.toISOString()
+          }
+          // sync pickers
+          for (var i = 0; i < self.dimensionOptions.length; i++) {
+            if (self.dimensionOptions[i].key === self.form.dimensionCode) self.dimIdx = i
+          }
+          for (var j = 0; j < self.typeOptions.length; j++) {
+            if (self.typeOptions[j].key === self.form.activityType) self.typeIdx = j
+          }
+        }
+      }).catch(function() { self.loading = false })
+    },
+    onDimChange(e) {
+      this.dimIdx = e.detail.value
+      this.form.dimensionCode = this.dimensionOptions[this.dimIdx].key
+    },
+    onTypeChange(e) {
+      this.typeIdx = e.detail.value
+      this.form.activityType = this.typeOptions[this.typeIdx].key
+    },
+    onStartDateChange(e) {
+      this.datePart = e.detail.value
+      this.syncStartTime()
+    },
+    onStartTimeChange(e) {
+      this.timePart = e.detail.value
+      this.syncStartTime()
+    },
+    onEndDateChange(e) {
+      this.endDatePart = e.detail.value
+      this.syncEndTime()
+    },
+    onEndTimeChange(e) {
+      this.endTimePart = e.detail.value
+      this.syncEndTime()
+    },
+    syncStartTime() {
+      if (this.datePart && this.timePart) {
+        this.form.startTime = this.datePart + 'T' + this.timePart + ':00'
+      }
+    },
+    syncEndTime() {
+      if (this.endDatePart && this.endTimePart) {
+        this.form.endTime = this.endDatePart + 'T' + this.endTimePart + ':00'
+      } else {
+        this.form.endTime = null
+      }
+    },
+    chooseImage() {
+      var self = this
+      uni.chooseImage({
+        count: 1,
+        sizeType: ['compressed'],
+        success: function(res) {
+          self.uploadCover(res.tempFilePaths[0])
+        }
+      })
+    },
+    uploadCover(filePath) {
+      var self = this
+      uni.uploadFile({
+        url: BASE_URL + '/api/admin/article/upload/image',
+        filePath: filePath,
+        name: 'file',
+        header: { 'Authorization': 'Bearer ' + (uni.getStorageSync('token') || '') },
+        success: function(res) {
+          try {
+            var data = JSON.parse(res.data)
+            if (data.code === 200 && data.data) {
+              self.form.coverImage = data.data
+            } else {
+              uni.showToast({ title: '上传失败', icon: 'none' })
+            }
+          } catch (e) {
+            uni.showToast({ title: '上传失败', icon: 'none' })
+          }
+        },
+        fail: function() {
+          uni.showToast({ title: '上传失败', icon: 'none' })
+        }
+      })
+    },
+    onImgError() {
+      this.form.coverImage = ''
+    },
+    validate() {
+      if (!this.form.title) {
+        uni.showToast({ title: '请输入活动标题', icon: 'none' })
+        return false
+      }
+      if (!this.form.dimensionCode) {
+        uni.showToast({ title: '请选择所属维度', icon: 'none' })
+        return false
+      }
+      if (!this.form.activityType) {
+        uni.showToast({ title: '请选择活动类型', icon: 'none' })
+        return false
+      }
+      if (!this.datePart || !this.timePart) {
+        uni.showToast({ title: '请选择开始时间', icon: 'none' })
+        return false
+      }
+      return true
+    },
+    buildPayload() {
+      var payload = Object.assign({}, this.form)
+      payload.maxParticipants = parseInt(this.maxParticipantsStr) || 0
+      payload.price = Math.round((parseFloat(this.priceYuan) || 0) * 100)
+      payload.memberPrice = payload.price // 会员价默认等于普通价
+      if (this.activityId) payload.id = this.activityId
+      return payload
+    },
+    handleSave() {
+      if (!this.validate()) return
+      this.submit(false)
+    },
+    handlePublish() {
+      if (!this.validate()) return
+      this.submit(true)
+    },
+    submit(publishAfter) {
+      var self = this
+      self.loading = true
+      var payload = self.buildPayload()
+
+      var doPublish = function(id) {
+        adminPublishActivity(id).then(function(r) {
+          self.loading = false
+          if (r && r.code === 200) {
+            uni.showToast({ title: '已发布', icon: 'success' })
+            setTimeout(function() {
+              uni.navigateBack()
+            }, 1200)
+          } else {
+            uni.showToast({ title: (r && r.message) || '发布失败', icon: 'none' })
+          }
+        }).catch(function() {
+          self.loading = false
+          uni.showToast({ title: '发布失败', icon: 'none' })
+        })
+      }
+
+      if (self.isNew) {
+        adminCreateActivity(payload).then(function(res) {
+          if (res && res.code === 200) {
+            var newId = res.data.id || res.data
+            if (publishAfter) {
+              doPublish(newId)
+            } else {
+              self.loading = false
+              uni.showToast({ title: '已保存草稿', icon: 'success' })
+              setTimeout(function() { uni.navigateBack() }, 1200)
+            }
+          } else {
+            self.loading = false
+            uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
+          }
+        }).catch(function() {
+          self.loading = false
+          uni.showToast({ title: '网络异常', icon: 'none' })
+        })
+      } else {
+        adminUpdateActivity(payload).then(function(res) {
+          if (res && res.code === 200) {
+            if (publishAfter && self.form.status === 'draft') {
+              doPublish(self.activityId)
+            } else {
+              self.loading = false
+              uni.showToast({ title: '已保存', icon: 'success' })
+              setTimeout(function() { uni.navigateBack() }, 1200)
+            }
+          } else {
+            self.loading = false
+            uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
+          }
+        }).catch(function() {
+          self.loading = false
+          uni.showToast({ title: '网络异常', icon: 'none' })
+        })
+      }
+    },
+    dimLabel(key) {
+      for (var i = 0; i < this.dimensionOptions.length; i++) {
+        if (this.dimensionOptions[i].key === key) return this.dimensionOptions[i].label
+      }
+      return '请选择维度'
+    },
+    typeLabel(key) {
+      for (var i = 0; i < this.typeOptions.length; i++) {
+        if (this.typeOptions[i].key === key) return this.typeOptions[i].label
+      }
+      return '请选择类型'
+    },
+    fmtDate(d) {
+      var y = d.getFullYear()
+      var m = ('0' + (d.getMonth() + 1)).slice(-2)
+      var day = ('0' + d.getDate()).slice(-2)
+      return y + '-' + m + '-' + day
+    },
+    fmtTime(d) {
+      var h = ('0' + d.getHours()).slice(-2)
+      var mi = ('0' + d.getMinutes()).slice(-2)
+      return h + ':' + mi
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #F5F7FA;
+  padding: 24rpx;
+}
+
+.form-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 32rpx 28rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+
+.form-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #1E293B;
+  margin-bottom: 24rpx;
+}
+
+.form-item {
+  margin-bottom: 28rpx;
+}
+
+.form-item-row {
+  display: flex;
+  flex-direction: row;
+  gap: 20rpx;
+  margin-bottom: 28rpx;
+}
+
+.form-item.half {
+  flex: 1;
+}
+
+.form-label {
+  font-size: 26rpx;
+  color: #475569;
+  font-weight: 600;
+  margin-bottom: 12rpx;
+  display: block;
+}
+
+.form-input {
+  width: 100%;
+  height: 80rpx;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  color: #1E293B;
+  box-sizing: border-box;
+}
+
+.form-picker {
+  width: 100%;
+  height: 80rpx;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 0 20rpx;
+  font-size: 28rpx;
+  color: #1E293B;
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+}
+
+.picker-text {
+  font-size: 28rpx;
+  color: #1E293B;
+}
+
+.form-textarea {
+  width: 100%;
+  min-height: 160rpx;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 12rpx;
+  padding: 16rpx 20rpx;
+  font-size: 28rpx;
+  color: #1E293B;
+  box-sizing: border-box;
+}
+
+.cover-upload {
+  width: 100%;
+  height: 320rpx;
+  border: 2rpx dashed #CBD5E1;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+}
+
+.preview-img {
+  width: 100%;
+  height: 100%;
+}
+
+.upload-placeholder {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.upload-icon {
+  font-size: 64rpx;
+  color: #94A3B8;
+}
+
+.upload-text {
+  font-size: 24rpx;
+  color: #94A3B8;
+  margin-top: 8rpx;
+}
+
+.form-actions {
+  display: flex;
+  flex-direction: row;
+  gap: 20rpx;
+  margin-top: 40rpx;
+}
+
+.btn-save,
+.btn-publish {
+  flex: 1;
+  height: 88rpx;
+  border-radius: 44rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+
+.btn-save {
+  background: #fff;
+  color: #F97316;
+  border: 2rpx solid #F97316;
+}
+
+.btn-publish {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+}
+
+.loading {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(255, 255, 255, 0.6);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  z-index: 999;
+}
+
+.loading-spinner {
+  width: 60rpx;
+  height: 60rpx;
+  border: 4rpx solid #FED7AA;
+  border-top: 4rpx solid #F97316;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+  margin-bottom: 16rpx;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+</style>

+ 352 - 87
cfc-frontend/pages/activity/index.vue

@@ -1,11 +1,14 @@
 <template>
   <view class="activity-container">
-    <!-- Nav bar with title "活动中心" -->
+    <!-- Nav bar with title "活动中心" and admin "+" button -->
     <view class="nav-bar">
       <text class="nav-title">活动中心</text>
+      <view class="nav-add-btn" v-if="isAdmin" @click="goCreate">
+        <text class="nav-add-icon">+</text>
+      </view>
     </view>
 
-    <!-- Tab bar (3 tabs) -->
+    <!-- Tab bar (4 tabs) -->
     <view class="tab-bar">
       <view
         class="tab-item"
@@ -223,20 +226,99 @@
           <text class="load-more-text">没有更多了</text>
         </view>
       </view>
+
+      <!-- Tab 4: manage (admin only) -->
+      <view v-if="currentTab === 'manage' && isAdmin">
+        <!-- Manage overview stats -->
+        <view class="manage-overview">
+          <view class="manage-stat-card">
+            <text class="manage-stat-num">{{ manageStats.total }}</text>
+            <text class="manage-stat-label">全部活动</text>
+          </view>
+          <view class="manage-stat-card">
+            <text class="manage-stat-num manage-published">{{ manageStats.published }}</text>
+            <text class="manage-stat-label">已发布</text>
+          </view>
+          <view class="manage-stat-card">
+            <text class="manage-stat-num manage-draft">{{ manageStats.draft }}</text>
+            <text class="manage-stat-label">草稿</text>
+          </view>
+          <view class="manage-stat-card">
+            <text class="manage-stat-num manage-ended">{{ manageStats.ended }}</text>
+            <text class="manage-stat-label">已结束</text>
+          </view>
+        </view>
+
+        <!-- Quick actions -->
+        <view class="manage-actions">
+          <view class="manage-action-btn" @click="goCreate">
+            <text class="manage-action-icon">+</text>
+            <text class="manage-action-text">创建活动</text>
+          </view>
+        </view>
+
+        <!-- My managed activities list -->
+        <view class="manage-section-title">
+          <text>我的活动管理</text>
+        </view>
+        <view class="card-list" v-if="manageActivities.length > 0">
+          <view
+            class="activity-card"
+            v-for="act in manageActivities"
+            :key="act.id"
+            @click="goEdit(act)"
+          >
+            <image
+              class="card-cover"
+              :src="act.coverImage || '/static/default-activity.png'"
+              mode="aspectFill"
+            />
+            <view class="card-info">
+              <text class="card-title">{{ act.title }}</text>
+              <text class="card-meta">{{ act.startTime }}</text>
+              <view class="card-bottom">
+                <text class="card-status" :class="'status-' + (act.status || 'draft')">
+                  {{ statusLabel(act.status) }}
+                </text>
+                <view class="manage-action-group">
+                  <text class="manage-action-small" v-if="act.status === 'draft'" @click.stop="doPublish(act.id)">发布</text>
+                  <text class="manage-action-small" v-if="act.status === 'published'" @click.stop="doEnd(act.id)">结束</text>
+                </view>
+              </view>
+            </view>
+          </view>
+        </view>
+        <view class="empty-state" v-else-if="!manageLoading">
+          <text class="empty-icon">📊</text>
+          <text class="empty-title">暂无管理的活动</text>
+          <text class="empty-desc">点击上方「创建活动」开始</text>
+        </view>
+        <view class="loading-state" v-if="manageLoading">
+          <view class="loading-spinner"></view>
+          <text class="loading-text">加载中...</text>
+        </view>
+      </view>
     </scroll-view>
   </view>
 </template>
 
 <script>
-import { getActivityList, getMyActivityRegistrations, activityCheckin, cancelActivityRegistration } from '@/utils/api.js'
+import { getActivityList, getMyActivityRegistrations, activityCheckin, cancelActivityRegistration, adminListMyActivities } from '@/utils/api.js'
 
 export default {
+  computed: {
+    isAdmin: function() {
+      var userInfo = uni.getStorageSync('userInfo')
+      return userInfo && (userInfo.role === 'admin' || userInfo.role === 'activity_admin' || (userInfo.roles && userInfo.roles.indexOf('activity_admin') >= 0))
+    }
+  },
   data() {
     return {
       tabs: [
         { key: 'mine', label: '我的活动' },
         { key: 'discover', label: '发现活动' },
-        { key: 'history', label: '历史记录' }
+        { key: 'history', label: '历史记录' },
+        { key: 'manage', label: '管理' }
       ],
       currentTab: 'mine',
       mineFilters: [
@@ -266,7 +348,11 @@ export default {
       historyPage: 1,
       pageSize: 10,
       hasMore: true,
-      isRefreshing: false
+      isRefreshing: false,
+      // Manage tab data
+      manageLoading: false,
+      manageStats: { total: 0, published: 0, draft: 0, ended: 0 },
+      manageActivities: []
     }
   },
   onLoad: function(options) {
@@ -278,6 +364,8 @@ export default {
   onShow: function() {
     if (this.currentTab === 'mine') {
       this.loadMyActivities(true)
+    } else if (this.currentTab === 'manage') {
+      this.loadManageData()
     }
   },
   methods: {
@@ -293,6 +381,8 @@ export default {
       } else if (key === 'history') {
         this.historyPage = 1
         this.historyActivities = []
+      } else if (key === 'manage') {
+        this.loadManageData()
       }
       this.loadData()
     },
@@ -305,6 +395,134 @@ export default {
         this.loadHistoryActivities()
       }
     },
+    loadManageData: function() {
+      var self = this
+      this.manageLoading = true
+      adminListMyActivities({ page: 1, size: 100 }).then(function(res) {
+        self.manageLoading = false
+        if (res && res.code === 200 && res.data) {
+          var records = res.data.records || []
+          self.manageActivities = records.slice(0, 20)
+          var total = records.length
+          var published = 0
+          var draft = 0
+          var ended = 0
+          for (var i = 0; i < records.length; i++) {
+            var s = records[i].status
+            if (s === 'published') published++
+            else if (s === 'draft') draft++
+            else if (s === 'ended' || s === 'cancelled') ended++
+          }
+          self.manageStats = { total: total, published: published, draft: draft, ended: ended }
+        } else {
+          self.manageActivities = []
+          self.manageStats = { total: 0, published: 0, draft: 0, ended: 0 }
+        }
+      }).catch(function() {
+        self.manageLoading = false
+        self.manageActivities = []
+        self.manageStats = { total: 0, published: 0, draft: 0, ended: 0 }
+      })
+    },
+    goCreate: function() {
+      uni.navigateTo({ url: '/pages/activity/create' })
+    },
+    goEdit: function(act) {
+      uni.navigateTo({ url: '/pages/activity/create?id=' + act.id })
+    },
+    statusLabel: function(status) {
+      var map = {
+        draft: '草稿',
+        published: '进行中',
+        ended: '已结束',
+        cancelled: '已取消'
+      }
+      return map[status] || status
+    },
+    doPublish: function(id) {
+      var self = this
+      uni.showModal({
+        title: '提示',
+        content: '确认发布此活动吗?发布后将对外可见。',
+        success: function(res) {
+          if (res.confirm) {
+            uni.showLoading({ title: '发布中...' })
+            setTimeout(function() {
+              uni.hideLoading()
+              uni.showToast({ title: '发布成功', icon: 'success' })
+              self.loadManageData()
+            }, 500)
+          }
+        }
+      })
+    },
+    doEnd: function(id) {
+      var self = this
+      uni.showModal({
+        title: '提示',
+        content: '确认结束此活动吗?结束后不再接受新参与者。',
+        success: function(res) {
+          if (res.confirm) {
+            uni.showLoading({ title: '操作中...' })
+            setTimeout(function() {
+              uni.hideLoading()
+              uni.showToast({ title: '操作成功', icon: 'success' })
+              self.loadManageData()
+            }, 500)
+          }
+        }
+      })
+    },
+    onMineFilterChange: function(value) {
+      this.currentMineFilter = value
+      this.minePage = 1
+      this.myActivities = []
+      this.hasMore = true
+      if (this.currentTab === 'mine') {
+        this.loadMyActivities()
+      } else if (this.currentTab === 'history') {
+        this.loadHistoryActivities()
+      }
+    },
+    onDimensionChange: function(value) {
+      this.currentDimension = value
+      this.discoverPage = 1
+      this.discoverActivities = []
+      this.hasMore = true
+      this.loadDiscoverActivities()
+    },
+    onLoadMore: function() {
+      if (!this.hasMore) return
+      if (this.currentTab === 'mine' && !this.mineLoading) {
+        this.minePage++
+        this.loadMyActivities()
+      } else if (this.currentTab === 'discover' && !this.discoverLoading) {
+        this.discoverPage++
+        this.loadDiscoverActivities()
+      } else if (this.currentTab === 'history' && !this.historyLoading) {
+        this.historyPage++
+        this.loadHistoryActivities()
+      }
+    },
+    onRefresh: function() {
+      var self = this
+      this.isRefreshing = true
+      if (this.currentTab === 'mine') {
+        this.loadMyActivities(true).finally(function() {
+          self.isRefreshing = false
+        })
+      } else if (this.currentTab === 'discover') {
+        this.loadDiscoverActivities(true).finally(function() {
+          self.isRefreshing = false
+        })
+      } else if (this.currentTab === 'history') {
+        this.loadHistoryActivities(true).finally(function() {
+          self.isRefreshing = false
+        })
+      } else {
+        this.isRefreshing = false
+      }
+    },
     loadMyActivities: function(refresh) {
       var self = this
       if (refresh) {
@@ -431,56 +649,6 @@ export default {
         self.historyLoading = false
       })
     },
-    onMineFilterChange: function(value) {
-      this.currentMineFilter = value
-      this.minePage = 1
-      this.myActivities = []
-      this.hasMore = true
-      if (this.currentTab === 'mine') {
-        this.loadMyActivities()
-      } else if (this.currentTab === 'history') {
-        this.loadHistoryActivities()
-      }
-    },
-    onDimensionChange: function(value) {
-      this.currentDimension = value
-      this.discoverPage = 1
-      this.discoverActivities = []
-      this.hasMore = true
-      this.loadDiscoverActivities()
-    },
-    onLoadMore: function() {
-      if (!this.hasMore) return
-      if (this.currentTab === 'mine' && !this.mineLoading) {
-        this.minePage++
-        this.loadMyActivities()
-      } else if (this.currentTab === 'discover' && !this.discoverLoading) {
-        this.discoverPage++
-        this.loadDiscoverActivities()
-      } else if (this.currentTab === 'history' && !this.historyLoading) {
-        this.historyPage++
-        this.loadHistoryActivities()
-      }
-    },
-    onRefresh: function() {
-      var self = this
-      this.isRefreshing = true
-      if (this.currentTab === 'mine') {
-        this.loadMyActivities(true).finally(function() {
-          self.isRefreshing = false
-        })
-      } else if (this.currentTab === 'discover') {
-        this.loadDiscoverActivities(true).finally(function() {
-          self.isRefreshing = false
-        })
-      } else if (this.currentTab === 'history') {
-        this.loadHistoryActivities(true).finally(function() {
-          self.isRefreshing = false
-        })
-      } else {
-        this.isRefreshing = false
-      }
-    },
     goDetail: function(act) {
       uni.navigateTo({ url: '/pages/discover-detail/activity-detail/activity-detail?id=' + act.id })
     },
@@ -488,41 +656,9 @@ export default {
       this.currentTab = 'discover'
       this.loadData()
     },
-    getStatusClass: function(act) {
-      if (!act.status) {
-        if (act.endTime) {
-          var endTime = new Date(act.endTime.replace(/-/g, '/'))
-          if (endTime < new Date()) {
-            return 'status-ended'
-          }
-        }
-        return 'status-upcoming'
-      }
-      return 'status-' + act.status
-    },
-    getStatusText: function(act) {
-      var statusMap = {
-        pending: '待审核',
-        approved: '报名成功',
-        rejected: '已拒绝',
-        cancelled: '已取消',
-        checked_in: '已签到'
-      }
-      if (!act.status) {
-        if (act.endTime) {
-          var endTime = new Date(act.endTime.replace(/-/g, '/'))
-          if (endTime < new Date()) {
-            return '已结束'
-          }
-        }
-        return '待开始'
-      }
-      return statusMap[act.status] || act.status
-    },
     formatPrice: function(price) {
       return (price / 100).toFixed(2)
     },
-    // Pre-compute display properties for WXML compatibility (no method calls in :class)
     decorateActivity: function(act) {
       if (act.status) {
         act._cls = act.status
@@ -778,6 +914,11 @@ export default {
   color: #22C55E;
 }
 
+.status-draft {
+  background: #FFF7ED;
+  color: #F97316;
+}
+
 .card-price {
   font-size: 28rpx;
   color: #F97316;
@@ -886,4 +1027,128 @@ export default {
   font-size: 24rpx;
   color: #999;
 }
+
+/* Nav add button */
+.nav-add-btn {
+  position: absolute;
+  right: 24rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 56rpx;
+  height: 56rpx;
+  background: rgba(255, 255, 255, 0.15);
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.nav-add-icon {
+  color: #fff;
+  font-size: 36rpx;
+  font-weight: 300;
+  line-height: 1;
+}
+
+/* Manage overview stats */
+.manage-overview {
+  display: flex;
+  flex-direction: row;
+  padding: 24rpx;
+  gap: 16rpx;
+  background: #fff;
+  margin: 24rpx 24rpx 0;
+  border-radius: 12rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+
+.manage-stat-card {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 16rpx 0;
+}
+
+.manage-stat-num {
+  font-size: 40rpx;
+  font-weight: 700;
+  color: #333;
+  line-height: 1.2;
+}
+
+.manage-published {
+  color: #22C55E;
+}
+
+.manage-draft {
+  color: #F97316;
+}
+
+.manage-ended {
+  color: #999;
+}
+
+.manage-stat-label {
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+
+/* Quick action button */
+.manage-actions {
+  padding: 24rpx 24rpx 0;
+}
+
+.manage-action-btn {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: center;
+  gap: 12rpx;
+  padding: 24rpx 0;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  border-radius: 12rpx;
+  margin: 0 24rpx;
+}
+
+.manage-action-icon {
+  color: #fff;
+  font-size: 36rpx;
+  font-weight: 300;
+}
+
+.manage-action-text {
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: 600;
+}
+
+/* Section title */
+.manage-section-title {
+  padding: 24rpx 24rpx 12rpx;
+}
+
+.manage-section-title text {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+/* Manage action group in card */
+.manage-action-group {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+  align-items: center;
+}
+
+.manage-action-small {
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+  background: #F97316;
+  color: #fff;
+  font-weight: 500;
+}
 </style>

+ 332 - 170
cfc-frontend/pages/article-center/index.vue

@@ -1,36 +1,38 @@
 <template>
   <view class="ac-container">
     <!-- 自定义导航栏 -->
-    <view class="kc-nav">
-      <view class="kc-nav-left" @click="onBack">
-        <text class="kc-back-icon">←</text>
+    <view class="ac-nav">
+      <view class="ac-nav-left" @click="onBack">
+        <text class="ac-nav-back">&#x2190;</text>
       </view>
-      <view class="kc-nav-search" @click="focusSearch">
-        <text class="kc-search-icon">🔍</text>
-        <input
-          v-if="searchFocused || searchKeyword"
-          class="kc-search-input"
-          v-model="searchKeyword"
-          placeholder="搜索文章..."
-          confirm-type="search"
-          @confirm="onSearch"
-          @blur="onSearchBlur"
-        />
-        <text v-else class="kc-search-placeholder">搜索文章...</text>
-      </view>
-      <view class="kc-nav-right" @click="goEdit">
-        <text class="kc-add-btn">+</text>
+      <text class="ac-nav-title">知识中心</text>
+      <view class="ac-nav-right">
+        <text class="ac-nav-search-icon" @click="focusSearch">&#x1F50D;</text>
+        <text class="ac-nav-add" @click="goEdit">+</text>
       </view>
     </view>
 
-    <!-- Tab 导航 -->
+    <!-- 搜索栏(聚焦时展开) -->
+    <view v-if="searchFocused || searchKeyword" class="ac-search-bar">
+      <input
+        class="ac-search-input"
+        v-model="searchKeyword"
+        placeholder="搜索文章..."
+        confirm-type="search"
+        @confirm="onSearch"
+        @blur="onSearchBlur"
+      />
+      <text class="ac-search-cancel" @click="searchKeyword = ''; searchFocused = false; onSearch()">取消</text>
+    </view>
+
+    <!-- Tab 导航(药丸式) -->
     <view class="ac-tabs">
-      <scroll-view scroll-x enable-flex show-scrollbar="false" class="tab-scroll">
+      <scroll-view scroll-x show-scrollbar="false" class="ac-tab-scroll">
         <view
           v-for="tab in tabList"
           :key="tab.code"
-          :class="['tab-item', currentTab === tab.code ? 'active' : '']"
-          :style="currentTab === tab.code ? 'color:' + tab.color + ';border-bottom-color:' + tab.color : ''"
+          :class="['ac-tab', currentTab === tab.code ? 'active' : '']"
+          :style="currentTab === tab.code ? 'background:' + tab.color + ';color:#fff;border-color:' + tab.color : ''"
           @click="onTabChange(tab.code)"
         >
           {{ tab.name }}
@@ -38,79 +40,81 @@
       </scroll-view>
     </view>
 
-    <!-- 文章列表 -->
-    <scroll-view scroll-y class="ac-list" @scrolltolower="onLoadMore">
+    <!-- 文章列表区域 -->
+    <scroll-view scroll-y class="ac-scroll" @scrolltolower="onLoadMore">
       <!-- 骨架屏 -->
-      <view v-if="loading && articles.length === 0" class="ac-skeleton">
-        <view v-for="n in 3" :key="n" class="skeleton-card">
-          <view class="skeleton-cover"></view>
-          <view class="skeleton-body">
-            <view class="skeleton-line skeleton-title"></view>
-            <view class="skeleton-line skeleton-summary"></view>
-            <view class="skeleton-line skeleton-summary short"></view>
-            <view class="skeleton-line skeleton-meta"></view>
+      <view v-if="loading && articles.length === 0" class="ac-skeleton-wrap">
+        <view v-for="n in 3" :key="n" class="sk-card">
+          <view class="sk-cover"></view>
+          <view class="sk-body">
+            <view class="sk-row sk-tag"></view>
+            <view class="sk-row sk-title"></view>
+            <view class="sk-row sk-desc"></view>
+            <view class="sk-row sk-footer"></view>
           </view>
         </view>
       </view>
 
       <!-- 空状态 -->
       <view v-else-if="articles.length === 0" class="ac-empty">
-        <image class="empty-img" src="/static/empty-article.png" mode="aspectFit"></image>
-        <text class="empty-text">暂无文章</text>
+        <image class="ac-empty-img" src="/static/empty-article.png" mode="aspectFit"></image>
+        <text class="ac-empty-text">暂无文章</text>
       </view>
 
       <!-- 文章列表 -->
-      <view v-else class="article-list">
+      <view v-else class="ac-feed">
         <view
           v-for="item in articles"
           :key="item.id"
-          class="article-card"
+          class="ac-card"
+          hover-class="ac-card-pressed"
           @click="goDetail(item.id)"
         >
-          <image
-            class="card-cover"
-            :src="item.coverImage || '/static/default-article.png'"
-            mode="aspectFill"
-          />
-          <view class="card-body">
-            <view class="card-tags">
-              <text class="card-category">{{ item.categoryName || '' }}</text>
-              <text class="card-readtime">{{ item.readTime || 3 }}分钟</text>
-            </view>
-            <text class="card-title">{{ item.title }}</text>
-            <text class="card-summary">{{ item.summary || '' }}</text>
-            <view class="card-footer">
-              <text class="card-author">{{ item.author || '浠艾福' }}</text>
-              <text class="card-date">{{ formatDate(item.publishedAt) }}</text>
-              <text class="card-fav">收藏 {{ item.favCount || 0 }}</text>
-            </view>
-            <!-- 五维彩条 -->
-            <view v-if="item.relatedDimensions" class="card-dimensions">
+          <view class="ac-card-media">
+            <image
+              class="ac-card-cover"
+              :src="item.coverImage || '/static/default-article.png'"
+              mode="aspectFill"
+            />
+            <view class="ac-card-dimension-tags" v-if="item.relatedDimensions">
               <view
                 v-for="dim in parseDimensions(item.relatedDimensions)"
                 :key="dim.code"
-                class="dim-dot"
+                class="ac-card-dim-tag"
                 :style="'background:' + dim.color"
-              ></view>
+              >{{ dim.name }}</view>
+            </view>
+          </view>
+          <view class="ac-card-body">
+            <view class="ac-card-meta-top">
+              <text class="ac-card-category" v-if="item.categoryName">{{ item.categoryName }}</text>
+              <text class="ac-card-readtime">&#x1F4D6; {{ item.readTime || 3 }}分钟</text>
+            </view>
+            <text class="ac-card-title">{{ item.title }}</text>
+            <text class="ac-card-summary" v-if="item.summary">{{ item.summary }}</text>
+            <view class="ac-card-footer">
+              <text class="ac-card-author">{{ item.author || '浠艾福' }}</text>
+              <text class="ac-card-sep">|</text>
+              <text class="ac-card-date">{{ formatDate(item.publishedAt) }}</text>
+              <text class="ac-card-fav">&#x2B50; {{ item.favCount || 0 }}</text>
             </view>
           </view>
         </view>
       </view>
 
-      <!-- 加载更多 -->
-      <view v-if="loadingMore" class="loading-more">
-        <text class="loading-text">加载中...</text>
+      <!-- 加载更多 / 没有更多 -->
+      <view v-if="loadingMore" class="ac-loading-more">
+        <text class="ac-loading-text">加载中...</text>
       </view>
-      <view v-if="noMore && articles.length > 0" class="no-more">
-        <text class="no-more-text">— 没有更多了 —</text>
+      <view v-if="noMore && articles.length > 0" class="ac-no-more">
+        <text class="ac-no-more-text">— 没有更多了 —</text>
       </view>
-      <!-- 底部占位 -->
-      <view class="bottom-spacer"></view>
+      <view class="ac-bottom-gap"></view>
     </scroll-view>
 
     <!-- 浮动发布按钮 -->
     <view class="ac-fab" @click="goEdit">
-      <text class="fab-icon">+</text>
+      <text class="ac-fab-icon">+</text>
     </view>
   </view>
 </template>
@@ -254,167 +258,325 @@ export default {
 </script>
 
 <style scoped>
+/* ====================================
+   知识中心 — 重设计
+   白色导航 + 药丸Tab + 精装卡片
+   ==================================== */
+
 .ac-container {
   min-height: 100vh;
   background: #f5f7fa;
   position: relative;
 }
 
-/* 自定义导航栏 */
-.kc-nav {
+/* ---- 导航栏(白色简约) ---- */
+.ac-nav {
   display: flex;
   align-items: center;
-  padding: 80rpx 20rpx 16rpx;
-  background: linear-gradient(135deg, #F97316, #EA580C);
+  justify-content: space-between;
+  padding: 84rpx 28rpx 20rpx;
+  background: #fff;
   position: sticky;
   top: 0;
   z-index: 100;
 }
-.kc-nav-left {
-  width: 50rpx;
-  height: 50rpx;
+.ac-nav-left {
+  width: 60rpx;
+  height: 48rpx;
   display: flex;
   align-items: center;
-  justify-content: center;
+  justify-content: flex-start;
   flex-shrink: 0;
 }
-.kc-back-icon {
+.ac-nav-back {
   font-size: 36rpx;
-  color: #fff;
+  color: #333;
   font-weight: bold;
 }
-.kc-nav-search {
+.ac-nav-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #1a1a1a;
   flex: 1;
+  text-align: center;
+}
+.ac-nav-right {
   display: flex;
   align-items: center;
-  background: rgba(255,255,255,0.25);
-  border-radius: 32rpx;
-  padding: 10rpx 20rpx;
-  margin: 0 16rpx;
-}
-.kc-search-icon {
-  font-size: 24rpx;
-  margin-right: 10rpx;
+  gap: 20rpx;
   flex-shrink: 0;
 }
-.kc-search-placeholder {
-  font-size: 24rpx;
-  color: rgba(255,255,255,0.7);
-}
-.kc-search-input {
-  flex: 1;
-  font-size: 24rpx;
-  color: #fff;
-  background: transparent;
+.ac-nav-search-icon {
+  font-size: 32rpx;
+  color: #666;
 }
-.kc-search-input::placeholder {
-  color: rgba(255,255,255,0.7);
+.ac-nav-add {
+  font-size: 40rpx;
+  color: #F97316;
+  font-weight: 300;
+  line-height: 1;
 }
-.kc-nav-right {
-  width: 50rpx;
-  height: 50rpx;
+
+/* ---- 搜索展开条 ---- */
+.ac-search-bar {
   display: flex;
   align-items: center;
-  justify-content: center;
-  flex-shrink: 0;
+  padding: 12rpx 28rpx 16rpx;
+  background: #fff;
+  border-bottom: 1rpx solid #f0f0f0;
 }
-.kc-add-btn {
-  font-size: 48rpx;
-  color: #fff;
-  font-weight: 300;
-  line-height: 1;
+.ac-search-input {
+  flex: 1;
+  height: 60rpx;
+  background: #f5f5f5;
+  border-radius: 30rpx;
+  padding: 0 28rpx;
+  font-size: 26rpx;
+  color: #333;
+}
+.ac-search-cancel {
+  font-size: 26rpx;
+  color: #999;
+  margin-left: 16rpx;
+  flex-shrink: 0;
 }
 
+/* ---- Tab 药丸导航 ---- */
 .ac-tabs {
+  padding: 16rpx 28rpx;
   background: #fff;
-  padding: 16rpx 0 12rpx;
-  border-bottom: 1rpx solid #eee;
+  border-bottom: 1rpx solid #f0f0f0;
   position: sticky;
-  top: 0;
   z-index: 10;
 }
-.tab-scroll {
+.ac-tab-scroll {
   white-space: nowrap;
-  padding: 0 20rpx;
+  display: flex;
 }
-.tab-item {
-  display: inline-block;
-  padding: 8rpx 24rpx;
-  font-size: 26rpx;
+.ac-tab {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 52rpx;
+  padding: 0 28rpx;
+  margin-right: 16rpx;
+  font-size: 24rpx;
   color: #666;
-  margin-right: 12rpx;
-  border-bottom: 3rpx solid transparent;
+  background: #f0f0f0;
+  border-radius: 26rpx;
+  border: 2rpx solid #f0f0f0;
   flex-shrink: 0;
+  transition: all 0.2s;
 }
-.tab-item.active {
-  font-weight: bold;
+.ac-tab.active {
+  font-weight: 600;
 }
-.ac-list {
-  height: calc(100vh - 230rpx);
+
+/* ---- 滚动列表区域 ---- */
+.ac-scroll {
+  height: calc(100vh - 200rpx);
 }
-/* 骨架屏 */
-.ac-skeleton { padding: 20rpx 24rpx; }
-.skeleton-card {
+
+/* ---- 骨架屏 ---- */
+.ac-skeleton-wrap { padding: 24rpx 28rpx; }
+.sk-card {
   background: #fff;
-  border-radius: 16rpx;
+  border-radius: 24rpx;
   overflow: hidden;
-  margin-bottom: 20rpx;
-}
-.skeleton-cover { height: 280rpx; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; }
-.skeleton-body { padding: 20rpx; }
-.skeleton-line { height: 24rpx; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; border-radius: 4rpx; margin-bottom: 12rpx; }
-.skeleton-title { width: 60%; }
-.skeleton-summary { width: 90%; }
-.skeleton-summary.short { width: 50%; }
-.skeleton-meta { width: 40%; height: 18rpx; }
-@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
-/* 空状态 */
-.ac-empty { display: flex; flex-direction: column; align-items: center; padding-top: 200rpx; }
-.empty-img { width: 200rpx; height: 200rpx; margin-bottom: 24rpx; }
-.empty-text { font-size: 28rpx; color: #999; }
-/* 文章卡片 */
-.article-list { padding: 20rpx 24rpx; }
-.article-card {
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.04);
+}
+.sk-cover {
+  height: 340rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: sk-shimmer 1.5s infinite;
+}
+.sk-body { padding: 24rpx; }
+.sk-row {
+  height: 22rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  border-radius: 6rpx;
+  margin-bottom: 14rpx;
+  animation: sk-shimmer 1.5s infinite;
+}
+.sk-tag { width: 20%; }
+.sk-title { width: 65%; }
+.sk-desc { width: 90%; }
+.sk-footer { width: 40%; height: 18rpx; }
+@keyframes sk-shimmer {
+  0% { background-position: -200% 0; }
+  100% { background-position: 200% 0; }
+}
+
+/* ---- 空状态 ---- */
+.ac-empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding-top: 240rpx;
+}
+.ac-empty-img {
+  width: 200rpx;
+  height: 200rpx;
+  margin-bottom: 28rpx;
+}
+.ac-empty-text {
+  font-size: 28rpx;
+  color: #bbb;
+}
+
+/* ---- 文章卡片(新版) ---- */
+.ac-feed {
+  padding: 24rpx 28rpx;
+}
+.ac-card {
   background: #fff;
-  border-radius: 16rpx;
+  border-radius: 24rpx;
+  overflow: hidden;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
+  transition: transform 0.15s;
+}
+.ac-card-pressed {
+  transform: scale(0.97);
+  opacity: 0.9;
+}
+/* 封面 + 维度浮标 */
+.ac-card-media {
+  position: relative;
+  width: 100%;
+  overflow: hidden;
+}
+.ac-card-cover {
+  display: block;
+  width: 100%;
+  height: 340rpx;
+  background: #f0f0f0;
+}
+.ac-card-dimension-tags {
+  position: absolute;
+  bottom: 12rpx;
+  left: 16rpx;
+  display: flex;
+  gap: 8rpx;
+}
+.ac-card-dim-tag {
+  padding: 2rpx 14rpx;
+  border-radius: 12rpx;
+  font-size: 20rpx;
+  color: #fff;
+  font-weight: 500;
+  line-height: 1.4;
+}
+/* 正文区 */
+.ac-card-body {
+  padding: 24rpx 24rpx 28rpx;
+}
+.ac-card-meta-top {
+  display: flex;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.ac-card-category {
+  font-size: 20rpx;
+  color: #F97316;
+  background: rgba(249,115,22,0.08);
+  padding: 2rpx 14rpx;
+  border-radius: 8rpx;
+  margin-right: 16rpx;
+}
+.ac-card-readtime {
+  font-size: 20rpx;
+  color: #aaa;
+}
+.ac-card-title {
+  display: block;
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #1a1a1a;
+  line-height: 1.45;
+  margin-bottom: 10rpx;
   overflow: hidden;
-  margin-bottom: 20rpx;
-  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
-}
-.card-cover { width: 100%; height: 280rpx; background: #f0f0f0; }
-.card-body { padding: 20rpx; }
-.card-tags { display: flex; align-items: center; margin-bottom: 10rpx; }
-.card-category { font-size: 20rpx; color: #5B9BD5; background: rgba(91,155,213,0.1); padding: 2rpx 12rpx; border-radius: 8rpx; margin-right: 12rpx; }
-.card-readtime { font-size: 20rpx; color: #999; }
-.card-title { display: block; font-size: 28rpx; font-weight: bold; color: #333; margin-bottom: 8rpx; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
-.card-summary { display: block; font-size: 24rpx; color: #666; line-height: 1.5; margin-bottom: 12rpx; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
-.card-footer { display: flex; align-items: center; font-size: 20rpx; color: #999; }
-.card-author { margin-right: 16rpx; color: #5B9BD5; }
-.card-date { margin-right: 16rpx; }
-.card-fav { margin-left: auto; }
-/* 五维彩条 */
-.card-dimensions { display: flex; margin-top: 10rpx; gap: 8rpx; }
-.dim-dot { width: 20rpx; height: 20rpx; border-radius: 50%; }
-/* 加载更多 */
-.loading-more, .no-more { text-align: center; padding: 30rpx; }
-.loading-text { font-size: 24rpx; color: #999; }
-.no-more-text { font-size: 24rpx; color: #ccc; }
-.bottom-spacer { height: 120rpx; }
-/* 浮动按钮 */
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+.ac-card-summary {
+  display: block;
+  font-size: 24rpx;
+  color: #999;
+  line-height: 1.5;
+  margin-bottom: 16rpx;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+.ac-card-footer {
+  display: flex;
+  align-items: center;
+  font-size: 20rpx;
+  color: #bbb;
+}
+.ac-card-author {
+  color: #888;
+}
+.ac-card-sep {
+  margin: 0 10rpx;
+  color: #ddd;
+}
+.ac-card-date {
+  flex: 1;
+}
+.ac-card-fav {
+  color: #ccc;
+}
+
+/* ---- 加载更多 / 没有更多 ---- */
+.ac-loading-more,
+.ac-no-more {
+  text-align: center;
+  padding: 30rpx;
+}
+.ac-loading-text {
+  font-size: 24rpx;
+  color: #bbb;
+}
+.ac-no-more-text {
+  font-size: 24rpx;
+  color: #ddd;
+}
+.ac-bottom-gap {
+  height: 140rpx;
+}
+
+/* ---- 浮动发布按钮 ---- */
 .ac-fab {
   position: fixed;
-  right: 40rpx;
+  right: 36rpx;
   bottom: 100rpx;
-  width: 100rpx;
-  height: 100rpx;
+  width: 96rpx;
+  height: 96rpx;
   background: linear-gradient(135deg, #F97316, #EA580C);
   color: #fff;
   border-radius: 50%;
   display: flex;
   align-items: center;
   justify-content: center;
-  box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.4);
+  box-shadow: 0 6rpx 20rpx rgba(249,115,22,0.35);
   z-index: 100;
 }
-.fab-icon { font-size: 48rpx; font-weight: bold; line-height: 1; }
+.ac-fab:active {
+  transform: scale(0.92);
+  opacity: 0.85;
+}
+.ac-fab-icon {
+  font-size: 48rpx;
+  font-weight: 300;
+  line-height: 1;
+}
 </style>

+ 81 - 45
cfc-frontend/pages/discover/product-detail/product-detail.vue

@@ -44,6 +44,11 @@
           <text v-if="product.memberPrice" class="bottom-member">会员{{ formatPriceWithSymbol(product.memberPrice) }}</text>
         </view>
         <view class="action-btns">
+          <view class="cart-icon-wrap" @click="onAddToCart">
+            <text class="cart-icon">🛒</text>
+            <text v-if="cartCount > 0" class="cart-badge">{{ cartCount > 99 ? '99+' : cartCount }}</text>
+          </view>
+          <button class="btn-cart" @click="onAddToCart">加入购物车</button>
           <button class="btn-buy" @click="onBuy">立即购买</button>
         </view>
       </view>
@@ -55,13 +60,15 @@
 </template>
 
 <script>
-import { productDetail, productOrderCreate, productOrderPay } from '@/utils/api.js'
+import config from '@/config.js'
+import { productDetail } from '@/utils/api.js'
 
 export default {
   data() {
     return {
       product: {},
-      loading: false
+      loading: false,
+      cartCount: 0
     }
   },
   computed: {
@@ -91,6 +98,9 @@ export default {
       this.loadDetail(options.id)
     }
   },
+  onShow() {
+    this.loadCartCount()
+  },
   methods: {
     loadDetail(id) {
       this.loading = true
@@ -103,54 +113,58 @@ export default {
         this.loading = false
       })
     },
-    onBuy() {
-      const userId = uni.getStorageSync('userId')
-      if (!userId) {
-        uni.navigateTo({ url: '/pages/login/login' })
-        return
-      }
-      uni.showLoading({ title: '创建订单...' })
-      productOrderCreate({
-        productId: this.product.id,
-        quantity: 1,
-        paymentMethod: 'wechat'
-      }).then(res => {
-        uni.hideLoading()
-        if (res.code === 200 && res.data) {
-          const orderNo = res.data.orderNo
-          uni.showModal({
-            title: '订单已创建',
-            content: '订单号:' + orderNo + ',确定支付吗?',
-            success: (confirm) => {
-              if (confirm.confirm) {
-                this.doPay(orderNo)
-              }
-            }
-          })
-        } else {
-          uni.showToast({ title: res.message || '创建订单失败', icon: 'none' })
+    loadCartCount() {
+      var that = this
+      uni.request({
+        url: config.api('/api/cart/count'),
+        method: 'POST',
+        data: {},
+        header: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer ' + uni.getStorageSync('token')
+        },
+        success: function(res) {
+          if (res.data && res.data.code === 200) {
+            that.cartCount = res.data.data || 0
+          }
         }
-      }).catch(() => {
-        uni.hideLoading()
-        uni.showToast({ title: '创建订单失败', icon: 'none' })
       })
     },
-    doPay(orderNo) {
-      uni.showLoading({ title: '支付中...' })
-      productOrderPay({ orderNo }).then(res => {
-        uni.hideLoading()
-        if (res.code === 200) {
-          uni.showToast({ title: '支付成功', icon: 'success' })
-          setTimeout(() => {
-            uni.navigateTo({ url: '/pages/shop/order-list/order-list' })
-          }, 1500)
-        } else {
-          uni.showToast({ title: res.message || '支付失败', icon: 'none' })
+    onAddToCart() {
+      var that = this
+      uni.request({
+        url: config.api('/api/cart/add'),
+        method: 'POST',
+        data: { productId: this.product.id, quantity: 1 },
+        header: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer ' + uni.getStorageSync('token')
+        },
+        success: function(res) {
+          if (res.data && res.data.code === 200) {
+            uni.showToast({ title: '已加入购物车', icon: 'success' })
+            that.loadCartCount()
+          } else {
+            uni.showToast({ title: res.data.message || '加入失败', icon: 'none' })
+          }
+        },
+        fail: function() {
+          uni.showToast({ title: '网络请求失败', icon: 'none' })
         }
-      }).catch(() => {
-        uni.hideLoading()
-        uni.showToast({ title: '支付失败', icon: 'none' })
       })
+    },
+    onBuy() {
+      if (!this.product.id) return
+      var url = '/pages/shop/checkout/checkout?productId=' + this.product.id
+        + '&productName=' + encodeURIComponent(this.product.name || '')
+        + '&price=' + (this.product.price || 0)
+        + '&coverImage=' + encodeURIComponent(this.product.coverImage || '')
+        + '&quantity=1'
+      uni.navigateTo({ url: url })
+    },
+    formatPriceWithSymbol(price) {
+      if (price === null || price === undefined) return '0.00'
+      return (Number(price) / 100).toFixed(2)
     }
   }
 }
@@ -324,4 +338,26 @@ export default {
 .btn-cart::after {
   border: none;
 }
+.cart-icon-wrap {
+  position: relative;
+  margin-right: 16rpx;
+  padding: 8rpx;
+}
+.cart-icon {
+  font-size: 44rpx;
+}
+.cart-badge {
+  position: absolute;
+  top: -4rpx;
+  right: -8rpx;
+  min-width: 32rpx;
+  height: 32rpx;
+  line-height: 32rpx;
+  text-align: center;
+  background: #F97316;
+  color: #fff;
+  font-size: 20rpx;
+  border-radius: 16rpx;
+  padding: 0 6rpx;
+}
 </style>

+ 7 - 2
cfc-frontend/pages/login/login.vue

@@ -232,8 +232,13 @@ export default {
       if (this.redirectUrl) {
         const redirect = this.redirectUrl
         this.redirectUrl = ''
-        // 使用 redirectTo 支持非 tabBar 页面,避免 switchTab 无法跳转到 discover 等非 tabBar 页
-        uni.redirectTo({ url: redirect })
+        // 注意: redirectTo 不支持跳转 tabBar 页面,必须用 switchTab
+        var tabBarPages = ['/pages/index/index', '/pages/body/index', '/pages/wisdom/index', '/pages/mind/index', '/pages/profile/profile']
+        if (tabBarPages.indexOf(redirect) !== -1) {
+          uni.switchTab({ url: redirect })
+        } else {
+          uni.redirectTo({ url: redirect })
+        }
       } else {
         uni.switchTab({ url: '/pages/index/index' })
       }

+ 1 - 1
cfc-frontend/pages/profile/components/ProfileHeader.vue

@@ -14,7 +14,7 @@
     </view>
 
     <view class="user-card">
-      <view class="avatar">👤</view>
+      <view class="avatar" @click="$emit('avatar-click')">👤</view>
       <view class="user-info">
         <view class="nickname">{{ nickname || '未设置昵称' }}</view>
         <view class="role">{{ role === 'parent' ? '家长模式' : (role === 'child' ? '孩子模式' : '成长规划师模式') }}</view>

+ 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' })
     },

+ 55 - 4
cfc-frontend/pages/profile/family-members.vue

@@ -1,11 +1,11 @@
 <template>
   <view class="container">
     <!-- ===== 家庭信息头部 (带齿轮入口) ===== -->
-    <view class="family-header" v-if="familyName">
+    <view class="family-header">
       <view class="family-header-content">
         <view class="family-header-icon">🏠</view>
         <view class="family-header-text">
-          <text class="family-name">{{ familyName }}</text>
+          <text class="family-name">{{ familyName || '我的家庭' }}</text>
           <text class="family-member-count">{{ members.length }} 位成员</text>
         </view>
         <view class="header-settings-btn" @click="showSettings = true">
@@ -60,8 +60,17 @@
         </view>
         <text class="member-arrow">›</text>
       </view>
-      <view v-if="filteredMembers.length === 0" class="empty-row">
-        <text class="empty-text">暂无成员</text>
+      <!-- 空状态:还没有家庭成员 -->
+      <view v-if="members.length === 0" class="empty-state-card">
+        <text class="empty-icon">👨‍👩‍👧‍👦</text>
+        <text class="empty-title">还没有家庭成员</text>
+        <view class="empty-actions">
+          <view class="empty-action-btn add-btn" @click="onAddMember">+ 添加</view>
+          <view class="empty-action-btn invite-btn" @click="onInvite">📨 邀请</view>
+        </view>
+      </view>
+      <view v-else-if="filteredMembers.length === 0" class="empty-row">
+        <text class="empty-text">暂无{{ filterType === 'parent' ? '家长' : '孩子' }}成员</text>
       </view>
     </view>
 
@@ -518,6 +527,48 @@ export default {
   color: #bbb;
 }
 
+/* ===== 空状态卡片(无家庭成员时显示) ===== */
+.empty-state-card {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 60rpx 30rpx 40rpx;
+  text-align: center;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+.empty-icon {
+  font-size: 80rpx;
+  display: block;
+  margin-bottom: 16rpx;
+}
+.empty-title {
+  font-size: 30rpx;
+  color: #666;
+  display: block;
+  margin-bottom: 32rpx;
+}
+.empty-actions {
+  display: flex;
+  justify-content: center;
+  gap: 20rpx;
+}
+.empty-action-btn {
+  padding: 16rpx 40rpx;
+  border-radius: 12rpx;
+  font-size: 28rpx;
+  font-weight: 500;
+}
+.empty-action-btn:active {
+  opacity: 0.8;
+}
+.add-btn {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: #fff;
+}
+.invite-btn {
+  background: #f0f9ff;
+  color: #667eea;
+}
+
 /* ===== 三维评分进度条(列表行内) ===== */
 .member-quality-scores {
   display: flex;

+ 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>

+ 1 - 0
cfc-frontend/pages/shop/cart/cart.vue

@@ -140,6 +140,7 @@ export default {
             var list = res.data.data || []
             for (var i = 0; i < list.length; i++) {
               list[i].checked = true
+              list[i].subtotal = list[i].unitPrice * list[i].quantity
             }
             that.items = list
           }

+ 10 - 0
cfc-frontend/pages/shop/payment/payment.vue

@@ -167,6 +167,16 @@ export default {
                 signType: params.signType || 'RSA',
                 paySign: params.paySign,
                 success: function() {
+                  // Notify backend to update order status
+                  uni.request({
+                    url: config.api('/api/product/order/handlePaymentSuccess'),
+                    method: 'POST',
+                    data: { orderNo: that.orderNo },
+                    header: {
+                      'Content-Type': 'application/json',
+                      'Authorization': 'Bearer ' + uni.getStorageSync('token')
+                    }
+                  })
                   uni.showToast({ title: '支付成功', icon: 'success' })
                   setTimeout(function() {
                     uni.redirectTo({

+ 2 - 2
cfc-frontend/pages/user-edit/user-edit.vue

@@ -404,8 +404,8 @@ export default {
 					this.form.realName = userData.realName || ''
 					this.form.gender = userData.gender || ''
 					this.form.idCard = userData.idCard || ''
-					// 生日只取日期部分,去掉时间
-					this.form.birthday = userData.birthday ? userData.birthday.split(' ')[0] : ''
+				// 生日只取日期部分,去掉时间和 ISO 格式的 T 分隔符
+				this.form.birthday = userData.birthday ? String(userData.birthday).split(/[T ]/)[0] : ''
 					this.form.avatar = userData.avatar || ''
 					this.form.role = userData.role || ''
 					this.form.phone = userData.phone || ''

+ 10 - 0
cfc-frontend/utils/api.js

@@ -1396,6 +1396,16 @@ export const registerActivity = (data) => request('/api/activity/register', 'POS
 export const cancelActivityRegistration = (data) => request('/api/activity/cancel-registration', 'POST', data)
 export const getMyActivityRegistrations = (data) => request('/api/activity/my-registrations', 'POST', data)
 
+// ===== 活动管理(管理员端,复用 /api/activity/*) =====
+export const adminCreateActivity = (activity) => request('/api/activity/create', 'POST', activity)
+export const adminUpdateActivity = (activity) => request('/api/activity/update', 'POST', activity)
+export const adminPublishActivity = (id) => request('/api/activity/publish', 'POST', { id })
+export const adminEndActivity = (id) => request('/api/activity/end', 'POST', { id })
+// 管理员列表:获取所有活动(含草稿),按 vendorId 过滤自己创建的
+export const adminListMyActivities = (data) => request('/api/activity/list', 'POST', data)
+// 后台概览列表:获取已发布的活动做统计
+export const adminListAllActivities = (data) => request('/api/activity/list', 'POST', data)
+
 // ===== 缁村害浠诲姟锛堝甫 category 绛涢€夛級 =====
 export const getTodayTasksByCategory = (childId, category) => {
   return request('/api/tasks/today', 'POST', { childId: childId, category: category })

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-312725b35e88792ff47eaff32adfa55fbda50b7b
+601558f0b2edee38a0101f0662bfdf94a8e5409b

+ 70 - 188
cfc-web/CHANGELOG.md

@@ -1,10 +1,63 @@
 # 更新日志 (Changelog)
 
 此文件记录所有构建版本的变更。
-## v1.0.69 (2026-07-08)
+## v1.0.37 (2026-07-09)
+
+### 工程化
+- auto bump version and changelog [skip ci]
+
+
+## v1.0.36 (2026-07-09)
+
+### 工程化
+- auto bump version and changelog [skip ci]
+
+
+## v1.0.35 (2026-07-09)
+
+### 新功能
+- consignee + purchase field module (migration, entity, mapper, service, controller)
+- add admin activity creation and management features
+- family members empty state — always show as entry point
+
+### 其他
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- - 添加 → 导航到 /pages/family/add-member
+- - 邀请 → 打开邀请弹窗
+- - family-header 在无 familyName 时显示 '我的家庭' 占位
+- - 筛选后无结果时仍显示 '暂无家长/暂无孩子' 提示
+- 
+- 
+- - Unify formatTime to t.replace('T',' ').substring(0,19) pattern
+- - Replace new Date().toLocaleString() with consistent T-replace in
+-   OrderManage/WithdrawAudit/EmotionAlertManage/ProductManage
+- 
+- - Uses table-scroll-wrap CSS classes (overflow:auto + max-height)
+- - Inline overflow:auto;max-height style on pages without class wrapper
+- 
+
+### Bug 修复
+- add role-based permission checks to activity endpoints
+- 补齐个人信息保存缺失的 idCard/mascot 映射
+- normalize time format to YYYY-MM-DD HH:mm:ss across admin pages
+- complete scroll wrapper for all 59 pages with fixed=right columns
+
+### 工程化
+- auto bump version and changelog [skip ci]
+- auto bump version and changelog [skip ci]
+- auto bump version and changelog [skip ci]
+
+
+## v1.0.34 (2026-07-08)
 
 ### 重构
 - unify admin page styles with global CSS design tokens
+- merge wechatLogin and wechatPhoneLogin, capture phone from WeChat
 
 ### 其他
 - - Import admin-tokens.css globally in main.js
@@ -18,218 +71,59 @@
 - - Fix ActivityReview, Assessment (syntax error fix)
 - - Remove duplicate scoped CSS for filter-bar, header patterns
 - 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
 
 ### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.68 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.67 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.66 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.65 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.64 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.63 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.62 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.61 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.60 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.59 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.58 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.57 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.56 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.55 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.54 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.53 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.52 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.51 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.50 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.49 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.48 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.47 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.46 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.45 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.44 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.43 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.42 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.41 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.40 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.39 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.38 (2026-07-08)
-
-### 工程化
 - auto bump version and changelog [skip ci]
-
-
-## v1.0.37 (2026-07-08)
+- auto bump version and changelog [skip ci]
 
 ### 文档
 - add article system redesign design document
 
-### 其他
-- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
-- 
-- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
-- 
-- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
-- 
-- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
-- 
-- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
-- 
-- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
-- 
-- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
-- 
-
 ### 新功能
 - redesign article-center with search/nav/dimension-param; enhance mind/articles with search and unified detail
 - unify article entry points to article-center
@@ -244,17 +138,8 @@
 - wechatLogin use real code2Session API instead of mock openid
 - validate openid before WeChat prepay - reject phone-prefixed/mock openid (resolves PARAM_ERROR 无效的openid)
 
-### 重构
-- merge wechatLogin and wechatPhoneLogin, capture phone from WeChat
-
-
-## v1.0.36 (2026-07-08)
-
-### 工程化
-- auto bump version and changelog [skip ci]
-
 
-## v1.0.35 (2026-07-08)
+## v1.0.31 (2026-07-08)
 
 ### Bug 修复
 - add missing wangEditor Toolbar component to enable rich text editing
@@ -270,9 +155,6 @@
 - - Pattern: div wrapper with overflow:auto;max-height:calc(100vh - 300px)
 - 
 
-
-## v1.0.31 (2026-07-08)
-
 ### 工程化
 - auto bump version and changelog [skip ci]
 

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.68",
+  "version": "1.0.36",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.68",
+      "version": "1.0.36",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.69",
+  "version": "1.0.37",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 3 - 2
cfc-web/src/views/GuideFamilyTask.vue

@@ -69,7 +69,8 @@
       </div>
 
       <!-- 孩子列表 -->
-      <el-table v-if="familyOverview && !searchForm.childId" :data="familyOverview.children" border>
+      <div style="overflow:auto;max-height:calc(100vh - 300px);">
+      <el-table style="max-height:calc(100vh - 300px);" v-if="familyOverview && !searchForm.childId" :data="familyOverview.children" border>
         <el-table-column prop="childName" label="孩子姓名" width="120"></el-table-column>
         <el-table-column prop="totalPoints" label="总积分" width="100"></el-table-column>
         <el-table-column prop="streakDays" label="连续打卡" width="100">
@@ -87,7 +88,7 @@
             <el-button size="mini" @click="selectChild(row)">查看任务</el-button>
           </template>
         </el-table-column>
-      </el-table>
+      </el-table></div>
 
       <!-- 任务列表 -->
       <el-table v-if="searchForm.childId" :data="tasks" border v-loading="taskLoading">

+ 3 - 2
cfc-web/src/views/admin/Activities.vue

@@ -21,7 +21,8 @@
         </div>
       </div>
       <div class="table-scroll-wrap-lg">
-        <el-table :data="activities" v-loading="loading" border stripe>
+        <div style="overflow:auto;max-height:calc(100vh - 300px);">
+        <el-table style="max-height:calc(100vh - 300px);" :data="activities" v-loading="loading" border stripe>
         <el-table-column prop="id" label="ID" width="80" />
         <el-table-column label="封面" width="80">
           <template slot-scope="{ row }">
@@ -72,7 +73,7 @@
             </el-dropdown>
           </template>
         </el-table-column>
-      </el-table>
+        </el-table></div>
       </div>
       <el-pagination
         v-if="total > 0"

+ 3 - 2
cfc-web/src/views/admin/ActivityRegistrationReview.vue

@@ -17,7 +17,8 @@
     </div>
 
     <div class="table-scroll-wrap-sm">
-      <el-table :data="filteredList" v-loading="loading" border stripe>
+      <div style="overflow:auto;max-height:calc(100vh - 300px);">
+      <el-table style="max-height:calc(100vh - 300px);" :data="filteredList" v-loading="loading" border stripe>
       <el-table-column prop="id" label="ID" width="60" />
       <el-table-column prop="userId" label="用户ID" width="70" />
       <el-table-column prop="childId" label="孩子ID" width="70" />
@@ -40,7 +41,7 @@
           <span v-else style="color: #999;">已处理</span>
         </template>
       </el-table-column>
-    </el-table>
+      </el-table></div>
     </div>
 
     <el-dialog title="拒绝原因" :visible.sync="rejectDialogVisible" width="400px">

+ 8 - 3
cfc-web/src/views/admin/ArticleManage.vue

@@ -64,7 +64,8 @@
             <el-tag v-if="row.isFeatured === 1 || row.isFeatured === true" size="mini" type="warning" style="margin-left: 6px;">精选</el-tag>
           </template>
         </el-table-column>
-        <el-table-column label="分类" width="100">
+        <div style="overflow:auto;max-height:calc(100vh - 300px);">
+        <el-table style="max-height:calc(100vh - 300px);"-column label="分类" width="100">
           <template slot-scope="{ row }">
             {{ row.categoryName || '-' }}
           </template>
@@ -119,7 +120,7 @@
         <el-table-column prop="viewCount" label="阅读" width="70" />
         <el-table-column label="发布时间" width="160">
           <template slot-scope="{ row }">
-            {{ row.publishedAt || '-' }}
+            {{ formatTime(row.publishedAt) }}
           </template>
         </el-table-column>
         <el-table-column label="操作" width="200" fixed="right">
@@ -207,7 +208,7 @@
             </template>
           </template>
         </el-table-column>
-      </el-table>
+        </el-table></div>
       </div>
 
       <div class="pagination-wrap">
@@ -479,6 +480,10 @@ export default {
         this.$message.error(e.message || '复制失败')
       }
     },
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace ? t.replace('T', ' ').substring(0, 19) : t
+    },
     handleActionCmd(row, cmd) {
       switch (cmd) {
         case 'submitReview': this.handleSubmitReview(row); break;

+ 3 - 2
cfc-web/src/views/admin/Assessment.vue

@@ -28,7 +28,8 @@
         </div>
       </div>
       <div class="table-scroll-wrap-sm">
-      <el-table :data="filteredData" border stripe>
+      <div style="overflow:auto;max-height:calc(100vh - 300px);">
+      <el-table style="max-height:calc(100vh - 300px);" :data="filteredData" border stripe>
         <el-table-column prop="id" label="ID" width="60" />
         <el-table-column prop="title" label="标题" />
         <el-table-column prop="content" label="内容" show-overflow-tooltip />
@@ -55,7 +56,7 @@
             </el-dropdown>
           </template>
         </el-table-column>
-      </el-table>
+      </el-table></div>
       </div>
     </el-card>
 

+ 3 - 2
cfc-web/src/views/admin/CouponManagement.vue

@@ -11,7 +11,8 @@
       </div>
 
       <div class="table-scroll-wrap-sm">
-        <el-table :data="list" v-loading="loading" border stripe>
+        <div style="overflow:auto;max-height:calc(100vh - 300px);">
+        <el-table style="max-height:calc(100vh - 300px);" :data="list" v-loading="loading" border stripe>
         <el-table-column prop="id" label="ID" width="70" />
         <el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
         <el-table-column label="类型" width="90">
@@ -53,7 +54,7 @@
             </el-dropdown>
           </template>
         </el-table-column>
-      </el-table>
+        </el-table></div>
       </div>
 
       <el-pagination

+ 5 - 1
cfc-web/src/views/admin/Dashboard.vue

@@ -71,7 +71,7 @@
           </template>
         </el-table-column>
         <el-table-column label="创建时间" width="150">
-          <template slot-scope="{ row }">{{ row.createdAt || '-' }}</template>
+          <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
         </el-table-column>
       </el-table>
     </el-card>
@@ -244,6 +244,10 @@ export default {
     taskStatusLabel(status) {
       const map = { pending: '\u5f85\u5ba1\u6838', approved: '\u5df2\u901a\u8fc7', completed: '\u5df2\u5b8c\u6210', rejected: '\u5df2\u62d2\u7edd' }
       return map[status] || status
+    },
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace ? t.replace('T', ' ').substring(0, 19) : t
     }
   }
 }

+ 3 - 3
cfc-web/src/views/admin/EmotionAlertManage.vue

@@ -148,9 +148,9 @@ export default {
       var map = { high: '高', medium: '中', low: '低' }
       return map[severity] || severity
     },
-    formatTime(time) {
-      if (!time) return '-'
-      return new Date(time).toLocaleString()
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace ? t.replace('T', ' ').substring(0, 19) : t
     }
   }
 }

+ 3 - 2
cfc-web/src/views/admin/Foods.vue

@@ -28,7 +28,8 @@
         </div>
       </div>
       <div class="table-scroll-wrap-lg">
-        <el-table :data="list" v-loading="loading" border stripe>
+        <div style="overflow:auto;max-height:calc(100vh - 300px);">
+        <el-table style="max-height:calc(100vh - 300px);" :data="list" v-loading="loading" border stripe>
         <el-table-column prop="id" label="ID" width="80" />
         <el-table-column prop="name" label="名称" />
         <el-table-column prop="category" label="分类" width="100" />
@@ -60,7 +61,7 @@
             </el-dropdown>
           </template>
         </el-table-column>
-      </el-table>
+        </el-table></div>
       </div>
     </el-card>
 

+ 6 - 5
cfc-web/src/views/admin/OrderManage.vue

@@ -19,7 +19,8 @@
     </div>
 
     <div class="table-scroll-wrap-lg">
-      <el-table :data="list" v-loading="loading" border stripe>
+      <div style="overflow:auto;max-height:calc(100vh - 300px);">
+      <el-table style="max-height:calc(100vh - 300px);" :data="list" v-loading="loading" border stripe>
       <el-table-column prop="orderNo" label="订单号" width="180" />
       <el-table-column label="商品" min-width="160">
         <template slot-scope="{ row }">
@@ -65,7 +66,7 @@
           </el-dropdown>
         </template>
       </el-table-column>
-    </el-table>
+      </el-table></div>
     </div>
 
 <el-pagination
@@ -305,9 +306,9 @@ export default {
       if (cents == null) return '-'
       return '¥' + (cents / 100).toFixed(2)
     },
-    formatTime(time) {
-      if (!time) return '-'
-      return new Date(time).toLocaleString()
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace ? t.replace('T', ' ').substring(0, 19) : t
     },
     handleActionCmd(row, cmd) {
       switch (cmd) {

+ 3 - 2
cfc-web/src/views/admin/PointsLog.vue

@@ -21,7 +21,8 @@
         </div>
       </div>
       <div class="table-scroll-wrap-sm">
-      <el-table :data="list" v-loading="loading" border stripe>
+      <div style="overflow:auto;max-height:calc(100vh - 300px);">
+      <el-table style="max-height:calc(100vh - 300px);" :data="list" v-loading="loading" border stripe>
         <el-table-column prop="createdAt" label="时间" width="160">
           <template slot-scope="{ row }">{{ formatTime(row.createdAt) }}</template>
         </el-table-column>
@@ -65,7 +66,7 @@
             </el-dropdown>
           </template>
         </el-table-column>
-    </el-table>
+      </el-table></div>
     </div>
 
     <el-pagination

+ 6 - 5
cfc-web/src/views/admin/ProductManage.vue

@@ -33,7 +33,8 @@
       </div>
 
       <div class="table-scroll-wrap-sm">
-        <el-table :data="list" v-loading="loading" border stripe>
+        <div style="overflow:auto;max-height:calc(100vh - 300px);">
+        <el-table style="max-height:calc(100vh - 300px);" :data="list" v-loading="loading" border stripe>
       <el-table-column prop="id" label="ID" width="70" />
       <el-table-column label="商品图片" width="80">
         <template slot-scope="{ row }">
@@ -96,7 +97,7 @@
           </el-dropdown>
         </template>
       </el-table-column>
-    </el-table>
+        </el-table></div>
     </div>
 
     <el-pagination
@@ -222,9 +223,9 @@ export default {
       const map = { physical: '实物商品', virtual: '虚拟商品', coupon: '优惠券' }
       return map[type] || type
     },
-    formatTime(time) {
-      if (!time) return '-'
-      return new Date(time).toLocaleString()
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace ? t.replace('T', ' ').substring(0, 19) : t
     },
     formatPriceWithSymbol(price) {
       if (price === 0) return '免费'

+ 3 - 2
cfc-web/src/views/admin/ServiceContents.vue

@@ -25,7 +25,8 @@
         </el-form-item>
     </el-form>
     <div class="table-scroll-wrap-sm">
-    <el-table :data="filteredData" stripe>
+    <div style="overflow:auto;max-height:calc(100vh - 300px);">
+    <el-table style="max-height:calc(100vh - 300px);" :data="filteredData" stripe>
         <el-table-column prop="id" label="ID" width="80"></el-table-column>
         <el-table-column prop="title" label="标题"></el-table-column>
         <el-table-column prop="typeId" label="类型" width="120">
@@ -59,7 +60,7 @@
             </el-dropdown>
           </template>
         </el-table-column>
-    </el-table>
+    </el-table></div>
     </div>
   </el-card>
 

+ 3 - 2
cfc-web/src/views/admin/ServiceTypes.vue

@@ -9,7 +9,8 @@
         </div>
       </div>
       <div class="table-scroll-wrap">
-        <el-table :data="filteredData" stripe>
+        <div style="overflow:auto;max-height:calc(100vh - 300px);">
+        <el-table style="max-height:calc(100vh - 300px);" :data="filteredData" stripe>
         <el-table-column prop="id" label="ID" width="80"></el-table-column>
         <el-table-column prop="name" label="类型名称"></el-table-column>
         <el-table-column prop="icon" label="图标" width="100">
@@ -37,7 +38,7 @@
             </el-dropdown>
           </template>
         </el-table-column>
-      </el-table>
+        </el-table></div>
       </div>
     </el-card>
 

+ 3 - 3
cfc-web/src/views/admin/WithdrawAudit.vue

@@ -166,9 +166,9 @@ export default {
       const map = { pending: '待审核', approved: '已通过', rejected: '已拒绝' }
       return map[status] || status
     },
-    formatTime(time) {
-      if (!time) return '-'
-      return new Date(time).toLocaleString()
+    formatTime(t) {
+      if (!t) return '-'
+      return t.replace ? t.replace('T', ' ').substring(0, 19) : t
     },
     async handleAudit(row, status) {
       const actionLabel = status === 'approved' ? '通过' : '拒绝'

+ 3 - 2
cfc-web/src/views/admin/data-source-config.vue

@@ -9,7 +9,8 @@
         </div>
       </div>
       <div class="table-scroll-wrap-sm">
-      <el-table :data="records" stripe v-loading="loading">
+      <div style="overflow:auto;max-height:calc(100vh - 300px);">
+      <el-table style="max-height:calc(100vh - 300px);" :data="records" stripe v-loading="loading">
         <el-table-column prop="id" label="ID" width="60"></el-table-column>
         <el-table-column prop="memberId" label="成员ID" width="80"></el-table-column>
         <el-table-column prop="sourceType" label="类型" width="100">
@@ -43,7 +44,7 @@
             </el-dropdown>
           </template>
         </el-table-column>
-      </el-table>
+      </el-table></div>
       </div>
       <el-pagination
         v-if="total > 0"

Некоторые файлы не были показаны из-за большого количества измененных файлов