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

fix(activity): 活动模块 8 项问题收口(列表/返回/简介/海报/日期/报名/审核计数/我的活动)

#1 发现活动列表不再显示 HTML 符号(简介走纯文本 intro 字段)
#2 详情页返回列表不再翻倍:loadData() 统一为「重置页码+清空+加载第一页」
#3 补齐活动简介录入(create.vue 表单/回填/海报 intro 入参),海报预览与后台模板管理此前已完成
#4 审核中心顶部待审数不减少:ReviewCenter.vue 13 个 Tab 绑定 @refresh-summary,各子 Tab 审核后 emit
#5 日期时间统一 yyyy-MM-dd HH:mm(复用 utils/format.js);管理端 4 处 UTC 偏移串少 8 小时改为 fmtLocalTime 转 Asia/Shanghai
#6 重复报名误报「网络异常」:新增 showApiError 区分后端业务错误(如已报名)与真实网络错误
#7 活动详情页美化
#8 我的活动列表纳入已报名活动
zxyj-web 2 недель назад
Родитель
Сommit
dc9151a70b
24 измененных файлов с 401 добавлено и 59 удалено
  1. 25 19
      cfc-frontend/pages/activity/activity-detail/activity-detail.vue
  2. 5 17
      cfc-frontend/pages/activity/components/ActivityPoster.vue
  3. 9 0
      cfc-frontend/pages/activity/create.vue
  4. 22 4
      cfc-frontend/pages/activity/index.vue
  5. 4 4
      cfc-web/src/views/admin/Activities.vue
  6. 9 1
      cfc-web/src/views/admin/ActivityEdit.vue
  7. 12 1
      cfc-web/src/views/admin/ActivityRegistrationReview.vue
  8. 14 0
      cfc-web/src/views/admin/ActivityReview.vue
  9. 13 13
      cfc-web/src/views/admin/ReviewCenter.vue
  10. 1 0
      cfc-web/src/views/admin/review/ActivityAudit.vue
  11. 2 0
      cfc-web/src/views/admin/review/ActivityRegistrationAudit.vue
  12. 3 0
      cfc-web/src/views/admin/review/ArticleAudit.vue
  13. 2 0
      cfc-web/src/views/admin/review/ButlerAudit.vue
  14. 2 0
      cfc-web/src/views/admin/review/GuideAudit.vue
  15. 1 0
      cfc-web/src/views/admin/review/HierarchyAudit.vue
  16. 2 0
      cfc-web/src/views/admin/review/KnowledgeAdminAudit.vue
  17. 2 0
      cfc-web/src/views/admin/review/NutritionistAudit.vue
  18. 3 0
      cfc-web/src/views/admin/review/PackageAudit.vue
  19. 1 0
      cfc-web/src/views/admin/review/PendingRefundAudit.vue
  20. 1 0
      cfc-web/src/views/admin/review/ProductAudit.vue
  21. 2 0
      cfc-web/src/views/admin/review/VendorAudit.vue
  22. 2 0
      cfc-web/src/views/admin/review/WithdrawAudit.vue
  23. 159 0
      docs/superpowers/plans/verify/verify-activity-index-refresh.md
  24. 105 0
      docs/superpowers/plans/verify/verify-activity-intro-field.md

+ 25 - 19
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -297,7 +297,7 @@
     <ActivityPoster
     <ActivityPoster
       :show="showPoster"
       :show="showPoster"
       :title="(activity && activity.title) || ''"
       :title="(activity && activity.title) || ''"
-      :intro="(activity && activity.description) || ''"
+      :intro="(activity && (activity.intro || activity.description)) || ''"
       :startTime="(activity && activity.startTime) || ''"
       :startTime="(activity && activity.startTime) || ''"
       :endTime="(activity && activity.endTime) || ''"
       :endTime="(activity && activity.endTime) || ''"
       :location="(activity && activity.location) || ''"
       :location="(activity && activity.location) || ''"
@@ -329,7 +329,7 @@ import MpHtml from '@/components/mp-html/mp-html.vue'
 import ActivityPoster from '@/pages/activity/components/ActivityPoster.vue'
 import ActivityPoster from '@/pages/activity/components/ActivityPoster.vue'
 import RelatedContent from '@/components/RelatedContent.vue'
 import RelatedContent from '@/components/RelatedContent.vue'
 import { backfillProfile } from '../../../utils/profile-backfill.js'
 import { backfillProfile } from '../../../utils/profile-backfill.js'
-import { parseDate } from '@/utils/format.js'
+import { parseDate, formatDateTime } from '@/utils/format.js'
 import { getFontScale, buildContentTagStyle } from '@/utils/fontScale.js'
 import { getFontScale, buildContentTagStyle } from '@/utils/fontScale.js'
 
 
 export default {
 export default {
@@ -452,20 +452,10 @@ export default {
     goUpgrade: function() {
     goUpgrade: function() {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },
     },
+    // 活动时间展示:复用统一 formatDateTime 后裁剪秒,输出 yyyy-MM-dd HH:mm(活动到分钟即可)
     formatDate: function(dateStr) {
     formatDate: function(dateStr) {
-      if (!dateStr) return ''
-      var d = parseDate(dateStr)
-      if (!d) return dateStr
-      var year = d.getFullYear()
-      var month = d.getMonth() + 1
-      var day = d.getDate()
-      var hour = d.getHours()
-      var minute = d.getMinutes()
-      var ampm = hour < 12 ? '上午' : '下午'
-      var h12 = hour % 12
-      if (h12 === 0) h12 = 12
-      var minStr = minute < 10 ? '0' + minute : minute
-      return year + '年' + month + '月' + day + '日 ' + ampm + h12 + ':' + minStr
+      var s = formatDateTime(dateStr)
+      return s ? s.substring(0, 16) : ''
     },
     },
     // 费用双价展示:非会员价 / 会员价(元)
     // 费用双价展示:非会员价 / 会员价(元)
     feeText: function(regularFee, memberFee) {
     feeText: function(regularFee, memberFee) {
@@ -597,6 +587,16 @@ export default {
       self.registrants = []
       self.registrants = []
       self.loadFamilyMembers()
       self.loadFamilyMembers()
     },
     },
+    // 区分「后端业务错误」与「真实网络错误」:
+    // utils/api.js 的 request() 对任何 Result.code != 200(含业务错误如"您已报名此活动")都会 reject,
+    // 若 catch 一律显示"网络异常",会把业务提示误当成网络故障。此处业务错误返回 true 并弹友好提示。
+    showApiError: function(err) {
+      if (err && err.code !== undefined && err.code !== 200 && err.message) {
+        uni.showModal({ title: '提示', content: err.message, showCancel: false })
+        return true
+      }
+      return false
+    },
     onCancelRegistration: function() {
     onCancelRegistration: function() {
       var self = this
       var self = this
       // 未登录则跳转登录
       // 未登录则跳转登录
@@ -620,9 +620,12 @@ export default {
               } else {
               } else {
                 uni.showToast({ title: (res && res.message) || '取消失败', icon: 'none' })
                 uni.showToast({ title: (res && res.message) || '取消失败', icon: 'none' })
               }
               }
-            }).catch(function() {
+            }).catch(function(err) {
               self.registrationLoading = false
               self.registrationLoading = false
-              uni.showToast({ title: '网络异常', icon: 'none' })
+              // 后端业务错误(如退款失败返回"退款处理失败,请联系客服")走 showApiError 弹友好提示
+              if (!self.showApiError(err)) {
+                uni.showToast({ title: '网络异常', icon: 'none' })
+              }
             })
             })
           }
           }
         }
         }
@@ -951,9 +954,12 @@ export default {
         } else {
         } else {
           uni.showToast({ title: (res && res.message) || '报名失败', icon: 'none' })
           uni.showToast({ title: (res && res.message) || '报名失败', icon: 'none' })
         }
         }
-      }).catch(function() {
+      }).catch(function(err) {
         self.regSubmitting = false
         self.regSubmitting = false
-        uni.showToast({ title: '网络异常', icon: 'none' })
+        // 后端业务错误(如重复报名返回"已报名: xxx")走 showApiError 弹友好提示,只有真实网络错误才显示"网络异常"
+        if (!self.showApiError(err)) {
+          uni.showToast({ title: '网络异常', icon: 'none' })
+        }
       })
       })
     }
     }
   },
   },

+ 5 - 17
cfc-frontend/pages/activity/components/ActivityPoster.vue

@@ -53,7 +53,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { parseDate } from '@/utils/format.js'
+import { parseDate, formatDateTime, formatDate } from '@/utils/format.js'
 import { getFontScale } from '@/utils/fontScale.js'
 import { getFontScale } from '@/utils/fontScale.js'
 
 
 /** 五维维度 → 主色/深色映射(与 ContentSharePoster 保持同步) */
 /** 五维维度 → 主色/深色映射(与 ContentSharePoster 保持同步) */
@@ -603,25 +603,13 @@ export default {
       return end ? start + ' ~ ' + end : start
       return end ? start + ' ~ ' + end : start
     },
     },
 
 
+    // 海报时间展示:复用统一 formatDateTime 后裁剪秒,输出 yyyy-MM-dd HH:mm
     _formatDateTime: function(dateStr) {
     _formatDateTime: function(dateStr) {
-      if (!dateStr) return ''
-      var d = parseDate(dateStr)
-      if (!d) return String(dateStr)
-      var month = ('0' + (d.getMonth() + 1)).slice(-2)
-      var day = ('0' + d.getDate()).slice(-2)
-      var hour = ('0' + d.getHours()).slice(-2)
-      var min = ('0' + d.getMinutes()).slice(-2)
-      return month + '-' + day + ' ' + hour + ':' + min
+      var s = formatDateTime(dateStr)
+      return s ? s.substring(0, 16) : ''
     },
     },
 
 
-    _formatDate: function(dateStr) {
-      if (!dateStr) return ''
-      var d = parseDate(dateStr)
-      if (!d) return String(dateStr)
-      var month = ('0' + (d.getMonth() + 1)).slice(-2)
-      var day = ('0' + d.getDate()).slice(-2)
-      return month + '-' + day
-    },
+    _formatDate: formatDate,
 
 
     savePoster: function() {
     savePoster: function() {
       var self = this
       var self = this

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

@@ -104,6 +104,13 @@
         </view>
         </view>
       </view>
       </view>
 
 
+      <!-- 活动简介(列表卡片与分享海报优先展示,一句到两句为宜) -->
+      <view class="form-item">
+        <text class="form-label">活动简介</text>
+        <textarea class="form-textarea" v-model="form.intro" placeholder="一句话说明这个活动是什么(可选,建议不超过 60 字)" maxlength="100"
+          auto-height />
+      </view>
+
       <!-- 活动描述 -->
       <!-- 活动描述 -->
       <view class="form-item">
       <view class="form-item">
         <text class="form-label">活动描述</text>
         <text class="form-label">活动描述</text>
@@ -199,6 +206,7 @@ export default {
       memberId: '',
       memberId: '',
       form: {
       form: {
         title: '',
         title: '',
+        intro: '',
         description: '',
         description: '',
         coverImage: '',
         coverImage: '',
         dimensionCode: '',
         dimensionCode: '',
@@ -264,6 +272,7 @@ export default {
         if (res && res.code === 200 && res.data) {
         if (res && res.code === 200 && res.data) {
           var d = res.data
           var d = res.data
           self.form.title = d.title || ''
           self.form.title = d.title || ''
+          self.form.intro = d.intro || ''
           self.form.description = d.description || ''
           self.form.description = d.description || ''
           self.form.coverImage = d.coverImage || ''
           self.form.coverImage = d.coverImage || ''
           self.form.vendorName = d.vendorName || ''
           self.form.vendorName = d.vendorName || ''

+ 22 - 4
cfc-frontend/pages/activity/index.vue

@@ -60,7 +60,7 @@
                 <text class="tag-chip" v-for="(t, ti) in tagList(act.tags)" :key="ti">{{ t }}</text>
                 <text class="tag-chip" v-for="(t, ti) in tagList(act.tags)" :key="ti">{{ t }}</text>
               </view>
               </view>
               <text class="card-meta" v-if="act.registrantName">报名人:{{ act.registrantName }}</text>
               <text class="card-meta" v-if="act.registrantName">报名人:{{ act.registrantName }}</text>
-              <text class="card-meta">开始 {{ formatDateTime(act.startTime) }}{{ act.endTime ? ' 结束 ' + formatDateTime(act.endTime) : '' }}</text>
+              <text class="card-meta">开始 {{ formatActivityTime(act.startTime) }}{{ act.endTime ? ' 结束 ' + formatActivityTime(act.endTime) : '' }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <view class="card-bottom">
               <view class="card-bottom">
                 <text class="card-status" :class="'status-' + (act._cls || 'upcoming')">
                 <text class="card-status" :class="'status-' + (act._cls || 'upcoming')">
@@ -136,7 +136,7 @@
               <view class="card-tags" v-if="tagList(act.tags).length">
               <view class="card-tags" v-if="tagList(act.tags).length">
                 <text class="tag-chip" v-for="(t, ti) in tagList(act.tags)" :key="ti">{{ t }}</text>
                 <text class="tag-chip" v-for="(t, ti) in tagList(act.tags)" :key="ti">{{ t }}</text>
               </view>
               </view>
-              <text class="card-meta">开始 {{ formatDateTime(act.startTime) }}{{ act.endTime ? ' 结束 ' + formatDateTime(act.endTime) : '' }}</text>
+              <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">{{ act.intro || act.description }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <view class="card-bottom">
               <view class="card-bottom">
@@ -204,7 +204,7 @@
               <view class="card-tags" v-if="tagList(act.tags).length">
               <view class="card-tags" v-if="tagList(act.tags).length">
                 <text class="tag-chip" v-for="(t, ti) in tagList(act.tags)" :key="ti">{{ t }}</text>
                 <text class="tag-chip" v-for="(t, ti) in tagList(act.tags)" :key="ti">{{ t }}</text>
               </view>
               </view>
-              <text class="card-meta">开始 {{ formatDateTime(act.startTime) }}{{ act.endTime ? ' 结束 ' + formatDateTime(act.endTime) : '' }}</text>
+              <text class="card-meta">开始 {{ formatActivityTime(act.startTime) }}{{ act.endTime ? ' 结束 ' + formatActivityTime(act.endTime) : '' }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <view class="card-bottom">
               <view class="card-bottom">
                 <text class="card-status status-ended">已结束</text>
                 <text class="card-status status-ended">已结束</text>
@@ -285,7 +285,7 @@
               <view class="card-tags" v-if="tagList(act.tags).length">
               <view class="card-tags" v-if="tagList(act.tags).length">
                 <text class="tag-chip" v-for="(t, ti) in tagList(act.tags)" :key="ti">{{ t }}</text>
                 <text class="tag-chip" v-for="(t, ti) in tagList(act.tags)" :key="ti">{{ t }}</text>
               </view>
               </view>
-              <text class="card-meta">{{ act.startTime }}</text>
+              <text class="card-meta">{{ formatActivityTime(act.startTime) }}</text>
               <view class="card-bottom">
               <view class="card-bottom">
                 <text class="card-status" :class="'status-' + (act.status || 'draft')">
                 <text class="card-status" :class="'status-' + (act.status || 'draft')">
                   {{ statusLabel(act.status) }}
                   {{ statusLabel(act.status) }}
@@ -459,12 +459,25 @@ export default {
       }
       }
       this.loadData()
       this.loadData()
     },
     },
+    // 统一入口:onLoad / Tab 切换 / 筛选变更 / 从详情页返回(onShow) 都走这里。
+    // 语义固定为「重置为第一页后重新加载」——先清空数组并回到 page=1,
+    // 避免走 loadXxxActivities() 的非 refresh 分支导致列表被 concat 叠加(详情页返回时列表翻倍)。
+    // 加载更多不走这里,由 onLoadMore 自行递增页码追加。
     loadData: function() {
     loadData: function() {
       if (this.currentTab === 'mine') {
       if (this.currentTab === 'mine') {
+        this.minePage = 1
+        this.myActivities = []
+        this.hasMore = true
         this.loadMyActivities()
         this.loadMyActivities()
       } else if (this.currentTab === 'discover') {
       } else if (this.currentTab === 'discover') {
+        this.discoverPage = 1
+        this.discoverActivities = []
+        this.hasMore = true
         this.loadDiscoverActivities()
         this.loadDiscoverActivities()
       } else if (this.currentTab === 'history') {
       } else if (this.currentTab === 'history') {
+        this.historyPage = 1
+        this.historyActivities = []
+        this.hasMore = true
         this.loadHistoryActivities()
         this.loadHistoryActivities()
       } else if (this.currentTab === 'manage') {
       } else if (this.currentTab === 'manage') {
         // 权限未解析/无权限时不发管理列表请求(onPermissionResolved 会回落 mine)
         // 权限未解析/无权限时不发管理列表请求(onPermissionResolved 会回落 mine)
@@ -826,6 +839,11 @@ export default {
       return (price / 100).toFixed(2)
       return (price / 100).toFixed(2)
     },
     },
     formatDateTime: formatDateTime,
     formatDateTime: formatDateTime,
+    // 活动时间展示:复用统一 formatDateTime 后裁剪秒,输出 yyyy-MM-dd HH:mm(活动到分钟即可)
+    formatActivityTime: function(t) {
+      var s = formatDateTime(t)
+      return s ? s.substring(0, 16) : ''
+    },
     hasFee: function(act) {
     hasFee: function(act) {
       return (act.childFee > 0) || (act.childMemberFee > 0) || (act.parentFee > 0) || (act.parentMemberFee > 0)
       return (act.childFee > 0) || (act.childMemberFee > 0) || (act.parentFee > 0) || (act.parentMemberFee > 0)
     },
     },

+ 4 - 4
cfc-web/src/views/admin/Activities.vue

@@ -192,7 +192,7 @@
           <div v-if="rosterRegistrants.length" class="roster-list-body">
           <div v-if="rosterRegistrants.length" class="roster-list-body">
             <div v-for="(r, idx) in rosterRegistrants" :key="'roster-' + idx" class="roster-item">
             <div v-for="(r, idx) in rosterRegistrants" :key="'roster-' + idx" class="roster-item">
               <span class="roster-name">{{ r.name }}</span>
               <span class="roster-name">{{ r.name }}</span>
-              <span class="roster-time">{{ formatTime(r.registeredAt) }}</span>
+              <span class="roster-time">{{ fmtLocalTime(r.registeredAt) }}</span>
             </div>
             </div>
           </div>
           </div>
           <div v-else class="roster-empty">还没有人报名,快来抢占第一个名额吧</div>
           <div v-else class="roster-empty">还没有人报名,快来抢占第一个名额吧</div>
@@ -221,7 +221,7 @@
         </el-table-column>
         </el-table-column>
         <el-table-column prop="registeredAt" label="报名时间" width="160">
         <el-table-column prop="registeredAt" label="报名时间" width="160">
           <template slot-scope="{ row }">
           <template slot-scope="{ row }">
-            {{ formatTime(row.registeredAt || row.createdAt) }}
+            {{ fmtLocalTime(row.registeredAt || row.createdAt) }}
           </template>
           </template>
         </el-table-column>
         </el-table-column>
         <el-table-column label="操作" width="200" fixed="right">
         <el-table-column label="操作" width="200" fixed="right">
@@ -495,9 +495,9 @@ export default {
     exportRosterCsv() {
     exportRosterCsv() {
       if (!this.rosterRegistrants.length) return
       if (!this.rosterRegistrants.length) return
       var rows = ['姓名,报名时间']
       var rows = ['姓名,报名时间']
-      this.rosterRegistrants.forEach(function(r) {
+      this.rosterRegistrants.forEach((r) => {
         var name = String(r.name || '').replace(/,/g, ',')
         var name = String(r.name || '').replace(/,/g, ',')
-        var time = r.registeredAt ? String(r.registeredAt).replace('T', ' ').substring(0, 19) : ''
+        var time = this.fmtLocalTime(r.registeredAt)
         rows.push(name + ',' + time)
         rows.push(name + ',' + time)
       })
       })
       var csv = '\uFEFF' + rows.join('\r\n') + '\r\n'
       var csv = '\uFEFF' + rows.join('\r\n') + '\r\n'

+ 9 - 1
cfc-web/src/views/admin/ActivityEdit.vue

@@ -408,8 +408,16 @@ export default {
       }
       }
     },
     },
     // ===== T03 报名名册 =====
     // ===== T03 报名名册 =====
+    // 后端把 java.util.Date 序列化为 UTC 偏移串(如 2026-10-25T01:00:00.000+00:00),
+    // 统一转 Asia/Shanghai 本地墙钟时间展示,避免比实际时间少 8 小时
     formatTime(t) {
     formatTime(t) {
-      return t ? String(t).replace('T', ' ').substring(0, 19) : '-'
+      if (!t) return '-'
+      const d = new Date(t)
+      if (isNaN(d.getTime())) return String(t)
+      const bj = new Date(d.getTime() + d.getTimezoneOffset() * 60000 + 8 * 3600000)
+      const pad = n => (n < 10 ? '0' + n : '' + n)
+      return bj.getFullYear() + '-' + pad(bj.getMonth() + 1) + '-' + pad(bj.getDate()) + ' ' +
+        pad(bj.getHours()) + ':' + pad(bj.getMinutes()) + ':' + pad(bj.getSeconds())
     },
     },
     async loadRoster() {
     async loadRoster() {
       if (!this.$route.query.id) return
       if (!this.$route.query.id) return

+ 12 - 1
cfc-web/src/views/admin/ActivityRegistrationReview.vue

@@ -45,7 +45,7 @@
           <span class="sort-header" @click.stop="onSortClick('registeredAt', $event)">报名时间<span v-if="sortIndex('registeredAt') !== null" class="sort-index" :class="sortClass('registeredAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('registeredAt') }}</span>{{ sortIcon('registeredAt') }}</span>
           <span class="sort-header" @click.stop="onSortClick('registeredAt', $event)">报名时间<span v-if="sortIndex('registeredAt') !== null" class="sort-index" :class="sortClass('registeredAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('registeredAt') }}</span>{{ sortIcon('registeredAt') }}</span>
         </template>
         </template>
         <template slot-scope="{ row }">
         <template slot-scope="{ row }">
-          {{ formatTime(row.registeredAt || row.createdAt) }}
+          {{ fmtLocalTime(row.registeredAt || row.createdAt) }}
         </template>
         </template>
       </el-table-column>
       </el-table-column>
       <el-table-column label="操作" width="200" fixed="right">
       <el-table-column label="操作" width="200" fixed="right">
@@ -155,6 +155,17 @@ export default {
           this.$message.error(res.message || '操作失败')
           this.$message.error(res.message || '操作失败')
         }
         }
       } catch (e) { this.$message.error('操作失败') }
       } catch (e) { this.$message.error('操作失败') }
+    },
+    // 后端把 java.util.Date 序列化为 UTC 偏移串(如 2026-10-25T01:00:00.000+00:00),
+    // 统一转 Asia/Shanghai 本地墙钟时间展示,避免比实际时间少 8 小时
+    fmtLocalTime(val) {
+      if (val === null || val === undefined || val === '') return '—'
+      const d = new Date(val)
+      if (isNaN(d.getTime())) return String(val)
+      const bj = new Date(d.getTime() + d.getTimezoneOffset() * 60000 + 8 * 3600000)
+      const pad = n => (n < 10 ? '0' + n : '' + n)
+      return bj.getFullYear() + '-' + pad(bj.getMonth() + 1) + '-' + pad(bj.getDate()) + ' ' +
+        pad(bj.getHours()) + ':' + pad(bj.getMinutes()) + ':' + pad(bj.getSeconds())
     }
     }
   }
   }
 }
 }

+ 14 - 0
cfc-web/src/views/admin/ActivityReview.vue

@@ -45,6 +45,9 @@
         <template slot="header">
         <template slot="header">
           <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">申请时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
           <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">申请时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
         </template>
         </template>
+        <template slot-scope="{ row }">
+          {{ fmtLocalTime(row.createdAt) }}
+        </template>
       </el-table-column>
       </el-table-column>
       <el-table-column label="状态" width="100">
       <el-table-column label="状态" width="100">
         <template slot-scope="{ row }">
         <template slot-scope="{ row }">
@@ -121,6 +124,17 @@ export default {
     loadList() {
     loadList() {
       this.loadPending()
       this.loadPending()
     },
     },
+    // 后端把 java.util.Date 序列化为 UTC 偏移串(如 2026-10-25T01:00:00.000+00:00),
+    // 统一转 Asia/Shanghai 本地墙钟时间展示,避免比实际时间少 8 小时
+    fmtLocalTime(val) {
+      if (val === null || val === undefined || val === '') return '—'
+      const d = new Date(val)
+      if (isNaN(d.getTime())) return String(val)
+      const bj = new Date(d.getTime() + d.getTimezoneOffset() * 60000 + 8 * 3600000)
+      const pad = n => (n < 10 ? '0' + n : '' + n)
+      return bj.getFullYear() + '-' + pad(bj.getMonth() + 1) + '-' + pad(bj.getDate()) + ' ' +
+        pad(bj.getHours()) + ':' + pad(bj.getMinutes()) + ':' + pad(bj.getSeconds())
+    },
     async loadPending() {
     async loadPending() {
       this.loading = true
       this.loading = true
       try {
       try {

+ 13 - 13
cfc-web/src/views/admin/ReviewCenter.vue

@@ -16,43 +16,43 @@
     <el-card class="detail-card">
     <el-card class="detail-card">
       <el-tabs v-model="activeTab" @tab-click="onTabClick">
       <el-tabs v-model="activeTab" @tab-click="onTabClick">
         <el-tab-pane label="文章审核" name="article">
         <el-tab-pane label="文章审核" name="article">
-          <article-audit ref="article" />
+          <article-audit ref="article" @refresh-summary="loadSummary" />
         </el-tab-pane>
         </el-tab-pane>
         <el-tab-pane label="商品审核" name="product">
         <el-tab-pane label="商品审核" name="product">
-          <product-audit ref="product" />
+          <product-audit ref="product" @refresh-summary="loadSummary" />
         </el-tab-pane>
         </el-tab-pane>
         <el-tab-pane label="规划师审核" name="guide">
         <el-tab-pane label="规划师审核" name="guide">
-          <guide-audit ref="guide" />
+          <guide-audit ref="guide" @refresh-summary="loadSummary" />
         </el-tab-pane>
         </el-tab-pane>
         <el-tab-pane label="营养师审核" name="nutritionist">
         <el-tab-pane label="营养师审核" name="nutritionist">
-          <nutritionist-audit ref="nutritionist" />
+          <nutritionist-audit ref="nutritionist" @refresh-summary="loadSummary" />
         </el-tab-pane>
         </el-tab-pane>
         <el-tab-pane label="服务商审核" name="vendor">
         <el-tab-pane label="服务商审核" name="vendor">
-          <vendor-audit ref="vendor" />
+          <vendor-audit ref="vendor" @refresh-summary="loadSummary" />
         </el-tab-pane>
         </el-tab-pane>
         <el-tab-pane label="管家审核" name="butler">
         <el-tab-pane label="管家审核" name="butler">
-          <butler-audit ref="butler" />
+          <butler-audit ref="butler" @refresh-summary="loadSummary" />
         </el-tab-pane>
         </el-tab-pane>
         <el-tab-pane label="任务模板审核" name="package">
         <el-tab-pane label="任务模板审核" name="package">
-          <package-audit ref="package" />
+          <package-audit ref="package" @refresh-summary="loadSummary" />
         </el-tab-pane>
         </el-tab-pane>
         <el-tab-pane label="活动审核" name="activity">
         <el-tab-pane label="活动审核" name="activity">
-          <activity-audit ref="activity" />
+          <activity-audit ref="activity" @refresh-summary="loadSummary" />
         </el-tab-pane>
         </el-tab-pane>
         <el-tab-pane label="活动报名审核" name="activityRegistration">
         <el-tab-pane label="活动报名审核" name="activityRegistration">
-          <activity-registration-audit ref="activityRegistration" />
+          <activity-registration-audit ref="activityRegistration" @refresh-summary="loadSummary" />
         </el-tab-pane>
         </el-tab-pane>
         <el-tab-pane label="提现审核" name="withdraw">
         <el-tab-pane label="提现审核" name="withdraw">
-          <withdraw-audit ref="withdraw" />
+          <withdraw-audit ref="withdraw" @refresh-summary="loadSummary" />
         </el-tab-pane>
         </el-tab-pane>
         <el-tab-pane label="待退款审核" name="refund">
         <el-tab-pane label="待退款审核" name="refund">
-          <pending-refund-audit ref="refund" />
+          <pending-refund-audit ref="refund" @refresh-summary="loadSummary" />
         </el-tab-pane>
         </el-tab-pane>
         <el-tab-pane label="更换上级审核" name="hierarchy">
         <el-tab-pane label="更换上级审核" name="hierarchy">
-          <hierarchy-audit ref="hierarchy" />
+          <hierarchy-audit ref="hierarchy" @refresh-summary="loadSummary" />
         </el-tab-pane>
         </el-tab-pane>
         <el-tab-pane label="知识管理员审核" name="knowledge">
         <el-tab-pane label="知识管理员审核" name="knowledge">
-          <knowledge-admin-audit ref="knowledge" />
+          <knowledge-admin-audit ref="knowledge" @refresh-summary="loadSummary" />
         </el-tab-pane>
         </el-tab-pane>
       </el-tabs>
       </el-tabs>
     </el-card>
     </el-card>

+ 1 - 0
cfc-web/src/views/admin/review/ActivityAudit.vue

@@ -166,6 +166,7 @@ export default {
         this.$message.success('审核完成')
         this.$message.success('审核完成')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     }
     }

+ 2 - 0
cfc-web/src/views/admin/review/ActivityRegistrationAudit.vue

@@ -110,6 +110,7 @@ export default {
         this.$message.success('已通过')
         this.$message.success('已通过')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     },
     },
@@ -121,6 +122,7 @@ export default {
         this.$message.success('已拒绝')
         this.$message.success('已拒绝')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     }
     }

+ 3 - 0
cfc-web/src/views/admin/review/ArticleAudit.vue

@@ -190,6 +190,7 @@ export default {
         this.$message.success(action === 'approved' ? '已通过' : '已驳回')
         this.$message.success(action === 'approved' ? '已通过' : '已驳回')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) {
       } catch (e) {
         this.$message.error('操作失败')
         this.$message.error('操作失败')
       } finally {
       } finally {
@@ -209,6 +210,7 @@ export default {
         this.$message.success('已批量通过')
         this.$message.success('已批量通过')
         this.selectedRows = []
         this.selectedRows = []
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) {
       } catch (e) {
         if (e !== 'cancel') this.$message.error('操作失败')
         if (e !== 'cancel') this.$message.error('操作失败')
       }
       }
@@ -223,6 +225,7 @@ export default {
         this.$message.success('已批量驳回')
         this.$message.success('已批量驳回')
         this.selectedRows = []
         this.selectedRows = []
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) {
       } catch (e) {
         this.$message.error('操作失败')
         this.$message.error('操作失败')
       }
       }

+ 2 - 0
cfc-web/src/views/admin/review/ButlerAudit.vue

@@ -225,6 +225,7 @@ export default {
         this.$message.success('审核完成')
         this.$message.success('审核完成')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     },
     },
@@ -240,6 +241,7 @@ export default {
         this.$message.success('已拒绝')
         this.$message.success('已拒绝')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     }
     }

+ 2 - 0
cfc-web/src/views/admin/review/GuideAudit.vue

@@ -192,6 +192,7 @@ export default {
         this.$message.success('已通过')
         this.$message.success('已通过')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     },
     },
@@ -207,6 +208,7 @@ export default {
         this.$message.success('已拒绝')
         this.$message.success('已拒绝')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     }
     }

+ 1 - 0
cfc-web/src/views/admin/review/HierarchyAudit.vue

@@ -123,6 +123,7 @@ export default {
         this.$message.success('审核完成')
         this.$message.success('审核完成')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     }
     }

+ 2 - 0
cfc-web/src/views/admin/review/KnowledgeAdminAudit.vue

@@ -132,6 +132,7 @@ export default {
         this.$message.success('已通过')
         this.$message.success('已通过')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     },
     },
@@ -143,6 +144,7 @@ export default {
         this.$message.success('已拒绝')
         this.$message.success('已拒绝')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     }
     }

+ 2 - 0
cfc-web/src/views/admin/review/NutritionistAudit.vue

@@ -188,6 +188,7 @@ export default {
         this.$message.success('已通过')
         this.$message.success('已通过')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     },
     },
@@ -203,6 +204,7 @@ export default {
         this.$message.success('已拒绝')
         this.$message.success('已拒绝')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     }
     }

+ 3 - 0
cfc-web/src/views/admin/review/PackageAudit.vue

@@ -185,6 +185,7 @@ export default {
         this.$message.success(action === 'approve' ? '已通过' : '已驳回')
         this.$message.success(action === 'approve' ? '已通过' : '已驳回')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) {
       } catch (e) {
         this.$message.error('操作失败')
         this.$message.error('操作失败')
       } finally {
       } finally {
@@ -204,6 +205,7 @@ export default {
         this.$message.success('已批量通过')
         this.$message.success('已批量通过')
         this.selectedRows = []
         this.selectedRows = []
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) {
       } catch (e) {
         if (e !== 'cancel') this.$message.error('操作失败')
         if (e !== 'cancel') this.$message.error('操作失败')
       }
       }
@@ -218,6 +220,7 @@ export default {
         this.$message.success('已批量驳回')
         this.$message.success('已批量驳回')
         this.selectedRows = []
         this.selectedRows = []
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) {
       } catch (e) {
         this.$message.error('操作失败')
         this.$message.error('操作失败')
       }
       }

+ 1 - 0
cfc-web/src/views/admin/review/PendingRefundAudit.vue

@@ -232,6 +232,7 @@ export default {
           this.$message.success('已中止退款')
           this.$message.success('已中止退款')
           this.detailVisible = false
           this.detailVisible = false
           this.loadData()
           this.loadData()
+          this.$emit('refresh-summary')
         } else {
         } else {
           this.$message.error(res.message || '操作失败')
           this.$message.error(res.message || '操作失败')
         }
         }

+ 1 - 0
cfc-web/src/views/admin/review/ProductAudit.vue

@@ -122,6 +122,7 @@ export default {
         this.$message.success('审核完成')
         this.$message.success('审核完成')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     }
     }

+ 2 - 0
cfc-web/src/views/admin/review/VendorAudit.vue

@@ -202,6 +202,7 @@ export default {
         this.$message.success('审核完成')
         this.$message.success('审核完成')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     },
     },
@@ -217,6 +218,7 @@ export default {
         this.$message.success('已拒绝')
         this.$message.success('已拒绝')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) { this.$message.error('操作失败')
       } catch (e) { this.$message.error('操作失败')
       } finally { this.auditLoading = false }
       } finally { this.auditLoading = false }
     }
     }

+ 2 - 0
cfc-web/src/views/admin/review/WithdrawAudit.vue

@@ -152,6 +152,7 @@ export default {
         this.$message.success('已通过')
         this.$message.success('已通过')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) {
       } catch (e) {
         this.$message.error('操作失败')
         this.$message.error('操作失败')
       } finally {
       } finally {
@@ -166,6 +167,7 @@ export default {
         this.$message.success('已拒绝')
         this.$message.success('已拒绝')
         this.detailVisible = false
         this.detailVisible = false
         this.loadData()
         this.loadData()
+        this.$emit('refresh-summary')
       } catch (e) {
       } catch (e) {
         this.$message.error('操作失败')
         this.$message.error('操作失败')
       } finally {
       } finally {

+ 159 - 0
docs/superpowers/plans/verify/verify-activity-index-refresh.md

@@ -0,0 +1,159 @@
+# 小程序活动模块遗留项核验报告
+
+> 日期:2026-10-24  
+> 核验范围:`cfc-frontend/pages/activity/index.vue` + 活动模块相关前后端代码
+
+---
+
+## 任务 A:`loadData()` 修复核验
+
+### 修复内容(用户描述)
+
+将 `loadData()`(约 462-488 行)改为:先重置当前 tab 的页码为 1、清空对应数组、再调用 `loadXxxActivities()` 加载第一页。
+
+### 实际代码(index.vue 第 462-488 行)
+
+```js
+loadData: function() {
+  if (this.currentTab === 'mine') {
+    this.minePage = 1
+    this.myActivities = []
+    this.hasMore = true
+    this.loadMyActivities()
+  } else if (this.currentTab === 'discover') {
+    this.discoverPage = 1
+    this.discoverActivities = []
+    this.hasMore = true
+    this.loadDiscoverActivities()
+  } else if (this.currentTab === 'history') {
+    this.historyPage = 1
+    this.historyActivities = []
+    this.hasMore = true
+    this.loadHistoryActivities()
+  } else if (this.currentTab === 'manage') {
+    if (this.canManageActivity) {
+      this.loadManageData()
+    }
+  }
+}
+```
+
+### 问题 1:从详情页返回走的确实是 onShow → loadData 路径
+
+**证据(index.vue 第 391-398 行):**
+
+```js
+onShow: function() {
+  if (this._onLoadFired) {
+    this._onLoadFired = false   // 首次进入:onLoad 已加载,跳过避免双发
+  } else {
+    this.resolveManagePermission()
+    this.loadData()             // ← 切 Tab/从子页返回时走这里
+  }
+},
+```
+
+详情页跳转用 `uni.navigateTo`(非 tab 页面),返回时触发 `onShow`;首次进入时 `onLoad` 已设 `_onLoadFired = true`,`onShow` 跳过。后续返回时标志已被重置为 `false`,会真正调用 `loadData()`。✅
+
+**关键:loadXxxActivities 在非 refresh 分支会 concat,但 loadData 先清空了数组,所以 concat 后不会叠加。**
+
+`loadDiscoverActivities` 非 refresh 分支(第 754-755 行):
+```js
+self.discoverActivities = self.discoverActivities.concat(list)
+```
+此时 `discoverActivities` 已被 `loadData()` 初始化为 `[]`,concat 结果 = `list`,不会翻倍。✅
+
+### 问题 2:各路径行为核验
+
+| 路径 | 实际行为 | 是否正确 |
+|------|---------|---------|
+| **下拉刷新** `onRefresh()` (659-677 行) | 调用 `loadXxxActivities(true)` → refresh=true 分支清空数组 + page=1 | ✅ 正确 |
+| **加载更多** `onLoadMore()` (646-658 行) | 递增 page++ → 调用 `loadXxxActivities()` 无 refresh → concat 追加 | ✅ 正确 |
+| **Tab 切换** `onTabChange()` (447-460 行) | 先清空数组+page=1,再调 `loadData()` → loadData 内部不再重复清空(不影响) | ✅ 正确(轻微冗余但无害) |
+| **维度筛选切换** `onDimensionChange()` (639-644 行) | 先清空 discover 数组+page=1,再调 `loadDiscoverActivities()` 无 refresh,concat 到空数组 | ✅ 正确 |
+| **mine 筛选切换** `onMineFilterChange()` (628-638 行) | 同维度筛选逻辑,直接调 `loadXxxActivities()` | ✅ 正确 |
+| **发布成功后 switchToDiscover** (831-837 行) | 先清空数组+page=1,再调 `loadDiscoverActivities()` 无 refresh | ✅ 正确 |
+| **manage 权限解析后回落** `onPermissionResolved()` (434-438 行) | 切换 tab 后调 `loadData()`,mine 分支会清空并加载 | ✅ 正确 |
+
+### 问题 3:语法检查
+
+运行 `node scripts/verify-vue-compile.js pages/activity/index.vue`:
+
+```
+校验文件数: 1
+  模板通过: 1   样式通过: 1   脚本通过: 1
+PASS
+```
+
+无语法错误、无未定义变量、this 指向正确(全用 `var self = this` 或箭头函数)。✅
+
+### 任务 A 结论:通过 ✅
+
+---
+
+## 任务 B:第 3 项需求完成度核验
+
+原始需求三点:
+1. ① 活动支持添加「活动简介」
+2. ② 活动详情页的「海报预览」要重新设计
+3. ③ 后台可以对海报模板进行定义
+
+### ① 活动简介字段
+
+| 位置 | 状态 | 证据 |
+|------|------|------|
+| 后端 Entity | ✅ 已有 | `Activity.java` 第 23 行:`private String intro;` |
+| 后端 DTO | ✅ 已有 | `ActivityDTO.java` 第 13 行:`private String intro;` |
+| 数据库 Schema | ✅ 已有 | `schema.sql` 第 2134 行:`intro VARCHAR(200) DEFAULT NULL COMMENT '活动简介(列表卡片展示)'` |
+| Web 管理端表单 | ✅ 已有 | `ActivityEdit.vue` 第 84-86 行:`<el-form-item label="活动简介">` + `v-model="form.intro"` + maxlength=200 |
+| Web 管理端保存 | ✅ 已有 | `ActivityEdit.vue` 第 496 行:payload 含 `intro: this.form.intro` |
+| 小程序活动列表 | ✅ 展示 | `index.vue` 第 140 行:`<text class="card-desc" v-if="act.intro || act.description">{{ act.intro || act.description }}</text>` |
+| 小程序创建活动页 | ❌ **没有** | `create.vue` 无 `intro` 输入框,仅有 `description`(第 108-112 行) |
+| 小程序详情页海报 | ⚠️ 传的是 description 而非 intro | `activity-detail.vue` 第 300 行:`:intro="(activity && activity.description) || ''"` — 把 description 赋给了 poster 的 intro prop |
+
+**小结**:后端字段、Web 管理端录入、列表展示均已实现。**小程序创建页缺录入入口,详情页海报传了 description 而非 intro**。
+
+### ② 详情页「海报预览」重新设计
+
+| 位置 | 状态 | 证据 |
+|------|------|------|
+| 小程序详情页「生成海报」按钮 | ✅ 有 | `activity-detail.vue` 第 132 行:`<button class="share-btn" @click="generatePoster">生成海报</button>` |
+| 海报组件 | ✅ 已重设计 | `ActivityPoster.vue`(755 行):Canvas 绘制,三套模板引擎(gradient/minimal/card),支持模板选择、背景色切换、二维码 CTA |
+| 模板选择行 | ✅ 有 | `ActivityPoster.vue` 第 18-32 行:`tplOptions` 渲染管理端模板列表 |
+| 背景色板 | ✅ 有 | `ActivityPoster.vue` 第 34-46 行:渐变模板下可切换背景色 |
+| 保存功能 | ✅ 有 | `ActivityPoster.vue` 第 49 行:「保存到相册」按钮 |
+
+**小结**:海报预览已重设计为独立的 `ActivityPoster.vue` Canvas 组件,支持模板选择与配色定制。**已完成** ✅。
+
+### ③ 后台可以对海报模板进行定义
+
+| 位置 | 状态 | 证据 |
+|------|------|------|
+| 后端 Entity | ✅ 已有 | `ActivityPosterTemplate.java`(53 行):name/templateKey/mainColor/darkColor/lightColor/bgColors/qrSize/sortOrder/status |
+| 后端 Service | ✅ 已有 | `ActivityPosterTemplateService.java`:listAll/listEnabled/save/delete |
+| 后端 Admin Controller | ✅ 已有 | `AdminPosterTemplateController.java`:`POST /api/admin/poster-template/list|save|delete` |
+| 后端公开 Controller | ✅ 已有 | `PosterTemplateController.java`:`POST /api/poster-template/list`(小程序端调用) |
+| 数据库表 | ✅ 已有 | `schema.sql` 第 5409 行:`CREATE TABLE activity_poster_templates` |
+| 迁移脚本 | ✅ 已有 | `DatabaseInitializer.java` 第 3106-3107 行:迁移 381 建表 + 初始化 3 套种子模板;第 3139-3141 行:迁移 388 给 activities 加 `poster_template_id` 列 |
+| Web 管理端页面 | ✅ 已有 | `PosterTemplateManage.vue`(360 行):列表+新增/编辑/删除弹窗,支持颜色选择器、QR 尺寸、排序、启用/停用 |
+| 路由注册 | ✅ 已有 | `router/index.js` 第 723 行:`path: 'poster-template-manage'`,菜单入口在 `Layout.vue` 第 246 行 |
+| 小程序端接入 | ✅ 已有 | `activity-detail.vue` 第 808 行:`getPosterTemplates()` 拉取模板列表传给 `<ActivityPoster :templates="posterTemplates">` |
+
+**小结**:后台海报模板定义功能(CRUD + 启用/停用 + 颜色/尺寸配置)**已完整实现** ✅。
+
+---
+
+## 最终结论
+
+### 任务 A:✅ 通过
+- 路径核验通过:onShow → loadData 路径正确,fix 不会再 concat 叠加
+- 6 条路径行为均正确(下拉刷新/加载更多/Tab 切换/维度筛选/mine 筛选/switchToDiscover)
+- 语法编译通过
+
+### 任务 B 三项子需求完成度
+
+| 子需求 | 完成度 | 说明 |
+|--------|--------|------|
+| ① 活动支持添加「活动简介」 | **部分完成** ⚠️ | 后端字段/DB/Web 管理端/列表展示均已完成;**小程序创建页缺少 intro 输入框**;**详情页海报传的是 description 而非 intro**(`activity-detail.vue:300`) |
+| ② 详情页海报预览重新设计 | **已完成** ✅ | `ActivityPoster.vue` 是全新 Canvas 组件,支持三套模板引擎、模板选择、背景色切换 |
+| ③ 后台自定义海报模板 | **已完成** ✅ | 后端 CRUD + 管理端 `PosterTemplateManage.vue` + 小程序端接入均已到位 |

+ 105 - 0
docs/superpowers/plans/verify/verify-activity-intro-field.md

@@ -0,0 +1,105 @@
+# Activity intro 字段完整链路核验报告
+
+核验时间:2026-10-24
+核验人:Agnes
+核验范围:cfc-frontend/pages/activity/create.vue + activity-detail.vue,后端 Entity/DTO/Migration,列表卡片与海报展示
+
+---
+
+## A. intro 完整链路核验 ✅ 通过
+
+### A1. 创建/编辑页录入(create.vue)
+
+| 节点 | 行号 | 状态 |
+|------|------|------|
+| `data.form.intro = ''` | L209 | ✅ |
+| textarea `v-model="form.intro"` + `maxlength="100"` | L110 | ✅ |
+| `loadDetail()` 回填 `self.form.intro = d.intro || ''` | L275 | ✅ |
+| `buildPayload()` 通过 `Object.assign({}, this.form)` 自动带出 intro | L459 | ✅ |
+
+### A2. 后端持久化链路
+
+| 节点 | 文件:行号 | 状态 |
+|------|-----------|------|
+| Entity `Activity.java` 有 `private String intro` | L23 | ✅ |
+| DTO `ActivityDTO.java` 有 `private String intro` | L13 | ✅ |
+| `ActivityDTO.from()` 映射 `dto.setIntro(activity.getIntro())` | L92 | ✅ |
+| `ActivityController` create/update 接收 `@RequestBody Activity`,MyBatis-Plus 全字段持久化 | L45/L57 | ✅ |
+
+### A3. 列表卡片展示(index.vue)
+
+| 节点 | 行号 | 状态 |
+|------|------|------|
+| discover tab 卡片:`v-if="act.intro || act.description"` 优先展示 intro | L140 | ✅ |
+| history tab 卡片:无 intro/description 展示(**历史遗留,非本次引入**) | L189-212 | ⚠️ 见 C 节备注 |
+
+### A4. 分享海报(activity-detail.vue + ActivityPoster.vue)
+
+| 节点 | 行号 | 状态 |
+|------|------|------|
+| detail.vue 海报 prop:`:intro="(activity && (activity.intro || activity.description)) || ''"` | L300 | ✅ |
+| ActivityPoster.vue 定义 `intro` prop(String,默认 '') | L75 | ✅ |
+| ActivityPoster.vue `_drawInfoCard` canvas 绘制简介(仅当 plainIntro 非空时画) | L457-464 | ✅ |
+
+**结论:A 全链路已通。**
+
+---
+
+## B. 存量数据库 intro 列兜底 ✅ 通过
+
+| 证据 | 内容 |
+|------|------|
+| `DatabaseInitializer.java` L3118-3119 | `// 迁移388: activities表添加intro列` + `ensureColumn("activities", "intro", "VARCHAR(200) DEFAULT NULL ...")` |
+| `schema.sql` L2134 | `intro VARCHAR(200) DEFAULT NULL COMMENT '活动简介(列表卡片展示)'` |
+| ensureColumn 实现(L4099附近) | 自动忽略「列已存在」异常,幂等可重复执行 |
+
+**结论:存量 DB 已有 intro 列兜底,不会产生 Unknown column 报错。**
+
+---
+
+## C. 回归风险核验
+
+### C1. validate() 是否需要同步校验 intro
+- `validate()` (create.vue L443) 只校验 title / activityType / startTime,未校验 intro
+- **结论:无需校验**。intro 是可选字段,不做必填校验是合理设计。
+
+### C2. intro 为空时是否表现正常(不会显示 undefined / 空行占位)
+
+| 场景 | 处理逻辑 | 结论 |
+|------|---------|------|
+| index.vue 列表卡片 | `v-if="act.intro || act.description"` 兜底到 description | ✅ 不展示空白 |
+| activity-detail.vue 海报 | `(activity && (activity.intro || activity.description)) || ''` → 空字符串 | ✅ 海报无简介时不画行 |
+| ActivityPoster.vue canvas | `if (plainIntro)` 条件判断才绘制 | ✅ 空字符串跳过 |
+| loadDetail() 回填 | `self.form.intro = d.intro \|\| ''` | ✅ 空值转为空字符串,不是 undefined |
+
+### C3. 编辑旧活动时不填简介是否误清空已有简介
+- `loadDetail()` 回填 `d.intro || ''`,旧活动有 intro 值时能正确回填到 `form.intro`
+- `buildPayload()` 通过 `Object.assign({}, this.form)` 整体拷贝,包括 intro
+- **关键检查**:如果用户未修改 intro 字段,`form.intro` 保留原值,payload 会携带原值提交 → 不会清空
+- **结论:✅ 不会误清空**
+
+### C4. 潜在遗留问题(非本次引入的回归)
+- **index.vue history tab(L189-212)**:发现页卡片有 `card-desc` 展示 intro/description,但历史 tab 没有对应展示——**这是 pre-existing 设计差异,与本次 intro 改动无关**,不在本次核验范围内。
+
+---
+
+## D. 编译验证 ✅ 通过
+
+```
+cd cfc-backend && mvn clean compile -q
+```
+- 退出码:0
+- 无编译错误输出
+
+---
+
+## 最终结论
+
+| 项 | 结论 |
+|----|------|
+| A. intro 完整链路 | ✅ 通过 |
+| B. 存量 intro 列兜底 | ✅ 通过 |
+| C. 回归风险 | ✅ 通过(history tab 展示差异为 pre-existing,非回归) |
+| D. 后端编译 | ✅ 通过 |
+
+**四项全绿,可以合入。**