Sfoglia il codice sorgente

feat(activity): 活动中心增强——管理员活动管理+已报名状态标记+报名截止时间+未报名签到扣'行'维度能量

E2E Test Bot 1 mese fa
parent
commit
c903ac9b1a

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

@@ -7273,5 +7273,13 @@ private void runMigration100() {
 		} catch (Exception e) {
 			log.warn("添加package_orders联系人字段失败: {}", e.getMessage());
 		}
+
+		// 迁移120: activities表添加registration_deadline列(活动中心-报名截止时间展示)
+		try {
+			ensureColumn("activities", "registration_deadline", "DATETIME COMMENT '报名截止时间'");
+			log.info("已完成activities.registration_deadline字段添加");
+		} catch (Exception e) {
+			log.warn("添加activities.registration_deadline字段失败: {}", e.getMessage());
+		}
 	}
 }

+ 8 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java

@@ -17,11 +17,15 @@ public class ActivityController {
     @Resource private ActivityRegistrationService registrationService;
 
     @PostMapping("/list")
-    public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId) {
+    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) {
         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 size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
-        return activityService.list(dimensionCode, page, size, userId);
+        return activityService.list(dimensionCode, status, mine, page, size, userId, role);
     }
 
     @PostMapping("/detail")
@@ -105,9 +109,10 @@ public class ActivityController {
     @PostMapping("/my-registrations")
     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;
+        String status = (String) params.get("status");
         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;
-        return registrationService.getMyRegistrations(userId, childId, page, size);
+        return registrationService.getMyRegistrations(userId, childId, page, size, status);
     }
 
     @PostMapping("/checkin")

+ 26 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminActivityRegistrationController.java

@@ -12,7 +12,10 @@ public class AdminActivityRegistrationController {
     @Resource private ActivityRegistrationService registrationService;
 
     @PostMapping("/registration/list")
-    public Result<?> list(@RequestBody Map<String, Object> params) {
+    public Result<?> list(@RequestBody Map<String, Object> params, @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"activity_admin".equals(role)) {
+            return Result.error("无权限:仅活动管理员可查看报名");
+        }
         Long activityId = params.get("activityId") != null ? Long.valueOf(params.get("activityId").toString()) : null;
         String status = (String) params.get("status");
         if (activityId == null) return Result.error("活动ID不能为空");
@@ -20,16 +23,34 @@ public class AdminActivityRegistrationController {
     }
 
     @PostMapping("/registration/approve")
-    public Result<Void> approve(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId) {
-        Long registrationId = params.get("registrationId") != null ? Long.valueOf(params.get("registrationId").toString()) : null;
+    public Result<Void> approve(@RequestBody Map<String, Object> params,
+                                @RequestAttribute("role") String role,
+                                @RequestAttribute(value = "userId", required = false) Long userId) {
+        if (!"admin".equals(role) && !"activity_admin".equals(role)) {
+            return Result.error("无权限:仅活动管理员可审核报名");
+        }
+        Long registrationId = readRegistrationId(params);
         if (registrationId == null) return Result.error("报名ID不能为空");
         return registrationService.approve(registrationId, userId);
     }
 
     @PostMapping("/registration/reject")
-    public Result<Void> reject(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId) {
-        Long registrationId = params.get("registrationId") != null ? Long.valueOf(params.get("registrationId").toString()) : null;
+    public Result<Void> reject(@RequestBody Map<String, Object> params,
+                               @RequestAttribute("role") String role,
+                               @RequestAttribute(value = "userId", required = false) Long userId) {
+        if (!"admin".equals(role) && !"activity_admin".equals(role)) {
+            return Result.error("无权限:仅活动管理员可审核报名");
+        }
+        Long registrationId = readRegistrationId(params);
         if (registrationId == null) return Result.error("报名ID不能为空");
         return registrationService.reject(registrationId, userId);
     }
+
+    /** 兼容 registrationId 与 id 两种入参 */
+    private Long readRegistrationId(Map<String, Object> params) {
+        Object regId = params.get("registrationId");
+        if (regId != null) return Long.valueOf(regId.toString());
+        Object id = params.get("id");
+        return id != null ? Long.valueOf(id.toString()) : null;
+    }
 }

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

@@ -3,6 +3,8 @@ package com.etotem.cfc.dto;
 import com.etotem.cfc.entity.Activity;
 import lombok.Data;
 
+import java.text.SimpleDateFormat;
+import java.util.Date;
 
 
 @Data
@@ -18,6 +20,14 @@ public class ActivityDTO {
     private String status;
     private String startTime;
     private String endTime;
+    private String registrationDeadline;
+
+    /** 当前用户是否已报名 */
+    private Boolean registered;
+
+    /** 当前用户的报名状态: pending/approved/rejected/cancelled */
+    private String registrationStatus;
+
     private String location;
     private Integer maxParticipants;
     private Integer currentParticipants;
@@ -55,8 +65,9 @@ public class ActivityDTO {
         dto.setDimensionWeights(activity.getDimensionWeights());
         dto.setActivityType(activity.getActivityType());
         dto.setStatus(activity.getStatus());
-        dto.setStartTime(activity.getStartTime() != null ? activity.getStartTime().toString() : null);
-        dto.setEndTime(activity.getEndTime() != null ? activity.getEndTime().toString() : null);
+        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.setLocation(activity.getLocation());
         dto.setMaxParticipants(activity.getMaxParticipants());
         dto.setCurrentParticipants(activity.getCurrentParticipants());
@@ -91,4 +102,9 @@ public class ActivityDTO {
         }
         return basePrice;
     }
+
+    /** 格式化日期为 "yyyy-MM-dd HH:mm",每次调用新建 SimpleDateFormat(非线程共享) */
+    private static String fmt(Date d) {
+        return new SimpleDateFormat("yyyy-MM-dd HH:mm").format(d);
+    }
 }

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

@@ -46,6 +46,9 @@ public class Activity implements Serializable {
 
     private Date endTime;
 
+    /** 报名截止时间 */
+    private Date registrationDeadline;
+
     private String location;
 
     private Integer maxParticipants;

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

@@ -11,8 +11,11 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
 import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -110,15 +113,19 @@ public class ActivityRegistrationService {
         return Result.success(null);
     }
 
-    public Result<Map<String, Object>> getMyRegistrations(Long userId, Long childId, Integer page, Integer size) {
+    public Result<Map<String, Object>> getMyRegistrations(Long userId, Long childId, Integer page, Integer size, String status) {
         if (page == null || page < 1) page = 1;
         if (size == null || size < 1) size = 10;
         LambdaQueryWrapper<ActivityRegistration> wrapper = new LambdaQueryWrapper<>();
-        if (userId != null) wrapper.eq(ActivityRegistration::getUserId, userId);
         if (childId != null) {
-            // 手动录入的报名 child_id 为 NULL,也属于该用户,需一并返回
+            // 按孩子查询,同时包含手动录入的报名(child_id 为 NULL 且属于该用户)
             wrapper.and(w -> w.eq(ActivityRegistration::getChildId, childId)
-                .or().isNull(ActivityRegistration::getChildId));
+                .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);
         }
         wrapper.orderByDesc(ActivityRegistration::getRegisteredAt);
         Page<ActivityRegistration> pageResult = registrationMapper.selectPage(new Page<>(page, size), wrapper);
@@ -128,11 +135,20 @@ public class ActivityRegistrationService {
             item.put("childId", reg.getChildId()); item.put("status", reg.getStatus());
             item.put("registrantName", reg.getRegistrantName());
             item.put("registrantPhone", reg.getRegistrantPhone());
-            item.put("registeredAt", reg.getRegisteredAt());
+            item.put("registeredAt", fmtLocal(reg.getRegisteredAt()));
             Activity activity = activityMapper.selectById(reg.getActivityId());
             if (activity != null) {
                 item.put("activityTitle", activity.getTitle());
-                item.put("activityStartTime", activity.getStartTime());
+                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());
             }
             return item;
         }).collect(Collectors.toList());
@@ -142,6 +158,32 @@ public class ActivityRegistrationService {
         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) {
         LambdaQueryWrapper<ActivityRegistration> wrapper = new LambdaQueryWrapper<ActivityRegistration>()
             .eq(ActivityRegistration::getActivityId, activityId).orderByDesc(ActivityRegistration::getRegisteredAt);

+ 39 - 20
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java

@@ -50,32 +50,50 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
     @Resource
     private MembershipService membershipService;
 
-    public Result<Map<String, Object>> list(String dimensionCode, Integer page, Integer size, Long userId) {
-        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));
+    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));
+            }
         }
         Page<Activity> pageResult = this.page(new Page<>(page, size), query);
-        boolean isGuest = (userId == null);
         String memberLevel = null;
         if (!isGuest) {
             try { memberLevel = membershipService.getMemberLevel(userId); } catch (Exception e) { /* ignore */ }
         }
         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 -> {
+            ActivityDTO dto = ActivityDTO.from(a, isGuest, level);
+            String regStatus = regStatusMap.get(a.getId());
+            dto.setRegistered(regStatus != null);
+            dto.setRegistrationStatus(regStatus);
+            return dto;
+        }).collect(Collectors.toList());
         Map<String, Object> data = new HashMap<>();
-        data.put("records", pageResult.getRecords().stream().map(a -> ActivityDTO.from(a, isGuest, level)).collect(Collectors.toList()));
+        data.put("records", records);
         data.put("total", pageResult.getTotal());
         data.put("page", pageResult.getCurrent());
         data.put("size", pageResult.getSize());
@@ -172,16 +190,17 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
                 Integer penaltyAmount = activity.getEnergyPenaltyUnregistered();
                 if (penaltyAmount != null && penaltyAmount > 0) {
                     try {
+                        // 未报名签到固定扣"行"维度(action)能量,与活动所属维度无关
                         int deducted = energyService.deductEnergyByCode(
                             childId,
-                            activity.getDimensionCode(),
+                            "action",
                             penaltyAmount,
                             "未报名签到活动《" + activity.getTitle() + "》"
                         );
                         if (deducted >= 0) {
                             result.put("energyPenalty", penaltyAmount);
                             log.info("未报名签到能量扣减: childId={}, activityId={}, penalty={}, dimension={}",
-                                childId, activityId, penaltyAmount, activity.getDimensionCode());
+                                childId, activityId, penaltyAmount, "action");
                         } else {
                             result.put("energyPenaltyFailed", "余额不足");
                             log.warn("未报名签到能量扣减失败(余额不足): childId={}, activityId={}",

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

@@ -1740,6 +1740,7 @@ CREATE TABLE IF NOT EXISTS activities (
     status           VARCHAR(20) DEFAULT 'draft' COMMENT '状态: draft/published/ended',
     start_time       DATETIME COMMENT '开始时间',
     end_time         DATETIME COMMENT '结束时间',
+    registration_deadline DATETIME COMMENT '报名截止时间',
     location         VARCHAR(200) COMMENT '活动地点',
     max_participants INT DEFAULT 0 COMMENT '最大参与人数',
     current_participants INT DEFAULT 0 COMMENT '当前参与人数',

+ 6 - 0
cfc-frontend/pages.json

@@ -1094,6 +1094,12 @@
           "style": {
             "navigationBarTitleText": "创建活动"
           }
+        },
+        {
+          "path": "registrations",
+          "style": {
+            "navigationBarTitleText": "报名管理"
+          }
         }
       ]
     },

+ 16 - 16
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -38,9 +38,9 @@
             <text class="meta-icon">🕐</text>
             <text class="meta-text">{{ formatDate(activity.startTime) }}{{ activity.endTime ? ' ~ ' + formatDate(activity.endTime) : '' }}</text>
           </view>
-          <view class="meta-item" v-if="activity.endTime && activity.requireRegistration === 1">
+          <view class="meta-item" v-if="activity.requireRegistration === 1 && (activity.registrationDeadline || activity.endTime)">
             <text class="meta-icon">⏰</text>
-            <text class="meta-text">报名截止: {{ formatDate(activity.endTime) }}</text>
+            <text class="meta-text">报名截止: {{ formatDate(activity.registrationDeadline || activity.endTime) }}</text>
           </view>
           <view class="meta-item" v-if="activity.location">
             <text class="meta-icon">📍</text>
@@ -263,6 +263,7 @@ export default {
       checkinDone: false,
       checkinPoints: 0,
       registrationStatus: null,
+      registrationChildId: null,
       registrationLoading: false,
       showPenaltyModal: false,
       penaltyAmount: 0,
@@ -324,20 +325,19 @@ export default {
           self.loadFeedbackStatus()
           // Load registration status if logged in and activity requires registration
           if (self.activity.requireRegistration === 1) {
-            var childId = uni.getStorageSync('currentChildId')
-            if (childId) {
-              getMyActivityRegistrations({ childId: childId, 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
-                      break
-                    }
+            // 不依赖全局 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() {})
-            }
+              }
+            }).catch(function() {})
           }
         } else {
           self.error = true
@@ -357,7 +357,7 @@ export default {
       var self = this
       if (self.checkinLoading || self.checkinDone) return
 
-      var childId = uni.getStorageSync('currentChildId')
+      var childId = uni.getStorageSync('currentChildId') || self.registrationChildId
       if (!childId) {
         uni.showToast({ title: '请选择孩子身份', icon: 'none' })
         return
@@ -414,7 +414,7 @@ export default {
         content: '确定要取消报名吗?',
         success: function(res) {
           if (res.confirm) {
-            var childId = uni.getStorageSync('currentChildId')
+            var childId = uni.getStorageSync('currentChildId') || self.registrationChildId
             self.registrationLoading = true
             cancelActivityRegistration({ id: self.activity.id, childId: childId }).then(function(res) {
               self.registrationLoading = false

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

@@ -47,6 +47,17 @@
         </picker>
       </view>
 
+      <!-- 报名截止时间 -->
+      <view class="form-item">
+        <text class="form-label">报名截止时间</text>
+        <picker class="form-picker" mode="date" :value="deadlineDatePart" @change="onDeadlineDateChange">
+          <text class="picker-text">{{ deadlineDatePart || '选择日期(可选)' }}</text>
+        </picker>
+        <picker class="form-picker" mode="time" :value="deadlineTimePart" @change="onDeadlineTimeChange" style="margin-top: 16rpx;">
+          <text class="picker-text">{{ deadlineTimePart || '选择时间(可选)' }}</text>
+        </picker>
+      </view>
+
       <!-- 活动地点 -->
       <view class="form-item">
         <text class="form-label">活动地点</text>
@@ -121,6 +132,7 @@ export default {
         status: 'draft',
         startTime: null,
         endTime: null,
+        registrationDeadline: null,
         location: '',
         maxParticipants: 0,
         price: 0,
@@ -136,6 +148,8 @@ export default {
       timePart: '',
       endDatePart: '',
       endTimePart: '',
+      deadlineDatePart: '',
+      deadlineTimePart: '',
       dimIdx: 0,
       typeIdx: 0,
       dimensionOptions: [
@@ -195,6 +209,12 @@ export default {
             self.endTimePart = self.fmtTime(ed)
             self.form.endTime = ed.toISOString()
           }
+          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()
+          }
           // sync pickers
           for (var i = 0; i < self.dimensionOptions.length; i++) {
             if (self.dimensionOptions[i].key === self.form.dimensionCode) self.dimIdx = i
@@ -229,6 +249,14 @@ export default {
       this.endTimePart = e.detail.value
       this.syncEndTime()
     },
+    onDeadlineDateChange(e) {
+      this.deadlineDatePart = e.detail.value
+      this.syncDeadline()
+    },
+    onDeadlineTimeChange(e) {
+      this.deadlineTimePart = e.detail.value
+      this.syncDeadline()
+    },
     syncStartTime() {
       if (this.datePart && this.timePart) {
         this.form.startTime = this.datePart + 'T' + this.timePart + ':00'
@@ -241,6 +269,13 @@ export default {
         this.form.endTime = null
       }
     },
+    syncDeadline() {
+      if (this.deadlineDatePart && this.deadlineTimePart) {
+        this.form.registrationDeadline = this.deadlineDatePart + 'T' + this.deadlineTimePart + ':00'
+      } else {
+        this.form.registrationDeadline = null
+      }
+    },
     chooseImage() {
       var self = this
       uni.chooseImage({

+ 149 - 75
cfc-frontend/pages/activity/index.vue

@@ -8,12 +8,12 @@
       </view>
     </view>
 
-    <!-- Tab bar (4 tabs) -->
+    <!-- Tab bar (role-aware tabs) -->
     <view class="tab-bar">
       <view
         class="tab-item"
         :class="{ active: currentTab === tab.key }"
-        v-for="tab in tabs"
+        v-for="tab in visibleTabs"
         :key="tab.key"
         @click="onTabChange(tab.key)"
       >
@@ -63,7 +63,9 @@
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
               <text class="card-meta" v-if="act.registrantName">报名人:{{ act.registrantName }}</text>
-              <text class="card-meta">{{ act.startTime }}</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" v-if="act.location">📍 {{ act.location }}</text>
               <view class="card-bottom">
                 <text class="card-status" :class="'status-' + (act._cls || 'upcoming')">
@@ -135,8 +137,13 @@
               mode="aspectFill"
             />
             <view class="card-info">
-              <text class="card-title">{{ act.title }}</text>
-              <text class="card-meta">{{ act.startTime }}</text>
+              <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-meta" v-if="act.location">📍 {{ act.location }}</text>
               <view class="card-bottom">
                 <text class="card-status status-published">进行中</text>
@@ -166,23 +173,8 @@
         </view>
       </view>
 
-      <!-- Tab 3: history -->
+      <!-- Tab 3: history (admin only — ended activities management) -->
       <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 -->
         <view class="card-list" v-if="historyActivities.length > 0">
           <view
@@ -198,12 +190,12 @@
             />
             <view class="card-info">
               <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">开始时间:{{ 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>
               <view class="card-bottom">
                 <text class="card-status status-ended">已结束</text>
-                <text class="card-price" v-if="act.price > 0">¥{{ formatPrice(act.price) }}</text>
-                <text class="card-price free" v-else>免费</text>
+                <text class="manage-action-small" @click.stop="goRegistrations(act)">报名管理</text>
               </view>
             </view>
           </view>
@@ -212,8 +204,8 @@
         <!-- Empty state for history -->
         <view class="empty-state" v-else-if="!historyLoading">
           <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>
 
         <!-- Loading state -->
@@ -276,7 +268,7 @@
             />
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
-              <text class="card-meta">{{ act.startTime }}</text>
+              <text class="card-meta">开始时间:{{ formatDateTime(act.startTime) }}</text>
               <view class="card-bottom">
                 <text class="card-status" :class="'status-' + (act.status || 'draft')">
                   {{ statusLabel(act.status) }}
@@ -284,6 +276,7 @@
                 <view class="manage-action-group">
                   <text class="manage-action-small" v-if="act.status === 'draft'" @click.stop="doPublish(act.id)">发布</text>
                   <text class="manage-action-small" v-if="act.status === 'published'" @click.stop="doEnd(act.id)">结束</text>
+                  <text class="manage-action-small" v-if="act.status === 'published'" @click.stop="goRegistrations(act)">报名管理</text>
                 </view>
               </view>
             </view>
@@ -304,29 +297,35 @@
 </template>
 
 <script>
-import { getActivityList, getMyActivityRegistrations, activityCheckin, cancelActivityRegistration, adminListMyActivities } from '@/utils/api.js'
+import { getActivityList, getMyActivityRegistrations, activityCheckin, cancelActivityRegistration, adminListMyActivities, adminPublishActivity, adminEndActivity } from '@/utils/api.js'
 
 export default {
   computed: {
     isAdmin: function() {
       var userInfo = uni.getStorageSync('userInfo')
       return userInfo && (userInfo.role === 'admin' || userInfo.role === 'activity_admin' || (userInfo.roles && userInfo.roles.indexOf('activity_admin') >= 0))
+    },
+    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() {
     return {
-      tabs: [
-        { key: 'mine', label: '我的活动' },
-        { key: 'discover', label: '发现活动' },
-        { key: 'history', label: '历史记录' },
-        { key: 'manage', label: '管理' }
-      ],
       currentTab: 'mine',
       mineFilters: [
         { label: '全部', value: '' },
         { label: '待开始', value: 'upcoming' },
         { label: '待审核', value: 'pending' },
-        { label: '已签到', value: 'checked_in' }
+        { label: '已通过', value: 'approved' },
+        { label: '已拒绝', value: 'rejected' }
       ],
       dimensionFilters: [
         { label: '全部', value: '' },
@@ -358,7 +357,12 @@ export default {
   },
   onLoad: function(options) {
     if (options && options.tab) {
-      this.currentTab = options.tab
+      // 非管理员不能停留在 历史记录/管理 tab
+      if ((options.tab === 'history' || options.tab === 'manage') && !this.isAdmin) {
+        this.currentTab = 'mine'
+      } else {
+        this.currentTab = options.tab
+      }
       this.loadData()
     }
   },
@@ -371,6 +375,10 @@ export default {
   },
   methods: {
     onTabChange: function(key) {
+      // 非管理员不能进入 历史记录/管理 tab
+      if ((key === 'history' || key === 'manage') && !this.isAdmin) {
+        return
+      }
       this.currentTab = key
       this.hasMore = true
       if (key === 'mine') {
@@ -399,7 +407,7 @@ export default {
     loadManageData: function() {
       var self = this
       this.manageLoading = true
-      adminListMyActivities({ page: 1, size: 100 }).then(function(res) {
+      adminListMyActivities({ page: 1, size: 100, mine: true }).then(function(res) {
         self.manageLoading = false
         if (res && res.code === 200 && res.data) {
           var records = res.data.records || []
@@ -448,11 +456,19 @@ export default {
         success: function(res) {
           if (res.confirm) {
             uni.showLoading({ title: '发布中...' })
-            setTimeout(function() {
+            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: 'success' })
+              uni.showToast({ title: '网络异常', icon: 'none' })
               self.loadManageData()
-            }, 500)
+            })
           }
         }
       })
@@ -465,11 +481,19 @@ export default {
         success: function(res) {
           if (res.confirm) {
             uni.showLoading({ title: '操作中...' })
-            setTimeout(function() {
+            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() {
               uni.hideLoading()
-              uni.showToast({ title: '操作成功', icon: 'success' })
+              uni.showToast({ title: '网络异常', icon: 'none' })
               self.loadManageData()
-            }, 500)
+            })
           }
         }
       })
@@ -532,15 +556,14 @@ export default {
       }
       this.mineLoading = true
       var childId = uni.getStorageSync('currentChildId')
-      if (!childId) {
-        this.mineLoading = false
-        return
-      }
       var params = {
-        childId: childId,
         page: this.minePage,
         size: this.pageSize
       }
+      if (childId) {
+        params.childId = childId
+      }
+      // 仅将后端可识别的状态传给接口;'upcoming' 为前端客户端过滤
       if (this.currentMineFilter && this.currentMineFilter !== 'upcoming') {
         params.status = this.currentMineFilter
       }
@@ -553,8 +576,8 @@ export default {
             var filtered = []
             for (var i = 0; i < records.length; i++) {
               var act = records[i]
-              if (act.startTime) {
-                var startTime = new Date(act.startTime.replace(/-/g, '/'))
+              if (act.activityStartTime) {
+                var startTime = new Date(act.activityStartTime.replace(/-/g, '/'))
                 if (startTime > now && act.status === 'approved') {
                   filtered.push(act)
                 }
@@ -563,6 +586,7 @@ export default {
             records = filtered
           }
           for (var di = 0; di < records.length; di++) {
+            self.normalizeRegistration(records[di])
             self.decorateActivity(records[di])
           }
           if (refresh) {
@@ -612,37 +636,20 @@ export default {
         this.historyActivities = []
       }
       this.historyLoading = true
-      var childId = uni.getStorageSync('currentChildId')
-      if (!childId) {
-        this.historyLoading = false
-        return
-      }
+      // 历史记录 tab:活动管理员查看已结束活动(用于管理)
       var params = {
-        childId: childId,
+        status: 'ended',
         page: this.historyPage,
         size: this.pageSize
       }
-      getMyActivityRegistrations(params).then(function(res) {
+      getActivityList(params).then(function(res) {
         self.historyLoading = false
         if (res && res.code === 200 && res.data) {
           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) {
-            self.historyActivities = ended
+            self.historyActivities = records
           } else {
-            self.historyActivities = self.historyActivities.concat(ended)
+            self.historyActivities = self.historyActivities.concat(records)
           }
           self.hasMore = records.length >= self.pageSize
         }
@@ -653,6 +660,9 @@ export default {
     goDetail: function(act) {
       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() {
       this.currentTab = 'discover'
       this.loadData()
@@ -660,6 +670,36 @@ export default {
     formatPrice: function(price) {
       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
+    },
+    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
+    },
     decorateActivity: function(act) {
       if (act.status) {
         act._cls = act.status
@@ -681,7 +721,8 @@ export default {
     },
     onCheckin: function(act) {
       var self = this
-      var childId = uni.getStorageSync('currentChildId')
+      // 优先使用全局当前孩子;为空时回退到报名记录上的 childId(家长自身视角也可签到)
+      var childId = uni.getStorageSync('currentChildId') || act.childId
       if (!childId) {
         uni.showToast({ title: '请先选择孩子身份', icon: 'none' })
         return
@@ -704,7 +745,8 @@ export default {
         content: '确定要取消报名吗?',
         success: function(res) {
           if (res.confirm) {
-            var childId = uni.getStorageSync('currentChildId')
+            // 优先使用全局当前孩子;为空时回退到报名记录上的 childId
+            var childId = uni.getStorageSync('currentChildId') || act.childId
             if (!childId) {
               uni.showToast({ title: '请先选择孩子身份', icon: 'none' })
               return
@@ -854,6 +896,38 @@ export default {
   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 {
   font-size: 24rpx;
   color: #999;

+ 390 - 0
cfc-frontend/pages/activity/registrations.vue

@@ -0,0 +1,390 @@
+<template>
+  <view class="container">
+    <!-- 活动标题 -->
+    <view class="header">
+      <text class="header-title">{{ activityTitle || '报名管理' }}</text>
+    </view>
+
+    <!-- 状态筛选 chips -->
+    <scroll-view class="filter-scroll" scroll-x enable-flex>
+      <view class="filter-chips">
+        <view
+          class="filter-chip"
+          :class="{ active: currentFilter === filter.value }"
+          v-for="filter in statusFilters"
+          :key="filter.value"
+          @click="onFilterChange(filter.value)"
+        >
+          <text class="filter-label">{{ filter.label }}</text>
+        </view>
+      </view>
+    </scroll-view>
+
+    <!-- 报名列表 -->
+    <view class="reg-list" v-if="registrations.length > 0">
+      <view
+        class="reg-card"
+        v-for="row in registrations"
+        :key="row.id"
+      >
+        <view class="reg-info">
+          <view class="reg-name-row">
+            <text class="reg-name">{{ row.registrantName || '未填写姓名' }}</text>
+            <text class="reg-tag" :class="'reg-tag-' + row.status">{{ statusText(row.status) }}</text>
+          </view>
+          <text class="reg-meta" v-if="row.registrantPhone">联系电话:{{ row.registrantPhone }}</text>
+          <text class="reg-meta">报名时间:{{ formatDateTime(row.registeredAt) }}</text>
+        </view>
+        <view class="reg-actions" v-if="row.status === 'pending'">
+          <button class="btn-approve" @click="onApprove(row)">通过</button>
+          <button class="btn-reject" @click="onReject(row)">拒绝</button>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view class="empty-state" v-else-if="!loading">
+      <text class="empty-icon">📋</text>
+      <text class="empty-title">暂无报名记录</text>
+      <text class="empty-desc">当前状态下没有报名申请</text>
+    </view>
+
+    <!-- 加载状态 -->
+    <view class="loading-state" v-if="loading">
+      <view class="loading-spinner"></view>
+      <text class="loading-text">加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { adminActivityRegistrationList, adminApproveRegistration, adminRejectRegistration, getActivityDetail } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      activityId: null,
+      activityTitle: '',
+      currentFilter: 'all',
+      statusFilters: [
+        { label: '全部', value: 'all' },
+        { label: '待审核', value: 'pending' },
+        { label: '已通过', value: 'approved' },
+        { label: '已拒绝', value: 'rejected' }
+      ],
+      registrations: [],
+      loading: false
+    }
+  },
+  onLoad: function(options) {
+    if (options && options.activityId) {
+      this.activityId = options.activityId
+    }
+    if (options && options.title) {
+      this.activityTitle = decodeURIComponent(options.title)
+    }
+    this.loadDetail()
+    this.loadList()
+  },
+  methods: {
+    loadDetail: function() {
+      var self = this
+      if (!this.activityId || this.activityTitle) return
+      getActivityDetail(this.activityId).then(function(res) {
+        if (res && res.code === 200 && res.data) {
+          self.activityTitle = res.data.title || ''
+        }
+      }).catch(function() {})
+    },
+    loadList: function() {
+      var self = this
+      this.loading = true
+      var params = {
+        activityId: this.activityId,
+        page: 1,
+        size: 100
+      }
+      if (this.currentFilter !== 'all') {
+        params.status = this.currentFilter
+      }
+      adminActivityRegistrationList(params).then(function(res) {
+        self.loading = false
+        if (res && res.code === 200 && res.data) {
+          var list = Array.isArray(res.data) ? res.data : (res.data.records || [])
+          self.registrations = list
+        } else {
+          self.registrations = []
+        }
+      }).catch(function() {
+        self.loading = false
+        self.registrations = []
+      })
+    },
+    onFilterChange: function(value) {
+      this.currentFilter = value
+      this.loadList()
+    },
+    onApprove: function(row) {
+      var self = this
+      uni.showModal({
+        title: '提示',
+        content: '确认通过该报名吗?',
+        success: function(res) {
+          if (res.confirm) {
+            adminApproveRegistration({ registrationId: row.id, activityId: self.activityId }).then(function(r) {
+              if (r && r.code === 200) {
+                uni.showToast({ title: '已通过', icon: 'success' })
+                self.loadList()
+              } else {
+                uni.showToast({ title: (r && r.message) || '操作失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.showToast({ title: '网络异常', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    onReject: function(row) {
+      var self = this
+      uni.showModal({
+        title: '提示',
+        content: '确认拒绝该报名吗?',
+        success: function(res) {
+          if (res.confirm) {
+            adminRejectRegistration({ registrationId: row.id, activityId: self.activityId }).then(function(r) {
+              if (r && r.code === 200) {
+                uni.showToast({ title: '已拒绝', icon: 'success' })
+                self.loadList()
+              } else {
+                uni.showToast({ title: (r && r.message) || '操作失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.showToast({ title: '网络异常', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    statusText: function(status) {
+      var map = { pending: '待审核', approved: '已通过', rejected: '已拒绝', cancelled: '已取消' }
+      return map[status] || status || '未知'
+    },
+    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())
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #F5F5F5;
+  display: flex;
+  flex-direction: column;
+}
+
+.header {
+  background: #1A0F00;
+  padding: 20rpx 24rpx;
+  text-align: center;
+}
+
+.header-title {
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: 600;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.filter-scroll {
+  padding: 20rpx 24rpx;
+  background: #fff;
+}
+
+.filter-chips {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+}
+
+.filter-chip {
+  padding: 12rpx 24rpx;
+  border-radius: 16px;
+  background: #fff;
+  border: 1rpx solid #ddd;
+  flex-shrink: 0;
+}
+
+.filter-chip.active {
+  background: #FFF7ED;
+  border-color: #F97316;
+}
+
+.filter-label {
+  font-size: 24rpx;
+  color: #666;
+}
+
+.filter-chip.active .filter-label {
+  color: #F97316;
+  font-weight: 500;
+}
+
+.reg-list {
+  padding: 20rpx 24rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.reg-card {
+  background: #fff;
+  border-radius: 12rpx;
+  padding: 24rpx;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+}
+
+.reg-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.reg-name-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.reg-name {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.reg-tag {
+  margin-left: 16rpx;
+  font-size: 20rpx;
+  padding: 2rpx 12rpx;
+  border-radius: 6rpx;
+}
+
+.reg-tag-pending {
+  background: #FFF7ED;
+  color: #F59E0B;
+}
+
+.reg-tag-approved {
+  background: #F0FDF4;
+  color: #10B981;
+}
+
+.reg-tag-rejected {
+  background: #F5F5F5;
+  color: #9CA3AF;
+}
+
+.reg-tag-cancelled {
+  background: #F5F5F5;
+  color: #9CA3AF;
+}
+
+.reg-meta {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+
+.reg-actions {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+  margin-left: 20rpx;
+  flex-shrink: 0;
+}
+
+.btn-approve {
+  padding: 8rpx 24rpx;
+  font-size: 24rpx;
+  color: #fff;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  border: none;
+  border-radius: 20rpx;
+  line-height: 1.5;
+}
+
+.btn-reject {
+  padding: 8rpx 24rpx;
+  font-size: 24rpx;
+  color: #999;
+  background: #fff;
+  border: 1rpx solid #ddd;
+  border-radius: 20rpx;
+  line-height: 1.5;
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 120rpx 40rpx;
+}
+
+.empty-icon {
+  font-size: 80rpx;
+  margin-bottom: 20rpx;
+}
+
+.empty-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 12rpx;
+}
+
+.empty-desc {
+  font-size: 26rpx;
+  color: #999;
+}
+
+.loading-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 80rpx 40rpx;
+}
+
+.loading-spinner {
+  width: 60rpx;
+  height: 60rpx;
+  border: 4rpx solid #FDE68A;
+  border-top-color: #F97316;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+  margin-bottom: 20rpx;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.loading-text {
+  font-size: 26rpx;
+  color: #999;
+}
+</style>

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

@@ -1775,6 +1775,10 @@ export const adminEndActivity = (id) => request('/api/activity/end', 'POST', { i
 export const adminListMyActivities = (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 getMySubscription = () => request('/api/subscription/status', 'POST')