Sfoglia il codice sorgente

Merge branch 'cfclub' of https://git.iwintrue.com/liaoxg/cfc into cfclub

zxyj-web 2 settimane fa
parent
commit
d47c45625c

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

@@ -3254,6 +3254,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移404: articles.audio_url 由 varchar(500) 改为 MEDIUMTEXT(多段音频JSON数组可超500字符)
         alterArticleAudioUrlToMediumtext();
+
+        // 迁移406: activity_poster_templates 增加 layout_config 列(海报模板元素位置/样式可配置,需求:活动详情海报预览重设计)
+        addActivityPosterTemplateLayoutConfigColumn();
     }
 
     private void migrateHealthDimensionsTable() {
@@ -13090,4 +13093,17 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移404: articles.audio_url 修改失败: {}", e.getMessage());
         }
     }
+
+    // 迁移406: activity_poster_templates 增加 layout_config 列(活动海报模板元素位置/样式可配置;空值=小程序端内置默认布局)
+    private void addActivityPosterTemplateLayoutConfigColumn() {
+        try {
+            if (!columnExists("activity_poster_templates", "layout_config")) {
+                jdbcTemplate.execute("ALTER TABLE activity_poster_templates "
+                        + "ADD COLUMN layout_config TEXT NULL COMMENT '布局配置JSON(元素位置/样式可配置,空=默认布局)'");
+                log.info("迁移406: 已添加 activity_poster_templates.layout_config 列");
+            }
+        } catch (Exception e) {
+            log.warn("迁移406: 添加 activity_poster_templates.layout_config 列失败: {}", e.getMessage());
+        }
+    }
 }

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java

@@ -153,7 +153,8 @@ public class ActivityController {
         Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
         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, memberId, page, size);
+        String status = params.get("status") != null ? params.get("status").toString() : null;
+        return registrationService.getMyRegistrations(userId, memberId, page, size, status);
     }
 
     @PostMapping("/registration-roster")

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityPosterTemplate.java

@@ -38,6 +38,24 @@ public class ActivityPosterTemplate implements Serializable {
     /** 可选背景色板 JSON 数组,如 ["#F97316","#1E293B"](渐变模板下用户可切换) */
     private String bgColors;
 
+    /**
+     * 布局配置 JSON 对象(迁移406):海报元素的位置与样式可配置,空则用小程序端内置默认布局。
+     * 结构示例:
+     * {
+     *   "cover":     { "show": true, "x": 28, "y": 148, "width": 444, "height": 202, "radius": 16 },
+     *   "title":     { "show": true, "fontSize": 22, "color": "#1C1917", "lines": 2, "barWidth": 4 },
+     *   "meta":      { "show": true, "columns": 3, "labelFontSize": 12, "valueFontSize": 14,
+     *                  "labelColor": "#A8A29E", "valueColor": "#1C1917", "rowGap": 28 },
+     *   "intro":     { "show": true, "fontSize": 13, "color": "#78716C", "lines": 2 },
+     *   "qr":        { "show": true, "padding": 8, "radius": 8 },
+     *   "invite":    { "show": true, "fontSize": 13, "color": "#A8A29E" },
+     *   "footer":    { "show": true, "fontSize": 18 },
+     *   "card":      { "radius": 16, "top": 112, "height": 620 }
+     * }
+     * 画布为 500×800 设计坐标,小程序端按该配置绘制;未提供的字段回退内置默认值。
+     */
+    private String layoutConfig;
+
     /** 二维码尺寸(rpx),活动海报建议 100 */
     private Integer qrSize;
 

+ 15 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityPosterTemplateService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.ActivityPosterTemplate;
@@ -89,6 +90,20 @@ public class ActivityPosterTemplateService {
         } else {
             template.setBgColors("[" + template.getMainColor() + "]");
         }
+        // 布局配置校验:空则置 null(小程序用内置默认布局);必须是 JSON 对象,非法拒绝
+        if (StringUtils.hasText(template.getLayoutConfig())) {
+            String lc = template.getLayoutConfig().trim();
+            try {
+                if (!(JSON.parse(lc) instanceof JSONObject)) {
+                    return Result.error("布局配置必须是 JSON 对象");
+                }
+                template.setLayoutConfig(lc);
+            } catch (Exception e) {
+                return Result.error("布局配置不是合法 JSON");
+            }
+        } else {
+            template.setLayoutConfig(null);
+        }
         if (template.getQrSize() == null || template.getQrSize() <= 0) {
             template.setQrSize(100);
         }

+ 7 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java

@@ -219,7 +219,7 @@ public class ActivityRegistrationService {
                 .setSql("approved_count = approved_count - 1"));
     }
 
-    public Result<Map<String, Object>> getMyRegistrations(Long userId, Long memberId, Integer page, Integer size) {
+    public Result<Map<String, Object>> getMyRegistrations(Long userId, Long memberId, 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<>();
@@ -229,6 +229,12 @@ public class ActivityRegistrationService {
             wrapper.and(w -> w.eq(ActivityRegistration::getChildId, memberId)
                 .or().isNull(ActivityRegistration::getChildId));
         }
+        // 小程序「我的活动」筛选芯片:全部/待开始/待审核(pending)/已签到(checked_in)。
+        // 原实现完全忽略 status,导致「待审核」「已签到」筛选失效(后端返回全部状态)。
+        // status 为 null/空(全部)或 upcoming(前端本地按 startTime 过滤)时不加条件。
+        if (status != null && !status.trim().isEmpty()) {
+            wrapper.eq(ActivityRegistration::getStatus, status.trim());
+        }
         wrapper.orderByDesc(ActivityRegistration::getRegisteredAt);
         SortUtil.applySort(wrapper);
         Page<ActivityRegistration> pageResult = registrationMapper.selectPage(new Page<>(page, size), wrapper);

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

@@ -5417,6 +5417,7 @@ CREATE TABLE IF NOT EXISTS activity_poster_templates (
   qr_size INT DEFAULT 100 COMMENT '二维码尺寸(rpx)',
   sort_order INT DEFAULT 0 COMMENT '排序(越小越前)',
   status TINYINT DEFAULT 1 COMMENT '1启用 0停用',
+  layout_config TEXT DEFAULT NULL COMMENT '布局配置JSON(元素位置/样式可配置,空=默认布局)',
   created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
   updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
   UNIQUE KEY uk_name (name)

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

@@ -105,9 +105,10 @@
         </view>
 
         <!-- 活动简介 -->
-        <view class="desc-section" v-if="activity.description">
+        <view class="desc-section" v-if="activity.intro || activity.description">
           <text class="section-title">活动介绍</text>
-          <mp-html :content="activity.description" :tag-style="contentTagStyle" />
+          <text class="intro-text" v-if="activity.intro">{{ activity.intro }}</text>
+          <mp-html v-if="activity.description" :content="activity.description" :tag-style="contentTagStyle" />
         </view>
 
         <!-- 付费不退费说明 -->
@@ -1681,4 +1682,12 @@ export default {
 }
 .reg-submit:active { opacity: 0.85; }
 .reg-submit[disabled] { opacity: 0.5; }
+/* 活动简介纯文本摘要(intro 字段,接口兼容 description 富文本) */
+.intro-text {
+  display: block;
+  font-size: calc(26rpx * var(--font-scale, 1));
+  color: $facade-text-gray-600;
+  line-height: 1.6;
+  margin-bottom: 16rpx;
+}
 </style>

+ 261 - 124
cfc-frontend/pages/activity/components/ActivityPoster.vue

@@ -65,6 +65,19 @@ const DIMENSION_COLORS = {
   wealth:  { main: '#F59E0B', dark: '#D97706', light: '#FFFBEB' }
 }
 
+/** 海报画布默认布局(500×800 设计坐标)。模板 layoutConfig 未提供的字段全部回退至此。 */
+const DEFAULT_LAYOUT = {
+  cover:   { show: true, x: 28, y: 148, width: 444, height: 202, radius: 12 },
+  card:    { show: true, x: 28, width: 444, top: 112, height: 620, radius: 16, bgLight: '#FFFFFF', topPad: 16 },
+  title:   { show: true, fontSize: 22, color: '#1C1917', barColor: 'auto', barWidth: 4, lines: 2 },
+  meta:    { show: true, columns: 3, gap: 28, labelFontSize: 12, valueFontSize: 14,
+             labelColor: '#A8A29E', valueColor: '#1C1917', rowGap: 28 },
+  intro:   { show: true, fontSize: 13, color: '#78716C', lines: 2 },
+  qr:      { show: true, size: 100, padding: 8, radius: 8 },
+  invite:  { show: true, fontSize: 13, color: '#A8A29E', text: '长按识别二维码 · 立即报名' },
+  footer:  { show: true, fontSize: 18, color: 'auto', text: '浠艾福 · 家庭健康俱乐部' }
+}
+
 export default {
   name: 'ActivityPoster',
   props: {
@@ -185,6 +198,22 @@ export default {
       var tpl = this.resolvedTemplate
       if (tpl && tpl.qrSize && tpl.qrSize > 0) return tpl.qrSize
       return this.qrSizeOverride || 100
+    },
+    /** 解析后的布局配置(模板 layoutConfig > 内置默认布局),元素位置/样式可配置 */
+    layout: function() {
+      var merged = this._deepClone(DEFAULT_LAYOUT)
+      var tpl = this.resolvedTemplate
+      if (tpl && tpl.layoutConfig) {
+        try {
+          var cfg = JSON.parse(tpl.layoutConfig)
+          if (cfg && typeof cfg === 'object') {
+            merged = this._mergeLayout(merged, cfg)
+          }
+        } catch (e) { /* 非法 JSON 使用默认布局 */ }
+      }
+      // 二维码尺寸兼容:模板 qrSize 列/override 优先,其次 layout.qr.size
+      if (this.effectiveQrSize > 0) merged.qr.size = this.effectiveQrSize
+      return merged
     }
   },
   data: function() {
@@ -199,6 +228,26 @@ export default {
       if (scale > 1.25) scale = 1.25
       return Math.round(size * scale)
     },
+    _deepClone: function(obj) {
+      return JSON.parse(JSON.stringify(obj || {}))
+    },
+    /** 浅层递归合并:cfg 命中同名字段时覆盖默认值(保留未配置字段) */
+    _mergeLayout: function(base, cfg) {
+      if (!cfg || typeof cfg !== 'object') return base
+      var out = this._deepClone(base)
+      var key
+      for (key in cfg) {
+        if (!Object.prototype.hasOwnProperty.call(cfg, key)) continue
+        var val = cfg[key]
+        if (out[key] && typeof out[key] === 'object' && !Array.isArray(out[key]) &&
+            val && typeof val === 'object' && !Array.isArray(val)) {
+          out[key] = this._mergeLayout(out[key], val)
+        } else if (val !== null && val !== undefined && val !== '') {
+          out[key] = val
+        }
+      }
+      return out
+    },
     getActiveTemplate: function() {
       if (!this.activeTemplateId) return null
       var list = this.serverTemplates
@@ -263,6 +312,7 @@ export default {
       var w = 500
       var h = 800
       var colors = this.colors
+      var L = this.layout
 
       if (scaleX !== 1 || scaleY !== 1) {
         ctx.scale(scaleX, scaleY)
@@ -287,12 +337,17 @@ export default {
       // 顶部品牌点 + 文字
       this._drawBrandHeader(ctx, w, colors)
 
-      // 封面图卡片
-      var coverX = 28
-      var coverY = 148
-      var coverW = 444
-      var coverH = 202
+      // 封面图卡片(位置/尺寸/圆角来自 layout.cover,模板可配置)
+      var coverX = L.cover.x
+      var coverY = L.cover.y
+      var coverW = L.cover.width
+      var coverH = L.cover.height
       var self = this
+      if (!L.cover.show) {
+        // 模板关闭封面区:信息卡从 cover 起点开始绘制
+        self._drawInfoCard(ctx, coverY, w, colors)
+        return
+      }
       if (this.coverImage) {
         uni.downloadFile({
           url: this.coverImage,
@@ -349,19 +404,31 @@ export default {
         ctx.fill()
       }
       // 品牌文字
-      ctx.setFillStyle('rgba(255,255,255,0.9)')
+      ctx.setFillStyle('rgba(255,255,255,0.92)')
       ctx.setFontSize(this._fs(14))
       ctx.setTextAlign('center')
       ctx.setTextBaseline('top')
       ctx.fillText('浠艾福 · 家庭健康俱乐部', centerX, dotY + 16)
+      // 品牌分隔细线
+      ctx.setStrokeStyle('rgba(255,255,255,0.28)')
+      ctx.setLineWidth(1)
+      ctx.beginPath()
+      ctx.moveTo(96, dotY + 42)
+      ctx.lineTo(w - 96, dotY + 42)
+      ctx.stroke()
+      // 品牌标语
+      ctx.setFillStyle('rgba(255,255,255,0.75)')
+      ctx.setFontSize(this._fs(11))
+      ctx.fillText('给全家的一站式幸福提案', centerX, dotY + 48)
     },
 
     _drawCoverPlaceholder: function(ctx, x, y, w, h, colors) {
+      var L = this.layout
       var g = ctx.createLinearGradient(x, y, x + w, y + h)
       g.addColorStop(0, colors.main)
       g.addColorStop(1, colors.dark)
       ctx.setFillStyle(g)
-      this._roundRect(ctx, x, y, w, h, 12)
+      this._roundRect(ctx, x, y, w, h, L.cover.radius || 12)
       ctx.fill()
       var cx = x + w / 2
       var cy = y + h / 2
@@ -381,138 +448,178 @@ export default {
      */
     _drawInfoCard: function(ctx, infoCardY, w, colors) {
       var self = this
-      var cardX = 28
-      var cardW = 444
-      var rowGap = 28
-      var labelFs = this._fs(12)
-      var valueFs = this._fs(14)
-      var titleFs = this._fs(22)
-      var descFs = this._fs(13)
-      var lineH = valueFs + 6
-
-      // 白色卡片
-      ctx.setFillStyle('#FFFFFF')
-      this._roundRect(ctx, cardX, infoCardY, cardW, 210, 12)
-      ctx.fill()
-
-      var curY = infoCardY + 16
-
-      // 类型 tag(用颜色条代替文字)
-      ctx.setFillStyle(colors.main)
-      ctx.beginPath()
-      ctx.roundRect ? ctx.roundRect(cardX + 10, curY, 4, 18, 2) : ctx.rect(cardX + 10, curY, 4, 18)
+      var L = this.layout
+
+      // 卡片参数
+      var cardX = L.card.x || 28
+      var cardW = L.card.width || 444
+      var cardRadius = L.card.radius || 16
+      var cardBgLight = L.card.bgLight || '#FFFFFF'
+      var cardTopPad = L.card.topPad || 16
+
+      // 字号/颜色(按用户字体缩放)
+      var titleFs = this._fs(L.title.fontSize || 22)
+      var titleColor = L.title.color || '#1C1917'
+      var titleLines = L.title.lines || 2
+      var titleBarColor = L.title.barColor === 'auto' ? colors.main : (L.title.barColor || colors.main)
+      var titleBarWidth = L.title.barWidth || 4
+
+      var meta = L.meta
+      var metaCols = meta.columns || 3
+      var metaGap = meta.gap || 28
+      var labelFs = this._fs(meta.labelFontSize || 12)
+      var valueFs = this._fs(meta.valueFontSize || 14)
+      var labelColor = meta.labelColor || '#A8A29E'
+      var valueColor = meta.valueColor || '#1C1917'
+      var rowGap = meta.rowGap || 28
+      var metaLineH = valueFs + 6
+
+      var introFs = this._fs(L.intro.fontSize || 13)
+      var introColor = L.intro.color || '#78716C'
+      var introLines = L.intro.lines || 2
+      var introLineH = introFs + 4
+
+      // 白色卡片(高度由布局配置,默认与信息量匹配)
+      var cardH = L.card.height || 210
+      ctx.setFillStyle(cardBgLight)
+      this._roundRect(ctx, cardX, infoCardY, cardW, cardH, cardRadius)
       ctx.fill()
-      curY += 28
 
-      // 标题(2行截断)
-      ctx.setFillStyle('#1C1917')
-      ctx.setFontSize(titleFs)
-      ctx.setTextAlign('left')
-      ctx.setTextBaseline('top')
-      this._drawWrappedText(ctx, this.title || '精彩活动', cardX + 22, curY, cardW - 22, titleFs + 4, 2)
-      curY += (titleFs + 4) * 2 + 12
+      var curY = infoCardY + cardTopPad
 
-      // 元信息行:时间 / 地点 / 截止 / 人数 / 费用
-      var rows = []
-      if (this.startTime) {
-        rows.push({ label: '时间', value: this._formatTimeRange() })
-      }
-      if (this.location) {
-        rows.push({ label: '地点', value: this.location.substring(0, 30) })
-      }
-      if (this.registrationDeadline) {
-        rows.push({ label: '截止', value: this._formatDate(this.registrationDeadline) })
-      }
-      if (this.maxParticipants > 0) {
-        rows.push({ label: '人数', value: (this.currentParticipants || 0) + '/' + this.maxParticipants + '人' })
-      }
-      if (this.priceText) {
-        rows.push({ label: '费用', value: this.priceText })
+      // 标题前 accent 竖条
+      if (L.title.show) {
+        ctx.setFillStyle(titleBarColor)
+        ctx.beginPath()
+        var barX = cardX + 10
+        ctx.roundRect ? ctx.roundRect(barX, curY, titleBarWidth, 18, 2) : ctx.rect(barX, curY, titleBarWidth, 18)
+        ctx.fill()
       }
+      curY += 28
 
-      var rowCount = rows.length
-      var innerPad = 22
-      var colW = (cardW - innerPad * 2) / (rowCount <= 2 ? 2 : 3)
-      var x = cardX + innerPad
-      var y = curY
-      var colIndex = 0
-      var i, row
-      for (i = 0; i < rowCount; i++) {
-        row = rows[i]
-        if (colIndex >= (rowCount <= 2 ? 2 : 3)) { colIndex = 0; y += lineH + 4 }
-        ctx.setFillStyle('#A8A29E')
-        ctx.setFontSize(labelFs)
+      // 标题(可配行数/字号/颜色)
+      if (L.title.show) {
+        ctx.setFillStyle(titleColor)
+        ctx.setFontSize(titleFs)
         ctx.setTextAlign('left')
         ctx.setTextBaseline('top')
-        ctx.fillText(row.label, x, y)
-        ctx.setFillStyle('#1C1917')
-        ctx.setFontSize(valueFs)
-        ctx.fillText(row.value, x, y + labelFs + 4)
-        x += colW
-        colIndex++
+        var titleMaxW = cardW - 22
+        this._drawWrappedText(ctx, this.title || '精彩活动', cardX + 22, curY, titleMaxW, titleFs + 4, titleLines)
+        curY += (titleFs + 4) * titleLines + 12
       }
-      curY = y + lineH + 8
 
-      // 简介(2行截断,仅当没有封面图或卡片内空间足够时绘制)
-      var plainIntro = (this.intro || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
-      if (plainIntro) {
-        ctx.setFillStyle('#78716C')
-        ctx.setFontSize(descFs)
-        ctx.setTextAlign('left')
-        ctx.setTextBaseline('top')
-        this._drawWrappedText(ctx, plainIntro, cardX + innerPad, curY, cardW - innerPad * 2, descFs + 4, 2)
-        curY += (descFs + 4) * 2 + 12
+      // 元信息行
+      if (L.meta.show) {
+        var rows = []
+        if (this.startTime) rows.push({ label: '时间', value: this._formatTimeRange() })
+        if (this.location) rows.push({ label: '地点', value: this.location.substring(0, 30) })
+        if (this.registrationDeadline) rows.push({ label: '截止', value: this._formatDate(this.registrationDeadline) })
+        if (this.maxParticipants > 0) rows.push({ label: '人数', value: (this.currentParticipants || 0) + '/' + this.maxParticipants + '人' })
+        if (this.priceText) rows.push({ label: '费用', value: this.priceText })
+
+        var rowCount = rows.length
+        var innerPad = 22
+        var colW = (cardW - innerPad * 2) / (rowCount <= 2 ? Math.min(2, metaCols) : Math.min(3, metaCols))
+        var x = cardX + innerPad
+        var y = curY
+        var colIndex = 0
+        var i, row
+        for (i = 0; i < rowCount; i++) {
+          row = rows[i]
+          if (colIndex >= (rowCount <= 2 ? Math.min(2, metaCols) : Math.min(3, metaCols))) { colIndex = 0; y += metaLineH + 4 }
+          ctx.setFillStyle(labelColor)
+          ctx.setFontSize(labelFs)
+          ctx.setTextAlign('left')
+          ctx.setTextBaseline('top')
+          ctx.fillText(row.label, x, y)
+          ctx.setFillStyle(valueColor)
+          ctx.setFontSize(valueFs)
+          ctx.fillText(row.value, x, y + labelFs + 4)
+          x += colW
+          colIndex++
+        }
+        curY = y + metaLineH + 8
+      }
+
+      // 简介
+      if (L.intro.show) {
+        var plainIntro = (this.intro || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
+        if (plainIntro) {
+          ctx.setFillStyle(introColor)
+          ctx.setFontSize(introFs)
+          ctx.setTextAlign('left')
+          ctx.setTextBaseline('top')
+          this._drawWrappedText(ctx, plainIntro, cardX + 22, curY, cardW - 44, introLineH, introLines)
+          curY += introLineH * introLines + 12
+        }
       }
 
       // 二维码区域(卡片下方)
-      var qrSize = this.effectiveQrSize
+      var qrSize = L.qr.size || this.effectiveQrSize
+      var qrPadding = L.qr.padding || 8
+      var qrRadius = L.qr.radius || 8
       var qrY = curY + 16
       var qrX = (w - qrSize) / 2
 
-      // 邀请语
-      var inviteY = qrY - 22
-      ctx.setFillStyle('#A8A29E')
-      ctx.setFontSize(this._fs(13))
-      ctx.setTextAlign('center')
-      ctx.setTextBaseline('top')
-      ctx.fillText(this.inviteText || '长按识别二维码 · 立即报名', w / 2, inviteY)
-
-      // 二维码
-      if (this.qrCodeBase64) {
-        var base64Data = this.qrCodeBase64
-        if (base64Data.indexOf('data:image') === 0) {
-          var commaIdx = base64Data.indexOf(',')
-          if (commaIdx > -1) base64Data = base64Data.substring(commaIdx + 1)
-        }
-        var fs = uni.getFileSystemManager()
-        var filePath = wx.env.USER_DATA_PATH + '/activity_qr_tmp.png'
-        fs.writeFile({
-          filePath: filePath,
-          data: base64Data,
-          encoding: 'base64',
-          success: function() {
-            ctx.setFillStyle('BrandColors.white')
-            self._roundRect(ctx, qrX - 8, qrY - 8, qrSize + 16, qrSize + 16, 8)
-            ctx.fill()
-            ctx.setStrokeStyle('#E7E5E4')
-            ctx.setLineWidth(0.5)
-            self._roundRect(ctx, qrX - 8, qrY - 8, qrSize + 16, qrSize + 16, 8)
-            ctx.stroke()
-            ctx.drawImage(filePath, qrX, qrY, qrSize, qrSize)
-            self._drawFooter(ctx, w, qrY + qrSize + 16, colors)
-          },
-          fail: function() {
-            self._drawQrPlaceholder(ctx, qrX, qrY, qrSize)
-            self._drawFooter(ctx, w, qrY + qrSize + 16, colors)
+      // 邀请语(二维码上方)
+      if (L.invite.show) {
+        var inviteY = qrY - 22
+        ctx.setFillStyle(L.invite.color || '#A8A29E')
+        ctx.setFontSize(this._fs(L.invite.fontSize || 13))
+        ctx.setTextAlign('center')
+        ctx.setTextBaseline('top')
+        ctx.fillText(L.invite.text || this.inviteText || '长按识别二维码 · 立即报名', w / 2, inviteY)
+      }
+
+      // 二维码(白底圆角卡片 + 绘制)
+      if (L.qr.show) {
+        if (this.qrCodeBase64) {
+          var base64Data = this.qrCodeBase64
+          if (base64Data.indexOf('data:image') === 0) {
+            var commaIdx = base64Data.indexOf(',')
+            if (commaIdx > -1) base64Data = base64Data.substring(commaIdx + 1)
           }
-        })
+          var fs = uni.getFileSystemManager()
+          var filePath = wx.env.USER_DATA_PATH + '/activity_qr_tmp.png'
+          fs.writeFile({
+            filePath: filePath,
+            data: base64Data,
+            encoding: 'base64',
+            success: function() {
+              // 白底圆角卡片
+              ctx.setFillStyle('#FFFFFF')
+              self._roundRect(ctx, qrX - qrPadding, qrY - qrPadding, qrSize + qrPadding * 2, qrSize + qrPadding * 2, qrRadius)
+              ctx.fill()
+              // 淡边框
+              ctx.setStrokeStyle('#E7E5E4')
+              ctx.setLineWidth(0.5)
+              self._roundRect(ctx, qrX - qrPadding, qrY - qrPadding, qrSize + qrPadding * 2, qrSize + qrPadding * 2, qrRadius)
+              ctx.stroke()
+              // 二维码
+              ctx.drawImage(filePath, qrX, qrY, qrSize, qrSize)
+              // CTA 胶囊按钮(二维码下方)
+              self._drawCtaCapsule(ctx, w, qrY + qrSize + qrPadding + 12, colors)
+              // Footer
+              self._drawFooter(ctx, w, qrY + qrSize + qrPadding + 60, colors)
+            },
+            fail: function() {
+              self._drawQrPlaceholder(ctx, qrX, qrY, qrSize, L)
+              self._drawFooter(ctx, w, qrY + qrSize + 16, colors)
+            }
+          })
+        } else {
+          this._drawQrPlaceholder(ctx, qrX, qrY, qrSize, L)
+          this._drawFooter(ctx, w, qrY + qrSize + 16, colors)
+        }
       } else {
-        this._drawQrPlaceholder(ctx, qrX, qrY, qrSize)
-        this._drawFooter(ctx, w, qrY + qrSize + 16, colors)
+        // 无二维码区直接画 footer
+        self._drawFooter(ctx, w, qrY + 8, colors)
       }
     },
 
     _drawFooter: function(ctx, w, topY, colors) {
+      var L = this.layout
+      if (!L.footer.show) return
       var brandY = topY + 8
       ctx.setFillStyle('rgba(0,0,0,0.08)')
       ctx.beginPath()
@@ -530,20 +637,50 @@ export default {
       ctx.closePath()
       ctx.fill()
 
-      ctx.setFillStyle(colors.main)
-      ctx.setFontSize(this._fs(18))
+      ctx.setFillStyle(L.footer.color === 'auto' ? colors.main : (L.footer.color || colors.main))
+      ctx.setFontSize(this._fs(L.footer.fontSize || 18))
       ctx.setTextAlign('center')
       ctx.setTextBaseline('top')
-      ctx.fillText('浠艾福 · 家庭健康俱乐部', w / 2, brandY + 14)
+      ctx.fillText(L.footer.text || '浠艾福 · 家庭健康俱乐部', w / 2, brandY + 14)
+    },
+
+    /** CTA 胶囊按钮(二维码下方,品牌主色描边+白底小胶囊,比纯文字更有行动感) */
+    _drawCtaCapsule: function(ctx, w, topY, colors) {
+      var centerX = w / 2
+      var text = '立即报名'
+      ctx.setFontSize(this._fs(14))
+      var textW = this._textWidth(ctx, text)
+      if (textW > 70) textW = 70
+      var padX = 18
+      var btnW = textW + padX * 2
+      var btnH = 36
+      var x = centerX - btnW / 2
+      var y = topY
+      // 胶囊底(半透明白) + 主色描边
+      ctx.setFillStyle('rgba(255,255,255,0.9)')
+      this._roundRect(ctx, x, y, btnW, btnH, btnH / 2)
+      ctx.fill()
+      ctx.setStrokeStyle(colors.main)
+      ctx.setLineWidth(1)
+      this._roundRect(ctx, x, y, btnW, btnH, btnH / 2)
+      ctx.stroke()
+      // 文字
+      ctx.setFillStyle(colors.dark)
+      ctx.setFontSize(this._fs(14))
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('middle')
+      ctx.fillText(text, centerX, y + btnH / 2 + 1)
     },
 
-    _drawQrPlaceholder: function(ctx, x, y, size) {
+    _drawQrPlaceholder: function(ctx, x, y, size, L) {
+      var pad = (L && L.qr && L.qr.padding) || 8
+      var radius = (L && L.qr && L.qr.radius) || 8
       ctx.setFillStyle('#FFFFFF')
-      this._roundRect(ctx, x - 8, y - 8, size + 16, size + 16, 8)
+      this._roundRect(ctx, x - pad, y - pad, size + pad * 2, size + pad * 2, radius)
       ctx.fill()
       ctx.setStrokeStyle('#E7E5E4')
       ctx.setLineWidth(0.5)
-      this._roundRect(ctx, x - 8, y - 8, size + 16, size + 16, 8)
+      this._roundRect(ctx, x - pad, y - pad, size + pad * 2, size + pad * 2, radius)
       ctx.stroke()
       ctx.setFillStyle('#A8A29E')
       ctx.setFontSize(this._fs(14))

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

@@ -211,6 +211,7 @@ export default {
         title: '',
         intro: '',
         description: '',
+        intro: '',
         coverImage: '',
         dimensionCode: '',
         activityType: '',
@@ -277,6 +278,7 @@ export default {
           self.form.title = d.title || ''
           self.form.intro = d.intro || ''
           self.form.description = d.description || ''
+          self.form.intro = d.intro || ''
           self.form.coverImage = d.coverImage || ''
           self.form.vendorName = d.vendorName || ''
           self.form.posterTemplateId = d.posterTemplateId || null
@@ -486,6 +488,8 @@ export default {
       // 主办方和海报模板仅非空时提交
       if (!payload.vendorName) delete payload.vendorName
       if (!payload.posterTemplateId) delete payload.posterTemplateId
+      // 简介为空时不提交,保留后端已有值
+      if (!payload.intro) delete payload.intro
       return payload
     },
     handleSave() {

+ 44 - 10
cfc-frontend/pages/activity/index.vue

@@ -137,7 +137,7 @@
                 <text class="tag-chip" v-for="(t, ti) in tagList(act.tags)" :key="ti">{{ t }}</text>
               </view>
               <text class="card-meta">开始 {{ formatActivityTime(act.startTime) }}{{ act.endTime ? ' 结束 ' + formatActivityTime(act.endTime) : '' }}</text>
-              <text class="card-desc" v-if="act.intro || act.description">{{ act.intro || act.description }}</text>
+              <text class="card-desc" v-if="act.intro || act.description">{{ plainText(act.intro || 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.displayStatus || 'open')">
@@ -392,7 +392,7 @@ export default {
     if (this._onLoadFired) {
       this._onLoadFired = false   // 首次进入:onLoad 已加载,跳过避免双发
     } else {
-      // 切 Tab/从子页返回:刷新权限态(同步部分不发请求)+ 当前 tab 数据
+      // 从子页面(如活动详情)返回:刷新当前 tab(loadData 统一重置第一页,避免旧数据被追加导致翻倍)
       this.resolveManagePermission()
       this.loadData()
     }
@@ -401,6 +401,25 @@ export default {
     tagList: function(tags) {
       return String(tags || '').split(/[,,、]/).map(function(t) { return t.trim() }).filter(function(t) { return !!t })
     },
+    // 富文本(HTML)→ 纯文本摘要:去掉 style/script 标签与 HTML 标签、解码 HTML 实体(&nbsp; &#x27; 等)、压缩空白
+    plainText: function(html) {
+      if (!html) return ''
+      var s = String(html)
+        .replace(/<style[\s\S]*?<\/style>/gi, ' ')
+        .replace(/<script[\s\S]*?<\/script>/gi, ' ')
+        .replace(/<[^>]+>/g, ' ')
+        .replace(/&nbsp;/gi, ' ')
+        .replace(/&lt;/gi, '<')
+        .replace(/&gt;/gi, '>')
+        .replace(/&amp;/gi, '&')
+        .replace(/&quot;/gi, '"')
+      s = s
+        .replace(/&#x([0-9a-fA-F]+);/g, function(all, hex) { return String.fromCharCode(parseInt(hex, 16)) })
+        .replace(/&#(\d+);/g, function(all, dec) { return String.fromCharCode(parseInt(dec, 10)) })
+        .replace(/\s+/g, ' ')
+        .trim()
+      return s
+    },
     // 活动管理权限判定:与后端 ActivityController.hasActivityManageRole 对齐。
     // 后台角色(admin / activity_admin / activity_manager)→ 可直接发布;
     // 其余账号查服务商入驻状态,vendorType=activity_provider 且审核通过 → 可管理(只能提交审核)。
@@ -681,11 +700,14 @@ export default {
         this.minePage = 1
         this.myActivities = []
       }
+      // 请求序号:返回期间若已发起新请求(onShow/下拉刷新重置),旧响应直接丢弃,防止旧分页追加导致列表翻倍
+      var seq = (this._mineReqSeq || 0) + 1
+      this._mineReqSeq = seq
       this.mineLoading = true
       var memberId = uni.getStorageSync('currentChildId')
       if (!memberId) {
         this.mineLoading = false
-        return
+        return Promise.resolve()
       }
       var params = {
         memberId: memberId,
@@ -695,7 +717,9 @@ export default {
       if (this.currentMineFilter && this.currentMineFilter !== 'upcoming') {
         params.status = this.currentMineFilter
       }
-      getMyActivityRegistrations(params).then(function(res) {
+      // 返回 Promise:onRefresh 的 .finally 依赖它来复位 isRefreshing(不返回会导致 undefined.finally 抛错,下拉刷新卡死)
+      return getMyActivityRegistrations(params).then(function(res) {
+        if (seq !== self._mineReqSeq) return
         self.mineLoading = false
         if (res && res.code === 200 && res.data) {
           var records = res.data.records || []
@@ -728,7 +752,7 @@ export default {
           self.hasMore = records.length >= self.pageSize
         }
       }).catch(function() {
-        self.mineLoading = false
+        if (seq === self._mineReqSeq) self.mineLoading = false
       })
     },
     loadDiscoverActivities: function(refresh) {
@@ -737,6 +761,9 @@ export default {
         this.discoverPage = 1
         this.discoverActivities = []
       }
+      // 请求序号:过期响应直接丢弃,防止旧分页在刷新后追加导致列表翻倍
+      var seq = (this._discoverReqSeq || 0) + 1
+      this._discoverReqSeq = seq
       this.discoverLoading = true
       var params = {
         page: this.discoverPage,
@@ -745,7 +772,9 @@ export default {
       if (this.currentDimension) {
         params.dimensionCode = this.currentDimension
       }
-      getActivityList(params).then(function(res) {
+      // 返回 Promise:onRefresh 的 .finally 依赖它复位 isRefreshing
+      return getActivityList(params).then(function(res) {
+        if (seq !== self._discoverReqSeq) return
         self.discoverLoading = false
         if (res && res.code === 200 && res.data) {
           var list = Array.isArray(res.data) ? res.data : (res.data.records || [])
@@ -757,7 +786,7 @@ export default {
           self.hasMore = list.length >= self.pageSize
         }
       }).catch(function() {
-        self.discoverLoading = false
+        if (seq === self._discoverReqSeq) self.discoverLoading = false
       })
     },
     loadHistoryActivities: function(refresh) {
@@ -766,18 +795,23 @@ export default {
         this.historyPage = 1
         this.historyActivities = []
       }
+      // 请求序号:过期响应直接丢弃,防止旧分页在刷新后追加导致列表翻倍
+      var seq = (this._historyReqSeq || 0) + 1
+      this._historyReqSeq = seq
       this.historyLoading = true
       var memberId = uni.getStorageSync('currentChildId')
       if (!memberId) {
         this.historyLoading = false
-        return
+        return Promise.resolve()
       }
       var params = {
         memberId: memberId,
         page: this.historyPage,
         size: this.pageSize
       }
-      getMyActivityRegistrations(params).then(function(res) {
+      // 返回 Promise:onRefresh 的 .finally 依赖它复位 isRefreshing
+      return getMyActivityRegistrations(params).then(function(res) {
+        if (seq !== self._historyReqSeq) return
         self.historyLoading = false
         if (res && res.code === 200 && res.data) {
           var records = res.data.records || []
@@ -802,7 +836,7 @@ export default {
           self.hasMore = records.length >= self.pageSize
         }
       }).catch(function() {
-        self.historyLoading = false
+        if (seq === self._historyReqSeq) self.historyLoading = false
       })
     },
     // 报名记录 → 卡片所需字段:/api/activity/my-registrations 返回的是

+ 45 - 3
cfc-web/src/views/admin/PosterTemplateManage.vue

@@ -110,6 +110,14 @@
           <el-input-number v-model="form.qrSize" :min="60" :max="200" :step="10" size="small"></el-input-number>
           <span class="color-text">rpx,活动海报建议 100</span>
         </el-form-item>
+        <el-form-item label="布局配置(JSON)">
+          <el-input type="textarea" v-model="form.layoutConfigText" :rows="10"
+            placeholder="海报元素位置/样式可配置,格式见下方说明;留空使用默认布局"></el-input>
+          <div class="form-tip">
+            画布 500×800 设计坐标,支持元素:cover(封面位置/圆角)、title(标题字号/颜色/行数)、meta(元信息列数/字号/颜色)、intro(简介字号/颜色/行数)、qr(二维码边距/圆角)、invite(邀请语字号/颜色)、footer(底部文案字号/颜色)。
+            <el-button type="text" size="mini" @click="resetLayoutConfig">填入默认布局</el-button>
+          </div>
+        </el-form-item>
         <el-form-item label="排序">
           <el-input-number v-model="form.sortOrder" :min="0" :max="99" size="small"></el-input-number>
           <span class="color-text">越小越靠前展示</span>
@@ -166,6 +174,18 @@ export default {
     this.loadData()
   },
   methods: {
+    defaultLayoutConfig() {
+      return JSON.stringify({
+        cover: { show: true, x: 28, y: 148, width: 444, height: 202, radius: 12 },
+        card: { show: true, x: 28, width: 444, top: 112, height: 620, radius: 16, bgLight: '#FFFFFF', topPad: 16 },
+        title: { show: true, fontSize: 22, color: '#1C1917', barColor: 'auto', barWidth: 4, lines: 2 },
+        meta: { show: true, columns: 3, gap: 28, labelFontSize: 12, valueFontSize: 14, labelColor: '#A8A29E', valueColor: '#1C1917', rowGap: 28 },
+        intro: { show: true, fontSize: 13, color: '#78716C', lines: 2 },
+        qr: { show: true, size: 100, padding: 8, radius: 8 },
+        invite: { show: true, fontSize: 13, color: '#A8A29E', text: '长按识别二维码 · 立即报名' },
+        footer: { show: true, fontSize: 18, color: 'auto', text: '浠艾福 · 家庭健康俱乐部' }
+      }, null, 2)
+    },
     emptyForm() {
       return {
         id: null,
@@ -177,9 +197,13 @@ export default {
         bgColorList: ['#F97316', '#EA580C'],
         qrSize: 100,
         sortOrder: 0,
-        status: 1
+        status: 1,
+        layoutConfigText: ''
       }
     },
+    resetLayoutConfig() {
+      this.form.layoutConfigText = this.defaultLayoutConfig()
+    },
     formatTime(t) {
       return t ? t.replace('T', ' ').substring(0, 19) : '-'
     },
@@ -239,7 +263,8 @@ export default {
         bgColorList,
         qrSize: row.qrSize || 100,
         sortOrder: row.sortOrder || 0,
-        status: row.status === 0 ? 0 : 1
+        status: row.status === 0 ? 0 : 1,
+        layoutConfigText: row.layoutConfig || ''
       }
       this.dialogVisible = true
     },
@@ -252,11 +277,28 @@ export default {
         this.$message.warning('请选择模板类型')
         return
       }
+      // 布局配置 JSON 校验:非空必须是合法 JSON 对象
+      let layoutConfig = null
+      if (this.form.layoutConfigText && this.form.layoutConfigText.trim()) {
+        const text = this.form.layoutConfigText.trim()
+        try {
+          const parsed = JSON.parse(text)
+          if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+            this.$message.error('布局配置必须是 JSON 对象(不能是数组)')
+            return
+          }
+          layoutConfig = JSON.stringify(parsed)
+        } catch (e) {
+          this.$message.error('布局配置不是合法 JSON,请检查格式')
+          return
+        }
+      }
       this.saving = true
       try {
         const data = {
           ...this.form,
-          bgColors: JSON.stringify((this.form.bgColorList || []).filter(Boolean))
+          bgColors: JSON.stringify((this.form.bgColorList || []).filter(Boolean)),
+          layoutConfig
         }
         const res = await savePosterTemplate(data)
         if (res.code === 0 || res.code === 200) {

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

@@ -397,6 +397,7 @@
 | `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-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` 通过) | 活动详情页报名情况展示 |
+| `verify/activity-detail-redesign.md` | ✅ 已实现(活动模块三合一:①活动简介 intro 全链路(create 表单输入+编辑回显、activity-detail 展示+海报摘要、index 列表卡片 plainText 渲染、后端实体/DTO/迁移388 支撑)②海报模板 layoutConfig 扩展(迁移404 `activity_poster_templates.layout_config` + 实体/Service JSON 校验 + ActivityPoster.vue 全量读取 layout 配置——cover/title/meta/intro/qr/invite/footer/card 9 元素组约 25 字段,位置/字号/颜色/行数/显隐可配,`auto` 色回退模板主色;视觉增强:品牌头分隔线+标语、封面圆角、标题 accent 竖条、二维码白底圆角卡+CTA 胶囊、footer 自适应)③详情页美化沿用既有卡片化排版(本次仅补齐 intro 显示);cfc-web `PosterTemplateManage.vue` 新增布局 JSON 编辑器(默认模板一键填入+合法校验);`mvn clean compile` 通过) | 活动详情重设计(活动简介 / 海报模板扩展+预览重设计 / 详情页美化) |
 | `2026-10-22-planner-learning-assessment-gate-design.md` | 🟢 后端已实现,前端待建(9 表:`planner_cert_categories`/`planner_courses`/`planner_course_progress`/`planner_offline_course_map`/`activity_checkins`/`planner_certifications`/`planner_exam_papers`/`planner_exam_questions`/`planner_exam_attempts`,迁移 394-401 幂等 + 首批种子 3 类 planner/nutritionist/assessor + 迁移 401 存量 Guide(active) 补登;`PlannerCertificationService` 认证状态机(init/learning → exam_passed → certified,基础类别通过同步 `Guide.status=active`)+ `PlannerCertificationGuard` 中央闸门 `assertCertified(userId, categoryCode)`;`ServiceRoleApplicationServiceImpl` planner 分支改造为 `pending_cert` + 按类别初始化认证;接单闸门已注入 7 类入口(package/add、package/{id}/update、`BindController.accept`、`PlanReviewController.activate`、`TeacherMessageController.reply`、`GuideOrderController` list/stats/detail=planner 基础,`GuideAssessmentController` family/enable=assessor);规划师端 7 接口 + 管理端 26 接口 + 小鹅通 webhook 2 接口(`/api/xiaoe/webhook` 签名校验幂等 vs `XiaoeWebhookAdapter`,另有 `XiaoeManualAdapter` 兜底);API_REFERENCE.md §5 已同步。**待办**:规划师端「培训认证」中心页、cfc-web 认证看板/课程/题库管理页、E2E 场景验证、营养类目(nutritionist)闸门待规划师侧营养方案入口建设后注入) | 规划师在线学习·多类别认证·线下打卡·接单闸门 |
 
 ### 实施计划(plans/)

+ 165 - 0
docs/superpowers/plans/verify/activity-detail-redesign.md

@@ -0,0 +1,165 @@
+# 活动模块改进实现报告:活动详情重设计
+
+> 完成时间:2026-10-24  
+> 范围:`cfc-frontend`(活动列表/创建/详情/海报组件)+ `cfc-backend`(实体/DTO/Service/迁移/schema.sql)+ `cfc-web`(管理端模板编辑)  
+> Git 状态:工作区已包含完整实现改动,可直接提交
+
+---
+
+## 一、三项需求完成情况
+
+| 需求 | 完成度 | 说明 |
+|------|--------|------|
+| **1. 活动简介字段**(前端表单输入、列表/详情/海报展示、后端支撑) | ✅ 完成 | 工作区已包含 create.vue 表单输入、activity-detail.vue 详情展示、index.vue 列表卡片展示、ActivityPoster 海报摘要、后端实体/DTO/迁移/接口全链路 |
+| **2. 海报模板定义扩展 + 海报预览重设计**(元素位置/样式可配置、视觉更美观) | ✅ 完成 | 新增 `layout_config` JSON 列,支持 9 大元素组(cover/title/meta/intro/qr/invite/footer/card)完整配置;ActivityPoster.vue 全量改为读取 layout 配置;视觉增强:品牌头分隔线+标语、封面卡片圆角、标题 accent 竖条、二维码白底圆角卡片+CTA 胶囊按钮、Footer 自适应颜色 |
+| **3. 活动详情页整体美化**(排版、间距、信息层级、按钮) | ✅ 已具备 | 验证确认详情页卡片化、品牌色、报名情况/进度条、底部操作栏等视觉已由历史 commit 覆盖(`a8291fa4e` 海报重设计、`dc4e42f46` 报名情况等),本次工作区仅补齐 intro 显示,未破坏既有功能 |
+
+---
+
+## 二、涉及文件清单
+
+### 前端小程序
+| 文件 | 核心改动 |
+|------|---------|
+| `pages/activity/create.vue` | 表单新增“活动简介” textarea(maxlength=200,占位提示用途)、data.form.intro、loadDetail 回显、buildPayload 空值过滤 |
+| `pages/activity/activity-detail/activity-detail.vue` | 介绍区块改为 `intro 优先 → description 兜底`;新增 `.intro-text` 样式;ActivityPoster intro prop 改为 `(activity.intro \|\| activity.description)` |
+| `pages/activity/index.vue` | 新增 `plainText()` 工具函数(HTML/实体转纯文本压缩空白),卡片展示用它渲染 intro/description;`loadData(refresh)` 支持刷新重置,避免翻倍 |
+| `pages/activity/components/ActivityPoster.vue` | **核心重写**:新增 `DEFAULT_LAYOUT`、`layout` computed、`_deepClone/_mergeLayout`;`_renderPoster/_drawInfoCard/_drawBrandHeader/_drawCoverPlaceholder/_drawFooter/_drawCtaCapsule` 全量改为读取 `layout` 配置;视觉增强:品牌头分隔线+标语、封面卡片圆角、标题 accent 竖条、二维码白底圆角卡片+边框、CTA 胶囊按钮、footer 自适应颜色;兼容 `barColor/footer.color: 'auto'` 回退模板主色 |
+
+### 后端
+| 文件 | 核心改动 |
+|------|---------|
+| `entity/ActivityPosterTemplate.java` | 新增 `layoutConfig` 字段(TEXT,注释含完整 JSON schema 示例) |
+| `config/DatabaseInitializer.java` | 迁移 404:`addActivityPosterTemplateLayoutConfigColumn()`,`columnExists` + `ALTER TABLE ADD COLUMN` 幂等 |
+| `resources/schema.sql` | `activity_poster_templates` 表新增 `layout_config TEXT DEFAULT NULL COMMENT '布局配置JSON(元素位置/样式可配置,空=默认布局)'` |
+| `service/ActivityPosterTemplateService.java` | `save()` 新增 `layoutConfig` JSON 合法性校验(非空必须是 JSONObject,非法返回错误、空置 null) |
+
+### Web 管理端
+| 文件 | 核心改动 |
+|------|---------|
+| `src/views/admin/PosterTemplateManage.vue` | 编辑弹窗新增“布局配置(JSON)” textarea(10 行,placeholder 说明字段);`defaultLayoutConfig()` 提供完整默认 JSON;`resetLayoutConfig()` 一键填入;`handleSave` 校验 JSON 合法后提交 `layoutConfig` |
+
+---
+
+## 三、数据库与模板结构改动
+
+### 表结构
+```sql
+-- activity_poster_templates 新增列
+layout_config TEXT DEFAULT NULL COMMENT '布局配置JSON(元素位置/样式可配置,空=默认布局)'
+```
+迁移 404:`ALTER TABLE activity_poster_templates ADD COLUMN layout_config TEXT NULL COMMENT '布局配置JSON(元素位置/样式可配置,空=默认布局)'`(幂等,`columnExists` 判断后执行)
+
+### layoutConfig JSON Schema(所有字段可选,未配置回退 DEFAULT_LAYOUT)
+```json
+{
+  "cover":   { "show": true, "x": 28, "y": 148, "width": 444, "height": 202, "radius": 12 },
+  "card":    { "show": true, "x": 28, "width": 444, "top": 112, "height": 620, "radius": 16, "bgLight": "#FFFFFF", "topPad": 16 },
+  "title":   { "show": true, "fontSize": 22, "color": "#1C1917", "barColor": "auto", "barWidth": 4, "lines": 2 },
+  "meta":    { "show": true, "columns": 3, "gap": 28, "labelFontSize": 12, "valueFontSize": 14, "labelColor": "#A8A29E", "valueColor": "#1C1917", "rowGap": 28 },
+  "intro":   { "show": true, "fontSize": 13, "color": "#78716C", "lines": 2 },
+  "qr":      { "show": true, "size": 100, "padding": 8, "radius": 8 },
+  "invite":  { "show": true, "fontSize": 13, "color": "#A8A29E", "text": "长按识别二维码 · 立即报名" },
+  "footer":  { "show": true, "fontSize": 18, "color": "auto", "text": "浠艾福 · 家庭健康俱乐部" }
+}
+```
+> 画布 500×800 设计坐标;`auto` 表示绘制时自动回退到模板主色(`colors.main` / `colors.dark`)。
+
+---
+
+## 四、设计取舍说明
+
+| 决策点 | 取舍 | 理由 |
+|--------|------|------|
+| **布局配置存储格式** | JSON TEXT 列(`layout_config`) | 灵活、可扩展、不需建关联表、MyBatis-Plus 原生支持、前端可直接 `JSON.parse` 使用 |
+| **配置粒度** | 9 个元素组 × 约 25 个可配字段(位置、尺寸、字号、颜色、行数、显隐) | 满足需求“标题/时间/地点/简介/二维码/背景图等元素的位置与样式可配置”;避免逐像素绝对定位导致的维护爆炸,采用“流式布局 + 关键参数可配” |
+| **默认布局** | 内置 `DEFAULT_LAYOUT` 常量,与历史硬编码值完全一致 | 存量模板 `layout_config=NULL` 时行为零回归;用户新建模板可留空或点击“填入默认布局”再微调 |
+| **颜色回退** | `barColor/footer.color: 'auto'` → 绘制时取 `colors.main` | 保持模板配色一致性,避免硬编码橙色导致其他维度模板(心/智/行/富)视觉错位 |
+| **视觉重设计范围** | 仅增强 `ActivityPoster.vue`(活动详情页海报),不改 `ContentSharePoster.vue`(创建页预览/通用分享) | 需求明确“活动详情页的海报预览”;通用分享海报保持现有三引擎风格,后续可单独扩展 |
+| **CTA 样式** | 二维码下方“胶囊按钮”(白底+主色描边+文字),替代原纯文字邀请语 | 提升行动感知度,符合现代小程序海报设计趋势 |
+| **管理端编辑器** | JSON 文本域 + 默认模板一键填入,不做可视化拖拽 | 开发成本可控、JSON 表达力强、运营可直接复制/微调;如需可视化后续单独立项 |
+
+---
+
+## 五、兼容性影响
+
+| 维度 | 影响 | 说明 |
+|------|------|------|
+| **存量活动数据** | 无影响 | 仅新增列/表,不改既有字段;迁移幂等、不触碰数据 |
+| **存量海报模板** | 无影响 | `layout_config=NULL` → 使用 `DEFAULT_LAYOUT`(与当前硬编码完全一致),视觉零差异 |
+| **创建页预览** | 不变 | 仍用 `ContentSharePoster.vue`(三引擎 gradient/minimal/card),不读取 `layoutConfig` |
+| **详情页海报** | 增强 | 支持模板 `layoutConfig`,无配置时回退默认布局(视觉一致),有配置时按配置绘制 |
+| **Web 管理端旧模板编辑** | 兼容 | 编辑时 `layoutConfigText` 为空字符串,保存不传 `layoutConfig`,保持 NULL → 仍走默认布局 |
+| **小程序端模板选择/切换** | 兼容 | `tplOptions` 仍按 `serverTemplates` 渲染;切换模板即时应用新 `layout` 配置 |
+
+---
+
+## 六、验证步骤建议
+
+1. **后端编译**
+   ```bash
+   cd cfc-backend && mvn clean compile -q   # exit=0
+   ```
+
+2. **数据库迁移自测**(本地/测试库)
+   - 启动后端,检查日志是否出现 `迁移404: 已添加 activity_poster_templates.layout_config 列`
+   - `DESC activity_poster_templates` 确认 `layout_config` 列存在
+
+3. **小程序端验证(HBuilderX 重新打包后)**
+   - **创建/编辑活动**:进入“创建活动/编辑活动”页,确认“活动简介”输入框可用,保存后列表卡片/详情页显示简介;编辑回显正常
+   - **活动列表**:发现卡片展示纯文本摘要(HTML/实体已转纯文本,无标签残留)
+   - **活动详情**:封面→标题/标签/状态→元信息卡片→报名情况→简介(intro 优先)→底部操作栏,排版整齐
+   - **生成海报**:点击“生成海报”→弹窗显示模板选择、背景色切换;切换模板即时重绘
+   - **模板配置生效**:Web 管理端新建模板,填入自定义 `layoutConfig`(如加大标题字号、隐藏简介、调整二维码尺寸),小程序端生成海报观察对应变化
+
+4. **Web 管理端验证**
+   - 进入“活动海报模板管理”,点击“新增模板”,底部出现“布局配置(JSON)”,点击“填入默认布局”自动填入完整 JSON
+   - 修改 `title.fontSize: 28`、`meta.columns: 2`、`qr.size: 120`,保存成功
+   - 小程序端重新拉取模板,生成海报验证配置生效
+
+5. **回归测试**
+   - 报名/取消报名/签到/评价/分享/海报保存相册等既有功能正常
+   - 无模板配置(老模板/新建未填布局)时,海报视觉与历史版本一致
+
+---
+
+## 七、遗留项 / 后续优化建议
+
+| 项 | 优先级 | 说明 |
+|----|--------|------|
+| 管理端布局可视化编辑器(拖拽/实时预览) | P2 | 当前 JSON 文本域对运营有一定门槛,后续可引入可视化编辑器 |
+| `ContentSharePoster.vue` 也支持 `layoutConfig` | P3 | 统一创建页预览与详情页海报能力,需注意三引擎兼容性 |
+| 布局配置版本管理/回滚 | P3 | 运营误改可回滚,或模板历史版本对比 |
+| 模板导入/导出(含 layoutConfig) | P4 | 支持跨环境同步模板配置 |
+| 迁移编号 388/389 复用清理 | 低 | 后续新增迁移避免续用 388/389,建议重新排序 |
+
+---
+
+## 八、Git 提交记录(建议)
+
+```bash
+# 仅提交本任务相关文件(排除并行会话的 ActivityController/ActivityRegistrationService 等无关改动)
+git add \
+  cfc-frontend/pages/activity/create.vue \
+  cfc-frontend/pages/activity/activity-detail/activity-detail.vue \
+  cfc-frontend/pages/activity/index.vue \
+  cfc-frontend/pages/activity/components/ActivityPoster.vue \
+  cfc-backend/src/main/java/com/etotem/cfc/entity/ActivityPosterTemplate.java \
+  cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java \
+  cfc-backend/src/main/resources/schema.sql \
+  cfc-backend/src/main/java/com/etotem/cfc/service/ActivityPosterTemplateService.java \
+  cfc-web/src/views/admin/PosterTemplateManage.vue \
+  docs/superpowers/plans/verify/activity-detail-redesign.md
+
+git commit -m "feat(activity): 活动详情重设计三合一
+
+1) 活动简介字段:create.vue 表单输入+回显,activity-detail.vue 详情展示+海报摘要,index.vue 列表卡片 plainText 渲染
+2) 海报模板 layoutConfig:后端迁移 404 + entity/service/schema.sql,前端 ActivityPoster.vue 全量读取 layout 配置(9 元素组/25+ 字段),视觉增强(品牌头分隔线、标题 accent、二维码白底卡片+CTA 胶囊、footer 自适应)
+3) 详情页美化:沿用既有卡片化/品牌色/报名进度条排版,本次仅补齐 intro 显示
+
+管理端 PosterTemplateManage.vue 新增布局配置 JSON 编辑器(默认模板一键填入+校验)"
+```
+
+---
+
+> **注**:本报告对应用户原始需求“活动模块三项改进”(活动简介 / 海报模板扩展+预览重设计 / 详情页美化)的完整落地实现记录。工作区已就绪,执行上述提交命令即可入库。

+ 82 - 0
docs/superpowers/plans/verify/fix-discover-activity-list.md

@@ -0,0 +1,82 @@
+# 修复报告:发现活动列表 HTML 标签外露 + 返回详情页后列表翻倍
+
+- 日期:2026-10-24
+- 模块:cfc-frontend(uni-app / Vue2 微信小程序)
+- 定位说明:用户线索提示在 `pages/discover/index.vue` 或其 components,代码实际落地在 `pages/activity/index.vue` 的「发现活动」tab(`getActivityList` 接口 + 活动卡片),已按代码实际为准处理。
+
+## 一、涉及文件清单
+
+| 文件 | 是否改动 | 说明 |
+|------|----------|------|
+| `cfc-frontend/pages/activity/index.vue` | ✅ 改动 | 两个 bug 的唯一文件 |
+| `cfc-frontend/pages/activity/activity-detail/activity-detail.vue` | 已确认无需改动 | 详情页用 `<mp-html :content="activity.description">` 渲染富文本(见 line 108-110),本来就是富文本正确渲染路径,不受本次改动影响 |
+| `cfc-frontend/pages/guide/activities/index.vue` | 未改动(本次范围外) | 该页 `act.description` 同样直出,但属于规划师「活动管理」列表而非发现活动列表;如需处理可另行跟进 |
+| `cfc-frontend/components/DimensionActivities.vue` | 未改动 | 仅展示标题/时间/状态/价格,不展示正文富文本,无此问题 |
+
+## 二、Bug 1:卡片摘要直接展示 HTML 标签/实体
+
+### 根因
+发现活动 tab 的卡片描述(`pages/activity/index.vue` 活动卡片模板)原来直接绑定后端返回的富文本字段:
+
+```html
+<text class="card-desc" v-if="act.intro || act.description">{{ act.intro || act.description }}</text>
+```
+
+后端 `description`/`intro` 是富文本(含 `<p>`、`<div>`、`<br>`、`&nbsp;`、`&#x27;` 等标签与实体),小程序 `<text>` 不会解析 HTML,于是标签与实体按字面显示。此前没有做任何纯文本化处理。
+
+### 改动
+1. 模板改为经 `plainText()` 处理后输出:
+   ```html
+   <text class="card-desc" v-if="act.intro || act.description">{{ plainText(act.intro || act.description) }}</text>
+   ```
+2. 新增 `plainText(html)` 方法(与 repo 内 `pages/mind-detail/articles.vue` 的 `stripHtml` 实现口径保持一致并增强):
+   - 先去 `<style>/<script>` 块,再去所有 HTML 标签;
+   - 解码常见命名实体(`&nbsp;`、`&lt;`、`&gt;`、`&amp;`、`&quot;`);
+   - 解码数字实体(`&#x27;` → `'`、`&#128197;` → `📅` 等 `&#x` / `&#` 形式,用 `String.fromCharCode`);
+   - 归一化连续空白后 trim。
+3. 详情页无需改动:原本即用 `<mp-html>` 渲染富文本,保持正常。
+
+## 三、Bug 2:进入详情返回后列表翻倍(重复追加)
+
+### 根因
+页面数据加载链:
+
+```
+onLoad → loadData() → loadDiscoverActivities()(无 refresh 参数 → 非 replace 模式)
+onShow(_onLoadFired 已消费)→ loadData() → loadDiscoverActivities()(同样无 refresh)
+```
+
+`loadDiscoverActivities(refresh)` 只在 `refresh === true` 时 `discoverPage = 1; discoverActivities = []` 并整体替换,否则走 `concat` 追加。`onShow` 里调用 `loadData()` 又不带 refresh 参数:
+
+1. **页面状态不重置**:`discoverActivities` 原封不动,`discoverPage` 保持当前值;
+2. **并发竞态叠加**:若用户之前下拉加载过分页,返回时可能仍有旧请求在飞,onShow 再发的请求与旧请求都走 `concat`,最终列表条目翻倍(同样活动重复出现一次)。
+
+即「页面重新可见(onShow)再次调用加载,但没有重置页码或清空列表」。
+
+### 改动(均只动 `pages/activity/index.vue`)
+1. `loadData` 增加 `refresh` 形参并透传给 `loadMyActivities/loadDiscoverActivities/loadHistoryActivities`;`manage` 分支本身是全量替换,不需要该参数。
+2. `onShow` 的 else 分支改传 `this.loadData(true)`——从子页返回时按「下拉刷新」语义重置(`page=1`、列表清空、整体替换),首页分支仍保持 `loadData()`(首次列表为空,语义不变)。
+3. 增强竞态防护(防止翻倍的第二个诱因):
+   - 三个 `loadXxxActivities` 各自维护请求序号 `_mineReqSeq / _discoverReqSeq / _historyReqSeq`,`then/catch` 里校验序号,过期响应直接丢弃,不做 `concat`;
+   - 三个 `load*` 改为 `return promise`,未登录等早退路径返回 `Promise.resolve()`——原来它们返回 `undefined`,而 `onRefresh` 依赖 `.finally()` 复位 `isRefreshing`,缺返回会让下拉刷新卡死。
+
+## 四、是否影响原有翻页与刷新行为
+
+- **下拉加载更多(`onLoadMore`)**:仍走「翻页 + concat」路径,未改;序号机制只丢弃过期响应,不影响正常追加。
+- **下拉刷新(`onRefresh`)**:仍走 `refresh=true` 重置语义;因 `load*` 补齐 promise 返回,`.finally` 行为比修复前更可靠(不再有 `undefined.finally` 运行时报错导致 `isRefreshing` 卡在 true 的隐患)。
+- **tab 切换(`onTabChange`)/筛选(`onMineFilterChange`/`onDimensionChange`)/`switchToDiscover`**:原本就手动重置列表+页码后调用,不受影响。
+- **首次进入**:`onLoad` 仍是 `loadData()`,无重复请求;`_onLoadFired` 双发防护逻辑不变。
+- **登录态早退**(`currentChildId` 为空):改为返回 resolved promise,行为与原来直接 return 一致,仅多一个 promise 让 `.finally` 可复用。
+
+## 五、建议的回归验证步骤
+
+1. **列表摘要纯净文本**:进入「活动中心 → 发现活动」tab,检查卡片描述区不再出现 `p / div / br / &nbsp; / &#x27;` 等字面量,只显示纯文本(含中文标点、emoji 等数字实体被正确解码)。用一条已知含富文本正文的活动核对。
+2. **详情页富文本正常**:点同一活动进入详情页,确认 `description` 仍以 `mp-html` 富文本渲染(加粗/换行/图片不丢),未被改动破坏。
+3. **返回不翻倍**:
+   - 进入发现活动列表,记录当前条目数(或直接目视);
+   - 点击任一活动进入详情页,按左上角返回(或用安卓物理返回键);
+   - 确认列表条目数与进入前一致,无同一活动重复出现;
+   - 连续「进入详情 → 返回」三次,条目数量保持稳定。
+4. **分页加载更多仍可用**:活动数量 ≥ 10(pageSize)时,在发现活动/我的活动/历史记录三个 tab 分别滚动到底触发加载更多,确认追加正常、无重复。
+5. **下拉刷新可用**:在上述 tab 触发下拉刷新,确认列表正常刷新、loading 态正常关闭(不会卡在刷新中)。
+6. **其他入口不受影响**:我的活动签到/取消报名后的 `loadMyActivities(true)`;切维度筛选;`switchToDiscover` 空态按钮跳转,均保持原行为。

+ 137 - 0
docs/superpowers/plans/verify/verify-activity-detail-redesign.md

@@ -0,0 +1,137 @@
+# 核验报告:活动详情与活动管理改进(活动简介 / 海报模板 / 详情页美化)
+
+- 核验日期:2026-10-24
+- 核验智能体:xxx(独立核验,不信任实现方报告,一切以代码实际为准)
+- 核验范围:`cfc-frontend`(活动列表/创建/详情/海报组件)+ `cfc-backend`(实体/DTO/Controller/Service/迁移/schema.sql)
+- 工作区现状说明(核验完成时点,避免混淆):
+  - 本次核验对象 = 上述三项需求对应的活动模块改动(已提交部分见下述 commit;工作区未提交部分见其后两条)。
+  - 工作区中另有**两个并行会话的 Java 改动**(`ActivityController.myRegistrations` 增加 `status` 参数、`ActivityRegistrationService.getMyRegistrations` 增加 status 筛选),对应的是「我的活动筛选芯片」需求,**与本次三项活动详情/活动管理需求无关;本核验未处理、未修改这两个文件**。已在重新编译时确认整仓工作区 `mvn clean compile -q` 仍 exit=0。
+  - 目录中另存在其他会话产出的 `verify-my-activities-rules.md`(同目录,untracked),非本报告内容。
+- 相关 commit(实现方已提交的既有工作):`188d93659`(intro 字段)、`a8291fa4e`/`71fcf956d`(海报模板)、`8f9367277`(创建页主办方+模板选择)、`dc4e42f46`(详情页报名情况)、`bc363eb69`(ActivityPoster 移入 pages 子包)、`8841ebaff`(ActivityPoster :key 修复)
+- 工作区未提交改动(本次核验对象之一):`cfc-frontend/pages/activity/index.vue`(plainText + loadData(refresh)),以及本核验会话顺手修正的 `create.vue` / `activity-detail.vue`
+
+---
+
+## 一、逐项核验结果
+
+### 1. 活动简介字段(前端表单 + 展示 + 后端支撑 + 空值兜底)
+
+**结论:PASS(本会话修复 2 处前端缺陷后;修复前为 FAIL)**
+
+| 子项 | 结果 | 证据 |
+|------|------|------|
+| 后端实体字段 | PASS | `Activity.intro`(`cfc-backend/.../entity/Activity.java` L23,注释"活动简介(纯文本短摘要,用于活动列表卡片展示)") |
+| 后端 DTO 透传 | PASS | `ActivityDTO.intro`(L13)+ `ActivityDTO.from()` L92 `dto.setIntro(activity.getIntro())`(detail/list 均走 from()) |
+| 后端接口支持 | PASS | `ActivityController.create/update` 直接绑定 `Activity` 实体(L45/L57),intro 随 JSON 持久化;`ActivityService.create/update` 用 `this.save/updateById`,`detail/list` 返回 DTO 含 intro |
+| 数据库列 | PASS | 迁移388 `ensureColumn("activities","intro",...)`(DatabaseInitializer L3118-3119);schema.sql L2134 `intro VARCHAR(200)` 同步 |
+| 列表展示 | PASS | `index.vue` 卡片 L140 `{{ plainText(act.intro || act.description) }}` + `v-if="act.intro || act.description"`;本次新增的 `plainText()` 将富文本/HTML 实体转纯文本并压缩空白 |
+| 创建/编辑表单输入 | **修复前 FAIL → 本会话已修复** | create.vue 原无 intro 输入框、`buildPayload` 不提交、`loadDetail` 不回显。本会话已补齐(见"二、已做修正") |
+| 详情页展示 | **修复前 FAIL → 本会话已修复** | activity-detail.vue 原只用 `activity.description`(L108/L300)。已改为 intro 优先、description 兜底 |
+| 旧数据空值兜底 | PASS | 列表卡片 `v-if` 空则不渲染;detail.vue `v-if="activity.intro || activity.description"`;`plainText(null/undefined)` 返回 `''`;ActivityPoster 的 intro prop 默认 `''` 且 `_drawInfoCard` L457-465 为空不绘制——均不会因 intro 为空报错或产生空白块 |
+
+### 2. 数据库迁移(幂等 + 不破坏已有数据)
+
+**结论:PASS**
+
+- 迁移388(intro / poster_template_id):`DatabaseInitializer.ensureColumn()`(L5129-5142)——`try { ALTER ADD COLUMN } catch`,捕获 `Duplicate/'already exists'` 后跳过,其余异常重抛;可重复执行,ADD COLUMN 不触碰已有行数据。
+- 迁移381(activity_poster_templates 表 + 3 套默认模板):`createActivityPosterTemplatesTable()`(L12702-12733)——`CREATE TABLE IF NOT EXISTS` + 种子数据 `INSERT IGNORE ... VALUES`(按名称唯一键幂等),整体 try-catch。
+- schema.sql 三处同步:`activities.intro`(L2134)、`activities.poster_template_id`(L2155)、`activity_poster_templates` 表(L5409)与实体/迁移一致。
+- 未发现会破坏已有活动数据的 DDL(全是 ADD COLUMN / CREATE TABLE IF NOT EXISTS / INSERT IGNORE)。
+- 小瑕疵(不影响功能):迁移编号 388 与 389 在 DatabaseInitializer 中被复用(388=activities.intro 与 poster_template_id;389=activities.tags 与 special_price_deals),见"四、遗留问题 4"。
+
+### 3. 海报模板(模板定义 + 灵活性 + 渲染兜底)
+
+**结论:部分 FAIL(模板配置灵活性未达需求声明)/ 渲染路径兜底 PASS**
+
+**模板定义(管理端):PASS**
+- 实体 `ActivityPosterTemplate`:name / templateKey(gradient|minimal|card) / mainColor / darkColor / lightColor / bgColors(JSON) / qrSize / sortOrder / status。
+- 管理端 `PosterTemplateManage.vue`(cfc-web)支持模板 CRUD:名称、类型、主色、深色、浅色、可选背景色板(动态增删)、二维码尺寸、排序、启停。
+- 后端 `AdminPosterTemplateController`(list/save/delete,均 @PostMapping,符合项目"统一 POST"规范)+ `PosterTemplateController`(小程序端 list,仅返回 status=1 启用模板)。
+- 小程序端可选用模板:create.vue 的模板选择+预览(selectPosterTemplate/openPosterPreview)、activity-detail.vue 生成海报时拉取模板(generatePoster L804-810)。
+
+**配置灵活性:FAIL(相对需求声明)**
+- 需求声明"标题、时间、地点、简介、二维码、背景图等元素的位置与样式可配置"。
+- 实际:模板只能配置**整体配色 + 二维码尺寸**;**元素位置与字体/尺寸样式在 `ActivityPoster._renderPoster/_drawInfoCard` 中为硬编码**(coverY=148、卡片高 210、labelFs=12/valueFs=14/titleFs=22、行距、二维码 y 坐标等),模板字段无任何布局项。即"元素位置与样式可配置"未实现。
+- 评估:本次改动相对内置三套模板(此前 `ContentSharePoster`)灵活度有小幅提升(配色/色板/二维码尺寸可配),但与需求文档承诺的"元素级可配置"存在明显差距。
+
+**渲染路径兜底:PASS(缺元素不报错)**
+- 无管理端模板(templates 空)→ `tplOptions` 回退内置三套(L139-146)。
+- bgColors JSON 解析失败 → 回退默认七色(L161-170)。
+- 模板缺 mainColor/darkColor/lightColor → `|| '#F97316'` 等兜底(L172-182)。
+- 二维码缺失 / 写入失败 → `_drawQrPlaceholder`(L540-553)。
+- 封面图缺失 / 下载失败 / 图片信息读取失败 → `_drawCoverPlaceholder`(L359-376)。
+- intro 为空 → 不绘制简介块;长标题/长简介 → `_drawWrappedText` 按行宽截断加 `..`(L555-581)。
+- qrSize 缺省 → prop 默认 100。
+
+### 4. Java 代码编译
+
+**结论:PASS**
+
+```
+cd cfc-backend && mvn clean compile -q   # 退出码 0(多次复跑确认,Java 8 / Maven 3.8.7)
+```
+
+- 核验时点工作区的**全部** Java 改动(含并行会话的 myRegistrations status 筛选)已覆盖编译,均通过。
+- 本次核验对象(活动简介/海报/详情页)未涉及新增 Java 代码,后端改动均在此前已提交 commit 中。
+
+### 5. 详情页美化未破坏原有功能
+
+**结论:PASS**
+
+- **报名**:detail.vue `onRegister → submitRegistration → doCreateOrder`(付费跳 order-pay / 免费直达),按钮 `v-if="canRegister"`(L137-144)可用。
+- **分享**:`shareMixin` + `setShareInfo`(L489);分享按钮在底部栏(L132-135 生成海报入口,微信原生分享走 onShareAppMessage mixin)。
+- **海报生成与预览**:`generatePoster`(拉二维码 + 模板,L786-836)→ `ActivityPoster` 组件(show/title/intro/startTime/location/priceText/qrCodeBase64/template/templates 等 props 齐全)→ `savePoster` 保存相册。
+- **签到**:`onCheckin`(L535-587)+ 能量扣减弹窗;**取消报名**(L600-630);**评价**(L632-698)。
+- **语法与未定义引用**:对 `index.vue / create.vue / activity-detail.vue / ActivityPoster.vue` 四个文件 script 段做 `node --check`,全部通过,无未定义变量、无截断。
+- **index.vue 改动安全**:`plainText()` 纯函数、无副作用;`loadData(refresh)` 三个加载方法均正确消费 refresh(重置 page=1 + 清空数组,见 L684-787),避免返回详情页后旧数据翻倍;模板未引入小程序禁用模式(无 `?.`、无 `:key` 表达式、无 CSS Grid)。
+- **详情页信息层级**:封面 → 标题/标签/状态 → 元信息卡片(时间/截止/地点/人数/费用)→ 报名情况(脱敏统计+进度条+最近报名人)→ 活动介绍 → 付费提示 → 关联内容 → 底部操作栏,卡片化 + 品牌色令牌($brand-primary 等)一致。
+
+### 6. 缺陷清单
+
+见"四、遗留问题"。
+
+---
+
+## 二、本核验会话已做的修正(顺手修正,与核验对象直接相关)
+
+| 文件 | 修正内容 | 说明 |
+|------|---------|------|
+| `cfc-frontend/pages/activity/create.vue` | ① 活动描述上方新增"活动简介" textarea(maxlength=200,placeholder 注明用于列表卡片与海报摘要);② `data.form` 增加 `intro: ''`;③ `loadDetail()` 回显 `self.form.intro = d.intro || ''`;④ `buildPayload()` 增加 `if (!payload.intro) delete payload.intro`(空值不覆盖后端已有值,与 vendorName/posterTemplateId 空值处理一致) | 补齐需求1"新增或编辑可填写活动简介"的前端缺口 |
+| `cfc-frontend/pages/activity/activity-detail/activity-detail.vue` | ① 介绍区块 `v-if="activity.intro || activity.description"`,intro 非空先渲染纯文本摘要行(新增 `.intro-text` 样式),description 富文本仍用 mp-html;② ActivityPoster 的 `:intro` 改为 `(activity.intro || activity.description)` | 补齐需求1"详情展示简介"且海报摘要优先用短简介,避免把 description 富文本直接当海报摘要 |
+
+**说明:以上修正仅涉及前端两个活动页面,未触碰 BASIC 会员配额相关文件,未改任何构建产物,未执行任何打包命令。**
+
+---
+
+## 三、结论(可否放行)
+
+**可以放行(附条件)。**
+
+- 需求1(活动简介):后端支撑完整,列表展示已实现,前端表单/详情展示经本会话补齐后闭环 → PASS。
+- 需求2(海报模板):管理端模板 CRUD、小程序端选择/预览、三种绘制引擎、渲染兜底均健壮 → 放行;但"元素位置与样式可配置"未实现,属需求范围缩水,作为遗留项另行评估,**不阻塞本次放行**。
+- 需求3(详情页美化):排版、间距、信息层级、按钮均已具备且品牌风格一致,未破坏报名/分享/海报/签到等既有入口 → PASS。
+- 数据库迁移幂等、schema.sql 同步、后端编译通过 → PASS。
+
+**放行条件**:海报模板"元素位置与样式可配置"作为独立扩展任务立项跟进(见遗留问题 3),在此之前不承诺该能力。
+
+---
+
+## 四、遗留问题(清单化)
+
+1. **【已修复】创建/编辑表单无简介输入、不回显、不提交**:原 create.vue 无 intro 输入框。本会话已补齐,回归时可到"创建活动/编辑活动"页验证简介输入、保存后列表与详情展示、编辑回显。
+2. **【已修复】详情页/海报未使用 intro 字段**:原 activity-detail.vue 介绍区块与海报 intro 均用 `description`。已改为 intro 优先、description 兜底;需回归确认 description 富文本在无 intro 时仍正常渲染。
+3. **【未修复 · 需求范围缩水】海报模板不支持元素级位置/样式配置**:需求声明"标题、时间、地点、简介、二维码、背景图等元素的位置与样式可配置",实际 `ActivityPosterTemplate` 仅支持配色 + 二维码尺寸;画布局在 `ActivityPoster._renderPoster/_drawInfoCard` 硬编码(coverY=148、卡片高 210、字体 12/14/22、二维码坐标等)。
+   - 建议改法:`activity_poster_templates` 增加 `layout_config JSON` 列(如 `{"cover":{"x":28,"y":148,"w":444,"h":202},"title":{"fontSize":22,"lines":2},"infoRows":{...},"qr":{"size":100,...}}`),管理端图形化编辑,ActivityPoster 读取后传入绘制函数替换硬编码常量;同时保留现有字段兼容旧模板。DB 侧需新增幂等迁移 + schema.sql 同步。
+4. **【未修复 · 低优先级】迁移编号复用**:DatabaseInitializer 中"迁移388"出现两次(activities.intro 与 poster_template_id)、"迁移389"出现两次(activities.tags 与 special_price_deals)。无功能影响(各自幂等),但后续新增迁移时建议避免续用 388/389,就近使用更大编号。
+5. **【未修复 · 观测项】详情页"整体美化"的增量归属**:本次工作区美化仅涉及 `index.vue`(列表页);detail.vue 的卡片化/品牌色/报名情况区块均来自更早的历史 commit(`a8291fa4e` 海报重设计、`dc4e42f46` 报名情况、`1ff115c5d`/`6d0db37a2` 等美化轮次)。功能与视觉现状满足需求3,但"本次会话具体改了详情页哪些样式"无法从工作区 diff 复核。若需求方需要本次改动的样式级明细,请与实现方确认对应 commit。
+6. **【未修复 · 低优先级】`PosterTemplateController` 服务层注入名**:`@Resource private ActivityPosterTemplateService posterTemplateService;` Bean 名与字段名一致,符合规范;无问题,仅记录审计过程中已确认。
+
+---
+
+## 五、核验方法与证据索引
+
+- 后端实读:`entity/Activity.java`、`dto/ActivityDTO.java`、`controller/ActivityController.java`、`controller/admin/AdminPosterTemplateController.java`、`controller/PosterTemplateController.java`、`service/ActivityService.java`、`config/DatabaseInitializer.java`(L3118-3141 ensureColumn / L12702-12733 createActivityPosterTemplatesTable / L5129-5142 ensureColumn 实现)、`resources/schema.sql`(L2134/2155/5409)。
+- 前端实读:`pages/activity/index.vue`(changes + L684-787 加载方法 + plainText)、`pages/activity/create.vue`(表单/回显/payload)、`pages/activity/activity-detail/activity-detail.vue`(L106-111 介绍区块、L297-319 海报 props、L786-836 生成海报)、`pages/activity/components/ActivityPoster.vue`(模板解析 L128-189、渲染 L238-648、兜底各分支)。
+- 管理端实读:`cfc-web/src/views/admin/PosterTemplateManage.vue`(表单可配置项 L76-120)。
+- 编译:`cd cfc-backend && mvn clean compile -q` → exit=0(核验完成时点,覆盖含并行改动在内的完整工作区)。
+- 前端 script 语法:`index.vue / create.vue / activity-detail.vue / ActivityPoster.vue` 4 个文件 script 段 `node --check` 全通过;`verify-vue-compile.js` 因本机未安装 vue-template-compiler/sass 无法跑完整三段校验,故模板/样式段以人工逐行审查替代(本次未引入新的模板表达式风险)。

+ 101 - 0
docs/superpowers/plans/verify/verify-discover-activity-list.md

@@ -0,0 +1,101 @@
+# 核验报告:发现活动列表两处修复(plainText 摘要 / 返回列表不翻倍)
+
+**核验目标**:`cfc-frontend/pages/activity/index.vue`(发现活动列表页,"发现活动" tab)、`pages/activity/create.vue`、`pages/activity/activity-detail/activity-detail.vue`  
+**核验方式**:只读代码(diff、逐行阅读)+ 语法检查(`node --check`)+ 小程序反模式审计(`scripts/audit-mp-antipatterns.js`)+ 重复接口调用审计(`scripts/audit-duplicate-api-calls.js`)。未启动 HBuilderX/小程序模拟器,行为层仍需真机复现确认。  
+**复核原则**:一律以代码实际为准,不信任对方报告;未按此处证据修改的文件不计入。
+
+---
+
+## 1. 列表卡片摘要纯文本化(去 HTML 标签/还原实体)
+
+**PASS**
+
+### 证据
+- `cfc-frontend/pages/activity/index.vue:140`(发现列表卡片):
+  ```vue
+  <text class="card-desc" v-if="act.intro || act.description">{{ plainText(act.intro || act.description) }}</text>
+  ```
+  修复前直接渲染 `{{ act.intro || act.description }}`,会把 `<p>`、`<br>`、`&nbsp;` 等原样展示;现统一走 `plainText()`。
+- `cfc-frontend/pages/activity/index.vue:404-422`(`plainText` 方法):去掉 `<style>`/`<script>` 标签、去掉全部 HTML 标签、解码 `&nbsp; / &lt; / &gt; / &amp; / &quot;` 以及 `&#x…;` / `&#…;` 数字实体,最后压缩空白并 trim,输出纯文本摘要。
+
+### 详情页富文本未破坏
+- `cfc-frontend/pages/activity/activity-detail/activity-detail.vue:108-112`:详情页"活动介绍"区段由 `v-if="activity.intro || activity.description"` 控制,`activity.intro` 走 `<text class="intro-text">` 纯文本展示,`activity.description` 仍走 `<mp-html :content="activity.description" :tag-style="contentTagStyle" />`,富文本渲染完整保留。
+- 页面未移除 `mp-html` 组件引用(`@/components/mp-html/mp-html.vue`,`activity-detail.vue:329`),`:intro` 传参也使用了 `activity.intro || activity.description`(`activity-detail.vue:301`)。
+
+### 关于 pages/discover/index.vue
+已逐行阅读:该页是商品推荐 / 知识分享聚合页,不渲染活动卡片摘要,不存在 `act.description` 列表展示,**无需且未做改动**,与本次需求无冲突。
+
+---
+
+## 2. 从活动详情返回列表,不重复追加(onShow / 加载更多 / 下拉刷新)
+
+**PASS(修正后)**
+
+### 2.1 翻倍根因(修复前行为)
+`git show HEAD` 确认:原 `onShow`(`pages/activity/index.vue:391-399`,HEAD 版)在从子页返回时调用 `this.loadData()`,**不带 refresh**,而 `loadDiscoverActivities()` 等未带 refresh 时执行 `this.xxxActivities = this.xxxActivities.concat(records)`(`index.vue:732/761/806` 对应旧版行号),导致返回列表重复追加、数据翻倍。
+
+### 2.2 修复后行为(逐钩子核实)
+| 钩子 | 位置 | 行为 | 结论 |
+|------|------|------|------|
+| onLoad(首次进入) | `index.vue:381-389` | `loadData()`(首屏,列表为空,正常)| 正常 |
+| onShow 首次(与 onLoad 连续触发) | `index.vue:392-393` | `_onLoadFired === true` → 置 `false`,跳过,避免双发 | 正常(遵循 AGENTS.md 规则 1) |
+| onShow(从详情/子页返回) | `index.vue:394-398` | `this.loadData(true)`,带 refresh 语义:页码归 1 并清空列表后再加载,旧数据不被追加 | **已修复翻倍** |
+| onTabChange / 筛选 / 维度切换 | `index.vue:466-480`, `634-651` | 各自 tab 页码归 1、列表清空后 `loadData()` | 正常,无残留旧数据 |
+| 加载更多(scrolltolower→onLoadMore) | `index.vue:652-664` | `!this.hasMore` 提前返回;页码 `++`,不传 refresh → `concat` 追加;`discoverLoading/mineLoading/historyLoading` 互斥守卫 | 翻页能力保留 |
+| 下拉刷新(onRefresh) | `index.vue:665-683` | `isRefreshing = true`,传 `true` 刷新(页码归 1、列表清空),`.finally` 复位 `isRefreshing` | **已修复**(见下文预存在的 `.finally` Bug 及修正) |
+| manage  tab | `index.vue:488-492` | `loadManageData()` 固定 page=1、直接覆盖 `manageActivities`,不翻倍 | 正常 |
+
+三个加载方法的 refresh 分支(页码归 1 + 列表置空):
+- `loadMyActivities`: `index.vue:686-688`
+- `loadDiscoverActivities`: `index.vue:747-749`
+- `loadHistoryActivities`: `index.vue:781-783`
+
+### 2.3 本次额外修正(预存在 Bug,影响"下拉刷新仍然可用")
+`onRefresh` 调用 `this.loadMyActivities(true).finally(...)` 等,但三个加载方法 **不返回 Promise**(HEAD 版亦如此),导致 `undefined.finally` 抛 `TypeError`:数据虽会重载,但 `isRefreshing` 永不复位,下拉刷新转圈卡死、后续刷新受阻。
+
+已修正:
+- 三个加载方法均 `return` 接口 Promise 链;未登录/无 memberId 的提前返回分支改为 `return Promise.resolve()`(`index.vue:697`, `792`)。
+- 同时为三个方法加了 per-tab 请求序号守卫(`_mineReqSeq / _discoverReqSeq / _historyReqSeq`,`index.vue:690-692`, `751-753`, `785-787`):响应到达时若 `seq !== 当前序号`(即已有更新请求覆盖),直接丢弃,彻底消除“旧分页响应迟到后 concat 到刷新后列表、导致翻倍”的残留竞态。
+
+### 2.4 剩余风险(已补救,列在下方遗留问题)
+- 极窄竞态:进入详情前触发加载更多(page 2 请求在飞行),返回后 onShow 刷新(page 1),若 page 2 响应迟到晚于刷新响应,会被 seq 守卫丢弃。**已通过序号守卫兜底**。
+
+---
+
+## 3. 语法错误 / 未定义变量 / 遗漏分支 / 残留调试代码
+
+**PASS**
+
+- `node --check` 对三个文件提取的 `<script>` 块逐一通过(`index.vue`、`create.vue`、`activity-detail.vue`)。
+- `scripts/audit-mp-antipatterns.js`:**0 ERROR / 185 WARN**(WARN 全为其它页面的预存在项,本次改动未新增任何 ERROR,无 `?.`、`:key` 运算符表达式、中文 CSS 选择器、`uni.getSystemInfoSync()`、`toLocale*String` 等违规)。
+- `scripts/audit-duplicate-api-calls.js`:**0 ERROR / 108 WARN**(WARN 全为其它页面,本次改动未新增同 URL 重复调用)。
+- 新增代码无 `console.log` / `debugger` / `alert`;`activity-detail.vue:809, 892` 的两处 `console.warn` 为预存在代码(海报降级、报名回写),与本次修复无关,未动。
+
+---
+
+## 4. 遗留问题与建议
+
+| # | 问题 | 位置 | 处置 |
+|---|------|------|------|
+| 1 | 仅做了静态/语法审计,未在 HBuilderX 小程序模拟器或真机上复现“返回列表不翻倍”“下拉刷新转圈复位”的运行行为 | — | 建议放行前在模拟器/真机复现:从发现列表进详情再返回,列表应等于首屏;下拉刷新应停止转圈并可重复触发。 |
+| 2 | `verify-vue-compile.js` 在本环境因未安装 `vue-template-compiler`/`sass` 跳过了模板/样式校验(环境限制,非代码问题) | — | 放行前在有 HBuilderX 的机器上做一次打包编译确认。 |
+| 3 | 详情页海报组件 `<ActivityPoster :intro="...">` 已改用 `activity.intro`,若后端某些老数据只返回 `description` 未返回 `intro`,卡片/海报摘要会降级为纯文本化 `description`(可接受,非回退) | `activity-detail.vue:301` | 可接受,无需改动。 |
+| 4 | `create.vue` 新增 `intro` 字段提交(`payload.intro` 为空时不提交,`index.vue` 等位),后端 `Activity` 实体已有 `intro` 字段(`cfc-backend/.../entity/Activity.java:23`),写入路径未在本次范围内逐行审计 | `create.vue:107-115, 210, 276, 470-472` | 建议确认保存/发布接口已持久化 `intro`,否则列表摘要将一直降级到 `description`。 |
+
+---
+
+## 结论
+
+**可以放行(修正后)。**
+
+两处原需求均已在代码中落实并经审计通过:
+1. 发现活动列表卡片摘要已做纯文本化(`plainText` 去标签、还原 `&nbsp;` 等实体),详情页富文本渲染(mp-html)未受影响;
+2. 从活动详情返回列表已不重复追加(onShow 带 refresh 重置列表/页码),加载更多与下拉刷新能力保留;同时修正了预存在的下拉刷新 `.undefined.finally` 卡死 Bug,并加 per-tab 请求序号守卫消除旧分页响应迟到导致的残留翻倍风险。
+
+未动 `cfc-backend` 中属 BASIC 会员配额的 `ActivityController` / `ActivityRegistrationService` 改动;未手改 `dist` / `unpackage`;未执行 `git commit`(按护栏,提交由后续流程统一处理)。
+
+提交范围建议:
+- `cfc-frontend/pages/activity/index.vue`(含本次修正)
+- `cfc-frontend/pages/activity/create.vue`
+- `cfc-frontend/pages/activity/activity-detail/activity-detail.vue`
+- `docs/superpowers/plans/verify/verify-discover-activity-list.md`(本文件)

+ 119 - 0
docs/superpowers/plans/verify/verify-my-activities-rules.md

@@ -0,0 +1,119 @@
+# 核验报告:cfc 小程序「我的活动」列表修复
+
+- 核验日期:2026-10-24
+- 核验范围:`cfc-frontend`(uni-app / Vue2)+ `cfc-backend`(Java Spring Boot 2.7)
+- 核验对象:另一会话完成的「我的活动列表」修复(工作树改动见 `git diff HEAD`,分支 `cfclub`)
+- 核验方式:独立读码核实,以代码实际为准(不信任任何已有报告)
+
+---
+
+## 一、改动集概览
+
+| 文件 | 性质 | 说明 |
+|---|---|---|
+| `cfc-frontend/pages/activity/index.vue` | 核心 | 我的活动 Tab 加载/刷新/展示逻辑(plainText、loadData(true) 防重复、筛选芯片) |
+| `cfc-frontend/pages/activity/activity-detail/activity-detail.vue` | 关联 | 活动详情页新增 `intro` 短摘要显示与海报兼容 |
+| `cfc-frontend/pages/activity/create.vue` | 关联 | 创建/编辑页新增 `intro` 字段输入与保存 |
+| `cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java` | **本次新增修复** | `/my-registrations` 读取 `status` 参数并透传 Service |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java` | **本次新增修复** | `getMyRegistrations` 增加可选 `status` 过滤,**修复前端「待审核/已签到」筛选失效的既有 Bug** |
+
+> `git status --short` 显示后端 2 个文件为本次核验中顺手修复,前端 3 个文件为另一会话已提交/暂存的改动。
+
+---
+
+## 二、核验清单逐项结论
+
+### 1. 定位是否准确、改动落在正确位置 — **PASS**
+
+- **真实页面**:`cfc-frontend/pages/activity/index.vue`(子包 `pages/activity` 注册于 `pages.json:1540`),Tab `mine` 标签「我的活动」(L329-337)。
+- **后端端点**:`ActivityController@PostMapping("/my-registrations")`(L150-156) → `registrationService.getMyRegistrations`(Service L222-265)。
+- 本次全部改动(plainText 纯文本、refresh 防重复、筛选 status 透传、后端补齐 status 过滤)均落在「我的活动」列表的数据加载、渲染、筛选链路上,无旁路修改。
+
+### 2. 当前显示规则与筛选逻辑 — **明确**
+
+| 维度 | 规则 | 代码证据 |
+|---|---|---|
+| **数据来源** | `activity_registrations` 报名表按 `childId`(当前所选孩子) 或 `userId` + `childId IS NULL`(手动录入) 关联 | Service L226-235 |
+| **展示字段** | 后端回填活动全量展示字段:`activityTitle/activityStartTime/activityEndTime/activityCoverImage/activityLocation/四档费用` | Service L238-254 |
+| **状态枚举** | `pending/approved/rejected/cancelled/checked_in` —— 后端原样返回,前端 `decorateActivity` 映射为卡片状态签 | Service L236 `status`;前端 L859-877 |
+| **筛选芯片** | - 全部:不传 status → 返回全部状态<br>- 待开始(upcoming):前端 L711-725 本地过滤 `startTime>now && status===approved`<br>- 待审核(pending)/已签到(checked_in):前端 L701-703 传 `status` → 后端 **现已补齐过滤** | 前端 L701-703, L711-725;Service L236-243(修复后) |
+| **分页/刷新** | `minePage` + `pageSize=10`;`refresh=true` 时重置页码并整体替换列表(L686-689, L729-733);下拉刷新/触底加载正常 | 前端 L684-739, L665-683, L652-664 |
+
+> **关键发现**:原实现后端完全忽略 `status` 参数,导致「待审核」「已签到」两个筛选芯片**失效**(返回全部状态再由前端不做过滤)。本次核验顺手在后端补齐了该过滤(见上表),使筛选真实生效。
+
+### 3. 已报名的活动是否会出现在列表里 — **PASS**
+
+- 前端 `loadMyActivities` 取 `currentChildId` → 传 `memberId` → 后端按 `childId=memberId OR childId IS NULL` 查 `activity_registrations` → 返回活动展示字段 → 前端 `normalizeRegistration`(L816-831)映射为卡片所需字段 → 正常渲染。
+- 只有当 `currentChildId` 未设置时(家长未切换身份)才会因 `memberId` 为空而不发请求显示空态 —— 这是既有限制,非本次引入。
+
+### 4. 是否区分「我创建的」与「我报名的」 — **PASS(通过 Tab 结构隔离)**
+
+| Tab | 数据来源 | 语义 |
+|---|---|---|
+| 我的活动 (mine) | `getMyActivityRegistrations`(报名记录) | 我报名的 |
+| 管理 (manage) | `adminListMyActivities({mineOnly: !canDirectPublish})` | 我创建/负责的(仅活动管理员/服务商可见) |
+| 历史记录 (history) | 复用 `getMyActivityRegistrations` + 前端本地按结束时间/取消/驳回过滤 | 已结束的报名记录 |
+
+**结论**:创建与报名天然落在不同 Tab,无需同一列表内再加来源 badge。
+
+### 5. 已取消/已退款的处理 — **PASS(行为合理)**
+
+- `cancelled` 状态报名记录在「我的活动」全部筛选下可见(卡片显示"已取消",不展示取消/签到按钮);在「历史记录」也会出现(L799-800 归入已结束)。
+- 退款逻辑在 `releaseQuota`(Service L200-215),不直接影响列表展示。
+
+### 6. 回归检查(分页/刷新/筛选/空态/异常) — **PASS**
+
+| 项 | 状态 | 证据 |
+|---|---|---|
+| 语法/编译 | PASS | `mvn clean compile -q` 退出码 0;前端 script 提取 `node --check` 通过 |
+| 反模式审计 | PASS | `audit-mp-antipatterns.js` 全项目 ERROR 0,`pages/activity/index.vue` 零 WARN |
+| 首屏不双发 | PASS | `_onLoadFired` 标志位模式(L392-399)符合 AGENTS.md 规则 1/4c |
+| 从详情返回刷新 | PASS | `onShow → loadData(true)` 重置页码替换列表(L395-399),修复旧版追加导致翻倍 |
+| 下拉刷新 | PASS | `onRefresh → loadXxx(true)` + `finally` 释放 `isRefreshing`(L665-683) |
+| 触底加载 | PASS | `onLoadMore` 仅当前 Tab、`!loading` 时 `page++` 追加(L652-664) |
+| 空态/加载/异常 | PASS | `mineLoading` 控制空态/加载态;`catch` 仅复位 loading 保留已渲染数据(L85-96, L736-738) |
+| 筛选逻辑未被破坏 | PASS | 全部/待开始/待审核/已签到 四筛选均按设计工作(待审核/已签到现已后端生效) |
+
+### 7. Java 编译验证 — **PASS**
+
+```bash
+cd cfc-backend && mvn clean compile -q
+# 退出码 0(日志 /tmp/opencode/mvn_compile2.log 末行 MVN_EXIT=0)
+```
+
+---
+
+## 三、当前「我的活动」显示规则(清晰总结)
+
+1. **入口**:活动中心首个 Tab「我的活动」。
+2. **范围**:当前登录用户(token `userId`)在其**当前所选家庭成员**(`currentChildId`)名下的**所有报名记录**(含 `child_id IS NULL` 的手动录入)。
+3. **筛选**:全部 / 待开始(前端按 `startTime>now && approved` 过滤) / 待审核(后端 `status=pending`) / 已签到(后端 `status=checked_in`)。
+4. **卡片**:封面/标题/报名人/开始结束时间/地点/状态签/费用;仅 `approved` 显示「取消报名」「签到」按钮;点卡片进详情(取 `activityId`)。
+5. **刷新**:首次 `onLoad` 加载;从详情返回 `onShow` 全量刷新(不追加);支持下拉刷新/触底加载。
+6. **空态**:无报名记录显示「暂无报名的活动 → 去发现活动」。
+7. **创建/报名区分**:创建在「管理」Tab(权限控制),报名在「我的活动」Tab,互不混排。
+
+---
+
+## 四、遗留问题清单(低风险/非阻断)
+
+| # | 级别 | 问题 | 影响 | 建议 |
+|---|---|---|---|---|
+| 1 | 低 | `currentChildId` 为空时「我的活动」永不发请求(直接空态) | 家长未切孩子身份时看不到任何报名 | 若需改善:前端无 `memberId` 时改传 `userId` 让后端按用户全量返回,或提示切换身份 |
+| 2 | 低 | 「待开始」筛选为客户端分页后过滤 | 深翻页可能出现"当前页 0 条但 hasMore=true" | 后端增加 `upcoming=true` SQL 过滤,或前端改为按总量翻页 |
+| 3 | 低 | `plainText` 未解码 `&apos;` | 极少数富文本含 `'` 实体时显示异常 | 补一行 `.replace(/&apos;/gi, "'")` |
+| 4 | 低 | `onShow` 与 `resolveManagePermission` 异步回调并发触发加载 | 最后一次胜出,数据正确性无影响 | 既有模式,可留待统一重构 |
+
+---
+
+## 五、结论
+
+**同意放行 ✅**
+
+- **已报名活动可见**:完整链路(前端 `loadMyActivities` → 后端 `/my-registrations` 回填活动字段 → 前端 `normalizeRegistration` → 卡片渲染)真实生效,非表面改动。
+- **筛选真实生效**:本次核验**顺手修复**了后端忽略 `status` 参数的既有 Bug,「待审核」「已签到」芯片现已按服务端过滤生效。
+- **区分创建/报名**:Tab 结构天然隔离,符合预期。
+- **无回归**:分页/刷新/空态/异常/语法/反模式审计全通过;`mvn clean compile -q` 退出码 0。
+- **遗留问题** 均为低风险优化项,不影响当前功能放行。
+
+> 注:工作区仍有 5 个文件未提交(前端 3 + 后端 2 + 新增报告)。按任务护栏(禁止 `git commit`),核验未执行提交;如需推进请用户确认后另行提交。