面向 AI 代理的工作者: 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(
- [ ])语法来跟踪进度。
目标: 新增 health_status 表与档案页(按 userId 一份),出方案流程(health-plan-summary.vue)读取健康现状并在未填时弹窗提醒,健康现状+饮食偏好随 AI 请求注入 Dify inputs,提升方案精准度。
架构: 后端新增 Entity/Mapper/Service/Controller(/api/health-status get+save,统一 POST),DatabaseInitializer 加迁移 229 建表并同步 schema.sql;修改 AIChatController.sendMessage 解析前端传来的 healthStatus/dietPrefs JSON 注入 inputs。前端新增独立档案页 health-status-form.vue,修改 health-plan-summary.vue(读取+弹窗+传参)与 api.js(新增 API + aiSendMessage 透传)。饮食偏好复用现有 /api/diet/preferences/current-member,不新增端点。
技术栈: Java 8 + Spring Boot 2.7.18 + MyBatis-Plus + MySQL 8(JSON 列);uni-app Vue 2 小程序(Options API)。
规格: docs/superpowers/specs/2026-08-13-health-status-survey-design.md
后端(cfc-backend/src/main/java/com/etotem/cfc/):
entity/HealthStatus.java — 实体,@TableName("health_status")mapper/HealthStatusMapper.java — BaseMapperservice/HealthStatusService.java — 接口(getByUserId/save)service/impl/HealthStatusServiceImpl.java — upsert 实现controller/HealthStatusController.java — /api/health-status get/saveconfig/DatabaseInitializer.java — 迁移 229 建表controller/ai/AIChatController.java — 注入 health_status/diet_preferences 到 inputs资源:
cfc-backend/src/main/resources/schema.sql — 追加 health_status 建表语句前端(cfc-frontend/):
pages/health/health-status-form.vue — 独立档案页pages/health/health-plan-summary.vue — 读取/弹窗/传参pages/health-main/index.vue — 加「健康档案」入口pages.json — 注册 health-status-formutils/api.js — 新增 getHealthStatus/saveHealthStatus + aiSendMessage 透传文档:
docs/superpowers/plans/2026-08-13-health-status-survey.md(本文件)文件:
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java(文件末尾 runMigrations() 内,// 迁移228 之后)修改:cfc-backend/src/main/resources/schema.sql(文件末尾追加)
[ ] 步骤 1:在 DatabaseInitializer 末尾添加迁移 229
在 // 迁移228: file_record 添加 file_type 和 description 列 之后、runMigrations() 的闭合 } 之前添加:
// 迁移229: 创建 health_status 表(当前状态调研-健康现状档案,SS-2026-08-13)
try {
jdbcTemplate.execute(
"CREATE TABLE IF NOT EXISTS health_status (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"user_id BIGINT NOT NULL COMMENT '所属账号(一个账号一份)', " +
"height_cm DECIMAL(5,1) DEFAULT NULL COMMENT '身高(cm)', " +
"weight_kg DECIMAL(5,1) DEFAULT NULL COMMENT '体重(kg)', " +
"blood_pressure VARCHAR(20) DEFAULT NULL COMMENT '血压,如 120/80', " +
"blood_glucose DECIMAL(4,1) DEFAULT NULL COMMENT '空腹血糖(mmol/L)', " +
"blood_lipids JSON DEFAULT NULL COMMENT '血脂 [{name,value,unit,note}]', " +
"disease_history JSON DEFAULT NULL COMMENT '疾病史 [{name,note}]', " +
"medications JSON DEFAULT NULL COMMENT '在服药物/治疗 [{name,note}]', " +
"notes VARCHAR(500) DEFAULT NULL COMMENT '其他补充说明', " +
"filled_at DATETIME DEFAULT NULL COMMENT '首次填写时间', " +
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
"UNIQUE KEY uk_user (user_id)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当前状态调研-健康现状档案'"
);
log.info("已创建health_status表");
} catch (Exception e) {
log.warn("创建health_status表失败: " + e.getMessage());
}
在 cfc-backend/src/main/resources/schema.sql 末尾追加:
-- 当前状态调研-健康现状档案(迁移229)
CREATE TABLE IF NOT EXISTS health_status (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL COMMENT '所属账号(一个账号一份)',
height_cm DECIMAL(5,1) DEFAULT NULL COMMENT '身高(cm)',
weight_kg DECIMAL(5,1) DEFAULT NULL COMMENT '体重(kg)',
blood_pressure VARCHAR(20) DEFAULT NULL COMMENT '血压,如 120/80',
blood_glucose DECIMAL(4,1) DEFAULT NULL COMMENT '空腹血糖(mmol/L)',
blood_lipids JSON DEFAULT NULL COMMENT '血脂 [{name,value,unit,note}]',
disease_history JSON DEFAULT NULL COMMENT '疾病史 [{name,note}]',
medications JSON DEFAULT NULL COMMENT '在服药物/治疗 [{name,note}]',
notes VARCHAR(500) DEFAULT NULL COMMENT '其他补充说明',
filled_at DATETIME DEFAULT NULL COMMENT '首次填写时间',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE KEY uk_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='当前状态调研-健康现状档案';
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS(迁移是幂等 try-catch,无需运行时验证)
[ ] 步骤 4:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
git commit -m "feat(db): health_status 表迁移229 + schema 同步"
文件:
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthStatus.java创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthStatusMapper.java
[ ] 步骤 1:创建 HealthStatus 实体
package com.etotem.cfc.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
@Data
@TableName("health_status")
public class HealthStatus implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
/** 所属账号(一个账号一份) */
private Long userId;
/** 身高(cm) */
private BigDecimal heightCm;
/** 体重(kg) */
private BigDecimal weightKg;
/** 血压,如 120/80 */
private String bloodPressure;
/** 空腹血糖(mmol/L) */
private BigDecimal bloodGlucose;
/** 血脂 JSON: [{name,value,unit,note}] */
private String bloodLipids;
/** 疾病史 JSON: [{name,note}] */
private String diseaseHistory;
/** 在服药物/治疗 JSON: [{name,note}] */
private String medications;
/** 其他补充说明 */
private String notes;
/** 首次填写时间 */
private Date filledAt;
/** 更新时间 */
private Date updatedAt;
}
[ ] 步骤 2:创建 Mapper
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.HealthStatus;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface HealthStatusMapper extends BaseMapper<HealthStatus> {
}
[ ] 步骤 3:验证编译
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[ ] 步骤 4:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/entity/HealthStatus.java cfc-backend/src/main/java/com/etotem/cfc/mapper/HealthStatusMapper.java
git commit -m "feat(health-status): 新增 HealthStatus 实体与 Mapper"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/HealthStatusService.java创建:cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthStatusServiceImpl.java
[ ] 步骤 1:创建 Service 接口
package com.etotem.cfc.service;
import com.etotem.cfc.entity.HealthStatus;
public interface HealthStatusService {
/** 查询指定账号的健康现状,未填写返回 null */
HealthStatus getByUserId(Long userId);
/** 保存/更新(upsert:有则更新,无则插入;filledAt 仅首次赋值) */
HealthStatus save(Long userId, HealthStatus status);
}
[ ] 步骤 2:创建实现类
package com.etotem.cfc.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.HealthStatus;
import com.etotem.cfc.mapper.HealthStatusMapper;
import com.etotem.cfc.service.HealthStatusService;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Date;
@Service
public class HealthStatusServiceImpl implements HealthStatusService {
@Resource
private HealthStatusMapper healthStatusMapper;
@Override
public HealthStatus getByUserId(Long userId) {
if (userId == null) return null;
return healthStatusMapper.selectOne(
new LambdaQueryWrapper<HealthStatus>()
.eq(HealthStatus::getUserId, userId));
}
@Override
public HealthStatus save(Long userId, HealthStatus status) {
if (userId == null) throw new IllegalArgumentException("userId不能为空");
if (status == null) throw new IllegalArgumentException("健康现状不能为空");
HealthStatus existing = getByUserId(userId);
status.setUserId(userId);
if (existing == null) {
status.setId(null);
status.setFilledAt(new Date());
status.setUpdatedAt(new Date());
healthStatusMapper.insert(status);
} else {
status.setId(existing.getId());
status.setFilledAt(existing.getFilledAt());
status.setUpdatedAt(new Date());
healthStatusMapper.updateById(status);
}
return getByUserId(userId);
}
}
[ ] 步骤 3:验证编译
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[ ] 步骤 4:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/HealthStatusService.java cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthStatusServiceImpl.java
git commit -m "feat(health-status): HealthStatusService upsert 实现"
文件:
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java检查路由冲突:现有 Controller 无
/api/health-status前缀(可用grep -rn 'health-status' cfc-backend/src/main/java/com/etotem/cfc/controller/确认)。类名HealthStatusController无 Bean 冲突(全库唯一)。
[ ] 步骤 1:创建 Controller
package com.etotem.cfc.controller;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.HealthStatus;
import com.etotem.cfc.service.HealthStatusService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.math.BigDecimal;
@Tag(name = "健康现状档案", description = "当前状态调研-健康现状档案接口")
@RestController
@RequestMapping("/api/health-status")
public class HealthStatusController {
@Resource
private HealthStatusService healthStatusService;
@Operation(summary = "查询当前账号健康现状(未填写返回 null)")
@PostMapping("/get")
public Result<HealthStatus> get(@RequestAttribute("userId") Long userId) {
return Result.success(healthStatusService.getByUserId(userId));
}
@Operation(summary = "保存/更新当前账号健康现状(upsert)")
@PostMapping("/save")
public Result<HealthStatus> save(
@RequestAttribute("userId") Long userId,
@RequestBody HealthStatus status) {
if (status == null || isEmpty(status)) {
return Result.error("请至少填写一项");
}
String validateMsg = validate(status);
if (validateMsg != null) {
return Result.error(validateMsg);
}
return Result.success(healthStatusService.save(userId, status));
}
/** 至少填写一项才算有效 */
private boolean isEmpty(HealthStatus s) {
return s.getHeightCm() == null && s.getWeightKg() == null
&& s.getBloodPressure() == null && s.getBloodGlucose() == null
&& s.getBloodLipids() == null && s.getDiseaseHistory() == null
&& s.getMedications() == null && s.getNotes() == null;
}
/** 数值校验,返回错误信息或 null */
private String validate(HealthStatus s) {
if (s.getHeightCm() != null && (s.getHeightCm().compareTo(BigDecimal.valueOf(30)) < 0
|| s.getHeightCm().compareTo(BigDecimal.valueOf(250)) > 0)) {
return "身高需在 30-250 cm 之间";
}
if (s.getWeightKg() != null && (s.getWeightKg().compareTo(BigDecimal.valueOf(3)) < 0
|| s.getWeightKg().compareTo(BigDecimal.valueOf(300)) > 0)) {
return "体重需在 3-300 kg 之间";
}
if (s.getBloodPressure() != null && !s.getBloodPressure().matches("\\d{2,3}/\\d{2,3}")) {
return "血压格式应为 120/80";
}
if (s.getBloodGlucose() != null && (s.getBloodGlucose().compareTo(BigDecimal.ONE) < 0
|| s.getBloodGlucose().compareTo(BigDecimal.valueOf(30)) > 0)) {
return "血糖需在 1-30 mmol/L 之间";
}
return null;
}
}
[ ] 步骤 2:检查路由冲突
运行:grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep health-status
预期:仅输出 /api/health-status/get 与 /api/health-status/save
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[ ] 步骤 4:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/controller/HealthStatusController.java
git commit -m "feat(health-status): HealthStatusController get/save 接口"
文件:
cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java当前
sendMessage已用@RequestBody Map<String, String> params解析query/conversationId/reportId/surveyId,在inputs = familyContextService.buildContext(...)之后、aiService.enrichInputsWithMemory(...)之前插入解析逻辑。ObjectMapper用新建实例(与AIService中private final ObjectMapper objectMapper = new ObjectMapper();同模式)。
在类头部现有 import 之后添加:
import com.fasterxml.jackson.databind.ObjectMapper;
在类内字段区(private TaskParseService taskParseService; 之后)添加:
private final ObjectMapper objectMapper = new ObjectMapper();
在 Map<String, Object> inputs; 与 if (reportId != null && surveyId != null) {...} 组装块之后、// 注入mascot信息到Dify inputs 之前,插入:
// 注入当前状态调研数据(健康现状 + 饮食偏好)
String healthStatusStr = params.get("healthStatus");
String dietPrefsStr = params.get("dietPrefs");
if (healthStatusStr != null && !healthStatusStr.trim().isEmpty()) {
try {
inputs.put("health_status", objectMapper.readValue(healthStatusStr, Map.class));
} catch (Exception e) {
log.warn("healthStatus解析失败: {}", e.getMessage());
}
}
if (dietPrefsStr != null && !dietPrefsStr.trim().isEmpty()) {
try {
inputs.put("diet_preferences", objectMapper.readValue(dietPrefsStr, Map.class));
} catch (Exception e) {
log.warn("dietPrefs解析失败: {}", e.getMessage());
}
}
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[ ] 步骤 4:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
git commit -m "feat(ai): AIChatController 注入 health_status/diet_preferences 到 Dify inputs"
文件:
修改:cfc-frontend/utils/api.js
[ ] 步骤 1:新增健康现状 API
在 getDietPreferences(约 2614 行)附近添加:
export const getHealthStatus = () => request('/api/health-status/get', 'POST', {})
export const saveHealthStatus = (data) => request('/api/health-status/save', 'POST', data)
当前代码(约 1317-1345 行):
export const aiSendMessage = (data) => {
var query = data && data.query ? data.query : ''
var conversationId = data && data.conversationId ? data.conversationId : ''
var userId = uni.getStorageSync('userId') || ''
var langgraphBody = {
query: query,
user: userId,
user_id: userId,
conversation_id: conversationId,
response_mode: 'blocking',
bot_name: 'AI管家'
}
if (data && data.reportId) {
langgraphBody.inputs = { report_id: data.reportId }
}
return langgraphRequest({
langgraphPath: '/api/v1/chat/completion',
langgraphBody: langgraphBody,
difyFallbackPath: '/api/ai/chat/send',
difyFallbackData: data,
transform: ...
})
}
将 if (data && data.reportId) { langgraphBody.inputs = { report_id: data.reportId } } 替换为:
var inputs = {}
if (data && data.reportId) {
inputs.report_id = data.reportId
}
if (data && data.healthStatus) {
inputs.health_status = data.healthStatus
}
if (data && data.dietPrefs) {
inputs.diet_preferences = data.dietPrefs
}
if (Object.keys(inputs).length > 0) {
langgraphBody.inputs = inputs
}
difyFallbackData: data已透传整个 data 对象,healthStatus/dietPrefs会随 POST body 到达/api/ai/chat/send。注意: 前端在 Task 8 中组装sendData时必须用JSON.stringify()把对象转成 JSON 字符串(后端Map<String,String>接收,再readValue解析回对象),否则 Jackson 无法把 JSON 对象反序列化进 String 字段会报错。
运行:cd cfc-frontend && node --check utils/api.js
预期:无输出(exit code 0)。若 node 版本对 ESM/import 报错(--check 在 CommonJS 下会报 Cannot use import statement),则用 npx babel-node 或跳过该文件级校验,改为人工核对修改段括号/逗号闭合。
[ ] 步骤 4:Commit
git add cfc-frontend/utils/api.js
git commit -m "feat(frontend): api.js 新增 health-status API 并透传 healthStatus/dietPrefs"
文件:
cfc-frontend/pages/health/health-status-form.vuecfc-frontend/pages.json(health 分包,health-plan-summary 之后注册)修改:cfc-frontend/pages/health-main/index.vue(「智能健康方案」feat-card 旁加「健康档案」card)
[ ] 步骤 1:创建档案页
遵循小程序限制:禁止可选链
?.(用&&)、禁止:key表达式(用:key="idx"索引或方法)、禁止new Date(string)。Vue 2 Options API。
<template>
<view class="hs-page">
<view class="nav-bar">
<text class="nav-back" @click="goBack">\ue601 返回</text>
<text class="nav-title">健康档案</text>
<view class="nav-placeholder"></view>
</view>
<view class="hs-tip">
<text class="hs-tip-text">填写当前健康现状,生成方案时 AI 将参考这些信息,方案更精准。可不填,但未填时方案可能不够准确。</text>
</view>
<view class="form-section">
<view class="form-title">基础体征</view>
<view class="form-row">
<text class="form-label">身高 (cm)</text>
<input class="form-input" type="digit" v-model="form.heightCm" placeholder="如 170" />
</view>
<view class="form-row">
<text class="form-label">体重 (kg)</text>
<input class="form-input" type="digit" v-model="form.weightKg" placeholder="如 60" />
</view>
<view class="form-row">
<text class="form-label">血压</text>
<input class="form-input" type="text" v-model="form.bloodPressure" placeholder="如 120/80" />
</view>
<view class="form-row">
<text class="form-label">空腹血糖 (mmol/L)</text>
<input class="form-input" type="digit" v-model="form.bloodGlucose" placeholder="如 5.6" />
</view>
</view>
<view class="form-section">
<view class="form-title">血脂</view>
<view v-for="(item, idx) in lipidRows" :key="idx" class="lipid-row">
<input class="lipid-name" type="text" v-model="item.name" placeholder="指标名" />
<input class="lipid-value" type="digit" v-model="item.value" placeholder="数值" />
<input class="lipid-unit" type="text" v-model="item.unit" placeholder="单位" />
<text class="lipid-del" @click="removeLipid(idx)">删除</text>
</view>
<text class="add-link" @click="addLipid">+ 添加血脂指标</text>
</view>
<view class="form-section">
<view class="form-title">疾病史</view>
<view class="tag-wrap">
<view
v-for="(tag, idx) in diseaseTags"
:key="idx"
class="tag-item"
:class="{ 'tag-active': form.diseaseNames.indexOf(tag) >= 0 }"
@click="toggleDisease(tag)"
>
<text>{{ tag }}</text>
</view>
<view v-for="(custom, idx) in customDiseases" :key="'c' + idx" class="tag-item tag-active">
<text>{{ custom }}</text>
<text class="tag-del" @click="removeCustomDisease(idx)">×</text>
</view>
</view>
<view class="custom-row">
<input class="custom-input" type="text" v-model="diseaseInput" placeholder="输入其他疾病" />
<text class="add-link" @click="addCustomDisease">添加</text>
</view>
</view>
<view class="form-section">
<view class="form-title">在服药物/治疗</view>
<view v-for="(med, idx) in medRows" :key="idx" class="med-row">
<input class="med-name" type="text" v-model="med.name" placeholder="药物/治疗名称" />
<input class="med-note" type="text" v-model="med.note" placeholder="备注(可选)" />
<text class="lipid-del" @click="removeMed(idx)">删除</text>
</view>
<text class="add-link" @click="addMed">+ 添加药物/治疗</text>
</view>
<view class="form-section">
<view class="form-title">其他补充</view>
<textarea class="notes-area" v-model="form.notes" placeholder="其他想补充的健康信息(选填)" :maxlength="500" />
</view>
<view class="save-bar">
<button class="save-btn" :disabled="saving" @click="save">{{ saving ? '保存中...' : '保存' }}</button>
</view>
</view>
</template>
<script>
import { getHealthStatus, saveHealthStatus } from '../../utils/api'
var DISEASE_PRESETS = ['高血压', '糖尿病', '高血脂', '过敏性鼻炎', '哮喘', '贫血', '胃炎', '甲状腺疾病', '心脏疾病', '肾脏疾病']
export default {
data() {
return {
form: {
heightCm: '',
weightKg: '',
bloodPressure: '',
bloodGlucose: '',
bloodLipids: '',
diseaseHistory: '',
medications: '',
notes: ''
},
lipidRows: [],
diseaseTags: DISEASE_PRESETS.slice(),
diseaseNames: [],
customDiseases: [],
diseaseInput: '',
medRows: [],
saving: false
}
},
onLoad: function() {
this.load()
},
methods: {
load: function() {
var self = this
getHealthStatus().then(function(res) {
if (res && res.code === 200 && res.data) {
var d = res.data
self.form.heightCm = d.heightCm || ''
self.form.weightKg = d.weightKg || ''
self.form.bloodPressure = d.bloodPressure || ''
self.form.bloodGlucose = d.bloodGlucose || ''
self.form.notes = d.notes || ''
self.lipidRows = self.parseLipids(d.bloodLipids)
self.loadDiseases(d.diseaseHistory)
self.medRows = self.parseMeds(d.medications)
}
}).catch(function() {})
},
parseLipids: function(json) {
var list = []
try {
var arr = JSON.parse(json || '[]')
if (arr instanceof Array) {
arr.forEach(function(item) {
list.push({ name: item.name || '', value: item.value || '', unit: item.unit || '', note: item.note || '' })
})
}
} catch (e) {}
if (list.length === 0) list.push({ name: '', value: '', unit: '', note: '' })
return list
},
loadDiseases: function(json) {
var self = this
try {
var arr = JSON.parse(json || '[]')
if (arr instanceof Array) {
arr.forEach(function(item) {
if (item.name && self.diseaseNames.indexOf(item.name) < 0) {
self.diseaseNames.push(item.name)
}
})
}
} catch (e) {}
},
parseMeds: function(json) {
var list = []
try {
var arr = JSON.parse(json || '[]')
if (arr instanceof Array) {
arr.forEach(function(item) {
list.push({ name: item.name || '', note: item.note || '' })
})
}
} catch (e) {}
if (list.length === 0) list.push({ name: '', note: '' })
return list
},
addLipid: function() {
this.lipidRows.push({ name: '', value: '', unit: '', note: '' })
},
removeLipid: function(idx) {
if (this.lipidRows.length > 1) this.lipidRows.splice(idx, 1)
},
toggleDisease: function(tag) {
var idx = this.diseaseNames.indexOf(tag)
if (idx >= 0) this.diseaseNames.splice(idx, 1)
else this.diseaseNames.push(tag)
},
addCustomDisease: function() {
var name = (this.diseaseInput || '').trim()
if (!name) return
if (this.diseaseNames.indexOf(name) < 0) {
this.diseaseNames.push(name)
this.customDiseases.push(name)
}
this.diseaseInput = ''
},
removeCustomDisease: function(idx) {
var name = this.customDiseases[idx]
this.customDiseases.splice(idx, 1)
var i = this.diseaseNames.indexOf(name)
if (i >= 0) this.diseaseNames.splice(i, 1)
},
addMed: function() {
this.medRows.push({ name: '', note: '' })
},
removeMed: function(idx) {
if (this.medRows.length > 1) this.medRows.splice(idx, 1)
},
buildPayload: function() {
var lipids = []
this.lipidRows.forEach(function(item) {
if (item.name && item.value) {
lipids.push({ name: item.name, value: parseFloat(item.value), unit: item.unit || '', note: item.note || '' })
}
})
var diseases = []
this.diseaseNames.forEach(function(name) {
diseases.push({ name: name, note: '' })
})
var meds = []
this.medRows.forEach(function(item) {
if (item.name) {
meds.push({ name: item.name, note: item.note || '' })
}
})
var payload = {}
if (this.form.heightCm) payload.heightCm = Number(this.form.heightCm)
if (this.form.weightKg) payload.weightKg = Number(this.form.weightKg)
if (this.form.bloodPressure) payload.bloodPressure = this.form.bloodPressure
if (this.form.bloodGlucose) payload.bloodGlucose = Number(this.form.bloodGlucose)
if (lipids.length > 0) payload.bloodLipids = JSON.stringify(lipids)
if (diseases.length > 0) payload.diseaseHistory = JSON.stringify(diseases)
if (meds.length > 0) payload.medications = JSON.stringify(meds)
if (this.form.notes) payload.notes = this.form.notes
return payload
},
save: function() {
var self = this
var payload = this.buildPayload()
if (Object.keys(payload).length === 0) {
uni.showToast({ title: '请至少填写一项', icon: 'none' })
return
}
if (this.form.bloodPressure && !/^\d{2,3}\/\d{2,3}$/.test(this.form.bloodPressure)) {
uni.showToast({ title: '血压格式应为 120/80', icon: 'none' })
return
}
self.saving = true
saveHealthStatus(payload).then(function(res) {
self.saving = false
if (res && res.code === 200) {
uni.showToast({ title: '已保存', icon: 'success' })
setTimeout(function() { uni.navigateBack() }, 800)
} else {
uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
}
}).catch(function() {
self.saving = false
uni.showToast({ title: '保存失败,请检查网络', icon: 'none' })
})
},
goBack: function() {
uni.navigateBack()
}
}
}
</script>
<style scoped>
.hs-page { min-height: 100vh; background: #F5F7FA; padding-bottom: 140rpx; }
.nav-bar { display: flex; align-items: center; justify-content: space-between; height: 88rpx; padding: 0 30rpx; background: #fff; border-bottom: 1rpx solid #eee; position: sticky; top: 0; z-index: 10; }
.nav-back { font-size: 32rpx; color: #F97316; }
.nav-title { font-size: 34rpx; font-weight: bold; color: #333; }
.nav-placeholder { width: 80rpx; }
.hs-tip { margin: 24rpx 30rpx; padding: 20rpx 24rpx; background: #FFF7E6; border-radius: 12rpx; }
.hs-tip-text { font-size: 26rpx; color: #8A6D3B; line-height: 1.6; }
.form-section { margin: 0 30rpx 24rpx; background: #fff; border-radius: 16rpx; padding: 24rpx; }
.form-title { font-size: 30rpx; font-weight: bold; color: #333; margin-bottom: 20rpx; }
.form-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20rpx; }
.form-label { font-size: 28rpx; color: #555; }
.form-input { flex: 1; margin-left: 30rpx; height: 68rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 20rpx; font-size: 28rpx; text-align: right; }
.lipid-row { display: flex; align-items: center; margin-bottom: 16rpx; }
.lipid-name { width: 30%; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
.lipid-value { width: 25%; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
.lipid-unit { width: 22%; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
.lipid-del { font-size: 26rpx; color: #E64340; }
.tag-wrap { display: flex; flex-wrap: wrap; }
.tag-item { padding: 12rpx 24rpx; background: #F5F7FA; border-radius: 30rpx; font-size: 26rpx; color: #666; margin: 0 16rpx 16rpx 0; }
.tag-active { background: #F97316; color: #fff; }
.tag-del { margin-left: 8rpx; font-size: 24rpx; }
.custom-row { display: flex; align-items: center; margin-top: 8rpx; }
.custom-input { flex: 1; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 20rpx; font-size: 26rpx; margin-right: 20rpx; }
.add-link { font-size: 28rpx; color: #F97316; }
.med-row { display: flex; align-items: center; margin-bottom: 16rpx; }
.med-name { flex: 1; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
.med-note { flex: 1; height: 64rpx; background: #F5F7FA; border-radius: 10rpx; padding: 0 16rpx; font-size: 26rpx; margin-right: 12rpx; }
.notes-area { width: 100%; height: 160rpx; background: #F5F7FA; border-radius: 10rpx; padding: 16rpx 20rpx; font-size: 26rpx; box-sizing: border-box; }
.save-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); }
.save-btn { width: 100%; background: #F97316; color: #fff; border-radius: 44rpx; height: 84rpx; line-height: 84rpx; font-size: 32rpx; }
.save-btn[disabled] { opacity: 0.6; }
</style>
在 pages.json 的 "root": "pages/health" 分包中,health-plan-summary 条目之后添加:
{
"path": "health-status-form",
"style": {
"navigationBarTitleText": "健康档案"
}
}
在 pages/health-main/index.vue 中「智能健康方案」feat-card(@click="goPlanSummary")之后添加:
<view class="feat-card" @click="goPage('/pages/health/health-status-form')">
<text class="feat-icon">📋</text>
<text class="feat-title">健康档案</text>
<text class="feat-desc">身高体重·血压血糖·疾病史</text>
</view>
运行(提取 <script> 块写入临时文件再 node --check,避免 ESM import 报错):
cd cfc-frontend
sed -n '/<script>/,/<\/script>/p' pages/health/health-status-form.vue | sed '1d;$d' > /tmp/hs-form-script.js
node --check /tmp/hs-form-script.js && rm -f /tmp/hs-form-script.js
预期:无输出(exit code 0)
[ ] 步骤 5:Commit
git add cfc-frontend/pages/health/health-status-form.vue cfc-frontend/pages.json cfc-frontend/pages/health-main/index.vue
git commit -m "feat(frontend): 健康档案页 + 注册 + 健康主页入口"
文件:
cfc-frontend/pages/health/health-plan-summary.vue当前结构:
onLoad(187-195 行)→generatePlan(246-272 行)→aiSendMessage({ query, reportId })。改造点:onLoad 读取健康现状+饮食偏好、generatePlan 前弹窗提醒、传参。
将 import { getFamilyReports, getFamilyMembers, aiSendMessage, createTask } from '../../utils/api' 改为:
import { getFamilyReports, getFamilyMembers, getHealthStatus, getDietPreferences, aiSendMessage, createTask } from '../../utils/api'
import { parseDate } from '../../utils/format'
parseDate为项目既有工具(utils/format.js),用于解析updatedAt(MySQL DATETIME 风格"2026-08-13 10:00:00"),禁止直接new Date(string)。
在 data 的 taskSubmittedCount: 0 后添加:
healthStatus: null,
dietPrefs: null
在 onLoad 中 this.loadFamilyReports() / this.loadExecutors() 之后添加:
this.loadHealthContext()
并新增方法(loadHealthContext 供 onLoad/onShow 复用):
loadHealthContext: function() {
var self = this
getHealthStatus().then(function(res) {
if (res && res.code === 200) {
self.healthStatus = res.data || null
}
}).catch(function() { self.healthStatus = null })
getDietPreferences().then(function(res) {
if (res && res.code === 200) {
self.dietPrefs = res.data || null
}
}).catch(function() { self.dietPrefs = null })
},
在 onLoad 之后(同生命周期区)添加 onShow,保证从档案页「去填写/去更新」返回后数据是最新的:
onShow: function() {
this.loadHealthContext()
},
注意:
onShow会在首次进入时先于/伴随onLoad触发,loadHealthContext幂等(重复请求无害),无需去重。
将 generatePlan 方法开头改为(未填时弹窗,去填写/直接生成):
generatePlan: function() {
var self = this
if (!self.canGenerate) return
if (!self.healthStatus) {
uni.showModal({
title: '填写健康现状',
content: '尚未填写健康现状,方案可能不够精准。是否先填写?',
confirmText: '去填写',
cancelText: '直接生成',
success: function(res) {
if (res.confirm) {
uni.navigateTo({ url: '/pages/health/health-status-form' })
} else {
self.doGeneratePlan()
}
}
})
return
}
if (self.isStatusStale()) {
uni.showModal({
title: '健康现状已较旧',
content: '上次填写时间较早,建议先更新健康现状以获得更精准方案。是否更新?',
confirmText: '去更新',
cancelText: '直接生成',
success: function(res) {
if (res.confirm) {
uni.navigateTo({ url: '/pages/health/health-status-form' })
} else {
self.doGeneratePlan()
}
}
})
return
}
self.doGeneratePlan()
},
isStatusStale: function() {
var updatedAt = this.healthStatus && this.healthStatus.updatedAt
if (!updatedAt) return false
var d = parseDate(updatedAt)
if (!d) return false
var sixMonthsAgo = new Date()
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6)
return d.getTime() < sixMonthsAgo.getTime()
},
doGeneratePlan: function() {
var self = this
self.loading = true
var reportIdsStr = self.selectedReportIds.join(',')
var sendData = { query: self.userGoal.trim(), reportId: reportIdsStr }
if (self.healthStatus) sendData.healthStatus = JSON.stringify(self.healthStatus)
if (self.dietPrefs) sendData.dietPrefs = JSON.stringify(self.dietPrefs)
aiSendMessage(sendData).then(function(res) {
self.loading = false
var answer = ''
if (res && res.answer) {
answer = res.answer
} else if (res && res.data && res.data.answer) {
answer = res.data.answer
} else if (res && typeof res === 'string') {
answer = res
}
if (answer) {
self.planContent = answer
self.parseTasksFromPlan(answer)
self.step = 3
} else {
uni.showToast({ title: '生成失败,请重试', icon: 'none' })
}
}).catch(function() {
self.loading = false
uni.showToast({ title: '请求失败,请检查网络', icon: 'none' })
})
},
原
generatePlan的 AI 调用逻辑整体移入doGeneratePlan,仅新增sendData组装与弹窗分支。getDietPreferences后端按@RequestAttribute("familyMemberId")返回当前选中成员的偏好(沿用现有接口语义)。
运行(提取 <script> 块写入临时文件再 node --check,避免 ESM import 报错):
cd cfc-frontend
sed -n '/<script>/,/<\/script>/p' pages/health/health-plan-summary.vue | sed '1d;$d' > /tmp/hs-plan-script.js
node --check /tmp/hs-plan-script.js && rm -f /tmp/hs-plan-script.js
预期:无输出(exit code 0)
[ ] 步骤 5:Commit
git add cfc-frontend/pages/health/health-plan-summary.vue
git commit -m "feat(frontend): 出方案流程读取健康现状+未填弹窗提醒+随请求传参"
文件: 无(验证任务)
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
运行:grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep -E 'health-status|health_status'
预期:仅 /api/health-status/get、/api/health-status/save
运行:对修改的 3 个 .vue 文件提取 <script> 块执行 node --check
预期:全部无语法错误
[ ] 步骤 5:最终 Commit(若有未提交改动)
git status --short
git add <遗漏文件>
git commit -m "chore: 健康现状档案实现收尾"