Răsfoiți Sursa

Merge remote-tracking branch 'origin/cfclub' into cfclub

E2E Test Bot 2 luni în urmă
părinte
comite
f0d8eca7a6

+ 4 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -1143,10 +1143,11 @@ public class HealthReportController {
 
 
             // 落库通用展示块(blocks 组装失败不阻断确认)
             // 落库通用展示块(blocks 组装失败不阻断确认)
             try {
             try {
-                reportBlockService.save("gut_flora", created.getId(),
-                        reportBlockAssembler.assembleGutFlora(payload));
+                String reportType = created.getReportType();
+                reportBlockService.save(reportType, created.getId(),
+                        reportBlockAssembler.assembleWithTemplate(reportType, payload));
             } catch (Exception e) {
             } catch (Exception e) {
-                log.warn("保存gut_flora展示块失败 reportId={}: {}", created.getId(), e.getMessage());
+                log.warn("保存展示块失败 reportId={}: {}", created.getId(), e.getMessage());
             }
             }
 
 
             Map<String, Object> detail = healthReportService.getReportDetail(created.getId());
             Map<String, Object> detail = healthReportService.getReportDetail(created.getId());

+ 3 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -1651,8 +1651,9 @@ public class HealthReportService {
         // 7. 编辑后重新组装展示块(第一套键 Map → 标准 Payload → blocks)
         // 7. 编辑后重新组装展示块(第一套键 Map → 标准 Payload → blocks)
         try {
         try {
             ParsedReportPayload.Payload editedPayload = reportBlockAssembler.fromMap(payload);
             ParsedReportPayload.Payload editedPayload = reportBlockAssembler.fromMap(payload);
-            reportBlockService.save("gut_flora", reportId,
-                    reportBlockAssembler.assembleGutFlora(editedPayload));
+            String reportType = oldReport.getReportType();
+            reportBlockService.save(reportType, reportId,
+                    reportBlockAssembler.assembleWithTemplate(reportType, editedPayload));
         } catch (Exception e) {
         } catch (Exception e) {
             log.warn("编辑后刷新展示块失败 reportId={}: {}", reportId, e.getMessage());
             log.warn("编辑后刷新展示块失败 reportId={}: {}", reportId, e.getMessage());
         }
         }

+ 4 - 5
cfc-backend/src/main/java/com/etotem/cfc/service/ReportBlockAssembler.java

@@ -35,17 +35,16 @@ public class ReportBlockAssembler {
      * 根据报告类型+模板配置组装 blocks。
      * 根据报告类型+模板配置组装 blocks。
      * 若该 reportType 有启用模板,优先使用模板 fixedBlocks;
      * 若该 reportType 有启用模板,优先使用模板 fixedBlocks;
      * freeDisplay 开启时,将未覆盖的指标追加到末尾。
      * freeDisplay 开启时,将未覆盖的指标追加到末尾。
+     * 无模板/模板未启用:默认调用 assembleGutFlora 做通用兜底(评分+风险+指标+菌种),
+     * 保证任何报告类型都不返回空列表,前端可依赖 blocks 展示而非旧字段回退。
      */
      */
     public List<Map<String, Object>> assembleWithTemplate(String reportType, ParsedReportPayload.Payload payload) {
     public List<Map<String, Object>> assembleWithTemplate(String reportType, ParsedReportPayload.Payload payload) {
         if (payload == null) return new ArrayList<>();
         if (payload == null) return new ArrayList<>();
 
 
         ReportTemplate template = reportTemplateService.getByReportType(reportType);
         ReportTemplate template = reportTemplateService.getByReportType(reportType);
         if (template == null || template.getIsActive() == null || template.getIsActive() != 1) {
         if (template == null || template.getIsActive() == null || template.getIsActive() != 1) {
-            // 无模板或已停用 → 使用默认逻辑
-            if ("gut_flora".equals(reportType)) {
-                return assembleGutFlora(payload);
-            }
-            return new ArrayList<>();
+            // 无模板或已停用 → 通用默认组装(复用 gut_flora 逻辑,标题已通用)
+            return assembleGutFlora(payload);
         }
         }
 
 
         return parseAndAssemble(template.getConfig(), reportType, payload);
         return parseAndAssemble(template.getConfig(), reportType, payload);

+ 6 - 6
cfc-frontend/pages/health-main/index.vue

@@ -505,13 +505,13 @@ export default {
     goReportDetail: function(report) {
     goReportDetail: function(report) {
       if (!report || !report.id) return
       if (!report || !report.id) return
       var type = report.reportType || 'gut_flora'
       var type = report.reportType || 'gut_flora'
+      var url
       if (type === 'dan') {
       if (type === 'dan') {
-        uni.navigateTo({ url: '/pages/dan-assessment/report-result?reportId=' + report.id })
-        return
-      }
-      var url = '/pages/health/report-detail?reportType=' + type + '&reportId=' + report.id
-      if (type === 'gut_flora') {
-        url += '&mode=edit'
+        url = '/pages/dan-assessment/report-result?reportId=' + report.id
+      } else if (type === 'gut_flora') {
+        url = '/pages/health/gut-flora-detail?reportId=' + report.id
+      } else {
+        url = '/pages/health/report-detail?reportType=' + type + '&reportId=' + report.id
       }
       }
       uni.navigateTo({ url: url })
       uni.navigateTo({ url: url })
     },
     },

+ 180 - 18
cfc-frontend/pages/health/gut-flora-detail.vue

@@ -213,18 +213,32 @@
 
 
     <feedback-popup :visible="feedbackVisible" :report-id="reportId" @close="closeFeedback" />
     <feedback-popup :visible="feedbackVisible" :report-id="reportId" @close="closeFeedback" />
 
 
-    <view class="fab" v-if="!isEditing" @tap="toggleEdit">
+    <view class="fab" v-if="!isEditing && !isDraftMode" @tap="toggleEdit">
       <text class="fab-icon">✏️</text>
       <text class="fab-icon">✏️</text>
     </view>
     </view>
     <view class="bottom-bar" v-if="isEditing">
     <view class="bottom-bar" v-if="isEditing">
       <view class="btn-cancel" @tap="cancelEdit">取消</view>
       <view class="btn-cancel" @tap="cancelEdit">取消</view>
       <view class="btn-save" @tap="saveEdit">保存</view>
       <view class="btn-save" @tap="saveEdit">保存</view>
     </view>
     </view>
+    <view class="draft-bar" v-if="isDraftMode && !confirming && !parsing">
+      <view class="btn-discard" @tap="doDiscard">放弃</view>
+      <view class="btn-confirm" @tap="doConfirm">确认入库</view>
+    </view>
+    <view class="draft-bar" v-if="parsing">
+      <view class="btn-parsing">
+        <text class="parsing-text">AI 解析中...</text>
+      </view>
+    </view>
+    <view class="draft-bar" v-if="confirming">
+      <view class="btn-parsing">
+        <text class="parsing-text">确认中...</text>
+      </view>
+    </view>
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
-import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis } from '../../utils/api.js'
+import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis, confirmReportPreview, discardReportDraft, parseReportDraft } from '../../utils/api.js'
 import HealthKnowledgePopup from '../../components/health-knowledge-popup.vue'
 import HealthKnowledgePopup from '../../components/health-knowledge-popup.vue'
 import FeedbackPopup from '../../components/feedback-popup.vue'
 import FeedbackPopup from '../../components/feedback-popup.vue'
 
 
@@ -262,12 +276,22 @@ export default {
       knowledgeData: {},
       knowledgeData: {},
       feedbackVisible: false,
       feedbackVisible: false,
       indStatuses: ['正常', '偏低', '偏高', '缺乏', '不足', '过多', '异常'],
       indStatuses: ['正常', '偏低', '偏高', '缺乏', '不足', '过多', '异常'],
-      analysisData: null
+      analysisData: null,
+      // draft 模式字段
+      draftId: null,
+      isDraftMode: false,
+      payload: null,
+      confirming: false,
+      parsing: false
     }
     }
   },
   },
   onLoad: function(options) {
   onLoad: function(options) {
     this.reportId = options.reportId ? parseInt(options.reportId) : null
     this.reportId = options.reportId ? parseInt(options.reportId) : null
-    if (this.reportId) {
+    this.draftId = options.draftId ? parseInt(options.draftId) : null
+    if (this.draftId) {
+      this.isDraftMode = true
+      this.loadDraftData()
+    } else if (this.reportId) {
       this.loadData()
       this.loadData()
     }
     }
   },
   },
@@ -322,24 +346,109 @@ export default {
         }
         }
       }).catch(function() {})
       }).catch(function() {})
     },
     },
+    loadDraftData: function() {
+      var self = this
+      self.parsing = true
+      parseReportDraft(self.draftId).then(function(res) {
+        self.parsing = false
+        if (res.code !== 200 || !res.data) {
+          uni.showToast({ title: res.message || '加载失败', icon: 'none' })
+          return
+        }
+        self.payload = res.data.payload || {}
+        self.isDraftMode = true
+        self.applyDraftPayload(self.payload)
+      }).catch(function() {
+        self.parsing = false
+        uni.showToast({ title: '加载异常', icon: 'none' })
+      })
+    },
+    applyDraftPayload: function(payload) {
+      // 将 draft payload 映射到模板所需的数据结构(report/scoreItems/indicators)
+      var report = {}
+      if (payload.summary) {
+        var s = payload.summary
+        report.personName = s.personName || ''
+        report.reportNumber = s.reportNumber || ''
+        report.gutAge = s.gutAge || ''
+        report.gutType = s.gutType || ''
+        report.overallScore = s.overallScore
+        report.gutHealthScore = s.gutHealthScore
+        report.chronicDiseaseScore = s.chronicDiseaseScore
+        report.nutritionScore = s.nutritionScore
+        report.balanceScore = s.balanceScore
+        report.diversityScore = s.diversityScore
+        report.beneficialScore = s.beneficialScore
+        report.harmfulScore = s.harmfulScore
+        report.coreGenusScore = s.coreGenusScore
+      }
+      this.report = report
+      this.buildScores({ report: report })
+      this.buildDraftIndicators(payload.indicators || [])
+    },
+    buildDraftIndicators: function(indicators) {
+      // 复用 buildIndicators 的分组逻辑,输入为 payload.indicators
+      var all = []
+      for (var i = 0; i < indicators.length; i++) {
+        var ind = indicators[i]
+        var item = {
+          indicatorName: ind.indicatorName || ind.name || '',
+          indicatorValue: ind.indicatorValue || ind.value || '',
+          status: ind.status || '正常',
+          refRange: ind.refRange || '',
+          category: ind.category || '其他'
+        }
+        item._cssClass = this._cssForStatus(item.status)
+        all.push(item)
+      }
+      this.allIndicators = all
+      this.indicatorCount = all.length
+      this._groupIndicators(all)
+    },
+    _groupIndicators: function(all) {
+      var self = this
+      var gut = []
+      var nutriGroups = []
+      var nutriMap = {}
+      for (var i = 0; i < all.length; i++) {
+        var ind = all[i]
+        var g = ind.category
+        var isGutBarrier = g === '肠道屏障与代谢物' || g === '短链脂肪酸' ||
+          g === '神经递质与激素' || g === '抗生素风险评估'
+        if (isGutBarrier) {
+          gut.push(ind)
+        } else {
+          if (!nutriMap[g]) nutriMap[g] = []
+          nutriMap[g].push(ind)
+        }
+      }
+      this.gutIndicators = gut
+      for (var key in nutriMap) {
+        nutriGroups.push({ category: key, items: nutriMap[key] })
+      }
+      this.nutritionGroups = nutriGroups
+    },
     buildScores: function(data) {
     buildScores: function(data) {
+      // 兼容两套字段名:实体存 balanceScore/diversityScore 等,旧视图读 gutBalanceScore 等
       var r = data.report || data
       var r = data.report || data
+      var s = r.summary || r
       var items = []
       var items = []
       var scoreFields = [
       var scoreFields = [
-        { key: 'overallScore', label: '综合', color: '#4A9BD7' },
-        { key: 'gutHealthScore', label: '菌群健康', color: '#10B981' },
-        { key: 'chronicDiseaseScore', label: '慢病控制', color: '#F59E0B' },
-        { key: 'nutritionScore', label: '营养均衡', color: '#FF8C42' },
-        { key: 'gutBalanceScore', label: '平衡', color: '#6366F1' },
-        { key: 'gutDiversityScore', label: '多样性', color: '#FF6B9D' },
-        { key: 'beneficialBacteriaScore', label: '有益菌', color: '#10B981' },
-        { key: 'harmfulBacteriaScore', label: '有害菌', color: '#C62828' },
-        { key: 'coreSpeciesScore', label: '核心菌属', color: '#4A9BD7' }
+        { key: 'overallScore',         label: '综合',            color: '#4A9BD7' },
+        { key: 'gutHealthScore',       label: '菌群健康',        color: '#10B981' },
+        { key: 'chronicDiseaseScore',  label: '慢病控制',        color: '#F59E0B' },
+        { key: 'nutritionScore',       label: '营养均衡',        color: '#FF8C42' },
+        { key: 'balanceScore',         label: '平衡',            color: '#6366F1' },
+        { key: 'diversityScore',       label: '多样性',          color: '#FF6B9D' },
+        { key: 'beneficialScore',      label: '有益菌',          color: '#10B981' },
+        { key: 'harmfulScore',         label: '有害菌',          color: '#C62828' },
+        { key: 'coreGenusScore',       label: '核心菌属',        color: '#4A9BD7' }
       ]
       ]
       for (var i = 0; i < scoreFields.length; i++) {
       for (var i = 0; i < scoreFields.length; i++) {
         var f = scoreFields[i]
         var f = scoreFields[i]
-        if (r[f.key] != null) {
-          items.push({ value: r[f.key], label: f.label, color: f.color })
+        var val = s[f.key] != null ? s[f.key] : r[f.key]
+        if (val != null) {
+          items.push({ value: val, label: f.label, color: f.color })
         }
         }
       }
       }
       this.scoreItems = items
       this.scoreItems = items
@@ -442,9 +551,17 @@ export default {
     saveEdit: function() {
     saveEdit: function() {
       var self = this
       var self = this
       var payload = {
       var payload = {
-        overallScore: this.report.overallScore,
-        gutHealthScore: this.report.gutHealthScore,
-        nutritionScore: this.report.nutritionScore,
+        summary: {
+          overallScore: this.report.overallScore,
+          gutHealthScore: this.report.gutHealthScore,
+          chronicDiseaseScore: this.report.chronicDiseaseScore,
+          nutritionScore: this.report.nutritionScore,
+          balanceScore: this.report.balanceScore,
+          diversityScore: this.report.diversityScore,
+          beneficialScore: this.report.beneficialScore,
+          harmfulScore: this.report.harmfulScore,
+          coreGenusScore: this.report.coreGenusScore
+        },
         indicators: this.allIndicators
         indicators: this.allIndicators
       }
       }
       editHealthReport(this.reportId, payload, 0).then(function(res) {
       editHealthReport(this.reportId, payload, 0).then(function(res) {
@@ -457,6 +574,45 @@ export default {
         }
         }
       })
       })
     },
     },
+    doConfirm: function() {
+      var self = this
+      if (!self.draftId) return
+      self.confirming = true
+      confirmReportPreview({ draftId: self.draftId, payload: self.payload }).then(function(res) {
+        self.confirming = false
+        if (res.code === 200) {
+          var reportId = res.data && res.data.reportId
+          if (reportId) {
+            uni.redirectTo({ url: '/pages/health/gut-flora-detail?reportId=' + reportId })
+          } else {
+            uni.navigateBack()
+          }
+        } else {
+          uni.showToast({ title: res.message || '确认失败', icon: 'none' })
+        }
+      }).catch(function() {
+        self.confirming = false
+        uni.showToast({ title: '确认异常', icon: 'none' })
+      })
+    },
+    doDiscard: function() {
+      var self = this
+      if (!self.draftId) return
+      uni.showModal({
+        title: '提示',
+        content: '确定放弃该报告草稿吗?',
+        success: function(res) {
+          if (!res.confirm) return
+          discardReportDraft(self.draftId).then(function(res2) {
+            if (res2.code === 200) {
+              uni.navigateBack()
+            } else {
+              uni.showToast({ title: res2.message || '操作失败', icon: 'none' })
+            }
+          })
+        }
+      })
+    },
     goToSpecies: function() {
     goToSpecies: function() {
       uni.navigateTo({ url: '/pages/health/gut-flora-species-detail?reportId=' + this.reportId })
       uni.navigateTo({ url: '/pages/health/gut-flora-species-detail?reportId=' + this.reportId })
     },
     },
@@ -546,6 +702,12 @@ export default {
 .fab { position: fixed; right: 30rpx; bottom: 40rpx; width: 100rpx; height: 100rpx; background: #4A9BD7; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4rpx 16rpx rgba(74,155,215,0.4); }
 .fab { position: fixed; right: 30rpx; bottom: 40rpx; width: 100rpx; height: 100rpx; background: #4A9BD7; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4rpx 16rpx rgba(74,155,215,0.4); }
 .fab-icon { font-size: 40rpx; color: #fff; }
 .fab-icon { font-size: 40rpx; color: #fff; }
 
 
+.draft-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; z-index: 100; }
+.btn-discard { flex: 1; padding: 20rpx; border: 1rpx solid #ddd; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #666; }
+.btn-confirm { flex: 1; padding: 20rpx; background: #4A9BD7; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #fff; }
+.btn-parsing { flex: 1; padding: 20rpx; background: #f0f0f0; border-radius: 12rpx; text-align: center; }
+.parsing-text { font-size: 28rpx; color: #999; }
+
 .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; z-index: 100; }
 .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; padding: 20rpx 30rpx; background: #fff; border-top: 1rpx solid #eee; gap: 20rpx; z-index: 100; }
 .btn-cancel { flex: 1; padding: 20rpx; border: 1rpx solid #ddd; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #666; }
 .btn-cancel { flex: 1; padding: 20rpx; border: 1rpx solid #ddd; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #666; }
 .btn-save { flex: 1; padding: 20rpx; background: #4A9BD7; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #fff; }
 .btn-save { flex: 1; padding: 20rpx; background: #4A9BD7; border-radius: 12rpx; text-align: center; font-size: 28rpx; color: #fff; }

+ 15 - 2
cfc-frontend/pages/health/report-confirm.vue

@@ -501,7 +501,12 @@ export default {
   },
   },
   onLoad: function(options) {
   onLoad: function(options) {
     this.draftId = options.draftId ? parseInt(options.draftId) : null
     this.draftId = options.draftId ? parseInt(options.draftId) : null
-    if (this.draftId) { uni.redirectTo({ url: '/pages/health/report-detail?draftId=' + this.draftId }); return }
+    if (this.draftId) {
+      // gut_flora 走个性化详情页,其他类型走通用详情页
+      var gotoUrl = '/pages/health/gut-flora-detail?draftId=' + this.draftId
+      uni.redirectTo({ url: gotoUrl })
+      return
+    }
     this.memberId = options.memberId ? parseInt(options.memberId) : null
     this.memberId = options.memberId ? parseInt(options.memberId) : null
     this.familyId = options.familyId ? parseInt(options.familyId) : null
     this.familyId = options.familyId ? parseInt(options.familyId) : null
 
 
@@ -827,7 +832,15 @@ export default {
             uni.showToast({ title: '入库成功', icon: 'success' })
             uni.showToast({ title: '入库成功', icon: 'success' })
             setTimeout(function() {
             setTimeout(function() {
               var reportType = data.reportType || ''
               var reportType = data.reportType || ''
-              uni.redirectTo({ url: '/pages/health/report-detail?reportType=' + reportType + '&reportId=' + reportId })
+              var gotoUrl
+              if (reportType === 'gut_flora') {
+                gotoUrl = '/pages/health/gut-flora-detail?reportId=' + reportId
+              } else if (reportType === 'dan') {
+                gotoUrl = '/pages/dan-assessment/report-result?reportId=' + reportId
+              } else {
+                gotoUrl = '/pages/health/report-detail?reportType=' + reportType + '&reportId=' + reportId
+              }
+              uni.redirectTo({ url: gotoUrl })
             }, 500)
             }, 500)
           } else {
           } else {
             uni.navigateBack()
             uni.navigateBack()

+ 14 - 14
cfc-frontend/pages/health/report-list.vue

@@ -128,20 +128,20 @@ export default {
         self.loading = false
         self.loading = false
       })
       })
     },
     },
-	goToDetail(reportId, reportType) {
-		var detailPages = {
-		'gut_flora': '/pages/health/report-detail?reportType=gut_flora',
-		'dan': '/pages/dan-assessment/report-result',
-		'physical_exam': '/pages/health/physical-exam-detail',
-		'tongue': '/pages/health/tongue-index'
-	  }
-	  var url = detailPages[reportType] || '/pages/body/health-report'
-	  var sep = url.indexOf('?') >= 0 ? '&' : '?'
-	  // 肠道菌群报告:从列表点击直接进入编辑模式,跳过查看过渡页
-	  if (reportType === 'gut_flora') {
-	    url += '&mode=edit'
-	  }
-	  uni.navigateTo({ url: url + sep + 'reportId=' + reportId })
+    goToDetail(reportId, reportType) {
+		var url
+		if (reportType === 'gut_flora') {
+			url = '/pages/health/gut-flora-detail?reportId=' + reportId
+		} else if (reportType === 'dan') {
+			url = '/pages/dan-assessment/report-result?reportId=' + reportId
+		} else if (reportType === 'physical_exam') {
+			url = '/pages/health/physical-exam-detail?reportId=' + reportId
+		} else if (reportType === 'tongue') {
+			url = '/pages/health/tongue-index?reportId=' + reportId
+		} else {
+			url = '/pages/body/health-report?reportId=' + reportId
+		}
+		uni.navigateTo({ url: url })
 	},
 	},
     goToUpload() {
     goToUpload() {
       uni.navigateTo({ url: '/pages/health/report-upload' })
       uni.navigateTo({ url: '/pages/health/report-upload' })

+ 1 - 1
cfc-frontend/pages/health/report-upload.vue

@@ -237,7 +237,7 @@ export default {
           uni.showToast({ title: (res && res.message) || '上传失败', icon: 'none' })
           uni.showToast({ title: (res && res.message) || '上传失败', icon: 'none' })
           return
           return
         }
         }
-        uni.redirectTo({ url: '/pages/health/report-detail?draftId=' + draftId })
+        uni.redirectTo({ url: '/pages/health/gut-flora-detail?draftId=' + draftId })
       }).catch((err) => {
       }).catch((err) => {
         uni.hideLoading()
         uni.hideLoading()
         this.uploading = false
         this.uploading = false

+ 16 - 8
cfc-frontend/pages/health/survey-questionnaire.vue

@@ -33,7 +33,9 @@ export default {
     if (options.periodStart) this.periodStart = options.periodStart
     if (options.periodStart) this.periodStart = options.periodStart
     if (options.periodEnd) this.periodEnd = options.periodEnd
     if (options.periodEnd) this.periodEnd = options.periodEnd
     if (options.questions) { try { this.questions = JSON.parse(decodeURIComponent(options.questions)) } catch(e) { this.questions = [] } }
     if (options.questions) { try { this.questions = JSON.parse(decodeURIComponent(options.questions)) } catch(e) { this.questions = [] } }
-    if (this.templateId && !this.questions.length) this.loadTemplate()
+    // 有 templateId 或带 dim 参数(如首页"再次了解"回跳 dim=all)且无内联题目时,加载模板
+    if (this.questions.length) return
+    if (this.templateId || options.dim) this.loadTemplate()
   },
   },
   methods: {
   methods: {
     getQuestionKey(q) { return q && q.text ? q.text : 'q-' + Math.random() },
     getQuestionKey(q) { return q && q.text ? q.text : 'q-' + Math.random() },
@@ -44,15 +46,21 @@ export default {
       try {
       try {
         var res = await getActiveTemplates()
         var res = await getActiveTemplates()
         if (res.code === 200 && res.data) {
         if (res.code === 200 && res.data) {
-          for (var i = 0; i < res.data.length; i++) {
-            var t = res.data[i]
-            if (t && t.id === this.templateId) {
-              this.templateTitle = t.title || '定期调研'
-              this.templateDimension = t.dimension || ''
-              if (t.questionsJson) { try { this.questions = JSON.parse(t.questionsJson) } catch(e) {} }
-              break
+          var found = null
+          if (this.templateId) {
+            for (var i = 0; i < res.data.length; i++) {
+              var t = res.data[i]
+              if (t && t.id === this.templateId) { found = t; break }
             }
             }
           }
           }
+          // dim=all 或无 templateId 匹配(如"再次了解"回跳):取第一个可用的活跃模板
+          if (!found && res.data.length) found = res.data[0]
+          if (found) {
+            this.templateId = found.id
+            this.templateTitle = found.title || '定期调研'
+            this.templateDimension = found.dimension || ''
+            if (found.questionsJson) { try { this.questions = JSON.parse(found.questionsJson) } catch(e) {} }
+          }
         }
         }
       } catch(e) { console.error('加载模板失败', e) }
       } catch(e) { console.error('加载模板失败', e) }
     },
     },

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-197a9fc98c91fbebb68d7c768bd08258c7ff214c
+9e9fbabc2a7b3db731c84b9e99d6461a876c178c

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.1227",
+  "version": "1.0.1229",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "cfc-web",
       "name": "cfc-web",
-      "version": "1.0.1227",
+      "version": "1.0.1229",
       "dependencies": {
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,34 @@
 
 
 ---
 ---
 
 
+## v1.0.1230 (2026-08-28)
+
+### 文档
+- 生成方案标准内容格式实现计划(LangGraph tasks + Java 懒加载回填 + 前端任务编辑)
+- 生成方案标准内容格式设计(planJson tasks 单一事实源 + Pydantic 校验 + 懒加载回填 + 规划师任务编辑UI)
+
+### Bug 修复
+- 首页'再次了解'回跳调研页未加载内容
+
+
+## v1.0.1229 (2026-08-28)
+
+### 文档
+- 生成方案标准内容格式设计(planJson tasks 单一事实源 + Pydantic 校验 + 懒加载回填 + 规划师任务编辑UI)
+
+### 新功能
+- 菌群报告统一走个性化详情页并支持任意类型通用组装
+
+### 其他
+- - ReportBlockAssembler.assembleWithTemplate 无模板时对任意报告类型默认调用通用组装(评分+风险+指标+菌种),不再只支持 gut_flora
+- - 确认入库与编辑保存两处改传 reportType,走 assembleWithTemplate 模板驱动+默认兜底
+- 前端:
+- - report-list / health-main / report-upload / report-confirm 的 gut_flora 跳转统一到 gut-flora-detail 个性化页
+- - gut-flora-detail 新增内嵌 draftId 模式(解析→确认入库/放弃),修复评分圆环字段错位 bug(兼容 balanceScore 等新键),saveEdit 载荷改标准嵌套结构
+- BREAKING CHANGE: 肠道菌群报告详情页由通用 report-detail 切换为个性化 gut-flora-detail,入口 URL 与参数(draftId 支持)变更
+- 
+
+
 ## v1.0.1228 (2026-08-27)
 ## v1.0.1228 (2026-08-27)
 
 
 ### 新功能
 ### 新功能

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

@@ -1,6 +1,6 @@
 # 更新日志
 # 更新日志
 
 
-> 当前版本: v1.0.1228
+> 当前版本: v1.0.1230
 
 
 ## 历史版本
 ## 历史版本
 
 
@@ -8,6 +8,34 @@
 
 
 ---
 ---
 
 
+## v1.0.1230 (2026-08-28)
+
+### 文档
+- 生成方案标准内容格式实现计划(LangGraph tasks + Java 懒加载回填 + 前端任务编辑)
+- 生成方案标准内容格式设计(planJson tasks 单一事实源 + Pydantic 校验 + 懒加载回填 + 规划师任务编辑UI)
+
+### Bug 修复
+- 首页'再次了解'回跳调研页未加载内容
+
+
+## v1.0.1229 (2026-08-28)
+
+### 文档
+- 生成方案标准内容格式设计(planJson tasks 单一事实源 + Pydantic 校验 + 懒加载回填 + 规划师任务编辑UI)
+
+### 新功能
+- 菌群报告统一走个性化详情页并支持任意类型通用组装
+
+### 其他
+- - ReportBlockAssembler.assembleWithTemplate 无模板时对任意报告类型默认调用通用组装(评分+风险+指标+菌种),不再只支持 gut_flora
+- - 确认入库与编辑保存两处改传 reportType,走 assembleWithTemplate 模板驱动+默认兜底
+- 前端:
+- - report-list / health-main / report-upload / report-confirm 的 gut_flora 跳转统一到 gut-flora-detail 个性化页
+- - gut-flora-detail 新增内嵌 draftId 模式(解析→确认入库/放弃),修复评分圆环字段错位 bug(兼容 balanceScore 等新键),saveEdit 载荷改标准嵌套结构
+- BREAKING CHANGE: 肠道菌群报告详情页由通用 report-detail 切换为个性化 gut-flora-detail,入口 URL 与参数(draftId 支持)变更
+- 
+
+
 ## v1.0.1228 (2026-08-27)
 ## v1.0.1228 (2026-08-27)
 
 
 ### 新功能
 ### 新功能

+ 1 - 0
docs/superpowers/PROJECT-OVERVIEW.md

@@ -359,6 +359,7 @@
 | `2026-08-13-health-status-survey-design.md` | 🔄 v2 设计已定稿(2026-08-18;v1 已实施;v2 疾病史两源预置清单+确诊时间+服药时长) | 当前状态调研 — 健康现状档案 |
 | `2026-08-13-health-status-survey-design.md` | 🔄 v2 设计已定稿(2026-08-18;v1 已实施;v2 疾病史两源预置清单+确诊时间+服药时长) | 当前状态调研 — 健康现状档案 |
 | `2026-08-18-health-status-survey-v2.md` | 🟢 已实施(单文件改 health-status-form.vue;8 组 54 项预置清单分类多选+确诊时间;用药年月+时长自动计算;后端零变更;v1 旧数据兼容) | 当前状态调研 — 健康现状档案 v2 |
 | `2026-08-18-health-status-survey-v2.md` | 🟢 已实施(单文件改 health-status-form.vue;8 组 54 项预置清单分类多选+确诊时间;用药年月+时长自动计算;后端零变更;v1 旧数据兼容) | 当前状态调研 — 健康现状档案 v2 |
 | `2026-08-23-coach-butler-design.md` | ✅ 已实施 | AI健康教练人格分化(浠宝/福宝话术路由)与 L2 家庭管家自助选择设计 |
 | `2026-08-23-coach-butler-design.md` | ✅ 已实施 | AI健康教练人格分化(浠宝/福宝话术路由)与 L2 家庭管家自助选择设计 |
+| `2026-08-28-plan-standard-content-design.md` | 🟡 设计已确认 | 生成方案标准内容格式(plan_json.sections[].tasks 唯一事实源 + LangGraph prompt tasks 输出 + Pydantic 校验 + 正则兜底 + 存量懒加载回填 + 规划师端结构化任务编辑 UI) |
 | `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程) | 后台接口参考文档 |
 | `api/API_REFERENCE.md` | 🟢 已建立(2026-08-18;200+ 接口清单;废弃接口标注;新增接口检查流程) | 后台接口参考文档 |
 
 
 ### 实施计划(plans/)
 ### 实施计划(plans/)

+ 1087 - 0
docs/superpowers/plans/2026-08-28-plan-standard-content.md

@@ -0,0 +1,1087 @@
+# 生成方案标准内容格式 实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 让 `plan_json.sections[].tasks` 成为任务生成的唯一事实源——AI 有标准格式返回、用户看到标准格式可编辑、结果确定性地转化为任务。
+
+**架构:** 三端联动改造。LangGraph 侧在 `PLAN_SYSTEM_PROMPT` 追加 `tasks` 输出契约并新增 `PlanTask` Pydantic 模型,用 `PlanResponse.model_validate_json()` 取代手动 `find/rfind` 截取,校验失败时用正则兜底。Java 侧 `generateDailyTasksFromPlan` 优先读 `planJson.sections[].tasks` 映射到 `TaskDraft`,section 无 tasks 时用现有正则兜底并**懒加载回填**落库。前端修复 `submitPlan` 漏写 tasks 的 bug,并在规划师端 `HealthPlanReview.vue` 新增结构化任务条目编辑 UI。
+
+**技术栈:** Python (FastAPI + LangGraph + Pydantic v2)、Java 8 + Spring Boot + MyBatis-Plus + Jackson、Vue 2 小程序、Vue 2 + Element UI 管理端。
+
+---
+
+## 文件结构
+
+设计文档:`docs/superpowers/specs/2026-08-28-plan-standard-content-design.md`(已 commit,3db0413d)
+
+### 修改文件与职责
+
+| 文件 | 职责 | 变更类型 |
+|---|---|---|
+| `cfc-langgraph/app/models/health_plan.py` | 新增 `PlanTask` 模型、给 `HealthPlanSection` 加 `tasks` 字段 | 修改 |
+| `cfc-langgraph/app/api/adapter.py` | `PLAN_SYSTEM_PROMPT` 追加 tasks 契约;`health_plan_generate` 用 Pydantic 校验 + 正则兜底;`health_plan_regenerate` 返回 `{content, tasks}`;提取可测的纯解析函数 `_parse_plan_response` | 修改 |
+| `cfc-langgraph/tests/test_health_plan.py` | `PlanTask` 校验、`_parse_plan_response` 正则兜底、重生成 tasks 结构 | 创建 |
+| `cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java` | `generateDailyTasksFromPlan` 优先读 tasks + 正则兜底 + 懒加载回填;`TaskDraft` 增加 `frequency` 字段并映射到 Task | 修改 |
+| `cfc-frontend/pages/health/health-plan-summary.vue` | `submitPlan` 补 `sections[].tasks`;regenerate 返回 tasks 时更新 | 修改 |
+| `cfc-web/src/views/teacher/HealthPlanReview.vue` | 编辑弹窗新增结构化任务条目编辑(增删改) | 修改 |
+| `docs/superpowers/PROJECT-OVERVIEW.md` | 登记实现计划 | 修改 |
+
+### 与设计文档一致性的说明
+
+设计文档第四节写到 `app/schemas.py`,但实际健康方案 Pydantic 模型位于 **`app/models/health_plan.py`**(已存在 `HealthPlanSection`/`HealthPlanResponse`)。本计划按实际文件位置实现,不新建 `schemas.py`。请以本计划为准。
+
+---
+
+## 任务 1:LangGraph — PlanTask 模型与 tasks 契约
+
+**文件:**
+- 修改:`cfc-langgraph/app/models/health_plan.py`
+
+- [ ] **步骤 1:在 `HealthPlanSection` 增加 `tasks` 字段并新增 `PlanTask` 模型**
+
+在 `app/models/health_plan.py` 中加入:
+
+```python
+from typing import Literal
+
+class PlanTask(BaseModel):
+    action_type: Literal["buy", "read", "exercise", "checkin", "diet", "activity"]
+    title: str
+    dimension: Literal["body", "mind", "wisdom", "action", "wealth"]
+    frequency: Literal["once", "daily"] = "daily"
+    notes: Optional[str] = None
+
+
+class HealthPlanSection(BaseModel):
+    key: str  # "nutrition" | "diet" | "exercise"
+    title: str
+    content: str  # markdown text
+    items: List[dict] = []  # structured items for UI rendering
+    tasks: List[PlanTask] = []  # machine-readable task intents
+
+
+class HealthPlanResponse(BaseModel):
+    overview: str = ""
+    sections: List[HealthPlanSection] = []
+    abnormal_indicators: List[dict] = []
+    knowledge_sources: List[dict] = []
+```
+
+注意:`from typing import Optional, List` 已在文件顶部。
+
+- [ ] **步骤 2:编写失败测试**
+
+创建 `cfc-langgraph/tests/test_health_plan.py`:
+
+```python
+from app.models.health_plan import PlanTask, HealthPlanSection, HealthPlanResponse
+
+
+def test_plan_task_valid():
+    t = PlanTask(action_type="buy", title="购买维生素D3", dimension="wealth", frequency="once")
+    assert t.frequency == "once"
+    assert t.notes is None
+
+
+def test_plan_task_default_frequency_daily():
+    t = PlanTask(action_type="exercise", title="每周跑步3次", dimension="body")
+    assert t.frequency == "daily"
+
+
+def test_plan_task_invalid_action_type():
+    import pytest
+    from pydantic import ValidationError
+    with pytest.raises(ValidationError):
+        PlanTask(action_type="cook", title="做饭", dimension="body")
+
+
+def test_plan_task_invalid_dimension():
+    import pytest
+    from pydantic import ValidationError
+    with pytest.raises(ValidationError):
+        PlanTask(action_type="diet", title="少油少盐", dimension="earth")
+
+
+def test_plan_response_with_tasks():
+    resp = HealthPlanResponse(
+        overview="总览",
+        sections=[HealthPlanSection(
+            key="nutrition", title="营养补充", content="## 建议",
+            tasks=[PlanTask(action_type="buy", title="购买鱼油", dimension="wealth", frequency="once")]
+        )]
+    )
+    assert resp.sections[0].tasks[0].action_type == "buy"
+    # 序列化后 tasks 字段存在
+    data = resp.model_dump()
+    assert "tasks" in data["sections"][0]
+```
+
+- [ ] **步骤 3:运行测试验证失败**
+
+运行:`cd cfc-langgraph && python -m pytest tests/test_health_plan.py -v`
+预期:FAIL,报错 `ModuleNotFoundError`(尚无 PlanTask)或 assert 失败。
+
+- [ ] **步骤 4:运行测试验证通过**
+
+预期上述测试全部 PASS(模型定义后)。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-langgraph/app/models/health_plan.py cfc-langgraph/tests/test_health_plan.py
+git commit -m "feat(langgraph): 健康方案 tasks 模型(PlanTask + section.tasks)"
+```
+
+---
+
+## 任务 2:LangGraph — PLAN_SYSTEM_PROMPT 追加 tasks 契约
+
+**文件:**
+- 修改:`cfc-langgraph/app/api/adapter.py`(`PLAN_SYSTEM_PROMPT`,约 586-628 行)
+
+- [ ] **步骤 1:在 PLAN_SYSTEM_PROMPT 的 section 结构中追加 tasks 字段说明**
+
+在 `PLAN_SYSTEM_PROMPT` 的三个 section 示例(nutrition/diet/exercise)后、`abnormal_indicators` 前,追加一个 tasks 契约说明。将现有 `"items": [...]` 行修改为同时包含 `tasks` 示例:
+
+```python
+PLAN_SYSTEM_PROMPT = """你是一个专业的家庭健康方案规划师。根据用户提供的健康数据和目标,生成结构化的健康改善方案。
+
+## 输出格式(必须输出合法 JSON,不要有其他内容)
+
+{
+  "overview": "总体概述(100字以内,说明方案目标和核心策略)",
+  "sections": [
+    {
+      "key": "nutrition",
+      "title": "营养补充建议",
+      "content": "Markdown 格式的详细内容",
+      "items": [
+        {"name": "产品名", "dosage": "用量", "timing": "服用时间", "reason": "推荐理由"}
+      ],
+      "tasks": [
+        {
+          "action_type": "buy",
+          "title": "购买维生素D3补充剂",
+          "dimension": "wealth",
+          "frequency": "once",
+          "notes": "每日一粒,随餐服用"
+        }
+      ]
+    },
+    {
+      "key": "diet",
+      "title": "饮食建议",
+      "content": "Markdown 格式的餐饮建议",
+      "items": [{"meal": "餐型", "food": "食物建议", "notes": "注意事项"}],
+      "tasks": [
+        {
+          "action_type": "diet",
+          "title": "早餐增加高蛋白与膳食纤维",
+          "dimension": "body",
+          "frequency": "daily",
+          "notes": ""
+        }
+      ]
+    },
+    {
+      "key": "exercise",
+      "title": "运动计划",
+      "content": "Markdown 格式的运动建议",
+      "items": [{"type": "运动类型", "duration": "时长", "frequency": "频率", "notes": "注意事项"}],
+      "tasks": [
+        {
+          "action_type": "exercise",
+          "title": "每周3次有氧运动,每次30分钟",
+          "dimension": "body",
+          "frequency": "daily",
+          "notes": ""
+        }
+      ]
+    }
+  ],
+  "abnormal_indicators": [
+    {"member": "姓名", "indicator": "指标名", "value": "值", "unit": "单位", "suggestion": "建议"}
+  ]
+}
+```
+
+## tasks 字段约定
+- 每个 section 的 `tasks` 是该 section 中"可执行的行动项"列表,与 `content`(人类可读 Markdown)分离。
+- `action_type` 取值仅限:`buy`(购买/补充产品)、`read`(阅读)、`exercise`(运动)、`checkin`(打卡/记录)、`diet`(饮食)、`activity`(活动/社交)。
+- `dimension` 取值仅限五维:`body`/`mind`/`wisdom`/`action`/`wealth`。
+- `frequency`:`once`=一次性任务;`daily`=每日重复任务。
+- `title` 是最终写入任务系统的标题,必须是**具体可执行的动作**,不要写纯原理/机制描述。
+- 若某 section 没有可执行的行动项,`tasks` 输出空数组 `[]`。
+
+## 原则
+1. 基于实际数据给出建议,不编造
+2. 引用知识库内容时标注来源
+3. 建议要具体可执行,避免空泛
+4. 营养补充部分要具体到产品类型和用量
+5. 严重健康问题建议咨询医生
+```
+
+- [ ] **步骤 2:运行测试验证无回归**
+
+运行:`cd cfc-langgraph && python -m pytest tests/test_health_plan.py -v`
+预期:全部 PASS(prompt 改动不影响模型测试)。
+
+- [ ] **步骤 3:Commit**
+
+```bash
+git add cfc-langgraph/app/api/adapter.py
+git commit -m "feat(langgraph): 健康方案 prompt 追加 tasks 输出契约"
+```
+
+---
+
+## 任务 3:LangGraph — health_plan_generate 用 Pydantic 校验 + 正则兜底
+
+**文件:**
+- 修改:`cfc-langgraph/app/api/adapter.py`(`health_plan_generate`,约 754-770 行)
+- 测试:`cfc-langgraph/tests/test_health_plan.py`
+
+- [ ] **步骤 1:提取纯解析函数 `_parse_plan_response`(可测试,无 IO)**
+
+在 `app/api/adapter.py` 中,`health_plan_generate` 函数定义之前新增模块级函数:
+
+```python
+import re
+from app.models.health_plan import PlanResponse
+
+# 从 LLM 原始输出中提取 tasks 的正则兜底:在 content 文本里找形如
+# "1. 动作(动词/名词)..." 的行。以可识别的动作词开头视为潜在任务。
+_TASK_FALLBACK_RE = re.compile(r"^\s*(?:\d+[\.、)]|\-\s*)\s*"
+                               r"(?:(?:购买|购置|阅读|看|运动|锻炼|跑步|散步|打卡|记录|饮食|吃|少|多|活动|参加|亲子).*)$")
+
+
+def _parse_plan_response(answer: str) -> dict:
+    """解析 LLM 原始输出为 PlanResponse;非法 JSON 或校验失败时用正则兜底提取 tasks。
+
+    返回 dict:{"success": bool, "data": {...}, "error": str|None}
+    """
+    import json
+    start = answer.find("{")
+    end = answer.rfind("}") + 1
+    if start >= 0 and end > start:
+        try:
+            parsed = json.loads(answer[start:end])
+            resp = PlanResponse.model_validate(parsed)
+            return {"success": True, "data": resp.model_dump(), "error": None}
+        except Exception as e:
+            # 校验失败:尝试正则兜底
+            fallback = _fallback_extract_tasks(parsed if isinstance(parsed, dict) else {})
+            if fallback is not None:
+                return {"success": True, "data": fallback, "error": str(e)}
+            return {"success": False, "data": {"raw": answer, "overview": answer[:200]},
+                    "error": str(e)}
+    return {"success": False, "data": {"raw": answer, "overview": answer[:200]},
+            "error": "no JSON found"}
+
+
+def _fallback_extract_tasks(parsed: dict) -> Optional[dict]:
+    """当 LLM 输出缺 tasks 或校验失败时,从各 section.content 用正则提取任务并回填。
+    任一 section 无 tasks 才触发;全部已含 tasks 则返回 None(表示无需兜底)。"""
+    if not isinstance(parsed, dict):
+        return None
+    sections = parsed.get("sections")
+    if not isinstance(sections, list) or not sections:
+        return None
+    changed = False
+    for sec in sections:
+        if not isinstance(sec, dict):
+            continue
+        tasks = sec.get("tasks")
+        if isinstance(tasks, list) and tasks:
+            continue  # 已有 tasks,跳过
+        content = sec.get("content", "")
+        extracted = []
+        for raw in content.split("\n"):
+            line = raw.strip()
+            if not line:
+                continue
+            m = _TASK_FALLBACK_RE.match(line)
+            if not m:
+                continue
+            # 去掉行首编号/项目符号
+            title = re.sub(r"^\s*(?:\d+[\.、)]|\-\s*)\s*", "", line).strip()
+            if not title:
+                continue
+            action = _classify_action(title)
+            if action is None:
+                continue
+            extracted.append({
+                "action_type": action["action_type"],
+                "title": title,
+                "dimension": action["dimension"],
+                "frequency": action["frequency"],
+                "notes": "",
+            })
+        if extracted:
+            sec["tasks"] = extracted
+            changed = True
+    if changed:
+        return parsed
+    return None
+
+
+def _classify_action(title: str) -> Optional[dict]:
+    """按动作词分类,映射到 action_type + 五维维度 + 频率(与 Java classifyTaskLine 对齐)。"""
+    if re.search(r"购买|购置|采购|下单|买入|囤|选购", title):
+        return {"action_type": "buy", "dimension": "wealth", "frequency": "once"}
+    if re.search(r"阅读|看|读书", title):
+        return {"action_type": "read", "dimension": "wisdom", "frequency": "daily"}
+    if re.search(r"运动|锻炼|跑步|散步|健身|瑜伽|拉伸", title):
+        return {"action_type": "exercise", "dimension": "body", "frequency": "daily"}
+    if re.search(r"打卡|记录|复盘|记", title):
+        return {"action_type": "checkin", "dimension": "mind", "frequency": "daily"}
+    if re.search(r"饮食|吃|少|多|餐|营养|水", title):
+        return {"action_type": "diet", "dimension": "body", "frequency": "daily"}
+    if re.search(r"活动|参加|亲子|社交|户外|游戏", title):
+        return {"action_type": "activity", "dimension": "action", "frequency": "daily"}
+    return None
+```
+
+- [ ] **步骤 2:编写失败测试**
+
+在 `tests/test_health_plan.py` 末尾追加:
+
+```python
+from app.api.adapter import _parse_plan_response, _fallback_extract_tasks
+
+
+def test_parse_plan_response_valid():
+    answer = '{"overview":"o","sections":[{"key":"diet","title":"t","content":"c","items":[],"tasks":[{"action_type":"diet","title":"少油少盐","dimension":"body","frequency":"daily"}]}],"abnormal_indicators":[]}'
+    res = _parse_plan_response(answer)
+    assert res["success"] is True
+    assert res["data"]["sections"][0]["tasks"][0]["action_type"] == "diet"
+
+
+def test_parse_plan_response_invalid_action_falls_back():
+    # LLM 输出含非法 action_type("cook"),校验失败但可被 fallback 处理
+    answer = '{"overview":"o","sections":[{"key":"diet","title":"t","content":"1. 少油少盐\\n2. 多吃蔬菜\\n3. 纯原理描述无动作","items":[]}],"abnormal_indicators":[]}'
+    res = _parse_plan_response(answer)
+    # fallback 从 content 提取动作行
+    assert res["success"] is True
+    tasks = res["data"]["sections"][0].get("tasks", [])
+    assert len(tasks) >= 2
+
+
+def test_parse_plan_response_no_json():
+    res = _parse_plan_response("这是纯文本没有 JSON")
+    assert res["success"] is False
+
+
+def test_fallback_skips_when_tasks_exist():
+    parsed = {"sections": [{"key": "diet", "title": "t", "content": "1. 少油少盐", "tasks": [{"action_type": "diet", "title": "已有", "dimension": "body"}]}]}
+    assert _fallback_extract_tasks(parsed) is None
+```
+
+- [ ] **步骤 3:运行测试验证失败**
+
+运行:`cd cfc-langgraph && python -m pytest tests/test_health_plan.py -v`
+预期:新增测试 FAIL,报错 `ImportError`(`_parse_plan_response` 尚未定义)。
+
+- [ ] **步骤 4:实现生成逻辑,用 `_parse_plan_response` 替代手动解析**
+
+将 `health_plan_generate` 中 `try` 块内的解析逻辑(当前 757-767 行)替换为:
+
+```python
+    try:
+        response = await llm.ainvoke(messages)
+        answer = response.content
+        result = _parse_plan_response(answer)
+        if result["success"]:
+            return {"success": True, "data": result["data"], "parse_error": result["error"]}
+        return {"success": True, "data": result["data"], "parse_error": result["error"]}
+    except Exception as e:
+        logger.error("方案生成失败: %s", e)
+        return {"success": False, "error": str(e)}
+```
+
+(保持返回结构兼容:成功时 `data` 为结构化 dict;失败时 `data.raw` 为原始文本,前端仍可降级兼容。)
+
+确认 `adapter.py` 顶部 imports 含 `Optional`、`PlanResponse`(`from app.models.health_plan import PlanResponse`)与 `re`。
+
+- [ ] **步骤 5:运行测试验证通过**
+
+运行:`cd cfc-langgraph && python -m pytest tests/test_health_plan.py -v`
+预期:全部 PASS。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-langgraph/app/models/health_plan.py cfc-langgraph/app/api/adapter.py cfc-langgraph/tests/test_health_plan.py
+git commit -m "feat(langgraph): 健康方案生成 Pydantic 校验 + 正则兜底提取 tasks"
+```
+
+---
+
+## 任务 4:LangGraph — regenerate-section 返回 {content, tasks}
+
+**文件:**
+- 修改:`cfc-langgraph/app/api/adapter.py`(`health_plan_regenerate`,约 773-804 行)
+
+- [ ] **步骤 1:修改 `REGENERATE_SECTION_SYSTEM_PROMPT` 让 LLM 输出 JSON {content, tasks}**
+
+将 `REGENERATE_SECTION_SYSTEM_PROMPT`(630-638 行)替换为:
+
+```python
+REGENERATE_SECTION_SYSTEM_PROMPT = """你是一个家庭健康方案规划师。根据用户反馈重新生成指定部分内容。
+
+## 输出格式(必须输出合法 JSON,不要有其他内容)
+{
+  "content": "重新生成的 Markdown 内容",
+  "tasks": [
+    {
+      "action_type": "buy|read|exercise|checkin|diet|activity",
+      "title": "可执行任务标题",
+      "dimension": "body|mind|wisdom|action|wealth",
+      "frequency": "once|daily",
+      "notes": "补充说明"
+    }
+  ]
+}
+
+## tasks 约定
+- action_type 取值:buy/read/exercise/checkin/diet/activity
+- dimension 取值:body/mind/wisdom/action/wealth
+- frequency:once=一次性;daily=每日重复
+- 无行动项时 tasks 输出 []
+
+## 原则
+- 保持与原格式一致
+- 结合用户反馈进行修改
+- 建议要具体可执行"""
+```
+
+- [ ] **步骤 2:修改 `health_plan_regenerate` 解析 JSON 并返回 {content, tasks}**
+
+将 `health_plan_regenerate` 末尾的调用块(799-804 行)替换为:
+
+```python
+    try:
+        response = await llm.ainvoke([SystemMessage(content=prompt)])
+        answer = response.content
+        import json
+        start = answer.find("{")
+        end = answer.rfind("}") + 1
+        content = answer
+        tasks = []
+        if start >= 0 and end > start:
+            try:
+                parsed = json.loads(answer[start:end])
+                content = parsed.get("content") or answer
+                raw_tasks = parsed.get("tasks") or []
+                # 用 PlanTask 校验,非法条目丢弃
+                from app.models.health_plan import PlanTask
+                for t in raw_tasks:
+                    try:
+                        pt = PlanTask.model_validate(t)
+                        tasks.append(pt.model_dump())
+                    except Exception:
+                        continue
+            except Exception as e:
+                logger.warning("解析重生成 section JSON 失败: %s", e)
+        return {"success": True, "content": content, "tasks": tasks}
+    except Exception as e:
+        logger.error("重新生成 section 失败: %s", e)
+        return {"success": False, "error": str(e)}
+```
+
+- [ ] **步骤 3:更新测试,覆盖重生成返回结构**
+
+在 `tests/test_health_plan.py` 追加(校验 `PlanTask` 模型在重生成中的过滤逻辑):
+
+```python
+def test_regenerate_tasks_validation_filter():
+    from app.models.health_plan import PlanTask
+    raw_tasks = [
+        {"action_type": "diet", "title": "少油少盐", "dimension": "body", "frequency": "daily"},
+        {"action_type": "cook", "title": "非法", "dimension": "body"},  # 非法 action,应被丢弃
+    ]
+    valid = []
+    for t in raw_tasks:
+        try:
+            valid.append(PlanTask.model_validate(t).model_dump())
+        except Exception:
+            continue
+    assert len(valid) == 1
+    assert valid[0]["action_type"] == "diet"
+```
+
+- [ ] **步骤 4:运行测试验证通过**
+
+运行:`cd cfc-langgraph && python -m pytest tests/test_health_plan.py -v`
+预期:全部 PASS。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-langgraph/app/api/adapter.py cfc-langgraph/tests/test_health_plan.py
+git commit -m "feat(langgraph): 方案 section 重生成返回 {content, tasks} 结构化"
+```
+
+---
+
+## 任务 5:Java — generateDailyTasksFromPlan 优先读 tasks + 懒加载回填
+
+**文件:**
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java`
+
+**设计要点:** `generateDailyTasksFromPlan`(264-375 行)目前从 `plan_content` 正则提取。改造为优先读 `plan_json.sections[].tasks`;该 section 无 tasks 时用正则兜底并回填落库。
+
+- [ ] **步骤 1:给 `TaskDraft` 增加 `frequency` 字段**
+
+将 `TaskDraft` 静态内部类(452-462 行)改为:
+
+```java
+    // 待生成任务的草稿:标题 + 类别中文标签 + 五维维度码 + 频率(once/daily)+ 父任务ID(购买→使用链)
+    private static class TaskDraft {
+        final String title;
+        final String categoryLabel;
+        final String dimensionCode;
+        final String frequency; // "once" | "daily"
+        Long parentTaskId;
+        TaskDraft(String title, String categoryLabel, String dimensionCode, String frequency) {
+            this.title = title;
+            this.categoryLabel = categoryLabel;
+            this.dimensionCode = dimensionCode;
+            this.frequency = frequency;
+        }
+    }
+```
+
+**注意:** `classifyTaskLine` 中所有 `new TaskDraft(...)` 调用(483、489、495、498、501、504、507 行)需同步补第四个参数 `frequency`:
+- 购买任务(483 行):`"once"`
+- 使用/服用子任务(489 行):`"daily"`
+- 其余(阅读/运动/打卡/饮食/活动):`"daily"`
+
+- [ ] **步骤 2:改写 `generateDailyTasksFromPlan` 为"优先 tasks、兜底正则、懒加载回填"**
+
+将当前方法体(264-375 行)开头部分(从方法声明到 `for (String raw : rawLines)` 之前)替换为:
+
+```java
+    private void generateDailyTasksFromPlan(HealthPlan plan) {
+        String content = plan.getPlanContent();
+        if (content == null || content.trim().isEmpty()) return;
+
+        List<String> rawLines = new java.util.ArrayList<>();
+        // 优先使用 plan_json.sections[].tasks
+        List<TaskDraft> structuredDrafts = buildDraftsFromPlanJson(plan);
+        boolean usedStructured = !structuredDrafts.isEmpty();
+        if (structuredDrafts.isEmpty()) {
+            rawLines = parseTaskLines(content);
+        }
+        if (usedStructured && structuredDrafts.isEmpty()) return;
+        if (!usedStructured && rawLines.isEmpty()) return;
+
+        Long familyId = plan.getFamilyId();
+        String[] memberIdArray = (plan.getMemberIds() != null && !plan.getMemberIds().isEmpty())
+            ? plan.getMemberIds().split(",") : new String[0];
+        Long rawMemberId = memberIdArray.length > 0 ? Long.valueOf(memberIdArray[0].trim()) : null;
+        Long familyMemberId = resolveFamilyMemberId(rawMemberId, familyId);
+        Long childId = familyMemberId;
+        Long executorId = familyMemberId;
+
+        // 当天截止 23:59:59(严格落在今日,避免存成次日 00:00 导致今日任务查不到)
+        Date deadline = buildTodayDeadline();
+
+        Date now = new Date();
+```
+
+然后将下方循环从 `for (String raw : rawLines)` 改为统一遍历草稿列表:
+
+```java
+        // 统一生成:structured 分支直接用草稿;regex 分支按行分类
+        List<TaskDraft> drafts = new java.util.ArrayList<>();
+        if (usedStructured) {
+            drafts = structuredDrafts;
+        } else {
+            for (String raw : rawLines) {
+                String line = raw.trim();
+                if (line.isEmpty()) continue;
+                List<TaskDraft> cls = classifyTaskLine(line);
+                if (cls != null && !cls.isEmpty()) drafts.addAll(cls);
+            }
+        }
+
+        // 购买→使用子任务链的父任务ID(一次性购买任务完成后派生每日使用/服用)
+        Long buyTaskId = null;
+
+        for (TaskDraft draft : drafts) {
+            // 幂等:同一方案今天已有同名任务则跳过(deadline 限定当天)
+            Calendar todayStart = Calendar.getInstance();
+            todayStart.set(Calendar.HOUR_OF_DAY, 0); todayStart.set(Calendar.MINUTE, 0);
+            todayStart.set(Calendar.SECOND, 0); todayStart.set(Calendar.MILLISECOND, 0);
+            Calendar todayEnd = (Calendar) todayStart.clone();
+            todayEnd.add(Calendar.DAY_OF_MONTH, 1);
+
+            boolean exists = taskMapper.selectCount(new QueryWrapper<Task>()
+                .eq("family_id", familyId)
+                .eq("source_type", "plan")
+                .eq("source_id", plan.getId())
+                .eq("title", draft.title)
+                .ge("deadline", todayStart.getTime())
+                .lt("deadline", todayEnd.getTime())) > 0;
+            if (exists) {
+                if ("购买任务".equals(draft.categoryLabel) && buyTaskId == null) {
+                    Task existingBuy = taskMapper.selectOne(new QueryWrapper<Task>()
+                        .eq("family_id", familyId)
+                        .eq("source_type", "plan")
+                        .eq("source_id", plan.getId())
+                        .eq("title", draft.title)
+                        .eq("category", "购买任务")
+                        .last("LIMIT 1"));
+                    if (existingBuy != null) buyTaskId = existingBuy.getId();
+                }
+                continue;
+            }
+            // ...(保留原有 Task 组装逻辑,唯一改动:repeatType/taskType/frequency 改用 draft.frequency)
+        }
+    }
+```
+
+**频率映射(替换原 342-372 行中的硬编码):**
+- 用一个开关 `String repeatType = "once".equals(draft.frequency) ? "none" : "daily";` 和 `String taskType = "once".equals(draft.frequency) ? "onetime" : "recurring";`、`String freq = "once".equals(draft.frequency) ? null : "daily";`
+- 购买任务(`"购买任务"` 类别):原本就是 once,命中 `frequency=once` → 与 draft.frequency 一致。
+- 使用任务/常规任务:draft.frequency=daily。
+
+在原有三种分支(购买/使用/常规)中,统一用上述计算值替代写死的 `"none"`/`"onetime"`、`"daily"`/`"recurring"`/`"daily"`。
+
+- [ ] **步骤 3:新增 `buildDraftsFromPlanJson` 与懒加载回填**
+
+在 `HealthPlanServiceImpl` 中新增方法:
+
+```java
+    /**
+     * 从 plan_json.sections[].tasks 构建任务草稿。返回 tasks 草稿列表;
+     * 若某 section 无 tasks,则用行分类逻辑兜底并回填 plan_json(懒加载),落库。
+     */
+    private List<TaskDraft> buildDraftsFromPlanJson(HealthPlan plan) {
+        List<TaskDraft> drafts = new java.util.ArrayList<>();
+        String planJsonStr = plan.getPlanJson();
+        if (planJsonStr == null || planJsonStr.trim().isEmpty()) return drafts;
+
+        try {
+            JsonNode root = objectMapper.readTree(planJsonStr);
+            JsonNode sections = root.path("sections");
+            if (!sections.isArray()) return drafts;
+
+            boolean backfilled = false;
+            for (JsonNode section : sections) {
+                JsonNode tasks = section.path("tasks");
+                if (tasks.isArray()) {
+                    for (JsonNode t : tasks) {
+                        String actionType = t.path("action_type").asText("");
+                        String title = t.path("title").asText("");
+                        String dimension = t.path("dimension").asText("");
+                        String frequency = t.path("frequency").asText("daily");
+                        if (title.isEmpty()) continue;
+                        TaskDraft draft = mapStructuredTask(actionType, title, dimension, frequency);
+                        if (draft != null) drafts.add(draft);
+                    }
+                }
+                // 懒加载回填:section 无 tasks 时用正则从 content 提取并写回
+                if (tasks.isMissingNode() || (tasks.isArray() && tasks.size() == 0)) {
+                    String sectionContent = section.path("content").asText("");
+                    List<String> lines = parseTaskLines(sectionContent);
+                    ArrayNode newTasks = objectMapper.createArrayNode();
+                    for (String raw : lines) {
+                        String line = raw.trim();
+                        if (line.isEmpty()) continue;
+                        List<TaskDraft> cls = classifyTaskLine(line);
+                        if (cls == null) continue;
+                        for (TaskDraft d : cls) {
+                            ObjectNode node = objectMapper.createObjectNode();
+                            node.put("action_type", actionTypeFromCategory(d.categoryLabel));
+                            node.put("title", d.title);
+                            node.put("dimension", d.dimensionCode);
+                            node.put("frequency", d.frequency);
+                            node.put("notes", "");
+                            newTasks.add(node);
+                        }
+                    }
+                    ((ObjectNode) section).set("tasks", newTasks);
+                    backfilled = true;
+                }
+            }
+            if (backfilled) {
+                plan.setPlanJson(objectMapper.writeValueAsString(root));
+                healthPlanMapper.updateById(plan);
+                log.info("方案{}懒加载回填 tasks 完成", plan.getId());
+            }
+        } catch (Exception e) {
+            log.warn("解析 plan_json 生成任务失败: {}", e.getMessage());
+        }
+        return drafts;
+    }
+```
+
+新增映射方法:
+
+```java
+    /** 将结构化 action_type/dimension/frequency 映射为 TaskDraft;非法类型返回 null。 */
+    private TaskDraft mapStructuredTask(String actionType, String title, String dimension, String frequency) {
+        String cat;
+        String dim;
+        String freq;
+        switch (actionType == null ? "" : actionType) {
+            case "buy": cat = "购买任务"; dim = "wealth"; freq = "once"; break;
+            case "read": cat = "阅读任务"; dim = "wisdom"; freq = "daily"; break;
+            case "exercise": cat = "运动任务"; dim = "body"; freq = "daily"; break;
+            case "checkin": cat = "打卡任务"; dim = "mind"; freq = "daily"; break;
+            case "diet": cat = "饮食任务"; dim = "body"; freq = "daily"; break;
+            case "activity": cat = "活动任务"; dim = "action"; freq = "daily"; break;
+            default: return null;
+        }
+        // dimension/frequency 显式值优先覆盖默认
+        if (dimension != null && !dimension.isEmpty()) dim = dimension;
+        if ("once".equals(frequency) || "daily".equals(frequency)) freq = frequency;
+        String finalFreq = freq;
+        String finalDim = dim;
+        return new TaskDraft(title, cat, finalDim, finalFreq);
+    }
+
+    /** 将 TaskDraft 的分类中文标签反解为 action_type 枚举(用于懒加载回填补全结构化字段)。 */
+    private String actionTypeFromCategory(String categoryLabel) {
+        if (categoryLabel == null) return "";
+        switch (categoryLabel) {
+            case "购买任务": return "buy";
+            case "阅读任务": return "read";
+            case "运动任务": return "exercise";
+            case "打卡任务": return "checkin";
+            case "饮食任务": return "diet";
+            case "活动任务": return "activity";
+            default: return "";
+        }
+    }
+```
+
+**注意:** `buy` 类型的购买任务原逻辑会在 classifyTaskLine 中派生"使用子任务"。structured 分支中 `buy` 只生成购买任务本身,不自动派生使用子任务(因为 LLM 的 tasks 已显式表达任务意图,派生逻辑属于正则启发式,仅在兜底路径保留)。这是**有意的简化**,符合"tasks 是唯一事实源"设计。
+
+- [ ] **步骤 4:检查 imports**
+
+确认 `HealthPlanServiceImpl.java` 已 import:
+- `com.fasterxml.jackson.databind.node.ObjectNode`、`ArrayNode`
+- `com.fasterxml.jackson.databind.JsonNode`(已有 `objectMapper.readTree` 用法,`objectMapper` 字段已存在)
+
+若无,在文件 import 区补充。
+
+- [ ] **步骤 5:运行编译验证**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java
+git commit -m "feat(backend): 方案任务生成优先读 tasks + 正则兜底 + 懒加载回填"
+```
+
+---
+
+## 任务 6:Java — 编译通过(含 TaskDraft 构造器兼容)
+
+**说明:** 任务 5 改了 `TaskDraft` 构造器签名,任务 6 是**验证所有调用点已同步**、整体可编译。
+
+- [ ] **步骤 1:全量编译**
+
+运行:`cd cfc-backend && mvn clean compile`
+预期:BUILD SUCCESS,无 `TaskDraft` 构造器参数不匹配错误。
+
+若编译报构造函数参数错误,逐个检查 `classifyTaskLine` 及 `buildDraftsFromPlanJson`/`mapStructuredTask` 中的 `new TaskDraft(...)` 调用,确保均为 4 参数。
+
+- [ ] **步骤 2:(可选)运行后端单测**
+
+运行:`cd cfc-backend && mvn test`
+预期:既有测试通过(若只有 1 个测试类则无新失败)。
+
+---
+
+## 任务 7:cfc-web — HealthPlanReview.vue 结构化任务条目编辑
+
+**文件:**
+- 修改:`cfc-web/src/views/teacher/HealthPlanReview.vue`
+
+**设计要点:** 编辑弹窗在 `planContent` 纯文本下方新增"任务条目"区域,编辑 `plan_json.sections[].tasks`,保存时透传更新后的 `plan_json`(现有 `pending-review/update` 接口已是透传 planJson)。
+
+- [ ] **步骤 1:在编辑弹窗 `el-dialog` 中新增任务编辑表单**
+
+在 `HealthPlanReview.vue` 的编辑弹窗(line 50-63)内、`方案内容` 文本框之后追加:
+
+```html
+<el-form-item label="任务条目">
+  <div v-for="(sec, si) in editTasks.sections" :key="'sec'+si" style="margin-bottom:12px;border:1px solid #ebeef5;border-radius:4px;padding:8px;">
+    <div style="font-weight:600;margin-bottom:6px;">{{ sec.title || sec.key }}</div>
+    <div v-for="(task, ti) in sec.tasks" :key="'t'+si+'-'+ti" style="display:flex;gap:6px;margin-bottom:6px;align-items:center;">
+      <el-select v-model="task.action_type" size="mini" style="width:90px">
+        <el-option v-for="a in actionTypes" :key="a.value" :label="a.label" :value="a.value" />
+      </el-select>
+      <el-select v-model="task.dimension" size="mini" style="width:80px">
+        <el-option v-for="d in dimensions" :key="d.value" :label="d.label" :value="d.value" />
+      </el-select>
+      <el-select v-model="task.frequency" size="mini" style="width:70px">
+        <el-option label="一次性" value="once" />
+        <el-option label="每日" value="daily" />
+      </el-select>
+      <el-input v-model="task.title" size="mini" placeholder="任务标题" />
+      <el-input v-model="task.notes" size="mini" placeholder="备注" style="width:120px" />
+      <el-button size="mini" type="danger" icon="el-icon-delete" @click="removeTask(si, ti)" />
+    </div>
+    <el-button size="mini" type="text" icon="el-icon-plus" @click="addTask(si)">添加任务</el-button>
+  </div>
+</el-form-item>
+```
+
+- [ ] **步骤 2:在 data() 增加状态**
+
+扩展 script 的 data():
+
+```js
+data() {
+  return {
+    // ...原有字段...
+    editTasks: { sections: [] },
+    actionTypes: [
+      { value: 'buy', label: '购买' },
+      { value: 'read', label: '阅读' },
+      { value: 'exercise', label: '运动' },
+      { value: 'checkin', label: '打卡' },
+      { value: 'diet', label: '饮食' },
+      { value: 'activity', label: '活动' }
+    ],
+    dimensions: [
+      { value: 'body', label: '身' },
+      { value: 'mind', label: '心' },
+      { value: 'wisdom', label: '智' },
+      { value: 'action', label: '行' },
+      { value: 'wealth', label: '富' }
+    ]
+  }
+}
+```
+
+- [ ] **步骤 3:在 methods 增加加载/增删任务方法**
+
+在 methods 中新增:
+
+```js
+parsePlanJson(str) {
+  try {
+    return JSON.parse(str || '{}')
+  } catch (e) {
+    return {}
+  }
+},
+loadEditTasks(planJson) {
+  const parsed = this.parsePlanJson(planJson)
+  const sections = (parsed.sections || []).map(s => ({
+    key: s.key || '',
+    title: s.title || '',
+    tasks: (s.tasks || []).map(t => ({
+      action_type: t.action_type || 'diet',
+      title: t.title || '',
+      dimension: t.dimension || 'body',
+      frequency: t.frequency || 'daily',
+      notes: t.notes || ''
+    }))
+  }))
+  this.editTasks = { sections }
+},
+addTask(si) {
+  this.editTasks.sections[si].tasks.push({
+    action_type: 'diet', title: '', dimension: 'body', frequency: 'daily', notes: ''
+  })
+},
+removeTask(si, ti) {
+  this.editTasks.sections[si].tasks.splice(ti, 1)
+},
+buildPlanJsonWithTasks() {
+  const parsed = this.parsePlanJson(this.currentPlan.planJson)
+  const sections = parsed.sections || []
+  this.editTasks.sections.forEach(es => {
+    const target = sections.find(s => s.key === es.key)
+    if (!target) return
+    // 过滤空标题任务
+    target.tasks = es.tasks.filter(t => t.title && t.title.trim())
+  })
+  return JSON.stringify(parsed)
+}
+```
+
+- [ ] **步骤 4:修改 `showEdit` 初始化 editTasks;`saveEdit` 回写 planJson**
+
+将 `showEdit`(139-146 行)改为:
+
+```js
+showEdit(row) {
+  this.currentPlan = row
+  this.editForm = {
+    planContent: row.planContent || '',
+    reviewComment: row.reviewComment || ''
+  }
+  this.loadEditTasks(row.planJson)
+  this.editVisible = true
+}
+```
+
+将 `saveEdit`(147-167 行)中的请求参数改为用 `buildPlanJsonWithTasks()` 生成新 planJson:
+
+```js
+async saveEdit() {
+  this.saving = true
+  try {
+    const newPlanJson = this.buildPlanJsonWithTasks()
+    const res = await updatePlanContent({
+      planId: this.currentPlan.id,
+      planContent: this.editForm.planContent,
+      planJson: newPlanJson
+    })
+    if (res.code === 200) {
+      this.$message.success('保存成功')
+      this.editVisible = false
+      this.loadPlans()
+    } else {
+      this.$message.error(res.message || '保存失败')
+    }
+  } catch(e) {
+    this.$message.error('请求失败')
+  } finally {
+    this.saving = false
+  }
+}
+```
+
+- [ ] **步骤 5:运行构建验证**
+
+运行:`cd cfc-web && npm run build`
+预期:BUILD SUCCESS,无语法/编译错误。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-web/src/views/teacher/HealthPlanReview.vue
+git commit -m "feat(web): 规划师端健康方案结构化任务条目编辑"
+```
+
+---
+
+## 任务 8:cfc-frontend — submitPlan 补 tasks + regenerate 更新 tasks
+
+**文件:**
+- 修改:`cfc-frontend/pages/health/health-plan-summary.vue`(1741 行)
+
+**设计要点:** 修复 `submitPlan` 组装 planJson 漏写 `sections[].tasks` 的 bug;反馈式重生成接口返回 `{content, tasks}` 时前端同步更新。
+
+- [ ] **步骤 1:修复 `submitPlan` 组装 planJson 漏写 tasks 的 bug**
+
+`submitPlan`(834-842 行)当前的 section 组装:
+
+```js
+var planJson = JSON.stringify({
+  overview: self.planData.overview || '',
+  sections: self.sectionKeys.map(function(key) {
+    return { key: key, content: self.sections[key].content, items: self.sections[key].items || [] }
+  })
+})
+```
+
+修复为在 section 对象中补上 `tasks`(透传该 section 已有的 tasks,无则空数组):
+
+```js
+var planJson = JSON.stringify({
+  overview: self.planData.overview || '',
+  sections: self.sectionKeys.map(function(key) {
+    return { key: key, content: self.sections[key].content, items: self.sections[key].items || [], tasks: self.sections[key].tasks || [] }
+  })
+})
+```
+
+这样 planJson 的每个 section 都携带 `tasks`,保存后由后端 `buildDraftsFromPlanJson` 消费。
+
+- [ ] **步骤 2:适配 `regenerateCurrentSection` 回流 {content, tasks}**
+
+`regenerateCurrentSection`(789-815 行)当前成功后只更新 content:
+
+```js
+regenerateSection(params).then(function(res) {
+  self.loading = false
+  if (res && res.code === 200 && res.data) {
+    self.sections[key].content = res.data.content || res.data
+    uni.showToast({ title: '生成成功', icon: 'success' })
+  } else {
+    uni.showToast({ title: res && res.message ? res.message : '生成失败', icon: 'none' })
+  }
+})
+```
+
+修改为同时更新 tasks(重生成接口返回 `{content, tasks}`):
+
+```js
+regenerateSection(params).then(function(res) {
+  self.loading = false
+  if (res && res.code === 200 && res.data) {
+    self.sections[key].content = res.data.content || res.data
+    if (res.data.tasks) {
+      self.sections[key].tasks = res.data.tasks
+    }
+    uni.showToast({ title: '生成成功', icon: 'success' })
+  } else {
+    uni.showToast({ title: res && res.message ? res.message : '生成失败', icon: 'none' })
+  }
+})
+```
+
+**注意**:`sections[key]` 对象初始定义于 `data()`(约 400 行 `sections: { nutrition: {...}, diet: {...}, exercise: {...} }`)与 `loadHealthPlan`(761-765 行,只回填 content/items)。`tasks` 字段通过动态赋值加入 section 对象,无需预先声明。**禁止在小程序模板中使用可选链 `?.` 访问 tasks**(遵循 cfc-frontend/AGENTS.md 规范)。
+
+- [ ] **步骤 3:运行验证(HBuilderX 手动打包)**
+
+**注意:** 小程序禁止 Agent 自行 `npm run build:mp-weixin`。此改动需在 HBuilderX 中重新打包验证。计划仅做代码改动,打包验证列入手工验收项。
+
+- [ ] **步骤 4:Commit**
+
+```bash
+git add cfc-frontend/pages/health/health-plan-summary.vue
+git commit -m "fix(frontend): 健康方案 submitPlan 补 tasks 字段 + regenerate 更新 tasks"
+```
+
+---
+
+## 任务 9:更新 PROJECT-OVERVIEW.md 并提交
+
+**文件:**
+- 修改:`docs/superpowers/PROJECT-OVERVIEW.md`
+
+- [ ] **步骤 1:登记实现计划**
+
+按 `docs/superpowers/AGENTS.md` 规范,在 PROJECT-OVERVIEW.md 中更新设计文档状态并登记计划。找到 `2026-08-28-plan-standard-content-design.md` 条目,将状态 `🟡 设计已确认` 更新为 `🟢 已实施`;在 plans 索引区登记本计划。
+
+- [ ] **步骤 2:Commit**
+
+```bash
+git add docs/superpowers/PROJECT-OVERVIEW.md
+git commit -m "docs: 登记生成方案标准内容格式实现计划"
+```
+
+---
+
+## 手工验收
+
+| # | 验收点 | 操作 |
+|---|--------|------|
+| 1 | 新方案 AI 生成含 tasks | 家长端生成方案 → 检查 plan_json.sections[].tasks 非空 → 发布 → 任务正确生成且维度匹配 |
+| 2 | 存量无 tasks 老 planJson | 找一条无 tasks 的历史方案 → 首次发布/确认 → 自动回填 → 任务正常 |
+| 3 | 规划师编辑任务条目 | cfc-web 打开编辑 → 增删改任务 → 保存 → 重新发布反映改动 |
+| 4 | LLM 漏输出 tasks | 构造缺 tasks 的响应 → 正则兜底仍能生成任务 |
+| 5 | 小程序验证 | HBuilderX 打包 → 生成方案 → 确认 → 查看任务 |
+
+## 验证命令汇总
+
+| 模块 | 命令 |
+|---|---|
+| LangGraph | `cd cfc-langgraph && python -m pytest tests/test_health_plan.py -v` |
+| 后端 | `cd cfc-backend && mvn clean compile`(+ 可选 `mvn test`) |
+| cfc-web | `cd cfc-web && npm run build` |
+| 小程序 | HBuilderX 手动打包(禁止 Agent 自行打包) |

+ 172 - 0
docs/superpowers/specs/2026-08-28-plan-standard-content-design.md

@@ -0,0 +1,172 @@
+# 生成方案标准内容格式设计
+
+**日期:** 2026-08-28
+**状态:** 待审查
+**优先级:** P1
+
+## 一、背景与问题
+
+现有健康方案生成链路存在三处脱节:
+
+1. **AI 返回侧**:`cfc-langgraph/app/api/adapter.py` 的 `PLAN_SYSTEM_PROMPT` 已要求 LLM 输出结构化 JSON(`overview` + `sections[].content/items` + `abnormal_indicators`),但 JSON 解析靠 `find("{")`/`rfind("}")` 手动截取,无 Pydantic 校验,失败时静默降级为 raw 文本——前端需同时兼容两种形态。
+
+2. **呈现/编辑侧**:`cfc-frontend/pages/health/health-plan-summary.vue` 已支持分段确认 + 反馈式重生成,但 `submitPlan` 组装 `planJson` 时**漏写 `sections[].tasks`**(当前 schema 根本没有该字段),导致保存时丢失任务意图。规划师端 `HealthPlanReview.vue` 仅提供 `planContent` 纯文本编辑,无结构化任务条目 UI。
+
+3. **任务转化侧**:`HealthPlanServiceImpl.generateDailyTasksFromPlan` 用正则(`TASK_PATTERN` + 动作词分类)从 `plan_content` 纯文本猜测任务行,可靠性建立在"LLM 输出恰好符合正则预期"上。结构化的 `plan_json` 在此链路完全未被消费。前后端各维护一份重复正则(`TASK_PATTERN` 与前端 `parseTasksFromPlan`)。
+
+**目标:** 让 `plan_json.sections[].tasks` 成为任务生成的唯一事实源,AI 有标准格式返回、用户看到标准格式可编辑、结果确定性地转化为任务。
+
+## 二、方案选型(用户已确认)
+
+采用**方案 A**:
+
+- LangGraph prompt 输出 `tasks` 数组 + Pydantic 校验
+- 校验失败时用现有正则从 content 兜底提取,仍写入 `tasks`
+- 任务生成优先读 `planJson.sections[].tasks`,缺失才回落正则
+- 存量方案用**懒加载回填**统一(每次生成任务时若发现无 tasks 则即时用正则回填并落库)
+
+不做方案 B(完全移除正则,容错低)或方案 C(仅前端展示,未解决可靠性)。
+
+## 三、数据契约
+
+在现有 `sections[].items` 基础上,为每个 section 增加 `tasks` 数组(机器可读任务意图),与 `content`(人类可读 Markdown)分离:
+
+```json
+{
+  "overview": "总体概述",
+  "sections": [
+    {
+      "key": "nutrition",
+      "title": "营养补充建议",
+      "content": "## 建议 1...(Markdown 展示文本)",
+      "items": [{"name","dosage","timing","reason"}],
+      "tasks": [
+        {
+          "action_type": "buy",
+          "title": "购买维生素D3补充剂",
+          "dimension": "wealth",
+          "frequency": "once",
+          "notes": "每日一粒,随餐服用"
+        }
+      ]
+    }
+  ],
+  "abnormal_indicators": []
+}
+```
+
+### 字段约定
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `action_type` | string 枚举 | `buy`/`read`/`exercise`/`checkin`/`diet`/`activity`,决定任务类别与后处理 |
+| `title` | string | 任务标题(最终写入 Task.title) |
+| `dimension` | string 枚举 | 五维维度 `body`/`mind`/`wisdom`/`action`/`wealth` |
+| `frequency` | string | `once`=一次性 / `daily`=每日重复 |
+| `notes` | string? | 补充说明(可选) |
+
+### action_type 与现有分类映射
+
+| action_type | 现有 TaskDraft 分类 | 维度 | 特殊后处理 |
+|---|---|---|---|
+| `buy` | 购买任务 | wealth | 派生"每日使用/服用"子任务(parentTaskId) |
+| `read` | 阅读任务 | wisdom | — |
+| `exercise` | 运动任务 | body | — |
+| `checkin` | 打卡任务 | mind | — |
+| `diet` | 饮食任务 | body | — |
+| `activity` | 活动任务 | action | — |
+
+## 四、LangGraph 层改造
+
+**文件:** `cfc-langgraph/app/api/adapter.py`、`cfc-langgraph/app/schemas.py`
+
+1. **`PLAN_SYSTEM_PROMPT` 追加 tasks 输出契约**:在每个 section 内强制输出 `tasks` 数组,明确枚举取值与 frequency 语义。
+
+2. **新增 Pydantic 模型**(`app/schemas.py`):
+
+```python
+from typing import List, Optional, Literal
+from pydantic import BaseModel
+
+class PlanTask(BaseModel):
+    action_type: Literal["buy","read","exercise","checkin","diet","activity"]
+    title: str
+    dimension: Literal["body","mind","wisdom","action","wealth"]
+    frequency: Literal["once","daily"] = "daily"
+    notes: Optional[str] = None
+
+class PlanSection(BaseModel):
+    key: str
+    title: str = ""
+    content: str = ""
+    items: List[dict] = []
+    tasks: List[PlanTask] = []
+
+class PlanResponse(BaseModel):
+    overview: str = ""
+    sections: List[PlanSection] = []
+    abnormal_indicators: List[dict] = []
+```
+
+3. **生成逻辑**:LLM 调用用结构化输出(`response_format={"type":"json_object"}`),随后 `PlanResponse.model_validate_json()` 校验;**校验失败 → 触发一次正则兜底重新提取 + 二次校验**,仍失败则降级(raw 文本 + 前端兼容)。
+
+4. **`regenerate-section`**:反馈重生成改为返回 `{content, tasks}`,前端同步更新,不再只回传纯字符串。
+
+## 五、Java 层改造
+
+**文件:** `HealthPlanServiceImpl.java`、`DatabaseInitializer.java`、`schema.sql`
+
+### 5.1 任务生成优先读 tasks
+
+`generateDailyTasksFromPlan` 新逻辑:
+1. **优先**:遍历 `plan_json.sections[].tasks[]`,按 `action_type` 直接映射现有 `TaskDraft` 分类。
+2. **兜底**:该 section 无 `tasks` 数组(老数据/LLM 漏输出)→ 回落现有 `TASK_PATTERN` 正则从 `content` 提取。
+3. `TaskDraft` 的 dimension/frequency 直接取自 `tasks[].dimension`/`tasks[].frequency`,不再依赖正则猜维度。
+
+### 5.2 存量方案懒加载回填(用户确认)
+
+在 `generateDailyTasksFromPlan` 开头:
+- 若 `plan_json` 中该 section 无 `tasks` 数组 → 用现有正则(`TASK_PATTERN` + 动作分类)从 `content` 提取任务,写入 `plan_json.sections[].tasks`,**落库**(`updateById`)。
+- 幂等:已回填的 section 跳过;重复调用不产生重复任务。
+
+无需预跑全量脚本,零运维成本;存量方案在首次发布/确认时自动统一。
+
+## 六、前端改造
+
+**文件:** `cfc-frontend/pages/health/health-plan-summary.vue`、`cfc-web/src/views/teacher/HealthPlanReview.vue`
+
+### 6.1 家长端(只读展示 + 反馈式重生成)
+
+- **修复 bug**:`submitPlan` 组装 `planJson` 时补上 `sections[].tasks`(当前只写 overview+sections,漏 tasks)。
+- 展示无 tasks 时降级 review 模式(兼容)。
+- 反馈式重生成保留;regenerate 返回 tasks 时前端同步更新。
+
+### 6.2 规划师端(结构化任务条目编辑)
+
+- `HealthPlanReview.vue` 当前只有 `planContent` 纯文本编辑,无 tasks UI → **新增任务条目级增删改**(action_type/title/dimension/frequency/notes)。
+- 任务编辑直接改 `plan_json.sections[].tasks`,保存走现有 `pending-review/update` 接口(已是透传 planJson)。
+
+## 七、测试与验证
+
+### 单元测试
+- **LangGraph**:`PlanResponse` 校验(合法/非法 action_type、frequency 越界)、正则兜底回填。
+- **Java**:`generateDailyTasksFromPlan` 覆盖"有 tasks(优先走 tasks)"与"无 tasks(回落正则)"两分支;`TaskDraft` 映射正确性(buy→wealth+派生使用子任务等);懒加载回填幂等性。
+
+### 验证命令
+| 模块 | 命令 |
+|---|---|
+| 后端 | `cd cfc-backend && mvn clean compile` + 针对性单测 |
+| LangGraph | `cd cfc-langgraph && pytest tests/ -v` |
+| cfc-web | `cd cfc-web && npm run build` |
+
+### 手工验收
+1. 新方案 AI 生成含 tasks → 发布 → 任务正确生成且维度匹配。
+2. 存量无 tasks 老 planJson → 首次发布/确认自动回填 → 任务正常。
+3. 规划师 cfc-web 编辑任务条目 → 保存 → 重新发布反映改动。
+4. LLM 漏输出 tasks → 正则兜底仍能生成任务。
+
+## 八、边界与注意事项
+
+- 正则 `TASK_PATTERN` 短期保留作兜底,不退役;等所有活跃方案走新格式后再评估退役。
+- 发现 `getPlanList` 中残留 `SortUtil.applySort` 调用(之前 revert 掉的分页+排序残留)——**不在本设计范围内**,仅提示后续清理。
+- 前端小程序需在 HBuilderX 重新打包验证(禁止 Agent 自行 `npm run build:mp-weixin`)。