Prechádzať zdrojové kódy

fix: 打卡/活动详情/推广/结算页 fixed 元素移出 scroll-view(iOS 兼容)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
asus 1 mesiac pred
rodič
commit
7d5efce0e9

+ 198 - 26
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -77,8 +77,201 @@
         </view>
       </view>
 
-      <!-- 底部操作栏 -->
-      <view class="bottom-bar">
+      </scroll-view>
+
+    <!-- 底部操作栏:必须与 scroll-view 平级,否则 iOS 上 position: fixed 在 scroll-view 内不生效(按钮不显示) -->
+    <view class="bottom-bar" v-if="!loading && activity.id">
+      <view class="price-info">
+        <text class="bottom-type">活动</text>
+        <text class="bottom-vendor" v-if="activity.vendorName">{{ activity.vendorName }}</text>
+      </view>
+      <view class="action-btns">
+        <button class="share-btn" @click="generatePoster">
+          <text class="share-btn-icon">📤</text>
+          <text class="share-btn-text">分享</text>
+        </button>
+        <!-- 报名按钮(仅报名中/未报名时显示) -->
+        <button
+          v-if="canRegister"
+          class="btn-register"
+          @click="onRegister"
+        >
+          <text v-if="hasFee">¥{{ minFeeText }} 立即报名</text>
+          <text v-else>立即报名</text>
+        </button>
+
+        <!-- 已报名待审核 -->
+        <button
+          v-if="activity.requireRegistration === 1 && registrationStatus === 'pending'"
+          class="btn-register-done"
+          disabled
+        >
+          <text>报名待审核</text>
+        </button>
+        
+        <!-- 已报名可取消 -->
+        <button
+          v-if="activity.requireRegistration === 1 && (registrationStatus === 'approved' || registrationStatus === 'registered')"
+          class="btn-cancel"
+          :disabled="registrationLoading"
+          @click="onCancelRegistration"
+        >
+          <text>取消报名</text>
+        </button>
+        
+        <!-- 签到按钮(仅进行中时显示) -->
+        <button
+          v-if="activity.displayStatus === 'ongoing'"
+          class="btn-checkin"
+          :class="{ 'btn-checkin-done': checkinDone, 'btn-checkin-loading': checkinLoading }"
+          :disabled="checkinLoading || checkinDone"
+          @click="onCheckin"
+        >
+          <text v-if="checkinLoading">签到中...</text>
+          <text v-else-if="checkinDone && activity.requireCheckinReview === 1">已签到待审核</text>
+          <text v-else-if="checkinDone">已签到 ✓</text>
+          <text v-else>签到</text>
+        </button>
+        <text v-if="activity.requireCheckinReview === 1 && !checkinDone && activity.displayStatus === 'ongoing'" class="checkin-review-hint">签到需要管理员审核</text>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view v-else class="empty-wrap">
+      <text class="empty-text">活动不存在或已下架</text>
+    </view>
+
+    <!-- 能量扣减提示弹窗:必须与 scroll-view 平级,否则 iOS 上 position: fixed 在 scroll-view 内不生效(按钮不显示) -->
+    <modal v-if="showPenaltyModal" class="penalty-modal" :show="showPenaltyModal" @close="showPenaltyModal = false">
+      <view class="penalty-content">
+        <text class="penalty-title">签到成功但已扣能量</text>
+        <text class="penalty-desc">您未报名参加此活动,已被扣除 {{ penaltyAmount }} 点能量值</text>
+        <button class="penalty-btn" @click="showPenaltyModal = false">我知道了</button>
+      </view>
+    </modal>
+
+    <!-- 活动评价 -->
+    <view class="feedback-section" v-if="activity.status === 'ended' && checkinDone">
+      <view class="feedback-header">
+        <text class="feedback-title">活动评价</text>
+      </view>
+      <!-- Already submitted feedback -->
+      <view v-if="submittedFeedback" class="feedback-submitted">
+        <view class="feedback-ratings-display">
+          <view class="fr-row" v-for="(val, key) in parsedSubmittedRatings" :key="key">
+            <text class="fr-label">{{ key }}</text>
+            <text class="fr-stars">{{ getStarDisplay(val) }}</text>
+          </view>
+        </view>
+        <text class="feedback-comment-text" v-if="submittedFeedback.comment">{{ submittedFeedback.comment }}</text>
+        <text class="feedback-thanks">感谢您的评价!</text>
+      </view>
+      <!-- Submit feedback button -->
+      <button v-else class="feedback-btn" @click="openFeedbackModal">
+        <text>提交活动评价</text>
+      </button>
+    </view>
+
+    <!-- 评价弹窗:必须与 scroll-view 平级,否则 iOS 上 position: fixed 在 scroll-view 内不生效(按钮不显示) -->
+    <modal v-if="showFeedbackModal" class="feedback-modal-overlay" :show="showFeedbackModal" @close="showFeedbackModal = false">
+      <view class="feedback-modal">
+        <text class="feedback-modal-title">活动评价</text>
+
+        <view class="rating-row" v-for="(dim, dimIdx) in feedbackDimensions" :key="dimIdx">
+          <text class="rating-label">{{ dim }}</text>
+          <view class="stars">
+            <text v-for="star in 5" :key="star"
+              class="star-icon"
+              :class="{'star-active': star <= (feedbackRatings[dim] || 0)}"
+              @click="feedbackRatings[dim] = star">★</text>
+          </view>
+        </view>
+
+        <textarea class="feedback-textarea" v-model="feedbackComment" placeholder="说说您的体验..." maxlength="500" />
+
+        <view class="feedback-modal-actions">
+          <button class="feedback-cancel-btn" @click="showFeedbackModal = false">取消</button>
+          <button class="feedback-submit-btn" :disabled="feedbackSubmitting" @click="submitFeedback">
+            <text v-if="feedbackSubmitting">提交中...</text>
+            <text v-else>提交评价</text>
+          </button>
+        </view>
+      </view>
+    </modal>
+
+    <!-- 报名表单弹窗:必须与 scroll-view 平级,否则 iOS 上 position: fixed 在 scroll-view 内不生效(按钮不显示) -->
+    <view v-if="showRegModal" class="reg-modal-overlay" @click.self="closeRegModal">
+      <view class="reg-modal">
+        <text class="reg-modal-title">活动报名</text>
+
+        <!-- 家庭成员选择 -->
+        <view class="reg-family-section" v-if="familyMembers.length > 0">
+          <text class="reg-section-label">从家庭成员中选择</text>
+          <view class="reg-family-list">
+            <view
+              class="reg-family-item"
+              v-for="member in familyMembers"
+              :key="member.id"
+              :class="{'reg-family-item-selected': isMemberSelected(member)}"
+              @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>
+              <text class="reg-checkmark" v-if="isMemberSelected(member)">✓</text>
+            </view>
+          </view>
+        </view>
+
+        <!-- 报名人列表 -->
+        <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>
+          <button class="reg-add-btn" @click="addManualEntry">+ 添加报名人</button>
+        </view>
+
+        <!-- 合计 -->
+        <view class="reg-total" v-if="hasFee">
+          <text>共 {{ registrants.length }} 人 · 合计 ¥{{ registrantsTotal }}</text>
+        </view>
+
+        <!-- 操作按钮 -->
+        <view class="reg-actions">
+          <button class="reg-cancel" @click="closeRegModal">取消</button>
+          <button class="reg-submit" :disabled="regSubmitting || registrants.length === 0" @click="submitRegistration">
+            <text v-if="regSubmitting">提交中...</text>
+            <text v-else>确认报名</text>
+          </button>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view v-else class="empty-wrap">
+      <text class="empty-text">活动不存在或已下架</text>
+    </view>
+
+    <!-- 分享海报 -->
+    <ContentSharePoster
+      :show="showPoster"
+      :title="activity && activity.title"
+      :coverImage="activity && getImageUrl(activity.coverImage)"
+      :qrCodeBase64="posterQrCode"
+      typeLabel="活动推荐"
+      :dimensionCode="activity && activity.dimensionCode"
+      :summary="posterSummary"
+      :priceText="posterPriceText"
+      :inviteText="posterInviteText"
+      @close="closePoster"
+    />
+
+      <!-- 底部操作栏:必须与 scroll-view 平级,否则 iOS 上 position: fixed 在 scroll-view 内不生效(按钮不显示) -->
+      <view class="bottom-bar" v-if="!loading && activity.id">
         <view class="price-info">
           <text class="bottom-type">活动</text>
           <text class="bottom-vendor" v-if="activity.vendorName">{{ activity.vendorName }}</text>
@@ -143,28 +336,6 @@
         </view>
       </modal>
 
-      <!-- 活动评价 -->
-      <view class="feedback-section" v-if="activity.status === 'ended' && checkinDone">
-        <view class="feedback-header">
-          <text class="feedback-title">活动评价</text>
-        </view>
-        <!-- Already submitted feedback -->
-        <view v-if="submittedFeedback" class="feedback-submitted">
-          <view class="feedback-ratings-display">
-            <view class="fr-row" v-for="(val, key) in parsedSubmittedRatings" :key="key">
-              <text class="fr-label">{{ key }}</text>
-              <text class="fr-stars">{{ getStarDisplay(val) }}</text>
-            </view>
-          </view>
-          <text class="feedback-comment-text" v-if="submittedFeedback.comment">{{ submittedFeedback.comment }}</text>
-          <text class="feedback-thanks">感谢您的评价!</text>
-        </view>
-        <!-- Submit feedback button -->
-        <button v-else class="feedback-btn" @click="openFeedbackModal">
-          <text>提交活动评价</text>
-        </button>
-      </view>
-
       <!-- 评价弹窗 -->
       <modal v-if="showFeedbackModal" class="feedback-modal-overlay" :show="showFeedbackModal" @close="showFeedbackModal = false">
         <view class="feedback-modal">
@@ -273,6 +444,7 @@ import shareMixin from '../../../components/share-mixin.js'
 import MpHtml from '@/components/mp-html/mp-html.vue'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
 import { backfillProfile } from '../../../utils/profile-backfill.js'
+import { parseDate } from '@/utils/format.js'
 
 export default {
   mixins: [shareMixin],
@@ -348,8 +520,8 @@ export default {
     },
     formatDate: function(dateStr) {
       if (!dateStr) return ''
-      var d = new Date(dateStr.replace(/-/g, '/'))
-      if (isNaN(d.getTime())) return dateStr
+      var d = parseDate(dateStr)
+      if (!d) return dateStr
       var year = d.getFullYear()
       var month = d.getMonth() + 1
       var day = d.getDate()

+ 7 - 7
cfc-frontend/pages/body-detail/checkin.vue

@@ -104,6 +104,7 @@
 
 <script>
 import config from '@/config.js'
+import { parseDate } from '@/utils/format.js'
 
 const healthRequest = function(url, data) {
   var token = uni.getStorageSync('token')
@@ -266,13 +267,12 @@ export default {
     },
     formatDate: function(date) {
       if (!date) return ''
-      if (typeof date === 'string') {
-        try { date = new Date(date) } catch (e) { return date }
-      }
-      var y = date.getFullYear()
-      var m = ('' + (date.getMonth() + 1)).padStart(2, '0')
-      var d = ('' + date.getDate()).padStart(2, '0')
-      return y + '-' + m + '-' + d
+      var d = parseDate(date)
+      if (!d) return date
+      var y = d.getFullYear()
+      var m = ('' + (d.getMonth() + 1)).padStart(2, '0')
+      var day = ('' + d.getDate()).padStart(2, '0')
+      return y + '-' + m + '-' + day
     },
     goBack: function() {
       uni.navigateBack()

+ 16 - 16
cfc-frontend/pages/promotion/index.vue

@@ -81,22 +81,6 @@
       </view>
     </view> -->
 
-    <!-- 分配弹窗 -->
-    <view class="sheet-mask" v-if="showDistributeSheet" @click="showDistributeSheet = false">
-      <view class="sheet-content" @click.stop>
-        <text class="sheet-title">分配收益</text>
-        <text class="sheet-hint">选择成员并输入分配金额(分)</text>
-        <picker class="sheet-picker" mode="selector" :range="memberOptions" :value="distributeMemberIdx" @change="onDistributeMemberChange">
-          <text>{{ distributeMemberIdx >= 0 ? memberOptions[distributeMemberIdx] : '选择成员' }}</text>
-        </picker>
-        <input class="sheet-input" type="digit" v-model="distributeAmount" placeholder="输入分配金额(分)" />
-        <view class="sheet-actions">
-          <button class="sheet-cancel" @click="showDistributeSheet = false">取消</button>
-          <button class="sheet-confirm" @click="doDistribute" :disabled="distributeMemberIdx < 0 || !distributeAmount || distributeAmount <= 0">确认分配</button>
-        </view>
-      </view>
-    </view>
-
     <!-- 统计卡片 -->
     <view class="stats-cards">
       <view class="stat-card today-card">
@@ -201,6 +185,22 @@
     <view style="height:40rpx"></view>
     </scroll-view>
 
+    <!-- iOS 修复:position: fixed 不能嵌套在 scroll-view 内,会导致固定定位失效。已移至 scroll-view 后 -->
+    <view class="sheet-mask" v-if="showDistributeSheet" @click="showDistributeSheet = false">
+      <view class="sheet-content" @click.stop>
+        <text class="sheet-title">分配收益</text>
+        <text class="sheet-hint">选择成员并输入分配金额(分)</text>
+        <picker class="sheet-picker" mode="selector" :range="memberOptions" :value="distributeMemberIdx" @change="onDistributeMemberChange">
+          <text>{{ distributeMemberIdx >= 0 ? memberOptions[distributeMemberIdx] : '选择成员' }}</text>
+        </picker>
+        <input class="sheet-input" type="digit" v-model="distributeAmount" placeholder="输入分配金额(分)" />
+        <view class="sheet-actions">
+          <button class="sheet-cancel" @click="showDistributeSheet = false">取消</button>
+          <button class="sheet-confirm" @click="doDistribute" :disabled="distributeMemberIdx < 0 || !distributeAmount || distributeAmount <= 0">确认分配</button>
+        </view>
+      </view>
+    </view>
+
     <SharePoster :show="showPoster" :referralCode="shareReferralCode" :qrCodeBase64="inviteQrCode" :nickname="shareNickname" :userAvatar="shareAvatar" :familyName="familyName" @close="showPoster = false" />
   </view>
 </template>

+ 31 - 31
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -138,43 +138,43 @@
       </view>
 
       <view class="bottom-spacer"></view>
+    </scroll-view>
 
-      <!-- Coupon Picker Bottom Sheet -->
-      <view class="modal-mask" v-if="showCouponPicker" @click="closeCouponPicker">
-        <view class="coupon-picker" @click.stop>
-          <view class="picker-header">
-            <text class="picker-title">选择优惠券</text>
-            <text class="picker-close" @click="closeCouponPicker">关闭</text>
-          </view>
-          <scroll-view scroll-y class="picker-list">
-            <view
-              v-for="coupon in availableCoupons"
-              :key="coupon.id"
-              class="picker-item"
-              :class="selectedCoupon && selectedCoupon.id === coupon.id ? 'picker-item-active' : ''"
-              @click="selectCoupon(coupon)"
-            >
-              <view class="picker-item-left">
-                <text class="picker-item-value">{{ formatPriceWithSymbol(coupon.value) }}</text>
-              </view>
-              <view class="picker-item-right">
-                <text class="picker-item-name">{{ coupon.name }}</text>
-                <text class="picker-item-condition">{{ getConditionText(coupon.minSpend) }}</text>
-              </view>
-              <view class="picker-item-check" v-if="selectedCoupon && selectedCoupon.id === coupon.id">
-                <text class="check-icon">✓</text>
-              </view>
+    <!-- 优惠券选择器必须与 scroll-view 平级,否则 iOS 上 position: fixed 在 scroll-view 内不生效(优惠券弹窗不显示) -->
+    <view class="modal-mask" v-if="showCouponPicker" @click="closeCouponPicker">
+      <view class="coupon-picker" @click.stop>
+        <view class="picker-header">
+          <text class="picker-title">选择优惠券</text>
+          <text class="picker-close" @click="closeCouponPicker">关闭</text>
+        </view>
+        <scroll-view scroll-y class="picker-list">
+          <view
+            v-for="coupon in availableCoupons"
+            :key="coupon.id"
+            class="picker-item"
+            :class="selectedCoupon && selectedCoupon.id === coupon.id ? 'picker-item-active' : ''"
+            @click="selectCoupon(coupon)"
+          >
+            <view class="picker-item-left">
+              <text class="picker-item-value">{{ formatPriceWithSymbol(coupon.value) }}</text>
+            </view>
+            <view class="picker-item-right">
+              <text class="picker-item-name">{{ coupon.name }}</text>
+              <text class="picker-item-condition">{{ getConditionText(coupon.minSpend) }}</text>
             </view>
-            <view class="picker-empty" v-if="availableCoupons.length === 0">
-              <text>暂无可用优惠券</text>
+            <view class="picker-item-check" v-if="selectedCoupon && selectedCoupon.id === coupon.id">
+              <text class="check-icon">✓</text>
             </view>
-          </scroll-view>
-          <view class="picker-footer" v-if="selectedCoupon">
-            <button class="picker-btn-remove" @click="removeCoupon">不使用优惠券</button>
           </view>
+          <view class="picker-empty" v-if="availableCoupons.length === 0">
+            <text>暂无可用优惠券</text>
+          </view>
+        </scroll-view>
+        <view class="picker-footer" v-if="selectedCoupon">
+          <button class="picker-btn-remove" @click="removeCoupon">不使用优惠券</button>
         </view>
       </view>
-    </scroll-view>
+    </view>
 
     <view class="bottom-bar">
       <view class="bottom-left">

+ 48 - 40
cfc-frontend/pages/wealth-sub/checkin.vue

@@ -1,5 +1,9 @@
 <template>
-  <scroll-view class="container" scroll-y>
+  <!-- iOS 修复:scroll-view 内部的 position:fixed 元素在 iOS 上不生效,
+       需将 fixed 元素(.dialog-mask / .energy-animation)移出 scroll-view,
+       作为 scroll-view 的同级兄弟节点,参考 cart.vue / product-detail.vue 布局 -->
+  <view class="page">
+    <scroll-view class="container" scroll-y>
     <!-- 顶部能量提示 -->
     <view class="energy-tip">
       <text class="energy-tip-text">⚡ 每次打卡可获得 5 财富能量</text>
@@ -113,53 +117,56 @@
       </view>
     </view>
 
-    <!-- 打卡弹窗 -->
-    <view class="dialog-mask" v-if="showDialog" @click="closeDialog">
-      <view class="dialog-card" @click.stop>
-        <text class="dialog-title">{{ dialogType ? getTypeLabel(dialogType) : '' }}打卡</text>
-        <view class="dialog-form">
-          <view class="form-item">
-            <text class="form-label">金额(选填)</text>
-            <input class="form-input" type="digit" v-model="formAmount" placeholder="输入金额" />
-          </view>
-          <view class="form-item">
-            <text class="form-label">分类</text>
-            <picker class="form-picker" mode="selector" :range="categoryOptions" @change="onCategoryChange">
-              <text class="picker-text">{{ formCategory || '选择分类' }}</text>
-            </picker>
-          </view>
-          <view class="form-item">
-            <text class="form-label">备注</text>
-            <input class="form-input" type="text" v-model="formNote" placeholder="备注信息" />
-          </view>
-          <view class="form-item">
-            <text class="form-label">心情</text>
-            <view class="mood-picker">
-              <text
-                v-for="(m, i) in moodOptions"
-                :key="i"
-                class="mood-item"
-                :class="{ selected: formMood === m.value }"
-                @click="selectMood(m.value)">{{ m.icon }}</text>
-            </view>
-          </view>
+    </scroll-view>
+
+  <!-- 打卡弹窗 -->
+  <view class="dialog-mask" v-if="showDialog" @click="closeDialog">
+    <view class="dialog-card" @click.stop>
+      <text class="dialog-title">{{ dialogType ? getTypeLabel(dialogType) : '' }}打卡</text>
+      <view class="dialog-form">
+        <view class="form-item">
+          <text class="form-label">金额(选填)</text>
+          <input class="form-input" type="digit" v-model="formAmount" placeholder="输入金额" />
         </view>
-        <view class="dialog-actions">
-          <button class="dialog-btn cancel" @click="closeDialog">取消</button>
-          <button class="dialog-btn confirm" @click="doCheckin">确认打卡</button>
+        <view class="form-item">
+          <text class="form-label">分类</text>
+          <picker class="form-picker" mode="selector" :range="categoryOptions" @change="onCategoryChange">
+            <text class="picker-text">{{ formCategory || '选择分类' }}</text>
+          </picker>
+        </view>
+        <view class="form-item">
+          <text class="form-label">备注</text>
+          <input class="form-input" type="text" v-model="formNote" placeholder="备注信息" />
+        </view>
+        <view class="form-item">
+          <text class="form-label">心情</text>
+          <view class="mood-picker">
+            <text
+              v-for="(m, i) in moodOptions"
+              :key="i"
+              class="mood-item"
+              :class="{ selected: formMood === m.value }"
+              @click="selectMood(m.value)">{{ m.icon }}</text>
+          </view>
         </view>
       </view>
+      <view class="dialog-actions">
+        <button class="dialog-btn cancel" @click="closeDialog">取消</button>
+        <button class="dialog-btn confirm" @click="doCheckin">确认打卡</button>
+      </view>
     </view>
+  </view>
 
-    <!-- 能量奖励动画 -->
-    <view class="energy-animation" v-if="showEnergyAnim">
-      <text class="energy-anim-text">+{{ earnedEnergy }} ⚡</text>
-    </view>
-  </scroll-view>
+  <!-- 能量奖励动画 -->
+  <view class="energy-animation" v-if="showEnergyAnim">
+    <text class="energy-anim-text">+{{ earnedEnergy }} ⚡</text>
+  </view>
+  </view>
 </template>
 
 <script>
 import { getCheckinList, getCheckinStats, createCheckin, deleteCheckin } from '../../utils/api'
+import { parseDate } from '../../utils/format'
 
 export default {
   data() {
@@ -289,7 +296,8 @@ export default {
       var today = new Date()
       var checkDate = new Date(today.getFullYear(), today.getMonth(), today.getDate())
       for (var j = 0; j < dates.length; j++) {
-        var d = new Date(dates[j])
+        var d = parseDate(dates[j])
+        if (!d) continue
         var diff = Math.round((checkDate - d) / 86400000)
         if (diff === streak) {
           streak++