Procházet zdrojové kódy

feat(activity): A3 活动费用展示层口径统一(成人/儿童/长者 × 会员/非会员 + 适用人群)

按 docs/superpowers/plans/2026-10-26-activity-create-page-ux-and-audience-design.md §二 2.6 逐条改造展示层。

- 新增 cfc-frontend/utils/activity-fee.js:统一 audienceScope/audienceType 归一化与成人/儿童/长者档位取价
  (长者复用成人价,D1 不新增长者价格列;parentFee/parentMemberFee 作为成人非会员价历史别名回落,A2 §2.4)
- activity-detail.vue:费用区按 audienceType 条件渲染「成人/儿童/长者」(成人恒显;儿童/长者需含该人群且对应价格 > 0);
  海报价格文案按 audienceType 选「(成人)/(儿童)/(长者)」,不再出现「家长」「孩子」;
  hasFee / hasMemberPrice 纳入 price / memberPrice(修复只填成人价的付费活动误显「免费」的真实 bug);
  minFeeText 改为当前用户可用档位的最小值(会员用会员价列、非会员用非会员价列);
  报名弹窗为每个报名人展示身份标签(成人/儿童/长者)与对应单价,合计同步按身份口径估算
- index.vue:normalizeRegistration 不再用 4 个 child/parent 费用的最小值覆盖 price,仅 price 为空时回落 parentFee;
  列表卡片的 hasFee / minFee 同步纳入成人档
- cfc-web ActivityEdit.vue:价格区之后追加适用人群(单选)与参与人群(多选),勾选「含儿童」时条件显示儿童两档费用;
  读写字段名对齐 A2 的 DTO(audienceScope / audienceType + childFee / childMemberFee),与小程序保存口径一致

范围说明:A2 的数据层(audience_scope / audience_type 列、实体与 DTO)与结算逻辑不在本任务范围;
audienceScope / audienceType 需 A2 合入后才能落库与回显,本分支前端已按 A2 契约对齐并对字段缺失做了降级。
Sisyphus před 2 týdny
rodič
revize
4a202be94c

+ 131 - 57
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -58,8 +58,10 @@
           <view class="meta-item" v-if="hasFee">
             <text class="meta-icon">💰</text>
             <view class="meta-text">
-              <text class="fee-line">孩子:¥{{ feeText(activity.childFee, activity.childMemberFee) }}</text>
-              <text class="fee-line">家长:¥{{ feeText(activity.parentFee, activity.parentMemberFee) }}</text>
+              <!-- A3:成人档恒显;儿童/长者档仅在 audienceType 含该人群且对应价格 > 0 时显示 -->
+              <text class="fee-line">成人:¥{{ feeText(adultFeeRegular, adultFeeMember) }}</text>
+              <text class="fee-line" v-if="showChildFee">儿童:¥{{ feeText(activity.childFee, activity.childMemberFee) }}</text>
+              <text class="fee-line" v-if="showElderFee">长者:¥{{ feeText(adultFeeRegular, adultFeeMember) }}</text>
               <text class="fee-line member-fee-link" v-if="!isMember && hasMemberPrice" @click.stop="goUpgrade">开通会员享会员价 ›</text>
             </view>
           </view>
@@ -260,8 +262,11 @@
               @click="toggleFamilyMember(member)"
             >
               <view class="reg-family-info">
-                <text class="reg-family-name">{{ member.name }}</text>
-                <text class="reg-family-relation">{{ member.relation }}</text>
+                <view class="reg-family-name-line">
+                  <text class="reg-family-name">{{ member.name }}</text>
+                  <text class="reg-identity-tag">{{ member.identityLabel }}</text>
+                </view>
+                <text class="reg-family-relation">{{ member.relation }}<text class="reg-family-price" v-if="hasFee"> · ¥{{ memberFeeText(member) }}</text></text>
               </view>
               <text class="reg-checkmark" v-if="isMemberSelected(member)">✓</text>
             </view>
@@ -272,9 +277,15 @@
         <view class="reg-list">
           <text class="reg-section-label">报名人</text>
           <view class="reg-entry" v-for="(entry, idx) in registrants" :key="idx">
-            <input class="reg-input reg-input-name" v-model="entry.name" placeholder="姓名" />
-            <input class="reg-input reg-input-phone" v-model="entry.phone" placeholder="手机号" type="number" maxlength="11" />
-            <text class="reg-remove" @click="removeRegistrant(idx)">✕</text>
+            <view class="reg-entry-head">
+              <text class="reg-identity-tag">{{ registrantIdentityLabel(entry) }}</text>
+              <text class="reg-entry-price" v-if="hasFee">¥{{ registrantFeeText(entry) }}</text>
+              <text class="reg-remove" @click="removeRegistrant(idx)">✕</text>
+            </view>
+            <view class="reg-entry-fields">
+              <input class="reg-input reg-input-name" v-model="entry.name" placeholder="姓名" />
+              <input class="reg-input reg-input-phone" v-model="entry.phone" placeholder="手机号" type="number" maxlength="11" />
+            </view>
           </view>
           <button class="reg-add-btn" @click="addManualEntry">+ 添加报名人</button>
         </view>
@@ -331,6 +342,19 @@ import ActivityPoster from '@/pages/activity/components/ActivityPoster.vue'
 import RelatedContent from '@/components/RelatedContent.vue'
 import { backfillProfile } from '../../../utils/profile-backfill.js'
 import { parseDate, formatDateTime } from '@/utils/format.js'
+import {
+  adultFees,
+  audienceHasChild,
+  audienceHasElder,
+  classifyIdentity,
+  feeText as feeTextUtil,
+  hasAnyFee,
+  hasAnyMemberFee,
+  identityLabel,
+  minAvailablePrice,
+  posterPriceInfo,
+  priceForIdentity
+} from '@/utils/activity-fee.js'
 import { getFontScale, buildContentTagStyle } from '@/utils/fontScale.js'
 
 export default {
@@ -458,16 +482,23 @@ export default {
       var s = formatDateTime(dateStr)
       return s ? s.substring(0, 16) : ''
     },
-    // 费用双价展示:非会员价 / 会员价(元)
+    // 费用双价展示:非会员价 / 会员价(元)—— 口径统一到 utils/activity-fee.js
     feeText: function(regularFee, memberFee) {
-      var regular = (regularFee || 0) / 100
-      var member = (memberFee || 0) / 100
-      if (member > 0 && member < regular) {
-        return member.toFixed(2) + '(会员) / ' + regular.toFixed(2)
-      }
-      if (regular === 0 && member === 0) return '0.00'
-      if (regular === 0) return '会员免费'
-      return regular.toFixed(2)
+      return feeTextUtil(regularFee, memberFee)
+    },
+    // A3:报名人身份标签(成人 / 儿童 / 长者)
+    registrantIdentityLabel: function(entry) {
+      return identityLabel(entry && entry.identity)
+    },
+    // A3:报名人对应单价(元)—— 按身份取档位,按当前用户会员状态取价列
+    registrantFeeText: function(entry) {
+      var price = priceForIdentity(this.activity, entry && entry.identity, this.isMember)
+      return (price / 100).toFixed(2)
+    },
+    // A3:家庭成员对应单价(元)
+    memberFeeText: function(member) {
+      var price = priceForIdentity(this.activity, member && member.identity, this.isMember)
+      return (price / 100).toFixed(2)
     },
     loadDetail: function(id) {
       var self = this
@@ -712,28 +743,36 @@ export default {
         if (res && res.code === 200 && res.data) {
           var list = []
           var members = res.data
-          // members: flat List<FamilyMemberVO>,source='parent' 为家长(users表),其余为孩子/成员(family_members表)
+          // members: flat List<FamilyMemberVO>,source='parent' 为账号本人(users表),其余为家庭成员(family_members表)
+          // A3:每个成员按 age 归类身份(成人/儿童/长者,age 为空一律成人,与 A2 §2.4 结算口径一致)
           for (var i = 0; i < members.length; i++) {
             var m = members[i]
+            var identity = classifyIdentity(m)
             if (m.source === 'parent') {
               list.push({
                 id: 'p_' + m.id,
                 name: m.nickname || m.name || '',
                 phone: m.phone || '',
-                relation: '家长',
+                relation: '本人',
                 memberId: m.id,
                 source: 'parent',
-                targetType: 'user'
+                targetType: 'user',
+                identity: identity,
+                identityLabel: identityLabel(identity),
+                age: m.age
               })
             } else {
               list.push({
                 id: 'c_' + m.id,
                 name: m.nickname || m.name || '',
                 phone: m.phone || '',
-                relation: '孩子',
+                relation: '家庭成员',
                 memberId: m.id,
                 source: 'child',
-                targetType: 'member'
+                targetType: 'member',
+                identity: identity,
+                identityLabel: identityLabel(identity),
+                age: m.age
               })
             }
           }
@@ -760,6 +799,8 @@ export default {
         phone: member.phone,
         childId: member.childId,
         targetType: member.targetType,
+        identity: member.identity || 'adult',
+        age: member.age,
         prevPhone: member.phone
       })
     },
@@ -768,7 +809,9 @@ export default {
         memberId: null,
         name: '',
         phone: '',
-        memberId: null
+        memberId: null,
+        // 手动添加的报名人无家庭成员档案 → 按成人档计费(A2 §2.4 self)
+        identity: 'adult'
       })
     },
     removeRegistrant: function(idx) {
@@ -814,20 +857,8 @@ export default {
         }
         if (res && res.data) {
           this.posterQrCode = res.data.qrCodeBase64 || ''
-          // 费用:优先显示家长常规价,兼容会员价/孩子价
-          var p = this.activity
-          var parentRegular = (p.parentFee || 0) / 100
-          var parentMember = (p.parentMemberFee || 0) / 100
-          var childRegular = (p.childFee || 0) / 100
-          var childMember = (p.childMemberFee || 0) / 100
-          var hasFee = parentRegular > 0 || parentMember > 0 || childRegular > 0 || childMember > 0
-          if (!hasFee) {
-            this.posterPriceText = '免费'
-          } else if (parentRegular > 0 || parentMember > 0) {
-            this.posterPriceText = this.feeText(p.parentFee, p.parentMemberFee) + '(家长)'
-          } else {
-            this.posterPriceText = this.feeText(p.childFee, p.childMemberFee) + '(孩子)'
-          }
+          // A3:海报价格文案按 audienceType 选档位标签(成人 / 儿童 / 长者)
+          this.posterPriceText = posterPriceInfo(this.activity).text
           this.posterInviteText = '「' + (uni.getStorageSync('nickname') || '好友') + '」邀请你一起参加这个活动'
           this.posterBgColor = this.buildPosterBgColor()
           this.showPoster = true
@@ -973,15 +1004,33 @@ export default {
         return {}
       }
     },
-    // 4 个费用字段是否存在(任一项 > 0 即为付费活动)
-    hasFee: function() {
+    // A3:成人档两列费用(price 优先、parentFee 回落,兼容老活动)
+    adultFeeRegular: function() {
+      return adultFees(this.activity).regular
+    },
+    adultFeeMember: function() {
+      return adultFees(this.activity).member
+    },
+    // A3:儿童档是否展示(audienceType 含 child 且儿童档有价)
+    showChildFee: function() {
       var a = this.activity || {}
-      return (a.childFee > 0) || (a.childMemberFee > 0) || (a.parentFee > 0) || (a.parentMemberFee > 0)
+      if (!audienceHasChild(a.audienceType)) return false
+      return (Number(a.childFee) || 0) > 0 || (Number(a.childMemberFee) || 0) > 0
+    },
+    // A3:长者档是否展示(audienceType 含 elder 且对应价格 > 0;长者复用成人价)
+    showElderFee: function() {
+      var a = this.activity || {}
+      if (!audienceHasElder(a.audienceType)) return false
+      var fees = adultFees(a)
+      return fees.regular > 0 || fees.member > 0
+    },
+    // 活动是否收费(纳入 price/memberPrice —— 只填成人价的老活动不再误显「免费」)
+    hasFee: function() {
+      return hasAnyFee(this.activity)
     },
     // 是否存在会员专享价(用于非会员引导)
     hasMemberPrice: function() {
-      var a = this.activity || {}
-      return (a.childMemberFee > 0) || (a.parentMemberFee > 0)
+      return hasAnyMemberFee(this.activity)
     },
     // 报名按钮可用:需报名 + 未报名 + 展示状态为报名中
     canRegister: function() {
@@ -991,28 +1040,17 @@ export default {
       var st = this.registrationStatus
       return !st || st === 'not_registered' || st === 'cancelled' || st === 'rejected'
     },
-    // 报名弹窗内合计(按当前用户会员价估算,实际以订单为准)
+    // 报名弹窗内合计(按每人身份与当前用户会员状态估算,实际以订单为准)
     registrantsTotal: function() {
-      var a = this.activity || {}
       var total = 0
       for (var i = 0; i < this.registrants.length; i++) {
-        if (this.registrants[i].memberId) {
-          total += (a.childFee > 0 ? a.childFee : a.childMemberFee) || 0
-        } else {
-          total += (a.parentFee > 0 ? a.parentFee : a.parentMemberFee) || 0
-        }
+        total += priceForIdentity(this.activity, this.registrants[i].identity, this.isMember)
       }
       return (total / 100).toFixed(2)
     },
-    // 报名按钮起步价文案
+    // 报名按钮起步价文案:当前用户可用档位的最小值(会员用会员价列,非会员用非会员价列)
     minFeeText: function() {
-      var a = this.activity || {}
-      var fees = [a.childFee, a.childMemberFee, a.parentFee, a.parentMemberFee]
-      var min = 0
-      for (var i = 0; i < fees.length; i++) {
-        if (fees[i] > 0 && (min === 0 || fees[i] < min)) min = fees[i]
-      }
-      return (min / 100).toFixed(2)
+      return (minAvailablePrice(this.activity, this.isMember) / 100).toFixed(2)
     },
     // 报名进度百分比(限人数时计算,封顶 100)
     rosterPercent: function() {
@@ -1580,6 +1618,11 @@ export default {
   border: 2rpx solid $brand-primary;
 }
 .reg-family-info {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
+}
+.reg-family-name-line {
   display: flex;
   flex-direction: row;
   align-items: center;
@@ -1596,6 +1639,19 @@ export default {
   padding: 2rpx 12rpx;
   border-radius: 8rpx;
 }
+/* A3:身份标签(成人 / 儿童 / 长者) */
+.reg-identity-tag {
+  font-size: 20rpx;
+  color: $brand-primary;
+  background: $uni-primary-pale;
+  border-radius: 8rpx;
+  padding: 2rpx 10rpx;
+  flex-shrink: 0;
+}
+.reg-family-price {
+  font-size: 22rpx;
+  color: $facade-text-strong;
+}
 .reg-checkmark {
   color: $brand-primary;
   font-size: 32rpx;
@@ -1605,12 +1661,30 @@ export default {
   margin-bottom: 20rpx;
 }
 .reg-entry {
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  margin-bottom: 16rpx;
+  gap: 8rpx;
+}
+.reg-entry-head {
   display: flex;
   flex-direction: row;
   align-items: center;
-  margin-bottom: 12rpx;
   gap: 10rpx;
 }
+.reg-entry-fields {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  gap: 10rpx;
+}
+.reg-entry-price {
+  margin-left: auto;
+  font-size: 26rpx;
+  font-weight: 600;
+  color: $status-danger;
+}
 .reg-input {
   flex: 1;
   background: $facade-fill-2;

+ 12 - 12
cfc-frontend/pages/activity/index.vue

@@ -319,6 +319,7 @@ import { getActivityList, getMyActivityRegistrations, activityCheckin, cancelAct
 import config from '@/config.js'
 import { parseDate, formatDateTime } from '../../utils/format.js'
 import { hasActivityManageRole, isApprovedActivityProvider } from '@/utils/activity-role.js'
+import { adultFees, childFees, minAvailablePrice } from '@/utils/activity-fee.js'
 import fontScaleMixin from '@/mixins/font-scale.js'
 
 export default {
@@ -848,11 +849,11 @@ export default {
       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
+      // A3:不再用 4 个 child/parent 费用的最小值覆盖 price(会把只填成人价的活动压成最低档);
+      // 仅在 price 为空/为 0 时回落 parentFee(老活动「成人非会员价」历史别名,A2 §2.4)
+      var price = Number(act.activityPrice) || Number(act.price) || 0
+      if (price <= 0) {
+        price = Number(act.activityParentFee) || 0
       }
       act.price = price
       return act
@@ -878,16 +879,15 @@ export default {
       var s = formatDateTime(t)
       return s ? s.substring(0, 16) : ''
     },
+    // A3:活动是否收费 —— 纳入 price/memberPrice(成人档),兼容老活动 parentFee 回落
     hasFee: function(act) {
-      return (act.childFee > 0) || (act.childMemberFee > 0) || (act.parentFee > 0) || (act.parentMemberFee > 0)
+      var adult = adultFees(act)
+      var child = childFees(act)
+      return adult.regular > 0 || adult.member > 0 || child.regular > 0 || child.member > 0
     },
+    // A3:当前用户可用档位的最小值(卡片展示非会员起步价)
     minFee: function(act) {
-      var fees = [act.childFee, act.childMemberFee, act.parentFee, act.parentMemberFee]
-      var min = 0
-      for (var i = 0; i < fees.length; i++) {
-        if (fees[i] > 0 && (min === 0 || fees[i] < min)) min = fees[i]
-      }
-      return this.formatPrice(min)
+      return (minAvailablePrice(act, false) / 100).toFixed(2)
     },
     decorateActivity: function(act) {
       if (act.status) {

+ 255 - 0
cfc-frontend/utils/activity-fee.js

@@ -0,0 +1,255 @@
+/**
+ * 活动费用展示口径(A3「活动费用展示层口径统一」共用工具)
+ *
+ * 数据契约(A2,字段名与取值对齐 A2 的 ActivityDTO,A2 未合入期间取默认值降级):
+ *   audienceScope: 'all'(默认)| 'member_only'                         —— 是否仅会员可参加
+ *   audienceType : 'adult'(默认)| 'include_child' | 'include_elder' | 'include_child_elder'
+ *
+ * 费用档位(A2 §2.1;长者复用成人价 —— D1 决策:不新增长者价格列):
+ *   成人·非会员 price        成人·会员 memberPrice
+ *   儿童·非会员 childFee     儿童·会员 childMemberFee
+ *
+ * 兼容存量(A2 §2.4):parentFee / parentMemberFee 降级为「成人非会员价」的历史别名
+ *   成人非会员价 = price > 0 ? price : parentFee
+ *   成人会员价   = memberPrice > 0 ? memberPrice : parentMemberFee
+ * 老活动(只填 parent/parentMember 费用、price 为 0)在展示层仍要正确显示成人价。
+ *
+ * 注意:ActivityDTO.price 已被后端按会员等级解析(会员拿到 memberPrice),
+ * memberPrice 仍是原始会员价。故本工具取"非会员列"用 price 优先 + parentFee 回落,
+ * 取"会员列"用 memberPrice 优先 + parentMemberFee 回落。
+ */
+
+export const AUDIENCE_SCOPE_ALL = 'all'
+export const AUDIENCE_SCOPE_MEMBER_ONLY = 'member_only'
+
+export const AUDIENCE_TYPE_ADULT = 'adult'
+export const AUDIENCE_TYPE_INCLUDE_CHILD = 'include_child'
+export const AUDIENCE_TYPE_INCLUDE_ELDER = 'include_elder'
+export const AUDIENCE_TYPE_INCLUDE_CHILD_ELDER = 'include_child_elder'
+
+const VALID_AUDIENCE_TYPES = [
+  AUDIENCE_TYPE_ADULT,
+  AUDIENCE_TYPE_INCLUDE_CHILD,
+  AUDIENCE_TYPE_INCLUDE_ELDER,
+  AUDIENCE_TYPE_INCLUDE_CHILD_ELDER
+]
+
+export const IDENTITY_ADULT = 'adult'
+export const IDENTITY_CHILD = 'child'
+export const IDENTITY_ELDER = 'elder'
+
+const IDENTITY_LABELS = { adult: '成人', child: '儿童', elder: '长者' }
+
+/* eslint-disable no-restricted-globals */
+export function num(value) {
+  if (value === null || value === undefined || value === '') return 0
+  var n = Number(value)
+  return isNaN(n) ? 0 : n
+}
+/* eslint-enable no-restricted-globals */
+
+/**
+ * 归一化 audienceType:空值/非法值一律回落 adult(兼容存量活动与 A2 未合入期)
+ * @param {string} type
+ * @returns {string} 'adult' | 'include_child' | 'include_elder' | 'include_child_elder'
+ */
+export function normalizeAudienceType(type) {
+  var t = String(type == null ? '' : type).trim()
+  if (VALID_AUDIENCE_TYPES.indexOf(t) >= 0) return t
+  return AUDIENCE_TYPE_ADULT
+}
+
+/**
+ * 归一化 audienceScope:空值/非法值一律回落 all
+ * @param {string} scope
+ * @returns {string} 'all' | 'member_only'
+ */
+export function normalizeAudienceScope(scope) {
+  return scope === AUDIENCE_SCOPE_MEMBER_ONLY ? AUDIENCE_SCOPE_MEMBER_ONLY : AUDIENCE_SCOPE_ALL
+}
+
+/** audienceType 是否含儿童人群 */
+export function audienceHasChild(type) {
+  var t = normalizeAudienceType(type)
+  return t === AUDIENCE_TYPE_INCLUDE_CHILD || t === AUDIENCE_TYPE_INCLUDE_CHILD_ELDER
+}
+
+/** audienceType 是否含长者人群 */
+export function audienceHasElder(type) {
+  var t = normalizeAudienceType(type)
+  return t === AUDIENCE_TYPE_INCLUDE_ELDER || t === AUDIENCE_TYPE_INCLUDE_CHILD_ELDER
+}
+
+/**
+ * 成人档两列费用(分):{ regular, member }
+ * 非会员列 price 优先、parentFee 回落;会员列 memberPrice 优先、parentMemberFee 回落
+ */
+export function adultFees(activity) {
+  var a = activity || {}
+  var price = num(a.price)
+  var memberPrice = num(a.memberPrice)
+  return {
+    regular: price > 0 ? price : num(a.parentFee),
+    member: memberPrice > 0 ? memberPrice : num(a.parentMemberFee)
+  }
+}
+
+/** 儿童档两列费用(分):{ regular, member } */
+export function childFees(activity) {
+  var a = activity || {}
+  return {
+    regular: num(a.childFee),
+    member: num(a.childMemberFee)
+  }
+}
+
+/** 长者档费用:复用成人价(D1:不新增长者价格列) */
+export function elderFees(activity) {
+  return adultFees(activity)
+}
+
+/** 身份标签文案:成人 / 儿童 / 长者 */
+export function identityLabel(identity) {
+  return IDENTITY_LABELS[identity] || '成人'
+}
+
+/**
+ * 身份判定(A2 §2.4 / D4):age < 18 → 儿童;age >= 60 → 长者;
+ * 其余(含 age 为空/缺失)一律成人 —— 绝不可缺省判为儿童,否则会多收费。
+ * @param {object} member FamilyMemberVO(需含 age)
+ * @returns {string} 'adult' | 'child' | 'elder'
+ */
+export function classifyIdentity(member) {
+  var m = member || {}
+  var age = num(m.age)
+  if (age > 0 && age < 18) return IDENTITY_CHILD
+  if (age >= 60) return IDENTITY_ELDER
+  return IDENTITY_ADULT
+}
+
+/**
+ * 当前用户在某身份下的生效单价(分)
+ * 会员:取会员价列,会员价留空(0)→ 回落非会员价
+ * 非会员:取非会员价列;长者复用成人价
+ * @param {object} activity 活动对象(price/memberPrice/childFee/childMemberFee/parentFee/parentMemberFee)
+ * @param {string} identity 'adult' | 'child' | 'elder'
+ * @param {boolean} isMember 当前用户是否会员
+ * @returns {number} 分值;0 表示免费
+ */
+export function priceForIdentity(activity, identity, isMember) {
+  var fees = identity === IDENTITY_CHILD ? childFees(activity) : adultFees(activity)
+  if (isMember) {
+    return fees.member > 0 ? fees.member : fees.regular
+  }
+  return fees.regular
+}
+
+/** 活动是否收费:任一人群任一档 > 0(纳入 price/memberPrice,修复"只填成人价误显免费") */
+export function hasAnyFee(activity) {
+  var adult = adultFees(activity)
+  var child = childFees(activity)
+  return adult.regular > 0 || adult.member > 0 || child.regular > 0 || child.member > 0
+}
+
+/** 是否存在会员专享价(用于非会员引导开通会员) */
+export function hasAnyMemberFee(activity) {
+  var adult = adultFees(activity)
+  var child = childFees(activity)
+  return adult.member > 0 || child.member > 0
+}
+
+/**
+ * 当前用户可用档位的最小生效单价(分)
+ * 可用档位 = 成人档(恒含)+ 儿童档(audienceType 含 child 时);长者复用成人价,不额外计档
+ * @returns {number} 0 表示无可用付费档位(免费活动)
+ */
+export function minAvailablePrice(activity, isMember) {
+  var a = activity || {}
+  var best = -1
+  var adult = priceForIdentity(a, IDENTITY_ADULT, isMember)
+  if (adult > 0) best = adult
+  if (audienceHasChild(a.audienceType)) {
+    var child = priceForIdentity(a, IDENTITY_CHILD, isMember)
+    if (child > 0 && (best < 0 || child < best)) best = child
+  }
+  return best < 0 ? 0 : best
+}
+
+/**
+ * 双价展示文案(与活动详情页原 feeText 口径一致)
+ * 会员价更低时:「80.00(会员) / 100.00」;否则只显示主价;regular=0 且 member>0 →「会员免费」
+ * @param {number} regularFee 非会员价(分)
+ * @param {number} memberFee 会员价(分)
+ * @returns {string} 如 "100.00" / "80.00(会员) / 100.00" / "0.00" / "会员免费"
+ */
+export function feeText(regularFee, memberFee) {
+  var regular = num(regularFee) / 100
+  var member = num(memberFee) / 100
+  if (member > 0 && member < regular) return member.toFixed(2) + '(会员) / ' + regular.toFixed(2)
+  if (regular === 0 && member === 0) return '0.00'
+  if (regular === 0) return '会员免费'
+  return regular.toFixed(2)
+}
+
+/**
+ * 海报价格文案信息:按 audienceType 选档位标签与价格
+ *   1) 含儿童人群且儿童档有价 → 儿童价(儿童)
+ *   2) 含长者人群            → 成人价(长者)   ← 长者复用成人价
+ *   3) 其余                  → 成人价(成人)
+ * @returns {{label:string, text:string}} label 为 '成人'/'儿童'/'长者'/'免费'
+ */
+export function posterPriceInfo(activity) {
+  var a = activity || {}
+  var t = normalizeAudienceType(a.audienceType)
+  var adult = adultFees(a)
+  var child = childFees(a)
+  if (!hasAnyFee(a)) return { label: '免费', text: '免费' }
+  var label = '成人'
+  var regular = adult.regular
+  var member = adult.member
+  if (audienceHasChild(t) && (child.regular > 0 || child.member > 0)) {
+    label = '儿童'
+    regular = child.regular
+    member = child.member
+  } else if (audienceHasElder(t)) {
+    label = '长者'
+  }
+  return { label: label, text: feeText(regular, member) + '(' + label + ')' }
+}
+
+/**
+ * audienceType → 后台多选勾选数组('adult' 恒选)
+ * @param {string} type
+ * @returns {string[]} 如 ['adult', 'child']
+ */
+export function audienceTypeList(type) {
+  var t = normalizeAudienceType(type)
+  var list = ['adult']
+  if (audienceHasChild(t)) list.push('child')
+  if (audienceHasElder(t)) list.push('elder')
+  return list
+}
+
+/**
+ * 后台多选勾选数组 → audienceType 编码
+ * @param {string[]} list 如 ['adult', 'child']
+ * @returns {string} 'adult' | 'include_child' | 'include_elder' | 'include_child_elder'
+ */
+export function encodeAudienceType(list) {
+  var arr = list || []
+  var hasChild = false
+  var hasElder = false
+  for (var i = 0; i < arr.length; i++) {
+    if (arr[i] === 'child') hasChild = true
+    else if (arr[i] === 'elder') hasElder = true
+  }
+  if (hasChild && hasElder) return AUDIENCE_TYPE_INCLUDE_CHILD_ELDER
+  if (hasChild) return AUDIENCE_TYPE_INCLUDE_CHILD
+  if (hasElder) return AUDIENCE_TYPE_INCLUDE_ELDER
+  return AUDIENCE_TYPE_ADULT
+}
+
+/** audienceScope 展示文案 */
+export function audienceScopeLabel(scope) {
+  return scope === AUDIENCE_SCOPE_MEMBER_ONLY ? '仅会员参加' : '全部用户'
+}

+ 82 - 0
cfc-web/src/views/admin/ActivityEdit.vue

@@ -153,6 +153,40 @@
           </el-col>
         </el-row>
 
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="适用人群">
+              <el-radio-group v-model="form.audienceScope" :disabled="readonly">
+                <el-radio label="all">全部用户</el-radio>
+                <el-radio label="member_only">仅会员参加</el-radio>
+              </el-radio-group>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="参与人群">
+              <el-checkbox-group v-model="form.audienceTypeList" :disabled="readonly">
+                <el-checkbox label="adult" disabled>成人</el-checkbox>
+                <el-checkbox label="child">含儿童</el-checkbox>
+                <el-checkbox label="elder">含长者</el-checkbox>
+              </el-checkbox-group>
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="16" v-if="form.audienceTypeList && form.audienceTypeList.indexOf('child') >= 0">
+          <el-col :span="12">
+            <el-form-item label="儿童价格(元)">
+              <el-input-number v-model="form.childFeeYuan" :min="0" :precision="2" :step="0.01" placeholder="0表示免费" class="full-width" :disabled="readonly" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="儿童会员价(元)">
+              <el-input-number v-model="form.childMemberFeeYuan" :min="0" :precision="2" :step="0.01" placeholder="留空等于儿童普通价" class="full-width" :disabled="readonly" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <div class="audience-hint" v-if="form.audienceTypeList && form.audienceTypeList.indexOf('child') >= 0">取消勾选「含儿童」后保存,儿童两档费用会写 0(与小程序口径一致)</div>
+
         <el-row :gutter="16">
           <el-col :span="12">
             <el-form-item label="开始时间" prop="startTime">
@@ -266,6 +300,11 @@ export default {
         maxParticipants: null,
         priceYuan: null,
         memberPriceYuan: null,
+        // A3:适用人群(字段名与取值对齐 A2 的 ActivityDTO)
+        audienceScope: 'all',
+        audienceTypeList: ['adult'],
+        childFeeYuan: null,
+        childMemberFeeYuan: null,
         startTime: null,
         endTime: null,
         visibility: 'public',
@@ -384,6 +423,29 @@ export default {
     onEndTimeChange(val) {
       this.form.endTime = this.floorTo5Minutes(val)
     },
+    // A3:audienceType(单值编码)→ 后台多选勾选数组(成人恒选)
+    buildAudienceTypeList(type) {
+      const t = type || 'adult'
+      const list = ['adult']
+      if (t === 'include_child' || t === 'include_child_elder') list.push('child')
+      if (t === 'include_elder' || t === 'include_child_elder') list.push('elder')
+      return list
+    },
+    // A3:后台多选勾选数组 → audienceType(单值编码,字段名对齐 A2 的 ActivityDTO)
+    encodeAudienceType() {
+      const list = this.form.audienceTypeList || ['adult']
+      const hasChild = list.indexOf('child') >= 0
+      const hasElder = list.indexOf('elder') >= 0
+      if (hasChild && hasElder) return 'include_child_elder'
+      if (hasChild) return 'include_child'
+      if (hasElder) return 'include_elder'
+      return 'adult'
+    },
+    // A3:是否勾选儿童人群(决定儿童两档费用是否显示 / 是否提交)
+    includeChildAudience() {
+      const list = this.form.audienceTypeList || []
+      return list.indexOf('child') >= 0
+    },
     async loadDetail(id) {
       this.loading = true
       try {
@@ -413,6 +475,11 @@ export default {
             maxParticipants: d.maxParticipants || null,
             priceYuan: d.price ? d.price / 100 : null,
             memberPriceYuan: d.memberPrice && d.memberPrice > 0 ? d.memberPrice / 100 : null,
+            // A3:适用人群回显(A2 未合入前 d.audienceScope/d.audienceType 可能缺失,取默认值)
+            audienceScope: d.audienceScope || 'all',
+            audienceTypeList: this.buildAudienceTypeList(d.audienceType),
+            childFeeYuan: d.childFee ? d.childFee / 100 : null,
+            childMemberFeeYuan: d.childMemberFee && d.childMemberFee > 0 ? d.childMemberFee / 100 : null,
             startTime: this.floorTo5Minutes(d.startTime),
             endTime: this.floorTo5Minutes(d.endTime),
             visibility: d.visibility || 'public',
@@ -533,6 +600,11 @@ export default {
           maxParticipants: this.form.maxParticipants,
           price: this.form.priceYuan != null ? Math.round(this.form.priceYuan * 100) : 0,
           memberPrice: this.form.memberPriceYuan != null && this.form.memberPriceYuan > 0 ? Math.round(this.form.memberPriceYuan * 100) : 0,
+          // A3:适用人群 + 儿童费用档(字段名对齐 A2 的 ActivityDTO;未勾选儿童时写 0)
+          audienceScope: this.form.audienceScope || 'all',
+          audienceType: this.encodeAudienceType(),
+          childFee: this.includeChildAudience() && this.form.childFeeYuan != null ? Math.round(this.form.childFeeYuan * 100) : 0,
+          childMemberFee: this.includeChildAudience() && this.form.childMemberFeeYuan != null && this.form.childMemberFeeYuan > 0 ? Math.round(this.form.childMemberFeeYuan * 100) : 0,
           startTime: this.form.startTime,
           endTime: this.form.endTime,
           visibility: this.form.visibility,
@@ -586,6 +658,10 @@ export default {
         maxParticipants: null,
         priceYuan: null,
         memberPriceYuan: null,
+        audienceScope: 'all',
+        audienceTypeList: ['adult'],
+        childFeeYuan: null,
+        childMemberFeeYuan: null,
         startTime: null,
         endTime: null,
         visibility: 'public',
@@ -642,6 +718,11 @@ export default {
           maxParticipants: this.form.maxParticipants,
           price: this.form.priceYuan != null ? Math.round(this.form.priceYuan * 100) : 0,
           memberPrice: this.form.memberPriceYuan != null && this.form.memberPriceYuan > 0 ? Math.round(this.form.memberPriceYuan * 100) : 0,
+          // A3:适用人群 + 儿童费用档(字段名对齐 A2 的 ActivityDTO;未勾选儿童时写 0)
+          audienceScope: this.form.audienceScope || 'all',
+          audienceType: this.encodeAudienceType(),
+          childFee: this.includeChildAudience() && this.form.childFeeYuan != null ? Math.round(this.form.childFeeYuan * 100) : 0,
+          childMemberFee: this.includeChildAudience() && this.form.childMemberFeeYuan != null && this.form.childMemberFeeYuan > 0 ? Math.round(this.form.childMemberFeeYuan * 100) : 0,
           startTime: this.form.startTime,
           endTime: this.form.endTime,
           visibility: this.form.visibility,
@@ -703,6 +784,7 @@ activityId
 .text-muted { color: var(--adm-text-45); }
 .editor-body { min-height: 300px; }
 .form-hint { margin-left: 8px; color: var(--adm-text-45); font-size: 12px; }
+.audience-hint { margin: -4px 0 16px 2px; color: var(--adm-text-45); font-size: 12px; }
 .success-body { text-align: center; padding: 10px 0; }
 .success-icon { font-size: 48px; color: var(--adm-success); }
 .success-text { font-size: 16px; margin-top: 12px; }