Преглед изворни кода

fix(activity): 修复活动中心管理 tab 白板及时间、字段显示问题

前端:
- 管理 tab 白板根因修复:权限判定原读 userInfo.role/vendorType,但登录态 userInfo 只存 nickname/userId/familyId,角色在独立的 role/currentRole storage key 中,导致恒判为无权限
- 新增共享判定 utils/activity-role.js(hasActivityManageRole + isApprovedActivityProvider),与后端 hasActivityManageRole 对齐(admin/activity_admin/activity_manager 或已通过审核的活动服务商)
- tabs 改为 visibleTabs 计算属性:无权限账号不渲染管理 tab,从根上消灭白板;?tab=manage 无权限时回落我的活动
- 未登录不发 vendorStatus 请求,避免游客被 401 弹去登录页
- 发现/我的/历史三个 tab 时间统一为 yyyy-MM-dd HH:mm:ss;发现 tab 补一句话简介
- 我的活动报名记录字段错位:新增 normalizeRegistration 映射 activityTitle/activityStartTime 等为卡片字段,goDetail 改取 activityId(报名 id 非活动 id)
- 历史记录判定:有结束时间按结束时间,无则按开始时间已过判断
- 从我的活动点去发现活动时重置分页直接加载,避免列表重复
- onLoad/onShow 用 _onLoadFired 标志防小程序首次进入双发请求

后端:
- AdminActivityController.list 新增 mineOnly 参数,活动服务商调 list 时按 userId 隔离,避免看到全平台活动;管理员视图不变
- ParamUtils 新增 getBoolean
- ActivityRegistrationService.getMyRegistrations 补发 activityEndTime/activityStatus/activityCoverImage/activityLocation 及四档费用字段
liaoxg пре 6 дана
родитељ
комит
2574143e68

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminActivityController.java

@@ -25,12 +25,17 @@ public class AdminActivityController {
      * 分页查询活动列表(包含草稿)
      * 支持按 status / dimensionCode 过滤
      * B-5:纯活动管理员登录时仅能看到自己创建/服务的活动
+     * mineOnly=true:小程序活动中心「管理」tab 的活动服务商(C 端账号,无后台角色)
+     *            显式要求只看自己创建/负责的活动,避免看到全平台活动
      */
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params,
                                             @RequestAttribute(value = "userId", required = false) Long userId,
                                             @RequestAttribute(value = "roles", required = false) java.util.List<String> roleList) {
         Long vendorUserId = com.etotem.cfc.common.AdminRoles.isActivityManagerOnly(roleList) ? userId : null;
+        if (vendorUserId == null && Boolean.TRUE.equals(ParamUtils.getBoolean(params.get("mineOnly"))) && userId != null) {
+            vendorUserId = userId;
+        }
         return activityAdminService.list(params, vendorUserId);
     }
 

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

@@ -142,8 +142,18 @@ public class ActivityRegistrationService {
             item.put("registeredAt", reg.getRegisteredAt());
             Activity activity = activityMapper.selectById(reg.getActivityId());
             if (activity != null) {
+                // 小程序「我的活动/历史记录」卡片需要活动维度的展示字段,
+                // 只返回 activityTitle/activityStartTime 会让前端标题、时间、封面、费用全部为空
                 item.put("activityTitle", activity.getTitle());
                 item.put("activityStartTime", activity.getStartTime());
+                item.put("activityEndTime", activity.getEndTime());
+                item.put("activityStatus", activity.getStatus());
+                item.put("activityCoverImage", activity.getCoverImage());
+                item.put("activityLocation", activity.getLocation());
+                item.put("activityChildFee", activity.getChildFee());
+                item.put("activityChildMemberFee", activity.getChildMemberFee());
+                item.put("activityParentFee", activity.getParentFee());
+                item.put("activityParentMemberFee", activity.getParentMemberFee());
             }
             return item;
         }).collect(Collectors.toList());

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/ParamUtils.java

@@ -48,4 +48,25 @@ public final class ParamUtils {
         Long parsed = getLong(value);
         return parsed != null ? parsed : defaultVal;
     }
+
+    /**
+     * 安全解析 Boolean:null / 空字符串 / 空白 => null(视为"未传")。
+     * 支持 true/false、1/0、yes/no、on/off(忽略大小写);其余值 => null。
+     */
+    public static Boolean getBoolean(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String s = value.toString().trim();
+        if (s.isEmpty()) {
+            return null;
+        }
+        if ("true".equalsIgnoreCase(s) || "1".equals(s) || "yes".equalsIgnoreCase(s) || "on".equalsIgnoreCase(s)) {
+            return Boolean.TRUE;
+        }
+        if ("false".equalsIgnoreCase(s) || "0".equals(s) || "no".equalsIgnoreCase(s) || "off".equalsIgnoreCase(s)) {
+            return Boolean.FALSE;
+        }
+        return null;
+    }
 }

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

@@ -130,14 +130,15 @@
 import { adminCreateActivity, adminUpdateActivity, adminPublishActivity, submitActivityForReview, getActivityDetail } from '../../utils/api.js'
 import config from '../../config.js'
 import { parseDate } from '../../utils/format.js'
+import { hasActivityManageRole } from '../../utils/activity-role.js'
 
 var BASE_URL = config.api('')
 
 export default {
   computed: {
+    // 后台活动管理员可直接发布;服务商账号只能保存后提交审核
     isAdmin: function() {
-      var userInfo = uni.getStorageSync('userInfo')
-      return userInfo && (userInfo.role === 'admin' || userInfo.role === 'activity_admin' || (userInfo.roles && userInfo.roles.indexOf('activity_admin') >= 0))
+      return hasActivityManageRole()
     }
   },
   data() {
@@ -393,8 +394,7 @@ export default {
       var self = this
       self.loading = true
       var payload = self.buildPayload()
-      var userInfo = uni.getStorageSync('userInfo')
-      var isAdmin = userInfo && (userInfo.role === 'admin' || userInfo.role === 'activity_admin' || (userInfo.roles && userInfo.roles.indexOf('activity_admin') >= 0))
+      var isAdmin = self.isAdmin
 
       var doPublish = function(id) {
         if (isAdmin) {

+ 119 - 48
cfc-frontend/pages/activity/index.vue

@@ -2,12 +2,12 @@
   <view class="activity-container" :style="{ '--font-scale': fontScale }">
     <!-- Nav bar with title "活动中心" and admin "+" button -->
 
-    <!-- Tab bar (4 tabs) -->
+    <!-- Tab bar(管理 tab 仅对有活动管理权限的账号可见:活动管理员 / 已通过审核的活动提供商) -->
     <view class="tab-bar">
       <view
         class="tab-item"
         :class="{ active: currentTab === tab.key }"
-        v-for="tab in tabs"
+        v-for="tab in visibleTabs"
         :key="tab.key"
         @click="onTabChange(tab.key)"
       >
@@ -57,7 +57,7 @@
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
               <text class="card-meta" v-if="act.registrantName">报名人:{{ act.registrantName }}</text>
-              <text class="card-meta">{{ act.startTime }}</text>
+              <text class="card-meta">开始 {{ formatDateTime(act.startTime) }}{{ act.endTime ? ' 结束 ' + formatDateTime(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')">
@@ -130,7 +130,8 @@
             />
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
-              <text class="card-meta">{{ act.startTime }}</text>
+              <text class="card-meta">开始 {{ formatDateTime(act.startTime) }}{{ act.endTime ? ' 结束 ' + formatDateTime(act.endTime) : '' }}</text>
+              <text class="card-desc" v-if="act.description">{{ 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')">
@@ -194,7 +195,7 @@
             />
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
-              <text class="card-meta">{{ act.startTime }}</text>
+              <text class="card-meta">开始 {{ formatDateTime(act.startTime) }}{{ act.endTime ? ' 结束 ' + formatDateTime(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>
@@ -224,8 +225,8 @@
         </view>
       </view>
 
-      <!-- Tab 4: manage (admin or activity_provider) -->
-      <view v-if="currentTab === 'manage' && (isAdmin || isActivityProvider)">
+      <!-- Tab 4: manage(visibleTabs 已按权限过滤,此处再兜底一次,避免无权限时白屏) -->
+      <view v-if="currentTab === 'manage' && canManageActivity">
         <!-- Manage overview stats -->
         <view class="manage-overview">
           <view class="manage-stat-card">
@@ -278,7 +279,7 @@
                   {{ statusLabel(act.status) }}
                 </text>
                 <view class="manage-action-group">
-                  <text class="manage-action-small" v-if="act.status === 'draft'" @click.stop="doPublish(act.id)">{{ isAdmin ? '发布' : '提交审核' }}</text>
+                  <text class="manage-action-small" v-if="act.status === 'draft'" @click.stop="doPublish(act.id)">{{ canDirectPublish ? '发布' : '提交审核' }}</text>
                   <text class="manage-action-small" v-if="act.status === 'rejected'" @click.stop="doPublish(act.id)">重新提交</text>
                   <text class="manage-action-small" v-if="act.status === 'published'" @click.stop="doEnd(act.id)">结束</text>
                   <text class="manage-action-small" v-if="act.status === 'published'" @click.stop="doWithdraw(act.id)">撤回</text>
@@ -302,39 +303,34 @@
 </template>
 
 <script>
-import { getActivityList, getMyActivityRegistrations, activityCheckin, cancelActivityRegistration, adminListMyActivities, adminPublishActivity, adminEndActivity, adminWithdrawActivity, submitActivityForReview, reportAppEnter} from '@/utils/api.js'
+import { getActivityList, getMyActivityRegistrations, activityCheckin, cancelActivityRegistration, adminListMyActivities, adminPublishActivity, adminEndActivity, adminWithdrawActivity, submitActivityForReview, vendorStatus, reportAppEnter} from '@/utils/api.js'
 import config from '@/config.js'
-import { parseDate } from '../../utils/format.js'
+import { parseDate, formatDateTime } from '../../utils/format.js'
+import { hasActivityManageRole, isApprovedActivityProvider } from '@/utils/activity-role.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
   mixins: [fontScaleMixin],
   computed: {
-    isAdmin: function() {
-      var userInfo = uni.getStorageSync('userInfo')
-      if (!userInfo) return false
-      if (userInfo.role === 'admin' || userInfo.role === 'activity_admin' || userInfo.role === 'activity_manager') return true
-      if (userInfo.roles && userInfo.roles.length) {
-        for (var i = 0; i < userInfo.roles.length; i++) {
-          var r = userInfo.roles[i]
-          if (r === 'activity_admin' || r === 'activity_manager' || r === 'admin') return true
-        }
+    // 管理 tab 只对有活动管理权限的账号展示(普通家长/孩子不显示,避免点进去是空白)
+    visibleTabs: function() {
+      var list = [
+        { key: 'mine', label: '我的活动' },
+        { key: 'discover', label: '发现活动' },
+        { key: 'history', label: '历史记录' }
+      ]
+      if (this.canManageActivity) {
+        list.push({ key: 'manage', label: '管理' })
       }
-      return false
-    },
-    isActivityProvider: function() {
-      var userInfo = uni.getStorageSync('userInfo')
-      return userInfo && userInfo.vendorType === 'activity_provider' && userInfo.activityProviderStatus === 'approved'
+      return list
     }
   },
   data() {
     return {
-      tabs: [
-        { key: 'mine', label: '我的活动' },
-        { key: 'discover', label: '发现活动' },
-        { key: 'history', label: '历史记录' },
-        { key: 'manage', label: '管理' }
-      ],
+      // 活动管理权限:后台角色(admin/activity_admin/activity_manager)或已通过审核的活动提供商
+      canManageActivity: false,
+      // 是否有权直接发布(后台活动管理员);服务商只能提交审核
+      canDirectPublish: false,
       currentTab: 'mine',
       mineFilters: [
         { label: '全部', value: '' },
@@ -372,20 +368,60 @@ export default {
   },
   onLoad: function(options) {
       reportAppEnter('activity');
-    
+
     if (options && options.tab) {
       this.currentTab = options.tab
-      this.loadData()
     }
+    this.resolveManagePermission()
+    this.loadData()
+    this._onLoadFired = true
   },
   onShow: function() {
-    if (this.currentTab === 'mine') {
-      this.loadMyActivities(true)
-    } else if (this.currentTab === 'manage') {
-      this.loadManageData()
+    if (this._onLoadFired) {
+      this._onLoadFired = false   // 首次进入:onLoad 已加载,跳过避免双发
+    } else {
+      // 切 Tab/从子页返回:刷新权限态(同步部分不发请求)+ 当前 tab 数据
+      this.resolveManagePermission()
+      this.loadData()
     }
   },
   methods: {
+    // 活动管理权限判定:与后端 ActivityController.hasActivityManageRole 对齐。
+    // 后台角色(admin / activity_admin / activity_manager)→ 可直接发布;
+    // 其余账号查服务商入驻状态,vendorType=activity_provider 且审核通过 → 可管理(只能提交审核)。
+    resolveManagePermission: function() {
+      var self = this
+      if (hasActivityManageRole()) {
+        self.canDirectPublish = true
+        self.canManageActivity = true
+        self.onPermissionResolved()
+        return
+      }
+      // 未登录不发 vendorStatus:请求层会因 401 触发静默续期/跳登录,影响游客浏览活动
+      if (!uni.getStorageSync('token')) {
+        return
+      }
+      vendorStatus().then(function(res) {
+        var approvedProvider = isApprovedActivityProvider((res && res.data) || {})
+        // 非后台角色才由服务商审核状态决定(后台角色已在上面提前返回,不会被覆盖)
+        if (!self.canDirectPublish) {
+          self.canManageActivity = approvedProvider
+        }
+        self.onPermissionResolved()
+      }).catch(function() {
+        if (!self.canDirectPublish) {
+          self.canManageActivity = false
+        }
+        self.onPermissionResolved()
+      })
+    },
+    // 权限解析完成后的兜底:以 ?tab=manage 进来但无权限时回落到「我的活动」,避免空白页
+    onPermissionResolved: function() {
+      if (this.currentTab === 'manage' && !this.canManageActivity) {
+        this.currentTab = 'mine'
+        this.loadData()
+      }
+    },
     getImageUrl: function(path) {
       if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
       var s = String(path).trim()
@@ -405,8 +441,6 @@ export default {
       } else if (key === 'history') {
         this.historyPage = 1
         this.historyActivities = []
-      } else if (key === 'manage') {
-        this.loadManageData()
       }
       this.loadData()
     },
@@ -417,12 +451,22 @@ export default {
         this.loadDiscoverActivities()
       } else if (this.currentTab === 'history') {
         this.loadHistoryActivities()
+      } else if (this.currentTab === 'manage') {
+        // 权限未解析/无权限时不发管理列表请求(onPermissionResolved 会回落 mine)
+        if (this.canManageActivity) {
+          this.loadManageData()
+        }
       }
     },
     loadManageData: function() {
       var self = this
       this.manageLoading = true
-      adminListMyActivities({ page: 1, size: 100 }).then(function(res) {
+      // 活动服务商(非后台角色)只看自己创建/负责的活动,后台活动管理员由后端按角色隔离
+      var params = { page: 1, size: 100 }
+      if (!self.canDirectPublish) {
+        params.mineOnly = true
+      }
+      adminListMyActivities(params).then(function(res) {
         self.manageLoading = false
         if (res && res.code === 200 && res.data) {
           var records = res.data.records || []
@@ -474,7 +518,7 @@ export default {
         success: function(res) {
           if (res.confirm) {
             uni.showLoading({ title: '发布中...' })
-            if (self.isAdmin) {
+            if (self.canDirectPublish) {
               adminPublishActivity(id).then(function(r) {
                 uni.hideLoading()
                 if (r && r.code === 200) {
@@ -627,6 +671,9 @@ export default {
         self.mineLoading = false
         if (res && res.code === 200 && res.data) {
           var records = res.data.records || []
+          for (var ni = 0; ni < records.length; ni++) {
+            self.normalizeRegistration(records[ni])
+          }
           if (self.currentMineFilter === 'upcoming') {
             var now = new Date()
             var filtered = []
@@ -710,12 +757,12 @@ export default {
           var ended = []
           for (var i = 0; i < records.length; i++) {
             var act = records[i]
-            if (act.endTime) {
-              var endTime = parseDate(act.endTime)
-              if ((endTime && endTime < now) || act.status === 'cancelled' || act.status === 'rejected') {
-                ended.push(act)
-              }
-            } else {
+            self.normalizeRegistration(act)
+            var endTime = parseDate(act.endTime)
+            var startTime = parseDate(act.startTime)
+            // 有结束时间按结束时间判断;无结束时间(如线上活动)按开始时间已过判断
+            var isPast = (endTime && endTime < now) || (!endTime && startTime && startTime < now)
+            if (isPast || act.status === 'cancelled' || act.status === 'rejected') {
               ended.push(act)
             }
           }
@@ -730,16 +777,40 @@ export default {
         self.historyLoading = false
       })
     },
+    // 报名记录 → 卡片所需字段:/api/activity/my-registrations 返回的是
+    // activityTitle/activityStartTime/... ,直接渲染会标题/时间/封面/费用全空
+    normalizeRegistration: function(act) {
+      if (!act) return act
+      if (!act.title && act.activityTitle) act.title = act.activityTitle
+      if (act.activityStartTime) act.startTime = formatDateTime(act.activityStartTime)
+      if (act.activityEndTime) act.endTime = formatDateTime(act.activityEndTime)
+      if (!act.coverImage && act.activityCoverImage) act.coverImage = act.activityCoverImage
+      if (!act.location && act.activityLocation) act.location = act.activityLocation
+      var fees = [act.activityChildFee, act.activityChildMemberFee, act.activityParentFee, act.activityParentMemberFee]
+      var price = 0
+      for (var i = 0; i < fees.length; i++) {
+        var f = Number(fees[i]) || 0
+        if (f > 0 && (price === 0 || f < price)) price = f
+      }
+      act.price = price
+      return act
+    },
     goDetail: function(act) {
-      uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + act.id })
+      // 报名记录的 id 是报名 id,必须取 activityId;发现列表的 id 即活动 id
+      var id = act.activityId || act.id
+      uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + id })
     },
     switchToDiscover: function() {
       this.currentTab = 'discover'
-      this.loadData()
+      this.hasMore = true
+      this.discoverPage = 1
+      this.discoverActivities = []
+      this.loadDiscoverActivities()
     },
     formatPrice: function(price) {
       return (price / 100).toFixed(2)
     },
+    formatDateTime: formatDateTime,
     hasFee: function(act) {
       return (act.childFee > 0) || (act.childMemberFee > 0) || (act.parentFee > 0) || (act.parentMemberFee > 0)
     },

+ 43 - 0
cfc-frontend/utils/activity-role.js

@@ -0,0 +1,43 @@
+/**
+ * 活动管理权限判定(小程序端)
+ *
+ * 与后端 `ActivityController.hasActivityManageRole`(com.etotem.cfc.controller)保持一致:
+ *   后台角色 admin / activity_admin 直接放行;roles 列表含 activity_manager 也放行。
+ *
+ * 注意:小程序登录态里 userInfo 只存 nickname/userId/familyId,
+ * 角色存在独立的 `role` / `currentRole` storage key 里(见 App.vue saveLoginInfo、utils/api.js _applyLoginData),
+ * 所以不能只读 userInfo.role,否则永远判为无权限。
+ */
+
+var ACTIVITY_MANAGE_ROLES = ['admin', 'activity_admin', 'activity_manager']
+
+function hasRoleInList(list, role) {
+  if (!list || !list.length) return false
+  for (var i = 0; i < list.length; i++) {
+    if (list[i] === role) return true
+  }
+  return false
+}
+
+/**
+ * 当前登录账号是否有活动管理权限(后台角色)
+ * @returns {boolean}
+ */
+export function hasActivityManageRole() {
+  var mainRole = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || ''
+  if (hasRoleInList(ACTIVITY_MANAGE_ROLES, mainRole)) return true
+  var userInfo = uni.getStorageSync('userInfo') || {}
+  if (hasRoleInList(ACTIVITY_MANAGE_ROLES, userInfo.role)) return true
+  if (hasRoleInList(ACTIVITY_MANAGE_ROLES, userInfo.roles)) return true
+  return false
+}
+
+/**
+ * vendorStatus 接口返回是否表示「已通过审核的活动提供商」
+ * @param {Object} vendorStatusData /api/vendor/status 的 data 字段
+ * @returns {boolean}
+ */
+export function isApprovedActivityProvider(vendorStatusData) {
+  var d = vendorStatusData || {}
+  return d.vendorType === 'activity_provider' && d.vendorStatus === 'approved'
+}