日期: 2026-10-16 状态: 待执行 前置设计:
docs/superpowers/specs/2026-10-16-comprehensive-health-survey-design.md(已定稿 commit564e29eab) 执行方式: 按 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/bloodLipidsstatus → 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/exerciseDurationsleep → lifestyle:sleepDuration/bedtimemed → health_status 列:medications(JSON 数组)allergy → diet_preferences:allergies + lifestyle:allergySymptoms(双写)ComprehensiveSurvey.vue文件:
cfc-frontend/pages/health/components/ComprehensiveSurvey.vue目标: 8 模块 step 问卷组件,支持按 modules props 渲染子集,合并式增量保存,暖灶主题。
创建文件,写入以下内容(完整代码,非占位):
<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>
关键实现说明(给执行者的补充,非代码):
innerForm/innerDiet 是 props 的浅拷贝(watch+created 初始化),用户在 UI 上编辑的是内部副本;syncToParent() 把 patch 推回父页。lifestyle 同理,但它在 buildLifestyleDiff 时对比原始 props form.lifestyle 得到 diff(未修改的 key 不 emit)。这一步对应设计 §5.2「lifestyle JSON merge」的 diff 来源。diseaseGroups 数组必须覆盖旧 MedicalHistoryStep.vue 的全部预置疾病——执行时对照 cfc-frontend/pages/health/components/MedicalHistoryStep.vue 的 diseaseGroups 常量逐项核对,两个来源必须一致(含 name/label 字段名)。med 模块的时长计算:durationText 为后端落库字段,若需要自动计算("已服 X 年"),执行时对照旧 MedicalHistoryStep 的 durationText 生成逻辑补一个 computed 或在提交前填充;若旧实现也是纯手工输入则保持纯输入。basic 的 lipidCholesterol:扁平输入值,提交时转 bloodLipids JSON 数组(沿用 health-plan-summary buildHealthPayload 的格式),父页 receive patch 字段名为 bloodLipids。selectedDiseases/medRows/noMedication 三个 key 存在于 innerForm(父页 memberFormMap 快照同构),buildFormPatch 转 JSON 字符串后以 diseaseHistory/medications 发出。父页只需把 patch 合到 form 快照。?.(用 &&)✓:key 无表达式(用常量字符串 / 索引)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。
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> 段类名全英文)。
git add cfc-frontend/pages/health/components/ComprehensiveSurvey.vue
git commit -m "feat(health): 新增 ComprehensiveSurvey 综合调研组件(8模块step+合并式增量保存)"
文件:
cfc-frontend/pages/health/health-survey.vuecfc-frontend/pages.json(注册页)cfc-frontend/pages/index-home/index.vue(goSelfTest 无待填分支)cfc-frontend/pages/health-main/index.vue(goSelfTest)目标: 从首页进入独立调研页(仅调研,不生成方案),URL 控制模块子集。
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>
关键实现说明:
health-plan-summary.vue 的 checkQuestionnaireGate()(:752 附近)与 showPaywall()(:776 附近),从后端响应字段判断是否开通——本页 checkGateAndLoad 的判断字段必须与 health-plan-summary 的判定表达式逐字段对齐(isFamilyMember/hasHealthPlan/level 等)。若 health-plan-summary 用的是不同字段名,以它为准改本页。onShow 购买恢复:SURVEY_RESUME_AFTER_PURCHASE 标记是本页自有约定(不干扰 health-plan-summary 的 PLAN_RESUME_AFTER_PURCHASE)。onLoad 触发 checkGateAndLoad(),未写 onShow 无条件刷新(无 _onLoadFired 因为本页非 Tab 页;onShow 仅处理购买返回),符合 AGENTS.md 规则 1/4c。getHealthStatus 响应若无 gender/age 字段,执行时保留 gender: '', age: '' 兜底(条件渲染区全隐藏,已填 lifestyle 值仍保留)。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')"
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上方注释同步更新。
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' })
},
cd /sc-data/cfc/cfc-frontend
node scripts/audit-duplicate-api-calls.js
要求退出码 0。若 health-survey.vue 被审计出 onLoad/onShow 双发告警,按 AGENTS.md 规则 1 加 _onLoadFired 标志修正后重跑。
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"
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 注册 + 两处首页入口改造"
文件:
cfc-frontend/pages/health/health-plan-summary.vuecfc-frontend/pages/health/components/HealthQuestionnaireWizard.vue + 5 个子 step目标: 方案制定页 Step2 改用新组件,多成员 tab/快照/保存流程语义不变。
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 组件的
formprop 语义为「快照的 form 子对象」(含selectedDiseases/medRows/noMedication/lifestyle/lipidCholesterol/扁平体征 6 字段),design spec §5.5 的字段清单已覆盖这些 key,不需要改 spec。
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 : '' }
},
执行时需核对
selectedMemberscomputed 与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 组件的
submitemit 的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(/