Bladeren bron

feat(activity): 活动详情页新增报名情况展示

- 后端新增 POST /api/activity/registration-roster(ActivityRegistrationService.getRegistrationRoster):
  实时统计 approvedCount/pendingCount/remaining + 最近20条已通过报名人(姓名 DataMaskUtil 脱敏,不返回手机号),游客可访问
- activity-detail 元信息后插入「报名情况」卡片:统计列 + 进度条(限人数时)+ 脱敏报名人列表,requireRegistration=1 才显示
- 报名成功/取消报名/重新登录后自动刷新
- docs: specs/2026-10-22-activity-registration-roster.md + API_REFERENCE 4.54 + PROJECT-OVERVIEW
Sisyphus 3 weken geleden
bovenliggende
commit
dc4e42f466

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java

@@ -156,6 +156,13 @@ public class ActivityController {
         return registrationService.getMyRegistrations(userId, memberId, page, size);
         return registrationService.getMyRegistrations(userId, memberId, page, size);
     }
     }
 
 
+    @PostMapping("/registration-roster")
+    public Result<Map<String, Object>> registrationRoster(@RequestBody Map<String, Object> params) {
+        Long activityId = ParamUtils.getLong(params.get("activityId"));
+        if (activityId == null) activityId = ParamUtils.getLong(params.get("id"));
+        return registrationService.getRegistrationRoster(activityId);
+    }
+
     @PostMapping("/checkin")
     @PostMapping("/checkin")
     public Result<Map<String, Object>> checkin(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId,
     public Result<Map<String, Object>> checkin(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId,
                                                @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
                                                @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {

+ 47 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java

@@ -2,6 +2,7 @@ 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.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.DataMaskUtil;
 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.ActivityOrder;
@@ -191,6 +192,52 @@ public class ActivityRegistrationService {
         return Result.success(result);
         return Result.success(result);
     }
     }
 
 
+    /**
+     * 活动详情页「报名情况」:面向所有访客(含游客)返回脱敏的报名统计与最近已通过报名人。
+     * - approvedCount 已通过人数、pendingCount 待审核人数、remaining 剩余名额(无人数限制时为 null)
+     * - registrants 最多 20 条已通过报名人(姓名脱敏,按报名时间倒序),不返回手机号
+     */
+    public Result<Map<String, Object>> getRegistrationRoster(Long activityId) {
+        if (activityId == null) return Result.error("活动ID不能为空");
+        Activity activity = activityMapper.selectById(activityId);
+        if (activity == null) return Result.error("活动不存在");
+
+        long approvedCount = registrationMapper.selectCount(new LambdaQueryWrapper<ActivityRegistration>()
+            .eq(ActivityRegistration::getActivityId, activityId)
+            .eq(ActivityRegistration::getStatus, "approved"));
+        long pendingCount = registrationMapper.selectCount(new LambdaQueryWrapper<ActivityRegistration>()
+            .eq(ActivityRegistration::getActivityId, activityId)
+            .eq(ActivityRegistration::getStatus, "pending"));
+
+        List<ActivityRegistration> approvedList = registrationMapper.selectList(new LambdaQueryWrapper<ActivityRegistration>()
+            .eq(ActivityRegistration::getActivityId, activityId)
+            .eq(ActivityRegistration::getStatus, "approved")
+            .orderByDesc(ActivityRegistration::getRegisteredAt)
+            .last("LIMIT 20"));
+
+        List<Map<String, Object>> registrants = approvedList.stream().map(reg -> {
+            Map<String, Object> item = new HashMap<>();
+            item.put("name", DataMaskUtil.maskName(reg.getRegistrantName()));
+            item.put("registeredAt", reg.getRegisteredAt());
+            return item;
+        }).collect(Collectors.toList());
+
+        Integer max = activity.getMaxParticipants();
+        Integer remaining = null;
+        if (max != null && max > 0) {
+            remaining = (int) Math.max(0, max - approvedCount);
+        }
+
+        Map<String, Object> data = new HashMap<>();
+        data.put("activityId", activityId);
+        data.put("maxParticipants", max != null ? max : 0);
+        data.put("approvedCount", approvedCount);
+        data.put("pendingCount", pendingCount);
+        data.put("remaining", remaining);
+        data.put("registrants", registrants);
+        return Result.success(data);
+    }
+
     /**
     /**
      * 管理端报名列表:activityId 为空时返回全部(可按状态过滤),补充活动标题与驳回原因。
      * 管理端报名列表:activityId 为空时返回全部(可按状态过滤),补充活动标题与驳回原因。
      */
      */

+ 178 - 2
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -69,6 +69,41 @@
           </view>
           </view>
         </view>
         </view>
 
 
+        <!-- 报名情况(脱敏统计 + 最近报名人,游客可见) -->
+        <view class="roster-section" v-if="activity.requireRegistration === 1 && roster">
+          <text class="section-title">报名情况</text>
+          <view class="roster-stat-row">
+            <view class="roster-stat">
+              <text class="roster-num">{{ roster.approvedCount || 0 }}</text>
+              <text class="roster-label">人已报名</text>
+            </view>
+            <view class="roster-stat" v-if="roster.maxParticipants > 0">
+              <text class="roster-num" :class="{ 'roster-full': roster.remaining === 0 }">{{ roster.remaining !== null ? roster.remaining : 0 }}</text>
+              <text class="roster-label">剩余名额</text>
+            </view>
+            <view class="roster-stat" v-if="roster.pendingCount > 0">
+              <text class="roster-num roster-pending-num">{{ roster.pendingCount }}</text>
+              <text class="roster-label">待审核</text>
+            </view>
+          </view>
+          <!-- 进度条(限人数时显示) -->
+          <view class="roster-progress-wrap" v-if="roster.maxParticipants > 0">
+            <view class="roster-progress-track">
+              <view class="roster-progress-bar" :style="{ width: rosterPercent + '%' }"></view>
+            </view>
+            <text class="roster-progress-text">{{ rosterPercent }}%</text>
+          </view>
+          <!-- 最近报名人 -->
+          <view class="roster-list" v-if="roster.registrants && roster.registrants.length > 0">
+            <view class="roster-item" v-for="(r, ri) in roster.registrants" :key="ri">
+              <view class="roster-avatar">{{ rosterAvatarText(r.name) }}</view>
+              <text class="roster-name">{{ r.name }}</text>
+              <text class="roster-time">{{ formatRosterTime(r.registeredAt) }}</text>
+            </view>
+          </view>
+          <text class="roster-empty" v-else>还没有人报名,快来抢占第一个名额吧</text>
+        </view>
+
         <!-- 活动简介 -->
         <!-- 活动简介 -->
         <view class="desc-section" v-if="activity.description">
         <view class="desc-section" v-if="activity.description">
           <text class="section-title">活动介绍</text>
           <text class="section-title">活动介绍</text>
@@ -287,7 +322,7 @@
 
 
 <script>
 <script>
 import { BrandColors } from '../../../utils/brand-colors.js'
 import { BrandColors } from '../../../utils/brand-colors.js'
-import { getActivityDetail, activityCheckin, cancelActivityRegistration, createActivityOrder, submitActivityFeedback, getActivityFeedbackList, getMyActivityFeedback, getFamilyMemberList, getShareQrCode, getMyMembership, getPosterTemplates } from '@/utils/api.js'
+import { getActivityDetail, activityCheckin, cancelActivityRegistration, createActivityOrder, submitActivityFeedback, getActivityFeedbackList, getMyActivityFeedback, getFamilyMemberList, getShareQrCode, getMyMembership, getPosterTemplates, getActivityRegistrationRoster } from '@/utils/api.js'
 import config from '@/config.js'
 import config from '@/config.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'
@@ -341,7 +376,9 @@ export default {
       posterInviteText: '',
       posterInviteText: '',
       posterTemplate: 'gradient',
       posterTemplate: 'gradient',
       posterBgColor: '',   // 封面背景色
       posterBgColor: '',   // 封面背景色
-      posterTemplates: []  // Web 管理端定义的海报模板(空则隐藏模板选择行)
+      posterTemplates: [],  // Web 管理端定义的海报模板(空则隐藏模板选择行)
+      // 报名情况(脱敏统计 + 最近报名人)
+      roster: null
     }
     }
   },
   },
   onLoad: function(options) {
   onLoad: function(options) {
@@ -454,6 +491,7 @@ export default {
           self.loadFeedbackStatus()
           self.loadFeedbackStatus()
           // registrationStatus 已由后端按当前用户计算返回
           // registrationStatus 已由后端按当前用户计算返回
           self.registrationStatus = res.data.registrationStatus || null
           self.registrationStatus = res.data.registrationStatus || null
+          self.loadRoster(id)
         } else {
         } else {
           self.error = true
           self.error = true
           self.errorMsg = (res && res.message) || '加载失败'
           self.errorMsg = (res && res.message) || '加载失败'
@@ -468,6 +506,32 @@ export default {
       var map = { draft: '草稿', published: '进行中', ended: '已结束' }
       var map = { draft: '草稿', published: '进行中', ended: '已结束' }
       return map[status] || '未知'
       return map[status] || '未知'
     },
     },
+    loadRoster: function(id) {
+      var self = this
+      getActivityRegistrationRoster(id).then(function(res) {
+        if (res && res.code === 200 && res.data) {
+          self.roster = res.data
+        } else {
+          self.roster = null
+        }
+      }).catch(function() {
+        self.roster = null
+      })
+    },
+    // 报名人首字作为头像占位
+    rosterAvatarText: function(name) {
+      if (!name) return '?'
+      return name.charAt(0)
+    },
+    // 报名时间短格式:MM-DD
+    formatRosterTime: function(t) {
+      if (!t) return ''
+      var d = parseDate(t)
+      if (!d) return ''
+      var mm = d.getMonth() + 1
+      var dd = d.getDate()
+      return (mm < 10 ? '0' + mm : mm) + '-' + (dd < 10 ? '0' + dd : dd)
+    },
     onCheckin: function() {
     onCheckin: function() {
       var self = this
       var self = this
       if (self.checkinLoading || self.checkinDone) return
       if (self.checkinLoading || self.checkinDone) return
@@ -551,6 +615,7 @@ export default {
               self.registrationLoading = false
               self.registrationLoading = false
               if (res && res.code === 200) {
               if (res && res.code === 200) {
                 self.registrationStatus = null
                 self.registrationStatus = null
+                self.loadRoster(self.activity.id)
                 uni.showToast({ title: '已取消报名', icon: 'success' })
                 uni.showToast({ title: '已取消报名', icon: 'success' })
               } else {
               } else {
                 uni.showToast({ title: (res && res.message) || '取消失败', icon: 'none' })
                 uni.showToast({ title: (res && res.message) || '取消失败', icon: 'none' })
@@ -941,6 +1006,15 @@ export default {
         if (fees[i] > 0 && (min === 0 || fees[i] < min)) min = fees[i]
         if (fees[i] > 0 && (min === 0 || fees[i] < min)) min = fees[i]
       }
       }
       return (min / 100).toFixed(2)
       return (min / 100).toFixed(2)
+    },
+    // 报名进度百分比(限人数时计算,封顶 100)
+    rosterPercent: function() {
+      var r = this.roster
+      if (!r || !r.maxParticipants || r.maxParticipants <= 0) return 0
+      var p = Math.round((r.approvedCount || 0) / r.maxParticipants * 100)
+      if (p < 0) p = 0
+      if (p > 100) p = 100
+      return p
     }
     }
   }
   }
 }
 }
@@ -1085,6 +1159,108 @@ export default {
   margin-top: 4rpx;
   margin-top: 4rpx;
 }
 }
 
 
+/* 报名情况 */
+.roster-section {
+  margin-top: 30rpx;
+  background: $app-surface;
+  border-radius: 16rpx;
+  padding: 24rpx;
+}
+.roster-stat-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+.roster-stat {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-right: 48rpx;
+}
+.roster-num {
+  font-size: 40rpx;
+  font-weight: 700;
+  color: $brand-primary;
+  line-height: 1.2;
+}
+.roster-num.roster-full {
+  color: $status-danger;
+}
+.roster-pending-num {
+  color: $facade-text-gray-600;
+}
+.roster-label {
+  font-size: 22rpx;
+  color: $facade-text-muted;
+  margin-top: 4rpx;
+}
+.roster-progress-wrap {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-top: 20rpx;
+}
+.roster-progress-track {
+  flex: 1;
+  height: 14rpx;
+  background: $facade-fill-2;
+  border-radius: 7rpx;
+  overflow: hidden;
+}
+.roster-progress-bar {
+  height: 100%;
+  border-radius: 7rpx;
+  background: linear-gradient(90deg, $brand-primary, $brand-primary-light);
+  transition: width 0.3s ease;
+}
+.roster-progress-text {
+  font-size: 22rpx;
+  color: $facade-text-muted;
+  margin-left: 16rpx;
+  width: 64rpx;
+  text-align: right;
+}
+.roster-list {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  margin-top: 8rpx;
+}
+.roster-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin: 12rpx 24rpx 0 0;
+}
+.roster-avatar {
+  width: 48rpx;
+  height: 48rpx;
+  border-radius: 24rpx;
+  background: linear-gradient(135deg, $brand-primary, $brand-primary-light);
+  color: #ffffff;
+  font-size: 24rpx;
+  font-weight: 600;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 8rpx;
+}
+.roster-name {
+  font-size: 24rpx;
+  color: $facade-text-gray-800;
+}
+.roster-time {
+  font-size: 22rpx;
+  color: $facade-text-muted;
+  margin-left: 8rpx;
+}
+.roster-empty {
+  display: block;
+  margin-top: 16rpx;
+  font-size: 24rpx;
+  color: $facade-text-muted;
+}
+
 /* 活动介绍 */
 /* 活动介绍 */
 .desc-section {
 .desc-section {
   margin-top: 30rpx;
   margin-top: 30rpx;

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

@@ -2311,6 +2311,8 @@ export const activityCheckin = (data) => request('/api/activity/checkin', 'POST'
 export const registerActivity = (data) => request('/api/activity/register', 'POST', data)
 export const registerActivity = (data) => request('/api/activity/register', 'POST', data)
 export const cancelActivityRegistration = (data) => request('/api/activity/cancel-registration', 'POST', data)
 export const cancelActivityRegistration = (data) => request('/api/activity/cancel-registration', 'POST', data)
 export const getMyActivityRegistrations = (data) => request('/api/activity/my-registrations', 'POST', data)
 export const getMyActivityRegistrations = (data) => request('/api/activity/my-registrations', 'POST', data)
+// 活动详情页「报名情况」(脱敏统计 + 最近报名人,游客可访问)
+export const getActivityRegistrationRoster = (activityId) => request('/api/activity/registration-roster', 'POST', { activityId: activityId })
 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)

+ 1 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -393,6 +393,7 @@
 | `2026-10-17-activity-poster-template-design.md` | ✅ 已实现(迁移381 `activity_poster_templates` 表 + 种子3套默认模板;后端4接口 `/api/admin/poster-template/*`(admin/operator 权限)+ `/api/poster-template/list`;cfc-web `PosterTemplateManage.vue` 管理页 + 路由 `poster-template-manage`(perm `activity:poster_template`)+ 活动管理菜单;小程序 `ContentSharePoster` 新增 `templates` prop 动态模板 + `activity-detail` 拉取(失败不阻塞);后端 `mvn clean compile` 通过) | Web 管理端定义活动海报模板 |
 | `2026-10-17-activity-poster-template-design.md` | ✅ 已实现(迁移381 `activity_poster_templates` 表 + 种子3套默认模板;后端4接口 `/api/admin/poster-template/*`(admin/operator 权限)+ `/api/poster-template/list`;cfc-web `PosterTemplateManage.vue` 管理页 + 路由 `poster-template-manage`(perm `activity:poster_template`)+ 活动管理菜单;小程序 `ContentSharePoster` 新增 `templates` prop 动态模板 + `activity-detail` 拉取(失败不阻塞);后端 `mvn clean compile` 通过) | Web 管理端定义活动海报模板 |
 | `2026-10-20-limited-time-special-price-design.md` | 🟢 已实现(迁移389 `special_price_deals` 表 + 迁移390 `product_orders.special_deal_id`;`SpecialPriceService`(computeRunStatus/list/create/update/updateStatus/delete/consumeQuota/releaseQuota/getUserPurchased + Mapper XML 原子扣量/回滚/按活动计数);公开 `POST /api/special-price/info`(JwtInterceptor 白名单)+ admin 6 接口;下单 `create()` 按 `specialDealId` 委托 `createSpecialDealOrder`(时间窗/互斥/每人限购/券开关/原子扣量紧跟 insert),回滚挂 cancel/approveRefund/OrderTimeoutCancelTask 三处 releaseQuota(GREATEST 防负);供应商改价 `reviewUpdate` 拒绝特价单;小程序商品页 scene `sp=` 解析 + 特价区块/倒计时/sold_out 回落 + checkout 一次性消费 storage key 透传特价单 + 特价禁券隐藏优惠券区;cfc-web `SpecialPriceManage.vue` + 路由 + admin.js 6 封装;5 测试类 31 case 全过 + `mvn clean compile`/全量 `mvn test -DskipTests=false` BUILD SUCCESS) | 限时特价(扫码闪购)设计规格 |
 | `2026-10-20-limited-time-special-price-design.md` | 🟢 已实现(迁移389 `special_price_deals` 表 + 迁移390 `product_orders.special_deal_id`;`SpecialPriceService`(computeRunStatus/list/create/update/updateStatus/delete/consumeQuota/releaseQuota/getUserPurchased + Mapper XML 原子扣量/回滚/按活动计数);公开 `POST /api/special-price/info`(JwtInterceptor 白名单)+ admin 6 接口;下单 `create()` 按 `specialDealId` 委托 `createSpecialDealOrder`(时间窗/互斥/每人限购/券开关/原子扣量紧跟 insert),回滚挂 cancel/approveRefund/OrderTimeoutCancelTask 三处 releaseQuota(GREATEST 防负);供应商改价 `reviewUpdate` 拒绝特价单;小程序商品页 scene `sp=` 解析 + 特价区块/倒计时/sold_out 回落 + checkout 一次性消费 storage key 透传特价单 + 特价禁券隐藏优惠券区;cfc-web `SpecialPriceManage.vue` + 路由 + admin.js 6 封装;5 测试类 31 case 全过 + `mvn clean compile`/全量 `mvn test -DskipTests=false` BUILD SUCCESS) | 限时特价(扫码闪购)设计规格 |
 | `2026-10-22-activity-poster-redesign.md` | ✅ 已实现(新建 `components/ActivityPoster.vue` 活动专属海报组件:500×800 画布,品牌渐变头 + 封面图 + 信息卡片(时间/地点/截止/人数/费用)+ 二维码「立即报名」CTA;`activity-detail` 改用该组件替换通用 ContentSharePoster,费用显示与详情页 feeText 口径一致) | 活动专属海报重新设计 |
 | `2026-10-22-activity-poster-redesign.md` | ✅ 已实现(新建 `components/ActivityPoster.vue` 活动专属海报组件:500×800 画布,品牌渐变头 + 封面图 + 信息卡片(时间/地点/截止/人数/费用)+ 二维码「立即报名」CTA;`activity-detail` 改用该组件替换通用 ContentSharePoster,费用显示与详情页 feeText 口径一致) | 活动专属海报重新设计 |
+| `2026-10-22-activity-registration-roster.md` | ✅ 已实现(活动详情页「报名情况」:新增 `POST /api/activity/registration-roster`(`ActivityRegistrationService.getRegistrationRoster`,实时统计 `approvedCount`/`pendingCount`/`remaining` + 最近20条已通过报名人姓名脱敏 `DataMaskUtil.maskName`,不含手机号,游客可访问);`activity-detail.vue` 元信息后插入报名情况卡片(统计列 + 进度条 + 报名人列表,`requireRegistration===1` 才显示),报名/取消/登录后刷新;`utils/api.js` 新增封装;`mvn clean compile` 通过) | 活动详情页报名情况展示 |
 
 
 ### 实施计划(plans/)
 ### 实施计划(plans/)
 
 

+ 33 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1837,3 +1837,36 @@ Web 管理端定义活动海报模板(迁移381 `activity_poster_templates` 
 | `POST /api/admin/special-price/qrcode` | admin | 生成小程序码(body: `{id}`;`scene=id={productId}&sp={dealId}`,落地页 `product-detail`;返回 `data:{qrCodeBase64, scene, note?}`;测试模式 note 提示无法生成真实码) |
 | `POST /api/admin/special-price/qrcode` | admin | 生成小程序码(body: `{id}`;`scene=id={productId}&sp={dealId}`,落地页 `product-detail`;返回 `data:{qrCodeBase64, scene, note?}`;测试模式 note 提示无法生成真实码) |
 
 
 *文档更新:2026-10-20(限时特价/扫码闪购新增)*
 *文档更新:2026-10-20(限时特价/扫码闪购新增)*
+
+### 4.54 活动报名情况(`POST /api/activity/registration-roster`)
+
+> 活动详情页「报名情况」区块数据源。面向所有访客(含游客,`/api/**` 下无需登录),返回脱敏的报名统计与最近已通过报名人,用于社交证明与名额紧迫感展示。姓名经 `DataMaskUtil.maskName` 脱敏(如"张小明"→"张某"),**不返回手机号**。
+
+**请求体:** `{ "activityId": 1 }`(兼容 `id`)
+
+**响应 `data` 字段:**
+
+```json
+{
+  "activityId": 1,
+  "maxParticipants": 50,
+  "approvedCount": 12,
+  "pendingCount": 3,
+  "remaining": 38,
+  "registrants": [
+    { "name": "张某", "registeredAt": "2026-10-22T10:00:00" }
+  ]
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `maxParticipants` | 人数上限(0 = 不限制) |
+| `approvedCount` | 已通过报名人数 |
+| `pendingCount` | 待审核报名人数 |
+| `remaining` | 剩余名额 = `max - approved`(不限制时为 `null`,下限 0) |
+| `registrants` | 最多 20 条已通过报名人(按报名时间倒序,姓名脱敏) |
+
+前端使用:`activity-detail.vue` 加载详情后并行拉取;报名成功/取消报名后重新拉取。区块仅当 `activity.requireRegistration === 1` 时展示,含统计列、进度条(限人数时)与最近报名人列表。
+
+*文档更新:2026-10-22(活动详情页新增报名情况)*

+ 81 - 0
docs/superpowers/specs/2026-10-22-activity-registration-roster.md

@@ -0,0 +1,81 @@
+# 活动详情页「报名情况」
+
+**优先级:** P1
+**日期:** 2026-10-22
+**状态:** ✅ 已实现
+
+## 背景
+
+活动详情页此前仅在元信息里以「`currentParticipants` / `maxParticipants` 人」一行文字展示报名人数,且该字段由活动记录维护,可能与 `activity_registrations` 实际记录不一致。用户希望在活动详情页看到更清晰的**报名情况**:已报名多少人、还剩多少名额、都有谁报了名,用于社交证明与名额紧迫感。
+
+## 用户故事
+
+As a 浏览活动的家长(含未登录游客),I want 在活动详情页看到实时报名人数、剩余名额与最近报名人,so that 我能判断活动热度并尽快决定报名。
+
+## 需求
+
+1. 展示**已报名人数**(以 `activity_registrations` 中 `status=approved` 为准,实时统计)
+2. 限人数活动展示**剩余名额**与**报名进度条**
+3. 展示**待审核人数**(`status=pending`,>0 才显示)
+4. 展示**最近报名人**(最多 20 条,姓名脱敏,按报名时间倒序)
+5. 无人报名时展示「还没有人报名,快来抢占第一个名额吧」引导
+6. 未登录游客同样可见(社交证明公开)
+7. **隐私**:不返回手机号;姓名经 `DataMaskUtil.maskName` 脱敏(如「张小明」→「张某」)
+
+## 技术方案
+
+### 后端
+
+新增 `POST /api/activity/registration-roster`(`ActivityController` → `ActivityRegistrationService.getRegistrationRoster`):
+
+```json
+{ "activityId": 1 }
+```
+
+响应 `data`:
+
+```json
+{
+  "activityId": 1,
+  "maxParticipants": 50,
+  "approvedCount": 12,
+  "pendingCount": 3,
+  "remaining": 38,
+  "registrants": [
+    { "name": "张某", "registeredAt": "2026-10-22T10:00:00" }
+  ]
+}
+```
+
+- `approvedCount` / `pendingCount` 用 `selectCount` 实时统计,避免依赖可能过期的 `activity.currentParticipants`
+- `remaining = max(0, maxParticipants - approvedCount)`;无人数上限(`maxParticipants` 为 null 或 0)时返回 `null`
+- `registrants` 取已通过报名记录,`LIMIT 20`,姓名脱敏
+- 接口在 `/api/**` 下,无 token 时按游客访问(与 `/detail` 一致)
+
+### 前端
+
+- `utils/api.js`:新增 `getActivityRegistrationRoster(activityId)`
+- `pages/activity/activity-detail/activity-detail.vue`:
+  - 加载详情成功后调用 `loadRoster(id)`,与详情并行展示
+  - 模板:元信息卡片之后、活动介绍之前插入「报名情况」卡片(统计列 + 进度条 + 报名人列表),仅 `activity.requireRegistration === 1` 时展示
+  - 报名成功 / 取消报名 / 重新登录后刷新
+  - 报名人头像用姓名首字占位;时间格式 `MM-DD`
+
+## 验收标准
+
+- [x] 活动详情页出现「报名情况」区块(需报名的活动)
+- [x] 已报名人数与 `activity_registrations` 已通过记录数一致
+- [x] 限人数活动显示剩余名额与进度条,满员时剩余名额标红
+- [x] 报名人姓名脱敏,接口响应不含手机号
+- [x] 游客可访问该接口
+- [x] `mvn clean compile` 通过;前端脚本语法校验通过
+
+## 涉及文件
+
+| 文件 | 变更 |
+|------|------|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java` | 新增 `getRegistrationRoster()` |
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java` | 新增 `POST /api/activity/registration-roster` |
+| `cfc-frontend/utils/api.js` | 新增 `getActivityRegistrationRoster` |
+| `cfc-frontend/pages/activity/activity-detail/activity-detail.vue` | 新增报名情况区块 + 数据加载 |
+| `docs/superpowers/api/API_REFERENCE.md` | 新增 4.54 章节 |