|
|
@@ -0,0 +1,1566 @@
|
|
|
+# 综合健康调研(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 + 数据层
|
|
|
+
|
|
|
+创建文件,写入以下内容(**完整代码,非占位**):
|
|
|
+
|
|
|
+```vue
|
|
|
+<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:语法校验
|
|
|
+
|
|
|
+```bash
|
|
|
+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:禁项扫描
|
|
|
+
|
|
|
+```bash
|
|
|
+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:提交
|
|
|
+
|
|
|
+```bash
|
|
|
+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`:
|
|
|
+
|
|
|
+```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` 条目格式):
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "path": "health-survey",
|
|
|
+ "style": {
|
|
|
+ "navigationBarTitleText": "综合健康调研",
|
|
|
+ "navigationStyle": "default"
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+```bash
|
|
|
+# 校验 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 模块):
|
|
|
+
|
|
|
+```js
|
|
|
+ } 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:
|
|
|
+
|
|
|
+```js
|
|
|
+ goSelfTest: function() {
|
|
|
+ uni.navigateTo({ url: '/pages/health/health-survey?modules=basic,status,diet,disease,bowel,sleep,med,allergy' })
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 2.5:API 去重审计
|
|
|
+
|
|
|
+```bash
|
|
|
+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:语法校验
|
|
|
+
|
|
|
+```bash
|
|
|
+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:提交
|
|
|
+
|
|
|
+```bash
|
|
|
+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>` 整块替换为:
|
|
|
+
|
|
|
+```html
|
|
|
+ <!-- 综合调研(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 暂不删(用于确认无引用后):
|
|
|
+
|
|
|
+```js
|
|
|
+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 模块):
|
|
|
+
|
|
|
+```js
|
|
|
+ // Step2 综合调研模块(方案制定前完整调研,全量 8 模块)
|
|
|
+ surveyModules: ['basic', 'status', 'diet', 'disease', 'bowel', 'sleep', 'med', 'allergy'],
|
|
|
+```
|
|
|
+
|
|
|
+**computed 区新增 3 个**:
|
|
|
+
|
|
|
+```js
|
|
|
+ // 当前填写成员的 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* 系列)**:
|
|
|
+
|
|
|
+```js
|
|
|
+ // ===== 综合调研事件: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,直接:
|
|
|
+> ```js
|
|
|
+> 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
|
|
|
+<!-- DEPRECATED: 已由 ComprehensiveSurvey.vue 替代,待确认无引用后清理(2026-10-16 标记) -->
|
|
|
+```
|
|
|
+
|
|
|
+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(/<script>([\s\S]*?)<\/script>/)
|
|
|
+ require('fs').writeFileSync('/tmp/opencode/_chk.js', m[1])
|
|
|
+ "
|
|
|
+ node --check /tmp/opencode/_chk.js && echo "OK: $f" || echo "FAIL: $f"
|
|
|
+done
|
|
|
+node scripts/audit-duplicate-api-calls.js && echo "AUDIT PASS"
|
|
|
+```
|
|
|
+
|
|
|
+### 步骤 3.6:提交
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/health/health-plan-summary.vue \
|
|
|
+ cfc-frontend/pages/health/components/HealthQuestionnaireWizard.vue \
|
|
|
+ cfc-frontend/pages/health/components/BasicVitalsStep.vue \
|
|
|
+ cfc-frontend/pages/health/components/MedicalHistoryStep.vue \
|
|
|
+ cfc-frontend/pages/health/components/LifestyleStep.vue \
|
|
|
+ cfc-frontend/pages/health/components/DietHabitsStep.vue
|
|
|
+git commit -m "refactor(health): 方案制定页 Step2 替换 ComprehensiveSurvey,旧向导标记 DEPRECATED"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 四、验证清单(全部完成后逐项跑)
|
|
|
+
|
|
|
+> **验证前提**:HBuilderX 重新打包(改代码后必须重新运行,否则旧产物白板/旧行为)。Agent 只做语法/结构校验,不打包。
|
|
|
+
|
|
|
+### A. 语法与静态检查
|
|
|
+
|
|
|
+1. `node --check` 对 3 个改动/新增页(health-survey.vue / ComprehensiveSurvey.vue / health-plan-summary.vue)全部通过
|
|
|
+2. `pages.json` JSON.parse 通过
|
|
|
+3. `node scripts/audit-duplicate-api-calls.js` 退出码 0
|
|
|
+4. 全仓 grep 无 `HealthQuestionnaireWizard` 引用
|
|
|
+
|
|
|
+### B. 手工验证(对照设计 §九)
|
|
|
+
|
|
|
+| # | 场景 | 预期 |
|
|
|
+|---|------|------|
|
|
|
+| 1 | 首页 `index-home` 无待填调研点自测大卡 | 跳 `/pages/health/health-survey?modules=basic,status,diet,disease,bowel,sleep,med,allergy`,8 step 流 |
|
|
|
+| 2 | `health-main` 自检卡点击 | 同上 |
|
|
|
+| 3 | 首页有待填调研(pendingSurveys 非空) | 仍跳 `survey-questionnaire`(现状保留) |
|
|
|
+| 4 | URL `modules=diet,bowel` | 仅 2 个 step(第三步完成后底部显示「完成」) |
|
|
|
+| 5 | 每个模块的交互(chip/滑块/动态行/分组折叠/自定义添加/无特殊偏好一键清空) | 与 preferences.vue / 旧 wizard 体验一致,无卡死 |
|
|
|
+| 6 | 保存独立调研 | health_status + diet_preferences 正确落库;回填一致 |
|
|
|
+| 7 | **增量验证** | 先填疾病史+用药保存 → 再只调研「运动」(`modules=bowel`)保存 → 疾病史/用药数据仍在(未被 null 覆盖) |
|
|
|
+| 8 | 过敏源模块 | `diet_preferences.allergies` 与 `lifestyle.allergySymptoms` 双写均有数据 |
|
|
|
+| 9 | 饮食结构 3 项 | vegetableFruit/dairy/sugaryDrinks 落库且回填 |
|
|
|
+| 10 | 方案制定页 Step2 | 多成员 tab 切换、各成员快照独立、生成方案成功、跳过问卷路径不变 |
|
|
|
+| 11 | 空提交 | 所有启用模块均未填 → toast「请至少填写一项」 |
|
|
|
+| 12 | 非法值 | 身高 999 / 血压 abc 被前端拦截 |
|
|
|
+| 13 | 会员门槛 | 非会员进独立调研页 → 弹付费墙 → 去开通返回后自动恢复 |
|
|
|
+
|
|
|
+### C. 后端零改动确认
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc && git diff --stat HEAD~5 -- cfc-backend/ && echo "--- 若上面为空即零后端改动 ---"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 五、风险与回滚
|
|
|
+
|
|
|
+| 风险 | 应对 |
|
|
|
+|------|------|
|
|
|
+| `memberFormMap` 快照结构与组件 `form` prop 契约不符导致 Step2 空白 | 任务 3.1 已明确传 `form` 子对象;验证清单 B10 覆盖 |
|
|
|
+| lifestyle 3 个新 key 在 LangGraph 侧无消费导致报错 | 设计已确认后端/ LangGraph 读不到新 key 时静默跳过(JSON 自由 key 无 schema 约束);不新增 LangGraph 改动 |
|
|
|
+| 旧 wizard 删除过早导致回归 | 仅标记 DEPRECATED,不删文件;后续单独清理任务 |
|
|
|
+| 会员字段判定与 health-plan-summary 不一致 | 步骤 2.1 说明已要求逐字段对齐;验证 B13 覆盖 |
|
|
|
+| 增量保存被 null 覆盖 | 依赖 MP `updateById` 非 null 更新语义;验证 B7 专项覆盖 |
|
|
|
+
|
|
|
+**回滚**:每个任务独立 commit。回滚 `git revert <commit>` 即可,无 DDL、无后端改动,数据层无损。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 六、文档同步(计划完成后)
|
|
|
+
|
|
|
+1. `docs/superpowers/PROJECT-OVERVIEW.md`:把综合调研特性从"设计中"改为"已实现"条目,登记计划文档索引
|
|
|
+2. 设计文档 `2026-10-16-comprehensive-health-survey-design.md` 状态从「待审查」改为「已实现」
|