Browse Source

feat: 活动模块升级——付费报名订单/审核发布流/总结资料

付费报名订单支付页(活动订单/支付/回调)+ 普通用户发布审核流(submit-review/auditStatus)+ 活动总结资料(材料上传)+ 状态机扩展。

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Sisyphus 3 months ago
parent
commit
754a54568d
23 changed files with 1793 additions and 423 deletions
  1. 94 16
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java
  3. 41 15
      cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java
  4. 50 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityMaterialController.java
  5. 99 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityOrderController.java
  6. 35 18
      cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java
  7. 18 3
      cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java
  8. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityMaterial.java
  9. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityOrder.java
  10. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ActivityMaterialMapper.java
  11. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/ActivityOrderMapper.java
  12. 67 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivityMaterialService.java
  13. 203 0
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivityOrderService.java
  14. 75 48
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java
  15. 157 40
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java
  16. 50 7
      cfc-backend/src/main/resources/schema.sql
  17. 2 2
      cfc-frontend/pages.json
  18. 99 51
      cfc-frontend/pages/activity/activity-detail/activity-detail.vue
  19. 96 51
      cfc-frontend/pages/activity/create.vue
  20. 121 161
      cfc-frontend/pages/activity/index.vue
  21. 322 0
      cfc-frontend/pages/activity/order-pay/order-pay.vue
  22. 6 11
      cfc-frontend/utils/api.js
  23. 189 0
      docs/superpowers/specs/2026-08-01-activity-registration-upgrade-design.md

+ 94 - 16
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2152,6 +2152,9 @@ log.info("已添加template_id列到tasks表");
             }
             }
         }
         }
 
 
+        // 关系类型表添加 default_role 列(角色模板)
+        ensureColumn("relationship_types", "default_role", "VARCHAR(20) DEFAULT NULL COMMENT '默认角色模板: parent/child'");
+
         // 家庭成员表添加 role_override 列(替换旧 capability_role)
         // 家庭成员表添加 role_override 列(替换旧 capability_role)
         ensureColumn("family_members", "role_override", "VARCHAR(20) DEFAULT 'auto' COMMENT '角色覆盖: auto/parent/child/elderly'");
         ensureColumn("family_members", "role_override", "VARCHAR(20) DEFAULT 'auto' COMMENT '角色覆盖: auto/parent/child/elderly'");
         try {
         try {
@@ -2857,6 +2860,34 @@ log.info("已添加template_id列到tasks表");
             log.warn("创建member_discount_configs表失败: {}", e.getMessage());
             log.warn("创建member_discount_configs表失败: {}", e.getMessage());
         }
         }
 
 
+        try {
+            Integer hasCap = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'relationship_types' AND COLUMN_NAME = 'capability_role'",
+                Integer.class);
+            Integer hasDefault = jdbcTemplate.queryForObject(
+                "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'relationship_types' AND COLUMN_NAME = 'default_role'",
+                Integer.class);
+            if (hasCap != null && hasCap == 0) {
+                if (hasDefault != null && hasDefault > 0) {
+                    jdbcTemplate.execute("ALTER TABLE relationship_types CHANGE COLUMN default_role capability_role VARCHAR(20) NOT NULL COMMENT '能力角色: parent/child'");
+                    log.info("已将relationship_types.default_role重命名为capability_role");
+                } else {
+                    jdbcTemplate.execute("ALTER TABLE relationship_types ADD COLUMN capability_role VARCHAR(20) NOT NULL DEFAULT 'parent' COMMENT '能力角色: parent/child' AFTER type_name");
+                    log.info("已添加capability_role列到relationship_types表");
+                }
+            }
+        } catch (Exception e) {
+            log.warn("relationship_types capability_role 迁移失败: {}", e.getMessage());
+        }
+
+        // 确保capability_role有默认值(生产库该列NOT NULL无默认值,导致INSERT时Field doesn't have a default value错误)
+        try {
+            jdbcTemplate.execute("ALTER TABLE relationship_types MODIFY COLUMN capability_role VARCHAR(20) NOT NULL DEFAULT 'parent' COMMENT '能力角色: parent/child'");
+            log.info("已修改relationship_types.capability_role添加默认值'parent'");
+        } catch (Exception e) {
+            log.warn("修改relationship_types.capability_role默认值失败: {}", e.getMessage());
+        }
+
         // ==================== 食材推荐指数 Phase: food_recommend_idx / food_recommend_idx_log ====================
         // ==================== 食材推荐指数 Phase: food_recommend_idx / food_recommend_idx_log ====================
         try {
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `food_recommend_idx` (" +
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `food_recommend_idx` (" +
@@ -3113,8 +3144,8 @@ log.info("已添加template_id列到tasks表");
         modifyColumnToText("guide_applications", "reject_reason", "拒绝原因");
         modifyColumnToText("guide_applications", "reject_reason", "拒绝原因");
         // guide_application_records.remark
         // guide_application_records.remark
         modifyColumnToText("guide_application_records", "remark", "备注/原因");
         modifyColumnToText("guide_application_records", "remark", "备注/原因");
-        // user_operation_logs.description
-        modifyColumnToText("user_operation_logs", "description", "操作描述");
+        // user_operation_log.description
+        modifyColumnToText("user_operation_log", "description", "操作描述");
         // follow_up.description, teacher_review
         // follow_up.description, teacher_review
         modifyColumnToText("follow_up", "description", "描述");
         modifyColumnToText("follow_up", "description", "描述");
         modifyColumnToText("follow_up", "teacher_review", "规划师点评");
         modifyColumnToText("follow_up", "teacher_review", "规划师点评");
@@ -3147,12 +3178,12 @@ log.info("已添加template_id列到tasks表");
         // life_number_relationship.description, advice
         // life_number_relationship.description, advice
         modifyColumnToText("life_number_relationship", "description", "关系解读");
         modifyColumnToText("life_number_relationship", "description", "关系解读");
         modifyColumnToText("life_number_relationship", "advice", "相处建议");
         modifyColumnToText("life_number_relationship", "advice", "相处建议");
-        // health_checkins.note
-        modifyColumnToText("health_checkins", "note", "备注");
-        // sleep_record.remark
-        modifyColumnToText("sleep_record", "remark", "备注");
-        // exercise_record.remark
-        modifyColumnToText("exercise_record", "remark", "备注");
+        // daily_health_checkin.remark
+        modifyColumnToText("daily_health_checkin", "remark", "备注");
+        // health_sleep_record.remark
+        modifyColumnToText("health_sleep_record", "remark", "备注");
+        // health_exercise_record.remark
+        modifyColumnToText("health_exercise_record", "remark", "备注");
         // finance_checkins.note
         // finance_checkins.note
         modifyColumnToText("finance_checkins", "note", "备注");
         modifyColumnToText("finance_checkins", "note", "备注");
         // guide_packages.description (old duplicate)
         // guide_packages.description (old duplicate)
@@ -3160,12 +3191,18 @@ log.info("已添加template_id列到tasks表");
         modifyColumnToText("assessment_appointments", "remark", "备注");
         modifyColumnToText("assessment_appointments", "remark", "备注");
         // withdrawal_requests.remark
         // withdrawal_requests.remark
         modifyColumnToText("withdrawal_requests", "remark", "备注");
         modifyColumnToText("withdrawal_requests", "remark", "备注");
-        // supply_settlement.remark
-        modifyColumnToText("supply_settlement", "remark", "备注");
+        // supply_settlements.remark
+        modifyColumnToText("supply_settlements", "remark", "备注");
         // content_sections.description
         // content_sections.description
         ensureColumn("content_sections", "description", "TEXT COMMENT '描述说明'");
         ensureColumn("content_sections", "description", "TEXT COMMENT '描述说明'");
         // education_systems.description
         // education_systems.description
         modifyColumnToText("education_systems", "description", "描述");
         modifyColumnToText("education_systems", "description", "描述");
+        // dimension_config.description
+        modifyColumnToText("dimension_config", "description", "描述");
+        // energy_rules.description
+        modifyColumnToText("energy_rules", "description", "规则描述");
+        // team_goals.description
+        modifyColumnToText("team_goals", "description", "团队描述");
         // product_dimension_config.remark
         // product_dimension_config.remark
         modifyColumnToText("product_dimension_config", "remark", "备注");
         modifyColumnToText("product_dimension_config", "remark", "备注");
         // indicator_values.remark
         // indicator_values.remark
@@ -5389,6 +5426,11 @@ try {
             log.info("已创建social_circle_member表");
             log.info("已创建social_circle_member表");
         } catch (Exception e) { /* 表已存在忽略 */ }
         } catch (Exception e) { /* 表已存在忽略 */ }
 
 
+        // 迁移89: relationship_types表添加分类和血亲字段
+        ensureColumn("relationship_types", "category", "VARCHAR(50) DEFAULT 'social' COMMENT '关系分类: blood血亲/marriage姻亲/social社会关系'");
+        ensureColumn("relationship_types", "is_blood_relation", "TINYINT DEFAULT 0 COMMENT '是否血亲关系: 1是0否'");
+        ensureColumn("relationship_types", "is_immediate_family", "TINYINT DEFAULT 0 COMMENT '是否直系亲属: 1是0否'");
+
     // 迁移96: 创建活动订单评价表
     // 迁移96: 创建活动订单评价表
     try {
     try {
         jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS review_orders (" +
         jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS review_orders (" +
@@ -6150,14 +6192,50 @@ try {
         log.warn("修正主表dimension_weights JSON失败: {}", e.getMessage());
         log.warn("修正主表dimension_weights JSON失败: {}", e.getMessage());
     }
     }
 
 
-    // 迁移142: package_orders.user_id/family_id/package_id改为可空(匿名肠道菌群检测支付流程无用户/家庭/套餐)
+    // 迁移142: activities表添加报名截止时间与会员/非会员双收费字段(活动升级:报名时间窗口+孩子/家长双收费+会员区分,需求#3#6)
+    ensureColumn("activities", "registration_deadline", "DATETIME COMMENT '报名截止时间(NULL=不限制, 默认=活动开始时间)'");
+    ensureColumn("activities", "child_fee", "INT DEFAULT 0 COMMENT '孩子·非会员价(分)'");
+    ensureColumn("activities", "child_member_fee", "INT DEFAULT 0 COMMENT '孩子·会员价(分), 0=免费'");
+    ensureColumn("activities", "parent_fee", "INT DEFAULT 0 COMMENT '家长·非会员价(分)'");
+    ensureColumn("activities", "parent_member_fee", "INT DEFAULT 0 COMMENT '家长·会员价(分), 0=免费'");
+    ensureColumn("activities", "creator_id", "BIGINT COMMENT '创建人userId(普通用户发布者, admin创建可为空)'");
+
+    // 迁移143: 创建活动报名订单表(活动付费报名:先支付后报名,需求#4)
     try {
     try {
-        jdbcTemplate.execute("ALTER TABLE package_orders MODIFY COLUMN user_id BIGINT DEFAULT NULL COMMENT '购买者用户ID'");
-        jdbcTemplate.execute("ALTER TABLE package_orders MODIFY COLUMN family_id BIGINT DEFAULT NULL COMMENT '家庭ID'");
-        jdbcTemplate.execute("ALTER TABLE package_orders MODIFY COLUMN package_id BIGINT DEFAULT NULL COMMENT '套餐ID'");
-        log.info("已修改package_orders.user_id/family_id/package_id为可空");
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_orders (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '订单号(ACT+时间戳+随机)', " +
+            "activity_id BIGINT NOT NULL COMMENT '活动ID', " +
+            "user_id BIGINT NOT NULL COMMENT '下单人userId', " +
+            "total_amount INT NOT NULL COMMENT '实付金额(分)', " +
+            "registrants_json TEXT COMMENT '报名人快照JSON: [{name, phone, childId, type: child/parent, fee}]', " +
+            "status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/paid/cancelled', " +
+            "transaction_id VARCHAR(64) COMMENT '微信支付单号', " +
+            "paid_at DATETIME COMMENT '支付时间', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_activity (activity_id), " +
+            "INDEX idx_user (user_id), " +
+            "INDEX idx_status (status)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动报名订单表'");
+        log.info("已创建activity_orders表");
+    } catch (Exception e) {
+        log.warn("创建activity_orders表失败: {}", e.getMessage());
+    }
+
+    // 迁移144: 创建活动总结资料表(总结中阶段发布人上传资料,需求#9)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS activity_materials (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "activity_id BIGINT NOT NULL COMMENT '活动ID', " +
+            "title VARCHAR(200) COMMENT '标题', " +
+            "file_url VARCHAR(500) NOT NULL COMMENT '文件地址(图片/PDF/外链URL)', " +
+            "file_type VARCHAR(20) DEFAULT 'image' COMMENT '类型: image/pdf/link', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+            "INDEX idx_activity (activity_id)" +
+            ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动总结资料表'");
+        log.info("已创建activity_materials表");
     } catch (Exception e) {
     } catch (Exception e) {
-        log.warn("修改package_orders可空列失败: {}", e.getMessage());
+        log.warn("创建activity_materials表失败: {}", e.getMessage());
     }
     }
     }
     }
 
 

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -51,6 +51,7 @@ public class JwtInterceptor implements HandlerInterceptor {
         "/api/articles/record-read",
         "/api/articles/record-read",
         "/api/activity/list",
         "/api/activity/list",
         "/api/activity/detail",
         "/api/activity/detail",
+        "/api/activity/order/notify",
         "/api/content-sections/visible",
         "/api/content-sections/visible",
         "/api/mind/alert/active",
         "/api/mind/alert/active",
         "/api/mind/alert/checkin-alerts",
         "/api/mind/alert/checkin-alerts",

+ 41 - 15
cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java

@@ -3,6 +3,7 @@ package com.etotem.cfc.controller;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.ActivityDTO;
 import com.etotem.cfc.dto.ActivityDTO;
 import com.etotem.cfc.entity.Activity;
 import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.service.ActivityAdminService;
 import com.etotem.cfc.service.ActivityRegistrationService;
 import com.etotem.cfc.service.ActivityRegistrationService;
 import com.etotem.cfc.service.ActivityService;
 import com.etotem.cfc.service.ActivityService;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
@@ -15,42 +16,45 @@ import java.util.Map;
 public class ActivityController {
 public class ActivityController {
     @Resource private ActivityService activityService;
     @Resource private ActivityService activityService;
     @Resource private ActivityRegistrationService registrationService;
     @Resource private ActivityRegistrationService registrationService;
+    @Resource private ActivityAdminService activityAdminService;
 
 
     @PostMapping("/list")
     @PostMapping("/list")
-    public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params,
-                                            @RequestAttribute(value = "userId", required = false) Long userId,
-                                            @RequestAttribute(value = "role", required = false) String role) {
+    public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId) {
         String dimensionCode = (String) params.get("dimensionCode");
         String dimensionCode = (String) params.get("dimensionCode");
-        String status = (String) params.get("status");
-        boolean mine = params.get("mine") != null && Boolean.parseBoolean(params.get("mine").toString());
         Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
         Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
         Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
         Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
-        return activityService.list(dimensionCode, status, mine, page, size, userId, role);
+        return activityService.list(dimensionCode, page, size, userId);
     }
     }
 
 
     @PostMapping("/detail")
     @PostMapping("/detail")
-    public Result<ActivityDTO> detail(@RequestBody Map<String, Object> params) {
+    public Result<ActivityDTO> detail(@RequestBody Map<String, Object> params,
+                                       @RequestAttribute(value = "userId", required = false) Long userId) {
         Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
         Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
         if (id == null) return Result.error("id不能为空");
         if (id == null) return Result.error("id不能为空");
-        return activityService.detail(id);
+        return activityService.detail(id, userId);
     }
     }
 
 
     @PostMapping("/create")
     @PostMapping("/create")
     public Result<ActivityDTO> create(@RequestBody Activity activity,
     public Result<ActivityDTO> create(@RequestBody Activity activity,
                                        @RequestAttribute(value = "userId", required = false) Long userId,
                                        @RequestAttribute(value = "userId", required = false) Long userId,
                                        @RequestAttribute("role") String role) {
                                        @RequestAttribute("role") String role) {
-        if (!"admin".equals(role) && !"activity_admin".equals(role)) {
-            return Result.error("只有管理员或活动管理员可以创建活动");
+        if (!"admin".equals(role) && !"activity_admin".equals(role) && userId == null) {
+            return Result.error("普通用户需登录后创建活动");
         }
         }
         if (userId != null) activity.setVendorId(userId);
         if (userId != null) activity.setVendorId(userId);
-        return activityService.create(activity);
+        return activityService.create(activity, userId);
     }
     }
 
 
     @PostMapping("/update")
     @PostMapping("/update")
     public Result<ActivityDTO> update(@RequestBody Activity activity,
     public Result<ActivityDTO> update(@RequestBody Activity activity,
+                                       @RequestAttribute(value = "userId", required = false) Long userId,
                                        @RequestAttribute("role") String role) {
                                        @RequestAttribute("role") String role) {
         if (!"admin".equals(role) && !"activity_admin".equals(role)) {
         if (!"admin".equals(role) && !"activity_admin".equals(role)) {
-            return Result.error("只有管理员或活动管理员可以修改活动");
+            Activity existing = activityService.getById(activity.getId());
+            if (existing == null) return Result.error("活动不存在");
+            if (userId == null || existing.getCreatorId() == null || !existing.getCreatorId().equals(userId)) {
+                return Result.error("只有创建人或管理员可以修改活动");
+            }
         }
         }
         return activityService.update(activity);
         return activityService.update(activity);
     }
     }
@@ -72,15 +76,38 @@ public class ActivityController {
 
 
     @PostMapping("/end")
     @PostMapping("/end")
     public Result<String> end(@RequestBody Map<String, Object> params,
     public Result<String> end(@RequestBody Map<String, Object> params,
+                               @RequestAttribute(value = "userId", required = false) Long userId,
                                @RequestAttribute("role") String role) {
                                @RequestAttribute("role") String role) {
         if (!"admin".equals(role) && !"activity_admin".equals(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不能为空");
+            Activity existing = activityService.getById(id);
+            if (existing == null) return Result.error("活动不存在");
+            if (userId == null || existing.getCreatorId() == null || !existing.getCreatorId().equals(userId)) {
+                return Result.error("只有创建人或管理员可以结束活动");
+            }
         }
         }
         Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
         Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
         if (id == null) return Result.error("id不能为空");
         if (id == null) return Result.error("id不能为空");
         return activityService.end(id);
         return activityService.end(id);
     }
     }
 
 
+    @PostMapping("/submit-review")
+    public Result<String> submitReview(@RequestBody Map<String, Object> params,
+                                        @RequestAttribute(value = "userId", required = false) Long userId,
+                                        @RequestAttribute("role") String role) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id不能为空");
+        Activity existing = activityService.getById(id);
+        if (existing == null) return Result.error("活动不存在");
+        boolean isAdmin = "admin".equals(role) || "activity_admin".equals(role);
+        if (!isAdmin && (userId == null || existing.getCreatorId() == null || !existing.getCreatorId().equals(userId))) {
+            return Result.error("只有创建人或管理员可以提交审核");
+        }
+        activityAdminService.submitForReview(id);
+        return Result.success("已提交审核");
+    }
+
     @PostMapping("/register")
     @PostMapping("/register")
     public Result<Map<String, Object>> register(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId) {
     public Result<Map<String, Object>> register(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId) {
         Long activityId = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
         Long activityId = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
@@ -109,10 +136,9 @@ public class ActivityController {
     @PostMapping("/my-registrations")
     @PostMapping("/my-registrations")
     public Result<Map<String, Object>> myRegistrations(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId) {
     public Result<Map<String, Object>> myRegistrations(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId) {
         Long childId = params.get("childId") != null ? Long.valueOf(params.get("childId").toString()) : null;
         Long childId = params.get("childId") != null ? Long.valueOf(params.get("childId").toString()) : null;
-        String status = (String) params.get("status");
         Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
         Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
         Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
         Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
-        return registrationService.getMyRegistrations(userId, childId, page, size, status);
+        return registrationService.getMyRegistrations(userId, childId, page, size);
     }
     }
 
 
     @PostMapping("/checkin")
     @PostMapping("/checkin")

+ 50 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityMaterialController.java

@@ -0,0 +1,50 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ActivityMaterial;
+import com.etotem.cfc.service.ActivityMaterialService;
+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/activity/material")
+public class ActivityMaterialController {
+
+    @Resource
+    private ActivityMaterialService materialService;
+
+    @PostMapping("/upload")
+    public Result<ActivityMaterial> upload(@RequestBody Map<String, Object> params,
+                                           @RequestAttribute("userId") Long userId,
+                                           @RequestAttribute("role") String role) {
+        Long activityId = params.get("activityId") != null ? Long.valueOf(params.get("activityId").toString()) : null;
+        if (activityId == null) return Result.error("活动ID不能为空");
+        String title = (String) params.get("title");
+        String fileUrl = (String) params.get("fileUrl");
+        String fileType = (String) params.get("fileType");
+        return materialService.upload(activityId, userId, role, title, fileUrl, fileType);
+    }
+
+    @PostMapping("/list")
+    public Result<List<ActivityMaterial>> list(@RequestBody Map<String, Object> params) {
+        Long activityId = params.get("activityId") != null ? Long.valueOf(params.get("activityId").toString()) : null;
+        if (activityId == null) return Result.error("活动ID不能为空");
+        return materialService.list(activityId);
+    }
+
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params,
+                                 @RequestAttribute("userId") Long userId,
+                                 @RequestAttribute("role") String role) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("资料ID不能为空");
+        return materialService.delete(id, userId, role);
+    }
+}

+ 99 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityOrderController.java

@@ -0,0 +1,99 @@
+package com.etotem.cfc.controller;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.ActivityOrderService;
+import com.etotem.cfc.service.PaymentService;
+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 javax.servlet.http.HttpServletRequest;
+import java.io.BufferedReader;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/activity/order")
+public class ActivityOrderController {
+
+    @Resource
+    private ActivityOrderService orderService;
+
+    @Resource
+    private PaymentService paymentService;
+
+    @PostMapping("/create")
+    public Result<Map<String, Object>> create(@RequestBody Map<String, Object> params,
+                                              @RequestAttribute("userId") Long userId) {
+        Long activityId = params.get("activityId") != null ? Long.valueOf(params.get("activityId").toString()) : null;
+        if (activityId == null) return Result.error("活动ID不能为空");
+        Object registrantsObj = params.get("registrants");
+        if (!(registrantsObj instanceof List)) return Result.error("报名人不能为空");
+        List<Map<String, Object>> registrants = (List<Map<String, Object>>) registrantsObj;
+        return orderService.create(activityId, userId, registrants);
+    }
+
+    @PostMapping("/pay")
+    public Result<Map<String, Object>> pay(@RequestBody Map<String, Object> params,
+                                           @RequestAttribute("userId") Long userId) {
+        String orderNo = (String) params.get("orderNo");
+        if (orderNo == null || orderNo.isEmpty()) return Result.error("订单号不能为空");
+        return orderService.pay(orderNo, userId);
+    }
+
+    @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 {
+            BufferedReader reader = request.getReader();
+            StringBuilder sb = new StringBuilder();
+            String line;
+            while ((line = reader.readLine()) != null) {
+                sb.append(line);
+            }
+            String requestBody = sb.toString();
+            String signature = request.getHeader("Wechatpay-Signature");
+
+            Map<String, Object> result = paymentService.handleWechatNotify(requestBody, signature);
+
+            if ("SUCCESS".equals(result.get("code"))) {
+                JSONObject body = JSON.parseObject(requestBody);
+                JSONObject resource = body.getJSONObject("resource");
+                if (resource != null) {
+                    String ciphertext = resource.getString("ciphertext");
+                    String associatedData = resource.getString("associated_data");
+                    String nonce = resource.getString("nonce");
+                    String plaintext = paymentService.decryptAes256Gcm(ciphertext, associatedData, nonce);
+                    JSONObject payResult = JSON.parseObject(plaintext);
+                    String orderNo = payResult.getString("out_trade_no");
+                    String transactionId = payResult.getString("transaction_id");
+                    orderService.handlePaymentSuccess(orderNo, transactionId);
+                }
+            }
+            return result;
+        } catch (Exception e) {
+            Map<String, Object> err = new HashMap<>();
+            err.put("code", "FAIL");
+            err.put("message", "处理异常");
+            return err;
+        }
+    }
+
+    @PostMapping("/status")
+    public Result<Map<String, Object>> status(@RequestBody Map<String, Object> params) {
+        String orderNo = (String) params.get("orderNo");
+        if (orderNo == null || orderNo.isEmpty()) return Result.error("订单号不能为空");
+        return orderService.status(orderNo);
+    }
+}

+ 35 - 18
cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java

@@ -3,8 +3,6 @@ package com.etotem.cfc.dto;
 import com.etotem.cfc.entity.Activity;
 import com.etotem.cfc.entity.Activity;
 import lombok.Data;
 import lombok.Data;
 
 
-import java.text.SimpleDateFormat;
-import java.util.Date;
 
 
 
 
 @Data
 @Data
@@ -20,14 +18,6 @@ public class ActivityDTO {
     private String status;
     private String status;
     private String startTime;
     private String startTime;
     private String endTime;
     private String endTime;
-    private String registrationDeadline;
-
-    /** 当前用户是否已报名 */
-    private Boolean registered;
-
-    /** 当前用户的报名状态: pending/approved/rejected/cancelled */
-    private String registrationStatus;
-
     private String location;
     private String location;
     private Integer maxParticipants;
     private Integer maxParticipants;
     private Integer currentParticipants;
     private Integer currentParticipants;
@@ -44,6 +34,33 @@ public class ActivityDTO {
     /** 会员价(分) */
     /** 会员价(分) */
     private Integer memberPrice;
     private Integer memberPrice;
 
 
+    /** 报名截止时间 */
+    private String registrationDeadline;
+
+    /** 孩子·非会员价(分) */
+    private Integer childFee;
+
+    /** 孩子·会员价(分), 0=免费 */
+    private Integer childMemberFee;
+
+    /** 家长·非会员价(分) */
+    private Integer parentFee;
+
+    /** 家长·会员价(分), 0=免费 */
+    private Integer parentMemberFee;
+
+    /** 创建人userId */
+    private Long creatorId;
+
+    /** 展示状态: open/registered/ongoing/checked_in/summarizing/ended/pending_start */
+    private String displayStatus;
+
+    /** 展示状态文案: 报名中/已报名/进行中/已签到/总结中/已结束/待开始 */
+    private String displayLabel;
+
+    /** 报名状态: not_registered/registered/pending/rejected/cancelled/checked_in */
+    private String registrationStatus;
+
     /** 评价维度列表JSON(如 ["组织形式","会场气氛","主持人","专家"]) */
     /** 评价维度列表JSON(如 ["组织形式","会场气氛","主持人","专家"]) */
     private String feedbackDimensions;
     private String feedbackDimensions;
 
 
@@ -65,9 +82,8 @@ public class ActivityDTO {
         dto.setDimensionWeights(activity.getDimensionWeights());
         dto.setDimensionWeights(activity.getDimensionWeights());
         dto.setActivityType(activity.getActivityType());
         dto.setActivityType(activity.getActivityType());
         dto.setStatus(activity.getStatus());
         dto.setStatus(activity.getStatus());
-        dto.setStartTime(activity.getStartTime() != null ? fmt(activity.getStartTime()) : null);
-        dto.setEndTime(activity.getEndTime() != null ? fmt(activity.getEndTime()) : null);
-        dto.setRegistrationDeadline(activity.getRegistrationDeadline() != null ? fmt(activity.getRegistrationDeadline()) : null);
+        dto.setStartTime(activity.getStartTime() != null ? activity.getStartTime().toString() : null);
+        dto.setEndTime(activity.getEndTime() != null ? activity.getEndTime().toString() : null);
         dto.setLocation(activity.getLocation());
         dto.setLocation(activity.getLocation());
         dto.setMaxParticipants(activity.getMaxParticipants());
         dto.setMaxParticipants(activity.getMaxParticipants());
         dto.setCurrentParticipants(activity.getCurrentParticipants());
         dto.setCurrentParticipants(activity.getCurrentParticipants());
@@ -75,6 +91,12 @@ public class ActivityDTO {
         dto.setRequireRegistration(activity.getRequireRegistration());
         dto.setRequireRegistration(activity.getRequireRegistration());
         dto.setRequireCheckinReview(activity.getRequireCheckinReview());
         dto.setRequireCheckinReview(activity.getRequireCheckinReview());
         dto.setMemberPrice(activity.getMemberPrice());
         dto.setMemberPrice(activity.getMemberPrice());
+        dto.setRegistrationDeadline(activity.getRegistrationDeadline() != null ? activity.getRegistrationDeadline().toString() : null);
+        dto.setChildFee(activity.getChildFee());
+        dto.setChildMemberFee(activity.getChildMemberFee());
+        dto.setParentFee(activity.getParentFee());
+        dto.setParentMemberFee(activity.getParentMemberFee());
+        dto.setCreatorId(activity.getCreatorId());
         dto.setFeedbackDimensions(activity.getFeedbackDimensions());
         dto.setFeedbackDimensions(activity.getFeedbackDimensions());
         // Member-aware pricing: resolve final display price based on membership level
         // Member-aware pricing: resolve final display price based on membership level
         // Guests (no memberLevel) see base price; members see memberPrice if applicable
         // Guests (no memberLevel) see base price; members see memberPrice if applicable
@@ -102,9 +124,4 @@ public class ActivityDTO {
         }
         }
         return basePrice;
         return basePrice;
     }
     }
-
-    /** 格式化日期为 "yyyy-MM-dd HH:mm",每次调用新建 SimpleDateFormat(非线程共享) */
-    private static String fmt(Date d) {
-        return new SimpleDateFormat("yyyy-MM-dd HH:mm").format(d);
-    }
 }
 }

+ 18 - 3
cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java

@@ -46,9 +46,6 @@ public class Activity implements Serializable {
 
 
     private Date endTime;
     private Date endTime;
 
 
-    /** 报名截止时间 */
-    private Date registrationDeadline;
-
     private String location;
     private String location;
 
 
     private Integer maxParticipants;
     private Integer maxParticipants;
@@ -60,6 +57,24 @@ public class Activity implements Serializable {
     /** 会员价(分) */
     /** 会员价(分) */
     private Integer memberPrice;
     private Integer memberPrice;
 
 
+    /** 报名截止时间(NULL=不限制, 默认=活动开始时间) */
+    private Date registrationDeadline;
+
+    /** 孩子·非会员价(分) */
+    private Integer childFee;
+
+    /** 孩子·会员价(分), 0=免费 */
+    private Integer childMemberFee;
+
+    /** 家长·非会员价(分) */
+    private Integer parentFee;
+
+    /** 家长·会员价(分), 0=免费 */
+    private Integer parentMemberFee;
+
+    /** 创建人userId(普通用户发布者, admin创建可为空) */
+    private Long creatorId;
+
     /** 是否面向家庭: 1=家庭成员参与过即排除, 0=仅按个人参与排除 */
     /** 是否面向家庭: 1=家庭成员参与过即排除, 0=仅按个人参与排除 */
     private Integer familyScope;
     private Integer familyScope;
 
 

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityMaterial.java

@@ -0,0 +1,23 @@
+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("activity_materials")
+public class ActivityMaterial implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long activityId;
+    private String title;
+    private String fileUrl;
+    private String fileType;
+    private Date createdAt;
+}

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityOrder.java

@@ -0,0 +1,27 @@
+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("activity_orders")
+public class ActivityOrder implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private String orderNo;
+    private Long activityId;
+    private Long userId;
+    private Integer totalAmount;
+    private String registrantsJson;
+    private String status;
+    private String transactionId;
+    private Date paidAt;
+    private Date createdAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ActivityMaterialMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ActivityMaterial;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ActivityMaterialMapper extends BaseMapper<ActivityMaterial> {
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ActivityOrderMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ActivityOrder;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ActivityOrderMapper extends BaseMapper<ActivityOrder> {
+}

+ 67 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityMaterialService.java

@@ -0,0 +1,67 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.ActivityMaterial;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ActivityMaterialMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Slf4j
+@Service
+public class ActivityMaterialService {
+
+    @Resource
+    private ActivityMaterialMapper materialMapper;
+
+    @Resource
+    private ActivityMapper activityMapper;
+
+    public Result<ActivityMaterial> upload(Long activityId, Long userId, String role, String title, String fileUrl, String fileType) {
+        if (fileUrl == null || fileUrl.trim().isEmpty()) return Result.error("文件地址不能为空");
+        Activity activity = activityMapper.selectById(activityId);
+        if (activity == null) return Result.error("活动不存在");
+        boolean isPublisher = "admin".equals(role) || "activity_admin".equals(role)
+                || (activity.getCreatorId() != null && activity.getCreatorId().equals(userId));
+        if (!isPublisher) return Result.error("仅发布人可上传资料");
+        if (!isSummarizing(activity)) return Result.error("仅活动结束进入总结阶段后可上传资料");
+
+        ActivityMaterial material = new ActivityMaterial();
+        material.setActivityId(activityId);
+        material.setTitle(title);
+        material.setFileUrl(fileUrl.trim());
+        material.setFileType(fileType != null && !fileType.isEmpty() ? fileType : "image");
+        material.setCreatedAt(new Date());
+        materialMapper.insert(material);
+        return Result.success(material);
+    }
+
+    public Result<List<ActivityMaterial>> list(Long activityId) {
+        return Result.success(materialMapper.selectList(new LambdaQueryWrapper<ActivityMaterial>()
+            .eq(ActivityMaterial::getActivityId, activityId)
+            .orderByDesc(ActivityMaterial::getCreatedAt)));
+    }
+
+    public Result<String> delete(Long id, Long userId, String role) {
+        ActivityMaterial material = materialMapper.selectById(id);
+        if (material == null) return Result.error("资料不存在");
+        Activity activity = activityMapper.selectById(material.getActivityId());
+        boolean isPublisher = activity != null && ("admin".equals(role) || "activity_admin".equals(role)
+                || (activity.getCreatorId() != null && activity.getCreatorId().equals(userId)));
+        if (!isPublisher) return Result.error("仅发布人可删除资料");
+        materialMapper.deleteById(id);
+        return Result.success("删除成功");
+    }
+
+    private boolean isSummarizing(Activity activity) {
+        Date now = new Date();
+        return "published".equals(activity.getStatus())
+                && activity.getEndTime() != null && now.after(activity.getEndTime());
+    }
+}

+ 203 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityOrderService.java

@@ -0,0 +1,203 @@
+package com.etotem.cfc.service;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.TypeReference;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.ActivityOrder;
+import com.etotem.cfc.entity.ActivityRegistration;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ActivityOrderMapper;
+import com.etotem.cfc.mapper.ActivityRegistrationMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+@Slf4j
+@Service
+public class ActivityOrderService {
+
+    @Resource
+    private ActivityOrderMapper orderMapper;
+
+    @Resource
+    private ActivityMapper activityMapper;
+
+    @Resource
+    private ActivityRegistrationMapper registrationMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private MembershipService membershipService;
+
+    @Resource
+    private PaymentService paymentService;
+
+    public Result<Map<String, Object>> create(Long activityId, Long userId, List<Map<String, Object>> registrants) {
+        Activity activity = activityMapper.selectById(activityId);
+        if (activity == null) return Result.error("活动不存在");
+        if (!"published".equals(activity.getStatus())) return Result.error("活动未发布");
+        if (activity.getRequireRegistration() != null && activity.getRequireRegistration() == 0) {
+            return Result.error("该活动无需报名");
+        }
+        if (activity.getRegistrationDeadline() != null
+                && activity.getRegistrationDeadline().toInstant().isBefore(java.time.Instant.now())) {
+            return Result.error("报名已截止");
+        }
+        if (registrants == null || registrants.isEmpty()) return Result.error("报名人不能为空");
+
+        String memberLevel = null;
+        try { memberLevel = membershipService.getMemberLevel(userId); } catch (Exception e) { /* ignore */ }
+        boolean isMember = "FAMILY".equals(memberLevel) || "PROVIDER".equals(memberLevel);
+
+        int totalAmount = 0;
+        List<Map<String, Object>> snapshot = new ArrayList<>();
+        for (Map<String, Object> reg : registrants) {
+            String name = (String) reg.get("name");
+            if (name == null || name.trim().isEmpty()) return Result.error("报名人姓名不能为空");
+            String phone = (String) reg.get("phone");
+            Object childIdObj = reg.get("childId");
+            Long childId = childIdObj != null ? Long.valueOf(childIdObj.toString()) : null;
+            if (childId != null && isRegistered(activityId, childId)) {
+                return Result.error("已报名: " + name);
+            }
+            boolean isChild = childId != null;
+            Integer fee = isChild
+                    ? (isMember ? activity.getChildMemberFee() : activity.getChildFee())
+                    : (isMember ? activity.getParentMemberFee() : activity.getParentFee());
+            if (fee == null) fee = 0;
+            totalAmount += fee;
+            Map<String, Object> item = new HashMap<>();
+            item.put("name", name.trim());
+            item.put("phone", phone != null ? phone.trim() : null);
+            item.put("childId", childId);
+            item.put("type", isChild ? "child" : "parent");
+            item.put("fee", fee);
+            snapshot.add(item);
+        }
+
+        if (totalAmount == 0) {
+            List<Long> registrationIds = writeRegistrations(activityId, userId, snapshot, "approved");
+            Map<String, Object> result = new HashMap<>();
+            result.put("registrationIds", registrationIds);
+            result.put("count", registrationIds.size());
+            result.put("totalAmount", 0);
+            result.put("free", true);
+            return Result.success(result);
+        }
+
+        ActivityOrder order = new ActivityOrder();
+        order.setOrderNo("ACT" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 6).toUpperCase());
+        order.setActivityId(activityId);
+        order.setUserId(userId);
+        order.setTotalAmount(totalAmount);
+        order.setRegistrantsJson(JSON.toJSONString(snapshot));
+        order.setStatus("pending");
+        order.setCreatedAt(new Date());
+        orderMapper.insert(order);
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("orderId", order.getId());
+        result.put("orderNo", order.getOrderNo());
+        result.put("totalAmount", totalAmount);
+        result.put("requiresPayment", true);
+        return Result.success(result);
+    }
+
+    public Result<Map<String, Object>> pay(String orderNo, Long userId) {
+        ActivityOrder order = getByOrderNo(orderNo);
+        if (order == null) return Result.error("订单不存在");
+        if (!order.getUserId().equals(userId)) return Result.error("无权操作此订单");
+        if (!"pending".equals(order.getStatus())) return Result.error("订单状态异常");
+        User user = userMapper.selectById(userId);
+        String openid = user == null ? null : user.getOpenid();
+        boolean invalidOpenid = openid == null || openid.isEmpty()
+                || openid.startsWith("phone:") || openid.startsWith("mock_openid_");
+        if (invalidOpenid) {
+            return Result.error("未绑定微信账号,请通过微信登录后再支付");
+        }
+        Map<String, Object> wechatPayParams = paymentService.createWechatPrepay(
+                orderNo, "活动报名", order.getTotalAmount(), openid);
+        Map<String, Object> result = new HashMap<>();
+        result.put("wechatPayParams", wechatPayParams);
+        result.put("orderNo", orderNo);
+        return Result.success(result);
+    }
+
+    @Transactional
+    public Result<String> handlePaymentSuccess(String orderNo, String transactionId) {
+        ActivityOrder order = getByOrderNo(orderNo);
+        if (order == null) return Result.error("订单不存在");
+        if (!"pending".equals(order.getStatus())) {
+            return Result.success("订单状态非待支付,跳过");
+        }
+        order.setStatus("paid");
+        order.setTransactionId(transactionId);
+        order.setPaidAt(new Date());
+        orderMapper.updateById(order);
+
+        List<Map<String, Object>> snapshot = JSON.parseObject(order.getRegistrantsJson(),
+            new TypeReference<List<Map<String, Object>>>() {});
+        writeRegistrations(order.getActivityId(), order.getUserId(), snapshot, "approved");
+        return Result.success("支付成功");
+    }
+
+    public Result<Map<String, Object>> status(String orderNo) {
+        ActivityOrder order = getByOrderNo(orderNo);
+        if (order == null) return Result.error("订单不存在");
+        Map<String, Object> result = new HashMap<>();
+        result.put("orderNo", order.getOrderNo());
+        result.put("status", order.getStatus());
+        result.put("totalAmount", order.getTotalAmount());
+        result.put("paidAt", order.getPaidAt());
+        return Result.success(result);
+    }
+
+    private List<Long> writeRegistrations(Long activityId, Long userId, List<Map<String, Object>> snapshot, String status) {
+        List<Long> ids = new ArrayList<>();
+        for (Map<String, Object> item : snapshot) {
+            Object childIdObj = item.get("childId");
+            Long childId = childIdObj != null ? Long.valueOf(childIdObj.toString()) : null;
+            if (childId != null && isRegistered(activityId, childId)) continue;
+            ActivityRegistration ar = new ActivityRegistration();
+            ar.setActivityId(activityId);
+            ar.setUserId(userId);
+            ar.setChildId(childId);
+            ar.setRegistrantName((String) item.get("name"));
+            ar.setRegistrantPhone((String) item.get("phone"));
+            ar.setStatus(status);
+            ar.setRegisteredAt(LocalDateTime.now());
+            ar.setCreatedAt(LocalDateTime.now());
+            ar.setUpdatedAt(LocalDateTime.now());
+            registrationMapper.insert(ar);
+            ids.add(ar.getId());
+        }
+        return ids;
+    }
+
+    private boolean isRegistered(Long activityId, Long childId) {
+        return registrationMapper.selectOne(new LambdaQueryWrapper<ActivityRegistration>()
+            .eq(ActivityRegistration::getActivityId, activityId)
+            .eq(ActivityRegistration::getChildId, childId)
+            .in(ActivityRegistration::getStatus, "pending", "approved").last("LIMIT 1")) != null;
+    }
+
+    private ActivityOrder getByOrderNo(String orderNo) {
+        return orderMapper.selectOne(new LambdaQueryWrapper<ActivityOrder>()
+            .eq(ActivityOrder::getOrderNo, orderNo));
+    }
+}

+ 75 - 48
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java

@@ -4,18 +4,18 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.Activity;
 import com.etotem.cfc.entity.Activity;
+import com.etotem.cfc.entity.ActivityOrder;
 import com.etotem.cfc.entity.ActivityRegistration;
 import com.etotem.cfc.entity.ActivityRegistration;
 import com.etotem.cfc.mapper.ActivityMapper;
 import com.etotem.cfc.mapper.ActivityMapper;
+import com.etotem.cfc.mapper.ActivityOrderMapper;
 import com.etotem.cfc.mapper.ActivityRegistrationMapper;
 import com.etotem.cfc.mapper.ActivityRegistrationMapper;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
-import java.text.SimpleDateFormat;
 import java.time.LocalDateTime;
 import java.time.LocalDateTime;
-import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.ArrayList;
-import java.util.Date;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashMap;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
@@ -28,6 +28,8 @@ public class ActivityRegistrationService {
     private ActivityRegistrationMapper registrationMapper;
     private ActivityRegistrationMapper registrationMapper;
     @Resource
     @Resource
     private ActivityMapper activityMapper;
     private ActivityMapper activityMapper;
+    @Resource
+    private ActivityOrderMapper orderMapper;
 
 
     @Transactional
     @Transactional
     public Result<Map<String, Object>> batchRegister(Long activityId, Long userId, List<Map<String, Object>> registrants) {
     public Result<Map<String, Object>> batchRegister(Long activityId, Long userId, List<Map<String, Object>> registrants) {
@@ -37,6 +39,8 @@ public class ActivityRegistrationService {
         if (activity.getRequireRegistration() != null && activity.getRequireRegistration() == 0) {
         if (activity.getRequireRegistration() != null && activity.getRequireRegistration() == 0) {
             return Result.error("该活动无需报名");
             return Result.error("该活动无需报名");
         }
         }
+        Result<Void> gate = checkRegistrationGate(activity, userId);
+        if (gate != null) return Result.error(gate.getMessage());
         List<Long> ids = new ArrayList<>();
         List<Long> ids = new ArrayList<>();
         for (Map<String, Object> reg : registrants) {
         for (Map<String, Object> reg : registrants) {
             String name = (String) reg.get("name");
             String name = (String) reg.get("name");
@@ -71,6 +75,8 @@ public class ActivityRegistrationService {
         if (activity.getRequireRegistration() != null && activity.getRequireRegistration() == 0) {
         if (activity.getRequireRegistration() != null && activity.getRequireRegistration() == 0) {
             return Result.error("该活动无需报名");
             return Result.error("该活动无需报名");
         }
         }
+        Result<Void> gate = checkRegistrationGate(activity, userId);
+        if (gate != null) return Result.error(gate.getMessage());
         if (isRegistered(activityId, childId)) return Result.error("您已报名此活动");
         if (isRegistered(activityId, childId)) return Result.error("您已报名此活动");
         ActivityRegistration reg = new ActivityRegistration();
         ActivityRegistration reg = new ActivityRegistration();
         reg.setActivityId(activityId); reg.setUserId(userId); reg.setChildId(childId);
         reg.setActivityId(activityId); reg.setUserId(userId); reg.setChildId(childId);
@@ -113,19 +119,15 @@ public class ActivityRegistrationService {
         return Result.success(null);
         return Result.success(null);
     }
     }
 
 
-    public Result<Map<String, Object>> getMyRegistrations(Long userId, Long childId, Integer page, Integer size, String status) {
+    public Result<Map<String, Object>> getMyRegistrations(Long userId, Long childId, Integer page, Integer size) {
         if (page == null || page < 1) page = 1;
         if (page == null || page < 1) page = 1;
         if (size == null || size < 1) size = 10;
         if (size == null || size < 1) size = 10;
         LambdaQueryWrapper<ActivityRegistration> wrapper = new LambdaQueryWrapper<>();
         LambdaQueryWrapper<ActivityRegistration> wrapper = new LambdaQueryWrapper<>();
+        if (userId != null) wrapper.eq(ActivityRegistration::getUserId, userId);
         if (childId != null) {
         if (childId != null) {
-            // 按孩子查询,同时包含手动录入的报名(child_id 为 NULL 且属于该用户)
+            // 手动录入的报名 child_id 为 NULL,也属于该用户,需一并返回
             wrapper.and(w -> w.eq(ActivityRegistration::getChildId, childId)
             wrapper.and(w -> w.eq(ActivityRegistration::getChildId, childId)
-                .or(inner -> inner.isNull(ActivityRegistration::getChildId).eq(ActivityRegistration::getUserId, userId)));
-        } else if (userId != null) {
-            wrapper.eq(ActivityRegistration::getUserId, userId);
-        }
-        if (status != null && !status.isEmpty()) {
-            wrapper.eq(ActivityRegistration::getStatus, status);
+                .or().isNull(ActivityRegistration::getChildId));
         }
         }
         wrapper.orderByDesc(ActivityRegistration::getRegisteredAt);
         wrapper.orderByDesc(ActivityRegistration::getRegisteredAt);
         Page<ActivityRegistration> pageResult = registrationMapper.selectPage(new Page<>(page, size), wrapper);
         Page<ActivityRegistration> pageResult = registrationMapper.selectPage(new Page<>(page, size), wrapper);
@@ -135,20 +137,11 @@ public class ActivityRegistrationService {
             item.put("childId", reg.getChildId()); item.put("status", reg.getStatus());
             item.put("childId", reg.getChildId()); item.put("status", reg.getStatus());
             item.put("registrantName", reg.getRegistrantName());
             item.put("registrantName", reg.getRegistrantName());
             item.put("registrantPhone", reg.getRegistrantPhone());
             item.put("registrantPhone", reg.getRegistrantPhone());
-            item.put("registeredAt", fmtLocal(reg.getRegisteredAt()));
+            item.put("registeredAt", reg.getRegisteredAt());
             Activity activity = activityMapper.selectById(reg.getActivityId());
             Activity activity = activityMapper.selectById(reg.getActivityId());
             if (activity != null) {
             if (activity != null) {
                 item.put("activityTitle", activity.getTitle());
                 item.put("activityTitle", activity.getTitle());
-                item.put("activityDescription", activity.getDescription());
-                item.put("activityCoverImage", activity.getCoverImage());
-                item.put("activityLocation", activity.getLocation());
-                item.put("activityPrice", activity.getPrice());
-                item.put("activityMemberPrice", activity.getMemberPrice());
-                item.put("activityStartTime", fmt(activity.getStartTime()));
-                item.put("activityEndTime", fmt(activity.getEndTime()));
-                item.put("activityRegistrationDeadline", fmt(activity.getRegistrationDeadline()));
-                item.put("activityRequireRegistration", activity.getRequireRegistration());
-                item.put("activityStatus", activity.getStatus());
+                item.put("activityStartTime", activity.getStartTime());
             }
             }
             return item;
             return item;
         }).collect(Collectors.toList());
         }).collect(Collectors.toList());
@@ -158,32 +151,6 @@ public class ActivityRegistrationService {
         return Result.success(data);
         return Result.success(data);
     }
     }
 
 
-    /**
-     * 返回当前用户对每个活动的报名状态(activityId -> status),仅 pending/approved 计入"已报名"
-     */
-    public Map<Long, String> getRegisteredStatusMap(Long userId) {
-        Map<Long, String> result = new HashMap<>();
-        if (userId == null) return result;
-        List<ActivityRegistration> list = registrationMapper.selectList(
-            new LambdaQueryWrapper<ActivityRegistration>()
-                .eq(ActivityRegistration::getUserId, userId)
-                .in(ActivityRegistration::getStatus, "pending", "approved"));
-        for (ActivityRegistration reg : list) {
-            result.put(reg.getActivityId(), reg.getStatus());
-        }
-        return result;
-    }
-
-    /** 格式化 Date 为 "yyyy-MM-dd HH:mm",每次调用新建 SimpleDateFormat(非线程共享) */
-    private String fmt(Date d) {
-        return d != null ? new SimpleDateFormat("yyyy-MM-dd HH:mm").format(d) : null;
-    }
-
-    /** 格式化 LocalDateTime 为 "yyyy-MM-dd HH:mm" */
-    private String fmtLocal(LocalDateTime t) {
-        return t != null ? t.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) : null;
-    }
-
     public Result<List<Map<String, Object>>> getRegistrationsByActivity(Long activityId, String status) {
     public Result<List<Map<String, Object>>> getRegistrationsByActivity(Long activityId, String status) {
         LambdaQueryWrapper<ActivityRegistration> wrapper = new LambdaQueryWrapper<ActivityRegistration>()
         LambdaQueryWrapper<ActivityRegistration> wrapper = new LambdaQueryWrapper<ActivityRegistration>()
             .eq(ActivityRegistration::getActivityId, activityId).orderByDesc(ActivityRegistration::getRegisteredAt);
             .eq(ActivityRegistration::getActivityId, activityId).orderByDesc(ActivityRegistration::getRegisteredAt);
@@ -201,6 +168,66 @@ public class ActivityRegistrationService {
         return Result.success(result);
         return Result.success(result);
     }
     }
 
 
+    public Map<Long, String> getRegistrationStatusMap(List<Long> activityIds, Long userId) {
+        Map<Long, String> map = new HashMap<>();
+        if (activityIds == null || activityIds.isEmpty() || userId == null) return map;
+        List<ActivityRegistration> regs = registrationMapper.selectList(new LambdaQueryWrapper<ActivityRegistration>()
+            .in(ActivityRegistration::getActivityId, activityIds)
+            .eq(ActivityRegistration::getUserId, userId)
+            .in(ActivityRegistration::getStatus, "pending", "approved", "rejected"));
+        for (ActivityRegistration reg : regs) {
+            String cur = reg.getStatus();
+            String prev = map.get(reg.getActivityId());
+            // 优先级: approved > pending > rejected
+            if (prev == null || rank(prev) < rank(cur)) {
+                map.put(reg.getActivityId(), cur);
+            }
+        }
+        return map;
+    }
+
+    public String getUserRegistrationStatus(Long activityId, Long userId) {
+        Map<Long, String> map = getRegistrationStatusMap(Collections.singletonList(activityId), userId);
+        return map.getOrDefault(activityId, "not_registered");
+    }
+
+    public List<Long> getChildIdsByUser(Long userId) {
+        if (userId == null) return new ArrayList<>();
+        return registrationMapper.selectList(new LambdaQueryWrapper<ActivityRegistration>()
+            .eq(ActivityRegistration::getUserId, userId)
+            .isNotNull(ActivityRegistration::getChildId))
+            .stream().map(ActivityRegistration::getChildId).distinct().collect(Collectors.toList());
+    }
+
+    private int rank(String status) {
+        if ("approved".equals(status)) return 3;
+        if ("pending".equals(status)) return 2;
+        return 1;
+    }
+
+    private Result<Void> checkRegistrationGate(Activity activity, Long userId) {
+        if (activity.getRegistrationDeadline() != null
+                && activity.getRegistrationDeadline().toInstant().isBefore(java.time.Instant.now())) {
+            return Result.error("报名已截止");
+        }
+        boolean isPaid = (activity.getChildFee() != null && activity.getChildFee() > 0)
+                || (activity.getChildMemberFee() != null && activity.getChildMemberFee() > 0)
+                || (activity.getParentFee() != null && activity.getParentFee() > 0)
+                || (activity.getParentMemberFee() != null && activity.getParentMemberFee() > 0);
+        if (isPaid && !hasPaidOrder(activity.getId(), userId)) {
+            return Result.error("该活动为付费活动,请先完成支付后再报名");
+        }
+        return null;
+    }
+
+    private boolean hasPaidOrder(Long activityId, Long userId) {
+        if (activityId == null || userId == null) return false;
+        return orderMapper.selectCount(new LambdaQueryWrapper<ActivityOrder>()
+            .eq(ActivityOrder::getActivityId, activityId)
+            .eq(ActivityOrder::getUserId, userId)
+            .eq(ActivityOrder::getStatus, "paid")) > 0;
+    }
+
     public boolean isRegistered(Long activityId, Long childId) {
     public boolean isRegistered(Long activityId, Long childId) {
         return registrationMapper.selectOne(new LambdaQueryWrapper<ActivityRegistration>()
         return registrationMapper.selectOne(new LambdaQueryWrapper<ActivityRegistration>()
             .eq(ActivityRegistration::getActivityId, activityId)
             .eq(ActivityRegistration::getActivityId, activityId)

+ 157 - 40
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 package com.etotem.cfc.service;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
@@ -10,19 +11,24 @@ import com.etotem.cfc.entity.ActivityRegistration;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.PointsLog;
 import com.etotem.cfc.entity.PointsLog;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.ActivityMapper;
 import com.etotem.cfc.mapper.ActivityMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.PointsLogMapper;
 import com.etotem.cfc.mapper.PointsLogMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.util.Date;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.List;
 import java.util.Map;
 import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 
 
 @Slf4j
 @Slf4j
@@ -50,67 +56,90 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
     @Resource
     @Resource
     private MembershipService membershipService;
     private MembershipService membershipService;
 
 
-    public Result<Map<String, Object>> list(String dimensionCode, String status, boolean mine, Integer page, Integer size, Long userId, String role) {
-        boolean isAdmin = "admin".equals(role) || "activity_admin".equals(role);
-        boolean isGuest = (userId == null || userId <= 0);
-        LambdaQueryWrapper<Activity> query = new LambdaQueryWrapper<>();
-        if (isAdmin && mine) {
-            // 活动管理员:我的活动(我发布的所有活动)
-            query.eq(Activity::getVendorId, userId);
-        } else if (isAdmin && status != null && !status.isEmpty()) {
-            // 活动管理员:按状态过滤(如 ended 历史记录)
-            query.eq(Activity::getStatus, status);
-        } else {
-            query.eq(Activity::getStatus, "published")
-                    .orderByAsc(Activity::getStartTime);
-            if (dimensionCode != null && !dimensionCode.isEmpty()) {
-                // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧版 dimension_code
-                query.and(w -> w
-                    .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dimensionCode)
-                    .or().eq(Activity::getDimensionCode, dimensionCode)
-                );
-            }
-            // 游客过滤:只返回公开活动
-            if (isGuest) {
-                query.and(w -> w.eq(Activity::getVisibility, "public")
-                        .or().eq(Activity::getVisibility, "all")
-                        .or().isNull(Activity::getVisibility));
-            }
+    @Resource
+    private UserMapper userMapper;
+
+    public Result<Map<String, Object>> list(String dimensionCode, Integer page, Integer size, Long userId) {
+        autoEndExpiredActivities();
+        LambdaQueryWrapper<Activity> query = new LambdaQueryWrapper<Activity>()
+                .eq(Activity::getStatus, "published")
+                .orderByAsc(Activity::getStartTime);
+        if (dimensionCode != null && !dimensionCode.isEmpty()) {
+            // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧版 dimension_code
+            query.and(w -> w
+                .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dimensionCode)
+                .or().eq(Activity::getDimensionCode, dimensionCode)
+            );
+        }
+        // 游客过滤:只返回公开活动
+        if (userId == null) {
+            query.and(w -> w.eq(Activity::getVisibility, "public")
+                    .or().eq(Activity::getVisibility, "all")
+                    .or().isNull(Activity::getVisibility));
         }
         }
         Page<Activity> pageResult = this.page(new Page<>(page, size), query);
         Page<Activity> pageResult = this.page(new Page<>(page, size), query);
+        boolean isGuest = (userId == null || userId <= 0);
+        Long effectiveUserId = isGuest ? null : userId;
         String memberLevel = null;
         String memberLevel = null;
         if (!isGuest) {
         if (!isGuest) {
             try { memberLevel = membershipService.getMemberLevel(userId); } catch (Exception e) { /* ignore */ }
             try { memberLevel = membershipService.getMemberLevel(userId); } catch (Exception e) { /* ignore */ }
         }
         }
         final String level = memberLevel;
         final String level = memberLevel;
-        // 当前用户报名状态(activityId -> status),用于"已报名"标记
-        final Map<Long, String> regStatusMap = isGuest ? new HashMap<>() : registrationService.getRegisteredStatusMap(userId);
-        List<ActivityDTO> records = pageResult.getRecords().stream().map(a -> {
+        List<Activity> records = pageResult.getRecords();
+        List<Long> activityIds = records.stream().map(Activity::getId).collect(Collectors.toList());
+        Map<Long, String> regStatusMap = new HashMap<>();
+        Set<Long> checkedInActivityIds = new HashSet<>();
+        if (effectiveUserId != null && !activityIds.isEmpty()) {
+            regStatusMap = registrationService.getRegistrationStatusMap(activityIds, effectiveUserId);
+            List<Long> childIds = registrationService.getChildIdsByUser(effectiveUserId);
+            if (!childIds.isEmpty()) {
+                checkedInActivityIds = taskMapper.selectList(new LambdaQueryWrapper<Task>()
+                    .eq(Task::getSourceType, "activity")
+                    .in(Task::getSourceId, activityIds)
+                    .in(Task::getChildId, childIds)
+                    .eq(Task::getStatus, "completed"))
+                    .stream().map(Task::getSourceId).collect(Collectors.toSet());
+            }
+        }
+        final Map<Long, String> regMap = regStatusMap;
+        final Set<Long> checkedMap = checkedInActivityIds;
+        List<ActivityDTO> dtos = records.stream().map(a -> {
             ActivityDTO dto = ActivityDTO.from(a, isGuest, level);
             ActivityDTO dto = ActivityDTO.from(a, isGuest, level);
-            String regStatus = regStatusMap.get(a.getId());
-            dto.setRegistered(regStatus != null);
-            dto.setRegistrationStatus(regStatus);
+            String regStatus = regMap.getOrDefault(a.getId(), "not_registered");
+            applyDisplayState(dto, a, regStatus, checkedMap.contains(a.getId()));
             return dto;
             return dto;
         }).collect(Collectors.toList());
         }).collect(Collectors.toList());
         Map<String, Object> data = new HashMap<>();
         Map<String, Object> data = new HashMap<>();
-        data.put("records", records);
+        data.put("records", dtos);
         data.put("total", pageResult.getTotal());
         data.put("total", pageResult.getTotal());
         data.put("page", pageResult.getCurrent());
         data.put("page", pageResult.getCurrent());
         data.put("size", pageResult.getSize());
         data.put("size", pageResult.getSize());
         return Result.success(data);
         return Result.success(data);
     }
     }
 
 
-    public Result<ActivityDTO> detail(Long id) {
+    public Result<ActivityDTO> detail(Long id, Long userId) {
         Activity activity = this.getById(id);
         Activity activity = this.getById(id);
         if (activity == null) {
         if (activity == null) {
             return Result.error("活动不存在");
             return Result.error("活动不存在");
         }
         }
-        return Result.success(ActivityDTO.from(activity));
+        boolean isGuest = (userId == null || userId <= 0);
+        String memberLevel = null;
+        if (!isGuest) {
+            try { memberLevel = membershipService.getMemberLevel(userId); } catch (Exception e) { /* ignore */ }
+        }
+        ActivityDTO dto = ActivityDTO.from(activity, isGuest, memberLevel);
+        if (!isGuest) {
+            String regStatus = registrationService.getUserRegistrationStatus(activity.getId(), userId);
+            boolean checkedIn = hasCheckedIn(activity.getId(), userId);
+            applyDisplayState(dto, activity, regStatus, checkedIn);
+        }
+        return Result.success(dto);
     }
     }
 
 
-    public Result<ActivityDTO> create(Activity activity) {
+    public Result<ActivityDTO> create(Activity activity, Long creatorId) {
         activity.setStatus("draft");
         activity.setStatus("draft");
         activity.setCurrentParticipants(0);
         activity.setCurrentParticipants(0);
+        activity.setCreatorId(creatorId);
         activity.setCreatedAt(new Date());
         activity.setCreatedAt(new Date());
         activity.setUpdatedAt(new Date());
         activity.setUpdatedAt(new Date());
         this.save(activity);
         this.save(activity);
@@ -169,12 +198,46 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
             return result;
             return result;
         }
         }
 
 
+        // 1.5 Time window check — checkin only allowed during the activity
+        Date now = new Date();
+        if (activity.getStartTime() != null && now.before(activity.getStartTime())) {
+            result.put("error", "活动尚未开始,暂不能签到");
+            return result;
+        }
+        if (activity.getEndTime() != null && now.after(activity.getEndTime())) {
+            result.put("error", "活动已结束,不能签到");
+            return result;
+        }
+
         // 2. If no childId, can't create task
         // 2. If no childId, can't create task
         if (childId == null) {
         if (childId == null) {
             result.put("error", "需要孩子身份签到");
             result.put("error", "需要孩子身份签到");
             return result;
             return result;
         }
         }
 
 
+        // 2.5 Identity: phone child self-checkin only; phone-less child allows same-family proxy
+        FamilyMember child = familyMemberMapper.selectById(childId);
+        if (child == null) {
+            result.put("error", "孩子不存在");
+            return result;
+        }
+        boolean childHasPhone = StringUtils.hasText(child.getPhone());
+        if (childHasPhone) {
+            boolean isSelf = child.getUserId() != null && child.getUserId().equals(userId);
+            if (!isSelf) {
+                result.put("error", "有手机号的孩子只能本人签到");
+                return result;
+            }
+        } else {
+            User operator = userMapper.selectById(userId);
+            boolean sameFamily = operator != null && operator.getFamilyId() != null
+                    && operator.getFamilyId().equals(child.getFamilyId());
+            if (!sameFamily) {
+                result.put("error", "无权限代签(需同一家庭)");
+                return result;
+            }
+        }
+
         // 3. Registration check (if activity requires registration)
         // 3. Registration check (if activity requires registration)
         boolean unregistered = false;
         boolean unregistered = false;
         if (activity.getRequireRegistration() != null && activity.getRequireRegistration() == 1) {
         if (activity.getRequireRegistration() != null && activity.getRequireRegistration() == 1) {
@@ -190,17 +253,16 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
                 Integer penaltyAmount = activity.getEnergyPenaltyUnregistered();
                 Integer penaltyAmount = activity.getEnergyPenaltyUnregistered();
                 if (penaltyAmount != null && penaltyAmount > 0) {
                 if (penaltyAmount != null && penaltyAmount > 0) {
                     try {
                     try {
-                        // 未报名签到固定扣"行"维度(action)能量,与活动所属维度无关
                         int deducted = energyService.deductEnergyByCode(
                         int deducted = energyService.deductEnergyByCode(
                             childId,
                             childId,
-                            "action",
+                            activity.getDimensionCode(),
                             penaltyAmount,
                             penaltyAmount,
                             "未报名签到活动《" + activity.getTitle() + "》"
                             "未报名签到活动《" + activity.getTitle() + "》"
                         );
                         );
                         if (deducted >= 0) {
                         if (deducted >= 0) {
                             result.put("energyPenalty", penaltyAmount);
                             result.put("energyPenalty", penaltyAmount);
                             log.info("未报名签到能量扣减: childId={}, activityId={}, penalty={}, dimension={}",
                             log.info("未报名签到能量扣减: childId={}, activityId={}, penalty={}, dimension={}",
-                                childId, activityId, penaltyAmount, "action");
+                                childId, activityId, penaltyAmount, activity.getDimensionCode());
                         } else {
                         } else {
                             result.put("energyPenaltyFailed", "余额不足");
                             result.put("energyPenaltyFailed", "余额不足");
                             log.warn("未报名签到能量扣减失败(余额不足): childId={}, activityId={}",
                             log.warn("未报名签到能量扣减失败(余额不足): childId={}, activityId={}",
@@ -243,7 +305,6 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
 
 
         // 5. Create task with status='completed' (auto-completed by check-in)
         // 5. Create task with status='completed' (auto-completed by check-in)
         Task task = new Task();
         Task task = new Task();
-        FamilyMember child = familyMemberMapper.selectById(childId);
         if (child != null) {
         if (child != null) {
             task.setFamilyId(child.getFamilyId());
             task.setFamilyId(child.getFamilyId());
         }
         }
@@ -312,4 +373,60 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
         result.put("pointsEarned", points);
         result.put("pointsEarned", points);
         return result;
         return result;
     }
     }
+
+    private void autoEndExpiredActivities() {
+        try {
+            Date threshold = new Date(System.currentTimeMillis() - 7L * 24 * 3600 * 1000);
+            this.update(new LambdaUpdateWrapper<Activity>()
+                    .eq(Activity::getStatus, "published")
+                    .lt(Activity::getEndTime, threshold)
+                    .set(Activity::getStatus, "ended")
+                    .set(Activity::getUpdatedAt, new Date()));
+        } catch (Exception e) {
+            log.warn("自动结束过期活动失败: {}", e.getMessage());
+        }
+    }
+
+    private boolean hasCheckedIn(Long activityId, Long userId) {
+        List<Long> childIds = registrationService.getChildIdsByUser(userId);
+        if (childIds.isEmpty()) {
+            return false;
+        }
+        return taskMapper.selectCount(new LambdaQueryWrapper<Task>()
+                .eq(Task::getSourceType, "activity")
+                .eq(Task::getSourceId, activityId)
+                .in(Task::getChildId, childIds)
+                .eq(Task::getStatus, "completed")) > 0;
+    }
+
+    private void applyDisplayState(ActivityDTO dto, Activity activity, String registrationStatus, boolean checkedIn) {
+        Date now = new Date();
+        String displayStatus;
+        String displayLabel;
+        boolean ended = "ended".equals(activity.getStatus())
+                || (activity.getEndTime() != null && now.after(activity.getEndTime()));
+        if (ended) {
+            displayStatus = "ended";
+            displayLabel = "已结束";
+        } else if (checkedIn) {
+            displayStatus = "checked_in";
+            displayLabel = "已签到";
+        } else if (activity.getStartTime() != null && activity.getEndTime() != null
+                && !now.before(activity.getStartTime()) && !now.after(activity.getEndTime())) {
+            displayStatus = "ongoing";
+            displayLabel = "进行中";
+        } else if ("approved".equals(registrationStatus)) {
+            displayStatus = "registered";
+            displayLabel = "已报名";
+        } else if (activity.getRegistrationDeadline() != null && now.after(activity.getRegistrationDeadline())) {
+            displayStatus = "pending_start";
+            displayLabel = "待开始";
+        } else {
+            displayStatus = "open";
+            displayLabel = "报名中";
+        }
+        dto.setDisplayStatus(displayStatus);
+        dto.setDisplayLabel(displayLabel);
+        dto.setRegistrationStatus(checkedIn ? "checked_in" : (registrationStatus != null ? registrationStatus : "not_registered"));
+    }
 }
 }

+ 50 - 7
cfc-backend/src/main/resources/schema.sql

@@ -620,9 +620,9 @@ CREATE TABLE IF NOT EXISTS task_plan_items (
 CREATE TABLE IF NOT EXISTS package_orders (
 CREATE TABLE IF NOT EXISTS package_orders (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     order_no VARCHAR(32) UNIQUE NOT NULL,
     order_no VARCHAR(32) UNIQUE NOT NULL,
-    user_id BIGINT DEFAULT NULL COMMENT '购买者用户ID',
-    family_id BIGINT DEFAULT NULL COMMENT '家庭ID',
-    package_id BIGINT DEFAULT NULL COMMENT '套餐ID',
+    user_id BIGINT NOT NULL COMMENT '购买者用户ID',
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    package_id BIGINT NOT NULL COMMENT '套餐ID',
     package_name VARCHAR(100) COMMENT '套餐名称',
     package_name VARCHAR(100) COMMENT '套餐名称',
     price INT NOT NULL COMMENT '支付金额(分)',
     price INT NOT NULL COMMENT '支付金额(分)',
     platform_fee INT DEFAULT 0 COMMENT '平台服务费(分)',
     platform_fee INT DEFAULT 0 COMMENT '平台服务费(分)',
@@ -633,9 +633,6 @@ CREATE TABLE IF NOT EXISTS package_orders (
     transaction_id VARCHAR(64) COMMENT '微信支付订单号',
     transaction_id VARCHAR(64) COMMENT '微信支付订单号',
     paid_at DATETIME COMMENT '支付时间',
     paid_at DATETIME COMMENT '支付时间',
     notify_data TEXT COMMENT '支付回调数据',
     notify_data TEXT COMMENT '支付回调数据',
-    contact_name VARCHAR(50) COMMENT '联系人姓名',
-    contact_phone VARCHAR(20) COMMENT '联系人手机号',
-    contact_address VARCHAR(500) COMMENT '收件地址',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_user_id (user_id),
     INDEX idx_user_id (user_id),
@@ -1741,7 +1738,6 @@ CREATE TABLE IF NOT EXISTS activities (
     status           VARCHAR(20) DEFAULT 'draft' COMMENT '状态: draft/published/ended',
     status           VARCHAR(20) DEFAULT 'draft' COMMENT '状态: draft/published/ended',
     start_time       DATETIME COMMENT '开始时间',
     start_time       DATETIME COMMENT '开始时间',
     end_time         DATETIME COMMENT '结束时间',
     end_time         DATETIME COMMENT '结束时间',
-    registration_deadline DATETIME COMMENT '报名截止时间',
     location         VARCHAR(200) COMMENT '活动地点',
     location         VARCHAR(200) COMMENT '活动地点',
     max_participants INT DEFAULT 0 COMMENT '最大参与人数',
     max_participants INT DEFAULT 0 COMMENT '最大参与人数',
     current_participants INT DEFAULT 0 COMMENT '当前参与人数',
     current_participants INT DEFAULT 0 COMMENT '当前参与人数',
@@ -1771,6 +1767,21 @@ ALTER TABLE activities
   ADD COLUMN audited_at DATETIME DEFAULT NULL
   ADD COLUMN audited_at DATETIME DEFAULT NULL
     COMMENT '审核时间';
     COMMENT '审核时间';
 
 
+-- v1.x: 活动报名升级(报名截止时间+孩子/家长双收费+会员区分+创建人)
+ALTER TABLE activities
+  ADD COLUMN registration_deadline DATETIME DEFAULT NULL
+    COMMENT '报名截止时间(NULL=不限制, 默认=活动开始时间)',
+  ADD COLUMN child_fee INT DEFAULT 0
+    COMMENT '孩子·非会员价(分)',
+  ADD COLUMN child_member_fee INT DEFAULT 0
+    COMMENT '孩子·会员价(分), 0=免费',
+  ADD COLUMN parent_fee INT DEFAULT 0
+    COMMENT '家长·非会员价(分)',
+  ADD COLUMN parent_member_fee INT DEFAULT 0
+    COMMENT '家长·会员价(分), 0=免费',
+  ADD COLUMN creator_id BIGINT DEFAULT NULL
+    COMMENT '创建人userId(普通用户发布者, admin创建可为空)';
+
 -- ===== v1.3 家庭成员体系 =====
 -- ===== v1.3 家庭成员体系 =====
 
 
 -- 手动添加的家庭成员表(替代children表用于新成员)
 -- 手动添加的家庭成员表(替代children表用于新成员)
@@ -3689,6 +3700,38 @@ CREATE TABLE IF NOT EXISTS recommendation_logs (
 -- activities表添加family_scope列(推荐系统-家庭参与去重)
 -- activities表添加family_scope列(推荐系统-家庭参与去重)
 -- ALTER TABLE activities ADD COLUMN family_scope TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员参与过即排除, 0=仅按个人参与排除';
 -- ALTER TABLE activities ADD COLUMN family_scope TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员参与过即排除, 0=仅按个人参与排除';
 
 
+-- ============================================================
+-- 活动报名订单表(付费报名:先支付后报名,迁移143)
+-- ============================================================
+CREATE TABLE IF NOT EXISTS activity_orders (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    order_no VARCHAR(64) NOT NULL UNIQUE COMMENT '订单号(ACT+时间戳+随机)',
+    activity_id BIGINT NOT NULL COMMENT '活动ID',
+    user_id BIGINT NOT NULL COMMENT '下单人userId',
+    total_amount INT NOT NULL COMMENT '实付金额(分)',
+    registrants_json TEXT COMMENT '报名人快照JSON: [{name, phone, childId, type: child/parent, fee}]',
+    status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending/paid/cancelled',
+    transaction_id VARCHAR(64) COMMENT '微信支付单号',
+    paid_at DATETIME COMMENT '支付时间',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_activity (activity_id),
+    INDEX idx_user (user_id),
+    INDEX idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动报名订单表';
+
+-- ============================================================
+-- 活动总结资料表(总结中阶段发布人上传资料,迁移144)
+-- ============================================================
+CREATE TABLE IF NOT EXISTS activity_materials (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    activity_id BIGINT NOT NULL COMMENT '活动ID',
+    title VARCHAR(200) COMMENT '标题',
+    file_url VARCHAR(500) NOT NULL COMMENT '文件地址(图片/PDF/外链URL)',
+    file_type VARCHAR(20) DEFAULT 'image' COMMENT '类型: image/pdf/link',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    INDEX idx_activity (activity_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动总结资料表';
+
 -- ============================================================
 -- ============================================================
 -- 小程序虚拟支付道具映射
 -- 小程序虚拟支付道具映射
 -- ============================================================
 -- ============================================================

+ 2 - 2
cfc-frontend/pages.json

@@ -1091,9 +1091,9 @@
           }
           }
         },
         },
         {
         {
-          "path": "registrations",
+          "path": "order-pay/order-pay",
           "style": {
           "style": {
-            "navigationBarTitleText": "报名管理"
+            "navigationBarTitleText": "订单支付"
           }
           }
         }
         }
       ]
       ]

+ 99 - 51
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -29,7 +29,7 @@
 
 
         <!-- 活动状态标签 -->
         <!-- 活动状态标签 -->
         <view class="status-row">
         <view class="status-row">
-          <text class="status-tag" :class="'status-' + (activity.status || 'upcoming')">{{ statusText(activity.status) }}</text>
+          <text class="status-tag" :class="'status-' + (activity.displayStatus || activity.status || 'upcoming')">{{ activity.displayLabel || statusText(activity.status) }}</text>
         </view>
         </view>
 
 
         <!-- 活动元信息 -->
         <!-- 活动元信息 -->
@@ -38,9 +38,9 @@
             <text class="meta-icon">🕐</text>
             <text class="meta-icon">🕐</text>
             <text class="meta-text">{{ formatDate(activity.startTime) }}{{ activity.endTime ? ' ~ ' + formatDate(activity.endTime) : '' }}</text>
             <text class="meta-text">{{ formatDate(activity.startTime) }}{{ activity.endTime ? ' ~ ' + formatDate(activity.endTime) : '' }}</text>
           </view>
           </view>
-          <view class="meta-item" v-if="activity.requireRegistration === 1 && (activity.registrationDeadline || activity.endTime)">
+          <view class="meta-item" v-if="activity.registrationDeadline && activity.requireRegistration === 1">
             <text class="meta-icon">⏰</text>
             <text class="meta-icon">⏰</text>
-            <text class="meta-text">报名截止: {{ formatDate(activity.registrationDeadline || activity.endTime) }}</text>
+            <text class="meta-text">报名截止: {{ formatDate(activity.registrationDeadline) }}</text>
           </view>
           </view>
           <view class="meta-item" v-if="activity.location">
           <view class="meta-item" v-if="activity.location">
             <text class="meta-icon">📍</text>
             <text class="meta-icon">📍</text>
@@ -50,12 +50,16 @@
             <text class="meta-icon">👥</text>
             <text class="meta-icon">👥</text>
             <text class="meta-text">{{ activity.currentParticipants || 0 }} / {{ activity.maxParticipants }} 人</text>
             <text class="meta-text">{{ activity.currentParticipants || 0 }} / {{ activity.maxParticipants }} 人</text>
           </view>
           </view>
-          <view class="meta-item">
+          <view class="meta-item" v-if="hasFee">
             <text class="meta-icon">💰</text>
             <text class="meta-icon">💰</text>
-            <view class="meta-text" v-if="activity.memberPrice && activity.memberPrice > 0 && activity.memberPrice < activity.price">
-              <text>会员价 ¥{{ activity.memberPrice }} 普通价 ¥{{ activity.price }}</text>
+            <view class="meta-text">
+              <text class="fee-line">孩子:¥{{ feeText(activity.childFee, activity.childMemberFee) }}</text>
+              <text class="fee-line">家长:¥{{ feeText(activity.parentFee, activity.parentMemberFee) }}</text>
             </view>
             </view>
-            <text class="meta-text" v-else>{{ activity.priceLabel || (activity.price > 0 ? '¥' + activity.price : '免费') }}</text>
+          </view>
+          <view class="meta-item" v-else>
+            <text class="meta-icon">💰</text>
+            <text class="meta-text">免费</text>
           </view>
           </view>
         </view>
         </view>
 
 
@@ -66,7 +70,7 @@
         </view>
         </view>
 
 
         <!-- 付费不退费说明 -->
         <!-- 付费不退费说明 -->
-        <view class="refund-notice" v-if="activity.price > 0">
+        <view class="refund-notice" v-if="hasFee">
           <text class="refund-notice-icon">⚠️</text>
           <text class="refund-notice-icon">⚠️</text>
           <text class="refund-notice-text">本活动为付费活动,报名成功后不支持退费</text>
           <text class="refund-notice-text">本活动为付费活动,报名成功后不支持退费</text>
         </view>
         </view>
@@ -81,14 +85,15 @@
         <view class="action-btns">
         <view class="action-btns">
           <button class="share-btn" @click="generatePoster">
           <button class="share-btn" @click="generatePoster">
             <text class="share-btn-icon">📤</text>
             <text class="share-btn-icon">📤</text>
+            <text class="share-btn-text">分享</text>
           </button>
           </button>
-          <!-- 报名按钮(仅 requireRegistration=1 时显示) -->
+          <!-- 报名按钮(仅报名中/未报名时显示) -->
           <button
           <button
-            v-if="activity.requireRegistration === 1 && !registrationStatus"
+            v-if="canRegister"
             class="btn-register"
             class="btn-register"
             @click="onRegister"
             @click="onRegister"
           >
           >
-            <text v-if="activity.price > 0">¥{{ activity.price }} 立即报名</text>
+            <text v-if="hasFee">¥{{ minFeeText }} 立即报名</text>
             <text v-else>立即报名</text>
             <text v-else>立即报名</text>
           </button>
           </button>
 
 
@@ -103,7 +108,7 @@
           
           
           <!-- 已报名可取消 -->
           <!-- 已报名可取消 -->
           <button
           <button
-            v-if="activity.requireRegistration === 1 && registrationStatus === 'approved'"
+            v-if="activity.requireRegistration === 1 && (registrationStatus === 'approved' || registrationStatus === 'registered')"
             class="btn-cancel"
             class="btn-cancel"
             :disabled="registrationLoading"
             :disabled="registrationLoading"
             @click="onCancelRegistration"
             @click="onCancelRegistration"
@@ -111,8 +116,9 @@
             <text>取消报名</text>
             <text>取消报名</text>
           </button>
           </button>
           
           
-          <!-- 签到按钮 -->
+          <!-- 签到按钮(仅进行中时显示) -->
           <button
           <button
+            v-if="activity.displayStatus === 'ongoing'"
             class="btn-checkin"
             class="btn-checkin"
             :class="{ 'btn-checkin-done': checkinDone, 'btn-checkin-loading': checkinLoading }"
             :class="{ 'btn-checkin-done': checkinDone, 'btn-checkin-loading': checkinLoading }"
             :disabled="checkinLoading || checkinDone"
             :disabled="checkinLoading || checkinDone"
@@ -123,6 +129,7 @@
             <text v-else-if="checkinDone">已签到 ✓</text>
             <text v-else-if="checkinDone">已签到 ✓</text>
             <text v-else>签到</text>
             <text v-else>签到</text>
           </button>
           </button>
+          <text v-if="activity.requireCheckinReview === 1 && !checkinDone && activity.displayStatus === 'ongoing'" class="checkin-review-hint">签到需要管理员审核</text>
         </view>
         </view>
       </view>
       </view>
       
       
@@ -221,8 +228,8 @@
           </view>
           </view>
 
 
           <!-- 合计 -->
           <!-- 合计 -->
-          <view class="reg-total" v-if="activity.price > 0">
-            <text>共 {{ registrants.length }} 人 · 合计 ¥{{ activity.price * registrants.length }}</text>
+          <view class="reg-total" v-if="hasFee">
+            <text>共 {{ registrants.length }} 人 · 合计 ¥{{ registrantsTotal }}</text>
           </view>
           </view>
 
 
           <!-- 操作按钮 -->
           <!-- 操作按钮 -->
@@ -259,7 +266,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { getActivityDetail, activityCheckin, registerActivity, cancelActivityRegistration, getMyActivityRegistrations, submitActivityFeedback, getActivityFeedbackList, getMyActivityFeedback, getFamilyMembers, getShareQrCode } from '@/utils/api.js'
+import { getActivityDetail, activityCheckin, cancelActivityRegistration, createActivityOrder, submitActivityFeedback, getActivityFeedbackList, getMyActivityFeedback, getFamilyMembers, getShareQrCode } from '@/utils/api.js'
 import shareMixin from '../../../components/share-mixin.js'
 import shareMixin from '../../../components/share-mixin.js'
 import MpHtml from '@/components/mp-html/mp-html.vue'
 import MpHtml from '@/components/mp-html/mp-html.vue'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
@@ -278,7 +285,6 @@ export default {
       checkinDone: false,
       checkinDone: false,
       checkinPoints: 0,
       checkinPoints: 0,
       registrationStatus: null,
       registrationStatus: null,
-      registrationChildId: null,
       registrationLoading: false,
       registrationLoading: false,
       showPenaltyModal: false,
       showPenaltyModal: false,
       penaltyAmount: 0,
       penaltyAmount: 0,
@@ -333,6 +339,17 @@ export default {
       var minStr = minute < 10 ? '0' + minute : minute
       var minStr = minute < 10 ? '0' + minute : minute
       return year + '年' + month + '月' + day + '日 ' + ampm + h12 + ':' + minStr
       return year + '年' + month + '月' + day + '日 ' + ampm + h12 + ':' + minStr
     },
     },
+    // 费用双价展示:非会员价 / 会员价(元)
+    feeText: function(regularFee, memberFee) {
+      var regular = (regularFee || 0) / 100
+      var member = (memberFee || 0) / 100
+      if (member > 0 && member < regular) {
+        return member.toFixed(2) + '(会员) / ' + regular.toFixed(2)
+      }
+      if (regular === 0 && member === 0) return '0.00'
+      if (regular === 0) return '会员免费'
+      return regular.toFixed(2)
+    },
     loadDetail: function(id) {
     loadDetail: function(id) {
       var self = this
       var self = this
       self.loading = true
       self.loading = true
@@ -344,22 +361,8 @@ export default {
           self.setShareInfo('推荐活动: ' + (res.data.title || ''), '/pages/activity/activity-detail/activity-detail?id=' + id)
           self.setShareInfo('推荐活动: ' + (res.data.title || ''), '/pages/activity/activity-detail/activity-detail?id=' + id)
           self.parseFeedbackDimensions()
           self.parseFeedbackDimensions()
           self.loadFeedbackStatus()
           self.loadFeedbackStatus()
-          // Load registration status if logged in and activity requires registration
-          if (self.activity.requireRegistration === 1) {
-            // 不依赖全局 currentChildId:后端按 userId 返回全部报名记录,再按活动匹配,并缓存记录上的 childId 供签到回退
-            getMyActivityRegistrations({ page: 1, size: 50 }).then(function(regRes) {
-              if (regRes && regRes.data && regRes.data.records) {
-                var records = regRes.data.records
-                for (var i = 0; i < records.length; i++) {
-                  if (records[i].activityId === self.activity.id) {
-                    self.registrationStatus = records[i].status
-                    self.registrationChildId = records[i].childId || null
-                    break
-                  }
-                }
-              }
-            }).catch(function() {})
-          }
+          // registrationStatus 已由后端按当前用户计算返回
+          self.registrationStatus = res.data.registrationStatus || null
         } else {
         } else {
           self.error = true
           self.error = true
           self.errorMsg = (res && res.message) || '加载失败'
           self.errorMsg = (res && res.message) || '加载失败'
@@ -451,7 +454,7 @@ export default {
         content: '确定要取消报名吗?',
         content: '确定要取消报名吗?',
         success: function(res) {
         success: function(res) {
           if (res.confirm) {
           if (res.confirm) {
-            var childId = uni.getStorageSync('currentChildId') || self.registrationChildId
+            var childId = uni.getStorageSync('currentChildId')
             self.registrationLoading = true
             self.registrationLoading = true
             cancelActivityRegistration({ id: self.activity.id, childId: childId }).then(function(res) {
             cancelActivityRegistration({ id: self.activity.id, childId: childId }).then(function(res) {
               self.registrationLoading = false
               self.registrationLoading = false
@@ -658,35 +661,30 @@ export default {
           return
           return
         }
         }
         var phone = (entry.phone || '').trim()
         var phone = (entry.phone || '').trim()
-        if (phone === '') {
-          uni.showToast({ title: '请输入第 ' + (i + 1) + ' 个报名人的手机号', icon: 'none' })
-          return
-        }
-        if (!/^1[3-9]\d{9}$/.test(phone)) {
+        if (phone !== '' && !/^1[3-9]\d{9}$/.test(phone)) {
           uni.showToast({ title: '请输入第 ' + (i + 1) + ' 个报名人的正确手机号', icon: 'none' })
           uni.showToast({ title: '请输入第 ' + (i + 1) + ' 个报名人的正确手机号', icon: 'none' })
           return
           return
         }
         }
       }
       }
 
 
       // 付费活动 → 先确认支付
       // 付费活动 → 先确认支付
-      if (self.activity && self.activity.price > 0) {
-        var total = self.activity.price * self.registrants.length
+      if (self.hasFee && self.registrantsTotal > 0) {
         uni.showModal({
         uni.showModal({
           title: '确认报名',
           title: '确认报名',
-          content: '共 ' + self.registrants.length + ' 人,需支付 ¥' + total + ',报名成功后不支持退费。是否继续?',
+          content: '共 ' + self.registrants.length + ' 人,需支付 ¥' + self.registrantsTotal + ',报名成功后不支持退费。是否继续?',
           confirmText: '确认支付',
           confirmText: '确认支付',
           cancelText: '再想想',
           cancelText: '再想想',
           success: function(confirmRes) {
           success: function(confirmRes) {
             if (confirmRes.confirm) {
             if (confirmRes.confirm) {
-              self.doBatchRegister()
+              self.doCreateOrder()
             }
             }
           }
           }
         })
         })
       } else {
       } else {
-        self.doBatchRegister()
+        self.doCreateOrder()
       }
       }
     },
     },
-    doBatchRegister: function() {
+    doCreateOrder: function() {
       var self = this
       var self = this
       self.regSubmitting = true
       self.regSubmitting = true
 
 
@@ -700,12 +698,25 @@ export default {
         })
         })
       }
       }
 
 
-      registerActivity({ id: self.activity.id, registrants: registrantsData }).then(function(res) {
+      createActivityOrder({ activityId: self.activity.id, registrants: registrantsData }).then(function(res) {
         self.regSubmitting = false
         self.regSubmitting = false
         if (res && res.code === 200) {
         if (res && res.code === 200) {
-          self.closeRegModal()
-          self.registrationStatus = 'pending'
-          uni.showToast({ title: '报名成功,请等待审核', icon: 'success' })
+          var data = res.data || {}
+          if (data.free) {
+            // 免费活动直接报名成功
+            self.closeRegModal()
+            self.registrationStatus = 'registered'
+            uni.showToast({ title: '报名成功', icon: 'success' })
+            self.loadDetail(self.activity.id)
+          } else if (data.requiresPayment && data.orderNo) {
+            // 付费活动 → 跳转支付页
+            self.closeRegModal()
+            uni.navigateTo({
+              url: '/pages/activity/order-pay/order-pay?orderNo=' + data.orderNo + '&totalAmount=' + data.totalAmount + '&activityId=' + self.activity.id
+            })
+          } else {
+            uni.showToast({ title: '报名失败', icon: 'none' })
+          }
         } else {
         } else {
           uni.showToast({ title: (res && res.message) || '报名失败', icon: 'none' })
           uni.showToast({ title: (res && res.message) || '报名失败', icon: 'none' })
         }
         }
@@ -723,6 +734,42 @@ export default {
       } catch(e) {
       } catch(e) {
         return {}
         return {}
       }
       }
+    },
+    // 4 个费用字段是否存在(任一项 > 0 即为付费活动)
+    hasFee: function() {
+      var a = this.activity || {}
+      return (a.childFee > 0) || (a.childMemberFee > 0) || (a.parentFee > 0) || (a.parentMemberFee > 0)
+    },
+    // 报名按钮可用:需报名 + 未报名 + 展示状态为报名中
+    canRegister: function() {
+      var a = this.activity || {}
+      if (a.requireRegistration !== 1) return false
+      if (a.displayStatus !== 'open') return false
+      var st = this.registrationStatus
+      return !st || st === 'not_registered' || st === 'cancelled' || st === 'rejected'
+    },
+    // 报名弹窗内合计(按当前用户会员价估算,实际以订单为准)
+    registrantsTotal: function() {
+      var a = this.activity || {}
+      var total = 0
+      for (var i = 0; i < this.registrants.length; i++) {
+        if (this.registrants[i].childId) {
+          total += (a.childFee > 0 ? a.childFee : a.childMemberFee) || 0
+        } else {
+          total += (a.parentFee > 0 ? a.parentFee : a.parentMemberFee) || 0
+        }
+      }
+      return (total / 100).toFixed(2)
+    },
+    // 报名按钮起步价文案
+    minFeeText: function() {
+      var a = this.activity || {}
+      var fees = [a.childFee, a.childMemberFee, a.parentFee, a.parentMemberFee]
+      var min = 0
+      for (var i = 0; i < fees.length; i++) {
+        if (fees[i] > 0 && (min === 0 || fees[i] < min)) min = fees[i]
+      }
+      return (min / 100).toFixed(2)
     }
     }
   }
   }
 }
 }
@@ -1093,9 +1140,10 @@ export default {
   border-radius: 40rpx; padding: 20rpx; font-size: 28rpx;
   border-radius: 40rpx; padding: 20rpx; font-size: 28rpx;
   text-align: center;
   text-align: center;
 }
 }
-.share-btn { height: 64rpx; width: 64rpx; background: #f5f5f5; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: none; flex-shrink: 0; }
+.share-btn { height: 64rpx; line-height: 64rpx; background: #f5f5f5; color: #666; font-size: 24rpx; border-radius: 32rpx; padding: 0 20rpx; display: flex; align-items: center; border: none; flex-shrink: 0; }
 .share-btn::after { border: none; }
 .share-btn::after { border: none; }
-.share-btn-icon { font-size: 30rpx; }
+.share-btn-icon { font-size: 28rpx; margin-right: 4rpx; }
+.share-btn-text { font-size: 24rpx; }
 
 
 /* 报名表单弹窗 */
 /* 报名表单弹窗 */
 .reg-modal-overlay {
 .reg-modal-overlay {

+ 96 - 51
cfc-frontend/pages/activity/create.vue

@@ -50,11 +50,11 @@
       <!-- 报名截止时间 -->
       <!-- 报名截止时间 -->
       <view class="form-item">
       <view class="form-item">
         <text class="form-label">报名截止时间</text>
         <text class="form-label">报名截止时间</text>
-        <picker class="form-picker" mode="date" :value="deadlineDatePart" @change="onDeadlineDateChange">
-          <text class="picker-text">{{ deadlineDatePart || '选择日期(可选)' }}</text>
+        <picker class="form-picker" mode="date" :value="regDatePart" @change="onRegDateChange">
+          <text class="picker-text">{{ regDatePart || '选择日期(不填=活动开始前)' }}</text>
         </picker>
         </picker>
-        <picker class="form-picker" mode="time" :value="deadlineTimePart" @change="onDeadlineTimeChange" style="margin-top: 16rpx;">
-          <text class="picker-text">{{ deadlineTimePart || '选择时间(可选)' }}</text>
+        <picker class="form-picker" mode="time" :value="regTimePart" @change="onRegTimeChange" style="margin-top: 16rpx;">
+          <text class="picker-text">{{ regTimePart || '选择时间' }}</text>
         </picker>
         </picker>
       </view>
       </view>
 
 
@@ -71,15 +71,25 @@
           <input class="form-input" type="number" v-model="maxParticipantsStr" placeholder="0=不限" />
           <input class="form-input" type="number" v-model="maxParticipantsStr" placeholder="0=不限" />
         </view>
         </view>
         <view class="form-item half">
         <view class="form-item half">
-          <text class="form-label">价格(元)</text>
-          <input class="form-input" type="digit" v-model="priceYuan" placeholder="0=免费" />
+          <text class="form-label">孩子费用(元)</text>
+          <input class="form-input" type="digit" v-model="childFeeYuan" placeholder="0=免费" />
+        </view>
+      </view>
+
+      <view class="form-item-row">
+        <view class="form-item half">
+          <text class="form-label">孩子会员费(元)</text>
+          <input class="form-input" type="digit" v-model="childMemberFeeYuan" placeholder="0=免费" />
+        </view>
+        <view class="form-item half">
+          <text class="form-label">家长费用(元)</text>
+          <input class="form-input" type="digit" v-model="parentFeeYuan" placeholder="0=免费" />
         </view>
         </view>
       </view>
       </view>
 
 
-      <!-- 会员价 -->
       <view class="form-item">
       <view class="form-item">
-        <text class="form-label">会员价(元)</text>
-        <input class="form-input" type="digit" v-model="memberPriceYuan" placeholder="留空等于普通价" />
+        <text class="form-label">家长会员费(元)</text>
+        <input class="form-input" type="digit" v-model="parentMemberFeeYuan" placeholder="0=免费" />
       </view>
       </view>
 
 
       <!-- 封面图片 -->
       <!-- 封面图片 -->
@@ -104,7 +114,7 @@
           <text>保存为草稿</text>
           <text>保存为草稿</text>
         </view>
         </view>
         <view class="btn-publish" @click="handlePublish">
         <view class="btn-publish" @click="handlePublish">
-          <text>保存并发布</text>
+          <text>{{ isAdmin ? '保存并发布' : '保存并提交审核' }}</text>
         </view>
         </view>
       </view>
       </view>
     </view>
     </view>
@@ -117,12 +127,18 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { adminCreateActivity, adminUpdateActivity, adminPublishActivity, getActivityDetail } from '../../utils/api.js'
+import { adminCreateActivity, adminUpdateActivity, adminPublishActivity, submitActivityForReview, getActivityDetail } from '../../utils/api.js'
 import config from '../../config.js'
 import config from '../../config.js'
 
 
 var BASE_URL = config.api('')
 var BASE_URL = config.api('')
 
 
 export default {
 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() {
   data() {
     return {
     return {
       isNew: true,
       isNew: true,
@@ -141,22 +157,26 @@ export default {
         registrationDeadline: null,
         registrationDeadline: null,
         location: '',
         location: '',
         maxParticipants: 0,
         maxParticipants: 0,
-        price: 0,
-        memberPrice: 0,
+        childFee: 0,
+        childMemberFee: 0,
+        parentFee: 0,
+        parentMemberFee: 0,
         visibility: 'public',
         visibility: 'public',
         requireRegistration: 1,
         requireRegistration: 1,
         requireCheckinReview: 0,
         requireCheckinReview: 0,
         checkinPoints: 0
         checkinPoints: 0
       },
       },
-      priceYuan: '0',
-      memberPriceYuan: '',
+      childFeeYuan: '0',
+      childMemberFeeYuan: '0',
+      parentFeeYuan: '0',
+      parentMemberFeeYuan: '0',
       maxParticipantsStr: '0',
       maxParticipantsStr: '0',
       datePart: '',
       datePart: '',
       timePart: '',
       timePart: '',
       endDatePart: '',
       endDatePart: '',
       endTimePart: '',
       endTimePart: '',
-      deadlineDatePart: '',
-      deadlineTimePart: '',
+      regDatePart: '',
+      regTimePart: '',
       dimIdx: 0,
       dimIdx: 0,
       typeIdx: 0,
       typeIdx: 0,
       dimensionOptions: [
       dimensionOptions: [
@@ -198,13 +218,17 @@ export default {
           self.form.activityType = d.activityType || ''
           self.form.activityType = d.activityType || ''
           self.form.location = d.location || ''
           self.form.location = d.location || ''
           self.form.maxParticipants = d.maxParticipants || 0
           self.form.maxParticipants = d.maxParticipants || 0
-          self.form.price = d.price || 0
-          self.form.memberPrice = d.memberPrice || 0
+          self.form.childFee = d.childFee || 0
+          self.form.childMemberFee = d.childMemberFee || 0
+          self.form.parentFee = d.parentFee || 0
+          self.form.parentMemberFee = d.parentMemberFee || 0
           self.form.status = d.status || 'draft'
           self.form.status = d.status || 'draft'
           self.form.visibility = d.visibility || 'public'
           self.form.visibility = d.visibility || 'public'
           self.maxParticipantsStr = String(self.form.maxParticipants)
           self.maxParticipantsStr = String(self.form.maxParticipants)
-          self.priceYuan = self.form.price ? (self.form.price / 100).toString() : '0'
-          self.memberPriceYuan = self.form.memberPrice && self.form.memberPrice > 0 ? (self.form.memberPrice / 100).toString() : ''
+          self.childFeeYuan = self.form.childFee ? (self.form.childFee / 100).toString() : '0'
+          self.childMemberFeeYuan = self.form.childMemberFee ? (self.form.childMemberFee / 100).toString() : '0'
+          self.parentFeeYuan = self.form.parentFee ? (self.form.parentFee / 100).toString() : '0'
+          self.parentMemberFeeYuan = self.form.parentMemberFee ? (self.form.parentMemberFee / 100).toString() : '0'
           if (d.startTime) {
           if (d.startTime) {
             var sd = new Date(d.startTime.replace ? d.startTime.replace(/-/g, '/') : d.startTime)
             var sd = new Date(d.startTime.replace ? d.startTime.replace(/-/g, '/') : d.startTime)
             self.datePart = self.fmtDate(sd)
             self.datePart = self.fmtDate(sd)
@@ -218,10 +242,10 @@ export default {
             self.form.endTime = ed.toISOString()
             self.form.endTime = ed.toISOString()
           }
           }
           if (d.registrationDeadline) {
           if (d.registrationDeadline) {
-            var dl = new Date(d.registrationDeadline.replace ? d.registrationDeadline.replace(/-/g, '/') : d.registrationDeadline)
-            self.deadlineDatePart = self.fmtDate(dl)
-            self.deadlineTimePart = self.fmtTime(dl)
-            self.form.registrationDeadline = dl.toISOString()
+            var rd = new Date(d.registrationDeadline.replace ? d.registrationDeadline.replace(/-/g, '/') : d.registrationDeadline)
+            self.regDatePart = self.fmtDate(rd)
+            self.regTimePart = self.fmtTime(rd)
+            self.form.registrationDeadline = rd.toISOString()
           }
           }
           // sync pickers
           // sync pickers
           for (var i = 0; i < self.dimensionOptions.length; i++) {
           for (var i = 0; i < self.dimensionOptions.length; i++) {
@@ -257,13 +281,13 @@ export default {
       this.endTimePart = e.detail.value
       this.endTimePart = e.detail.value
       this.syncEndTime()
       this.syncEndTime()
     },
     },
-    onDeadlineDateChange(e) {
-      this.deadlineDatePart = e.detail.value
-      this.syncDeadline()
+    onRegDateChange(e) {
+      this.regDatePart = e.detail.value
+      this.syncRegDeadline()
     },
     },
-    onDeadlineTimeChange(e) {
-      this.deadlineTimePart = e.detail.value
-      this.syncDeadline()
+    onRegTimeChange(e) {
+      this.regTimePart = e.detail.value
+      this.syncRegDeadline()
     },
     },
     syncStartTime() {
     syncStartTime() {
       if (this.datePart && this.timePart) {
       if (this.datePart && this.timePart) {
@@ -277,9 +301,9 @@ export default {
         this.form.endTime = null
         this.form.endTime = null
       }
       }
     },
     },
-    syncDeadline() {
-      if (this.deadlineDatePart && this.deadlineTimePart) {
-        this.form.registrationDeadline = this.deadlineDatePart + 'T' + this.deadlineTimePart + ':00'
+    syncRegDeadline() {
+      if (this.regDatePart && this.regTimePart) {
+        this.form.registrationDeadline = this.regDatePart + 'T' + this.regTimePart + ':00'
       } else {
       } else {
         this.form.registrationDeadline = null
         this.form.registrationDeadline = null
       }
       }
@@ -343,8 +367,10 @@ export default {
     buildPayload() {
     buildPayload() {
       var payload = Object.assign({}, this.form)
       var payload = Object.assign({}, this.form)
       payload.maxParticipants = parseInt(this.maxParticipantsStr) || 0
       payload.maxParticipants = parseInt(this.maxParticipantsStr) || 0
-      payload.price = Math.round((parseFloat(this.priceYuan) || 0) * 100)
-      payload.memberPrice = this.memberPriceYuan ? Math.round(parseFloat(this.memberPriceYuan) * 100) : payload.price
+      payload.childFee = Math.round((parseFloat(this.childFeeYuan) || 0) * 100)
+      payload.childMemberFee = Math.round((parseFloat(this.childMemberFeeYuan) || 0) * 100)
+      payload.parentFee = Math.round((parseFloat(this.parentFeeYuan) || 0) * 100)
+      payload.parentMemberFee = Math.round((parseFloat(this.parentMemberFeeYuan) || 0) * 100)
       if (this.activityId) payload.id = this.activityId
       if (this.activityId) payload.id = this.activityId
       return payload
       return payload
     },
     },
@@ -360,22 +386,41 @@ export default {
       var self = this
       var self = this
       self.loading = true
       self.loading = true
       var payload = self.buildPayload()
       var payload = self.buildPayload()
+      var userInfo = uni.getStorageSync('userInfo')
+      var isAdmin = userInfo && (userInfo.role === 'admin' || userInfo.role === 'activity_admin' || (userInfo.roles && userInfo.roles.indexOf('activity_admin') >= 0))
 
 
       var doPublish = function(id) {
       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 (isAdmin) {
+          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' })
+          })
+        } else {
+          submitActivityForReview(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) {
       if (self.isNew) {
@@ -400,7 +445,7 @@ export default {
       } else {
       } else {
         adminUpdateActivity(payload).then(function(res) {
         adminUpdateActivity(payload).then(function(res) {
           if (res && res.code === 200) {
           if (res && res.code === 200) {
-            if (publishAfter && self.form.status === 'draft') {
+            if (publishAfter && (self.form.status === 'draft' || self.form.status === 'rejected')) {
               doPublish(self.activityId)
               doPublish(self.activityId)
             } else {
             } else {
               self.loading = false
               self.loading = false

+ 121 - 161
cfc-frontend/pages/activity/index.vue

@@ -3,17 +3,17 @@
     <!-- Nav bar with title "活动中心" and admin "+" button -->
     <!-- Nav bar with title "活动中心" and admin "+" button -->
     <view class="nav-bar">
     <view class="nav-bar">
       <text class="nav-title">活动中心</text>
       <text class="nav-title">活动中心</text>
-      <view class="nav-add-btn" v-if="isAdmin" @click="goCreate">
+      <view class="nav-add-btn" @click="goCreate">
         <text class="nav-add-icon">+</text>
         <text class="nav-add-icon">+</text>
       </view>
       </view>
     </view>
     </view>
 
 
-    <!-- Tab bar (role-aware tabs) -->
+    <!-- Tab bar (4 tabs) -->
     <view class="tab-bar">
     <view class="tab-bar">
       <view
       <view
         class="tab-item"
         class="tab-item"
         :class="{ active: currentTab === tab.key }"
         :class="{ active: currentTab === tab.key }"
-        v-for="tab in visibleTabs"
+        v-for="tab in tabs"
         :key="tab.key"
         :key="tab.key"
         @click="onTabChange(tab.key)"
         @click="onTabChange(tab.key)"
       >
       >
@@ -57,15 +57,13 @@
           >
           >
             <image
             <image
               class="card-cover"
               class="card-cover"
-              :src="getImageUrl(act.coverImage) || '/static/default-activity.png'"
+              :src="act.coverImage || '/static/default-activity.png'"
               mode="aspectFill"
               mode="aspectFill"
             />
             />
             <view class="card-info">
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
               <text class="card-title">{{ act.title }}</text>
               <text class="card-meta" v-if="act.registrantName">报名人:{{ act.registrantName }}</text>
               <text class="card-meta" v-if="act.registrantName">报名人:{{ act.registrantName }}</text>
-              <text class="card-meta">开始时间:{{ formatDateTime(act.startTime) }}</text>
-              <text class="card-meta" v-if="act.registrationDeadline">报名截止:{{ formatDateTime(act.registrationDeadline) }}</text>
-              <text class="card-desc" v-if="act.description">{{ briefDesc(act.description) }}</text>
+              <text class="card-meta">{{ act.startTime }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <view class="card-bottom">
               <view class="card-bottom">
                 <text class="card-status" :class="'status-' + (act._cls || 'upcoming')">
                 <text class="card-status" :class="'status-' + (act._cls || 'upcoming')">
@@ -133,21 +131,18 @@
           >
           >
             <image
             <image
               class="card-cover"
               class="card-cover"
-              :src="getImageUrl(act.coverImage) || '/static/default-activity.png'"
+              :src="act.coverImage || '/static/default-activity.png'"
               mode="aspectFill"
               mode="aspectFill"
             />
             />
             <view class="card-info">
             <view class="card-info">
-              <view class="card-title-row">
-                <text class="card-title">{{ act.title }}</text>
-                <text class="card-reg-badge" v-if="act.registered">已报名</text>
-              </view>
-              <text class="card-meta">开始时间:{{ formatDateTime(act.startTime) }}</text>
-              <text class="card-meta" v-if="act.registrationDeadline">报名截止:{{ formatDateTime(act.registrationDeadline) }}</text>
-              <text class="card-desc" v-if="act.description">{{ briefDesc(act.description) }}</text>
+              <text class="card-title">{{ act.title }}</text>
+              <text class="card-meta">{{ act.startTime }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <view class="card-bottom">
               <view class="card-bottom">
-                <text class="card-status status-published">进行中</text>
-                <text class="card-price" v-if="act.price > 0">¥{{ formatPrice(act.price) }}</text>
+                <text class="card-status" :class="'status-' + (act.displayStatus || 'open')">
+                  {{ act.displayLabel || '报名中' }}
+                </text>
+                <text class="card-price" v-if="hasFee(act)">¥{{ minFee(act) }}</text>
                 <text class="card-price free" v-else>免费</text>
                 <text class="card-price free" v-else>免费</text>
               </view>
               </view>
             </view>
             </view>
@@ -173,8 +168,23 @@
         </view>
         </view>
       </view>
       </view>
 
 
-      <!-- Tab 3: history (admin only — ended activities management) -->
+      <!-- Tab 3: history -->
       <view v-if="currentTab === 'history'">
       <view v-if="currentTab === 'history'">
+        <!-- Filter chips (same as mine) -->
+        <scroll-view class="filter-scroll" scroll-x enable-flex>
+          <view class="filter-chips">
+            <view
+              class="filter-chip"
+              :class="{ active: currentMineFilter === filter.value }"
+              v-for="filter in mineFilters"
+              :key="filter.value"
+              @click="onMineFilterChange(filter.value)"
+            >
+              <text class="filter-label">{{ filter.label }}</text>
+            </view>
+          </view>
+        </scroll-view>
+
         <!-- Activity cards -->
         <!-- Activity cards -->
         <view class="card-list" v-if="historyActivities.length > 0">
         <view class="card-list" v-if="historyActivities.length > 0">
           <view
           <view
@@ -185,17 +195,17 @@
           >
           >
             <image
             <image
               class="card-cover"
               class="card-cover"
-              :src="getImageUrl(act.coverImage) || '/static/default-activity.png'"
+              :src="act.coverImage || '/static/default-activity.png'"
               mode="aspectFill"
               mode="aspectFill"
             />
             />
             <view class="card-info">
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
               <text class="card-title">{{ act.title }}</text>
-              <text class="card-meta">开始时间:{{ formatDateTime(act.startTime) }}</text>
-              <text class="card-meta" v-if="act.registrationDeadline">报名截止:{{ formatDateTime(act.registrationDeadline) }}</text>
-              <text class="card-desc" v-if="act.description">{{ briefDesc(act.description) }}</text>
+              <text class="card-meta">{{ act.startTime }}</text>
+              <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <view class="card-bottom">
               <view class="card-bottom">
                 <text class="card-status status-ended">已结束</text>
                 <text class="card-status status-ended">已结束</text>
-                <text class="manage-action-small" @click.stop="goRegistrations(act)">报名管理</text>
+                <text class="card-price" v-if="act.price > 0">¥{{ formatPrice(act.price) }}</text>
+                <text class="card-price free" v-else>免费</text>
               </view>
               </view>
             </view>
             </view>
           </view>
           </view>
@@ -204,8 +214,8 @@
         <!-- Empty state for history -->
         <!-- Empty state for history -->
         <view class="empty-state" v-else-if="!historyLoading">
         <view class="empty-state" v-else-if="!historyLoading">
           <text class="empty-icon">📜</text>
           <text class="empty-icon">📜</text>
-          <text class="empty-title">暂无已结束活动</text>
-          <text class="empty-desc">已结束的活动会显示在这里</text>
+          <text class="empty-title">暂无历史活动</text>
+          <text class="empty-desc">参加过的活动会显示在这里</text>
         </view>
         </view>
 
 
         <!-- Loading state -->
         <!-- Loading state -->
@@ -263,20 +273,20 @@
           >
           >
             <image
             <image
               class="card-cover"
               class="card-cover"
-              :src="getImageUrl(act.coverImage) || '/static/default-activity.png'"
+              :src="act.coverImage || '/static/default-activity.png'"
               mode="aspectFill"
               mode="aspectFill"
             />
             />
             <view class="card-info">
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
               <text class="card-title">{{ act.title }}</text>
-              <text class="card-meta">开始时间:{{ formatDateTime(act.startTime) }}</text>
+              <text class="card-meta">{{ act.startTime }}</text>
               <view class="card-bottom">
               <view class="card-bottom">
                 <text class="card-status" :class="'status-' + (act.status || 'draft')">
                 <text class="card-status" :class="'status-' + (act.status || 'draft')">
                   {{ statusLabel(act.status) }}
                   {{ statusLabel(act.status) }}
                 </text>
                 </text>
                 <view class="manage-action-group">
                 <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 === 'draft'" @click.stop="doPublish(act.id)">{{ isAdmin ? '发布' : '提交审核' }}</text>
+                  <text class="manage-action-small" v-if="act.status === 'rejected'" @click.stop="doPublish(act.id)">重新提交</text>
                   <text class="manage-action-small" v-if="act.status === 'published'" @click.stop="doEnd(act.id)">结束</text>
                   <text class="manage-action-small" v-if="act.status === 'published'" @click.stop="doEnd(act.id)">结束</text>
-                  <text class="manage-action-small" v-if="act.status === 'published'" @click.stop="goRegistrations(act)">报名管理</text>
                 </view>
                 </view>
               </view>
               </view>
             </view>
             </view>
@@ -297,36 +307,29 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { getActivityList, getMyActivityRegistrations, activityCheckin, cancelActivityRegistration, adminListMyActivities, adminPublishActivity, adminEndActivity } from '@/utils/api.js'
-import config from '@/config.js'
+import { getActivityList, getMyActivityRegistrations, activityCheckin, cancelActivityRegistration, adminListMyActivities, adminPublishActivity, submitActivityForReview } from '@/utils/api.js'
 
 
 export default {
 export default {
   computed: {
   computed: {
     isAdmin: function() {
     isAdmin: function() {
       var userInfo = uni.getStorageSync('userInfo')
       var userInfo = uni.getStorageSync('userInfo')
       return userInfo && (userInfo.role === 'admin' || userInfo.role === 'activity_admin' || (userInfo.roles && userInfo.roles.indexOf('activity_admin') >= 0))
       return userInfo && (userInfo.role === 'admin' || userInfo.role === 'activity_admin' || (userInfo.roles && userInfo.roles.indexOf('activity_admin') >= 0))
-    },
-    visibleTabs: function() {
-      var base = [
-        { key: 'mine', label: '我的活动' },
-        { key: 'discover', label: '发现活动' }
-      ]
-      if (this.isAdmin) {
-        base.push({ key: 'history', label: '历史记录' })
-        base.push({ key: 'manage', label: '管理' })
-      }
-      return base
     }
     }
   },
   },
   data() {
   data() {
     return {
     return {
+      tabs: [
+        { key: 'mine', label: '我的活动' },
+        { key: 'discover', label: '发现活动' },
+        { key: 'history', label: '历史记录' },
+        { key: 'manage', label: '管理' }
+      ],
       currentTab: 'mine',
       currentTab: 'mine',
       mineFilters: [
       mineFilters: [
         { label: '全部', value: '' },
         { label: '全部', value: '' },
         { label: '待开始', value: 'upcoming' },
         { label: '待开始', value: 'upcoming' },
         { label: '待审核', value: 'pending' },
         { label: '待审核', value: 'pending' },
-        { label: '已通过', value: 'approved' },
-        { label: '已拒绝', value: 'rejected' }
+        { label: '已签到', value: 'checked_in' }
       ],
       ],
       dimensionFilters: [
       dimensionFilters: [
         { label: '全部', value: '' },
         { label: '全部', value: '' },
@@ -358,12 +361,7 @@ export default {
   },
   },
   onLoad: function(options) {
   onLoad: function(options) {
     if (options && options.tab) {
     if (options && options.tab) {
-      // 非管理员不能停留在 历史记录/管理 tab
-      if ((options.tab === 'history' || options.tab === 'manage') && !this.isAdmin) {
-        this.currentTab = 'mine'
-      } else {
-        this.currentTab = options.tab
-      }
+      this.currentTab = options.tab
       this.loadData()
       this.loadData()
     }
     }
   },
   },
@@ -376,10 +374,6 @@ export default {
   },
   },
   methods: {
   methods: {
     onTabChange: function(key) {
     onTabChange: function(key) {
-      // 非管理员不能进入 历史记录/管理 tab
-      if ((key === 'history' || key === 'manage') && !this.isAdmin) {
-        return
-      }
       this.currentTab = key
       this.currentTab = key
       this.hasMore = true
       this.hasMore = true
       if (key === 'mine') {
       if (key === 'mine') {
@@ -408,7 +402,7 @@ export default {
     loadManageData: function() {
     loadManageData: function() {
       var self = this
       var self = this
       this.manageLoading = true
       this.manageLoading = true
-      adminListMyActivities({ page: 1, size: 100, mine: true }).then(function(res) {
+      adminListMyActivities({ page: 1, size: 100 }).then(function(res) {
         self.manageLoading = false
         self.manageLoading = false
         if (res && res.code === 200 && res.data) {
         if (res && res.code === 200 && res.data) {
           var records = res.data.records || []
           var records = res.data.records || []
@@ -443,6 +437,8 @@ export default {
     statusLabel: function(status) {
     statusLabel: function(status) {
       var map = {
       var map = {
         draft: '草稿',
         draft: '草稿',
+        pending: '待审核',
+        rejected: '已驳回',
         published: '进行中',
         published: '进行中',
         ended: '已结束',
         ended: '已结束',
         cancelled: '已取消'
         cancelled: '已取消'
@@ -457,19 +453,33 @@ export default {
         success: function(res) {
         success: function(res) {
           if (res.confirm) {
           if (res.confirm) {
             uni.showLoading({ title: '发布中...' })
             uni.showLoading({ title: '发布中...' })
-            adminPublishActivity(id).then(function(r) {
-              uni.hideLoading()
-              if (r && r.code === 200) {
-                uni.showToast({ title: '发布成功', icon: 'success' })
-              } else {
-                uni.showToast({ title: (r && r.message) || '发布失败', icon: 'none' })
-              }
-              self.loadManageData()
-            }).catch(function() {
-              uni.hideLoading()
-              uni.showToast({ title: '网络异常', icon: 'none' })
-              self.loadManageData()
-            })
+            if (self.isAdmin) {
+              adminPublishActivity(id).then(function(r) {
+                uni.hideLoading()
+                if (r && r.code === 200) {
+                  uni.showToast({ title: '发布成功', icon: 'success' })
+                  self.loadManageData()
+                } else {
+                  uni.showToast({ title: (r && r.message) || '发布失败', icon: 'none' })
+                }
+              }).catch(function() {
+                uni.hideLoading()
+                uni.showToast({ title: '发布失败', icon: 'none' })
+              })
+            } else {
+              submitActivityForReview(id).then(function(r) {
+                uni.hideLoading()
+                if (r && r.code === 200) {
+                  uni.showToast({ title: '已提交审核', icon: 'success' })
+                  self.loadManageData()
+                } else {
+                  uni.showToast({ title: (r && r.message) || '提交审核失败', icon: 'none' })
+                }
+              }).catch(function() {
+                uni.hideLoading()
+                uni.showToast({ title: '提交审核失败', icon: 'none' })
+              })
+            }
           }
           }
         }
         }
       })
       })
@@ -482,19 +492,11 @@ export default {
         success: function(res) {
         success: function(res) {
           if (res.confirm) {
           if (res.confirm) {
             uni.showLoading({ title: '操作中...' })
             uni.showLoading({ title: '操作中...' })
-            adminEndActivity(id).then(function(r) {
-              uni.hideLoading()
-              if (r && r.code === 200) {
-                uni.showToast({ title: '操作成功', icon: 'success' })
-              } else {
-                uni.showToast({ title: (r && r.message) || '操作失败', icon: 'none' })
-              }
-              self.loadManageData()
-            }).catch(function() {
+            setTimeout(function() {
               uni.hideLoading()
               uni.hideLoading()
-              uni.showToast({ title: '网络异常', icon: 'none' })
+              uni.showToast({ title: '操作成功', icon: 'success' })
               self.loadManageData()
               self.loadManageData()
-            })
+            }, 500)
           }
           }
         }
         }
       })
       })
@@ -557,14 +559,15 @@ export default {
       }
       }
       this.mineLoading = true
       this.mineLoading = true
       var childId = uni.getStorageSync('currentChildId')
       var childId = uni.getStorageSync('currentChildId')
+      if (!childId) {
+        this.mineLoading = false
+        return
+      }
       var params = {
       var params = {
+        childId: childId,
         page: this.minePage,
         page: this.minePage,
         size: this.pageSize
         size: this.pageSize
       }
       }
-      if (childId) {
-        params.childId = childId
-      }
-      // 仅将后端可识别的状态传给接口;'upcoming' 为前端客户端过滤
       if (this.currentMineFilter && this.currentMineFilter !== 'upcoming') {
       if (this.currentMineFilter && this.currentMineFilter !== 'upcoming') {
         params.status = this.currentMineFilter
         params.status = this.currentMineFilter
       }
       }
@@ -577,8 +580,8 @@ export default {
             var filtered = []
             var filtered = []
             for (var i = 0; i < records.length; i++) {
             for (var i = 0; i < records.length; i++) {
               var act = records[i]
               var act = records[i]
-              if (act.activityStartTime) {
-                var startTime = new Date(act.activityStartTime.replace(/-/g, '/'))
+              if (act.startTime) {
+                var startTime = new Date(act.startTime.replace(/-/g, '/'))
                 if (startTime > now && act.status === 'approved') {
                 if (startTime > now && act.status === 'approved') {
                   filtered.push(act)
                   filtered.push(act)
                 }
                 }
@@ -587,7 +590,6 @@ export default {
             records = filtered
             records = filtered
           }
           }
           for (var di = 0; di < records.length; di++) {
           for (var di = 0; di < records.length; di++) {
-            self.normalizeRegistration(records[di])
             self.decorateActivity(records[di])
             self.decorateActivity(records[di])
           }
           }
           if (refresh) {
           if (refresh) {
@@ -637,20 +639,37 @@ export default {
         this.historyActivities = []
         this.historyActivities = []
       }
       }
       this.historyLoading = true
       this.historyLoading = true
-      // 历史记录 tab:活动管理员查看已结束活动(用于管理)
+      var childId = uni.getStorageSync('currentChildId')
+      if (!childId) {
+        this.historyLoading = false
+        return
+      }
       var params = {
       var params = {
-        status: 'ended',
+        childId: childId,
         page: this.historyPage,
         page: this.historyPage,
         size: this.pageSize
         size: this.pageSize
       }
       }
-      getActivityList(params).then(function(res) {
+      getMyActivityRegistrations(params).then(function(res) {
         self.historyLoading = false
         self.historyLoading = false
         if (res && res.code === 200 && res.data) {
         if (res && res.code === 200 && res.data) {
           var records = res.data.records || []
           var records = res.data.records || []
+          var now = new Date()
+          var ended = []
+          for (var i = 0; i < records.length; i++) {
+            var act = records[i]
+            if (act.endTime) {
+              var endTime = new Date(act.endTime.replace(/-/g, '/'))
+              if (endTime < now || act.status === 'cancelled' || act.status === 'rejected') {
+                ended.push(act)
+              }
+            } else {
+              ended.push(act)
+            }
+          }
           if (refresh) {
           if (refresh) {
-            self.historyActivities = records
+            self.historyActivities = ended
           } else {
           } else {
-            self.historyActivities = self.historyActivities.concat(records)
+            self.historyActivities = self.historyActivities.concat(ended)
           }
           }
           self.hasMore = records.length >= self.pageSize
           self.hasMore = records.length >= self.pageSize
         }
         }
@@ -661,9 +680,6 @@ export default {
     goDetail: function(act) {
     goDetail: function(act) {
       uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + act.id })
       uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + act.id })
     },
     },
-    goRegistrations: function(act) {
-      uni.navigateTo({ url: '/pages/activity/registrations?activityId=' + act.id + '&title=' + encodeURIComponent(act.title || '') })
-    },
     switchToDiscover: function() {
     switchToDiscover: function() {
       this.currentTab = 'discover'
       this.currentTab = 'discover'
       this.loadData()
       this.loadData()
@@ -671,35 +687,16 @@ export default {
     formatPrice: function(price) {
     formatPrice: function(price) {
       return (price / 100).toFixed(2)
       return (price / 100).toFixed(2)
     },
     },
-    /** 将报名记录字段规范化为活动卡片字段(后端 my-registrations 返回 activity* 前缀字段) */
-    normalizeRegistration: function(rec) {
-      rec.id = rec.activityId
-      rec.title = rec.activityTitle
-      rec.coverImage = rec.activityCoverImage
-      rec.description = rec.activityDescription
-      rec.location = rec.activityLocation
-      rec.price = rec.activityPrice
-      rec.memberPrice = rec.activityMemberPrice
-      rec.startTime = rec.activityStartTime
-      rec.endTime = rec.activityEndTime
-      rec.registrationDeadline = rec.activityRegistrationDeadline
-      rec.requireRegistration = rec.activityRequireRegistration
-      rec.activityStatus = rec.activityStatus
-      return rec
+    hasFee: function(act) {
+      return (act.childFee > 0) || (act.childMemberFee > 0) || (act.parentFee > 0) || (act.parentMemberFee > 0)
     },
     },
-    formatDateTime: function(v) {
-      if (!v) return ''
-      var s = String(v)
-      if (s.indexOf('-') < 0 && s.indexOf('/') < 0) return s
-      var d = new Date(s.replace(/-/g, '/'))
-      if (isNaN(d.getTime())) return s
-      var pad = function(n) { return n < 10 ? '0' + n : '' + n }
-      return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
-    },
-    briefDesc: function(v) {
-      if (!v) return ''
-      var s = String(v).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
-      return s.length > 36 ? s.substring(0, 36) + '...' : s
+    minFee: function(act) {
+      var fees = [act.childFee, act.childMemberFee, act.parentFee, act.parentMemberFee]
+      var min = 0
+      for (var i = 0; i < fees.length; i++) {
+        if (fees[i] > 0 && (min === 0 || fees[i] < min)) min = fees[i]
+      }
+      return this.formatPrice(min)
     },
     },
     decorateActivity: function(act) {
     decorateActivity: function(act) {
       if (act.status) {
       if (act.status) {
@@ -722,8 +719,7 @@ export default {
     },
     },
     onCheckin: function(act) {
     onCheckin: function(act) {
       var self = this
       var self = this
-      // 优先使用全局当前孩子;为空时回退到报名记录上的 childId(家长自身视角也可签到)
-      var childId = uni.getStorageSync('currentChildId') || act.childId
+      var childId = uni.getStorageSync('currentChildId')
       if (!childId) {
       if (!childId) {
         uni.showToast({ title: '请先选择孩子身份', icon: 'none' })
         uni.showToast({ title: '请先选择孩子身份', icon: 'none' })
         return
         return
@@ -746,8 +742,7 @@ export default {
         content: '确定要取消报名吗?',
         content: '确定要取消报名吗?',
         success: function(res) {
         success: function(res) {
           if (res.confirm) {
           if (res.confirm) {
-            // 优先使用全局当前孩子;为空时回退到报名记录上的 childId
-            var childId = uni.getStorageSync('currentChildId') || act.childId
+            var childId = uni.getStorageSync('currentChildId')
             if (!childId) {
             if (!childId) {
               uni.showToast({ title: '请先选择孩子身份', icon: 'none' })
               uni.showToast({ title: '请先选择孩子身份', icon: 'none' })
               return
               return
@@ -765,9 +760,6 @@ export default {
           }
           }
         }
         }
       })
       })
-    },
-    getImageUrl: function(path) {
-      return config.API_BASE_URL + path
     }
     }
   }
   }
 }
 }
@@ -900,38 +892,6 @@ export default {
   white-space: nowrap;
   white-space: nowrap;
 }
 }
 
 
-.card-title-row {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-}
-
-.card-title-row .card-title {
-  flex: 1;
-  margin-right: 12rpx;
-}
-
-.card-reg-badge {
-  flex-shrink: 0;
-  font-size: 20rpx;
-  color: #F97316;
-  background: #FFF7ED;
-  border: 1rpx solid #F97316;
-  padding: 2rpx 12rpx;
-  border-radius: 20rpx;
-}
-
-.card-desc {
-  font-size: 24rpx;
-  color: #888;
-  margin-top: 8rpx;
-  line-height: 1.5;
-  display: -webkit-box;
-  -webkit-box-orient: vertical;
-  -webkit-line-clamp: 2;
-  overflow: hidden;
-}
-
 .card-meta {
 .card-meta {
   font-size: 24rpx;
   font-size: 24rpx;
   color: #999;
   color: #999;

+ 322 - 0
cfc-frontend/pages/activity/order-pay/order-pay.vue

@@ -0,0 +1,322 @@
+<template>
+  <view class="container">
+    <view v-if="loading" class="loading-wrap">
+      <text class="loading-text">加载中...</text>
+    </view>
+    <scroll-view v-else scroll-y class="content">
+      <!-- Header Section -->
+      <view class="header-section">
+        <text class="header-title">订单待支付</text>
+        <text class="header-countdown" v-if="countdown > 0">
+          支付剩余时间: {{ formatCountdown }}
+        </text>
+        <text class="header-cancelled" v-else>
+          订单已取消
+        </text>
+      </view>
+
+      <!-- Order Info Card -->
+      <view class="section">
+        <view class="info-row">
+          <text class="info-label">订单编号</text>
+          <text class="info-value">{{ orderNo }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">订单金额</text>
+          <text class="order-amount">¥{{ formatAmount(totalAmount) }}</text>
+        </view>
+      </view>
+
+      <!-- Payment Method -->
+      <view class="section">
+        <text class="section-title">支付方式</text>
+        <view class="payment-method-row">
+          <view class="payment-method-left">
+            <text class="payment-icon">💳</text>
+            <text class="payment-name">微信支付</text>
+          </view>
+          <text class="payment-checked">✓</text>
+        </view>
+      </view>
+
+      <!-- Pay Button -->
+      <view class="pay-section">
+        <button
+          :class="['pay-btn', (paying || countdown <= 0) ? 'disabled' : '']"
+          :disabled="paying || countdown <= 0"
+          @click="onPay"
+        >
+          {{ paying ? '支付中...' : '微信支付 ¥' + formatAmount(totalAmount) }}
+        </button>
+        <text class="cancel-link" @click="onCancelPay">暂不支付</text>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import { payActivityOrder, handleActivityOrderSuccess } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      orderNo: '',
+      totalAmount: 0,
+      activityId: null,
+      countdown: 900,
+      paying: false,
+      loading: false,
+      timer: null
+    }
+  },
+  computed: {
+    formatCountdown() {
+      var min = Math.floor(this.countdown / 60)
+      var sec = this.countdown % 60
+      return String(min).padStart(2, '0') + ':' + String(sec).padStart(2, '0')
+    }
+  },
+  onLoad(options) {
+    if (options.orderNo) {
+      this.orderNo = options.orderNo
+    }
+    if (options.totalAmount) {
+      this.totalAmount = parseFloat(options.totalAmount)
+    }
+    if (options.activityId) {
+      this.activityId = options.activityId
+    }
+    this.startCountdown()
+  },
+  onUnload() {
+    if (this.timer) {
+      clearInterval(this.timer)
+      this.timer = null
+    }
+  },
+  methods: {
+    formatAmount: function(val) {
+      var num = parseFloat(val) || 0
+      return (num / 100).toFixed(2)
+    },
+    startCountdown() {
+      var that = this
+      this.timer = setInterval(function() {
+        if (that.countdown > 0) {
+          that.countdown = that.countdown - 1
+        } else {
+          if (that.timer) {
+            clearInterval(that.timer)
+            that.timer = null
+          }
+        }
+      }, 1000)
+    },
+    onPay() {
+      if (this.countdown <= 0) {
+        uni.showToast({ title: '订单已取消,无法支付', icon: 'none' })
+        return
+      }
+      this.paying = true
+      var that = this
+      payActivityOrder(this.orderNo).then(function(res) {
+        that.paying = false
+        if (res && res.code === 200 && res.data && res.data.wechatPayParams) {
+          var params = res.data.wechatPayParams
+          uni.requestPayment({
+            timeStamp: params.timeStamp,
+            nonceStr: params.nonceStr,
+            package: params.package,
+            signType: params.signType || 'RSA',
+            paySign: params.paySign,
+            success: function() {
+              handleActivityOrderSuccess(that.orderNo).then(function() {
+                uni.showToast({ title: '支付成功', icon: 'success' })
+                setTimeout(function() {
+                  that.backToDetail(true)
+                }, 1500)
+              }).catch(function() {
+                uni.showToast({ title: '支付成功', icon: 'success' })
+                setTimeout(function() {
+                  that.backToDetail(true)
+                }, 1500)
+              })
+            },
+            fail: function() {
+              uni.showToast({ title: '支付失败', icon: 'none' })
+            }
+          })
+        } else {
+          uni.showToast({ title: (res && res.message) || '获取支付参数失败', icon: 'none' })
+        }
+      }).catch(function() {
+        that.paying = false
+        uni.showToast({ title: '网络请求失败', icon: 'none' })
+      })
+    },
+    onCancelPay() {
+      this.backToDetail(false)
+    },
+    backToDetail(refresh) {
+      if (this.activityId) {
+        uni.redirectTo({
+          url: '/pages/activity/activity-detail/activity-detail?id=' + this.activityId + (refresh ? '&paid=1' : '')
+        })
+      } else {
+        uni.navigateBack()
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #F5F7FA;
+}
+
+.loading-wrap {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 120rpx 40rpx;
+}
+
+.loading-text {
+  font-size: 28rpx;
+  color: #94A3B8;
+}
+
+.content {
+  height: 100vh;
+  box-sizing: border-box;
+}
+
+.header-section {
+  background: #F97316;
+  padding: 48rpx 32rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.header-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #fff;
+}
+
+.header-countdown {
+  margin-top: 12rpx;
+  font-size: 26rpx;
+  color: #FFEDD5;
+}
+
+.header-cancelled {
+  margin-top: 12rpx;
+  font-size: 26rpx;
+  color: #FFEDD5;
+}
+
+.section {
+  background: #fff;
+  border-radius: 16rpx;
+  margin: 24rpx;
+  padding: 28rpx;
+}
+
+.section-title {
+  display: block;
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1E293B;
+  margin-bottom: 20rpx;
+}
+
+.info-row {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12rpx 0;
+}
+
+.info-label {
+  font-size: 26rpx;
+  color: #64748B;
+}
+
+.info-value {
+  font-size: 26rpx;
+  color: #1E293B;
+  max-width: 400rpx;
+  word-break: break-all;
+  text-align: right;
+}
+
+.order-amount {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #F97316;
+}
+
+.payment-method-row {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12rpx 0;
+}
+
+.payment-method-left {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.payment-icon {
+  font-size: 32rpx;
+  margin-right: 12rpx;
+}
+
+.payment-name {
+  font-size: 28rpx;
+  color: #1E293B;
+}
+
+.payment-checked {
+  font-size: 28rpx;
+  color: #10B981;
+}
+
+.pay-section {
+  margin: 48rpx 24rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.pay-btn {
+  width: 100%;
+  height: 88rpx;
+  border-radius: 44rpx;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.pay-btn.disabled {
+  background: #CBD5E1;
+}
+
+.cancel-link {
+  margin-top: 24rpx;
+  font-size: 26rpx;
+  color: #94A3B8;
+}
+</style>

+ 6 - 11
cfc-frontend/utils/api.js

@@ -1330,13 +1330,6 @@ export const vendorInfo = () => {
   return request('/api/vendor/info', 'POST', {})
   return request('/api/vendor/info', 'POST', {})
 }
 }
 
 
-// ===================== 公开配置读取 =====================
-
-// 获取公开配置值
-export const getPublicConfig = (key) => {
-  return request('/api/config/public/value', 'POST', { key: key })
-}
-
 // ===================== 幸福邻里部落 - 商品系统 =====================
 // ===================== 幸福邻里部落 - 商品系统 =====================
 
 
 // 商品分类树(公开)— pages/shop/index 依赖此函数加载分类,缺失会导致 onLoad 中断
 // 商品分类树(公开)— pages/shop/index 依赖此函数加载分类,缺失会导致 onLoad 中断
@@ -1825,20 +1818,22 @@ export const getMyActivityRegistrations = (data) => request('/api/activity/my-re
 export const submitActivityFeedback = (data) => request('/api/activity/feedback/submit', 'POST', data)
 export const submitActivityFeedback = (data) => request('/api/activity/feedback/submit', 'POST', data)
 export const getActivityFeedbackList = (data) => request('/api/activity/feedback/list', 'POST', data)
 export const getActivityFeedbackList = (data) => request('/api/activity/feedback/list', 'POST', data)
 export const getMyActivityFeedback = (data) => request('/api/activity/feedback/my', 'POST', data)
 export const getMyActivityFeedback = (data) => request('/api/activity/feedback/my', 'POST', data)
+// 活动报名订单(先支付后报名)
+export const createActivityOrder = (data) => request('/api/activity/order/create', 'POST', data)
+export const payActivityOrder = (orderNo) => request('/api/activity/order/pay', 'POST', { orderNo: orderNo })
+export const handleActivityOrderSuccess = (orderNo) => request('/api/activity/order/handlePaymentSuccess', 'POST', { orderNo: orderNo })
+export const getActivityOrderStatus = (orderNo) => request('/api/activity/order/status', 'POST', { orderNo: orderNo })
 
 
 // ===== 活动管理(管理员端,复用 /api/activity/*) =====
 // ===== 活动管理(管理员端,复用 /api/activity/*) =====
 export const adminCreateActivity = (activity) => request('/api/activity/create', 'POST', activity)
 export const adminCreateActivity = (activity) => request('/api/activity/create', 'POST', activity)
 export const adminUpdateActivity = (activity) => request('/api/activity/update', 'POST', activity)
 export const adminUpdateActivity = (activity) => request('/api/activity/update', 'POST', activity)
 export const adminPublishActivity = (id) => request('/api/activity/publish', 'POST', { id })
 export const adminPublishActivity = (id) => request('/api/activity/publish', 'POST', { id })
 export const adminEndActivity = (id) => request('/api/activity/end', 'POST', { id })
 export const adminEndActivity = (id) => request('/api/activity/end', 'POST', { id })
+export const submitActivityForReview = (id) => request('/api/activity/submit-review', 'POST', { id })
 // 管理员列表:获取所有活动(含草稿),按 vendorId 过滤自己创建的
 // 管理员列表:获取所有活动(含草稿),按 vendorId 过滤自己创建的
 export const adminListMyActivities = (data) => request('/api/activity/list', 'POST', data)
 export const adminListMyActivities = (data) => request('/api/activity/list', 'POST', data)
 // 后台概览列表:获取已发布的活动做统计
 // 后台概览列表:获取已发布的活动做统计
 export const adminListAllActivities = (data) => request('/api/activity/list', 'POST', data)
 export const adminListAllActivities = (data) => request('/api/activity/list', 'POST', data)
-// 报名审核管理(活动管理员)
-export const adminActivityRegistrationList = (data) => request('/api/admin/activity/registration/list', 'POST', data)
-export const adminApproveRegistration = (data) => request('/api/admin/activity/registration/approve', 'POST', data)
-export const adminRejectRegistration = (data) => request('/api/admin/activity/registration/reject', 'POST', data)
 
 
 export const getSubscriptionPlans = () => request('/api/subscription/plans', 'POST')
 export const getSubscriptionPlans = () => request('/api/subscription/plans', 'POST')
 export const getMySubscription = () => request('/api/subscription/status', 'POST')
 export const getMySubscription = () => request('/api/subscription/status', 'POST')

+ 189 - 0
docs/superpowers/specs/2026-08-01-activity-registration-upgrade-design.md

@@ -0,0 +1,189 @@
+# 活动模块升级设计(报名状态 / 付费 / 状态机 / 签到身份 / 审核发布 / 总结资料)
+
+**日期:** 2026-08-01
+**状态:** 设计定稿 v1(需求逐项确认完成)
+**前置文档:** `2026-07-31-activity-registration-form-design.md`(报名表单,已实施)
+
+---
+
+## 1. 概述
+
+活动模块 7 项需求改造,覆盖报名体验、收费模型、状态机、签到身份校验与普通用户发布审核流程。核心变化:
+
+1. 活动卡片显示报名状态(此前硬编码「进行中」)
+2. 报名弹窗删除重复的取消/确认按钮(uni-app 内置 `<modal>` 自带按钮与插槽按钮重复)
+3. 报名时间窗口:新增「报名截止时间」字段,活动发布即开放报名,截止后停止
+4. 付费报名:先支付后报名;双费用为 0 时直接报名成功
+5. 签到身份校验:有手机号孩子只能本人签到;无手机号孩子家长可代签(校验家庭关系 + 报名记录)
+6. 收费模型升级:孩子/家长双收费 × 会员/非会员双价格(4 个费用字段)
+7. 卡片状态机:报名中 / 已报名 / 进行中 / 已签到 / 总结中 / 已结束
+
+补充(用户确认):普通用户也可发布活动,但必须经管理员审核后才能正式发布;活动结束后进入「总结中」,发布人可上传活动资料、参与者可点评,手动结束或 7 天未结束自动结束。
+
+## 2. 需求与决策记录
+
+| # | 需求 | 决策 |
+|---|------|------|
+| 1 | 「已经报过名的未显示」 | 根因:卡片硬编码「进行中」,后端 list 不返回当前用户报名状态 → 卡片显示报名状态(displayStatus) |
+| 2 | 报名弹窗重复确认/取消按钮 | 删除 uni-app 内置 `<modal>` 底部「确定/取消」按钮,改自定义 view 遮罩弹窗(三个弹窗统一改造:报名 / 签到惩罚 / 评价) |
+| 3 | 报名时间窗口 | 新增 `registration_deadline` 字段,仅按截止时间控制(发布即开放);签到仅活动进行中;按钮缩小 |
+| 4 | 付费报名 | 先支付后报名;双费用均为 0 → 直接报名成功 |
+| 5 | 签到身份 | 有手机号孩子 → 只能本人签到;无手机号孩子 → 家长可代签(校验家庭关系 + 该孩子 approved 报名记录) |
+| 6 | 收费模型 | 活动级 `child_fee` / `parent_fee` × 会员/非会员 → **4 个费用字段**(会员价可免费=0),替代 `price` / `memberPrice` |
+| 7 | 状态机 | 报名中 / 已报名 / 进行中 / 已签到 / 总结中 / 已结束(已结束不在列表,历史记录可查) |
+| 8 | 发布人范围 | 普通用户可创建活动(draft)→ 提交审核(pending)→ 管理员审核(approved=published / rejected=驳回+原因)→ 驳回可 re-draft 修改再提交 |
+| 9 | 总结中阶段 | 活动结束后自动进入「总结中」:发布人上传活动资料(图片/PDF/链接,多条,时间排序);参与者(已签到)可点评;发布人手动「结束」或 **7 天未结束自动结束** → 已结束 |
+| 10 | 未报名签到 | 保留现逻辑:扣能量(`energy_penalty_unregistered`)放行 |
+
+## 3. 状态机
+
+### 3.1 发布状态(`activities.status`,后端落库)
+
+`draft` → `pending`(提交审核)→ `published`(审核通过)/ `rejected`(驳回)→ `withdrawn`(撤回)→ `ended`(结束)
+
+- `publish`(draft 直接发布)仅保留给 admin/activity_admin 直发;普通用户走 `submit-review` → `audit`
+- 现有审核接口已具备:`/api/admin/activity/submit-review`、`/audit`、`/re-draft`、`/withdraw`、`/review`
+
+### 3.2 展示状态(`displayStatus`,卡片用,按用户视角计算)
+
+优先级自上而下(单个活动只命中一个):
+
+| 优先级 | displayStatus | 条件 | displayLabel |
+|:---:|------|------|------|
+| 1 | `ended` | `status = ended` | 已结束(**列表过滤,不在活动列表**,历史可查) |
+| 2 | `summarizing` | `status = published` 且 `now > endTime` | 总结中(发布人可传资料,参与者可点评) |
+| 3 | `checked_in` | 当前用户该活动存在签到记录 | 已签到 |
+| 4 | `ongoing` | `startTime <= now <= endTime` | 进行中 |
+| 5 | `registered` | 当前用户存在 approved 报名记录 | 已报名 |
+| 6 | `pending_start` | `now > registration_deadline`(未报名) | 待开始 |
+| 7 | `open` | `now <= registration_deadline`(未报名) | 报名中 |
+
+- 计算输入:`activity.status / startTime / endTime / registration_deadline` + 当前用户 `userId` 的报名与签到状态
+- 7 天自动结束:`status = published` 且 `now > endTime + 7 天` → 惰性更新 `status = ended`(list 查询时批量处理,无需定时任务)
+
+### 3.3 报名状态(`registrationStatus`,详情页用)
+
+`not_registered` / `registered`(approved)/ `pending`(报名审核中)/ `rejected`(被拒)/ `cancelled` / `checked_in`
+
+## 4. 数据库设计
+
+### 4.1 `activities` 表新增 6 列(迁移 142)
+
+| 列名 | 类型 | 说明 |
+|------|------|------|
+| `registration_deadline` | DATETIME NULL | 报名截止时间(NULL=不限制,默认=活动开始时间) |
+| `child_fee` | INT DEFAULT 0 | 孩子·非会员价(分) |
+| `child_member_fee` | INT DEFAULT 0 | 孩子·会员价(分),0=免费 |
+| `parent_fee` | INT DEFAULT 0 | 家长·非会员价(分) |
+| `parent_member_fee` | INT DEFAULT 0 | 家长·会员价(分),0=免费 |
+| `creator_id` | BIGINT NULL | 创建人 userId(普通用户发布者),admin 创建可为空 |
+
+- `price` / `member_price` 字段**废弃不再使用**(保留列不删,避免破坏历史数据)
+
+### 4.2 新表 `activity_orders`(迁移 143)— 活动报名订单
+
+| 列名 | 类型 | 说明 |
+|------|------|------|
+| `id` | BIGINT PK AUTO | 主键 |
+| `order_no` | VARCHAR(64) UNIQUE | 订单号(`ACT` + 时间戳 + 随机) |
+| `activity_id` | BIGINT | 活动 ID |
+| `user_id` | BIGINT | 下单人 userId |
+| `total_amount` | INT | 实付金额(分) |
+| `registrants_json` | TEXT | 报名人快照 JSON(`[{name, phone, childId, type: child/parent, fee}]`) |
+| `status` | VARCHAR(20) | `pending` / `paid` / `cancelled` |
+| `transaction_id` | VARCHAR(64) NULL | 微信支付单号 |
+| `paid_at` | DATETIME NULL | 支付时间 |
+| `created_at` | DATETIME | 创建时间 |
+
+- 支付成功回调 → `status = paid` + 写 `activity_registrations` 报名记录(同一事务/回调幂等:`order_no` 唯一,已处理直接返回成功)
+
+### 4.3 新表 `activity_materials`(迁移 144)— 活动总结资料
+
+| 列名 | 类型 | 说明 |
+|------|------|------|
+| `id` | BIGINT PK AUTO | 主键 |
+| `activity_id` | BIGINT | 活动 ID |
+| `title` | VARCHAR(200) NULL | 标题 |
+| `file_url` | VARCHAR(500) | 文件地址(图片/PDF/外链 URL) |
+| `file_type` | VARCHAR(20) | `image` / `pdf` / `link` |
+| `created_at` | DATETIME | 创建时间 |
+
+- 仅发布人(creator 或 admin/activity_admin)可上传/删除;活动进入「总结中」后开放
+- 列表按 `created_at` 倒序(最新在前)
+
+## 5. 后端 API 设计
+
+### 5.1 改动接口
+
+| 接口 | 变更 |
+|------|------|
+| `POST /api/activity/create` | **放开普通用户**(parent 可创建,`status=draft`,记录 `creatorId`);admin/activity_admin 行为不变 |
+| `POST /api/activity/update` | 同上放开;仅创建人/admin 可改 |
+| `POST /api/activity/list` | 返回 `displayStatus` / `displayLabel` / `registrationStatus`(按当前 userId);过滤 `ended`;惰性处理 7 天自动结束 |
+| `POST /api/activity/detail` | 返回 `displayStatus` / `displayLabel` / `registrationStatus` / 4 个费用字段 / `registrationDeadline` / 资料列表 `materials` |
+| `POST /api/activity/register` | 校验 `registration_deadline`;手机号可选(无手机号孩子允许报名);付费活动必须先有已支付订单(校验 `activity_orders`) |
+| `POST /api/activity/checkin` | 时间窗校验(仅活动进行中);身份校验:有手机号孩子=本人签,无手机号孩子=家长代签(家庭关系 + 该孩子 approved 报名) |
+| `POST /api/activity/end` | 保持(手动结束),admin/activity_admin/**创建人**可结束 |
+| `POST /api/activity/submit-review` | 普通用户提交审核:`draft → pending`(仅创建人/admin 可提交自己的活动)——复用 `ActivityAdminService.submitForReview` 能力,需加 creatorId 校验 |
+
+### 5.2 新接口
+
+| 接口 | 说明 |
+|------|------|
+| `POST /api/activity/order/create` | 创建活动报名订单:入参 `{activityId, registrants:[{name, phone, childId}]}`;校验截止时间/名额/重复报名;按下单人会员等级(`MembershipService.getMemberLevel`,FAMILY/PROVIDER=会员)选择费用;总额=Σ(人数×单价);0 元直接走报名成功不建订单 |
+| `POST /api/activity/order/pay` | 调 `PaymentService.createWechatPrepay` 返回 `wechatPayParams` |
+| `POST /api/activity/order/handlePaymentSuccess` | 前端支付成功回调:幂等置 `paid` + 写报名记录(仿照 `ProductOrderController.handlePaymentSuccess`) |
+| `POST /api/activity/order/notify` | 微信支付异步通知:复用 `PaymentService.handleWechatNotify`,**加入 JwtInterceptor 公开白名单** |
+| `POST /api/activity/order/status` | 查询订单状态(支付页轮询/回调兜底) |
+| `POST /api/activity/material/upload` | 上传资料(仅发布人,总结中阶段) |
+| `POST /api/activity/material/list` | 资料列表(按 activityId,倒序) |
+| `POST /api/activity/material/delete` | 删除资料(仅发布人) |
+
+- 费用计算复用现有会员判定:`ActivityDTO.from(activity, isGuest, memberLevel)` 已有会员价格解析先例,新逻辑按下单人 `memberLevel` 取对应 4 个字段
+- 回调幂等:`order_no` 唯一索引,`status=paid` 直接返回成功
+
+## 6. 小程序前端改动
+
+### 6.1 `activity-detail.vue`(核心改造)
+
+- **弹窗改造**:报名 / 签到惩罚 / 评价三个 `<modal>` → 自定义 view 遮罩弹窗(去掉内置「确定/取消」按钮,保留插槽内自定义操作按钮);报名弹窗底部「确认报名」按钮缩小
+- **报名截止**:`报名截止: MM-DD HH:mm`(用 `registrationDeadline`,不再误用 `endTime`)
+- **付费流程**:点击报名 → 若费用合计 > 0 → `order/create` → 支付页/`requestPayment` → 成功回调 → 刷新为已报名;合计 = 0 → 直接 `register` 成功
+- **费用展示**:孩子价 / 家长价(区分会员与非会员),当前用户为会员时展示会员价(含「会员免费」)
+- **手机号可选**:报名表单手机号非必填;无手机号孩子可报名
+- **总结中**:`displayStatus=summarizing` 时展示资料区(发布人可上传/删除,参与者可查看)+ 评价入口(已签到者)
+- **签到按钮**:仅 `ongoing` 显示;签到后显示已签到
+
+### 6.2 `index.vue`
+
+- 卡片状态改用后端 `displayLabel`(删除硬编码「进行中」)
+- 管理 tab:「发布」按钮 → 普通用户调 `submit-review`(提交审核),admin/activity_admin 保留直发
+- 列表自动过滤已结束活动
+
+### 6.3 `create.vue`
+
+- 新增字段:报名截止时间、孩子非会员价、孩子会员价、家长非会员价、家长会员价(单位:元,前端转分)
+- 发布人角色放开(普通用户可进入创建页)
+
+## 7. Web 管理端改动
+
+### 7.1 `ActivityEdit.vue`
+
+- 新增:报名截止时间 + 4 个费用字段(孩子费/孩子会员费/家长费/家长会员费,单位元)
+- 价格展示不再使用 `price` / `memberPrice`
+
+### 7.2 审核流(已存在,无需大改)
+
+- `ActivityReview.vue` 已支持审核(approved/rejected+原因);普通用户提交审核后进入待审核列表
+
+## 8. 验收标准
+
+- [ ] 卡片显示真实状态(报名中/已报名/进行中/已签到/总结中),不再硬编码
+- [ ] 报名弹窗无重复取消/确认按钮,报名按钮缩小
+- [ ] 超过报名截止时间无法报名;签到仅活动进行中可操作
+- [ ] 付费活动:未支付无法报名;支付成功后自动报名成功;免费活动直接报名
+- [ ] 有手机号孩子不能家长代签;无手机号孩子家长可代签(需家庭关系+报名记录)
+- [ ] 会员看到会员价(可免费),非会员看到非会员价
+- [ ] 活动结束进入「总结中」:发布人可传资料(图片/PDF/链接多条)、参与者可点评;手动结束或 7 天自动结束
+- [ ] 普通用户可创建活动并提交审核,管理员审核通过后正式发布
+- [ ] `mvn clean compile` 通过;迁移幂等可重复执行