2026-10-16-comprehensive-survey.md 77 KB

综合健康调研(Comprehensive Survey)实现计划

日期: 2026-10-16 状态: 待执行 前置设计: docs/superpowers/specs/2026-10-16-comprehensive-health-survey-design.md(已定稿 commit 564e29eab) 执行方式: 按 superpowers:subagent-driven-development 逐任务实现,或 superpowers:executing-plans 内联执行 技术栈: uni-app 微信小程序(Vue2 Options API)+ Spring Boot 后端零改动


一、目标

新建 ComprehensiveSurvey.vue 组件承载 8 模块 step 式综合调研(基本情况/当前状态/饮食偏好/疾病史/排便及运动/睡眠情况/用药情况/过敏源),首页新增独立调研页(URL modules= 控制模块子集),方案制定页 Step2 同步替换旧向导,实现「调研与方案制定共享组件和数据」。

二、方案选型(已确认,详见设计文档 §二)

# 决策
1 组件化替换:新建 ComprehensiveSurvey.vue,首页新页面与方案制定页 Step2 嵌入同一组件
2 模块由调用方传参(URL query modules=,8 key 逗号分隔子集;方案制定页传全量 8 模块)
3 仅当前成员维度(currentChildId/currentMemberId 快照),方案制定页多成员 tab 由父页切换传快照
4 index-home 无待填调研分支 + health-main 两处 goSelfTest 改跳新页
5 新字段(currentStatus/exerciseFrequency/exerciseDuration)走 health_status.lifestyle JSON 自由 key
6 过敏原独立成 allergy 模块:过敏原多选 → diet_preferences.allergies、过敏症状多选 → lifestyle.allergySymptoms
7 饮食偏好模块补采 vegetableFruit/dairy/sugaryDrinks(后端 DTO 已有,页面漏采)
8 旧 HealthQuestionnaireWizard.vue + 5 子 step 标记 DEPRECATED,保留文件不删

三、文件结构

文件 操作 职责
cfc-frontend/pages/health/components/ComprehensiveSurvey.vue 新增 核心组件:8 模块 step 问卷、进度条、合并式增量保存、暖灶主题样式(预计 1000-1200 行)
cfc-frontend/pages/health/health-survey.vue 新增 独立调研包装页:解析 URL modules + 单成员快照 + 会员门槛 + 保存返回(预计 200 行)
cfc-frontend/pages.json 修改 注册 health/health-survey 到 health 分包
cfc-frontend/pages/index-home/index.vue 修改 goSelfTest 无待填调研分支改跳新页(L1639-1648)
cfc-frontend/pages/health-main/index.vue 修改 goSelfTest 改跳新页(L518)
cfc-frontend/pages/health/health-plan-summary.vue 修改 Step2 替换 Wizard 为 ComprehensiveSurvey;新增 update-survey 事件处理;memberFormMap 结构不变(lifestyle 全量存快照)
cfc-frontend/pages/health/components/HealthQuestionnaireWizard.vue + 5 子 step 标记 文件头加 DEPRECATED 注释,保留不删
后端任何文件 不改 零后端、零 DDL

字段归属:

  • basic → health_status 列:heightCm/weightKg/bloodPressure/bloodGlucose/bloodLipids
  • status → lifestyle:currentStatus/menstrualCycle/specialPeriod/growthDevelopment/pickyEating/osteoporosisRisk/fallRisk/screenTime/teenMood(含人群专属条件渲染)
  • diet → diet_preferences 13 key(不含 allergies,含补采 3 项)
  • disease → health_status 列:diseaseHistory(JSON 数组)
  • bowel → lifestyle:bowelFrequency/bowelForm/exerciseFrequency/exerciseDuration
  • sleep → lifestyle:sleepDuration/bedtime
  • med → health_status 列:medications(JSON 数组)
  • allergy → diet_preferences:allergies + lifestyle:allergySymptoms(双写)

任务 1:创建核心组件 ComprehensiveSurvey.vue

文件:

  • 创建:cfc-frontend/pages/health/components/ComprehensiveSurvey.vue

目标: 8 模块 step 问卷组件,支持按 modules props 渲染子集,合并式增量保存,暖灶主题。


步骤 1.1:组件骨架 + props/events + 数据层

创建文件,写入以下内容(完整代码,非占位):

<template>
  <view class="survey-wrap">
    <!-- 顶部进度条 -->
    <view class="progress-bar">
      <view class="progress-track">
        <view class="progress-fill" :style="'width:' + progressPercent + '%'"></view>
      </view>
      <view class="progress-labels">
        <text v-for="(m, i) in activeModules" :key="i"
              class="progress-label" :class="{ 'progress-label-active': currentStep > i }">{{ moduleTitles[m] }}</text>
      </view>
    </view>

    <!-- 步骤内容 -->
    <scroll-view class="content" scroll-y>
      <!-- Step 1: 基本情况 -->
      <view v-if="currentModule === 'basic'">
        <view class="step-head">
          <text class="step-icon">📏</text>
          <view class="step-head-text">
            <text class="step-title">基本情况</text>
            <text class="step-sub">身高、体重、血压、血糖、总胆固醇</text>
          </view>
        </view>
        <view class="card">
          <view class="card-row"><text class="card-label">身高(cm)</text>
            <input class="card-input" type="digit" v-model="form.heightCm" placeholder="如 170" /></view>
          <view class="card-row"><text class="card-label">体重(kg)</text>
            <input class="card-input" type="digit" v-model="form.weightKg" placeholder="如 65" /></view>
          <view class="card-row"><text class="card-label">血压(mmHg)</text>
            <input class="card-input" v-model="form.bloodPressure" placeholder="如 120/80" /></view>
          <view class="card-row"><text class="card-label">空腹血糖(mmol/L)</text>
            <input class="card-input" type="digit" v-model="form.bloodGlucose" placeholder="如 5.6" /></view>
          <view class="card-row"><text class="card-label">总胆固醇(mmol/L)</text>
            <input class="card-input" type="digit" v-model="form.lipidCholesterol" placeholder="如 4.5" /></view>
        </view>
      </view>

      <!-- Step 2: 当前状态 -->
      <view v-if="currentModule === 'status'">
        <view class="step-head">
          <text class="step-icon">🩺</text>
          <view class="step-head-text">
            <text class="step-title">当前状态</text>
            <text class="step-sub">特殊身体状态 + 人群专属情况</text>
          </view>
        </view>
        <view class="card">
          <text class="card-title">特殊状态</text>
          <view class="chip-group">
            <view v-for="opt in currentStatusOptions" :key="opt"
                  class="chip" :class="{ 'chip-active': lifestyle.currentStatus === opt }"
                  @tap="setLifestyle('currentStatus', opt)"><text>{{ opt }}</text></view>
          </view>
        </view>
        <!-- 女性专属 -->
        <block v-if="isFemale">
          <view class="card"><text class="card-title">月经周期</text>
            <view class="chip-group">
              <view v-for="opt in menstrualOptions" :key="opt" class="chip"
                    :class="{ 'chip-active': lifestyle.menstrualCycle === opt }"
                    @tap="setLifestyle('menstrualCycle', opt)"><text>{{ opt }}</text></view>
            </view>
          </view>
          <view class="card"><text class="card-title">备孕/怀孕/哺乳</text>
            <view class="chip-group">
              <view v-for="opt in specialPeriodOptions" :key="opt" class="chip"
                    :class="{ 'chip-active': lifestyle.specialPeriod === opt }"
                    @tap="setLifestyle('specialPeriod', opt)"><text>{{ opt }}</text></view>
            </view>
          </view>
        </block>
        <!-- 儿童专属 -->
        <block v-if="isChild">
          <view class="card"><text class="card-title">发育情况</text>
            <view class="chip-group">
              <view v-for="opt in growthOptions" :key="opt" class="chip"
                    :class="{ 'chip-active': lifestyle.growthDevelopment === opt }"
                    @tap="setLifestyle('growthDevelopment', opt)"><text>{{ opt }}</text></view>
            </view>
          </view>
          <view class="card"><text class="card-title">挑食程度</text>
            <view class="chip-group">
              <view v-for="opt in pickyOptions" :key="opt" class="chip"
                    :class="{ 'chip-active': lifestyle.pickyEating === opt }"
                    @tap="setLifestyle('pickyEating', opt)"><text>{{ opt }}</text></view>
            </view>
          </view>
        </block>
        <!-- 老年专属 -->
        <block v-if="isElderly">
          <view class="card"><text class="card-title">跌倒风险</text>
            <view class="chip-group">
              <view v-for="opt in fallOptions" :key="opt" class="chip"
                    :class="{ 'chip-active': lifestyle.fallRisk === opt }"
                    @tap="setLifestyle('fallRisk', opt)"><text>{{ opt }}</text></view>
            </view>
          </view>
          <view class="card"><text class="card-title">骨质疏松风险</text>
            <view class="chip-group">
              <view v-for="opt in osteoOptions" :key="opt" class="chip"
                    :class="{ 'chip-active': lifestyle.osteoporosisRisk === opt }"
                    @tap="setLifestyle('osteoporosisRisk', opt)"><text>{{ opt }}</text></view>
            </view>
          </view>
        </block>
        <!-- 青少年专属 -->
        <block v-if="isTeenager">
          <view class="card"><text class="card-title">屏幕时间</text>
            <view class="chip-group">
              <view v-for="opt in screenOptions" :key="opt" class="chip"
                    :class="{ 'chip-active': lifestyle.screenTime === opt }"
                    @tap="setLifestyle('screenTime', opt)"><text>{{ opt }}</text></view>
            </view>
          </view>
          <view class="card"><text class="card-title">情绪状态</text>
            <view class="chip-group">
              <view v-for="opt in teenMoodOptions" :key="opt" class="chip"
                    :class="{ 'chip-active': lifestyle.teenMood === opt }"
                    @tap="setLifestyle('teenMood', opt)"><text>{{ opt }}</text></view>
            </view>
          </view>
        </block>
      </view>

      <!-- Step 3: 饮食偏好 -->
      <view v-if="currentModule === 'diet'">
        <view class="step-head">
          <text class="step-icon">🍽️</text>
          <view class="step-head-text">
            <text class="step-title">饮食偏好</text>
            <text class="step-sub">忌口、口味、菜系、烹饪方式、饮食结构</text>
          </view>
        </view>
        <!-- 无特殊偏好:一键清空 -->
        <view class="pref-none-row">
          <button class="pref-none-btn" :class="{ 'pref-none-btn-active': diet.noPreference }"
                  @tap="setNoPreference">{{ diet.noPreference ? '已选:无特殊偏好' : '无特殊偏好' }}</button>
        </view>
        <view class="card"><text class="card-title">绝对忌口(多选)</text>
          <view class="chip-group">
            <view v-for="item in presetAvoid" :key="item" class="chip"
                  :class="{ 'chip-active': diet.absoluteAvoid.indexOf(item) >= 0 }"
                  @tap="toggleChip('diet', 'absoluteAvoid', item)"><text>{{ item }}</text></view>
          </view>
          <view class="custom-row" v-if="customField === 'absoluteAvoid'">
            <input class="custom-input" placeholder="输入其他忌口" v-model="customInput"
                   confirm-type="done" @confirm="addCustom" />
            <view class="custom-confirm" @tap="addCustom"><text>添加</text></view>
          </view>
          <view class="custom-add" v-else @tap="openCustom('absoluteAvoid')"><text>+ 自定义添加</text></view>
        </view>
        <view class="card"><text class="card-title">必吃食物(多选)</text>
          <view class="chip-group">
            <view v-for="item in presetMustEat" :key="item" class="chip"
                  :class="{ 'chip-active': diet.mustEat.indexOf(item) >= 0 }"
                  @tap="toggleChip('diet', 'mustEat', item)"><text>{{ item }}</text></view>
          </view>
        </view>
        <view class="card"><text class="card-title">口味偏好(多选)</text>
          <view class="chip-group">
            <view v-for="item in presetFlavor" :key="item" class="chip"
                  :class="{ 'chip-active': diet.flavorPref.indexOf(item) >= 0 }"
                  @tap="toggleChip('diet', 'flavorPref', item)"><text>{{ item }}</text></view>
          </view>
        </view>
        <view class="card"><text class="card-title">菜系偏好(多选)</text>
          <view class="chip-group">
            <view v-for="item in presetCuisine" :key="item" class="chip"
                  :class="{ 'chip-active': diet.cuisinePref.indexOf(item) >= 0 }"
                  @tap="toggleChip('diet', 'cuisinePref', item)"><text>{{ item }}</text></view>
          </view>
        </view>
        <view class="card"><text class="card-title">烹饪方式(多选)</text>
          <view class="chip-group">
            <view v-for="item in presetCooking" :key="item" class="chip"
                  :class="{ 'chip-active': diet.cookingMethods.indexOf(item) >= 0 }"
                  @tap="toggleChip('diet', 'cookingMethods', item)"><text>{{ item }}</text></view>
          </view>
        </view>
        <view class="card"><text class="card-title">健康目标(多选)</text>
          <view class="chip-group">
            <view v-for="item in presetGoals" :key="item" class="chip"
                  :class="{ 'chip-active': diet.healthGoals.indexOf(item) >= 0 }"
                  @tap="toggleChip('diet', 'healthGoals', item)"><text>{{ item }}</text></view>
          </view>
        </view>
        <view class="card"><text class="card-title">辣度(0=不辣,5=极辣)</text>
          <view class="slider-row">
            <text class="slider-val">{{ diet.spiceLevel }}</text>
            <slider :value="diet.spiceLevel" min="0" max="5" step="1"
                    @changing="onSpiceChanging" @change="onSpiceChange" activeColor="#FF8C42" />
          </view>
        </view>
        <view class="card"><text class="card-title">宗教饮食</text>
          <view class="chip-group">
            <view v-for="opt in religiousOptions" :key="opt.value" class="chip"
                  :class="{ 'chip-active': diet.religiousDiet === opt.value }"
                  @tap="setDiet('religiousDiet', opt.value)"><text>{{ opt.label }}</text></view>
          </view>
        </view>
        <view class="card"><text class="card-title">饮食结构现状(单选)</text>
          <view class="struct-row"><text class="struct-label">每日蔬果量</text>
            <view class="chip-group">
              <view v-for="opt in vegFruitOptions" :key="opt" class="chip"
                    :class="{ 'chip-active': diet.vegetableFruit === opt }"
                    @tap="setDiet('vegetableFruit', opt)"><text>{{ opt }}</text></view>
            </view>
          </view>
          <view class="struct-row"><text class="struct-label">乳制品摄入</text>
            <view class="chip-group">
              <view v-for="opt in dairyOptions" :key="opt" class="chip"
                    :class="{ 'chip-active': diet.dairy === opt }"
                    @tap="setDiet('dairy', opt)"><text>{{ opt }}</text></view>
            </view>
          </view>
          <view class="struct-row"><text class="struct-label">含糖饮料频率</text>
            <view class="chip-group">
              <view v-for="opt in sugaryOptions" :key="opt" class="chip"
                    :class="{ 'chip-active': diet.sugaryDrinks === opt }"
                    @tap="setDiet('sugaryDrinks', opt)"><text>{{ opt }}</text></view>
            </view>
          </view>
        </view>
      </view>

      <!-- Step 4: 疾病史 -->
      <view v-if="currentModule === 'disease'">
        <view class="step-head">
          <text class="step-icon">📋</text>
          <view class="step-head-text">
            <text class="step-title">疾病史</text>
            <text class="step-sub">选择过往确诊疾病({{ form.selectedDiseases.length }} 项已选)</text>
          </view>
        </view>
        <view v-for="(group, gi) in diseaseGroups" :key="group.key" class="card">
          <view class="card-head" @tap="toggleGroup(group.key)">
            <text class="card-title">{{ group.title }}</text>
            <text class="group-arrow">{{ expandedGroups.indexOf(group.key) >= 0 ? '收起' : '展开' }}</text>
          </view>
          <view v-if="expandedGroups.indexOf(group.key) >= 0" class="chip-group">
            <view v-for="(item, ii) in group.items" :key="ii" class="chip"
                  :class="{ 'chip-active': isDiseaseSelected(item.name) }"
                  @tap="toggleDisease(item.name)">
              <text>{{ item.label }}</text>
            </view>
          </view>
        </view>
        <view class="card">
          <view class="card-head"><text class="card-title">自定义疾病</text></view>
          <view v-if="customDiseaseMode" class="custom-row">
            <input class="custom-input" placeholder="疾病名称" v-model="customDisease.name" />
            <view class="custom-confirm" @tap="confirmCustomDisease"><text>添加</text></view>
            <view class="custom-cancel" @tap="customDiseaseMode = false"><text>取消</text></view>
          </view>
          <view class="custom-add" v-else @tap="customDiseaseMode = true"><text>+ 添加自定义疾病</text></view>
        </view>
      </view>

      <!-- Step 5: 排便及运动 -->
      <view v-if="currentModule === 'bowel'">
        <view class="step-head">
          <text class="step-icon">🚽</text>
          <view class="step-head-text">
            <text class="step-title">排便及运动</text>
            <text class="step-sub">排便习惯 + 运动频率与时长</text>
          </view>
        </view>
        <view class="card"><text class="card-title">排便频率</text>
          <view class="chip-group">
            <view v-for="opt in bowelFreqOptions" :key="opt" class="chip"
                  :class="{ 'chip-active': lifestyle.bowelFrequency === opt }"
                  @tap="setLifestyle('bowelFrequency', opt)"><text>{{ opt }}</text></view>
          </view>
        </view>
        <view class="card"><text class="card-title">粪便性状</text>
          <view class="chip-group">
            <view v-for="opt in bowelFormOptions" :key="opt" class="chip"
                  :class="{ 'chip-active': lifestyle.bowelForm === opt }"
                  @tap="setLifestyle('bowelForm', opt)"><text>{{ opt }}</text></view>
          </view>
        </view>
        <view class="card"><text class="card-title">运动频率</text>
          <view class="chip-group">
            <view v-for="opt in exerciseFreqOptions" :key="opt" class="chip"
                  :class="{ 'chip-active': lifestyle.exerciseFrequency === opt }"
                  @tap="setLifestyle('exerciseFrequency', opt)"><text>{{ opt }}</text></view>
          </view>
        </view>
        <view class="card"><text class="card-title">每次运动时长</text>
          <view class="chip-group">
            <view v-for="opt in exerciseDurationOptions" :key="opt" class="chip"
                  :class="{ 'chip-active': lifestyle.exerciseDuration === opt }"
                  @tap="setLifestyle('exerciseDuration', opt)"><text>{{ opt }}</text></view>
          </view>
        </view>
      </view>

      <!-- Step 6: 睡眠情况 -->
      <view v-if="currentModule === 'sleep'">
        <view class="step-head">
          <text class="step-icon">😴</text>
          <view class="step-head-text">
            <text class="step-title">睡眠情况</text>
            <text class="step-sub">睡眠时长与入睡时间</text>
          </view>
        </view>
        <view class="card"><text class="card-title">睡眠时长</text>
          <view class="chip-group">
            <view v-for="opt in sleepDurationOptions" :key="opt" class="chip"
                  :class="{ 'chip-active': lifestyle.sleepDuration === opt }"
                  @tap="setLifestyle('sleepDuration', opt)"><text>{{ opt }}</text></view>
          </view>
        </view>
        <view class="card"><text class="card-title">入睡时间</text>
          <view class="chip-group">
            <view v-for="opt in bedtimeOptions" :key="opt" class="chip"
                  :class="{ 'chip-active': lifestyle.bedtime === opt }"
                  @tap="setLifestyle('bedtime', opt)"><text>{{ opt }}</text></view>
          </view>
        </view>
      </view>

      <!-- Step 7: 用药情况 -->
      <view v-if="currentModule === 'med'">
        <view class="step-head">
          <text class="step-icon">💊</text>
          <view class="step-head-text">
            <text class="step-title">用药情况</text>
            <text class="step-sub">当前长期用药;如无请勾选「无长期用药」</text>
          </view>
        </view>
        <view class="card">
          <view class="checkbox-row" @tap="form.noMedication = !form.noMedication">
            <view class="checkbox" :class="{ 'checkbox-on': form.noMedication }">
              <text v-if="form.noMedication">✓</text>
            </view>
            <text class="checkbox-label">无长期用药</text>
          </view>
        </view>
        <view v-if="!form.noMedication" v-for="(row, i) in form.medRows" :key="i" class="card">
          <view class="card-head"><text class="card-title">用药 {{ i + 1 }}</text>
            <text class="card-del" @tap="delMedRow(i)">删除</text></view>
          <view class="card-row"><text class="card-label">药名</text>
            <input class="card-input" v-model="row.name" placeholder="如 二甲双胍" /></view>
          <view class="card-row"><text class="card-label">开始年月</text>
            <input class="card-input" v-model="row.startDate" placeholder="如 2024-03" /></view>
          <view class="card-row"><text class="card-label">服药时间</text>
            <input class="card-input" v-model="row.time" placeholder="如 早饭后" /></view>
        </view>
        <view class="add-row" v-if="!form.noMedication && form.medRows.length < 10" @tap="addMedRow">
          <text class="add-icon">+</text><text class="add-text">添加用药</text>
        </view>
      </view>

      <!-- Step 8: 过敏源 -->
      <view v-if="currentModule === 'allergy'">
        <view class="step-head">
          <text class="step-icon">⚠️</text>
          <view class="step-head-text">
            <text class="step-title">过敏源</text>
            <text class="step-sub">过敏原与过敏症状,可多选</text>
          </view>
        </view>
        <view class="card"><text class="card-title">过敏原(多选)</text>
          <view class="chip-group">
            <view v-for="item in presetAllergies" :key="item" class="chip"
                  :class="{ 'chip-active': diet.allergies.indexOf(item) >= 0 }"
                  @tap="toggleChip('diet', 'allergies', item)"><text>{{ item }}</text></view>
          </view>
          <view class="custom-row" v-if="customField === 'allergies'">
            <input class="custom-input" placeholder="输入其他过敏原" v-model="customInput"
                   confirm-type="done" @confirm="addCustom" />
            <view class="custom-confirm" @tap="addCustom"><text>添加</text></view>
          </view>
          <view class="custom-add" v-else @tap="openCustom('allergies')"><text>+ 自定义添加</text></view>
        </view>
        <view class="card"><text class="card-title">过敏症状(多选)</text>
          <view class="chip-group">
            <view v-for="item in allergySymptomOptions" :key="item" class="chip"
                  :class="{ 'chip-active': lifestyle.allergySymptoms.indexOf(item) >= 0 }"
                  @tap="toggleLifestyleArray('allergySymptoms', item)"><text>{{ item }}</text></view>
          </view>
        </view>
      </view>
    </scroll-view>

    <!-- 底部固定栏 -->
    <view class="footer-bar">
      <button class="btn-prev" v-if="currentStep > 1" @tap="prevStep">上一步</button>
      <button class="btn-next" v-if="currentStep < activeModules.length" @tap="nextStep">下一步</button>
      <button class="btn-done" v-else :disabled="submitting" @tap="onSubmit">
        {{ submitting ? '保存中...' : '完成' }}
      </button>
    </view>
  </view>
</view>

<script>
// 预设选项常量
var PRESET_ALLERGIES = ['海鲜', '花生', '乳糖', '鸡蛋', '牛奶', '豆类', '麸质', '坚果', '芒果', '其他']
var PRESET_AVOID = ['牛肉', '羊肉', '猪肉', '内脏', '油腻', '辛辣', '生冷', '腌制']
var PRESET_FLAVOR = ['酸', '甜', '咸', '鲜', '苦', '清淡', '浓郁']
var PRESET_CUISINE = ['川', '粤', '鲁', '淮扬', '日料', '西餐', '东南亚', '韩餐']
var PRESET_COOKING = ['蒸', '煮', '炒', '烤', '炖', '凉拌', '煎', '炸']
var PRESET_MUSTEAT = ['蔬菜', '水果', '粗粮', '鱼类', '鸡蛋', '豆制品', '菌菇']
var PRESET_GOALS = ['减脂', '增肌', '控糖', '肠道调理', '增强免疫']

export default {
  name: 'ComprehensiveSurvey',
  props: {
    // 启用模块 key 列表(顺序决定步骤顺序)
    modules: { type: Array, default: function() { return [] } },
    // health_status 快照(父页维护)
    form: { type: Object, default: function() { return {} } },
    // diet_preferences 快照(父页维护)
    diet: { type: Object, default: function() { return {} } },
    // 成员画像(驱动条件渲染)
    profile: { type: Object, default: function() { return {} } },
    // 外部提交态
    submitting: { type: Boolean, default: false }
  },
  data: function() {
    return {
      currentStep: 1,
      // lifestyle 工作副本(含全部 15 旧 key + 3 新 key)
      lifestyle: {},
      // 用 inheritable:deep watch props.form/diet,快照变更时同步
      innerForm: {},
      innerDiet: {},
      // 自定义输入
      customField: '',
      customInput: '',
      customDiseaseMode: false,
      customDisease: { name: '', diagnosedAt: '' },
      // 疾病分组展开态
      expandedGroups: [],
      // 选项常量
      presetAllergies: PRESET_ALLERGIES,
      presetAvoid: PRESET_AVOID,
      presetFlavor: PRESET_FLAVOR,
      presetCuisine: PRESET_CUISINE,
      presetCooking: PRESET_COOKING,
      presetMustEat: PRESET_MUSTEAT,
      presetGoals: PRESET_GOALS,
      currentStatusOptions: ['正常', '哺乳期', '备孕', '怀孕', '住院期', '恢复期', '其他'],
      menstrualOptions: ['规律', '不规律', '已绝经'],
      specialPeriodOptions: ['无', '备孕中', '已怀孕', '哺乳期'],
      growthOptions: ['正常', '偏慢', '偏快'],
      pickyOptions: ['不挑食', '轻微挑食', '严重挑食'],
      fallOptions: ['低风险', '中风险', '高风险'],
      osteoOptions: ['低风险', '中风险', '高风险'],
      screenOptions: ['<1小时', '1-3小时', '3-5小时', '>5小时'],
      teenMoodOptions: ['稳定', '易焦虑', '易低落', '波动大'],
      bowelFreqOptions: ['每天1次', '每天2-3次', '每2-3天1次', '每周1-2次', '不定期'],
      bowelFormOptions: ['成型软便', '稀便', '硬结', '水样', '羊粪状'],
      exerciseFreqOptions: ['不运动', '每周1-2次', '每周3-4次', '每周5次以上'],
      exerciseDurationOptions: ['30分钟以内', '30-60分钟', '60分钟以上'],
      sleepDurationOptions: ['<6小时', '6-7小时', '7-8小时', '8-9小时', '>9小时'],
      bedtimeOptions: ['21点前', '21-22点', '22-23点', '23-24点', '24点后'],
      vegFruitOptions: ['<200g', '200-400g', '400-600g', '>600g'],
      dairyOptions: ['不喝', '每周1-2次', '每天1次', '每天2次以上'],
      sugaryOptions: ['不喝', '每周1-2次', '每天1次', '每天2次以上'],
      allergySymptomOptions: ['无', '皮疹', '鼻痒', '眼痒', '哮喘样', '食物过敏'],
      moduleTitles: {
        basic: '基本情况', status: '当前状态', diet: '饮食偏好', disease: '疾病史',
        bowel: '排便运动', sleep: '睡眠', med: '用药', allergy: '过敏源'
      },
      diseaseGroups: [
        { key: 'meta', title: '代谢与内分泌', items: [
          { name: '糖尿病', label: '糖尿病' }, { name: '甲状腺功能减退', label: '甲减' },
          { name: '甲状腺功能亢进', label: '甲亢' }, { name: '高血脂', label: '高血脂' },
          { name: '痛风', label: '痛风' }, { name: '肥胖症', label: '肥胖症' }
        ]},
        { key: 'cardio', title: '心脑血管', items: [
          { name: '高血压', label: '高血压' }, { name: '冠心病', label: '冠心病' },
          { name: '心肌梗死', label: '心梗' }, { name: '心律失常', label: '心律失常' },
          { name: '脑卒中', label: '脑卒中' }, { name: '心力衰竭', label: '心衰' }
        ]},
        { key: 'digest', title: '消化系统', items: [
          { name: '慢性胃炎', label: '慢性胃炎' }, { name: '胃溃疡', label: '胃溃疡' },
          { name: '十二指肠溃疡', label: '十二指肠溃疡' }, { name: '炎症性肠病', label: '炎症性肠病' },
          { name: '肠易激综合征', label: '肠易激综合征' }, { name: '脂肪肝', label: '脂肪肝' },
          { name: '慢性肝炎', label: '慢性肝炎' }, { name: '胆结石', label: '胆结石' },
          { name: '慢性胰腺炎', label: '慢性胰腺炎' }
        ]},
        { key: 'resp', title: '呼吸系统', items: [
          { name: '哮喘', label: '哮喘' }, { name: '慢性支气管炎', label: '慢性支气管炎' },
          { name: '慢性阻塞性肺疾病', label: '慢阻肺' }, { name: '支气管扩张', label: '支气管扩张' },
          { name: '睡眠呼吸暂停', label: '睡眠呼吸暂停' }
        ]},
        { key: 'renal', title: '泌尿系统', items: [
          { name: '慢性肾炎', label: '慢性肾炎' }, { name: '肾病综合征', label: '肾病综合征' },
          { name: '慢性肾衰竭', label: '慢性肾衰竭' }, { name: '肾结石', label: '肾结石' },
          { name: '前列腺增生', label: '前列腺增生' }
        ]},
        { key: 'immune', title: '免疫与过敏', items: [
          { name: '类风湿关节炎', label: '类风湿关节炎' }, { name: '系统性红斑狼疮', label: '红斑狼疮' },
          { name: '强直性脊柱炎', label: '强直性脊柱炎' }, { name: '干燥综合征', label: '干燥综合征' },
          { name: '食物过敏', label: '食物过敏' }, { name: '药物过敏', label: '药物过敏' },
          { name: '过敏性鼻炎', label: '过敏性鼻炎' }, { name: '慢性荨麻疹', label: '慢性荨麻疹' },
          { name: '乳糖不耐受', label: '乳糖不耐受' }, { name: '麸质不耐受', label: '麸质不耐受' }
        ]},
        { key: 'neuro', title: '神经系统', items: [
          { name: '偏头痛', label: '偏头痛' }, { name: '癫痫', label: '癫痫' },
          { name: '帕金森病', label: '帕金森病' }, { name: '阿尔茨海默病', label: '阿尔茨海默病' },
          { name: '周围神经病变', label: '周围神经病变' }
        ]},
        { key: 'other', title: '其他', items: [
          { name: '贫血', label: '贫血' }, { name: '骨质疏松', label: '骨质疏松' },
          { name: '甲状腺结节', label: '甲状腺结节' }, { name: '恶性肿瘤', label: '恶性肿瘤' },
          { name: '抑郁症', label: '抑郁症' }, { name: '焦虑症', label: '焦虑症' }
        ]}
      ],
      religiousOptions: [
        { value: 'none', label: '无限制' },
        { value: 'halal', label: '清真' },
        { value: 'vegetarian', label: '素食' },
        { value: 'vegan', label: '纯素' }
      ]
    }
  },
  computed: {
    // 过滤非法 module key + 兜底全量
    activeModules: function() {
      var valid = ['basic', 'status', 'diet', 'disease', 'bowel', 'sleep', 'med', 'allergy']
      var list = (this.modules || []).filter(function(m) { return valid.indexOf(m) >= 0 })
      if (list.length === 0) list = valid.slice()
      // 去重(保持顺序)
      var seen = {}
      return list.filter(function(m) { if (seen[m]) return false; seen[m] = true; return true })
    },
    currentModule: function() {
      return this.activeModules[this.currentStep - 1] || ''
    },
    progressPercent: function() {
      return Math.round((this.currentStep / this.activeModules.length) * 100)
    },
    isFemale: function() {
      var g = this.profile && this.profile.gender
      return g === 'female' || g === '女'
    },
    isChild: function() {
      var age = this.profile && this.profile.age
      return age != null && Number(age) > 0 && Number(age) < 12
    },
    isTeenager: function() {
      var age = this.profile && this.profile.age
      return age != null && Number(age) >= 12 && Number(age) < 18
    },
    isElderly: function() {
      var age = this.profile && this.profile.age
      return age != null && Number(age) >= 60
    }
  },
  watch: {
    // props.form/diet/lifestyle 变更 → 同步内部工作副本
    form: {
      handler: function(v) { this.innerForm = v || {} },
      immediate: true,
      deep: true
    },
    diet: {
      handler: function(v) { this.innerDiet = v || {} },
      immediate: true,
      deep: true
    }
  },
  created: function() {
    // 快照默认值(父页未传时兜底)
    this.innerForm = this.form || {}
    this.innerDiet = this.diet || {}
    // 从 form.lifestyle 还原 lifestyle 工作副本
    var ls = this.form && this.form.lifestyle
    this.lifestyle = (ls && typeof ls === 'object') ? JSON.parse(JSON.stringify(ls)) : {}
  },
  methods: {
    // ===== 步骤导航 =====
    prevStep: function() { if (this.currentStep > 1) this.currentStep-- },
    nextStep: function() {
      if (this.currentStep < this.activeModules.length) {
        this.syncToParent()
        this.currentStep++
      }
    },
    // ===== 状态同步(切步/提交前把工作副本推回父页) =====
    syncToParent: function() {
      var formPatch = this.buildFormPatch()
      if (Object.keys(formPatch).length > 0) {
        this.$emit('update-form', formPatch)
      }
      var dietPatch = this.buildDietPatch()
      if (Object.keys(dietPatch).length > 0) {
        this.$emit('update-diet', dietPatch)
      }
    },
    // ===== 提交 =====
    onSubmit: function() {
      if (this.submitting) return
      // 数值校验(仅基本情况的启用模块)
      if (this.activeModules.indexOf('basic') >= 0) {
        var h = Number(this.innerForm.heightCm)
        var w = Number(this.innerForm.weightKg)
        var g = Number(this.innerForm.bloodGlucose)
        var bp = this.innerForm.bloodPressure
        if (this.innerForm.heightCm && (h < 30 || h > 250)) {
          uni.showToast({ title: '身高应在 30-250 之间', icon: 'none' }); return
        }
        if (this.innerForm.weightKg && (w < 3 || w > 300)) {
          uni.showToast({ title: '体重应在 3-300 之间', icon: 'none' }); return
        }
        if (this.innerForm.bloodGlucose && (g < 1 || g > 30)) {
          uni.showToast({ title: '血糖应在 1-30 之间', icon: 'none' }); return
        }
        if (bp && !/^\d{2,3}\/\d{2,3}$/.test(bp)) {
          uni.showToast({ title: '血压格式应为 120/80', icon: 'none' }); return
        }
      }
      // 空提交拦截:所有启用模块均未填任何内容
      var formPatch = this.buildFormPatch()
      var dietPatch = this.buildDietPatch()
      var lifestyleDiff = this.buildLifestyleDiff()
      if (Object.keys(formPatch).length === 0 && Object.keys(dietPatch).length === 0 &&
          Object.keys(lifestyleDiff).length === 0) {
        uni.showToast({ title: '请至少填写一项', icon: 'none' }); return
      }
      this.$emit('submit', { form: this.innerForm, diet: this.innerDiet, lifestyleDiff: lifestyleDiff })
    },
    // ===== form/diet patch 组装(仅含本次有值的字段) =====
    buildFormPatch: function() {
      var patch = {}
      var f = this.innerForm
      if (f.heightCm != null && f.heightCm !== '') patch.heightCm = f.heightCm
      if (f.weightKg != null && f.weightKg !== '') patch.weightKg = f.weightKg
      if (f.bloodPressure != null && f.bloodPressure !== '') patch.bloodPressure = f.bloodPressure
      if (f.bloodGlucose != null && f.bloodGlucose !== '') patch.bloodGlucose = f.bloodGlucose
      if (f.lipidCholesterol != null && f.lipidCholesterol !== '') {
        patch.bloodLipids = JSON.stringify([{ name: '总胆固醇', value: parseFloat(f.lipidCholesterol), unit: 'mmol/L', note: '' }])
      }
      if (f.notes != null && f.notes !== '') patch.notes = f.notes
      // 疾病史
      var dis = (f.selectedDiseases || []).map(function(item) {
        var d = { name: item.name }
        if (item.diagnosedAt) d.diagnosedAt = item.diagnosedAt
        return d
      })
      if (dis.length > 0) patch.diseaseHistory = JSON.stringify(dis)
      // 用药
      var meds = (f.medRows || []).filter(function(item) { return item.name }).map(function(item) {
        var m = { name: item.name }
        if (item.startDate) m.startDate = item.startDate
        if (item.durationText) m.durationText = item.durationText
        if (item.time) m.time = item.time
        return m
      })
      if (meds.length > 0) patch.medications = JSON.stringify(meds)
      if (f.noMedication) patch.noMedication = true
      return patch
    },
    buildDietPatch: function() {
      var patch = {}
      var d = this.innerDiet
      var arrKeys = ['allergies', 'absoluteAvoid', 'mustEat', 'flavorPref', 'cuisinePref', 'cookingMethods', 'healthGoals']
      arrKeys.forEach(function(k) { if (d[k] && d[k].length > 0) patch[k] = d[k].slice() })
      var strKeys = ['vegetableFruit', 'dairy', 'sugaryDrinks']
      strKeys.forEach(function(k) { if (d[k]) patch[k] = d[k] })
      if (d.religiousDiet && d.religiousDiet !== 'none') patch.religiousDiet = d.religiousDiet
      if (d.spiceLevel && Number(d.spiceLevel) > 0) patch.spiceLevel = Number(d.spiceLevel)
      if (d.noPreference) patch.noPreference = true
      return patch
    },
    // lifestyle diff:仅含本次修改过的 key
    buildLifestyleDiff: function() {
      var diff = {}
      var orig = (this.form && this.form.lifestyle) || {}
      var keys = ['currentStatus', 'menstrualCycle', 'specialPeriod', 'growthDevelopment', 'pickyEating',
        'osteoporosisRisk', 'fallRisk', 'screenTime', 'teenMood',
        'bowelFrequency', 'bowelForm', 'exerciseFrequency', 'exerciseDuration',
        'sleepDuration', 'bedtime', 'antibioticHistory', 'mood', 'allergySymptoms']
      var self = this
      keys.forEach(function(k) {
        var val = self.lifestyle[k]
        if (val == null) return
        if (val instanceof Array) { if (val.length > 0) diff[k] = val.slice() }
        else if (val !== '') diff[k] = val
      })
      // 移除与原始快照完全相同的 key(未修改)
      Object.keys(diff).forEach(function(k) {
        var a = JSON.stringify(diff[k])
        var b = JSON.stringify(orig[k] != null ? orig[k] : null)
        if (a === b) delete diff[k]
      })
      return diff
    },
    // ===== lifestyle 工具 =====
    setLifestyle: function(key, val) {
      this.$set(this.lifestyle, key, this.lifestyle[key] === val ? '' : val)
    },
    toggleLifestyleArray: function(key, val) {
      var arr = (this.lifestyle[key] || []).slice()
      var idx = arr.indexOf(val)
      if (idx >= 0) arr.splice(idx, 1); else arr.push(val)
      this.$set(this.lifestyle, key, arr)
    },
    // ===== diet 工具 =====
    setDiet: function(key, val) {
      this.$set(this.innerDiet, key, val)
    },
    toggleChip: function(scope, key, val) {
      var arr = (this.innerDiet[key] || []).slice()
      var idx = arr.indexOf(val)
      if (idx >= 0) arr.splice(idx, 1); else arr.push(val)
      this.$set(this.innerDiet, key, arr)
    },
    setNoPreference: function() {
      this.$set(this.innerDiet, 'noPreference', !this.innerDiet.noPreference)
    },
    onSpiceChanging: function(e) {
      this.$set(this.innerDiet, 'spiceLevel', e.detail.value)
    },
    onSpiceChange: function(e) {
      this.$set(this.innerDiet, 'spiceLevel', e.detail.value)
    },
    // ===== 自定义添加 =====
    openCustom: function(field) { this.customField = field; this.customInput = '' },
    addCustom: function() {
      var val = (this.customInput || '').trim()
      if (!val) { this.customField = ''; return }
      this.toggleChip('diet', this.customField, val)
      this.customInput = ''; this.customField = ''
    },
    // ===== 疾病 =====
    toggleGroup: function(key) {
      var idx = this.expandedGroups.indexOf(key)
      if (idx >= 0) this.expandedGroups.splice(idx, 1); else this.expandedGroups.push(key)
    },
    isDiseaseSelected: function(name) {
      return (this.innerForm.selectedDiseases || []).some(function(d) { return d.name === name })
    },
    toggleDisease: function(name) {
      var list = (this.innerForm.selectedDiseases || []).slice()
      var idx = -1
      for (var i = 0; i < list.length; i++) { if (list[i].name === name) { idx = i; break } }
      if (idx >= 0) list.splice(idx, 1); else list.push({ name: name, diagnosedAt: '', custom: false })
      this.$set(this.innerForm, 'selectedDiseases', list)
    },
    confirmCustomDisease: function() {
      var name = (this.customDisease.name || '').trim()
      if (!name) return
      this.innerForm.selectedDiseases = (this.innerForm.selectedDiseases || []).concat([{ name: name, diagnosedAt: '', custom: true }])
      this.customDisease.name = ''; this.customDiseaseMode = false
    },
    // ===== 用药 =====
    addMedRow: function() {
      var rows = (this.innerForm.medRows || []).slice()
      rows.push({ name: '', startDate: '', durationText: '', time: '' })
      this.$set(this.innerForm, 'medRows', rows)
    },
    delMedRow: function(i) {
      var rows = (this.innerForm.medRows || []).slice()
      rows.splice(i, 1)
      this.$set(this.innerForm, 'medRows', rows)
    }
  }
}
</script>

<style scoped>
.survey-wrap { min-height: 100vh; background: linear-gradient(180deg, #FFF7ED, #FFF3E6); display: flex; flex-direction: column; }
.progress-bar { padding: 24rpx 32rpx 8rpx; background: #FFF7ED; }
.progress-track { height: 8rpx; background: #FFE6D2; border-radius: 4rpx; overflow: hidden; }
.progress-fill { height: 100%; background: #FF8C42; border-radius: 4rpx; transition: width 0.3s; }
.progress-labels { display: flex; justify-content: space-between; margin-top: 12rpx; }
.progress-label { font-size: 20rpx; color: #B08968; }
.progress-label-active { color: #FF8C42; font-weight: 600; }
.content { flex: 1; padding: 24rpx 32rpx 200rpx; }
.step-head { display: flex; align-items: flex-start; margin-bottom: 24rpx; }
.step-icon { font-size: 48rpx; margin-right: 20rpx; }
.step-head-text { flex: 1; }
.step-title { display: block; font-size: 36rpx; font-weight: 700; color: #4A3728; }
.step-sub { display: block; font-size: 24rpx; color: #B08968; margin-top: 8rpx; }
.card { background: #FFFFFF; border-radius: 24rpx; padding: 28rpx; margin-bottom: 24rpx; box-shadow: 0 4rpx 16rpx rgba(255, 140, 66, 0.08); }
.card-title { display: block; font-size: 28rpx; font-weight: 600; color: #4A3728; margin-bottom: 16rpx; }
.card-head { display: flex; justify-content: space-between; align-items: center; }
.card-del { font-size: 24rpx; color: #E53E3E; }
.card-count { font-size: 22rpx; color: #B08968; }
.group-arrow { font-size: 22rpx; color: #B08968; }
.card-row { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 0; border-bottom: 1rpx solid #F7EFE6; }
.card-row:last-child { border-bottom: none; }
.card-label { font-size: 26rpx; color: #6B5744; }
.card-input { flex: 1; text-align: right; font-size: 28rpx; color: #4A3728; }
.chip-group { display: flex; flex-wrap: wrap; gap: 16rpx; }
.chip { padding: 12rpx 28rpx; background: #FFF3E6; color: #6B5744; border-radius: 999rpx; font-size: 24rpx; border: 2rpx solid transparent; }
.chip-active { background: #FF8C42; color: #FFFFFF; border-color: #FF8C42; font-weight: 600; }
.custom-row { display: flex; align-items: center; margin-top: 16rpx; gap: 16rpx; }
.custom-input { flex: 1; background: #FFF3E6; border-radius: 16rpx; padding: 16rpx 20rpx; font-size: 24rpx; color: #4A3728; }
.custom-confirm { padding: 12rpx 24rpx; background: #FF8C42; color: #FFF; border-radius: 16rpx; font-size: 24rpx; }
.custom-cancel { padding: 12rpx 24rpx; background: #F0E6DC; color: #6B5744; border-radius: 16rpx; font-size: 24rpx; }
.custom-add { margin-top: 16rpx; font-size: 24rpx; color: #FF8C42; }
.add-row { display: flex; align-items: center; justify-content: center; padding: 24rpx; border: 2rpx dashed #FFD4B8; border-radius: 24rpx; color: #FF8C42; font-size: 26rpx; }
.add-icon { margin-right: 8rpx; }
.pref-none-row { margin-bottom: 24rpx; }
.pref-none-btn { width: 100%; background: #FFF3E6; color: #6B5744; border-radius: 999rpx; font-size: 26rpx; padding: 16rpx; border: 2rpx solid #FFD4B8; }
.pref-none-btn-active { background: #FF8C42; color: #FFF; border-color: #FF8C42; }
.struct-row { margin-bottom: 20rpx; }
.struct-label { display: block; font-size: 24rpx; color: #6B5744; margin-bottom: 12rpx; }
.slider-row { display: flex; align-items: center; gap: 24rpx; }
.slider-val { font-size: 32rpx; font-weight: 700; color: #FF8C42; width: 48rpx; }
.checkbox-row { display: flex; align-items: center; gap: 16rpx; }
.checkbox { width: 40rpx; height: 40rpx; border-radius: 8rpx; border: 2rpx solid #FFD4B8; display: flex; align-items: center; justify-content: center; color: #FFF; font-size: 26rpx; background: #FFF; }
.checkbox-on { background: #FF8C42; border-color: #FF8C42; }
.checkbox-label { font-size: 26rpx; color: #4A3728; }
.footer-bar { position: fixed; left: 0; right: 0; bottom: 0; padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom)); background: #FFFFFF; box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.06); display: flex; gap: 20rpx; }
.btn-prev, .btn-next, .btn-done { flex: 1; border-radius: 999rpx; font-size: 28rpx; font-weight: 600; padding: 20rpx 0; background: #FFF3E6; color: #6B5744; }
.btn-next, .btn-done { background: #FF8C42; color: #FFFFFF; }
.btn-done[disabled] { opacity: 0.6; }
</style>

关键实现说明(给执行者的补充,非代码):

  1. 工作副本模式:innerForm/innerDiet 是 props 的浅拷贝(watch+created 初始化),用户在 UI 上编辑的是内部副本;syncToParent() 把 patch 推回父页。lifestyle 同理,但它在 buildLifestyleDiff 时对比原始 props form.lifestyle 得到 diff(未修改的 key 不 emit)。这一步对应设计 §5.2「lifestyle JSON merge」的 diff 来源。
  2. 疾病 8 组 54 项:diseaseGroups 数组必须覆盖旧 MedicalHistoryStep.vue 的全部预置疾病——执行时对照 cfc-frontend/pages/health/components/MedicalHistoryStep.vue 的 diseaseGroups 常量逐项核对,两个来源必须一致(含 name/label 字段名)。
  3. med 模块的时长计算:durationText 为后端落库字段,若需要自动计算("已服 X 年"),执行时对照旧 MedicalHistoryStep 的 durationText 生成逻辑补一个 computed 或在提交前填充;若旧实现也是纯手工输入则保持纯输入。
  4. basic 的 lipidCholesterol:扁平输入值,提交时转 bloodLipids JSON 数组(沿用 health-plan-summary buildHealthPayload 的格式),父页 receive patch 字段名为 bloodLipids。
  5. selectedDiseases/medRows/noMedication 三个 key 存在于 innerForm(父页 memberFormMap 快照同构),buildFormPatch 转 JSON 字符串后以 diseaseHistory/medications 发出。父页只需把 patch 合到 form 快照。
  6. 空提交拦截语义:仅当 patch 和 diff 全部为空时拦截(设计 §5.3),单模块空提交允许。
  7. 模板禁项自查(必须逐条过):
    • 无 ?.(用 &&)✓
    • :key 无表达式(用常量字符串 / 索引)

步骤 1.2:语法校验

cd /sc-data/cfc/cfc-frontend
node -e "
var fs = require('fs')
var src = fs.readFileSync('pages/health/components/ComprehensiveSurvey.vue', 'utf8')
var m = src.match(/<script>([\s\S]*?)<\/script>/)
require('fs').writeFileSync('/tmp/opencode/_survey_check.js', m[1])
"
node --check /tmp/opencode/_survey_check.js && echo "SYNTAX OK"

预期输出 SYNTAX OK。

步骤 1.3:禁项扫描

cd /sc-data/cfc/cfc-frontend
# 模板禁项:可选链 / :key 表达式 / 中文类名
grep -nE '\?\.[a-zA-Z]' pages/health/components/ComprehensiveSurvey.vue || echo "PASS: no optional chaining"
grep -nE ':key="[^"]*(\|\||&&|\?)' pages/health/components/ComprehensiveSurvey.vue || echo "PASS: no key expression"
grep -nE '\.[\x{4e00}-\x{9fa5}]' -P pages/health/components/ComprehensiveSurvey.vue || echo "PASS: no chinese class"

三条均输出 PASS 才算通过(若无 -P 支持可省略第三条,改用人眼核对 <style> 段类名全英文)。

步骤 1.4:提交

git add cfc-frontend/pages/health/components/ComprehensiveSurvey.vue
git commit -m "feat(health): 新增 ComprehensiveSurvey 综合调研组件(8模块step+合并式增量保存)"

任务 2:新增独立调研页 + 注册 + 两处入口改造

文件:

  • 创建:cfc-frontend/pages/health/health-survey.vue
  • 修改:cfc-frontend/pages.json(注册页)
  • 修改:cfc-frontend/pages/index-home/index.vue(goSelfTest 无待填分支)
  • 修改:cfc-frontend/pages/health-main/index.vue(goSelfTest)

目标: 从首页进入独立调研页(仅调研,不生成方案),URL 控制模块子集。


步骤 2.1:创建 health-survey.vue

创建 cfc-frontend/pages/health/health-survey.vue:

<template>
  <view class="survey-page">
    <!-- 加载中 -->
    <view v-if="loading" class="loading-state">
      <text class="loading-text">加载中...</text>
    </view>
    <!-- 调研组件 -->
    <comprehensive-survey
      v-else
      :modules="activeModules"
      :form="formSnapshot"
      :diet="dietSnapshot"
      :profile="memberProfile"
      :submitting="submitting"
      @update-form="onUpdateForm"
      @update-diet="onUpdateDiet"
      @submit="onSubmit"
    />
  </view>
</template>

<script>
import { getHealthStatus, saveHealthStatus, getDietPreferences, saveDietPreferences, getMyMembership } from '../../utils/api'
import ComprehensiveSurvey from '@/pages/health/components/ComprehensiveSurvey.vue'

var ALL_MODULES = ['basic', 'status', 'diet', 'disease', 'bowel', 'sleep', 'med', 'allergy']

export default {
  components: { ComprehensiveSurvey },
  data: function() {
    return {
      loading: true,
      submitting: false,
      activeModules: ALL_MODULES.slice(),
      familyMemberId: '',
      formSnapshot: {},
      dietSnapshot: {},
      memberProfile: {},
      _loaded: false
    }
  },
  onLoad: function(options) {
    // 1. 解析 modules(缺省/空串/全非法 → 全量)
    var modules = options && options.modules
    if (modules) {
      var list = String(modules).split(',').filter(function(m) { return ALL_MODULES.indexOf(m) >= 0 })
      if (list.length > 0) this.activeModules = list
    }
    // 2. 当前成员快照(与 preferences.vue 一致)
    this.familyMemberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || ''
    // 3. 会员门槛(与 health-plan-summary checkQuestionnaireGate 语义一致)
    this.checkGateAndLoad()
  },
  methods: {
    // 会员门槛:未登录静默/付费墙引导(复用 health-plan-summary 逻辑)
    checkGateAndLoad: function() {
      var self = this
      getMyMembership().then(function(res) {
        var hasMember = res && res.code === 200 && res.data &&
          (res.data.isFamilyMember || res.data.hasHealthPlan || res.data.isMember || res.data.level)
        if (res && res.code !== 200) {
          self.loading = false
          self.showPaywall()
          return
        }
        if (!hasMember) {
          self.loading = false
          self.showPaywall()
          return
        }
        self.loadData()
      }).catch(function() {
        // 网络失败放行(不阻断调研,保存时再让后端校验)
        self.loadData()
      })
    },
    showPaywall: function() {
      var self = this
      uni.showModal({
        title: '开通 9.9 元/月体验会员',
        content: '填写健康调研需先开通体验会员(9.9 元/月),开通后可填写并保存。',
        confirmText: '去开通',
        cancelText: '再想想',
        success: function(r) {
          if (r.confirm) {
            uni.setStorageSync('SURVEY_RESUME_AFTER_PURCHASE', '1')
            uni.navigateTo({ url: '/pages/membership/pay?levelCode=BASIC&period=monthly' })
          } else {
            uni.navigateBack()
          }
        }
      })
    },
    // 加载快照(health_status + diet_preferences 并行)
    loadData: function() {
      var self = this
      self.loading = true
      var tasks = []
      tasks.push(getHealthStatus(self.familyMemberId || undefined).then(function(res) {
        var d = (res && res.code === 200 && res.data) ? res.data : null
        self.formSnapshot = d ? {
          heightCm: d.heightCm || '',
          weightKg: d.weightKg || '',
          bloodPressure: d.bloodPressure || '',
          bloodGlucose: d.bloodGlucose || '',
          lipidCholesterol: parseLipid(d.bloodLipids),
          notes: d.notes || '',
          selectedDiseases: parseDiseases(d.diseaseHistory),
          medRows: parseMeds(d.medications),
          noMedication: false,
          lifestyle: d.lifestyle ? parseLifestyle(d.lifestyle) : {}
        } : { selectedDiseases: [], medRows: [], noMedication: false, lifestyle: {} }
        // 成员画像(性别/年龄)
        self.memberProfile = {
          gender: d && (d.gender || d.sex) || '',
          age: d && d.age != null ? d.age : ''
        }
      }).catch(function() {}))
      tasks.push(getDietPreferences({ memberId: self.familyMemberId }).then(function(res) {
        var d = (res && res.code === 200 && res.data) ? res.data : null
        self.dietSnapshot = normalizeDiet(d)
      }).catch(function() { self.dietSnapshot = normalizeDiet(null) }))
      Promise.all(tasks).then(function() {
        self.loading = false
        self._loaded = true
      })
    },
    // 组件 patch → 合并到快照
    onUpdateForm: function(patch) {
      var self = this
      Object.keys(patch || {}).forEach(function(k) {
        self.$set(self.formSnapshot, k, patch[k])
      })
    },
    onUpdateDiet: function(patch) {
      var self = this
      Object.keys(patch || {}).forEach(function(k) {
        self.$set(self.dietSnapshot, k, patch[k])
      })
    },
    // 提交:health_status 侧 merge lifestyle + diet_preferences 侧
    onSubmit: function(payload) {
      var self = this
      if (self.submitting) return
      self.submitting = true
      // 血压格式兜底校验
      if (payload.form.bloodPressure && !/^\d{2,3}\/\d{2,3}$/.test(payload.form.bloodPressure)) {
        self.submitting = false
        uni.showToast({ title: '血压格式应为 120/80', icon: 'none' })
        return
      }
      // 1) health_status payload:启用模块涉及的 form 字段 + lifestyle merge
      var formFields = ['heightCm', 'weightKg', 'bloodPressure', 'bloodGlucose', 'bloodLipids', 'diseaseHistory', 'medications', 'notes', 'noMedication']
      var hsPayload = {}
      formFields.forEach(function(k) {
        if (payload.form[k] != null && payload.form[k] !== '' && payload.form[k] !== false) hsPayload[k] = payload.form[k]
      })
      // lifestyle merge:现有 lifestyle + diff
      var existing = self.formSnapshot.lifestyle || {}
      var merged = Object.assign({}, existing)
      Object.keys(payload.lifestyleDiff || {}).forEach(function(k) { merged[k] = payload.lifestyleDiff[k] })
      if (Object.keys(merged).length > 0) hsPayload.lifestyle = JSON.stringify(merged)
      // 2) diet payload:启用模块字段 + allergies(allergy 模块)
      var dietFields = ['allergies', 'absoluteAvoid', 'mustEat', 'flavorPref', 'cuisinePref', 'cookingMethods', 'healthGoals', 'vegetableFruit', 'dairy', 'sugaryDrinks', 'religiousDiet', 'spiceLevel', 'noPreference']
      var dietPayload = {}
      dietFields.forEach(function(k) {
        if (payload.diet[k] != null && payload.diet[k] !== '' && payload.diet[k] !== false && !(payload.diet[k] instanceof Array && payload.diet[k].length === 0)) {
          dietPayload[k] = payload.diet[k]
        }
      })
      var saveTasks = []
      if (Object.keys(hsPayload).length > 0) {
        hsPayload.memberId = self.familyMemberId
        saveTasks.push(saveHealthStatus(hsPayload))
      }
      if (Object.keys(dietPayload).length > 0) {
        dietPayload.familyMemberId = self.familyMemberId
        saveTasks.push(saveDietPreferences(dietPayload))
      }
      Promise.all(saveTasks).then(function() {
        uni.showToast({ title: '已保存', icon: 'success' })
        self.submitting = false
        setTimeout(function() { uni.navigateBack() }, 800)
      }).catch(function(err) {
        self.submitting = false
        uni.showToast({ title: (err && err.message) || '保存失败,请重试', icon: 'none' })
      })
    },
    onShow: function() {
      // 购买返回:恢复调研
      if (uni.getStorageSync('SURVEY_RESUME_AFTER_PURCHASE') === '1') {
        uni.removeStorageSync('SURVEY_RESUME_AFTER_PURCHASE')
        if (!this._loaded) this.checkGateAndLoad()
      }
    }
  }
}

// ===== 本地工具函数(模块外,避免污染 data) =====
function parseLipid(json) {
  try {
    var arr = JSON.parse(json || '[]')
    if (arr instanceof Array && arr.length > 0 && arr[0].value) return String(arr[0].value)
  } catch (e) {}
  return ''
}
function parseDiseases(json) {
  var list = []
  try {
    var arr = JSON.parse(json || '[]')
    if (arr instanceof Array) {
      arr.forEach(function(item) {
        if (item.name) list.push({ name: item.name, diagnosedAt: item.diagnosedAt || '', custom: false })
      })
    }
  } catch (e) {}
  return list
}
function parseMeds(json) {
  var list = []
  try {
    var arr = JSON.parse(json || '[]')
    if (arr instanceof Array) {
      arr.forEach(function(item) {
        list.push({ name: item.name || '', startDate: item.startDate || '', durationText: item.durationText || '', time: item.time || '' })
      })
    }
  } catch (e) {}
  return list
}
function parseLifestyle(json) {
  var obj = {}
  try {
    var parsed = JSON.parse(json)
    if (parsed && typeof parsed === 'object') {
      var keys = ['mood', 'sleepDuration', 'bedtime', 'bowelFrequency', 'bowelForm', 'antibioticHistory',
        'currentStatus', 'exerciseFrequency', 'exerciseDuration',
        'allergySymptoms', 'menstrualCycle', 'specialPeriod', 'growthDevelopment',
        'pickyEating', 'osteoporosisRisk', 'fallRisk', 'screenTime', 'teenMood']
      keys.forEach(function(k) { obj[k] = parsed[k] || (Array.isArray(parsed[k]) ? [] : '') })
    }
  } catch (e) {}
  return obj
}
function normalizeDiet(d) {
  return {
    allergies: (d && d.allergies) || [],
    absoluteAvoid: (d && d.absoluteAvoid) || [],
    mustEat: (d && d.mustEat) || [],
    religiousDiet: (d && d.religiousDiet) || 'none',
    spiceLevel: (d && d.spiceLevel) || 0,
    flavorPref: (d && d.flavorPref) || [],
    cuisinePref: (d && d.cuisinePref) || [],
    cookingMethods: (d && d.cookingMethods) || [],
    healthGoals: (d && d.healthGoals) || [],
    noPreference: !!(d && d.noPreference),
    vegetableFruit: (d && d.vegetableFruit) || '',
    dairy: (d && d.dairy) || '',
    sugaryDrinks: (d && d.sugaryDrinks) || ''
  }
}
</script>

<style>
.survey-page { min-height: 100vh; background: linear-gradient(180deg, #FFF7ED, #FFF3E6); }
.loading-state { padding: 200rpx 0; text-align: center; }
.loading-text { font-size: 28rpx; color: #B08968; }
</style>

关键实现说明:

  1. 会员门槛对齐:执行时打开 health-plan-summary.vue 的 checkQuestionnaireGate()(:752 附近)与 showPaywall()(:776 附近),从后端响应字段判断是否开通——本页 checkGateAndLoad 的判断字段必须与 health-plan-summary 的判定表达式逐字段对齐(isFamilyMember/hasHealthPlan/level 等)。若 health-plan-summary 用的是不同字段名,以它为准改本页。
  2. onShow 购买恢复:SURVEY_RESUME_AFTER_PURCHASE 标记是本页自有约定(不干扰 health-plan-summary 的 PLAN_RESUME_AFTER_PURCHASE)。
  3. 首次加载只走一次:onLoad 触发 checkGateAndLoad(),未写 onShow 无条件刷新(无 _onLoadFired 因为本页非 Tab 页;onShow 仅处理购买返回),符合 AGENTS.md 规则 1/4c。
  4. profile 来源:getHealthStatus 响应若无 gender/age 字段,执行时保留 gender: '', age: '' 兜底(条件渲染区全隐藏,已填 lifestyle 值仍保留)。

步骤 2.2:pages.json 注册

在 cfc-frontend/pages.json 的 health 分包("root": "pages/health")的 pages 数组末尾追加(对齐 health-status-form 条目格式):

{
  "path": "health-survey",
  "style": {
    "navigationBarTitleText": "综合健康调研",
    "navigationStyle": "default"
  }
}
# 校验 pages.json 仍是合法 JSON
cd /sc-data/cfc/cfc-frontend
node -e "JSON.parse(require('fs').readFileSync('pages.json','utf8')); console.log('pages.json VALID')"

步骤 2.3:改造 index-home/index.vue goSelfTest

文件 cfc-frontend/pages/index-home/index.vue,把 goSelfTest 的 else 分支(L1646-1649)从跳 health-plan-summary 改为跳新调研页(全量 8 模块):

      } else {
        // 无待填调研 → 进入独立综合调研(仅调研不生成方案;会员门槛由调研页 onLoad 把关)
        uni.navigateTo({ url: '/pages/health/health-survey?modules=basic,status,diet,disease,bowel,sleep,med,allergy' })
      }

注意保留 if 分支(survey-questionnaire)不变,仅改 else 分支 3 行。goSelfTest 上方注释同步更新。

步骤 2.4:改造 health-main/index.vue goSelfTest

文件 cfc-frontend/pages/health-main/index.vue L518:

    goSelfTest: function() {
      uni.navigateTo({ url: '/pages/health/health-survey?modules=basic,status,diet,disease,bowel,sleep,med,allergy' })
    },

步骤 2.5:API 去重审计

cd /sc-data/cfc/cfc-frontend
node scripts/audit-duplicate-api-calls.js

要求退出码 0。若 health-survey.vue 被审计出 onLoad/onShow 双发告警,按 AGENTS.md 规则 1 加 _onLoadFired 标志修正后重跑。

步骤 2.6:语法校验

cd /sc-data/cfc/cfc-frontend
node -e "
var fs = require('fs')
var src = fs.readFileSync('pages/health/health-survey.vue', 'utf8')
var m = src.match(/<script>([\s\S]*?)<\/script>/)
require('fs').writeFileSync('/tmp/opencode/_survey_page_check.js', m[1])
"
node --check /tmp/opencode/_survey_page_check.js && echo "SYNTAX OK"

步骤 2.7:提交

git add cfc-frontend/pages/health/health-survey.vue cfc-frontend/pages.json \
        cfc-frontend/pages/index-home/index.vue cfc-frontend/pages/health-main/index.vue
git commit -m "feat(health): 新增独立综合调研页 health-survey + pages.json 注册 + 两处首页入口改造"

任务 3:方案制定页 Step2 组件替换 + 旧向导标记 DEPRECATED

文件:

  • 修改:cfc-frontend/pages/health/health-plan-summary.vue
  • 标记:cfc-frontend/pages/health/components/HealthQuestionnaireWizard.vue + 5 个子 step

目标: 方案制定页 Step2 改用新组件,多成员 tab/快照/保存流程语义不变。


步骤 3.1:模板替换

health-plan-summary.vue 模板 L54-67 的 <health-questionnaire-wizard> 整块替换为:

      <!-- 综合调研(8 模块 step:组件内部管理步骤,多成员 tab 由父页切换传快照) -->
      <comprehensive-survey
        :modules="surveyModules"
        :form="currentMemberSnapshot ? currentMemberSnapshot.form : {}"
        :diet="currentDiet"
        :profile="currentMemberProfile"
        :submitting="loading"
        @update-form="onSurveyForm"
        @update-diet="onSurveyDiet"
        @submit="onSurveySubmit"
      />

注意:memberFormMap 每项结构为 {form:{...}, lipidCholesterol, selectedDiseases, medRows, noMedication, lifestyle}——设计文档 props 契约写的是 form: Object 平铺结构。执行时按本页快照实际结构传 :form="currentMemberSnapshot.form",即把快照的 form 子对象(含 lifestyle/selectedDiseases/medRows 等)整体作为组件 form prop。这要求组件侧 buildFormPatch 读取的是 innerForm.selectedDiseases/medRows/noMedication/lifestyle——与任务 1 代码一致。

一致性修正说明:任务 1 组件的 form prop 语义为「快照的 form 子对象」(含 selectedDiseases/medRows/noMedication/lifestyle/lipidCholesterol/扁平体征 6 字段),design spec §5.5 的字段清单已覆盖这些 key,不需要改 spec。

步骤 3.2:script 修改(import + components + 新增 computed + 事件方法)

import 区(L188-193):新增 ComprehensiveSurvey import,保留 HealthQuestionnaireWizard import 暂不删(用于确认无引用后):

import MemberChipPicker from '@/pages/health/components/MemberChipPicker.vue'
import HealthQuestionnaireWizard from '@/pages/health/components/HealthQuestionnaireWizard.vue'
import ComprehensiveSurvey from '@/pages/health/components/ComprehensiveSurvey.vue'

export default {
  components: { MemberChipPicker, HealthQuestionnaireWizard, ComprehensiveSurvey },

data 区:新增 surveyModules 常量(全量 8 模块):

      // Step2 综合调研模块(方案制定前完整调研,全量 8 模块)
      surveyModules: ['basic', 'status', 'diet', 'disease', 'bowel', 'sleep', 'med', 'allergy'],

computed 区新增 3 个:

    // 当前填写成员的 health 快照
    currentMemberSnapshot: function() {
      return this.memberFormMap[String(this.activeFormMemberId || '__account')] || null
    },
    // 当前成员的 form 子对象(组件 form prop)
    currentMemberForm: function() {
      var s = this.currentMemberSnapshot
      return (s && s.form) || {
        heightCm: '', weightKg: '', bloodPressure: '', bloodGlucose: '', notes: '',
        lipidCholesterol: '', selectedDiseases: [], medRows: [], noMedication: false,
        lifestyle: {}
      }
    },
    // 当前成员的 diet 快照
    currentDiet: function() {
      return this.memberDietMap[String(this.activeFormMemberId || '__account')] || null
    },
    // 当前成员画像(性别/年龄,供组件条件渲染;数据源见 selectedMembers/pickerMembers)
    currentMemberProfile: function() {
      var m = this.selectedMembers.filter(function(x) { return String(x.memberId) === String(this.activeFormMemberId) }, this)
      var one = m.length > 0 ? m[0] : null
      return { gender: (one && (one.gender || one.sex)) || '', age: (one && one.age != null) ? one.age : '' }
    },

执行时需核对 selectedMembers computed 与 pickerMembers 里成员的字段名(gender/sex/age/birthday),若成员列表无 age 字段而有 birthday,用 utils/format.js 的 parseDate 计算年龄;若两者都取不到,保持空串兜底(条件渲染隐藏)。

methods 区新增/替换(把旧 onWizard* 系列替换为 onSurvey* 系列):

    // ===== 综合调研事件:patch 合并到当前成员快照 =====
    // form patch:可能是扁平体征(heightCm 等)、JSON 串(diseaseHistory/medications)、
    // 或结构化 key(selectedDiseases/medRows/noMedication)。统一处理两类:
    onSurveyForm: function(patch) {
      var key = String(this.activeFormMemberId || '__account')
      var prev = this.memberFormMap[key] || { form: {}, lifestyle: {} }
      var newForm = Object.assign({}, prev.form)
      // 扁平/JSON 字段直接进 form
      var formKeys = ['heightCm', 'weightKg', 'bloodPressure', 'bloodGlucose', 'notes', 'diseaseHistory', 'medications', 'bloodLipids', 'noMedication', 'selectedDiseases', 'medRows', 'lipidCholesterol']
      var self = this
      formKeys.forEach(function(k) {
        if (patch[k] != null) newForm[k] = patch[k]
      })
      this.$set(this.memberFormMap, key, Object.assign({}, prev, { form: newForm }))
    },
    // diet patch 合并到当前成员 diet 快照
    onSurveyDiet: function(patch) {
      var key = String(this.activeFormMemberId || '__account')
      var prev = this.memberDietMap[key] || this.normalizeDiet(null)
      this.$set(this.memberDietMap, key, Object.assign({}, prev, patch))
    },
    // lifestyle merge:把 diff 合并进当前成员 lifestyle 后整体回写快照
    onSurveyLifestyle: function(diff) {
      var key = String(this.activeFormMemberId || '__account')
      var prev = this.memberFormMap[key] || { form: {}, lifestyle: {} }
      var merged = Object.assign({}, prev.lifestyle || {}, diff || {})
      this.$set(this.memberFormMap, key, Object.assign({}, prev, { lifestyle: merged }))
    },
    // 提交:走原 saveAndGenerate(血压校验 + Promise.all 保存 + generatePlan 不变)
    onSurveySubmit: function(payload) {
      var self = this
      // 组件 emit 前已做空提交拦截,此处把 lifestyle diff 合并后触发保存
      if (payload && payload.lifestyleDiff) self.onSurveyLifestyle(payload.lifestyleDiff)
      // 同步 form/diet 快照的最终值(组件工作副本可能比 patch 更新)
      if (payload && payload.form) self.onSurveyForm(payload.form)
      if (payload && payload.diet) self.onSurveyDiet(payload.form ? payload.form : null)  // 见下方修正
      self.saveAndGenerate()
    },

⚠️ 执行修正(任务1 组件 emit 契约落地):任务 1 组件的 submit emit 的 payload.form 是完整工作副本(含所有结构化 key),不是 patch。所以 onSurveySubmit 不需要拆 patch,直接:

> onSurveySubmit: function(payload) {
>   var key = String(this.activeFormMemberId || '__account')
>   var prev = this.memberFormMap[key] || { form: {}, lifestyle: {} }
>   var mergedLifestyle = Object.assign({}, prev.lifestyle || {}, payload.lifestyleDiff || {})
>   this.$set(this.memberFormMap, key, {
>     form: payload.form || prev.form,
>     lifestyle: mergedLifestyle
>   })
>   if (payload.diet) this.$set(this.memberDietMap, key, payload.diet)
>   this.saveAndGenerate()
> },
> ```
> **同时**:`buildHealthPayload`(L552-593)的 `lifestyleKeys` 数组必须补 3 个新 key:
> ```js
> var lifestyleKeys = ['mood', 'sleepDuration', 'bedtime', 'bowelFrequency', 'bowelForm', 'antibioticHistory', 'allergySymptoms', 'menstrualCycle', 'specialPeriod', 'growthDevelopment', 'pickyEating', 'osteoporosisRisk', 'fallRisk', 'screenTime', 'teenMood', 'currentStatus', 'exerciseFrequency', 'exerciseDuration']
> ```
> 且 `parseLifestyle`(L607-630)同样补这 3 个 key 的解析(否则回填丢失)。

**删除/保留旧的 onWizard\* 方法**:`onWizardVitals`/`onWizardMedical`/`onWizardLifestyle`/`onWizardDiet`/`onWizardSubmit`/`mergeWizardForm` 共 6 个方法(L512-551)**全部删除**(新组件不再 emit 旧事件)。执行时逐个删除,确保无残留引用:

bash grep -n 'onWizard|mergeWizardForm' cfc-frontend/pages/health/health-plan-summary.vue


预期无输出。若模板或 `$refs.wizard` 仍有引用,一并清理。

### 步骤 3.3:旧向导标记 DEPRECATED(6 个文件)

给以下 6 个文件**文件头第一行**(`<template>` 之前)插入同一句注释:

html


1. `cfc-frontend/pages/health/components/HealthQuestionnaireWizard.vue`
2. `cfc-frontend/pages/health/components/BasicVitalsStep.vue`
3. `cfc-frontend/pages/health/components/MedicalHistoryStep.vue`
4. `cfc-frontend/pages/health/components/LifestyleStep.vue`
5. `cfc-frontend/pages/health/components/DietHabitsStep.vue`
6. `cfc-frontend/pages/health/components/ConfirmStep.vue`(以实际文件列表为准,执行时 `ls cfc-frontend/pages/health/components/` 核对旧 wizard 的子组件集合)

> 执行时先确认第 6 个文件名:`ls cfc-frontend/pages/health/components/`;旧 wizard import 了哪些子 step,就标记哪些。**只加注释,不删任何代码行**。

### 步骤 3.4:全量引用检查(旧 wizard 必须零引用)

bash cd /sc-data/cfc/cfc-frontend grep -rn 'HealthQuestionnaireWizard|health-questionnaire-wizard' pages/ components/ utils/ || echo "PASS: no wizard reference"


预期 `PASS: no wizard reference`。若有残留(如 `$refs.wizard`),清理后重跑。

### 步骤 3.5:语法校验 + 审计

bash cd /sc-data/cfc/cfc-frontend for f in pages/health/health-plan-summary.vue pages/health/components/HealthQuestionnaireWizard.vue pages/health/components/BasicVitalsStep.vue pages/health/components/MedicalHistoryStep.vue pages/health/components/LifestyleStep.vue pages/health/components/DietHabitsStep.vue; do node -e " var fs = require('fs') var src = fs.readFileSync('$f', 'utf8') var m = src.match(/