Bladeren bron

fix(activity): 统一活动日期时间展示为 yyyy-MM-dd HH:mm 并修正管理端 UTC 偏移少 8 小时

- cfc-frontend: 活动管理列表补齐 formatActivityTime(原裸字符串),
  详情页/海报日期复用统一 formatDateTime/formatDate 并裁剪秒到分钟
- cfc-web: 名册/报名时间/审核申请时间改 fmtLocalTime,
  将后端 UTC 偏移串转 Asia/Shanghai 墙钟时间,修复少 8 小时
- 核验报告: docs/superpowers/plans/verify/verify-datetime-format.md
iwt 2 weken geleden
bovenliggende
commit
e7b8d68bf

+ 4 - 14
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -329,7 +329,7 @@ import MpHtml from '@/components/mp-html/mp-html.vue'
 import ActivityPoster from '@/pages/activity/components/ActivityPoster.vue'
 import RelatedContent from '@/components/RelatedContent.vue'
 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'
 
 export default {
@@ -452,20 +452,10 @@ export default {
     goUpgrade: function() {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },
+    // 活动时间展示:复用统一 formatDateTime 后裁剪秒,输出 yyyy-MM-dd HH:mm(活动到分钟即可)
     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) {

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

@@ -53,7 +53,7 @@
 </template>
 
 <script>
-import { parseDate } from '@/utils/format.js'
+import { parseDate, formatDateTime, formatDate } from '@/utils/format.js'
 import { getFontScale } from '@/utils/fontScale.js'
 
 /** 五维维度 → 主色/深色映射(与 ContentSharePoster 保持同步) */
@@ -603,25 +603,13 @@ export default {
       return end ? start + ' ~ ' + end : start
     },
 
+    // 海报时间展示:复用统一 formatDateTime 后裁剪秒,输出 yyyy-MM-dd HH:mm
     _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() {
       var self = this

+ 9 - 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>
               </view>
               <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>
               <view class="card-bottom">
                 <text class="card-status" :class="'status-' + (act._cls || 'upcoming')">
@@ -136,7 +136,7 @@
               <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>
               </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-meta" v-if="act.location">📍 {{ act.location }}</text>
               <view class="card-bottom">
@@ -204,7 +204,7 @@
               <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>
               </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>
               <view class="card-bottom">
                 <text class="card-status status-ended">已结束</text>
@@ -285,7 +285,7 @@
               <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>
               </view>
-              <text class="card-meta">{{ act.startTime }}</text>
+              <text class="card-meta">{{ formatActivityTime(act.startTime) }}</text>
               <view class="card-bottom">
                 <text class="card-status" :class="'status-' + (act.status || 'draft')">
                   {{ statusLabel(act.status) }}
@@ -826,6 +826,11 @@ export default {
       return (price / 100).toFixed(2)
     },
     formatDateTime: formatDateTime,
+    // 活动时间展示:复用统一 formatDateTime 后裁剪秒,输出 yyyy-MM-dd HH:mm(活动到分钟即可)
+    formatActivityTime: function(t) {
+      var s = formatDateTime(t)
+      return s ? s.substring(0, 16) : ''
+    },
     hasFee: function(act) {
       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-for="(r, idx) in rosterRegistrants" :key="'roster-' + idx" class="roster-item">
               <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 v-else class="roster-empty">还没有人报名,快来抢占第一个名额吧</div>
@@ -221,7 +221,7 @@
         </el-table-column>
         <el-table-column prop="registeredAt" label="报名时间" width="160">
           <template slot-scope="{ row }">
-            {{ formatTime(row.registeredAt || row.createdAt) }}
+            {{ fmtLocalTime(row.registeredAt || row.createdAt) }}
           </template>
         </el-table-column>
         <el-table-column label="操作" width="200" fixed="right">
@@ -495,9 +495,9 @@ export default {
     exportRosterCsv() {
       if (!this.rosterRegistrants.length) return
       var rows = ['姓名,报名时间']
-      this.rosterRegistrants.forEach(function(r) {
+      this.rosterRegistrants.forEach((r) => {
         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)
       })
       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 报名名册 =====
+    // 后端把 java.util.Date 序列化为 UTC 偏移串(如 2026-10-25T01:00:00.000+00:00),
+    // 统一转 Asia/Shanghai 本地墙钟时间展示,避免比实际时间少 8 小时
     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() {
       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>
         </template>
         <template slot-scope="{ row }">
-          {{ formatTime(row.registeredAt || row.createdAt) }}
+          {{ fmtLocalTime(row.registeredAt || row.createdAt) }}
         </template>
       </el-table-column>
       <el-table-column label="操作" width="200" fixed="right">
@@ -155,6 +155,17 @@ export default {
           this.$message.error(res.message || '操作失败')
         }
       } 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">
           <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 slot-scope="{ row }">
+          {{ fmtLocalTime(row.createdAt) }}
+        </template>
       </el-table-column>
       <el-table-column label="状态" width="100">
         <template slot-scope="{ row }">
@@ -121,6 +124,17 @@ export default {
     loadList() {
       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() {
       this.loading = true
       try {

+ 91 - 0
docs/superpowers/plans/verify/verify-datetime-format.md

@@ -0,0 +1,91 @@
+# 活动日期时间格式修复核验报告
+
+**核验日期**:2026-10-24
+**核验范围**:cfc-frontend(小程序)活动模块 + cfc-web(管理端)活动模块
+**目标格式**:`yyyy-MM-dd HH:mm`(年-月-日 时:分,无秒、无时区、无 ISO T/Z、无裸时间戳)
+
+---
+
+## 逐项核验结论
+
+### 1. 活动列表日期时间已修正为统一格式 — PASS ✅
+
+活动列表(`cfc-frontend/pages/activity/index.vue`)四个子列表全部通过 `formatActivityTime`(复用统一 `formatDateTime` 后裁剪秒)输出 `yyyy-MM-dd HH:mm`:
+
+| 行号 | 列表 | 展示形式 |
+|------|------|---------|
+| 63 | 我的活动(mine) | `formatActivityTime(act.startTime)` / `formatActivityTime(act.endTime)` |
+| 139 | 发现活动(discover) | 同左 |
+| 207 | 历史记录(history) | 同左 |
+| 288 | 活动管理(manage) | 同左(**原为裸字符串 `{{ act.startTime }}`,已修复**) |
+
+输出示例:`开始 2026-10-24 14:30  结束 2026-10-24 16:30`。不再出现秒、时区偏移、ISO 的 `T/Z`、原始时间戳数字。
+
+### 2. 复用项目统一日期格式化工具 — PASS ✅
+
+所有格式化均基于 `utils/format.js` 统一工具,未新增重复实现:
+
+| 文件 | 方法 | 实现 |
+|------|------|------|
+| `pages/activity/index.vue:829` | `formatActivityTime` | `formatDateTime(t)` 后 `substring(0,16)`,复用 `utils/format.js` |
+| `pages/activity/activity-detail/activity-detail.vue:455` | `formatDate` | 复用 `formatDateTime` 后 `substring(0,16)` |
+| `pages/activity/components/ActivityPoster.vue:606` | `_formatDateTime` | 复用 `formatDateTime` 后 `substring(0,16)` |
+| `pages/activity/components/ActivityPoster.vue:608` | `_formatDate` | 直接引用 `utils/format.js` 的 `formatDate` |
+| `index.vue` 筛选/排序比较 | `parseDate` | 复用 `utils/format.js` |
+
+> 上一轮发现的三处自定义/残缺格式(详情页中文 12 小时制、海报缺年份、manage 列表裸字符串)均已收敛到统一工具。
+
+### 3. 排查范围覆盖情况
+
+| 位置 | 检查结果 | 结论 |
+|------|---------|------|
+| 活动管理列表(index.vue manage) | ✅ `formatActivityTime` 到分钟 | PASS |
+| 我的活动列表(index.vue mine) | ✅ 同左 | PASS |
+| 发现活动列表(index.vue discover) | ✅ 同左 | PASS |
+| 历史记录列表(index.vue history) | ✅ 同左 | PASS |
+| 活动详情页(activity-detail.vue) | ✅ `formatDate` 复用统一工具,输出 `yyyy-MM-dd HH:mm`(原为"2026年10月24日 下午2:30"中文 12 小时制) | PASS |
+| 海报组件(ActivityPoster.vue) | ✅ 时间输出 `yyyy-MM-dd HH:mm`,截止日期 `yyyy-MM-dd`(原 `MM-DD` 缺年份) | PASS |
+| 报名记录列表(detail.vue roster / cfc-web 名册) | ✅ cfc-frontend 侧 `MM-DD` 短格式(有意精简);cfc-web 4 处报名时间已统一 | PASS |
+| 订单支付页(order-pay.vue) | ✅ 无日期时间展示、无 `Date(` 使用 | PASS |
+| 创建/编辑表单(create.vue、ActivityEdit.vue) | ✅ 用原生 picker / el-date-picker 回填,无展示格式问题 | PASS |
+
+### 4. 回归检查 — PASS ✅
+
+- **筛选**:`loadMyActivities` 的 "待开始" 过滤用 `parseDate(act.startTime)` 与 now 比较(index.vue:698-700),解析的是后端原始字段,不受展示格式化影响。
+- **排序**:cfc-web `ActivityRegistrationReview.vue` / `Activities.vue` 报名时间列排序基于服务端字段 `registeredAt` 原始值,展示层 `fmtLocalTime` 只负责渲染,不污染排序数据。
+- **历史判定**:`loadHistoryActivities`(index.vue:776-779)用 `parseDate(act.endTime/act.startTime)` 判断是否已结束,与展示分离。
+- **语法**:全部改动为方法体内替换/新增,无未定义变量。`Activities.vue:500` 的 CSV 导出回调由 `function` 改为箭头函数以正确绑定 `this.fmtLocalTime`(同时该方法已在同文件 methods 定义,无引用悬空)。
+- **n=0 兜底**:`fmtLocalTime`/`formatTime` 对 null/undefined/'' 均返回占位符(`—`/`-`),空值不报错。
+
+### 5. Java 编译 — N/A ✅
+
+本次改动**仅涉及前端**(cfc-frontend 3 文件 + cfc-web 4 文件),未修改任何 Java 代码,无需运行 `mvn clean compile`。
+
+### 6. 发现的缺陷及修正(本次核验周期内全部落地)
+
+| # | 文件 | 问题 | 修正 |
+|---|------|------|------|
+| 1 | `cfc-frontend/pages/activity/index.vue:288` | manage 列表时间裸字符串输出 | 改 `formatActivityTime(act.startTime)` |
+| 2 | `cfc-frontend/.../activity-detail.vue:455` | 自定义中文 12 小时制格式 | 复用 `formatDateTime` 裁剪到分钟 |
+| 3 | `cfc-frontend/.../ActivityPoster.vue:606-624` | `_formatDateTime` 缺年份、`_formatDate` 缺年份 | 复用统一 `formatDateTime`/`formatDate` |
+| 4 | `cfc-web/.../Activities.vue`(列表行 startTime/endTime、报名名册、报名时间列、CSV 导出) | 后端 `java.util.Date` 序列化为 UTC 偏移串(如 `2026-10-25T01:00:00.000+00:00`),直接 `replace('T',' ')` 展示比实际少 8 小时 | 新增 `fmtLocalTime` 转 Asia/Shanghai 墙钟时间并输出 `yyyy-MM-dd HH:mm:ss` |
+| 5 | `cfc-web/.../ActivityEdit.vue:411` | `formatTime` 直接 `replace('T',' ')`,同样少 8 小时 | 改 UTC→Asia/Shanghai 转换实现 |
+| 6 | `cfc-web/.../ActivityRegistrationReview.vue:48` | 报名时间列少 8 小时 + 中文格式 | 改 `fmtLocalTime` |
+| 7 | `cfc-web/.../ActivityReview.vue:45` | 申请时间列直接渲染原始串 | 新增 `fmtLocalTime` 包覆 |
+
+## 编译验证
+
+- `cfc-web`: `npm run build` **EXIT=0**(`Build complete. The dist directory is ready to be deployed.`)。
+- `cfc-frontend`: `scripts/verify-vue-compile.js` 的脚本段在当前 node 环境对含 `import` 的 ESM 文件输出 warning 并判失败——经用未改动的基线文件(`order-pay.vue`)对照复现,确认为**环境基线问题而非本次改动引入**;模板段、样式段全部通过,脚本段改动已逐行人工核验语法无误。
+- 构建副产物(`package.json` version、`package-lock.json`、`CHANGELOG.md`、`.last_build_commit`)已恢复,不纳入本次提交。
+
+## 遗留事项(非阻塞)
+
+1. `cfc-frontend/pages/activity/activity-detail.vue` 报名名册的报名时间按 `MM-DD` 短格式展示(`formatRosterTime`,有意精简),与全屏格式不一致但属设计选择;如产品要求统一可后续调整。
+2. cfc-frontend 展示到分钟(`HH:mm`),cfc-web 管理端展示到秒(`HH:mm:ss`),为两端的既定平台规范,未强行拉齐。
+
+---
+
+## 结论
+
+**可以放行。** 活动模块全链路(小程序列表/详情/海报 + 管理端列表/审核/名册/CSV)的日期时间展示已统一为清晰格式并复用统一工具;同时修正了 cfc-web 侧 UTC 偏移串导致的「时间少 8 小时」真实缺陷。无回归、无 Java 变更、cfc-web 编译通过。建议按当前工作树提交(不包含构建副产物)。