|
|
@@ -0,0 +1,662 @@
|
|
|
+# 报告快速分析功能实现计划
|
|
|
+
|
|
|
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
+
|
|
|
+**目标:** 在报告详情页(`report-detail.vue`)提供「快速分析」功能——用户输入问题,AI 基于当前报告内容做初步解读。免费版(FREE)每日 1 次(Redis 计数),付费版(FAMILY/PROVIDER)多轮对话。
|
|
|
+
|
|
|
+**架构:** 后端在 `AIChatController` 新增 `POST /api/ai/report/analyze` 端点:查 `User.memberLevel` → 免费版 Redis 计数校验 → 复用 `sendMessage` 的上下文组装 + `aiService.sendMessage()` 调 LangGraph `chat_graph`(零 Python 改动)。前端 `report-detail.vue` 底部新增悬浮按钮 + 浮层(消息区/输入框/快捷问题),免费版禁用多轮(不带 conversationId)。
|
|
|
+
|
|
|
+**技术栈:** Spring Boot 2.7.18 (Java 8) + Redis (spring-boot-starter-data-redis) + uni-app Vue 2 小程序(Options API,禁可选链/禁 CSS Grid/禁 `:key` 表达式)。
|
|
|
+
|
|
|
+**设计规格:** `docs/superpowers/specs/2026-09-10-report-quick-analyze-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构
|
|
|
+
|
|
|
+### 后端(cfc-backend)
|
|
|
+
|
|
|
+| 文件 | 操作 | 职责 |
|
|
|
+|------|------|------|
|
|
|
+| `pom.xml` | 修改 | 新增 `spring-boot-starter-data-redis` 依赖 |
|
|
|
+| `src/main/resources/application.yml` | 修改 | Redis 连接配置(默认 localhost:6379) |
|
|
|
+| `src/main/java/com/etotem/cfc/config/RedisConfig.java` | 创建 | `RedisTemplate<String, Object>` bean,String 序列化 |
|
|
|
+| `src/main/java/com/etotem/cfc/controller/ai/AIChatController.java` | 修改 | 抽出 `buildChatInputs()` 私有方法 + 新增 `POST /report/analyze` 端点(会员检查 + Redis 计数 + 调 aiService.sendMessage) |
|
|
|
+
|
|
|
+### 前端(cfc-frontend)
|
|
|
+
|
|
|
+| 文件 | 操作 | 职责 |
|
|
|
+|------|------|------|
|
|
|
+| `utils/api.js` | 修改 | 新增 `reportQuickAnalyze(query, reportId, conversationId)` 封装 |
|
|
|
+| `pages/health/report-detail.vue` | 修改 | 底部悬浮按钮 + 浮层(消息区/输入框/3 个快捷问题/加载态/免费版禁用态) |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 1:后端引入 Redis 依赖与配置
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/pom.xml:90`(Validation 依赖之后)
|
|
|
+- 修改:`cfc-backend/src/main/resources/application.yml`
|
|
|
+- 创建:`cfc-backend/src/main/java/com/etotem/cfc/config/RedisConfig.java`
|
|
|
+
|
|
|
+- [ ] **步骤 1:pom.xml 新增 redis starter**
|
|
|
+
|
|
|
+在 `<dependency>` 块(`spring-boot-starter-validation` 之后,约 90 行)插入:
|
|
|
+
|
|
|
+```xml
|
|
|
+ <!-- Redis -->
|
|
|
+ <dependency>
|
|
|
+ <groupId>org.springframework.boot</groupId>
|
|
|
+ <artifactId>spring-boot-starter-data-redis</artifactId>
|
|
|
+ </dependency>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:application.yml 添加 Redis 配置**
|
|
|
+
|
|
|
+在 `spring:` 配置节点下添加(保持 YAML 缩进与现有 `datasource` 平级):
|
|
|
+
|
|
|
+```yaml
|
|
|
+ redis:
|
|
|
+ host: ${REDIS_HOST:localhost}
|
|
|
+ port: ${REDIS_PORT:6379}
|
|
|
+ password: ${REDIS_PASSWORD:}
|
|
|
+ timeout: 3000ms
|
|
|
+ lettuce:
|
|
|
+ pool:
|
|
|
+ max-active: 8
|
|
|
+ max-idle: 8
|
|
|
+ min-idle: 0
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:创建 RedisConfig.java**
|
|
|
+
|
|
|
+创建 `cfc-backend/src/main/java/com/etotem/cfc/config/RedisConfig.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.config;
|
|
|
+
|
|
|
+import org.springframework.context.annotation.Bean;
|
|
|
+import org.springframework.context.annotation.Configuration;
|
|
|
+import org.springframework.data.redis.connection.RedisConnectionFactory;
|
|
|
+import org.springframework.data.redis.core.RedisTemplate;
|
|
|
+import org.springframework.data.redis.serializer.StringRedisSerializer;
|
|
|
+
|
|
|
+@Configuration
|
|
|
+public class RedisConfig {
|
|
|
+
|
|
|
+ @Bean
|
|
|
+ public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
|
|
|
+ RedisTemplate<String, Object> template = new RedisTemplate<>();
|
|
|
+ template.setConnectionFactory(factory);
|
|
|
+ StringRedisSerializer stringSerializer = new StringRedisSerializer();
|
|
|
+ template.setKeySerializer(stringSerializer);
|
|
|
+ template.setValueSerializer(stringSerializer);
|
|
|
+ template.setHashKeySerializer(stringSerializer);
|
|
|
+ template.setHashValueSerializer(stringSerializer);
|
|
|
+ template.afterPropertiesSet();
|
|
|
+ return template;
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile -q -Dmaven.test.skip=true`
|
|
|
+预期:BUILD SUCCESS(无输出即成功)
|
|
|
+
|
|
|
+- [ ] **步骤 5:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/pom.xml cfc-backend/src/main/resources/application.yml cfc-backend/src/main/java/com/etotem/cfc/config/RedisConfig.java
|
|
|
+git commit -m "feat: 引入 Redis 依赖与 RedisTemplate 配置(报告快速分析计数用)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 2:AIChatController 抽出 buildChatInputs + 新增 report/analyze 端点
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java`
|
|
|
+
|
|
|
+**背景:** 现有 `sendMessage`(72-166 行)已包含完整的上下文组装逻辑(mascot/portrait/自检/记忆层),需抽出为 `buildChatInputs(userId, params)` 供两个端点复用。新端点 `POST /report/analyze` 只做会员检查 + Redis 计数 + 调 `aiService.sendMessage`。
|
|
|
+
|
|
|
+- [ ] **步骤 1:新增 import(RedisTemplate + LocalDate)**
|
|
|
+
|
|
|
+在 `AIChatController.java` 头部 import 区添加(现有 import 在 10-27 行):
|
|
|
+
|
|
|
+```java
|
|
|
+import org.springframework.data.redis.core.RedisTemplate;
|
|
|
+import java.time.LocalDate;
|
|
|
+import java.time.Duration;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:注入 RedisTemplate 依赖**
|
|
|
+
|
|
|
+在 `private com.fasterxml.jackson.databind.ObjectMapper objectMapper;`(约 72 行)之后添加:
|
|
|
+
|
|
|
+```java
|
|
|
+ @Resource
|
|
|
+ private RedisTemplate<String, Object> redisTemplate;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:抽出 buildChatInputs 私有方法**
|
|
|
+
|
|
|
+现有 `sendMessage` 内 93-155 行(从 `// 获取用户mascot设置` 到 `inputs = aiService.enrichInputsWithMemory(...)`)是上下文组装逻辑。抽出为新私有方法(保留原有 `sendMessage` 调用它):
|
|
|
+
|
|
|
+```java
|
|
|
+ /**
|
|
|
+ * 组装 AI 对话上下文(mascot + 家庭/报告 + 画像 + 自检 + 记忆层)
|
|
|
+ * 供 /chat/send 和 /report/analyze 共用
|
|
|
+ */
|
|
|
+ private Map<String, Object> buildChatInputs(Long userId, Map<String, String> params) {
|
|
|
+ String conversationId = params.get("conversationId");
|
|
|
+ String reportIdStr = params.get("reportId");
|
|
|
+ String surveyIdStr = params.get("surveyId");
|
|
|
+ String selfCheckIdStr = params.get("selfCheckId");
|
|
|
+
|
|
|
+ // 获取用户mascot设置
|
|
|
+ com.etotem.cfc.entity.User user = userService.getUserInfo(userId);
|
|
|
+ String mascotCode = user != null ? user.getMascot() : null;
|
|
|
+
|
|
|
+ // 组装家庭上下文(如果指定reportId/surveyId,注入报告+问卷数据)
|
|
|
+ Long reportId = null;
|
|
|
+ Long surveyId = null;
|
|
|
+ if (reportIdStr != null && !reportIdStr.trim().isEmpty()) {
|
|
|
+ reportId = Long.valueOf(reportIdStr);
|
|
|
+ }
|
|
|
+ if (surveyIdStr != null && !surveyIdStr.trim().isEmpty()) {
|
|
|
+ surveyId = Long.valueOf(surveyIdStr);
|
|
|
+ }
|
|
|
+ Map<String, Object> inputs;
|
|
|
+ if (reportId != null && surveyId != null) {
|
|
|
+ inputs = familyContextService.buildContext(userId, reportId, surveyId);
|
|
|
+ } else if (reportId != null) {
|
|
|
+ inputs = familyContextService.buildContext(userId, reportId);
|
|
|
+ } else {
|
|
|
+ inputs = familyContextService.buildContext(userId);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 注入mascot信息到LangGraph inputs
|
|
|
+ if (mascotCode != null && !mascotCode.isEmpty()) {
|
|
|
+ com.etotem.cfc.enums.MascotEnum mascot = com.etotem.cfc.enums.MascotEnum.fromCode(mascotCode);
|
|
|
+ if (mascot != null) {
|
|
|
+ inputs.put("mascot_name", mascot.name());
|
|
|
+ inputs.put("mascot_gender", mascot.gender());
|
|
|
+ inputs.put("mascot_persona", mascot.persona());
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ inputs.put("mascot_name", "小助手");
|
|
|
+ inputs.put("mascot_gender", "");
|
|
|
+ inputs.put("mascot_persona", "");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 画像注入
|
|
|
+ String chatMemberIdStr = params.get("memberId");
|
|
|
+ Long chatMemberId = chatMemberIdStr != null && !chatMemberIdStr.trim().isEmpty()
|
|
|
+ ? Long.valueOf(chatMemberIdStr) : null;
|
|
|
+ String chatPortrait = portraitService.buildPortraitPrompt(userId, chatMemberId);
|
|
|
+ if (chatPortrait != null) {
|
|
|
+ inputs.put("portrait_prompt", chatPortrait);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 注入自检上下文(P1-2)
|
|
|
+ if (selfCheckIdStr != null && !selfCheckIdStr.trim().isEmpty()) {
|
|
|
+ try {
|
|
|
+ Long selfCheckId = Long.valueOf(selfCheckIdStr);
|
|
|
+ com.etotem.cfc.entity.FiveDimensionSelfCheck selfCheck = selfCheckMapper.selectById(selfCheckId);
|
|
|
+ if (selfCheck != null) {
|
|
|
+ java.util.Map<String, Object> sc = new java.util.LinkedHashMap<>();
|
|
|
+ sc.put("totalScore", selfCheck.getTotalScore());
|
|
|
+ sc.put("scores", selfCheck.getScoresJson());
|
|
|
+ sc.put("createdAt", selfCheck.getCreatedAt() != null ? selfCheck.getCreatedAt().toString() : "");
|
|
|
+ inputs.put("self_check_result", objectMapper.writeValueAsString(sc));
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("注入自检上下文失败: {}", e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // AI记忆层注入(会话摘要+关键事实)
|
|
|
+ inputs = aiService.enrichInputsWithMemory(userId, conversationId, inputs);
|
|
|
+ return inputs;
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+**重构 sendMessage:** 将原 `sendMessage` 中 93-155 行替换为:
|
|
|
+
|
|
|
+```java
|
|
|
+ // 复用上下文组装(mascot + 家庭/报告 + 画像 + 自检 + 记忆层)
|
|
|
+ Map<String, Object> inputs = buildChatInputs(userId, params);
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 4:新增 /report/analyze 端点**
|
|
|
+
|
|
|
+在 `sendMessage` 方法结束后(`}` 之后)添加:
|
|
|
+
|
|
|
+```java
|
|
|
+ @Operation(summary = "报告快速分析(免费版每日1次,付费版多轮)")
|
|
|
+ @PostMapping("/report/analyze")
|
|
|
+ public Result<Map<String, Object>> reportAnalyze(
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
+ @RequestBody Map<String, String> params) {
|
|
|
+ String query = params.get("query");
|
|
|
+ String reportIdStr = params.get("reportId");
|
|
|
+ if (query == null || query.trim().isEmpty()) {
|
|
|
+ return Result.error("消息不能为空");
|
|
|
+ }
|
|
|
+ if (reportIdStr == null || reportIdStr.trim().isEmpty()) {
|
|
|
+ return Result.error("报告ID不能为空");
|
|
|
+ }
|
|
|
+ Long reportId = Long.valueOf(reportIdStr);
|
|
|
+
|
|
|
+ // 1. 会员级别检查
|
|
|
+ com.etotem.cfc.entity.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:analyze: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. 组装上下文(免费版不带 conversationId → LangGraph 每次新建会话,锁死多轮)
|
|
|
+ if (isFree) {
|
|
|
+ params.remove("conversationId");
|
|
|
+ }
|
|
|
+ Map<String, Object> inputs = buildChatInputs(userId, params);
|
|
|
+
|
|
|
+ // 4. 调 LangGraph(经 aiService.sendMessage 含记忆层+镜像)
|
|
|
+ Map<String, Object> difyResp = aiService.sendMessage(
|
|
|
+ query, String.valueOf(userId),
|
|
|
+ params.get("conversationId"), inputs);
|
|
|
+
|
|
|
+ if (difyResp == null || difyResp.isEmpty()) {
|
|
|
+ return Result.error("AI 服务暂不可用,请稍后重试");
|
|
|
+ }
|
|
|
+ String answer = (String) difyResp.getOrDefault("answer", "");
|
|
|
+ String cleanAnswer = taskParseService.stripTaskMarkers(answer);
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
+ result.put("answer", cleanAnswer);
|
|
|
+ result.put("conversationId", difyResp.getOrDefault("conversationId", ""));
|
|
|
+ return Result.success(result);
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 5:编译验证**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile -q -Dmaven.test.skip=true`
|
|
|
+预期:BUILD SUCCESS(无输出即成功)
|
|
|
+注意:若 `User.getMemberLevel()` 不存在,检查 `entity/User.java` 是否有 `memberLevel` 字段(AGENTS.md 显示 `User.java:136` 有 `private String memberLevel;`)
|
|
|
+
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/ai/AIChatController.java
|
|
|
+git commit -m "feat: 新增报告快速分析端点 /api/ai/report/analyze(免费版Redis每日1次+付费版多轮)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 3:前端 api.js 新增 reportQuickAnalyze 封装
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-frontend/utils/api.js:1426`(`aiDeleteConversation` 之后)
|
|
|
+
|
|
|
+- [ ] **步骤 1:新增封装函数**
|
|
|
+
|
|
|
+在 `aiDeleteConversation` 函数后添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+// 报告快速分析(免费版每日1次,付费版多轮)
|
|
|
+export const reportQuickAnalyze = (query, reportId, conversationId) => {
|
|
|
+ var data = { query: query, reportId: reportId }
|
|
|
+ if (conversationId) data.conversationId = conversationId
|
|
|
+ return request('/api/ai/report/analyze', 'POST', data)
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:语法校验**
|
|
|
+
|
|
|
+运行:`node -e "const fs=require('fs');const src=fs.readFileSync('cfc-frontend/utils/api.js','utf8');const m=src.match(/export const reportQuickAnalyze[\s\S]*?\n}/);if(!m){console.log('FAIL');process.exit(1)}console.log('OK')"`
|
|
|
+预期:OK
|
|
|
+
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/utils/api.js
|
|
|
+git commit -m "feat(frontend): api.js 新增 reportQuickAnalyze 封装"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 4:report-detail.vue 底部悬浮按钮 + 浮层
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 修改:`cfc-frontend/pages/health/report-detail.vue`
|
|
|
+
|
|
|
+**背景:** `report-detail.vue` 共 2162+ 行(大文件),本次只做增量添加,不重构。按钮显示在 view 模式(`pageMode==='view'`)且 `reportId` 存在时。浮层为固定定位弹层,含消息区、输入框、3 个快捷问题。
|
|
|
+
|
|
|
+- [ ] **步骤 1:script 引入 reportQuickAnalyze + 新增 data**
|
|
|
+
|
|
|
+`import` 行(394 行)添加 `reportQuickAnalyze`:
|
|
|
+
|
|
|
+```javascript
|
|
|
+import { getReportDetail, getDanReportDetail, getReportPayload, editHealthReport, parseReportDraft, confirmReportPreview, discardReportDraft, confirmTongue, discardTongue, getFamilyMemberList, addFamilyMember, queryIndicatorKnowledge, queryBacteriaKnowledge, reportQuickAnalyze } from '../../utils/api.js'
|
|
|
+```
|
|
|
+
|
|
|
+`data()` 内 `groupExpandList: []` 附近添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+ // 报告快速分析浮层
|
|
|
+ quickAnalyze: {
|
|
|
+ show: false,
|
|
|
+ sending: false,
|
|
|
+ messages: [], // [{role:'user'|'ai', content}]
|
|
|
+ inputText: '',
|
|
|
+ conversationId: '',
|
|
|
+ usedToday: false // 免费版今日已用(前端预判,最终以后端为准)
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 2:新增快捷问题数组(computed 或 data 常量)**
|
|
|
+
|
|
|
+在 `computed` 中添加(free 版 3 个预设问题):
|
|
|
+
|
|
|
+```javascript
|
|
|
+ quickQuestions: function() {
|
|
|
+ return [
|
|
|
+ '这份报告最需要关注哪些异常指标?',
|
|
|
+ '报告中提示了哪些健康风险?',
|
|
|
+ '有哪些改善建议?'
|
|
|
+ ]
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 3:新增浮层 methods**
|
|
|
+
|
|
|
+在 `methods` 中(`loadData` 附近)添加:
|
|
|
+
|
|
|
+```javascript
|
|
|
+ // ===== 报告快速分析 =====
|
|
|
+ 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() {})
|
|
|
+ },
|
|
|
+ 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
|
|
|
+ })
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+> **注意:** `getMyMembership` 需从 `../../utils/api.js` import。若 `report-detail.vue` 当前未 import,加到 import 行。
|
|
|
+
|
|
|
+- [ ] **步骤 4:模板添加悬浮按钮 + 浮层**
|
|
|
+
|
|
|
+在 `<template>` 的 `</scroll-view>` 之后、`</view>`(根容器)之前插入:
|
|
|
+
|
|
|
+```html
|
|
|
+ <!-- 报告快速分析入口(仅查看模式) -->
|
|
|
+ <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>
|
|
|
+
|
|
|
+ <!-- 快速分析浮层 -->
|
|
|
+ <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>
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 5:添加样式**
|
|
|
+
|
|
|
+在 `<style scoped>` 末尾(`.bottom-spacer` 样式之后)添加:
|
|
|
+
|
|
|
+```css
|
|
|
+/* ===== 报告快速分析 ===== */
|
|
|
+.quick-analyze-fab {
|
|
|
+ position: fixed;
|
|
|
+ right: 30rpx;
|
|
|
+ bottom: 60rpx;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: center;
|
|
|
+ background: linear-gradient(135deg, #5B9BD5, #3B82F6);
|
|
|
+ border-radius: 44rpx;
|
|
|
+ padding: 18rpx 30rpx;
|
|
|
+ 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; }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **步骤 6:语法校验**
|
|
|
+
|
|
|
+运行:`cd cfc-frontend && node -e "const fs=require('fs');const src=fs.readFileSync('pages/health/report-detail.vue','utf8');const m=src.match(/<script>([\s\S]*?)<\/script>/);new Function(m[1].replace(/import[\s\S]*?(?=\n)/g,'').replace(/export default/,'const x='));console.log('OK')"`
|
|
|
+预期:OK
|
|
|
+
|
|
|
+- [ ] **步骤 7:Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add cfc-frontend/pages/health/report-detail.vue
|
|
|
+git commit -m "feat(frontend): 报告详情页新增快速分析浮层(免费版单次/付费版多轮)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 任务 5:验证
|
|
|
+
|
|
|
+**文件:**
|
|
|
+- 验证:`cfc-backend` 编译 + 手动验证清单
|
|
|
+
|
|
|
+- [ ] **步骤 1:后端全量编译**
|
|
|
+
|
|
|
+运行:`cd cfc-backend && mvn clean compile -q -Dmaven.test.skip=true`
|
|
|
+预期:BUILD SUCCESS(无输出即成功)
|
|
|
+
|
|
|
+- [ ] **步骤 2:手动验证清单**
|
|
|
+
|
|
|
+| 场景 | 预期 |
|
|
|
+|------|------|
|
|
|
+| 免费版第 1 次提问 | 返回 answer |
|
|
|
+| 免费版第 2 次提问 | 返回「免费版每日仅限 1 次快速分析,升级会员解锁更多」 |
|
|
|
+| 免费版次日 | 计数重置(Redis TTL 24h) |
|
|
|
+| 付费版多轮 | 追问带 conversationId,AI 记得上文 |
|
|
|
+| 报告上下文注入 | 回答体现报告内容(指标/菌群) |
|
|
|
+| Redis 停掉 | 免费版仍可用(fail-open),后端日志记 warn |
|
|
|
+
|
|
|
+- [ ] **步骤 3:最终 Commit(若有无需变更则跳过)**
|
|
|
+
|
|
|
+```bash
|
|
|
+git status --short
|
|
|
+# 确认无遗漏
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 自检结果
|
|
|
+
|
|
|
+**规格覆盖度检查:**
|
|
|
+- ✅ 会员限制(FREE 每日 1 次 Redis 计数)→ 任务 2 步骤 4
|
|
|
+- ✅ 付费版多轮(conversationId 回传)→ 任务 2 步骤 4 + 任务 4 步骤 3
|
|
|
+- ✅ 免费版锁死多轮(remove conversationId)→ 任务 2 步骤 4
|
|
|
+- ✅ Redis 依赖 + 配置 → 任务 1
|
|
|
+- ✅ 复用 buildChatInputs → 任务 2 步骤 3
|
|
|
+- ✅ 前端浮层(按钮/输入框/快捷问题/加载态/禁用态)→ 任务 4
|
|
|
+- ✅ api.js 封装 → 任务 3
|
|
|
+- ✅ Redis fail-open 降级 → 任务 2 步骤 4
|
|
|
+- ✅ 错误处理(空 query/空 reportId/超限/AI 不可用)→ 任务 2 步骤 4
|
|
|
+
|
|
|
+**占位符扫描:** 无 TODO/待定/后续实现 等占位符。
|
|
|
+
|
|
|
+**类型一致性:**
|
|
|
+- `buildChatInputs(Long userId, Map<String, String> params)` → 任务 2 步骤 3 定义,步骤 4 调用,签名一致
|
|
|
+- `reportQuickAnalyze(query, reportId, conversationId)` → 任务 3 定义,任务 4 步骤 3 调用,签名一致
|
|
|
+- `quickAnalyze.usedToday` → 任务 4 步骤 1 定义,步骤 3/4 使用,一致
|
|
|
+- `reportId` → 任务 4 步骤 3 使用 `this.reportId`,data 已有(563 行 `reportId: null`),一致
|