Răsfoiți Sursa

feat(challenge): 小程序同意/拒绝+参与成员多选+pending展示

asus 2 luni în urmă
părinte
comite
dd7aa00079

+ 110 - 1
cfc-frontend/components/FamilyChallengeCard.vue

@@ -3,11 +3,21 @@
     <view class="card-header">
       <text class="card-title">{{ challenge.title }}</text>
       <text class="card-status" :class="statusClass">
-        {{ challenge.status === 'active' ? '进行中' : (challenge.status === 'cancelled' ? '已取消' : '已完成') }}
+        {{ statusText }}
       </text>
     </view>
     <text class="card-desc">{{ challenge.description || '' }}</text>
 
+    <!-- 待同意:显示确认进度(pending 状态挑战) -->
+    <view class="pending-section" v-if="challenge.status === 'pending'">
+      <text class="pending-hint">等待成员确认(已同意 {{ agreedCount }}/{{ totalCount }})</text>
+    </view>
+
+    <!-- 成员状态徽标:rejected 成员显示"已拒绝" -->
+    <view class="member-status-row" v-if="hasRejected">
+      <text class="member-status-tag member-rejected" v-for="mp in rejectedMembers" :key="getMemberKey(mp)">{{ mp.nickname }} 已拒绝</text>
+    </view>
+
     <!-- 目标展示(两种模式) -->
     <view class="target-section" v-if="challenge.targetMode">
       <view class="target-header">
@@ -44,6 +54,13 @@
       <text class="detail-label reward" v-if="challenge.rewardPoints">· 🏆 +{{ challenge.rewardPoints }}分</text>
     </view>
 
+    <!-- 待同意:同意/拒绝按钮(仅我的状态=pending 时显示) -->
+    <view class="card-action" v-if="challenge.status === 'pending' && challenge.myStatus === 'pending'">
+      <view class="respond-btn respond-agree" @click="onRespond('agree')">✅ 同意</view>
+      <view class="respond-btn respond-reject" @click="onRespond('reject')">✖ 拒绝</view>
+    </view>
+
+    <!-- 进行中:打卡 -->
     <view class="card-action" v-if="showCheckin && challenge.status === 'active'">
       <view class="checkin-btn" @click="onCheckin">✅ 打卡</view>
     </view>
@@ -60,6 +77,36 @@ export default {
     statusClass: function() {
       return 'status-' + (this.challenge && this.challenge.status ? this.challenge.status : 'active')
     },
+    statusText: function() {
+      if (!this.challenge) return ''
+      if (this.challenge.status === 'active') return '进行中'
+      if (this.challenge.status === 'pending') return '待同意'
+      if (this.challenge.status === 'cancelled') return '已取消'
+      return '已完成'
+    },
+    agreedCount: function() {
+      var list = this.challenge && this.challenge.memberProgress ? this.challenge.memberProgress : []
+      var count = 0
+      for (var i = 0; i < list.length; i++) {
+        if (list[i].status === 'agreed') count++
+      }
+      return count
+    },
+    totalCount: function() {
+      var list = this.challenge && this.challenge.memberProgress ? this.challenge.memberProgress : []
+      return list.length
+    },
+    hasRejected: function() {
+      return this.rejectedMembers.length > 0
+    },
+    rejectedMembers: function() {
+      var list = this.challenge && this.challenge.memberProgress ? this.challenge.memberProgress : []
+      var result = []
+      for (var i = 0; i < list.length; i++) {
+        if (list[i].status === 'rejected') result.push(list[i])
+      }
+      return result
+    },
     targetModeLabel: function() {
       if (!this.challenge) return ''
       if (this.challenge.targetMode === 'aggregate') return '🎯 全家目标'
@@ -88,6 +135,22 @@ export default {
       var p = Math.round((value || 0) / target * 100)
       return Math.min(p, 100)
     },
+    getMemberKey: function(mp) {
+      return mp.memberId
+    },
+    onRespond: function(action) {
+      var self = this
+      var tip = action === 'agree' ? '确认同意参与该挑战吗?' : '确认拒绝该挑战吗?'
+      uni.showModal({
+        title: '确认操作',
+        content: tip,
+        success: function(res) {
+          if (res.confirm) {
+            self.$emit('respond', { challengeId: self.challenge.id, action: action })
+          }
+        }
+      })
+    },
     onCheckin: function() {
       var self = this
       uni.showModal({
@@ -140,6 +203,36 @@ export default {
   display: block;
 }
 
+/* 待同意提示区 */
+.pending-section {
+  background: #FEF3C7;
+  border-radius: 10rpx;
+  padding: 10rpx 12rpx;
+  margin-bottom: 6rpx;
+}
+.pending-hint {
+  font-size: 22rpx;
+  color: #B45309;
+  font-weight: 600;
+}
+
+/* 成员状态徽标行 */
+.member-status-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8rpx;
+  margin-bottom: 6rpx;
+}
+.member-status-tag {
+  font-size: 18rpx;
+  padding: 2rpx 10rpx;
+  border-radius: 999rpx;
+}
+.member-rejected {
+  background: #FEE2E2;
+  color: #DC2626;
+}
+
 /* 目标区 */
 .target-section {
   background: #F8FAFC;
@@ -251,4 +344,20 @@ export default {
   font-size: 22rpx;
   font-weight: 600;
 }
+.respond-btn {
+  padding: 10rpx 24rpx;
+  border-radius: 999rpx;
+  font-size: 22rpx;
+  font-weight: 600;
+  margin-left: 12rpx;
+}
+.respond-agree {
+  background: #10B981;
+  color: #FFFFFF;
+}
+.respond-reject {
+  background: #FFFFFF;
+  color: #DC2626;
+  border: 2rpx solid #FCA5A5;
+}
 </style>

+ 59 - 7
cfc-frontend/pages/health/challenge-manage.vue

@@ -125,6 +125,14 @@
               <input class="form-input" type="number" v-model="form.rewardPoints" placeholder="50" />
         </view>
       </view>
+          <view class="form-item" v-if="!editMode">
+            <text class="form-label">参与成员</text>
+            <view class="member-picker">
+              <text class="member-pick-item" :class="{ active: isMemberSelected(m.id) }"
+                    v-for="m in familyMembers" :key="m.id"
+                    @click="toggleMember(m.id)">{{ m.nickname }}</text>
+            </view>
+          </view>
     </view>
         <view class="modal-footer">
           <view class="modal-btn modal-btn-cancel" @click="closeModal">取消</view>
@@ -136,7 +144,7 @@
 </template>
 
 <script>
-import { getChallengeList, getChallengeHistory, createChallenge, updateChallenge, deleteChallenge, getChallengeTemplates, getParentDashboard } from '../../utils/api.js'
+import { getChallengeList, getChallengeHistory, createChallenge, updateChallenge, deleteChallenge, getChallengeTemplates, getParentDashboard, listFamilyMembers } from '../../utils/api.js'
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
 
 export default {
@@ -148,6 +156,8 @@ export default {
       templates: [],
       activeChallenges: [],
       historyChallenges: [],
+      familyMembers: [],
+      selectedMemberIds: [],
       showModal: false,
       editMode: false,
       editingId: null,
@@ -170,15 +180,10 @@ export default {
     }
   },
   onLoad() {
-    var role = this.getUserRole()
-    if (role !== 'parent') {
-      uni.showToast({ title: '仅家长可管理挑战', icon: 'none' })
-      setTimeout(function() { uni.navigateBack() }, 1000)
-      return
-    }
     this.loadFamilyId()
     this.loadData()
     this.loadTemplates()
+    this.loadMembers()
   },
   onShow() {
     if (this.familyId) {
@@ -248,6 +253,32 @@ export default {
       }
       return map[type] || '🎯'
     },
+    loadMembers: function() {
+      var self = this
+      listFamilyMembers().then(function(res) {
+        var list = (res && res.data) || []
+        self.familyMembers = Array.isArray(list) ? list : []
+        // 默认全选
+        self.selectedMemberIds = []
+        for (var i = 0; i < self.familyMembers.length; i++) {
+          self.selectedMemberIds.push(self.familyMembers[i].id)
+        }
+      }).catch(function() {
+        self.familyMembers = []
+        self.selectedMemberIds = []
+      })
+    },
+    isMemberSelected: function(memberId) {
+      return this.selectedMemberIds.indexOf(memberId) >= 0
+    },
+    toggleMember: function(memberId) {
+      var idx = this.selectedMemberIds.indexOf(memberId)
+      if (idx >= 0) {
+        this.selectedMemberIds.splice(idx, 1)
+      } else {
+        this.selectedMemberIds.push(memberId)
+      }
+    },
     createFromTemplate: function(template) {
       this.editMode = false
       this.editingId = null
@@ -303,6 +334,9 @@ export default {
         durationDays: parseInt(self.form.durationDays || '7', 10),
         rewardPoints: parseInt(self.form.rewardPoints || '0', 10)
       }
+      if (!self.editMode) {
+        data.participantMemberIds = self.selectedMemberIds
+      }
       self.loading = true
       var promise
       if (self.editMode && self.editingId) {
@@ -654,4 +688,22 @@ export default {
   background: #10B981;
   color: #fff;
 }
+.member-picker {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+.member-pick-item {
+  padding: 8rpx 20rpx;
+  border-radius: 999rpx;
+  background: #F1F5F9;
+  color: #475569;
+  font-size: 22rpx;
+  border: 2rpx solid transparent;
+}
+.member-pick-item.active {
+  background: #DCFCE7;
+  color: #16A34A;
+  border-color: #10B981;
+}
 </style>

+ 17 - 2
cfc-frontend/pages/index-home/index.vue

@@ -110,7 +110,8 @@
         :key="ch.id"
         :challenge="ch"
         :showCheckin="showCheckinBtn"
-        @checkin="onChallengeCheckin" />
+        @checkin="onChallengeCheckin"
+        @respond="onChallengeRespond" />
     </view>
     <view v-else>
       <FamilyEmptyState type="card" />
@@ -379,7 +380,7 @@ import CircleCard from '../../components/CircleCard.vue'
 import CircleDetail from '../../components/CircleDetail.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
-import { acceptParentInvite, productList, getActivityList, getFeaturedArticles, getFamilyEnergySandbox, getVisibleFamilyMembers, getTodayTasksByCategory, getChildren, getEnergyOverview, getProductsByDomain, getChallengeList, getMyCircles, discoverCircles, joinGeneralCircle, leaveGeneralCircle, switchToFamilyMember, getMyMembership, updateChallengeProgress } from '../../utils/api.js'
+import { acceptParentInvite, productList, getActivityList, getFeaturedArticles, getFamilyEnergySandbox, getVisibleFamilyMembers, getTodayTasksByCategory, getChildren, getEnergyOverview, getProductsByDomain, getChallengeList, getMyCircles, discoverCircles, joinGeneralCircle, leaveGeneralCircle, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge } from '../../utils/api.js'
 import store from '../../store/index.js'
 import config from '@/config.js'
 
@@ -743,6 +744,20 @@ export default {
         uni.showToast({ title: '打卡失败,请重试', icon: 'none' })
       })
     },
+    onChallengeRespond: function(payload) {
+      var self = this
+      respondChallenge({ challengeId: payload.challengeId, action: payload.action }).then(function(res) {
+        if (res && res.code === 200) {
+          uni.showToast({ title: payload.action === 'agree' ? '已同意' : '已拒绝', icon: 'success' })
+          self.loadChallenges()
+        } else {
+          uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
+          self.loadChallenges()
+        }
+      }).catch(function() {
+        uni.showToast({ title: '操作失败,请重试', icon: 'none' })
+      })
+    },
     loadMyCircles: function() {
       var self = this
       if (!this.currentChildId) return

+ 1 - 0
cfc-frontend/utils/api.js

@@ -1938,6 +1938,7 @@ export const getFamilyRanking = (data) => request('/api/health/family/ranking',
 export const getChallengeList = (data) => request('/api/health/challenge/list', 'POST', data)
 export const getChallengeHistory = (data) => request('/api/health/challenge/history', 'POST', data)
 export const updateChallengeProgress = (data) => request('/api/health/challenge/progress', 'POST', data)
+export const respondChallenge = (data) => request('/api/health/challenge/respond', 'POST', data)
 export const createChallenge = (data) => request('/api/health/challenge/create', 'POST', data)
 export const updateChallenge = (id, data) => request('/api/health/challenge/update/' + id, 'POST', data)
 export const deleteChallenge = (id) => request('/api/health/challenge/delete/' + id, 'POST', data)