Bläddra i källkod

fix: 家庭挑战功能修复与待同意状态流转实现

Bug修复:
- 挑战页点击已存在挑战跳转至活动首页(错误URL) → 改为打开详情弹窗
- 首页家庭挑战待同意状态卡片无法点击 → 添加整卡@cardClick事件并传递challengeId
- 编辑挑战任务无法修改参与成员 → 移除v-if=editMode限制,openEditModal预填充memberIds,submitForm编辑时也传participantMemberIds

功能新增:
- 邀请参与人时任务状态为pending,设置回复时效(24/48/72h可选)
- 超时未回复的pending成员自动置为rejected
- 除发起人外全部拒绝 → 挑战状态变为not_launched(未成功发起)
- pending挑战详情页展示截止时间/超时标签

后端:
- schema.sql + DatabaseInitializer: 新增respond_deadline字段
- FamilyChallenge实体: 新增respondDeadline属性
- createChallenge: 支持respondDeadlineHours参数(默认48h)
- updateChallenge: 支持pending状态编辑 + participantMemberIds更新(方案2A:active不可改)
- respondChallenge: 惰性检查deadline超时
- 新增expirePendingChallenge方法处理超时
- getChallengeHistory纳入not_launched状态

前端:
- challenge-manage.vue: 添加详情弹窗(含同意/拒绝/编辑/删除按钮),添加回复时效选择器(24/48/72h),auto-open通过URL参数?id=xxx
- FamilyChallengeCard.vue: 整卡@click+('cardClick'),pending状态展示剩余天数/超时标签
- index-home.vue + parent-index.vue: 绑定@cardClick → goChallengeManage(ch.id)
Sisyphus Agent 3 dagar sedan
förälder
incheckning
6b2d8dd853

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2948,6 +2948,8 @@ public class DatabaseInitializer implements CommandLineRunner {
         // 家庭挑战:目标模式与目标值(aggregate=集体总合目标 / all_members=全员个人目标)
         ensureColumn("family_challenge", "target_mode", "VARCHAR(20) DEFAULT 'aggregate' COMMENT '目标模式: aggregate=集体总合/all_members=全员个人目标'");
         ensureColumn("family_challenge", "target_value", "INT DEFAULT 0 COMMENT '目标值(aggregate=家庭合计值, all_members=每人目标值)'");
+        // 迁移: family_challenge 表添加回复时效字段(待同意成员超时未回复自动拒绝)
+        ensureColumn("family_challenge", "respond_deadline", "DATETIME NULL COMMENT '回复时效截止时间(超过后未回复的 pending 成员自动置为 rejected)'");
 
         // 迁移248: 创建 health_report_edit_logs 表(健康报告修改留痕,原迁移222误置于initializeDefaultData中仅测试环境执行)
         try {

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java

@@ -46,6 +46,9 @@ public class FamilyChallenge implements Serializable {
 
     private Date createdAt;
 
+    /** 回复时效截止时间:超过后未回复的 pending 成员自动置为 rejected */
+    private Date respondDeadline;
+
     /** 非表字段:家庭总进度(aggregate 模式使用) */
     @TableField(exist = false)
     private Integer totalProgress;

+ 141 - 7
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java

@@ -61,6 +61,11 @@ public class FamilyChallengeService {
                     && c.getEndDate() != null && new Date().after(c.getEndDate())) {
                 checkAndSettle(c);
             }
+            // pending 且已过回复时效 → 自动拒绝未回复成员(惰性检查)
+            else if ("pending".equals(c.getStatus())
+                    && c.getRespondDeadline() != null && new Date().after(c.getRespondDeadline())) {
+                expirePendingChallenge(c);
+            }
         }
 
         SortUtil.applySort(wrapper);
@@ -144,7 +149,8 @@ public class FamilyChallengeService {
     /**
      * 同意/拒绝挑战(参与成员动作,即时结算方向评分)。
      * agree:status→agreed → 结算同意评分 → 除 rejected 外全部 agreed → 挑战转 active
-     * reject:status→rejected → 结算拒绝评分 → 除发起人外无剩余参与成员 → 挑战自动 cancelled(Q6)
+     * reject:status→rejected → 结算拒绝评分 → 除发起人外无剩余参与成员 → 挑战自动 not_launched(未成功发起)
+     * 回复时效:pending 且已过 respondDeadline 时,未回复成员自动置为 rejected。
      */
     @Transactional
     public Result<Void> respondChallenge(Long challengeId, Long memberId, String action) {
@@ -159,7 +165,17 @@ public class FamilyChallengeService {
         if (challenge == null) {
             return Result.error("挑战不存在");
         }
+        // 惰性超时:pending 且已过回复时效 → 未回复成员自动拒绝
+        if ("pending".equals(challenge.getStatus())
+                && challenge.getRespondDeadline() != null
+                && new Date().after(challenge.getRespondDeadline())) {
+            expirePendingChallenge(challenge);
+            challenge = familyChallengeMapper.selectById(challengeId);
+        }
         if (!"pending".equals(challenge.getStatus())) {
+            if ("not_launched".equals(challenge.getStatus())) {
+                return Result.error("挑战未成功发起");
+            }
             return Result.error("挑战当前不可操作");
         }
 
@@ -172,6 +188,11 @@ public class FamilyChallengeService {
             return Result.error("你不在该挑战参与名单中");
         }
         if (!"pending".equals(participant.getStatus())) {
+            if ("rejected".equals(participant.getStatus())
+                    && challenge.getRespondDeadline() != null
+                    && new Date().after(challenge.getRespondDeadline())) {
+                return Result.error("回复已超时");
+            }
             return Result.error("该挑战已处理过,请勿重复操作");
         }
 
@@ -221,14 +242,14 @@ public class FamilyChallengeService {
                 challengeScoreService.applyReject(challenge.getFamilyId(), memberId, memberId, agreedMemberIds, challengeId);
             }
 
-            // 除发起人外无剩余参与成员 → cancelled(Q6)
+            // 除发起人外无剩余参与成员 → not_launched(未成功发起)
             int nonRejected = 0;
             for (FamilyChallengeParticipant p : allParticipants) {
                 if (!"rejected".equals(p.getStatus())) nonRejected++;
             }
             // 非 rejected 的只剩发起人本人(且发起人是 participant)
             if (nonRejected <= 1) {
-                challenge.setStatus("cancelled");
+                challenge.setStatus("not_launched");
                 familyChallengeMapper.updateById(challenge);
             }
         }
@@ -252,6 +273,49 @@ public class FamilyChallengeService {
                         .orderByAsc(FamilyChallengeParticipant::getId));
     }
 
+    /**
+     * 回复时效超时处理:pending 成员自动置为 rejected。
+     * 除发起人外全部拒绝 → not_launched(未成功发起);否则若其余参与成员均同意 → active。
+     */
+    private void expirePendingChallenge(FamilyChallenge challenge) {
+        List<FamilyChallengeParticipant> parts = listParticipants(challenge.getId());
+        boolean expired = false;
+        for (FamilyChallengeParticipant p : parts) {
+            if ("pending".equals(p.getStatus())) {
+                p.setStatus("rejected");
+                p.setAgreedAt(new Date());
+                familyChallengeParticipantMapper.updateById(p);
+                expired = true;
+            }
+        }
+        if (!expired) return;
+        log.info("挑战回复时效已过,自动拒绝未回复成员: challengeId={}", challenge.getId());
+
+        // 除发起人外无剩余参与成员 → not_launched(未成功发起)
+        int nonRejected = 0;
+        for (FamilyChallengeParticipant p : parts) {
+            if (!"rejected".equals(p.getStatus())) nonRejected++;
+        }
+        if (nonRejected <= 1) {
+            challenge.setStatus("not_launched");
+            familyChallengeMapper.updateById(challenge);
+            return;
+        }
+        // 否则:除 rejected 外全部 agreed → active
+        boolean allAgreed = true;
+        for (FamilyChallengeParticipant p : parts) {
+            if ("rejected".equals(p.getStatus())) continue;
+            if (!"agreed".equals(p.getStatus())) {
+                allAgreed = false;
+                break;
+            }
+        }
+        if (allAgreed) {
+            challenge.setStatus("active");
+            familyChallengeMapper.updateById(challenge);
+        }
+    }
+
     @Transactional
     public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
         if (challengeId == null || childId == null) {
@@ -422,7 +486,7 @@ public class FamilyChallengeService {
         if (familyId == null) return new ArrayList<>();
         LambdaQueryWrapper<FamilyChallenge> wrapper = new LambdaQueryWrapper<FamilyChallenge>()
                 .eq(FamilyChallenge::getFamilyId, familyId)
-                .eq(FamilyChallenge::getStatus, "completed")
+                .in(FamilyChallenge::getStatus, "completed", "not_launched", "cancelled")
                 .orderByDesc(FamilyChallenge::getEndDate);
         SortUtil.applySort(wrapper);
         List<FamilyChallenge> history = familyChallengeMapper.selectList(wrapper);
@@ -593,6 +657,17 @@ public class FamilyChallengeService {
             return Result.error("家庭挑战至少需要2名成员参与");
         }
 
+        // 回复时效(小时),默认48;可选24/48/72
+        int respondHours = 48;
+        Object rawHours = params.get("respondDeadlineHours");
+        if (rawHours != null) {
+            try {
+                int h = Integer.parseInt(rawHours.toString());
+                if (h > 0) respondHours = h;
+            } catch (NumberFormatException ignored) {
+            }
+        }
+
         // 创建挑战(pending 状态,参与成员同意后转 active)
         FamilyChallenge challenge = new FamilyChallenge();
         challenge.setFamilyId(familyId);
@@ -608,6 +683,7 @@ public class FamilyChallengeService {
         challenge.setEndDate(new Date(System.currentTimeMillis() + durationDays * 24L * 60 * 60 * 1000));
         challenge.setStatus("pending");
         challenge.setCreatedAt(new Date());
+        challenge.setRespondDeadline(new Date(System.currentTimeMillis() + respondHours * 3600L * 1000));
 
         familyChallengeMapper.insert(challenge);
 
@@ -648,9 +724,10 @@ public class FamilyChallengeService {
             return Result.error("无权修改此挑战");
         }
         
-        // 验证挑战状态
-        if (!"active".equals(challenge.getStatus())) {
-            return Result.error("只能修改active状态的挑战");
+        // 验证挑战状态:pending(待同意)/ active(进行中)均可编辑
+        boolean isPending = "pending".equals(challenge.getStatus());
+        if (!isPending && !"active".equals(challenge.getStatus())) {
+            return Result.error("只能修改待同意或进行中的挑战");
         }
         
         // 验证挑战创建者权限
@@ -695,6 +772,63 @@ public class FamilyChallengeService {
         String description = params.get("description") != null ? params.get("description").toString() : challenge.getDescription();
         challenge.setDescription(description);
 
+        // 参与成员更新(仅 pending 状态可增删;active 不允许改动 - 方案2A)
+        Object rawIds = params.get("participantMemberIds");
+        if (rawIds != null) {
+            if (!isPending) {
+                return Result.error("进行中的挑战不能修改参与成员");
+            }
+            List<Long> newIds = new ArrayList<>();
+            if (rawIds instanceof java.util.Collection) {
+                for (Object o : (java.util.Collection<?>) rawIds) {
+                    if (o == null) continue;
+                    newIds.add(Long.valueOf(o.toString()));
+                }
+            }
+            if (newIds.isEmpty()) {
+                return Result.error("参与成员不能为空");
+            }
+            if (newIds.size() < 2) {
+                return Result.error("家庭挑战至少需要2名成员参与");
+            }
+            // 必须包含发起人本人
+            Long creatorMemberId = resolveMemberIdByUserId(userId);
+            if (creatorMemberId != null && !newIds.contains(creatorMemberId)) {
+                return Result.error("参与成员必须包含发起人本人");
+            }
+            // 均属本家庭
+            for (Long pid : newIds) {
+                LambdaQueryWrapper<FamilyMember> mw = new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getId, pid)
+                        .eq(FamilyMember::getFamilyId, familyId);
+                if (familyMemberMapper.selectCount(mw) == 0) {
+                    return Result.error("参与成员不属于本家庭");
+                }
+            }
+            // 重建 participant 行:保留已在列表中的,新增插入,移除的删除
+            List<FamilyChallengeParticipant> oldParts = listParticipants(challengeId);
+            Map<Long, FamilyChallengeParticipant> oldMap = new HashMap<>();
+            for (FamilyChallengeParticipant p : oldParts) oldMap.put(p.getMemberId(), p);
+            for (Long pid : newIds) {
+                if (oldMap.remove(pid) != null) continue; // 已在参与名单,保留
+                FamilyChallengeParticipant np = new FamilyChallengeParticipant();
+                np.setChallengeId(challengeId);
+                np.setMemberId(pid);
+                if (creatorMemberId != null && pid.equals(creatorMemberId)) {
+                    np.setStatus("agreed");
+                    np.setAgreedAt(new Date());
+                } else {
+                    np.setStatus("pending");
+                }
+                familyChallengeParticipantMapper.insert(np);
+                log.info("挑战编辑新增参与成员: challengeId={}, memberId={}", challengeId, pid);
+            }
+            for (FamilyChallengeParticipant removed : oldMap.values()) {
+                familyChallengeParticipantMapper.deleteById(removed.getId());
+                log.info("挑战编辑移除参与成员: challengeId={}, memberId={}", challengeId, removed.getMemberId());
+            }
+        }
+
         // 更新挑战
         familyChallengeMapper.updateById(challenge);
         

+ 2 - 1
cfc-backend/src/main/resources/schema.sql

@@ -3598,9 +3598,10 @@ CREATE TABLE IF NOT EXISTS family_challenge (
     target_value INT DEFAULT 0 COMMENT '目标值(aggregate=家庭合计值, all_members=每人目标值)',
     start_date DATE COMMENT '开始日期',
     end_date DATE COMMENT '结束日期',
-    status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled',
+    status VARCHAR(20) DEFAULT 'active' COMMENT 'pending(待同意)/active(进行中)/completed(已完成)/cancelled(已取消)/not_launched(未成功发起)',
     creator_id BIGINT COMMENT '创建者ID(NULL=系统创建)',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    respond_deadline DATETIME NULL COMMENT '回复时效截止时间(超过后未回复的 pending 成员自动置为 rejected)',
     INDEX idx_family_id (family_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战';
 

+ 17 - 5
cfc-frontend/components/FamilyChallengeCard.vue

@@ -1,5 +1,5 @@
 <template>
-  <view class="challenge-card" v-if="challenge">
+  <view class="challenge-card" v-if="challenge" @click="onCardClick">
     <view class="card-header">
       <text class="card-title">{{ challenge.title }}</text>
       <text class="card-status" :class="statusClass">
@@ -10,7 +10,7 @@
 
     <!-- 待同意:显示确认进度(pending 状态挑战) -->
     <view class="pending-section" v-if="challenge.status === 'pending'">
-      <text class="pending-hint">等待成员确认(已同意 {{ agreedCount }}/{{ totalCount }})</text>
+      <text class="pending-hint">等待成员确认(已同意 {{ agreedCount }}/{{ totalCount }}){{ deadlineText }}</text>
     </view>
 
     <!-- 成员状态徽标:rejected 成员显示"已拒绝" -->
@@ -56,13 +56,13 @@
 
     <!-- 待同意:同意/拒绝按钮(仅我的状态=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 class="respond-btn respond-agree" @click.stop="onRespond('agree')">✅ 同意</view>
+      <view class="respond-btn respond-reject" @click.stop="onRespond('reject')">✖ 拒绝</view>
     </view>
 
     <!-- 进行中:打卡 -->
     <view class="card-action" v-if="showCheckin && challenge.status === 'active'">
-      <view class="checkin-btn" @click="onCheckin">✅ 打卡</view>
+      <view class="checkin-btn" @click.stop="onCheckin">✅ 打卡</view>
     </view>
   </view>
 </template>
@@ -129,6 +129,15 @@ export default {
       if (!dateStr) return ''
       return String(dateStr).slice(0, 10)
     },
+    deadlineText: function() {
+      if (!this.challenge || !this.challenge.respondDeadline) return ''
+      var deadline = new Date(this.challenge.respondDeadline)
+      var now = new Date()
+      var diffDays = Math.round((deadline - now) / (1000 * 60 * 60 * 24))
+      if (diffDays < 0) return '(已超时)'
+      if (diffDays === 0) return '(截止今天)'
+      return '(剩余 ' + diffDays + ' 天)'
+    },
     memberPercent: function(value) {
       var target = this.challenge && this.challenge.targetValue ? this.challenge.targetValue : 1
       if (target <= 0) return 0
@@ -162,6 +171,9 @@ export default {
           }
         }
       })
+    },
+    onCardClick: function() {
+      this.$emit('cardClick', { challengeId: this.challenge.id })
     }
   }
 }

+ 422 - 9
cfc-frontend/pages/health/challenge-manage.vue

@@ -160,8 +160,23 @@
             </view>
           </view>
 
-          <view class="field" v-if="!editMode">
-            <text class="field-label">参与成员</text>
+          <view class="field-row" v-if="!editMode">
+            <view class="field half">
+              <text class="field-label">回复时效</text>
+              <view class="radio-group">
+                <view class="radio-btn" :class="{ active: form.respondDeadlineHours === '24' }" @click="form.respondDeadlineHours = '24'">24h</view>
+                <view class="radio-btn" :class="{ active: form.respondDeadlineHours === '48' }" @click="form.respondDeadlineHours = '48'">48h</view>
+                <view class="radio-btn" :class="{ active: form.respondDeadlineHours === '72' }" @click="form.respondDeadlineHours = '72'">72h</view>
+              </view>
+            </view>
+            <view class="field half">
+              <text class="field-label">超时处理</text>
+              <view class="field-tip">超时未回复的成员将自动视为拒绝</view>
+            </view>
+          </view>
+
+          <view class="field" v-if="!editMode || editingStatus === 'pending'">
+            <text class="field-label">参与成员 <text class="required" v-if="editMode && editingStatus === 'pending'">(待同意挑战可修改)</text></text>
             <view class="member-grid">
               <view
                 class="member-item"
@@ -183,6 +198,96 @@
       </view>
     </view>
 
+    <!-- 挑战详情弹窗 -->
+    <view class="overlay" v-if="showDetailModal" @click="closeDetailModal">
+      <view class="modal detail-modal" @click.stop>
+        <view class="modal-header">
+          <text class="modal-title">挑战详情</text>
+          <text class="modal-close" @click="closeDetailModal">×</text>
+        </view>
+
+        <view v-if="detailChallenge" class="detail-body">
+          <view class="detail-header">
+            <text class="detail-title">{{ detailChallenge.title }}</text>
+            <text class="detail-badge" :class="detailStatusClass">{{ detailStatusText }}</text>
+          </view>
+
+          <text class="detail-desc">{{ detailChallenge.description || '' }}</text>
+
+          <!-- 待同意:显示确认进度(pending 状态挑战) -->
+          <view class="pending-section" v-if="detailChallenge.status === 'pending'">
+            <text class="pending-hint">等待成员确认(已同意 {{ detailAgreedCount }}/{{ detailTotalCount }})</text>
+          </view>
+
+          <!-- 目标展示(两种模式) -->
+          <view class="detail-target">
+            <view class="detail-row">
+              <text class="detail-label">目标模式:</text>
+              <text>{{ detailChallenge.targetMode === 'aggregate' ? '全家合计' : '每人目标' }}</text>
+            </view>
+            <view class="detail-row">
+              <text class="detail-label">目标值:</text>
+              <text>{{ detailChallenge.targetValue }}</text>
+            </view>
+            <view class="detail-row">
+              <text class="detail-label">持续天数:</text>
+              <text>{{ detailChallenge.durationDays }}天</text>
+            </view>
+            <view class="detail-row" v-if="detailChallenge.startDate">
+              <text class="detail-label">开始日期:</text>
+              <text>{{ detailChallenge.startDate.slice(0,10) }}</text>
+            </view>
+            <view class="detail-row" v-if="detailChallenge.endDate">
+              <text class="detail-label">截止日期:</text>
+              <text>{{ detailChallenge.endDate.slice(0,10) }}</text>
+            </view>
+            <view class="detail-row" v-if="detailChallenge.respondDeadline">
+              <text class="detail-label">回复截止:</text>
+              <text :class="detailDeadlinePassed ? 'detail-label detail-label-danger' : 'detail-label'">{{ detailChallenge.respondDeadline.slice(0,16).replace('T',' ') }}{{ detailDeadlinePassed ? '(已超时)' : '' }}</text>
+            </view>
+            <view class="detail-row" v-if="detailChallenge.rewardPoints">
+              <text class="detail-label">奖励积分:</text>
+              <text class="detail-label detail-label-reward">+{{ detailChallenge.rewardPoints }}分</text>
+            </view>
+          </view>
+
+          <!-- 活动/已完成进度 -->
+          <view class="detail-progress" v-if="detailChallenge.status !== 'pending'">
+            <text class="detail-progress-title">进度</text>
+            <view class="detail-progress-bar-wrap">
+              <view class="detail-progress-bar-bg">
+                <view class="detail-progress-bar-fill" :style="{width: detailProgressPercent + '%'}"></view>
+              </view>
+              <text class="detail-progress-text">{{ detailChallenge.totalProgress || 0 }}/{{ detailChallenge.targetValue }}</text>
+            </view>
+            <view class="detail-member-list" v-if="detailChallenge.memberProgress && detailChallenge.memberProgress.length > 0">
+              <view class="detail-member-row" v-for="mp in detailChallenge.memberProgress" :key="mp.memberId">
+                <text class="detail-member-name">{{ mp.nickname }}</text>
+                <text class="detail-member-val">{{ mp.progressValue || 0 }}/{{ detailChallenge.targetValue }}</text>
+                <text class="detail-member-status" :class="'member-' + mp.status">{{ detailMemberStatusLabel(mp.status) }}</text>
+              </view>
+            </view>
+          </view>
+
+          <!-- 同意/拒绝按钮 -->
+          <view class="detail-action" v-if="detailChallenge.status === 'pending' && detailChallenge.myStatus === 'pending'">
+            <view class="respond-btn respond-agree" @click="onDetailRespond('agree')">✅ 同意</view>
+            <view class="respond-btn respond-reject" @click="onDetailRespond('reject')">✖ 拒绝</view>
+          </view>
+
+          <!-- 编辑/删除按钮(仅创建者) -->
+          <view class="detail-action" v-if="creatorCanOperate">
+            <button class="btn-edit-detail" @click="closeDetailModal; openEditModal(detailChallenge)">编辑</button>
+            <button class="btn-delete-detail" @click="confirmDeleteDetail">删除</button>
+          </view>
+        </view>
+
+        <view class="modal-footer">
+          <button class="btn-cancel" @click="closeDetailModal">关闭</button>
+        </view>
+      </view>
+    </view>
+
     <!-- 家庭成员不足提示弹窗 -->
     <view class="overlay" v-if="showMemberPrompt" @click="showMemberPrompt = false">
       <view class="modal prompt-modal" @click.stop>
@@ -224,7 +329,7 @@
 </template>
 
 <script>
-import { getChallengeList, getChallengeHistory, createChallenge, updateChallenge, deleteChallenge, getChallengeTemplates, getFamilyMemberList, getMyMembership } from '../../utils/api.js'
+import { getChallengeList, getChallengeHistory, createChallenge, updateChallenge, deleteChallenge, getChallengeTemplates, getFamilyMemberList, getMyMembership, respondChallenge } from '../../utils/api.js'
 import store from '../../store/index.js'
 
 export default {
@@ -237,10 +342,13 @@ export default {
       familyMembers: [],
       selectedMemberIds: [],
       showModal: false,
+      showDetailModal: false,
+      detailChallenge: null,
       showMemberPrompt: false,
       showAddChildPrompt: false,
       editMode: false,
       editingId: null,
+      editingStatus: null,
       form: {
         title: '',
         description: '',
@@ -248,12 +356,60 @@ export default {
         targetMode: 'all_members',
         targetValue: '7',
         durationDays: '7',
-        rewardPoints: '50'
+        rewardPoints: '50',
+        respondDeadlineHours: '48'
       },
       isFreeMember: false
     }
   },
-  onLoad() {
+  computed: {
+    detailStatusClass: function() {
+      if (!this.detailChallenge) return ''
+      if (this.detailChallenge.status === 'active') return 'status-active'
+      if (this.detailChallenge.status === 'pending') return 'status-pending'
+      if (this.detailChallenge.status === 'completed') return 'status-completed'
+      if (this.detailChallenge.status === 'cancelled') return 'status-cancelled'
+      return 'status-active'
+    },
+    detailStatusText: function() {
+      if (!this.detailChallenge) return ''
+      if (this.detailChallenge.status === 'active') return '进行中'
+      if (this.detailChallenge.status === 'pending') return '待同意'
+      if (this.detailChallenge.status === 'completed') return '已完成'
+      if (this.detailChallenge.status === 'cancelled') return '已取消'
+      return '进行中'
+    },
+    detailAgreedCount: function() {
+      if (!this.detailChallenge || !this.detailChallenge.memberProgress) return 0
+      var count = 0
+      for (var i = 0; i < this.detailChallenge.memberProgress.length; i++) {
+        if (this.detailChallenge.memberProgress[i].status === 'agreed') count++
+      }
+      return count
+    },
+    detailTotalCount: function() {
+      if (!this.detailChallenge || !this.detailChallenge.memberProgress) return 0
+      return this.detailChallenge.memberProgress.length
+    },
+    detailProgressPercent: function() {
+      if (!this.detailChallenge || this.detailChallenge.targetValue <= 0) return 0
+      return Math.round(this.detailChallenge.totalProgress / this.detailChallenge.targetValue * 100)
+    },
+    detailDeadlinePassed: function() {
+      if (!this.detailChallenge || !this.detailChallenge.respondDeadline) return false
+      return new Date().getTime() > new Date(this.detailChallenge.respondDeadline).getTime()
+    },
+    creatorCanOperate: function() {
+      if (!this.detailChallenge) return false
+      return String(this.detailChallenge.creatorId) === String(this.$store.state.userId)
+    },
+    detailMemberStatusLabel: function(status) {
+      var map = { pending: '待确认', agreed: '已同意', rejected: '已拒绝' }
+      return map[status] || status
+    }
+  },
+  onLoad(options) {
+    this._pendingChallengeId = options && options.id
     this.loadTemplates()
     this.loadMembers()
     this.checkMembership()
@@ -283,6 +439,7 @@ export default {
         ])
         this.activeChallenges = Array.isArray(res1.data) ? res1.data : []
         this.historyChallenges = Array.isArray(res2.data) ? res2.data : []
+        this.openPendingChallengeIfNeeded()
       } catch (e) {
         this.activeChallenges = []
         this.historyChallenges = []
@@ -290,6 +447,22 @@ export default {
         this.loading = false
       }
     },
+    // 从首页卡片点击进入时,自动展开对应挑战详情弹窗
+    openPendingChallengeIfNeeded() {
+      var id = this._pendingChallengeId
+      if (!id) return
+      this._pendingChallengeId = null
+      var found = null
+      for (var i = 0; i < this.activeChallenges.length; i++) {
+        if (String(this.activeChallenges[i].id) === String(id)) { found = this.activeChallenges[i]; break }
+      }
+      if (!found) {
+        for (var j = 0; j < this.historyChallenges.length; j++) {
+          if (String(this.historyChallenges[j].id) === String(id)) { found = this.historyChallenges[j]; break }
+        }
+      }
+      if (found) this.viewChallenge(found)
+    },
     async loadTemplates() {
       try {
         const res = await getChallengeTemplates()
@@ -376,7 +549,8 @@ export default {
       this.editingId = null
       this.form = {
         title: '', description: '', challengeType: 'custom',
-        targetMode: 'all_members', targetValue: '7', durationDays: '7', rewardPoints: '50'
+        targetMode: 'all_members', targetValue: '7', durationDays: '7', rewardPoints: '50',
+        respondDeadlineHours: '48'
       }
       this.selectedMemberIds = this.familyMembers.map(m => m.id)
       this.showModal = true
@@ -384,6 +558,7 @@ export default {
     openEditModal(ch) {
       this.editMode = true
       this.editingId = ch.id
+      this.editingStatus = ch.status
       this.form = {
         title: ch.title || '',
         description: ch.description || '',
@@ -393,6 +568,14 @@ export default {
         durationDays: String(ch.durationDays || 7),
         rewardPoints: String(ch.rewardPoints || 50)
       }
+      // 编辑时预填充参与成员:从 memberProgress 读取所有状态的成员
+      var selected = []
+      if (ch.memberProgress && ch.memberProgress.length) {
+        for (var i = 0; i < ch.memberProgress.length; i++) {
+          selected.push(ch.memberProgress[i].memberId)
+        }
+      }
+      this.selectedMemberIds = selected
       this.showModal = true
     },
     closeModal() {
@@ -421,7 +604,8 @@ export default {
         durationDays: parseInt(this.form.durationDays || 7, 10),
         rewardPoints: parseInt(this.form.rewardPoints || 0, 10)
       }
-      if (!this.editMode) data.participantMemberIds = this.selectedMemberIds
+      if (!this.editMode || this.editingStatus === 'pending') data.participantMemberIds = this.selectedMemberIds
+      if (!this.editMode && this.form.respondDeadlineHours) data.respondDeadlineHours = parseInt(this.form.respondDeadlineHours, 10)
 
       this.loading = true
       try {
@@ -464,8 +648,58 @@ export default {
       })
     },
     viewChallenge(ch) {
-      // 跳转到挑战详情
-      uni.navigateTo({ url: '/pages/activity/index' })
+      // 打开挑战详情弹窗
+      this.detailChallenge = ch
+      this.showDetailModal = true
+    },
+    closeDetailModal() {
+      this.showDetailModal = false
+      this.detailChallenge = null
+    },
+    async onDetailRespond(action) {
+      var self = this
+      uni.showModal({
+        title: '确认操作',
+        content: action === 'agree' ? '确认同意参与该挑战吗?' : '确认拒绝该挑战吗?',
+        success: function(res) {
+          if (res.confirm) {
+            respondChallenge({ challengeId: self.detailChallenge.id, action: action }).then(function(resp) {
+              if (resp && resp.code === 200) {
+                uni.showToast({ title: action === 'agree' ? '已同意' : '已拒绝', icon: 'success' })
+                self.closeDetailModal()
+                self.loadData()
+              } else {
+                uni.showToast({ title: (resp && resp.message) || '操作失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.showToast({ title: '操作失败,请重试', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    confirmDeleteDetail() {
+      var self = this
+      if (!this.detailChallenge) return
+      uni.showModal({
+        title: '确认删除',
+        content: '确定要取消这个挑战吗?',
+        success: function(r) {
+          if (r.confirm) {
+            deleteChallenge(self.detailChallenge.id).then(function(res) {
+              if (res.code === 200) {
+                uni.showToast({ title: '已删除', icon: 'success' })
+                self.closeDetailModal()
+                self.loadData()
+              } else {
+                uni.showToast({ title: res.message || '删除失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.showToast({ title: '删除失败', icon: 'none' })
+            })
+          }
+        }
+      })
     },
     goToAddMember() {
       this.showMemberPrompt = false
@@ -854,6 +1088,185 @@ export default {
   border-color: #10B981;
 }
 
+/* 详情弹窗 */
+.detail-modal {
+  max-width: 90vw;
+  padding: 32rpx 28rpx;
+  background: #fff;
+  border-radius: 24rpx;
+  box-shadow: 0 8rpx 48rpx rgba(0,0,0,0.12);
+}
+.detail-modal .modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24rpx;
+  padding-bottom: 16rpx;
+  border-bottom: 2rpx solid #E2E8F0;
+}
+.detail-modal .modal-title {
+  font-size: 34rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.detail-modal .modal-close {
+  font-size: 30rpx;
+  color: #64748B;
+  cursor: pointer;
+}
+.detail-modal .modal-close:hover {
+  color: #DC2626;
+}
+.detail-body {
+  font-size: 26rpx;
+  line-height: 1.6;
+  color: #475569;
+}
+.detail-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12rpx;
+}
+.detail-header .detail-title {
+  font-size: 30rpx;
+  font-weight: 700;
+  color: #1E293B;
+}
+.detail-header .detail-badge {
+  font-size: 22rpx;
+  padding: 2rpx 8rpx;
+  border-radius: 999rpx;
+}
+.detail-header .status-active { background: #DCFCE7; color: #16A34A; }
+.detail-header .status-pending { background: #FEF3C7; color: #B45309; }
+.detail-header .status-completed { background: #F1F5F9; color: #64748B; }
+.detail-header .status-cancelled { background: #FEE2E2; color: #DC2626; }
+.detail-header .status-not-launched { background: #E5E7EB; color: #6B7280; }
+.detail-desc {
+  font-size: 24rpx;
+  color: #64748B;
+  line-height: 1.4;
+  margin-bottom: 16rpx;
+  display: block;
+}
+.detail-target {
+  margin-top: 16rpx;
+}
+.detail-row {
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 8rpx;
+}
+.detail-row .detail-label {
+  font-size: 24rpx;
+  color: #647489;
+  font-weight: 500;
+}
+.detail-row .detail-value {
+  font-size: 26rpx;
+  color: #10B981;
+  font-weight: 600;
+}
+.detail-row .detail-value-danger {
+  font-size: 26rpx;
+  color: #DC2626;
+}
+.detail-progress {
+  margin-top: 16rpx;
+  padding-top: 16rpx;
+  border-top: 2rpx solid #E2E8F0;
+}
+.detail-progress-title {
+  font-size: 24rpx;
+  color: #64748B;
+  margin-bottom: 12rpx;
+}
+.detail-progress-bar-wrap {
+  height: 10rpx;
+  background: #E2E8F0;
+  border-radius: 999rpx;
+  overflow: hidden;
+  margin-bottom: 8rpx;
+}
+.detail-progress-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #10B981, #34D399);
+  border-radius: 999rpx;
+  transition: width 0.3s ease;
+}
+.detail-progress-text {
+  display: flex;
+  justify-content: space-between;
+  font-size: 22rpx;
+  color: #64748B;
+}
+.detail-member-list {
+  margin-top: 12rpx;
+}
+.detail-member-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 8rpx 0;
+  border-bottom: 1rpx solid #F1F5F9;
+  font-size: 24rpx;
+}
+.detail-member-name {
+  color: #1E293B;
+  font-weight: 500;
+}
+.detail-member-val {
+  color: #64748B;
+  font-weight: 400;
+}
+.detail-member-status {
+  padding: 2rpx 6rpx;
+  border-radius: 999rpx;
+  font-size: 20rpx;
+  font-weight: 400;
+}
+.detail-member-pending { background: #FEF3C7; color: #B45309; }
+.detail-member-agreed { background: #DCFCE7; color: #16A34A; }
+.detail-member-rejected { background: #FEE2E2; color: #DC2626; }
+.detail-action {
+  display: flex;
+  gap: 12rpx;
+  margin-top: 20rpx;
+}
+.detail-action .respond-btn {
+  flex: 1;
+  height: 72rpx;
+  line-height: 72rpx;
+  font-size: 28rpx;
+  font-weight: 500;
+}
+.detail-action .btn-edit-detail {
+  flex: 1;
+  height: 72rpx;
+  line-height: 72rpx;
+  font-size: 28rpx;
+  background: #F3F4F6;
+  color: #374151;
+  border-radius: 12rpx;
+}
+.detail-action .btn-delete-detail {
+  flex: 1;
+  height: 72rpx;
+  line-height: 72rpx;
+  font-size: 28rpx;
+  background: #FEE2E2;
+  color: #DC2626;
+  border-radius: 12rpx;
+}
+.detail-label-reward {
+  color: #F59E0B;
+  font-weight: 500;
+}
+.detail-label-danger {
+  color: #DC2626;
+}
+
 /* 底部按钮 */
 .modal-footer {
   display: flex;

+ 5 - 2
cfc-frontend/pages/home-pages/parent-index.vue

@@ -53,7 +53,8 @@
         <FamilyChallengeCard
           v-for="ch in activeChallenges"
           :key="ch.id"
-          :challenge="ch" />
+          :challenge="ch"
+          @cardClick="goToChallengeManage(ch.id)" />
       </view>
 
     <!-- ===== 健康圈入口(P3:社区扩散) ===== -->
@@ -1167,7 +1168,9 @@ export default {
       })
     },
     addTask() { uni.navigateTo({ url: '/pages/tasks/create-task' }) },
-    goToChallengeManage() { uni.navigateTo({ url: '/pages/health/challenge-manage' }) },
+    goToChallengeManage: function(id) {
+      uni.navigateTo({ url: '/pages/health/challenge-manage?id=' + id })
+    },
     goToReview() { uni.navigateTo({ url: '/pages/tasks/review' }) },
     goToWishApprove() { uni.navigateTo({ url: '/pages/wishes/approve' }) },
     goToAssessmentReport() { uni.navigateTo({ url: '/pages/assessment/report' }) },

+ 4 - 3
cfc-frontend/pages/index-home/index.vue

@@ -172,7 +172,8 @@
         :challenge="ch"
         :showCheckin="showCheckinBtn"
         @checkin="onChallengeCheckin"
-        @respond="onChallengeRespond" />
+        @respond="onChallengeRespond"
+        @cardClick="goChallengeManage(ch.id)" />
       <view v-if="activeChallenges.length === 0" class="challenge-empty">
         <text class="challenge-empty-icon">🏆</text>
         <text class="challenge-empty-text">您还没有挑战,去添加吧</text>
@@ -1502,8 +1503,8 @@ title: '添加家庭成员',
     goActivities: function() {
       uni.navigateTo({ url: '/pages/activity/index' })
     },
-    goChallengeManage: function() {
-      uni.navigateTo({ url: '/pages/health/challenge-manage' })
+    goChallengeManage: function(id) {
+      uni.navigateTo({ url: '/pages/health/challenge-manage?id=' + id })
     },
     goArticles: function() {
       uni.navigateTo({ url: '/pages/article-center/index' })