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

chore: auto bump version and changelog [skip ci]

iwt пре 1 месец
родитељ
комит
f29e880b0b

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

@@ -5470,6 +5470,28 @@ try {
     } catch (Exception e) {
         log.warn("创建daily_feedback表失败: {}", e.getMessage());
     }
+
+    // 迁移104: 创建 health_alerts 表(健康行为智能预警)
+    try {
+        jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS health_alerts (" +
+            "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+            "user_id BIGINT COMMENT '用户ID', " +
+            "child_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
+            "rule_code VARCHAR(10) NOT NULL COMMENT '规则编码: R001-R006', " +
+            "alert_text VARCHAR(200) NOT NULL COMMENT '预警文案', " +
+            "action_suggestion VARCHAR(200) COMMENT '行动建议', " +
+            "severity VARCHAR(10) DEFAULT 'medium' COMMENT '严重程度: high/medium/low', " +
+            "dismissed TINYINT DEFAULT 0 COMMENT '是否已关闭: 0未关闭 1已关闭', " +
+            "dismissed_at DATETIME COMMENT '关闭时间', " +
+            "alert_date DATETIME NOT NULL COMMENT '预警日期', " +
+            "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+            "INDEX idx_child_date (child_id, alert_date), " +
+            "INDEX idx_rule (rule_code)" +
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康预警记录'");
+        log.info("已创建health_alerts表");
+    } catch (Exception e) {
+        log.warn("创建health_alerts表失败: {}", e.getMessage());
+    }
     }
 
     private void runMigration82() {

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthAlertController.java

@@ -0,0 +1,39 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.HealthAlert;
+import com.etotem.cfc.service.HealthAlertService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+@Tag(name = "健康预警", description = "P1-2 健康行为智能预警")
+@RestController
+@RequestMapping("/api/health/alert")
+public class HealthAlertController {
+
+    @Resource
+    private HealthAlertService healthAlertService;
+
+    @Operation(summary = "获取活跃预警列表")
+    @PostMapping("/list")
+    public Result<List<HealthAlert>> list(@RequestBody Map<String, Object> params) {
+        Long childId = params.get("childId") != null
+                ? Long.valueOf(params.get("childId").toString()) : null;
+        return Result.success(healthAlertService.getActiveAlerts(childId));
+    }
+
+    @Operation(summary = "关闭预警")
+    @PostMapping("/dismiss")
+    public Result<String> dismiss(@RequestBody Map<String, Object> params) {
+        Long id = params.get("id") != null
+                ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) return Result.error("id不能为空");
+        healthAlertService.dismiss(id);
+        return Result.success("ok");
+    }
+}

+ 46 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthAlert.java

@@ -0,0 +1,46 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("health_alerts")
+public class HealthAlert implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 家庭成员ID */
+    private Long childId;
+
+    /** 规则编码: R001-R006 */
+    private String ruleCode;
+
+    /** 预警文案 */
+    private String alertText;
+
+    /** 行动建议 */
+    private String actionSuggestion;
+
+    /** 严重程度: high/medium/low */
+    private String severity;
+
+    /** 是否已关闭: 0未关闭 1已关闭 */
+    private Integer dismissed;
+
+    /** 关闭时间 */
+    private Date dismissedAt;
+
+    /** 预警日期 */
+    private Date alertDate;
+
+    private Date createdAt;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthAlertMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.HealthAlert;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface HealthAlertMapper extends BaseMapper<HealthAlert> {
+}

+ 280 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthAlertEngine.java

@@ -0,0 +1,280 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.*;
+import com.etotem.cfc.mapper.*;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Component
+public class HealthAlertEngine {
+
+    @Resource
+    private HealthCheckinMapper healthCheckinMapper;
+
+    @Resource
+    private HealthSleepRecordMapper healthSleepRecordMapper;
+
+    @Resource
+    private HealthExerciseRecordMapper healthExerciseRecordMapper;
+
+    @Resource
+    private HealthWaterRecordMapper healthWaterRecordMapper;
+
+    @Resource
+    private MicroActionRecordMapper microActionRecordMapper;
+
+    /** 规则定义:编码 -> (文案, 建议, 严重程度) */
+    private static final Map<String, String[]> RULES = new LinkedHashMap<>();
+
+    static {
+        RULES.put("R001", new String[]{
+            "您已连续%d天睡眠质量下降",
+            "睡前1小时放下手机,喝杯温牛奶",
+            "medium"
+        });
+        RULES.put("R002", new String[]{
+            "您已经%d天没有运动了",
+            "站起来走3分钟就是好的开始!",
+            "medium"
+        });
+        RULES.put("R003", new String[]{
+            "近%d天饮水量偏低",
+            "每1小时喝一杯水,设置定时提醒",
+            "low"
+        });
+        RULES.put("R004", new String[]{
+            "今天运动量比平时多一半",
+            "适当拉伸,不要过度训练",
+            "medium"
+        });
+        RULES.put("R005", new String[]{
+            "您已经%d天没有健康打卡",
+            "从最小的动作开始:喝一杯水 ☕️",
+            "high"
+        });
+        RULES.put("R006", new String[]{
+            "今天的健康打卡还没完成哦",
+            "点我完成今日微行动",
+            "high"
+        });
+    }
+
+    public static Map<String, String[]> getRules() {
+        return RULES;
+    }
+
+    /**
+     * 评估单个成员的所有预警规则
+     * @return 命中的规则列表(含填充后的文案和严重程度)
+     */
+    public List<Map<String, String>> evaluate(Long childId) {
+        if (childId == null) return Collections.emptyList();
+        List<Map<String, String>> results = new ArrayList<>();
+
+        Calendar cal = Calendar.getInstance();
+        Date today = cal.getTime();
+
+        // R006: 今日未打卡(18:00后触发)
+        cal.set(Calendar.HOUR_OF_DAY, 18);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        Date triggerTime = cal.getTime();
+        if (today.after(triggerTime)) {
+            String todayStr = new SimpleDateFormat("yyyy-MM-dd").format(today);
+            long todayCheckinCount = healthCheckinMapper.selectCount(
+                new LambdaQueryWrapper<HealthCheckin>()
+                    .eq(HealthCheckin::getChildId, childId)
+                    .apply("DATE(checkin_date) = {0}", todayStr)
+            );
+            long todayMicroCount = microActionRecordMapper.selectCount(
+                new LambdaQueryWrapper<MicroActionRecord>()
+                    .eq(MicroActionRecord::getChildId, childId)
+                    .apply("DATE(completed_date) = {0}", todayStr)
+            );
+            if (todayCheckinCount == 0 && todayMicroCount == 0) {
+                results.add(buildResult("R006", 0));
+                return results;
+            }
+        }
+
+        // R005: 连续7天无打卡
+        int noCheckinDays = countConsecutiveDaysWithoutCheckin(childId, today);
+        if (noCheckinDays >= 7) {
+            results.add(buildResult("R005", noCheckinDays));
+        }
+
+        // R001: 连续3天睡眠评分下降
+        if (checkSleepDecline(childId)) {
+            results.add(buildResult("R001", 3));
+        }
+
+        // R002: 连续5天无运动
+        int noExerciseDays = countDaysSinceLastExercise(childId);
+        if (noExerciseDays >= 5) {
+            results.add(buildResult("R002", noExerciseDays));
+        }
+
+        // R003: 饮水<500ml 连续2天
+        int lowWaterDays = countConsecutiveLowWaterDays(childId);
+        if (lowWaterDays >= 2) {
+            results.add(buildResult("R003", lowWaterDays));
+        }
+
+        // R004: 运动强度突增>50%
+        if (checkExerciseSurge(childId)) {
+            results.add(buildResult("R004", 0));
+        }
+
+        return results;
+    }
+
+    private Map<String, String> buildResult(String ruleCode, int days) {
+        String[] rule = RULES.get(ruleCode);
+        Map<String, String> result = new HashMap<>();
+        String text = String.format(rule[0], days);
+        result.put("ruleCode", ruleCode);
+        result.put("alertText", text);
+        result.put("actionSuggestion", rule[1]);
+        result.put("severity", rule[2]);
+        return result;
+    }
+
+    /** 计算连续无打卡天数(从今天往回数) */
+    private int countConsecutiveDaysWithoutCheckin(Long childId, Date today) {
+        int days = 0;
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(today);
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+
+        for (int i = 0; i < 30; i++) {
+            String dateStr = sdf.format(cal.getTime());
+            long count = healthCheckinMapper.selectCount(
+                new LambdaQueryWrapper<HealthCheckin>()
+                    .eq(HealthCheckin::getChildId, childId)
+                    .apply("DATE(checkin_date) = {0}", dateStr)
+            );
+            long microCount = microActionRecordMapper.selectCount(
+                new LambdaQueryWrapper<MicroActionRecord>()
+                    .eq(MicroActionRecord::getChildId, childId)
+                    .apply("DATE(completed_date) = {0}", dateStr)
+            );
+            if (count > 0 || microCount > 0) break;
+            days++;
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+        }
+        return days;
+    }
+
+    /** 检查连续3天睡眠评分下降 */
+    private boolean checkSleepDecline(Long childId) {
+        Calendar cal = Calendar.getInstance();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+        List<Integer> scores = new ArrayList<>();
+
+        for (int i = 0; i < 4; i++) {
+            String dateStr = sdf.format(cal.getTime());
+            HealthSleepRecord record = healthSleepRecordMapper.selectOne(
+                new LambdaQueryWrapper<HealthSleepRecord>()
+                    .eq(HealthSleepRecord::getMemberId, childId)
+                    .apply("DATE(created_at) = {0}", dateStr)
+                    .last("LIMIT 1")
+            );
+            if (record != null && record.getQualityScore() != null) {
+                scores.add(record.getQualityScore());
+            } else {
+                return false; // 某天无数据,无法判断趋势
+            }
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+        }
+
+        // 需要最近4天数据,检查最近3天的趋势
+        if (scores.size() < 4) return false;
+        // 每天严格递减才算"持续下降"
+        return scores.get(0) < scores.get(1) &&
+               scores.get(1) < scores.get(2) &&
+               scores.get(2) < scores.get(3);
+    }
+
+    /** 计算距离上次运动的天数 */
+    private int countDaysSinceLastExercise(Long childId) {
+        HealthExerciseRecord last = healthExerciseRecordMapper.selectOne(
+            new LambdaQueryWrapper<HealthExerciseRecord>()
+                .eq(HealthExerciseRecord::getMemberId, childId)
+                .orderByDesc(HealthExerciseRecord::getStartTime)
+                .last("LIMIT 1")
+        );
+        if (last == null || last.getStartTime() == null) return 999;
+        long diff = System.currentTimeMillis() - last.getStartTime().getTime();
+        return (int) (diff / (1000 * 60 * 60 * 24));
+    }
+
+    /** 计算连续饮水偏低天数 */
+    private int countConsecutiveLowWaterDays(Long childId) {
+        Calendar cal = Calendar.getInstance();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+        int days = 0;
+
+        for (int i = 0; i < 7; i++) {
+            String dateStr = sdf.format(cal.getTime());
+            // 统计当天总饮水量
+            List<HealthWaterRecord> records = healthWaterRecordMapper.selectList(
+                new LambdaQueryWrapper<HealthWaterRecord>()
+                    .eq(HealthWaterRecord::getMemberId, childId)
+                    .apply("DATE(drunk_at) = {0}", dateStr)
+            );
+            int totalMl = records.stream().filter(r -> r.getAmountMl() != null)
+                                .mapToInt(HealthWaterRecord::getAmountMl).sum();
+            if (totalMl >= 500) break;
+            if (!records.isEmpty()) days++;
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+        }
+        return days;
+    }
+
+    /** 检查运动量是否突增 >50%(对比前3天均值) */
+    private boolean checkExerciseSurge(Long childId) {
+        Calendar cal = Calendar.getInstance();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
+
+        // 今天运动量
+        String todayStr = sdf.format(cal.getTime());
+        int todayDuration = 0;
+        List<HealthExerciseRecord> todayRecords = healthExerciseRecordMapper.selectList(
+            new LambdaQueryWrapper<HealthExerciseRecord>()
+                .eq(HealthExerciseRecord::getMemberId, childId)
+                .apply("DATE(start_time) = {0}", todayStr)
+        );
+        for (HealthExerciseRecord r : todayRecords) {
+            if (r.getDurationMinutes() != null) todayDuration += r.getDurationMinutes();
+        }
+        if (todayDuration == 0) return false;
+
+        // 过去3天均值
+        int prevTotal = 0;
+        int prevCount = 0;
+        for (int i = 1; i <= 3; i++) {
+            cal.add(Calendar.DAY_OF_YEAR, -1);
+            String prevStr = sdf.format(cal.getTime());
+            List<HealthExerciseRecord> prevRecords = healthExerciseRecordMapper.selectList(
+                new LambdaQueryWrapper<HealthExerciseRecord>()
+                    .eq(HealthExerciseRecord::getMemberId, childId)
+                    .apply("DATE(start_time) = {0}", prevStr)
+            );
+            for (HealthExerciseRecord r : prevRecords) {
+                if (r.getDurationMinutes() != null) {
+                    prevTotal += r.getDurationMinutes();
+                    prevCount++;
+                }
+            }
+        }
+        if (prevCount == 0) return false;
+        int avgPrev = prevTotal / prevCount;
+        return avgPrev > 0 && todayDuration > avgPrev * 1.5;
+    }
+}

+ 99 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthAlertService.java

@@ -0,0 +1,99 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.HealthAlert;
+import com.etotem.cfc.mapper.HealthAlertMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.*;
+
+@Slf4j
+@Service
+public class HealthAlertService {
+
+    @Resource
+    private HealthAlertMapper healthAlertMapper;
+
+    @Resource
+    private HealthAlertEngine healthAlertEngine;
+
+    /**
+     * 获取成员的活跃预警(未关闭的)
+     * 同时触发引擎评估新规则
+     */
+    public List<HealthAlert> getActiveAlerts(Long childId) {
+        if (childId == null) return Collections.emptyList();
+
+        // 1. 触发引擎评估
+        evaluateAndSave(childId);
+
+        // 2. 返回今日未关闭预警,最多5条
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+        List<HealthAlert> alerts = healthAlertMapper.selectList(
+            new LambdaQueryWrapper<HealthAlert>()
+                .eq(HealthAlert::getChildId, childId)
+                .eq(HealthAlert::getDismissed, 0)
+                .apply("DATE(alert_date) = {0}", today)
+                .orderByDesc(HealthAlert::getSeverity)
+                .last("LIMIT 5")
+        );
+        // 按严重程度排序: high > medium > low
+        alerts.sort((a, b) -> {
+            int scoreA = severityScore(a.getSeverity());
+            int scoreB = severityScore(b.getSeverity());
+            return scoreB - scoreA;
+        });
+        return alerts;
+    }
+
+    /** 触发引擎评估并去重保存 */
+    private void evaluateAndSave(Long childId) {
+        List<Map<String, String>> triggered = healthAlertEngine.evaluate(childId);
+        String today = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+
+        for (Map<String, String> rule : triggered) {
+            String ruleCode = rule.get("ruleCode");
+
+            // 去重:同一规则24h内不重复
+            long existing = healthAlertMapper.selectCount(
+                new LambdaQueryWrapper<HealthAlert>()
+                    .eq(HealthAlert::getChildId, childId)
+                    .eq(HealthAlert::getRuleCode, ruleCode)
+                    .apply("DATE(alert_date) = {0}", today)
+            );
+            if (existing > 0) continue;
+
+            HealthAlert alert = new HealthAlert();
+            alert.setChildId(childId);
+            alert.setRuleCode(ruleCode);
+            alert.setAlertText(rule.get("alertText"));
+            alert.setActionSuggestion(rule.get("actionSuggestion"));
+            alert.setSeverity(rule.get("severity"));
+            alert.setDismissed(0);
+            alert.setAlertDate(new Date());
+            alert.setCreatedAt(new Date());
+            healthAlertMapper.insert(alert);
+            log.info("预警 {} 已为 childId={} 生成", ruleCode, childId);
+        }
+    }
+
+    /** 关闭预警 */
+    public void dismiss(Long alertId) {
+        HealthAlert alert = healthAlertMapper.selectById(alertId);
+        if (alert != null) {
+            alert.setDismissed(1);
+            alert.setDismissedAt(new Date());
+            healthAlertMapper.updateById(alert);
+        }
+    }
+
+    private int severityScore(String severity) {
+        if ("high".equals(severity)) return 3;
+        if ("medium".equals(severity)) return 2;
+        if ("low".equals(severity)) return 1;
+        return 0;
+    }
+}

+ 17 - 0
cfc-backend/src/main/resources/schema.sql

@@ -2966,3 +2966,20 @@ CREATE TABLE IF NOT EXISTS daily_feedback (
     INDEX idx_child_date (child_id, feedback_date),
     INDEX idx_feedback_type (feedback_type)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日健康反馈';
+
+-- 健康预警记录表(P1-2 健康行为智能预警)
+CREATE TABLE IF NOT EXISTS health_alerts (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT COMMENT '用户ID',
+    child_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    rule_code VARCHAR(10) NOT NULL COMMENT '规则编码: R001-R006',
+    alert_text VARCHAR(200) NOT NULL COMMENT '预警文案',
+    action_suggestion VARCHAR(200) COMMENT '行动建议',
+    severity VARCHAR(10) DEFAULT 'medium' COMMENT '严重程度: high/medium/low',
+    dismissed TINYINT DEFAULT 0 COMMENT '是否已关闭: 0未关闭 1已关闭',
+    dismissed_at DATETIME COMMENT '关闭时间',
+    alert_date DATETIME NOT NULL COMMENT '预警日期',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    INDEX idx_child_date (child_id, alert_date),
+    INDEX idx_rule (rule_code)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='健康预警记录';

+ 87 - 0
cfc-frontend/components/HealthAlertBanner.vue

@@ -0,0 +1,87 @@
+<template>
+  <view class="alert-banner" v-if="alerts && alerts.length > 0">
+    <view
+      class="alert-item"
+      v-for="alert in visibleAlerts"
+      :key="alert.id"
+      :class="'alert-severity-' + alert.severity">
+      <view class="alert-icon">
+        <text v-if="alert.severity === 'high'">🔴</text>
+        <text v-else-if="alert.severity === 'medium'">🟡</text>
+        <text v-else>🟢</text>
+      </view>
+      <view class="alert-body">
+        <text class="alert-text">{{ alert.alertText }}</text>
+        <text class="alert-suggestion" v-if="alert.actionSuggestion">
+          {{ alert.actionSuggestion }}
+        </text>
+      </view>
+      <text class="alert-dismiss" @click="dismiss(alert)">×</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  props: {
+    alerts: { type: Array, default: function() { return [] } }
+  },
+  computed: {
+    visibleAlerts: function() {
+      return this.alerts.slice(0, 2)
+    }
+  },
+  methods: {
+    dismiss: function(alert) {
+      this.$emit('dismiss', alert.id)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.alert-banner {
+  margin: 0 20rpx 16rpx 20rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 8rpx;
+}
+.alert-item {
+  display: flex;
+  align-items: flex-start;
+  gap: 12rpx;
+  padding: 16rpx;
+  border-radius: 16rpx;
+  position: relative;
+}
+.alert-severity-high {
+  background: #FEF2F2;
+  border: 1rpx solid #FECACA;
+}
+.alert-severity-medium {
+  background: #FFFBEB;
+  border: 1rpx solid #FDE68A;
+}
+.alert-severity-low {
+  background: #F0F9FF;
+  border: 1rpx solid #BAE6FD;
+}
+.alert-icon { font-size: 36rpx; flex-shrink: 0; padding-top: 2rpx; }
+.alert-body { flex: 1; min-width: 0; }
+.alert-text { font-size: 26rpx; font-weight: 600; color: #1E293B; line-height: 1.4; }
+.alert-suggestion {
+  display: block;
+  font-size: 22rpx;
+  color: #64748B;
+  margin-top: 4rpx;
+  line-height: 1.4;
+}
+.alert-dismiss {
+  font-size: 36rpx;
+  color: #94A3B8;
+  padding: 0 8rpx;
+  line-height: 1;
+  flex-shrink: 0;
+}
+.alert-dismiss:active { color: #64748B; }
+</style>

+ 30 - 3
cfc-frontend/pages/index/parent-index.vue

@@ -22,6 +22,12 @@
   :childId="selectedChild.id"
   @completed="onMicroActionCompleted" />
 
+<!-- ===== 健康预警横幅(P1-2) ===== -->
+<HealthAlertBanner
+  v-if="selectedMemberId && selectedChild && selectedChild.id"
+  :alerts="healthAlerts"
+  @dismiss="onAlertDismiss" />
+
 <!-- ===== 家庭成员关系图谱 ===== -->
 <FamilyRelationGraph
   v-if="familyMembersVisible.length > 0"
@@ -377,7 +383,7 @@
 </template>
 
 <script>
-import { getFamilyMembers, getChildren, getPendingReviewTasks, getPendingWishes, approveTask, rejectTask, getChildCompletionStats, getParentDashboard, getFamilyEnergySandbox, getEnergyOverview, getVisibleFamilyMembers, getActivityList, getProductsByDomain, getContactList, getFeaturedArticles } from '../../utils/api.js'
+import { getFamilyMembers, getChildren, getPendingReviewTasks, getPendingWishes, approveTask, rejectTask, getChildCompletionStats, getParentDashboard, getFamilyEnergySandbox, getEnergyOverview, getVisibleFamilyMembers, getActivityList, getProductsByDomain, getContactList, getFeaturedArticles, getActiveAlerts, dismissAlert } from '../../utils/api.js'
 import PageBanner from '../../components/PageBanner.vue'
 import PlayfulCard from '../../components/PlayfulCard.vue'
 import PlayfulButton from '../../components/PlayfulButton.vue'
@@ -394,10 +400,11 @@ import MicroActionCard from '../../components/MicroActionCard.vue'
 import FeedbackToast from '../../components/FeedbackToast.vue'
 import MilestoneCelebration from '../../components/MilestoneCelebration.vue'
 import DailySummary from '../../components/DailySummary.vue'
+import HealthAlertBanner from '../../components/HealthAlertBanner.vue'
 import feedbackEngine from '../../utils/feedback-engine.js'
 
 export default {
-  components: { PageBanner, RecommendedFeed, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport, FamilyMemberStrip, MicroActionCard, FeedbackToast, MilestoneCelebration, DailySummary },
+  components: { PageBanner, RecommendedFeed, PlayfulCard, PlayfulButton, BaseBadge, BaseEmpty, BaseLoading, FamilyRelationGraph, WuxingSandbox, ContactCard, ContactImport, FamilyMemberStrip, MicroActionCard, FeedbackToast, MilestoneCelebration, DailySummary, HealthAlertBanner },
   data() {
     return {
       nickname: '',
@@ -456,7 +463,9 @@ export default {
       celebrationVisible: false,
       celebrationTitle: '',
       celebrationSubtitle: '',
-      celebrationParticles: []
+      celebrationParticles: [],
+      // P1-2 健康预警
+      healthAlerts: []
     }
   },
   computed: {
@@ -802,6 +811,24 @@ export default {
     onDailySummaryDismiss() {
       // DailySummary 已关闭,无额外操作
     },
+    loadAlerts: function() {
+      var childId = this.selectedChild && this.selectedChild.id
+      if (!childId) return
+      var self = this
+      api.getActiveAlerts({ childId: childId }).then(function(res) {
+        if (res.code === 200) {
+          self.healthAlerts = res.data || []
+        }
+      }).catch(function() {})
+    },
+    onAlertDismiss: function(alertId) {
+      var self = this
+      api.dismissAlert({ id: alertId }).then(function(res) {
+        if (res.code === 200) {
+          self.healthAlerts = self.healthAlerts.filter(function(a) { return a.id !== alertId })
+        }
+      }).catch(function() {})
+    },
     goMemberDetail(member) {
       if (!member || !member.memberId) return
       uni.navigateTo({

+ 59 - 0
cfc-frontend/store/health-alert.js

@@ -0,0 +1,59 @@
+/**
+ * 健康预警状态模块
+ * P1-2 健康行为智能预警
+ */
+
+import api from '@/utils/api.js'
+
+var healthAlertStore = {
+  state: {
+    alerts: [],
+    alertLoading: false,
+    alertError: false,
+    lastFetchTime: 0
+  },
+  mutations: {
+    setAlerts: function(state, alerts) {
+      state.alerts = alerts
+      state.alertLoading = false
+      state.alertError = false
+      state.lastFetchTime = Date.now()
+    },
+    setAlertLoading: function(state) {
+      state.alertLoading = true
+    },
+    setAlertError: function(state) {
+      state.alertLoading = false
+      state.alertError = true
+    },
+    dismissAlert: function(state, alertId) {
+      state.alerts = state.alerts.filter(function(a) { return a.id !== alertId })
+    }
+  },
+  actions: {
+    fetchAlerts: function({ commit }, childId) {
+      if (!childId) return
+      commit('setAlertLoading')
+      api.getActiveAlerts({ childId: childId }).then(function(res) {
+        if (res.code === 200) {
+          commit('setAlerts', res.data || [])
+        } else {
+          commit('setAlertError')
+        }
+      }).catch(function() {
+        commit('setAlertError')
+      })
+    },
+    dismissAlert: function({ commit }, alertId) {
+      api.dismissAlert({ id: alertId }).then(function(res) {
+        if (res.code === 200) {
+          commit('dismissAlert', alertId)
+        }
+      }).catch(function() {
+        // silent fail
+      })
+    }
+  }
+}
+
+export default healthAlertStore

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

@@ -1468,6 +1468,10 @@ export const getTodayFeedback = (data) => request('/api/feedback/today', 'POST',
 export const markFeedbackRead = (data) => request('/api/feedback/read', 'POST', data)
 export const getRecentFeedbacks = (data) => request('/api/feedback/recent', 'POST', data)
 
+// ===== 健康预警(P1-2 健康行为智能预警) =====
+export const getActiveAlerts = (data) => request('/api/health/alert/list', 'POST', data)
+export const dismissAlert = (data) => request('/api/health/alert/dismiss', 'POST', data)
+
 // ===== 娲诲姩妯″潡锛堢淮搴︾瓫閫夛級 =====
 export const getActivityList = (data) => request('/api/activity/list', 'POST', data)
 export const getActivityDetail = (id) => request('/api/activity/detail', 'POST', { id })

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-4ec7f83b2aa8b9c113fabbe87645f554ecc8c3fc
+3a02b2f240b656b26e7a9de1925b9a8abe76aa9f

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.481",
+  "version": "1.0.482",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 17 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,23 @@
 
 ---
 
+## v1.0.482 (2026-07-22)
+
+### Bug 修复
+- point develop env to cfc.etotem.com.cn; extract deep selector to non-scoped style for HBuilderX compat
+
+### 新功能
+- P1-1 即时多巴胺反馈实现
+
+### 其他
+- - 数据库: schema.sql + DatabaseInitializer migration 103
+- - 前端: FeedbackToast, MilestoneCelebration, DailySummary 组件
+- - 预设文案引擎: 12 场景触发 + 里程碑判定
+- - Vuex 反馈状态模块 + parent-index 集成
+- - 微行动完成后自动触发即时反馈
+- 
+
+
 ## v1.0.481 (2026-07-22)
 
 ### 其他

+ 18 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.481
+> 当前版本: v1.0.482
 
 ## 历史版本
 
@@ -8,6 +8,23 @@
 
 ---
 
+## v1.0.482 (2026-07-22)
+
+### Bug 修复
+- point develop env to cfc.etotem.com.cn; extract deep selector to non-scoped style for HBuilderX compat
+
+### 新功能
+- P1-1 即时多巴胺反馈实现
+
+### 其他
+- - 数据库: schema.sql + DatabaseInitializer migration 103
+- - 前端: FeedbackToast, MilestoneCelebration, DailySummary 组件
+- - 预设文案引擎: 12 场景触发 + 里程碑判定
+- - Vuex 反馈状态模块 + parent-index 集成
+- - 微行动完成后自动触发即时反馈
+- 
+
+
 ## v1.0.481 (2026-07-22)
 
 ### 其他