Procházet zdrojové kódy

feat(feedback): 报告反馈合并快速分析 - AI解答+结果可查

- 三处报告页(report-detail/report-confirm/gut-flora-detail)统一替换报告快速分析为报告反馈弹窗
- feedback-popup 重写:疑问/建议双类型 + AI 解答内联展示 + 免费版每日1次提示
- 新增 my-feedback 页:我的反馈记录列表(含 AI 解答状态)
- 后端 report_feedback 表(迁移327) + submit/list 接口 + AiGateway 解答,失败落库未失败状态
- 移除 reportQuickAnalyze 前端调用,API_REFERENCE 同步 4.37 节
liaoxg před 1 dnem
rodič
revize
29cf1e4ea6

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -10892,6 +10892,28 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         createUserShortcutConfigTable();
         createUserAppUsageTable();
 
+        // 迁移327: 创建 report_feedback 表(报告反馈+AI解答)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS report_feedback (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "user_id BIGINT NOT NULL COMMENT '提交人 users.id', " +
+                    "member_id BIGINT DEFAULT NULL COMMENT '关联家庭成员 family_members.id', " +
+                    "report_id BIGINT DEFAULT NULL COMMENT '健康报告ID', " +
+                    "draft_id BIGINT DEFAULT NULL COMMENT '草稿ID', " +
+                    "feedback_type VARCHAR(20) DEFAULT 'question' COMMENT 'question=疑问 / suggestion=建议', " +
+                    "content VARCHAR(500) NOT NULL COMMENT '反馈内容', " +
+                    "ai_answer TEXT COMMENT 'AI 解答', " +
+                    "ai_status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/answered/failed', " +
+                    "conversation_id VARCHAR(64) DEFAULT NULL COMMENT 'AI 会话ID(付费多轮)', " +
+                    "created_at DATETIME NOT NULL COMMENT '创建时间', " +
+                    "INDEX idx_rf_user (user_id, created_at), " +
+                    "INDEX idx_rf_report (report_id) " +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告反馈与AI解答'");
+            log.info("迁移327: 已创建 report_feedback 表");
+        } catch (Exception e) {
+            // 表已存在,忽略错误
+        }
+
         // 迁移313: 修复 meal_configs 历史双编码 participant_member_ids(共餐配置前端曾 JSON.stringify 二次编码,存成了 "\"[1,2]\"",
         //          导致再次进入页面时无法识别选中成员)。仅处理以双引号包裹的值,不触碰正常 "[]..." 格式
         try {

+ 21 - 14
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -174,6 +174,9 @@ public class HealthReportController {
     @Resource
     private com.etotem.cfc.service.AiGateway aiGateway;
 
+    @Resource
+    private com.etotem.cfc.service.ReportFeedbackService reportFeedbackService;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -2207,23 +2210,27 @@ public class HealthReportController {
     }
 
     /**
-     * 提交报告反馈(v1.1:日志记录,不入库
+     * 提交报告反馈(持久化 + AI 解答
      */
-    @Operation(summary = "提交报告反馈")
+    @Operation(summary = "提交报告反馈(持久化 + AI 解答)")
     @PostMapping("/feedback/submit")
-    public Result<String> submitFeedback(@RequestBody Map<String, Object> params) {
-        Object contentObj = params.get("content");
-        if (contentObj == null || contentObj.toString().trim().isEmpty()) {
-            return Result.error("反馈内容不能为空");
-        }
+    public Result<?> submitFeedback(@RequestBody Map<String, Object> params,
+                                    @RequestAttribute("userId") Long userId,
+                                    @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
         Long reportId = ParamUtils.getLong(params.get("reportId"));
-        String type = params.get("type") != null ? params.get("type").toString() : "其他";
-        String content = contentObj.toString().trim();
-        if (content.length() > 500) {
-            return Result.error("反馈内容不能超过500字");
-        }
-        log.info("用户报告反馈 - reportId: {}, type: {}, content: {}", reportId, type, content);
-        return Result.success("感谢您的反馈");
+        Long draftId = ParamUtils.getLong(params.get("draftId"));
+        Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
+        String type = params.get("type") != null ? params.get("type").toString() : null;
+        String content = params.get("content") != null ? params.get("content").toString() : null;
+        return reportFeedbackService.submit(userId, memberId, reportId, draftId, type, content);
+    }
+
+    @Operation(summary = "我的报告反馈列表(含 AI 解答)")
+    @PostMapping("/feedback/list")
+    public Result<?> listFeedback(@RequestAttribute("userId") Long userId,
+                                  @RequestBody(required = false) Map<String, Object> params) {
+        Long reportId = (params != null) ? ParamUtils.getLong(params.get("reportId")) : null;
+        return reportFeedbackService.listMine(userId, reportId);
     }
 
     /**

+ 47 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/ReportFeedback.java

@@ -0,0 +1,47 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import java.util.Date;
+
+@TableName("report_feedback")
+public class ReportFeedback {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+    private Long userId;
+    private Long memberId;
+    private Long reportId;
+    private Long draftId;
+    private String feedbackType;
+    private String content;
+    private String aiAnswer;
+    private String aiStatus;
+    private String conversationId;
+    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
+    private Date createdAt;
+
+    public Long getId() { return id; }
+    public void setId(Long id) { this.id = id; }
+    public Long getUserId() { return userId; }
+    public void setUserId(Long userId) { this.userId = userId; }
+    public Long getMemberId() { return memberId; }
+    public void setMemberId(Long memberId) { this.memberId = memberId; }
+    public Long getReportId() { return reportId; }
+    public void setReportId(Long reportId) { this.reportId = reportId; }
+    public Long getDraftId() { return draftId; }
+    public void setDraftId(Long draftId) { this.draftId = draftId; }
+    public String getFeedbackType() { return feedbackType; }
+    public void setFeedbackType(String feedbackType) { this.feedbackType = feedbackType; }
+    public String getContent() { return content; }
+    public void setContent(String content) { this.content = content; }
+    public String getAiAnswer() { return aiAnswer; }
+    public void setAiAnswer(String aiAnswer) { this.aiAnswer = aiAnswer; }
+    public String getAiStatus() { return aiStatus; }
+    public void setAiStatus(String aiStatus) { this.aiStatus = aiStatus; }
+    public String getConversationId() { return conversationId; }
+    public void setConversationId(String conversationId) { this.conversationId = conversationId; }
+    public Date getCreatedAt() { return createdAt; }
+    public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/ReportFeedbackMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.ReportFeedback;
+
+public interface ReportFeedbackMapper extends BaseMapper<ReportFeedback> {
+}

+ 126 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ReportFeedbackService.java

@@ -0,0 +1,126 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ReportFeedback;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ReportFeedbackMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class ReportFeedbackService {
+
+    private static final Logger log = LoggerFactory.getLogger(ReportFeedbackService.class);
+
+    @Resource
+    private ReportFeedbackMapper reportFeedbackMapper;
+
+    @Resource
+    private AiGateway aiGateway;
+
+    @Resource
+    private FamilyContextService familyContextService;
+
+    @Resource
+    private UserService userService;
+
+    @Resource
+    private RedisTemplate<String, Object> redisTemplate;
+
+    /**
+     * 提交报告反馈(持久化 + AI 解答)。
+     * 免费版每日 1 次,付费版不限/多轮(沿用报告快速分析策略)。
+     */
+    public Result<?> submit(Long userId, Long memberId, Long reportId, Long draftId, String type, String content) {
+        if (content == null || content.trim().isEmpty()) {
+            return Result.error("反馈内容不能为空");
+        }
+        content = content.trim();
+        if (content.length() > 500) {
+            return Result.error("反馈内容不能超过500字");
+        }
+        // 归一化类型:question=疑问 / suggestion=建议,其他默认 question
+        String normalizedType = "suggestion".equals(type) ? "suggestion" : "question";
+
+        // 1. 会员级别检查
+        User user = userService.getUserInfo(userId);
+        String memberLevel = user != null ? user.getMemberLevel() : "FREE";
+        boolean isFree = memberLevel == null || "FREE".equals(memberLevel);
+
+        // 2. 免费版 Redis 计数(每日 1 次;Redis 异常时 fail-open 放行)
+        if (isFree) {
+            try {
+                String key = "report:feedback:free:" + userId + ":" + LocalDate.now();
+                Long count = redisTemplate.opsForValue().increment(key);
+                if (count != null && count == 1L) {
+                    redisTemplate.expire(key, Duration.ofDays(1));
+                }
+                if (count != null && count > 1L) {
+                    return Result.error("免费版每日仅限 1 次反馈解答,升级会员解锁更多");
+                }
+            } catch (Exception e) {
+                log.warn("Redis 计数失败,放行: {}", e.getMessage());
+            }
+        }
+
+        // 3. 组装 AI 上下文(指定报告/草稿时注入对应数据)
+        Map<String, Object> inputs;
+        if (reportId != null) {
+            inputs = familyContextService.buildContext(userId, reportId);
+        } else if (draftId != null) {
+            inputs = familyContextService.buildContextFromDraft(userId, draftId);
+        } else {
+            inputs = familyContextService.buildContext(userId);
+        }
+
+        // 4. 组装提问(单轮问答;conversationId 留待后续多轮扩展)
+        String query = "【" + ("suggestion".equals(normalizedType) ? "建议" : "疑问") + "】" + content;
+
+        // 5. 调 LangGraph(经 AiGateway,统一熔断/配额/脱敏)
+        Map<String, Object> resp = aiGateway.chat(query, userId, null, inputs);
+
+        // 6. 组装记录(AI 失败也落库,ai_status=failed)
+        ReportFeedback record = new ReportFeedback();
+        record.setUserId(userId);
+        record.setMemberId(memberId);
+        record.setReportId(reportId);
+        record.setDraftId(draftId);
+        record.setFeedbackType(normalizedType);
+        record.setContent(content);
+        if (resp == null) {
+            record.setAiStatus("failed");
+            record.setAiAnswer(null);
+        } else {
+            record.setAiStatus("answered");
+            record.setAiAnswer((String) resp.get("answer"));
+            record.setConversationId((String) resp.get("conversationId"));
+        }
+        record.setCreatedAt(new Date());
+        reportFeedbackMapper.insert(record);
+        return Result.success(record);
+    }
+
+    /**
+     * 我的报告反馈列表(含 AI 解答),按创建时间倒序,最多 50 条。
+     */
+    public Result<?> listMine(Long userId, Long reportId) {
+        QueryWrapper<ReportFeedback> wrapper = new QueryWrapper<>();
+        wrapper.eq("user_id", userId);
+        if (reportId != null) {
+            wrapper.eq("report_id", reportId);
+        }
+        wrapper.orderByDesc("created_at").last("LIMIT 50");
+        List<ReportFeedback> list = reportFeedbackMapper.selectList(wrapper);
+        return Result.success(list);
+    }
+}

+ 17 - 0
cfc-backend/src/main/resources/schema.sql

@@ -5855,3 +5855,20 @@ CREATE TABLE IF NOT EXISTS `task_orchestration_node_instances` (
   KEY `idx_execution_id` (`execution_id`),
   KEY `idx_task_id` (`task_id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务编排流节点实例';
+-- 报告反馈与AI解答(迁移327)
+-- ============================================================
+CREATE TABLE IF NOT EXISTS report_feedback (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    user_id BIGINT NOT NULL COMMENT '提交人 users.id',
+    member_id BIGINT DEFAULT NULL COMMENT '关联家庭成员 family_members.id',
+    report_id BIGINT DEFAULT NULL COMMENT '健康报告ID',
+    draft_id BIGINT DEFAULT NULL COMMENT '草稿ID',
+    feedback_type VARCHAR(20) DEFAULT 'question' COMMENT 'question=疑问 / suggestion=建议',
+    content VARCHAR(500) NOT NULL COMMENT '反馈内容',
+    ai_answer TEXT COMMENT 'AI 解答',
+    ai_status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/answered/failed',
+    conversation_id VARCHAR(64) DEFAULT NULL COMMENT 'AI 会话ID(付费多轮)',
+    created_at DATETIME NOT NULL COMMENT '创建时间',
+    INDEX idx_rf_user (user_id, created_at),
+    INDEX idx_rf_report (report_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='报告反馈与AI解答';

+ 167 - 21
cfc-frontend/components/feedback-popup.vue

@@ -5,17 +5,43 @@
         <text class="fb-title">报告反馈</text>
         <text class="fb-close" @tap="close">×</text>
       </view>
-      <textarea class="fb-textarea" v-model="content" placeholder="请描述您的问题或建议..." maxlength="500" />
-      <picker :value="typeIndex" :range="types" @change="onTypeChange">
-        <view class="fb-picker">
-          <text>{{ types[typeIndex] }}</text>
-          <text class="fb-arrow">▼</text>
+
+      <!-- 结果态:已提交,展示解答 -->
+      <block v-if="submitted">
+        <view class="fb-quote">
+          <text class="fb-quote-label">{{ typeLabel }}</text>
+          <text class="fb-quote-text">{{ submittedContent }}</text>
         </view>
-      </picker>
-      <view class="fb-actions">
-        <view class="fb-btn fb-btn-cancel" @tap="close">取消</view>
-        <view class="fb-btn fb-btn-submit" @tap="submit">提交反馈</view>
-      </view>
+        <view class="fb-answer">
+          <text class="fb-answer-label">解答</text>
+          <view class="fb-answer-loading" v-if="aiStatus === 'pending'">
+            <text class="fb-answer-loading-text">正在生成解答...</text>
+          </view>
+          <text class="fb-answer-text" v-else-if="aiStatus === 'answered'">{{ aiAnswer }}</text>
+          <text class="fb-answer-text fb-answer-failed" v-else>解答暂不可用,可稍后在「我的反馈」中查看</text>
+        </view>
+        <view class="fb-note">解答由智能助手生成,仅供参考,不构成医疗建议</view>
+        <view class="fb-actions">
+          <view class="fb-btn fb-btn-cancel" @tap="goMyFeedback">我的反馈</view>
+          <view class="fb-btn fb-btn-submit" @tap="close">完成</view>
+        </view>
+      </block>
+
+      <!-- 填写态 -->
+      <block v-else>
+        <textarea class="fb-textarea" v-model="content" placeholder="请描述您对该报告的疑问或建议..." maxlength="500" />
+        <picker :value="typeIndex" :range="typeLabels" @change="onTypeChange">
+          <view class="fb-picker">
+            <text>{{ typeLabels[typeIndex] }}</text>
+            <text class="fb-arrow">▼</text>
+          </view>
+        </picker>
+        <view class="fb-hint" v-if="quotaHint">{{ quotaHint }}</view>
+        <view class="fb-actions">
+          <view class="fb-btn fb-btn-cancel" @tap="close">取消</view>
+          <view class="fb-btn fb-btn-submit" :class="{ 'fb-btn-disabled': submitting }" @tap="submit">{{ submitText }}</view>
+        </view>
+      </block>
     </view>
   </view>
 </template>
@@ -26,14 +52,57 @@ import { submitFeedback } from '../utils/api.js'
 export default {
   props: {
     visible: Boolean,
-    reportId: Number
+    reportId: Number,
+    draftId: Number
   },
   data: function() {
     return {
       content: '',
-      types: ['功能建议', '数据疑问', '内容错误', '其他'],
+      types: [
+        { label: '疑问', value: 'question' },
+        { label: '建议', value: 'suggestion' }
+      ],
       typeIndex: 0,
-      submitting: false
+      submitting: false,
+      // 提交后结果态
+      submitted: false,
+      submittedContent: '',
+      submittedType: '',
+      aiStatus: '',
+      aiAnswer: '',
+      quotaHint: ''
+    }
+  },
+  computed: {
+    typeLabels: function() {
+      var arr = []
+      for (var i = 0; i < this.types.length; i++) {
+        arr.push(this.types[i].label)
+      }
+      return arr
+    },
+    typeLabel: function() {
+      for (var i = 0; i < this.types.length; i++) {
+        if (this.types[i].value === this.submittedType) return this.types[i].label
+      }
+      return '反馈'
+    },
+    submitText: function() {
+      return this.submitting ? '提交中...' : '提交'
+    }
+  },
+  watch: {
+    visible: function(val) {
+      if (val) {
+        // 每次打开重置为填写态
+        this.submitted = false
+        this.submittedContent = ''
+        this.submittedType = ''
+        this.aiStatus = ''
+        this.aiAnswer = ''
+        this.quotaHint = ''
+        this.submitting = false
+      }
     }
   },
   methods: {
@@ -43,27 +112,42 @@ export default {
     close: function() {
       this.$emit('close')
     },
+    goMyFeedback: function() {
+      this.close()
+      uni.navigateTo({ url: '/pages/health/my-feedback' })
+    },
     submit: function() {
       if (!this.content.trim()) {
         uni.showToast({ title: '请填写内容', icon: 'none' })
         return
       }
       if (this.submitting) return
-      this.submitting = true
       var self = this
+      var typeValue = this.types[this.typeIndex].value
+      var text = this.content.trim()
+      this.submitting = true
       var payload = {
-        reportId: this.reportId,
-        type: this.types[this.typeIndex],
-        content: this.content.trim()
+        type: typeValue,
+        content: text
       }
+      if (this.reportId) payload.reportId = this.reportId
+      if (this.draftId) payload.draftId = this.draftId
       submitFeedback(payload).then(function(res) {
-        if (res.code === 200) {
-          uni.showToast({ title: '感谢反馈', icon: 'success' })
+        if (res.code === 200 && res.data) {
+          self.submitted = true
+          self.submittedContent = res.data.content || text
+          self.submittedType = res.data.feedbackType || typeValue
+          self.aiStatus = res.data.aiStatus || 'answered'
+          self.aiAnswer = res.data.aiAnswer || ''
           self.content = ''
           self.typeIndex = 0
-          self.close()
         } else {
-          uni.showToast({ title: res.message || '提交失败', icon: 'none' })
+          var msg = res.message || '提交失败'
+          if (msg.indexOf('每日仅限') !== -1) {
+            self.quotaHint = msg
+          } else {
+            uni.showToast({ title: msg, icon: 'none' })
+          }
         }
       }).catch(function() {
         uni.showToast({ title: '提交失败', icon: 'none' })
@@ -135,6 +219,12 @@ export default {
   color: #999;
   font-size: 22rpx;
 }
+.fb-hint {
+  font-size: 24rpx;
+  color: #E6A23C;
+  margin-bottom: 16rpx;
+  line-height: 1.5;
+}
 .fb-actions {
   display: flex;
 }
@@ -154,4 +244,60 @@ export default {
   background: #4A9BD7;
   color: #fff;
 }
+.fb-btn-disabled {
+  opacity: 0.6;
+}
+/* ===== 结果态 ===== */
+.fb-quote {
+  background: #F5FAFE;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  margin-bottom: 20rpx;
+}
+.fb-quote-label {
+  display: block;
+  font-size: 24rpx;
+  color: #4A9BD7;
+  margin-bottom: 8rpx;
+}
+.fb-quote-text {
+  font-size: 26rpx;
+  color: #333;
+  line-height: 1.6;
+  word-break: break-all;
+}
+.fb-answer {
+  border: 1rpx solid #eee;
+  border-radius: 12rpx;
+  padding: 20rpx;
+  margin-bottom: 16rpx;
+}
+.fb-answer-label {
+  display: block;
+  font-size: 24rpx;
+  color: #10B981;
+  margin-bottom: 10rpx;
+}
+.fb-answer-text {
+  font-size: 26rpx;
+  color: #333;
+  line-height: 1.7;
+  word-break: break-all;
+}
+.fb-answer-failed {
+  color: #999;
+}
+.fb-answer-loading {
+  padding: 8rpx 0;
+}
+.fb-answer-loading-text {
+  font-size: 26rpx;
+  color: #999;
+}
+.fb-note {
+  font-size: 22rpx;
+  color: #bbb;
+  line-height: 1.5;
+  margin-bottom: 20rpx;
+}
 </style>

+ 7 - 0
cfc-frontend/pages.json

@@ -1065,6 +1065,13 @@
           "style": {
             "navigationBarTitleText": "报告详情"
           }
+        },
+        {
+          "path": "my-feedback",
+          "style": {
+            "navigationBarTitleText": "我的反馈",
+            "enablePullDownRefresh": true
+          }
         }
       ]
     },

+ 7 - 209
cfc-frontend/pages/health/gut-flora-detail.vue

@@ -219,7 +219,7 @@
       </view>
 
 
-      <view class="feedback-section" @tap="openQuickAnalyze">
+      <view class="feedback-section" @tap="openFeedback">
         <text class="feedback-icon">💬</text>
         <text class="feedback-text">对这个报告有疑问或建议?告诉我们</text>
         <text class="feedback-arrow">›</text>
@@ -228,42 +228,9 @@
       <view class="bottom-spacer"></view>
     </scroll-view>
 
-    <!-- 快速分析浮层(由「有疑问或建议」入口唤起) -->
-    <view class="quick-analyze-overlay" v-if="quickAnalyze.show" @tap="closeQuickAnalyze"></view>
-    <view class="quick-analyze-panel" v-if="quickAnalyze.show">
-      <view class="quick-analyze-header">
-        <text class="quick-analyze-title">报告快速分析</text>
-        <view class="quick-analyze-header-right">
-          <text class="quick-analyze-feedback" @tap="openFeedbackFromAnalyze">提交反馈</text>
-          <text class="quick-analyze-close" @tap="closeQuickAnalyze">✕</text>
-        </view>
-      </view>
-      <view class="quick-analyze-hint" v-if="quickAnalyze.usedToday">免费版今日次数已用完,升级会员解锁更多分析</view>
-      <scroll-view class="quick-analyze-messages" scroll-y>
-        <view class="quick-analyze-message" v-for="(msg, qidx) in quickAnalyze.messages" :key="qidx" :class="'msg-' + msg.role">
-          <text class="quick-analyze-msg-text">{{ msg.content }}</text>
-        </view>
-        <view class="quick-analyze-loading" v-if="quickAnalyze.sending">
-          <text class="quick-analyze-loading-text">AI 正在分析中...</text>
-        </view>
-      </scroll-view>
-      <view class="quick-analyze-chips" v-if="quickAnalyze.messages.length === 0">
-        <view class="quick-analyze-chip" v-for="(q, qi) in quickQuestions" :key="qi" @tap="sendQuickQuestion(q)">
-          <text class="quick-analyze-chip-text">{{ q }}</text>
-        </view>
-      </view>
-      <view class="quick-analyze-input-bar">
-        <input class="quick-analyze-input" v-model="quickAnalyze.inputText" placeholder="输入你想问的问题..."
-          :disabled="quickAnalyze.sending || quickAnalyze.usedToday" confirm-type="send" @confirm="sendQuickAnalyze" />
-        <view class="quick-analyze-send" :class="{ 'send-disabled': quickAnalyze.sending || quickAnalyze.usedToday }" @tap="sendQuickAnalyze">
-          <text class="quick-analyze-send-text">发送</text>
-        </view>
-      </view>
-    </view>
-
     <health-knowledge-popup :visible="knowledgeVisible" :data="knowledgeData" @close="closeKnowledge" />
 
-    <feedback-popup :visible="feedbackVisible" :report-id="reportId" @close="closeFeedback" />
+    <feedback-popup :visible="feedbackVisible" :report-id="reportId" :draft-id="draftId" @close="closeFeedback" />
 
     <view class="fab" v-if="!isEditing && !isDraftMode" @tap="toggleEdit">
       <text class="fab-icon">✏️</text>
@@ -365,7 +332,7 @@
 </template>
 
 <script>
-import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis, confirmReportPreview, discardReportDraft, parseReportDraft, getHealthFoods, getFoodCautionList, getFamilyMemberList, addFamilyMember, reportQuickAnalyze, getMyMembership } from '../../utils/api.js'
+import { getReportDetail, editHealthReport, queryKnowledgeBase, queryIndicatorKnowledge, getGutFloraAnalysis, confirmReportPreview, discardReportDraft, parseReportDraft, getHealthFoods, getFoodCautionList, getFamilyMemberList, addFamilyMember } from '../../utils/api.js'
 import HealthKnowledgePopup from '../../components/health-knowledge-popup.vue'
 import FeedbackPopup from '../../components/feedback-popup.vue'
 import { parseDate } from '../../utils/format.js'
@@ -431,16 +398,7 @@ export default {
       candidates: [],
       extractedName: '',
       extractedGender: '',
-      extractedAge: '',
-      quickAnalyze: {
-        show: false,
-        sending: false,
-        messages: [],
-        inputText: '',
-        conversationId: '',
-        usedToday: false,
-        checkingMembership: false
-      }
+      extractedAge: ''
     }
   },
   computed: {
@@ -516,13 +474,6 @@ riskGroupAbnormalCount: function() {
       if (this.selectedMemberObj) return this.selectedMemberObj.nickname || this.selectedMemberObj.name || '已选择'
       if (this.extractedName) return this.extractedName + '(待绑定)'
       return '未选择'
-    },
-    quickQuestions: function() {
-      return [
-        '这份报告最需要关注哪些异常指标?',
-        '报告中提示了哪些健康风险?',
-        '有哪些改善建议?'
-      ]
     }
   },
   onLoad: function(options) {
@@ -536,55 +487,9 @@ riskGroupAbnormalCount: function() {
     }
   },
   methods: {
-    // ===== 报告快速分析 =====
-    openQuickAnalyze: function() {
-      this.quickAnalyze.show = true
-      var self = this
-      if (self.quickAnalyze.checkingMembership) return
-      self.quickAnalyze.checkingMembership = true
-      getMyMembership().then(function(res) {
-        var level = res.data && res.data.memberLevel
-        if (level && level !== 'FREE') {
-          self.quickAnalyze.usedToday = false
-        }
-      }).catch(function() {}).finally(function() {
-        self.quickAnalyze.checkingMembership = false
-      })
-    },
-    closeQuickAnalyze: function() {
-      this.quickAnalyze.show = false
-    },
-    sendQuickQuestion: function(q) {
-      if (q && q.trim()) {
-        this.quickAnalyze.inputText = q
-        this.sendQuickAnalyze()
-      }
-    },
-    sendQuickAnalyze: function() {
-      var self = this
-      var text = (this.quickAnalyze.inputText || '').trim()
-      if (!text || this.quickAnalyze.sending) return
-      this.quickAnalyze.messages.push({ role: 'user', content: text })
-      this.quickAnalyze.inputText = ''
-      this.quickAnalyze.sending = true
-      reportQuickAnalyze(text, this.reportId, this.quickAnalyze.conversationId, this.draftId).then(function(res) {
-        if (res.code === 200 && res.data) {
-          self.quickAnalyze.messages.push({ role: 'ai', content: res.data.answer || '(无回答)' })
-          if (res.data.conversationId) {
-            self.quickAnalyze.conversationId = res.data.conversationId
-          }
-        } else {
-          var msg = res.message || '分析失败'
-          if (msg.indexOf('每日仅限') !== -1) {
-            self.quickAnalyze.usedToday = true
-          }
-          self.quickAnalyze.messages.push({ role: 'ai', content: msg })
-        }
-      }).catch(function() {
-        self.quickAnalyze.messages.push({ role: 'ai', content: 'AI 服务暂不可用,请稍后重试' })
-      }).finally(function() {
-        self.quickAnalyze.sending = false
-      })
+    // ===== 报告反馈(原「快速分析」入口已合并,反馈由 AI 解答) =====
+    openFeedback: function() {
+      this.feedbackVisible = true
     },
     goBack: function() { uni.navigateBack() },
     /** 状态 → CSS安全英文(兼容中英文,与 report-confirm 保持一致) */
@@ -983,10 +888,6 @@ applyDraftPayload: function(payload) {
     closeFeedback: function() {
       this.feedbackVisible = false
     },
-    openFeedbackFromAnalyze: function() {
-      this.quickAnalyze.show = false
-      this.feedbackVisible = true
-    },
     toggleEdit: function() {
       this.isEditing = true
     },
@@ -1466,107 +1367,4 @@ applyDraftPayload: function(payload) {
   margin-top: 4rpx;
   line-height: 1.5;
 }
-/* ===== 报告快速分析 ===== */
-.quick-analyze-overlay {
-  position: fixed;
-  top: 0; left: 0; right: 0; bottom: 0;
-  background: rgba(0, 0, 0, 0.45);
-  z-index: 98;
-}
-.quick-analyze-panel {
-  position: fixed;
-  left: 30rpx; right: 30rpx;
-  bottom: 40rpx;
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 24rpx;
-  z-index: 99;
-  display: flex;
-  flex-direction: column;
-  max-height: 70vh;
-}
-.quick-analyze-header {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 16rpx;
-}
-.quick-analyze-title { font-size: 30rpx; font-weight: bold; color: #333; }
-.quick-analyze-header-right {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  gap: 16rpx;
-}
-.quick-analyze-feedback {
-  font-size: 24rpx;
-  color: #5B9BD5;
-  padding: 8rpx;
-}
-.quick-analyze-feedback:active { opacity: 0.7; }
-.quick-analyze-close { font-size: 32rpx; color: #999; padding: 8rpx; }
-.quick-analyze-hint {
-  font-size: 24rpx;
-  color: #E65100;
-  background: #FFF3E0;
-  border-radius: 12rpx;
-  padding: 12rpx 16rpx;
-  margin-bottom: 12rpx;
-}
-.quick-analyze-messages {
-  max-height: 50vh;
-  margin-bottom: 16rpx;
-}
-.quick-analyze-message {
-  margin-bottom: 12rpx;
-  display: flex;
-  flex-direction: column;
-}
-.quick-analyze-message.msg-user { align-items: flex-end; }
-.quick-analyze-message.msg-ai { align-items: flex-start; }
-.quick-analyze-msg-text {
-  font-size: 26rpx;
-  color: #333;
-  background: #F3F4F6;
-  border-radius: 14rpx;
-  padding: 14rpx 18rpx;
-  max-width: 80%;
-  line-height: 1.5;
-}
-.msg-user .quick-analyze-msg-text { background: #5B9BD5; color: #fff; }
-.quick-analyze-loading { display: flex; justify-content: center; padding: 12rpx 0; }
-.quick-analyze-loading-text { font-size: 24rpx; color: #999; }
-.quick-analyze-chips { display: flex; flex-direction: column; margin-bottom: 16rpx; }
-.quick-analyze-chip {
-  background: #EFF6FF;
-  border: 1rpx solid #BFDBFE;
-  border-radius: 12rpx;
-  padding: 14rpx 18rpx;
-  margin-bottom: 10rpx;
-}
-.quick-analyze-chip:active { opacity: 0.8; }
-.quick-analyze-chip-text { font-size: 26rpx; color: #1D4ED8; }
-.quick-analyze-input-bar {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-}
-.quick-analyze-input {
-  flex: 1;
-  height: 72rpx;
-  background: #F3F4F6;
-  border-radius: 36rpx;
-  padding: 0 28rpx;
-  font-size: 26rpx;
-  color: #333;
-  margin-right: 16rpx;
-}
-.quick-analyze-send {
-  background: #5B9BD5;
-  border-radius: 36rpx;
-  padding: 16rpx 34rpx;
-}
-.quick-analyze-send.send-disabled { opacity: 0.5; }
-.quick-analyze-send-text { font-size: 26rpx; color: #fff; }
 </style>

+ 191 - 0
cfc-frontend/pages/health/my-feedback.vue

@@ -0,0 +1,191 @@
+<template>
+  <view class="mf-page">
+    <view class="mf-list" v-if="list.length > 0">
+      <view class="mf-item" v-for="(item, idx) in list" :key="item.id">
+        <view class="mf-item-head">
+          <text class="mf-badge" :class="badgeClass(item)">{{ typeText(item) }}</text>
+          <text class="mf-time">{{ formatTime(item.createdAt) }}</text>
+        </view>
+        <text class="mf-content">{{ item.content }}</text>
+        <view class="mf-answer">
+          <text class="mf-answer-label">解答</text>
+          <text class="mf-answer-text" v-if="isAnswered(item)">{{ item.aiAnswer }}</text>
+          <text class="mf-answer-text mf-answer-muted" v-else-if="isPending(item)">解答生成中,请稍后刷新查看</text>
+          <text class="mf-answer-text mf-answer-muted" v-else>解答暂不可用</text>
+        </view>
+      </view>
+    </view>
+
+    <view class="mf-empty" v-else-if="!loading">
+      <text class="mf-empty-icon">💬</text>
+      <text class="mf-empty-text">还没有提交过反馈</text>
+      <text class="mf-empty-hint">在健康报告详情页可以向我们提出疑问或建议</text>
+    </view>
+
+    <view class="mf-loading" v-else>
+      <text class="mf-loading-text">加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getMyReportFeedbacks } from '../../utils/api.js'
+
+export default {
+  data: function() {
+    return {
+      list: [],
+      loading: false,
+      reportId: null
+    }
+  },
+  onLoad: function(options) {
+    if (options && options.reportId) {
+      this.reportId = Number(options.reportId)
+    }
+  },
+  onShow: function() {
+    this.loadList()
+  },
+  onPullDownRefresh: function() {
+    var self = this
+    this.loadList(function() {
+      uni.stopPullDownRefresh()
+    })
+  },
+  methods: {
+    loadList: function(done) {
+      var self = this
+      this.loading = true
+      var payload = {}
+      if (this.reportId) payload.reportId = this.reportId
+      getMyReportFeedbacks(payload).then(function(res) {
+        if (res.code === 200) {
+          self.list = res.data || []
+        } else {
+          uni.showToast({ title: res.message || '加载失败', icon: 'none' })
+        }
+      }).catch(function() {
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      }).then(function() {
+        self.loading = false
+        if (done) done()
+      })
+    },
+    typeText: function(item) {
+      return item.feedbackType === 'suggestion' ? '建议' : '疑问'
+    },
+    badgeClass: function(item) {
+      return item.feedbackType === 'suggestion' ? 'mf-badge-suggestion' : 'mf-badge-question'
+    },
+    isAnswered: function(item) {
+      return item.aiStatus === 'answered' && !!item.aiAnswer
+    },
+    isPending: function(item) {
+      return item.aiStatus === 'pending'
+    },
+    formatTime: function(v) {
+      if (!v) return ''
+      var s = String(v)
+      if (s.length >= 19) return s.substring(0, 19).replace('T', ' ')
+      return s.substring(0, 10)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.mf-page {
+  min-height: 100vh;
+  background: #F7F8FA;
+  padding: 20rpx;
+  box-sizing: border-box;
+}
+.mf-item {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 20rpx;
+}
+.mf-item-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 14rpx;
+}
+.mf-badge {
+  font-size: 22rpx;
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+}
+.mf-badge-question {
+  color: #4A9BD7;
+  background: #EAF4FC;
+}
+.mf-badge-suggestion {
+  color: #E6A23C;
+  background: #FDF4E7;
+}
+.mf-time {
+  font-size: 22rpx;
+  color: #bbb;
+}
+.mf-content {
+  display: block;
+  font-size: 28rpx;
+  color: #333;
+  line-height: 1.6;
+  word-break: break-all;
+  margin-bottom: 18rpx;
+}
+.mf-answer {
+  background: #F5FAFE;
+  border-radius: 12rpx;
+  padding: 18rpx;
+}
+.mf-answer-label {
+  display: block;
+  font-size: 22rpx;
+  color: #10B981;
+  margin-bottom: 8rpx;
+}
+.mf-answer-text {
+  font-size: 26rpx;
+  color: #333;
+  line-height: 1.7;
+  word-break: break-all;
+}
+.mf-answer-muted {
+  color: #999;
+}
+.mf-empty {
+  padding: 160rpx 60rpx 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.mf-empty-icon {
+  font-size: 80rpx;
+  margin-bottom: 24rpx;
+}
+.mf-empty-text {
+  font-size: 30rpx;
+  color: #666;
+  margin-bottom: 12rpx;
+}
+.mf-empty-hint {
+  font-size: 24rpx;
+  color: #aaa;
+  text-align: center;
+  line-height: 1.6;
+}
+.mf-loading {
+  padding: 160rpx 0;
+  display: flex;
+  justify-content: center;
+}
+.mf-loading-text {
+  font-size: 26rpx;
+  color: #999;
+}
+</style>

+ 21 - 194
cfc-frontend/pages/health/report-confirm.vue

@@ -342,51 +342,26 @@
       </view>
     </view>
 
-    <!-- 报告快速分析入口(解析完成、非解析中) -->
-    <view class="quick-analyze-fab" v-if="!parsing && draftId" @tap="openQuickAnalyze">
-      <text class="quick-analyze-fab-icon">🎯</text>
-      <text class="quick-analyze-fab-text">快速分析</text>
+    <!-- 报告反馈入口(解析完成、非解析中) -->
+    <view class="feedback-entry" v-if="!parsing && draftId" @tap="openFeedback">
+      <text class="feedback-entry-icon">💬</text>
+      <text class="feedback-entry-text">对报告有疑问或建议</text>
+      <text class="feedback-entry-arrow">›</text>
     </view>
 
-    <!-- 快速分析浮层 -->
-    <view class="quick-analyze-overlay" v-if="quickAnalyze.show" @tap="closeQuickAnalyze"></view>
-    <view class="quick-analyze-panel" v-if="quickAnalyze.show">
-      <view class="quick-analyze-header">
-        <text class="quick-analyze-title">报告快速分析</text>
-        <text class="quick-analyze-close" @tap="closeQuickAnalyze">✕</text>
-      </view>
-      <view class="quick-analyze-hint" v-if="quickAnalyze.usedToday">免费版今日次数已用完,升级会员解锁更多分析</view>
-      <scroll-view class="quick-analyze-messages" scroll-y>
-        <view class="quick-analyze-message" v-for="(msg, qidx) in quickAnalyze.messages" :key="qidx" :class="'msg-' + msg.role">
-          <text class="quick-analyze-msg-text">{{ msg.content }}</text>
-        </view>
-        <view class="quick-analyze-loading" v-if="quickAnalyze.sending">
-          <text class="quick-analyze-loading-text">AI 正在分析中...</text>
-        </view>
-      </scroll-view>
-      <view class="quick-analyze-chips" v-if="quickAnalyze.messages.length === 0">
-        <view class="quick-analyze-chip" v-for="(q, qi) in quickQuestions" :key="qi" @tap="sendQuickQuestion(q)">
-          <text class="quick-analyze-chip-text">{{ q }}</text>
-        </view>
-      </view>
-      <view class="quick-analyze-input-bar">
-        <input class="quick-analyze-input" v-model="quickAnalyze.inputText" placeholder="输入你想问的问题..."
-          :disabled="quickAnalyze.sending || quickAnalyze.usedToday" confirm-type="send" @confirm="sendQuickAnalyze" />
-        <view class="quick-analyze-send" :class="{ 'send-disabled': quickAnalyze.sending || quickAnalyze.usedToday }" @tap="sendQuickAnalyze">
-          <text class="quick-analyze-send-text">发送</text>
-        </view>
-      </view>
-    </view>
+    <feedback-popup :visible="feedbackVisible" :draft-id="draftId" @close="closeFeedback" />
 
     <view class="bottom-spacer"></view>
   </view>
 </template>
 
 <script>
-import { confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, parseReportDraft, reportQuickAnalyze, getMyMembership } from '../../utils/api.js'
+import { confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, parseReportDraft } from '../../utils/api.js'
 import { addFamilyMember, getFamilyMemberList } from '../../utils/api.js'
+import FeedbackPopup from '../../components/feedback-popup.vue'
 
 export default {
+  components: { FeedbackPopup },
   data() {
     return {
       draftId: null,
@@ -410,16 +385,7 @@ export default {
       extractedName: '',
       extractedGender: '',
       extractedAge: '',
-      // 报告快速分析浮层
-      quickAnalyze: {
-        show: false,
-        sending: false,
-        messages: [],
-        inputText: '',
-        conversationId: '',
-        usedToday: false,
-        checkingMembership: false
-      },
+      feedbackVisible: false,
       matchedMemberId: null,
       needBind: false,
       selectedMemberId: null,
@@ -489,13 +455,6 @@ export default {
       if (this.needBind) return '需手动绑定'
       return this.extractedName || '未识别'
     },
-    quickQuestions: function() {
-      return [
-        '这份报告最需要关注哪些异常指标?',
-        '报告中提示了哪些健康风险?',
-        '有哪些改善建议?'
-      ]
-    },
     /** 全量成员减去候选区已展示的成员,避免重复 */
     otherMembers() {
       var candIds = {}
@@ -630,55 +589,12 @@ export default {
     }
   },
   methods: {
-    // ===== 报告快速分析 =====
-    openQuickAnalyze: function() {
-      this.quickAnalyze.show = true
-      var self = this
-      if (self.quickAnalyze.checkingMembership) return
-      self.quickAnalyze.checkingMembership = true
-      getMyMembership().then(function(res) {
-        var level = res.data && res.data.memberLevel
-        if (level && level !== 'FREE') {
-          self.quickAnalyze.usedToday = false
-        }
-      }).catch(function() {}).finally(function() {
-        self.quickAnalyze.checkingMembership = false
-      })
+    // ===== 报告反馈(原「快速分析」入口已合并,反馈由 AI 解答) =====
+    openFeedback: function() {
+      this.feedbackVisible = true
     },
-    closeQuickAnalyze: function() {
-      this.quickAnalyze.show = false
-    },
-    sendQuickQuestion: function(q) {
-      if (q && q.trim()) {
-        this.quickAnalyze.inputText = q
-        this.sendQuickAnalyze()
-      }
-    },
-    sendQuickAnalyze: function() {
-      var self = this
-      var text = (this.quickAnalyze.inputText || '').trim()
-      if (!text || this.quickAnalyze.sending) return
-      this.quickAnalyze.messages.push({ role: 'user', content: text })
-      this.quickAnalyze.inputText = ''
-      this.quickAnalyze.sending = true
-      reportQuickAnalyze(text, null, this.quickAnalyze.conversationId, this.draftId).then(function(res) {
-        if (res.code === 200 && res.data) {
-          self.quickAnalyze.messages.push({ role: 'ai', content: res.data.answer || '(无回答)' })
-          if (res.data.conversationId) {
-            self.quickAnalyze.conversationId = res.data.conversationId
-          }
-        } else {
-          var msg = res.message || '分析失败'
-          if (msg.indexOf('每日仅限') !== -1) {
-            self.quickAnalyze.usedToday = true
-          }
-          self.quickAnalyze.messages.push({ role: 'ai', content: msg })
-        }
-      }).catch(function() {
-        self.quickAnalyze.messages.push({ role: 'ai', content: 'AI 服务暂不可用,请稍后重试' })
-      }).finally(function() {
-        self.quickAnalyze.sending = false
-      })
+    closeFeedback: function() {
+      this.feedbackVisible = false
     },
     // 仅 draftId 模式:调用后端按草稿解析,成功后自动填充页面
     parseByDraftId() {
@@ -1886,8 +1802,8 @@ export default {
   color: #333;
   line-height: 1.6;
 }
-/* ===== 报告快速分析 ===== */
-.quick-analyze-fab {
+/* ===== 报告反馈入口 ===== */
+.feedback-entry {
   position: fixed;
   right: 30rpx;
   bottom: 60rpx;
@@ -1900,97 +1816,8 @@ export default {
   box-shadow: 0 4rpx 16rpx rgba(59, 130, 246, 0.4);
   z-index: 90;
 }
-.quick-analyze-fab-icon { font-size: 32rpx; margin-right: 10rpx; }
-.quick-analyze-fab-text { font-size: 28rpx; color: #fff; font-weight: 500; }
-.quick-analyze-fab:active { opacity: 0.85; }
-.quick-analyze-overlay {
-  position: fixed;
-  top: 0; left: 0; right: 0; bottom: 0;
-  background: rgba(0, 0, 0, 0.45);
-  z-index: 98;
-}
-.quick-analyze-panel {
-  position: fixed;
-  left: 30rpx; right: 30rpx;
-  bottom: 40rpx;
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 24rpx;
-  z-index: 99;
-  display: flex;
-  flex-direction: column;
-  max-height: 70vh;
-}
-.quick-analyze-header {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 16rpx;
-}
-.quick-analyze-title { font-size: 30rpx; font-weight: bold; color: #333; }
-.quick-analyze-close { font-size: 32rpx; color: #999; padding: 8rpx; }
-.quick-analyze-hint {
-  font-size: 24rpx;
-  color: #E65100;
-  background: #FFF3E0;
-  border-radius: 12rpx;
-  padding: 12rpx 16rpx;
-  margin-bottom: 12rpx;
-}
-.quick-analyze-messages {
-  max-height: 50vh;
-  margin-bottom: 16rpx;
-}
-.quick-analyze-message {
-  margin-bottom: 12rpx;
-  display: flex;
-  flex-direction: column;
-}
-.quick-analyze-message.msg-user { align-items: flex-end; }
-.quick-analyze-message.msg-ai { align-items: flex-start; }
-.quick-analyze-msg-text {
-  font-size: 26rpx;
-  color: #333;
-  background: #F3F4F6;
-  border-radius: 14rpx;
-  padding: 14rpx 18rpx;
-  max-width: 80%;
-  line-height: 1.5;
-}
-.msg-user .quick-analyze-msg-text { background: #5B9BD5; color: #fff; }
-.quick-analyze-loading { display: flex; justify-content: center; padding: 12rpx 0; }
-.quick-analyze-loading-text { font-size: 24rpx; color: #999; }
-.quick-analyze-chips { display: flex; flex-direction: column; margin-bottom: 16rpx; }
-.quick-analyze-chip {
-  background: #EFF6FF;
-  border: 1rpx solid #BFDBFE;
-  border-radius: 12rpx;
-  padding: 14rpx 18rpx;
-  margin-bottom: 10rpx;
-}
-.quick-analyze-chip:active { opacity: 0.8; }
-.quick-analyze-chip-text { font-size: 26rpx; color: #1D4ED8; }
-.quick-analyze-input-bar {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-}
-.quick-analyze-input {
-  flex: 1;
-  height: 72rpx;
-  background: #F3F4F6;
-  border-radius: 36rpx;
-  padding: 0 28rpx;
-  font-size: 26rpx;
-  color: #333;
-  margin-right: 16rpx;
-}
-.quick-analyze-send {
-  background: #5B9BD5;
-  border-radius: 36rpx;
-  padding: 16rpx 34rpx;
-}
-.quick-analyze-send.send-disabled { opacity: 0.5; }
-.quick-analyze-send-text { font-size: 26rpx; color: #fff; }
+.feedback-entry-icon { font-size: 32rpx; margin-right: 10rpx; }
+.feedback-entry-text { font-size: 28rpx; color: #fff; font-weight: 500; }
+.feedback-entry-arrow { font-size: 30rpx; color: #fff; margin-left: 6rpx; }
+.feedback-entry:active { opacity: 0.85; }
 </style>

+ 21 - 191
cfc-frontend/pages/health/report-detail.vue

@@ -393,50 +393,25 @@
       </view>
     </view>
 
-    <!-- 报告快速分析入口(仅查看模式) -->
-    <view class="quick-analyze-fab" v-if="pageMode==='view' && reportId" @tap="openQuickAnalyze">
-      <text class="quick-analyze-fab-icon">🎯</text>
-      <text class="quick-analyze-fab-text">快速分析</text>
+    <!-- 报告反馈入口(仅查看模式) -->
+    <view class="feedback-entry" v-if="pageMode==='view' && reportId" @tap="openFeedback">
+      <text class="feedback-entry-icon">💬</text>
+      <text class="feedback-entry-text">对报告有疑问或建议</text>
+      <text class="feedback-entry-arrow">›</text>
     </view>
 
-    <!-- 快速分析浮层 -->
-    <view class="quick-analyze-overlay" v-if="quickAnalyze.show" @tap="closeQuickAnalyze"></view>
-    <view class="quick-analyze-panel" v-if="quickAnalyze.show">
-      <view class="quick-analyze-header">
-        <text class="quick-analyze-title">报告快速分析</text>
-        <text class="quick-analyze-close" @tap="closeQuickAnalyze">✕</text>
-      </view>
-      <view class="quick-analyze-hint" v-if="quickAnalyze.usedToday">免费版今日次数已用完,升级会员解锁更多分析</view>
-      <scroll-view class="quick-analyze-messages" scroll-y>
-        <view class="quick-analyze-message" v-for="(msg, qidx) in quickAnalyze.messages" :key="qidx" :class="'msg-' + msg.role">
-          <text class="quick-analyze-msg-text">{{ msg.content }}</text>
-        </view>
-        <view class="quick-analyze-loading" v-if="quickAnalyze.sending">
-          <text class="quick-analyze-loading-text">AI 正在分析中...</text>
-        </view>
-      </scroll-view>
-      <view class="quick-analyze-chips" v-if="quickAnalyze.messages.length === 0">
-        <view class="quick-analyze-chip" v-for="(q, qi) in quickQuestions" :key="qi" @tap="sendQuickQuestion(q)">
-          <text class="quick-analyze-chip-text">{{ q }}</text>
-        </view>
-      </view>
-      <view class="quick-analyze-input-bar">
-        <input class="quick-analyze-input" v-model="quickAnalyze.inputText" placeholder="输入你想问的问题..."
-          :disabled="quickAnalyze.sending || quickAnalyze.usedToday" confirm-type="send" @confirm="sendQuickAnalyze" />
-        <view class="quick-analyze-send" :class="{ 'send-disabled': quickAnalyze.sending || quickAnalyze.usedToday }" @tap="sendQuickAnalyze">
-          <text class="quick-analyze-send-text">发送</text>
-        </view>
-      </view>
-    </view>
+    <feedback-popup :visible="feedbackVisible" :report-id="reportId" @close="closeFeedback" />
   </view>
 </template>
 
 <script>
 import ReportBlocksRenderer from '../../components/report-blocks-renderer.vue'
-import { getReportDetail, getDanReportDetail, getReportPayload, editHealthReport, parseReportDraft, confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, getFamilyMemberList, addFamilyMember, queryIndicatorKnowledge, queryBacteriaKnowledge, reportQuickAnalyze, getMyMembership } from '../../utils/api.js'
+import FeedbackPopup from '../../components/feedback-popup.vue'
+import { getReportDetail, getDanReportDetail, getReportPayload, editHealthReport, parseReportDraft, confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, getFamilyMemberList, addFamilyMember, queryIndicatorKnowledge, queryBacteriaKnowledge } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
+  components: { FeedbackPopup },
   data() {
     return {
       reportType: 'gut_flora',
@@ -492,15 +467,7 @@ export default {
       showHelpPopup: false,
       helpData: {},
       _kbCache: {},
-      // 报告快速分析浮层
-      quickAnalyze: {
-        show: false,
-        sending: false,
-        messages: [],      // [{role:'user'|'ai', content}]
-        inputText: '',
-        conversationId: '',
-        usedToday: false    // 免费版今日已用(前端预判,最终以后端为准)
-      }
+      feedbackVisible: false
     }
   },
   computed: {
@@ -508,13 +475,6 @@ export default {
       var map = { gut_flora: '肠道菌群报告', dan: 'DAN测评报告', physical_exam: '体检报告', tongue: '舌诊报告' }
       return map[this.reportType] || '健康报告'
     },
-    quickQuestions: function() {
-      return [
-        '这份报告最需要关注哪些异常指标?',
-        '报告中提示了哪些健康风险?',
-        '有哪些改善建议?'
-      ]
-    },
     isGutFlora() {
       return this.reportType === 'gut_flora'
     },
@@ -637,52 +597,12 @@ export default {
     }
   },
   methods: {
-    // ===== 报告快速分析 =====
-    openQuickAnalyze: function() {
-      this.quickAnalyze.show = true
-      var self = this
-      // 预判免费版今日额度(仅用于 UI 展示,最终以后端校验为准)
-      getMyMembership().then(function(res) {
-        var level = res.data && res.data.memberLevel
-        if (level && level !== 'FREE') {
-          self.quickAnalyze.usedToday = false
-        }
-      }).catch(function() {})
+    // ===== 报告反馈(原「快速分析」入口已合并,反馈由 AI 解答) =====
+    openFeedback: function() {
+      this.feedbackVisible = true
     },
-    closeQuickAnalyze: function() {
-      this.quickAnalyze.show = false
-    },
-    sendQuickQuestion: function(q) {
-      if (q && q.trim()) {
-        this.quickAnalyze.inputText = q
-        this.sendQuickAnalyze()
-      }
-    },
-    sendQuickAnalyze: function() {
-      var self = this
-      var text = (this.quickAnalyze.inputText || '').trim()
-      if (!text || this.quickAnalyze.sending) return
-      this.quickAnalyze.messages.push({ role: 'user', content: text })
-      this.quickAnalyze.inputText = ''
-      this.quickAnalyze.sending = true
-      reportQuickAnalyze(text, this.reportId, this.quickAnalyze.conversationId).then(function(res) {
-        if (res.code === 200 && res.data) {
-          self.quickAnalyze.messages.push({ role: 'ai', content: res.data.answer || '(无回答)' })
-          if (res.data.conversationId) {
-            self.quickAnalyze.conversationId = res.data.conversationId
-          }
-        } else {
-          var msg = res.message || '分析失败'
-          if (msg.indexOf('每日仅限') !== -1) {
-            self.quickAnalyze.usedToday = true
-          }
-          self.quickAnalyze.messages.push({ role: 'ai', content: msg })
-        }
-      }).catch(function() {
-        self.quickAnalyze.messages.push({ role: 'ai', content: 'AI 服务暂不可用,请稍后重试' })
-      }).finally(function() {
-        self.quickAnalyze.sending = false
-      })
+    closeFeedback: function() {
+      this.feedbackVisible = false
     },
     loadData() {
       var self = this
@@ -2288,8 +2208,8 @@ export default {
   opacity: 0.8;
 }
 
-/* ===== 报告快速分析 ===== */
-.quick-analyze-fab {
+/* ===== 报告反馈入口 ===== */
+.feedback-entry {
   position: fixed;
   right: 30rpx;
   bottom: 60rpx;
@@ -2302,98 +2222,8 @@ export default {
   box-shadow: 0 4rpx 16rpx rgba(59, 130, 246, 0.4);
   z-index: 90;
 }
-.quick-analyze-fab-icon { font-size: 32rpx; margin-right: 10rpx; }
-.quick-analyze-fab-text { font-size: 28rpx; color: #fff; font-weight: 500; }
-.quick-analyze-fab:active { opacity: 0.85; }
-
-.quick-analyze-overlay {
-  position: fixed;
-  top: 0; left: 0; right: 0; bottom: 0;
-  background: rgba(0, 0, 0, 0.45);
-  z-index: 98;
-}
-.quick-analyze-panel {
-  position: fixed;
-  left: 30rpx; right: 30rpx;
-  bottom: 40rpx;
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 24rpx;
-  z-index: 99;
-  display: flex;
-  flex-direction: column;
-  max-height: 70vh;
-}
-.quick-analyze-header {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 16rpx;
-}
-.quick-analyze-title { font-size: 30rpx; font-weight: bold; color: #333; }
-.quick-analyze-close { font-size: 32rpx; color: #999; padding: 8rpx; }
-.quick-analyze-hint {
-  font-size: 24rpx;
-  color: #E65100;
-  background: #FFF3E0;
-  border-radius: 12rpx;
-  padding: 12rpx 16rpx;
-  margin-bottom: 12rpx;
-}
-.quick-analyze-messages {
-  max-height: 50vh;
-  margin-bottom: 16rpx;
-}
-.quick-analyze-message {
-  margin-bottom: 12rpx;
-  display: flex;
-  flex-direction: column;
-}
-.quick-analyze-message.msg-user { align-items: flex-end; }
-.quick-analyze-message.msg-ai { align-items: flex-start; }
-.quick-analyze-msg-text {
-  font-size: 26rpx;
-  color: #333;
-  background: #F3F4F6;
-  border-radius: 14rpx;
-  padding: 14rpx 18rpx;
-  max-width: 80%;
-  line-height: 1.5;
-}
-.msg-user .quick-analyze-msg-text { background: #5B9BD5; color: #fff; }
-.quick-analyze-loading { display: flex; justify-content: center; padding: 12rpx 0; }
-.quick-analyze-loading-text { font-size: 24rpx; color: #999; }
-.quick-analyze-chips { display: flex; flex-direction: column; margin-bottom: 16rpx; }
-.quick-analyze-chip {
-  background: #EFF6FF;
-  border: 1rpx solid #BFDBFE;
-  border-radius: 12rpx;
-  padding: 14rpx 18rpx;
-  margin-bottom: 10rpx;
-}
-.quick-analyze-chip:active { opacity: 0.8; }
-.quick-analyze-chip-text { font-size: 26rpx; color: #1D4ED8; }
-.quick-analyze-input-bar {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-}
-.quick-analyze-input {
-  flex: 1;
-  height: 72rpx;
-  background: #F3F4F6;
-  border-radius: 36rpx;
-  padding: 0 28rpx;
-  font-size: 26rpx;
-  color: #333;
-  margin-right: 16rpx;
-}
-.quick-analyze-send {
-  background: #5B9BD5;
-  border-radius: 36rpx;
-  padding: 16rpx 34rpx;
-}
-.quick-analyze-send.send-disabled { opacity: 0.5; }
-.quick-analyze-send-text { font-size: 26rpx; color: #fff; }
+.feedback-entry-icon { font-size: 32rpx; margin-right: 10rpx; }
+.feedback-entry-text { font-size: 28rpx; color: #fff; font-weight: 500; }
+.feedback-entry-arrow { font-size: 30rpx; color: #fff; margin-left: 6rpx; }
+.feedback-entry:active { opacity: 0.85; }
 </style>

+ 4 - 10
cfc-frontend/utils/api.js

@@ -1421,15 +1421,6 @@ export const aiDeleteConversation = (id) => {
   return request('/api/ai/chat/conversations/' + id + '/delete', 'POST', {})
 }
 
-// 报告快速分析(免费版每日1次,付费版多轮)
-export const reportQuickAnalyze = (query, reportId, conversationId, draftId) => {
-  var data = { query: query }
-  if (reportId) data.reportId = reportId
-  if (draftId) data.draftId = draftId
-  if (conversationId) data.conversationId = conversationId
-  return request('/api/ai/report/analyze', 'POST', data)
-}
-
 // 营养助手(基于体检报告)
 export const aiSendNutritionMessage = (data) => {
   return request('/api/ai/nutrition/send', 'POST', data)
@@ -2277,8 +2268,11 @@ export const createReportSurvey = (reportId, memberId) => request('/api/health/s
 export const submitReportSurvey = (surveyId, surveyData) => request('/api/health/survey/submit', 'POST', { surveyId, surveyData })
 export const getReportSurveyStatus = (reportId) => request('/api/health/survey/status', 'POST', { reportId })
 
-// ===== Report Feedback (gut flora report feedback) =====
+// ===== Report Feedback(报告反馈:提交后由 AI 解答,可查看结果) =====
+// 提交反馈:{ reportId?, draftId?, type: 'question'|'suggestion', content } → { id, feedbackType, content, aiAnswer, aiStatus, createdAt }
 export const submitFeedback = (data) => request('/api/health/feedback/submit', 'POST', data)
+// 我的反馈列表(含 AI 解答):{ reportId? } → List<ReportFeedback>
+export const getMyReportFeedbacks = (data) => request('/api/health/feedback/list', 'POST', data || {})
 
 // ===== 重要关系(联系人) =====
 export const getContactList = (data) => request('/api/contact/list', 'POST', data)

+ 47 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1065,6 +1065,53 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 ---
 
+### 4.37 报告反馈与 AI 解答(`/api/health/feedback/*`)
+
+用户对健康报告提交疑问/建议,后端**持久化**到 `report_feedback` 表并调用 AI 解答(经 `AiGateway`,免费版每日 1 次,付费版不限)。反馈记录含 `aiAnswer`/`aiStatus`,前端可据此展示 AI 解答结果。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/health/feedback/submit` | 提交报告反馈(持久化 + AI 解答),请求 `{ reportId?, draftId?, memberId?, type, content }`,返回 `Result<ReportFeedback>` |
+| `POST /api/health/feedback/list` | 我的报告反馈列表(含 AI 解答),请求 `{ reportId? }`,返回 `Result<List<ReportFeedback>>`(按创建时间倒序,最多 50 条) |
+
+**`/api/health/feedback/submit` 请求体:**
+```json
+{
+  "reportId": 123,              // 可选,关联健康报告ID
+  "draftId": null,              // 可选,关联解析草稿ID(reportId 与 draftId 二选一,均可为空)
+  "memberId": 456,              // 可选,关联家庭成员ID(默认当前成员)
+  "type": "question",           // question=疑问 / suggestion=建议,其他值默认 question
+  "content": "这份报告里菌群多样性偏低是什么意思?"
+}
+```
+
+**响应示例(`Result<ReportFeedback>`,`data` 字段):**
+```json
+{
+  "code": 200,
+  "message": "ok",
+  "data": {
+    "id": 1,
+    "userId": 1001,
+    "memberId": 456,
+    "reportId": 123,
+    "draftId": null,
+    "feedbackType": "question",
+    "content": "这份报告里菌群多样性偏低是什么意思?",
+    "aiAnswer": "菌群多样性反映肠道微生物的丰富程度……",
+    "aiStatus": "answered",
+    "conversationId": "conv_abc123",
+    "createdAt": "2026-09-22 14:30:00"
+  }
+}
+```
+
+- `aiStatus` 取值:`pending`(待解答)/ `answered`(AI 已解答)/ `failed`(AI 调用失败,仍会落库,`aiAnswer` 为空)。
+- 免费版每日仅限 1 次反馈解答(Redis 计数 `report:feedback:free:{userId}:{date}`,`memberLevel=FREE` 或为空时生效),超限返回 `Result.error("免费版每日仅限 1 次反馈解答,升级会员解锁更多")`;Redis 异常时 fail-open 放行。
+- `content` 必填且不超过 500 字。
+
+---
+
 ### 4.38 认知维度(`/api/cognitive/*`)
 
 六维认知体系:感知(perception) / 专注(focus) / 记忆(memory) / 逻辑(logic) / 空间(spatial) / 加工速度(processingSpeed)。