diff --git a/docs/Testing/T10-full-regression-report.md b/docs/Testing/T10-full-regression-report.md
new file mode 100644
index 0000000..f2209c2
--- /dev/null
+++ b/docs/Testing/T10-full-regression-report.md
@@ -0,0 +1,73 @@
+# T10 Full Regression Test Report
+
+**Plan:** smooth-moseying-tiger · **Task:** T10 Full regression testing
+**Date:** 2026-03-04
+**Scope:** Verify all 6 fixes: post detail tag, post/recipe AI fill, recipe favorite, recipe like visual, knowledge navigation.
+
+---
+
+## Summary
+
+| # | Issue | Test ID | Pass/Fail | Notes |
+|---|--------|---------|-----------|--------|
+| 1 | Post detail tag shows Chinese | T10-1 | *(run tests)* | Asserts `.meal-tag`/`.type-tag` has Chinese on post detail |
+| 2 | Post nutrition AI fill works | T10-2 | *(run tests)* | Clicks "AI 补充营养", expects stats and no error |
+| 3 | Recipe nutrition AI fill works | T10-3 | *(run tests)* | Recipe detail shows nutrition (API or AI fill) |
+| 4 | Recipe favorite no error | T10-4 | *(run tests)* | Favorite button shows 已收藏/已取消收藏, no 操作失败 |
+| 5 | Recipe like emoji changes visual | T10-5 | *(run tests)* | Like button emoji 🤍 ↔ ❤️ on click |
+| 6 | Knowledge articles can navigate to detail | T10-6 | *(run tests)* | 科普文章 → first item → detail has content |
+
+**To get pass/fail:** Run `./scripts/run-t10-regression.sh` or `npx playwright test tests/e2e/bug-regression.spec.ts --grep "T10"`, then set each row to PASS or FAIL from the test output.
+
+---
+
+## How to Run
+
+**Prerequisites:** Frontend at `http://localhost:8080`, backend API at `http://127.0.0.1:20822`, logged-in test account (phone/password in spec).
+
+```bash
+cd /Users/apple/scott2026/msh-system
+npx playwright test tests/e2e/bug-regression.spec.ts --grep "T10" --reporter=list
+```
+
+To run with HTML report:
+
+```bash
+npx playwright test tests/e2e/bug-regression.spec.ts --grep "T10" --reporter=html
+npx playwright show-report tests/e2e/reports
+```
+
+---
+
+## Test Descriptions
+
+| # | Issue | What the test does |
+|---|--------|---------------------|
+| **1** | **Post detail tag shows Chinese** | Opens community → first post → checks `.meal-tag` / `.type-tag` contains Chinese (e.g. 早餐/午餐/晚餐/分享). |
+| **2** | **Post nutrition AI fill works** | Opens a post detail; if "🤖 AI 补充营养" is visible, clicks it, waits ~8s; asserts no error and nutrition stats appear. |
+| **3** | **Recipe nutrition AI fill works** | Opens recipe detail (from tool main or `recipe-detail?id=1`), waits for nutrition card; asserts nutrition values are present (from API or AI fill). |
+| **4** | **Recipe favorite no error** | Opens recipe detail, clicks favorite (⭐) button; expects toast "已收藏" or "已取消收藏" and no "操作失败". |
+| **5** | **Recipe like emoji changes visual** | Opens recipe detail, reads like button emoji (🤍/❤️), clicks like; asserts emoji or state changes. |
+| **6** | **Knowledge articles can navigate to detail** | Goes to nutrition-knowledge → 科普文章 tab → clicks first article; asserts detail page has visible content. |
+
+---
+
+## After Running: Fill Pass/Fail
+
+Replace **Run tests to fill** in the Summary table with **PASS** or **FAIL** per test, and add any Notes (e.g. timeout, missing data, API error).
+
+Example:
+
+| # | Issue | Test ID | Pass/Fail | Notes |
+|---|--------|---------|-----------|--------|
+| 1 | Post detail tag shows Chinese | T10-1 | PASS | |
+| 2 | Post nutrition AI fill works | T10-2 | PASS | |
+| ... | ... | ... | ... | ... |
+
+---
+
+## Related Existing Tests
+
+- **TC-B07** – Diet guide & science article detail content (same scope as item 6).
+- **TC-B08** – Post detail nutrition stats visible.
+- **TC-B09** – Community tabs and post type tags in Chinese (list page; T10-1 checks post *detail* page).
diff --git a/docs/sql/check_knowledge_articles.sql b/docs/sql/check_knowledge_articles.sql
new file mode 100644
index 0000000..c33d29a
--- /dev/null
+++ b/docs/sql/check_knowledge_articles.sql
@@ -0,0 +1,21 @@
+-- T04: Check database for knowledge articles (v2_knowledge)
+-- Run against your DB: mysql -u user -p your_db < docs/sql/check_knowledge_articles.sql
+-- Or use API: GET /api/front/tool/knowledge/stats (returns total, byType, byStatus)
+--
+-- If (guide + article, published) counts are 0: run docs/sql/seed_v2_knowledge_guide_article.sql
+-- If data exists but list click doesn't open detail: check front-end console for
+-- [nutrition-knowledge] goToDetail logs (item keys, resolved id, navigate url).
+
+SELECT 'v2_knowledge: total' AS report, COUNT(*) AS cnt FROM v2_knowledge;
+
+SELECT type, status, COUNT(*) AS cnt
+FROM v2_knowledge
+GROUP BY type, status
+ORDER BY type, status;
+
+-- Published count by type (guide + article must be > 0 for list/click to work)
+SELECT type, COUNT(*) AS cnt
+FROM v2_knowledge
+WHERE status = 'published'
+GROUP BY type
+ORDER BY type;
diff --git a/docs/sql/seed_v2_knowledge_guide_article.sql b/docs/sql/seed_v2_knowledge_guide_article.sql
new file mode 100644
index 0000000..b60ecdb
--- /dev/null
+++ b/docs/sql/seed_v2_knowledge_guide_article.sql
@@ -0,0 +1,43 @@
+-- T04/T05: Seed v2_knowledge with guide/article rows when none exist (status='published')
+-- 1) Check first: run docs/sql/check_knowledge_articles.sql or GET /api/front/tool/knowledge/stats
+-- 2) If (guide + article, published) counts are 0, run this once.
+-- Usage: mysql -u user -p your_db < docs/sql/seed_v2_knowledge_guide_article.sql
+-- Example (local): mysql -h 127.0.0.1 -u test_java -p test_java < docs/sql/seed_v2_knowledge_guide_article.sql
+
+INSERT INTO `v2_knowledge` (
+ `title`, `content`, `summary`, `cover_image`, `type`, `category`,
+ `view_count`, `like_count`, `collect_count`, `share_count`, `status`,
+ `is_recommend`, `sort_order`, `published_at`
+) VALUES
+-- 饮食指南 (type=guide)
+(
+ '慢性肾病饮食指南概述',
+ '
慢性肾病(CKD)患者需在医生或营养师指导下调整饮食,以延缓病情、减少并发症。
基本原则:适量优质蛋白、控制钾磷钠、限水(必要时)、充足热量。
',
+ 'CKD饮食基本原则:优质蛋白、控钾磷钠、限水与充足热量。',
+ NULL, 'guide', '饮食指南', 0, 0, 0, 0, 'published', 1, 10, NOW()
+),
+(
+ '优质蛋白怎么选',
+ '优质蛋白主要来自蛋、奶、瘦肉、鱼及大豆类。
非优质蛋白(如米面、部分豆类)需在总蛋白控制下搭配,避免加重肾脏负担。
',
+ '蛋奶瘦肉鱼及大豆为优质蛋白来源,需在总蛋白限量内合理选择。',
+ NULL, 'guide', '饮食指南', 0, 0, 0, 0, 'published', 1, 9, NOW()
+),
+(
+ '钾与磷的饮食控制要点',
+ '高钾食物:香蕉、橙子、土豆、番茄、深色蔬菜等,需根据血钾水平限量和焯水去钾。
高磷食物:奶制品、坚果、动物内脏、全谷等,需配合磷结合剂与饮食控制。
',
+ '高钾高磷食物识别与限量、焯水去钾等实用要点。',
+ NULL, 'guide', '饮食指南', 0, 0, 0, 0, 'published', 1, 8, NOW()
+),
+-- 科普文章 (type=article)
+(
+ '认识慢性肾病分期与营养',
+ 'CKD 1–5期营养重点不同:早期注重预防与均衡,中晚期需严格控蛋白、钾、磷、钠及液体。
定期复查并与营养师沟通,制定个体化饮食方案。
',
+ '各分期营养侧重点与个体化饮食方案简介。',
+ NULL, 'article', '科普', 0, 0, 0, 0, 'published', 1, 10, NOW()
+),
+(
+ '透析患者一日饮食安排建议',
+ '透析日与非透析日可在营养师指导下微调蛋白与液体。
建议定时定量、少盐少油、适量优质蛋白,并注意钾磷控制。
',
+ '透析患者日常饮食安排与注意事项。',
+ NULL, 'article', '科普', 0, 0, 0, 0, 'published', 1, 9, NOW()
+);
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/interceptor/SwaggerInterceptor.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/interceptor/SwaggerInterceptor.java
index fcb0776..bb43420 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/interceptor/SwaggerInterceptor.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/interceptor/SwaggerInterceptor.java
@@ -6,8 +6,6 @@ import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.FileCopyUtils;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
-import sun.misc.BASE64Decoder;
-
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@@ -34,6 +32,14 @@ public class SwaggerInterceptor extends HandlerInterceptorAdapter {
this.password = password;
this.check = check;
}
+
+ public String getUsername() { return username; }
+ public void setUsername(String username) { this.username = username; }
+ public String getPassword() { return password; }
+ public void setPassword(String password) { this.password = password; }
+ public Boolean getCheck() { return check; }
+ public void setCheck(Boolean check) { this.check = check; }
+
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String authorization = request.getHeader("Authorization");
@@ -52,7 +58,7 @@ public class SwaggerInterceptor extends HandlerInterceptorAdapter {
public boolean httpBasicAuth(String authorization) throws IOException {
if(check){
if (authorization != null && authorization.split(" ").length == 2) {
- String userAndPass = new String(new BASE64Decoder().decodeBuffer(authorization.split(" ")[1]));
+ String userAndPass = new String(java.util.Base64.getDecoder().decode(authorization.split(" ")[1]));
String username = userAndPass.split(":").length == 2 ? userAndPass.split(":")[0] : null;
String password = userAndPass.split(":").length == 2 ? userAndPass.split(":")[1] : null;
return this.username.equals(username) && this.password.equals(password);
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/page/CommonPage.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/page/CommonPage.java
index 7f4d9d2..49e9c23 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/page/CommonPage.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/page/CommonPage.java
@@ -2,7 +2,6 @@ package com.zbkj.common.page;
import com.zbkj.common.constants.Constants;
import com.github.pagehelper.PageInfo;
-import lombok.Data;
import org.springframework.beans.BeanUtils;
import org.springframework.data.domain.Page;
@@ -15,7 +14,6 @@ import java.util.List;
* | Author:ScottPan
* +----------------------------------------------------------------------
*/
-@Data
public class CommonPage {
private Integer page = Constants.DEFAULT_PAGE;
private Integer limit = Constants.DEFAULT_LIMIT;
@@ -23,6 +21,16 @@ public class CommonPage {
private Long total = 0L ;
private List list = new ArrayList<>();
+ public Integer getPage() { return page; }
+ public void setPage(Integer page) { this.page = page; }
+ public Integer getLimit() { return limit; }
+ public void setLimit(Integer limit) { this.limit = limit; }
+ public Integer getTotalPage() { return totalPage; }
+ public void setTotalPage(Integer totalPage) { this.totalPage = totalPage; }
+ public Long getTotal() { return total; }
+ public void setTotal(Long total) { this.total = total; }
+ public List getList() { return list; }
+ public void setList(List list) { this.list = list; }
/**
* 将PageHelper分页后的list转为分页信息
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/ArticleSearchRequest.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/ArticleSearchRequest.java
index 33e2821..a49bbfb 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/ArticleSearchRequest.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/ArticleSearchRequest.java
@@ -35,4 +35,12 @@ public class ArticleSearchRequest implements Serializable {
@ApiModelProperty(value = "搜索关键字")
private String keywords;
+ public String getCid() {
+ return cid;
+ }
+
+ public String getKeywords() {
+ return keywords;
+ }
+
}
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/PageParamRequest.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/PageParamRequest.java
index e215db4..ad4cb4b 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/PageParamRequest.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/PageParamRequest.java
@@ -19,4 +19,12 @@ public class PageParamRequest {
@ApiModelProperty(value = "每页数量", example = Constants.DEFAULT_LIMIT + "")
private int limit = Constants.DEFAULT_LIMIT;
+ public int getPage() {
+ return page;
+ }
+
+ public int getLimit() {
+ return limit;
+ }
+
}
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuRequest.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuRequest.java
index 680b8c3..5920800 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuRequest.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuRequest.java
@@ -64,5 +64,40 @@ public class SystemMenuRequest implements Serializable {
@NotNull(message = "显示状态不能为空")
private Boolean isShow;
+ public Integer getId() {
+ return id;
+ }
+
+ public Integer getPid() {
+ return pid;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getIcon() {
+ return icon;
+ }
+
+ public String getPerms() {
+ return perms;
+ }
+
+ public String getComponent() {
+ return component;
+ }
+
+ public String getMenuType() {
+ return menuType;
+ }
+
+ public Integer getSort() {
+ return sort;
+ }
+
+ public Boolean getIsShow() {
+ return isShow;
+ }
}
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuSearchRequest.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuSearchRequest.java
index 2555148..83e568f 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuSearchRequest.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/request/SystemMenuSearchRequest.java
@@ -22,4 +22,12 @@ public class SystemMenuSearchRequest {
@ApiModelProperty(value = "菜单类型:M-目录,C-菜单,A-按钮")
@StringContains(limitValues = {"M","C","A"}, message = "未知的菜单类型")
private String menuType;
+
+ public String getName() {
+ return name;
+ }
+
+ public String getMenuType() {
+ return menuType;
+ }
}
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/CozeBaseResponse.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/CozeBaseResponse.java
index 1a427ba..4a7dbf3 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/CozeBaseResponse.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/CozeBaseResponse.java
@@ -2,16 +2,7 @@ package com.zbkj.common.response;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
-import lombok.AllArgsConstructor;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-/**
- * Coze API 统一响应
- */
-@Data
-@NoArgsConstructor
-@AllArgsConstructor
@ApiModel(value = "CozeBaseResponse", description = "Coze API 统一响应")
public class CozeBaseResponse {
@@ -24,15 +15,60 @@ public class CozeBaseResponse {
@ApiModelProperty(value = "响应数据")
private T data;
+ public CozeBaseResponse() {
+ }
+
+ public CozeBaseResponse(Integer code, String message, T data) {
+ this.code = code;
+ this.message = message;
+ this.data = data;
+ }
+
+ public Integer getCode() {
+ return code;
+ }
+
+ public void setCode(Integer code) {
+ this.code = code;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ public T getData() {
+ return data;
+ }
+
+ public void setData(T data) {
+ this.data = data;
+ }
+
public static CozeBaseResponse success(T data) {
- return new CozeBaseResponse<>(200, "success", data);
+ CozeBaseResponse response = new CozeBaseResponse<>();
+ response.setCode(200);
+ response.setMessage("success");
+ response.setData(data);
+ return response;
}
public static CozeBaseResponse error(Integer code, String message) {
- return new CozeBaseResponse<>(code, message, null);
+ CozeBaseResponse response = new CozeBaseResponse<>();
+ response.setCode(code);
+ response.setMessage(message);
+ response.setData(null);
+ return response;
}
public static CozeBaseResponse error(String message) {
- return new CozeBaseResponse<>(500, message, null);
+ CozeBaseResponse response = new CozeBaseResponse<>();
+ response.setCode(500);
+ response.setMessage(message);
+ response.setData(null);
+ return response;
}
}
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/MenusResponse.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/MenusResponse.java
index a1f153f..dbb7288 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/MenusResponse.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/MenusResponse.java
@@ -2,9 +2,6 @@ package com.zbkj.common.response;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
-import lombok.Data;
-import lombok.EqualsAndHashCode;
-import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.List;
@@ -15,9 +12,6 @@ import java.util.List;
* | Author:ScottPan
* +----------------------------------------------------------------------
*/
-@Data
-@EqualsAndHashCode(callSuper = false)
-@Accessors(chain = true)
@ApiModel(value="MenusResponse对象", description="系统左侧菜单对象")
public class MenusResponse implements Serializable {
@@ -49,4 +43,23 @@ public class MenusResponse implements Serializable {
@ApiModelProperty(value = "子对象列表")
private List childList;
+
+ public Integer getId() { return id; }
+ public void setId(Integer id) { this.id = id; }
+ public Integer getPid() { return pid; }
+ public void setPid(Integer pid) { this.pid = pid; }
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+ public String getIcon() { return icon; }
+ public void setIcon(String icon) { this.icon = icon; }
+ public String getPerms() { return perms; }
+ public void setPerms(String perms) { this.perms = perms; }
+ public String getComponent() { return component; }
+ public void setComponent(String component) { this.component = component; }
+ public String getMenuType() { return menuType; }
+ public void setMenuType(String menuType) { this.menuType = menuType; }
+ public Integer getSort() { return sort; }
+ public void setSort(Integer sort) { this.sort = sort; }
+ public List getChildList() { return childList; }
+ public void setChildList(List childList) { this.childList = childList; }
}
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAICreateTaskResponse.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAICreateTaskResponse.java
index 04950e2..d921ad4 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAICreateTaskResponse.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAICreateTaskResponse.java
@@ -2,7 +2,6 @@ package com.zbkj.common.response.kieai;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
-import lombok.Data;
import java.io.Serializable;
@@ -13,7 +12,6 @@ import java.io.Serializable;
* | Author:ScottPan
* +----------------------------------------------------------------------
*/
-@Data
@ApiModel(value = "KieAI创建任务响应", description = "KieAI创建任务响应结果")
public class KieAICreateTaskResponse implements Serializable {
@@ -28,6 +26,13 @@ public class KieAICreateTaskResponse implements Serializable {
@ApiModelProperty(value = "响应数据")
private TaskData data;
+ public Integer getCode() { return code; }
+ public void setCode(Integer code) { this.code = code; }
+ public String getMsg() { return msg; }
+ public void setMsg(String msg) { this.msg = msg; }
+ public TaskData getData() { return data; }
+ public void setData(TaskData data) { this.data = data; }
+
/**
* 便捷方法:获取任务ID
*/
@@ -42,7 +47,6 @@ public class KieAICreateTaskResponse implements Serializable {
return code != null && code == 200;
}
- @Data
public static class TaskData implements Serializable {
private static final long serialVersionUID = 1L;
@@ -51,5 +55,10 @@ public class KieAICreateTaskResponse implements Serializable {
@ApiModelProperty(value = "记录ID", example = "5d5ba5bc66c11c1a97f719312c76a5df")
private String recordId;
+
+ public String getTaskId() { return taskId; }
+ public void setTaskId(String taskId) { this.taskId = taskId; }
+ public String getRecordId() { return recordId; }
+ public void setRecordId(String recordId) { this.recordId = recordId; }
}
}
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAINanoBananaResponse.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAINanoBananaResponse.java
index 82cde48..5a0a018 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAINanoBananaResponse.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAINanoBananaResponse.java
@@ -2,7 +2,6 @@ package com.zbkj.common.response.kieai;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
-import lombok.Data;
import java.io.Serializable;
@@ -12,7 +11,6 @@ import java.io.Serializable;
* | Author:ScottPan
* +----------------------------------------------------------------------
*/
-@Data
@ApiModel(value = "KieAI NanoBanana响应", description = "KieAI NanoBanana通用响应")
public class KieAINanoBananaResponse implements Serializable {
@@ -27,6 +25,13 @@ public class KieAINanoBananaResponse implements Serializable {
@ApiModelProperty(value = "响应数据")
private T data;
+ public int getCode() { return code; }
+ public void setCode(int code) { this.code = code; }
+ public String getMessage() { return message; }
+ public void setMessage(String message) { this.message = message; }
+ public T getData() { return data; }
+ public void setData(T data) { this.data = data; }
+
public static KieAINanoBananaResponse success(T data) {
KieAINanoBananaResponse response = new KieAINanoBananaResponse<>();
response.setCode(200);
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAIQueryTaskResponse.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAIQueryTaskResponse.java
index 95c1826..ae2b5ec 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAIQueryTaskResponse.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/response/kieai/KieAIQueryTaskResponse.java
@@ -2,9 +2,9 @@ package com.zbkj.common.response.kieai;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
-import lombok.Data;
import java.io.Serializable;
+import java.util.List;
/**
* KieAI 查询任务响应 DTO(新版 API v1)
@@ -13,7 +13,6 @@ import java.io.Serializable;
* | Author:ScottPan
* +----------------------------------------------------------------------
*/
-@Data
@ApiModel(value = "KieAI查询任务响应", description = "KieAI查询任务状态及结果")
public class KieAIQueryTaskResponse implements Serializable {
@@ -28,6 +27,13 @@ public class KieAIQueryTaskResponse implements Serializable {
@ApiModelProperty(value = "响应数据")
private TaskData data;
+ public Integer getCode() { return code; }
+ public void setCode(Integer code) { this.code = code; }
+ public String getMsg() { return msg; }
+ public void setMsg(String msg) { this.msg = msg; }
+ public TaskData getData() { return data; }
+ public void setData(TaskData data) { this.data = data; }
+
/**
* 便捷方法:获取任务ID
*/
@@ -59,7 +65,7 @@ public class KieAIQueryTaskResponse implements Serializable {
/**
* 便捷方法:获取结果URL数组
*/
- public java.util.List getResultUrls() {
+ public List getResultUrls() {
return data != null ? data.getResultUrls() : null;
}
@@ -70,7 +76,6 @@ public class KieAIQueryTaskResponse implements Serializable {
return code != null && code == 200;
}
- @Data
public static class TaskData implements Serializable {
private static final long serialVersionUID = 1L;
@@ -89,17 +94,9 @@ public class KieAIQueryTaskResponse implements Serializable {
@ApiModelProperty(value = "结果JSON(包含resultUrls)", example = "{\"resultUrls\":[\"https://...\"]}")
private String resultJson;
-
+
@ApiModelProperty(value = "结果URL数组(视频/图片地址)")
- private java.util.List resultUrls;
-
- /**
- * 显式添加 setter 方法以确保 Jackson 反序列化正常工作
- * 解决 "Problem deserializing 'setterless' property" 错误
- */
- public void setResultUrls(java.util.List resultUrls) {
- this.resultUrls = resultUrls;
- }
+ private List resultUrls;
@ApiModelProperty(value = "失败错误码")
private String failCode;
@@ -118,5 +115,30 @@ public class KieAIQueryTaskResponse implements Serializable {
@ApiModelProperty(value = "更新时间戳")
private Long updateTime;
+
+ public String getTaskId() { return taskId; }
+ public void setTaskId(String taskId) { this.taskId = taskId; }
+ public String getModel() { return model; }
+ public void setModel(String model) { this.model = model; }
+ public String getState() { return state; }
+ public void setState(String state) { this.state = state; }
+ public String getParam() { return param; }
+ public void setParam(String param) { this.param = param; }
+ public String getResultJson() { return resultJson; }
+ public void setResultJson(String resultJson) { this.resultJson = resultJson; }
+ public List getResultUrls() { return resultUrls; }
+ public void setResultUrls(List resultUrls) { this.resultUrls = resultUrls; }
+ public String getFailCode() { return failCode; }
+ public void setFailCode(String failCode) { this.failCode = failCode; }
+ public String getFailMsg() { return failMsg; }
+ public void setFailMsg(String failMsg) { this.failMsg = failMsg; }
+ public Long getCostTime() { return costTime; }
+ public void setCostTime(Long costTime) { this.costTime = costTime; }
+ public Long getCompleteTime() { return completeTime; }
+ public void setCompleteTime(Long completeTime) { this.completeTime = completeTime; }
+ public Long getCreateTime() { return createTime; }
+ public void setCreateTime(Long createTime) { this.createTime = createTime; }
+ public Long getUpdateTime() { return updateTime; }
+ public void setUpdateTime(Long updateTime) { this.updateTime = updateTime; }
}
}
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/result/ResultAdvice.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/result/ResultAdvice.java
index c03f5a7..1f7fd09 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/result/ResultAdvice.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/result/ResultAdvice.java
@@ -1,8 +1,8 @@
package com.zbkj.common.result;
+import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zbkj.common.annotation.CustomResponseAnnotation;
-import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
@@ -49,11 +49,14 @@ public class ResultAdvice implements ResponseBodyAdvice {
/**
* 对返回数据进行处理
*/
- @SneakyThrows
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class extends HttpMessageConverter>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof String) {// 如果Controller直接返回String的话,SpringBoot是直接返回,故我们需要手动转换成json。
- return objectMapper.writeValueAsString(CommonResult.success(body));
+ try {
+ return objectMapper.writeValueAsString(CommonResult.success(body));
+ } catch (JsonProcessingException e) {
+ throw new RuntimeException("Failed to serialize response to JSON", e);
+ }
}
if (body instanceof CommonResult) {// 如果返回的结果是CommonResult对象,直接返回即可。
return body;
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/token/FrontTokenComponent.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/token/FrontTokenComponent.java
index 00142a9..74e4090 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/token/FrontTokenComponent.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/token/FrontTokenComponent.java
@@ -149,7 +149,15 @@ public class FrontTokenComponent {
return null;
// throw new CrmebException("登录信息已过期,请重新登录!");
}
- return redisUtil.get(getTokenKey(token));
+ Object value = redisUtil.get(getTokenKey(token));
+ if (value instanceof Integer) {
+ return (Integer) value;
+ }
+ if (value instanceof LoginUserVo) {
+ LoginUserVo loginUser = (LoginUserVo) value;
+ return loginUser.getUser() != null ? loginUser.getUser().getId() : null;
+ }
+ return null;
}
//路由在此处,则返回true,无论用户是否登录都可以访问
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/DateLimitUtilVo.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/DateLimitUtilVo.java
index 59bfa3d..97e30e3 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/DateLimitUtilVo.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/DateLimitUtilVo.java
@@ -19,4 +19,21 @@ public class DateLimitUtilVo {
private String startTime; //开始时间
private String endTime; //结束时间
+
+
+ public String getStartTime() {
+ return startTime;
+ }
+
+ public void setStartTime(String startTime) {
+ this.startTime = startTime;
+ }
+
+ public String getEndTime() {
+ return endTime;
+ }
+
+ public void setEndTime(String endTime) {
+ this.endTime = endTime;
+ }
}
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/ImageMergeUtilVo.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/ImageMergeUtilVo.java
index faf5548..1d25b75 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/ImageMergeUtilVo.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/ImageMergeUtilVo.java
@@ -32,4 +32,29 @@ public class ImageMergeUtilVo {
@ApiModelProperty(value = "y轴", required = true)
@Min(value = 0, message = "y轴至少为0")
private int y; //y轴
+
+ // 手动添加 getter/setter,避免 Lombok 未生效时编译报错
+ public String getPath() {
+ return path;
+ }
+
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ public int getX() {
+ return x;
+ }
+
+ public void setX(int x) {
+ this.x = x;
+ }
+
+ public int getY() {
+ return y;
+ }
+
+ public void setY(int y) {
+ this.y = y;
+ }
}
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/LoginUserVo.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/LoginUserVo.java
index 9a5f993..6c2c744 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/LoginUserVo.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/LoginUserVo.java
@@ -74,9 +74,15 @@ public class LoginUserVo implements UserDetails {
@Override
@JsonIgnore
public Collection extends GrantedAuthority> getAuthorities() {
+ if (permissions == null) {
+ return new ArrayList<>();
+ }
List authorities = new ArrayList<>(permissions.size());
for (SystemPermissions permission : permissions) {
- authorities.add(new SimpleGrantedAuthority(permission.getPath()));
+ String path = permission.getPath();
+ if (path != null) {
+ authorities.add(new SimpleGrantedAuthority(path));
+ }
}
return authorities;
}
@@ -201,4 +207,11 @@ public class LoginUserVo implements UserDetails {
this.user = user;
}
+ /**
+ * 获取用户昵称/显示名称(后台管理员为 realName)
+ */
+ public String getNickname() {
+ return user != null ? user.getRealName() : null;
+ }
+
}
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuCheckVo.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuCheckVo.java
index 897c5d0..dc5c38f 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuCheckVo.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuCheckVo.java
@@ -9,12 +9,6 @@ import lombok.experimental.Accessors;
import java.io.Serializable;
import java.util.List;
-/**
- * 菜单待选中Vo对象
- * +----------------------------------------------------------------------
- * | Author:ScottPan
- * +----------------------------------------------------------------------
- */
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@@ -43,4 +37,60 @@ public class MenuCheckVo implements Serializable {
@ApiModelProperty(value = "子对象列表")
private List childList;
+
+ public Integer getId() {
+ return id;
+ }
+
+ public void setId(Integer id) {
+ this.id = id;
+ }
+
+ public Integer getPid() {
+ return pid;
+ }
+
+ public void setPid(Integer pid) {
+ this.pid = pid;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getIcon() {
+ return icon;
+ }
+
+ public void setIcon(String icon) {
+ this.icon = icon;
+ }
+
+ public Boolean getChecked() {
+ return checked;
+ }
+
+ public void setChecked(Boolean checked) {
+ this.checked = checked;
+ }
+
+ public Integer getSort() {
+ return sort;
+ }
+
+ public void setSort(Integer sort) {
+ this.sort = sort;
+ }
+
+ public List getChildList() {
+ return childList;
+ }
+
+ public void setChildList(List childList) {
+ this.childList = childList;
+ }
}
diff --git a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuTree.java b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuTree.java
index e2fb33d..2aff512 100644
--- a/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuTree.java
+++ b/msh_crmeb_22/crmeb-common/src/main/java/com/zbkj/common/vo/MenuTree.java
@@ -22,6 +22,9 @@ public class MenuTree {
this.menuList = menuList;
}
+ public List getMenuList() { return menuList; }
+ public void setMenuList(List menuList) { this.menuList = menuList; }
+
//建立树形结构
public List buildTree(){
List treeMenus = new ArrayList();
@@ -35,7 +38,7 @@ public class MenuTree {
// 排序
private List sortList(List treeMenus) {
- treeMenus = treeMenus.stream().sorted(Comparator.comparing(MenusResponse::getSort).reversed()).collect(Collectors.toList());
+ treeMenus = treeMenus.stream().sorted(Comparator.comparing((MenusResponse m) -> m.getSort() != null ? m.getSort() : 0).reversed()).collect(Collectors.toList());
treeMenus.forEach(e -> {
if (CollUtil.isNotEmpty(e.getChildList())) {
e.setChildList(sortList(e.getChildList()));
diff --git a/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/ToolController.java b/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/ToolController.java
index d069868..6bbedb9 100644
--- a/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/ToolController.java
+++ b/msh_crmeb_22/crmeb-front/src/main/java/com/zbkj/front/controller/ToolController.java
@@ -53,6 +53,9 @@ public class ToolController {
@Autowired
private ToolPointsService toolPointsService;
+ @Autowired
+ private ToolNutritionFillService toolNutritionFillService;
+
@Autowired
private ToolHomeService toolHomeService;
@@ -312,6 +315,15 @@ public class ToolController {
return CommonResult.success(toolKnowledgeService.getNutrientDetail(name));
}
+ /**
+ * 检查知识库数据:按 type、status 汇总 v2_knowledge 条数(T04)
+ */
+ @ApiOperation(value = "知识库统计")
+ @GetMapping("/knowledge/stats")
+ public CommonResult> getKnowledgeStats() {
+ return CommonResult.success(toolKnowledgeService.getStats());
+ }
+
/**
* 为 cover_image 为空的饮食指南/科普文章生成封面图(KieAI 1:1,100KB 内,上传 OSS 并更新 v2_knowledge)
*/
@@ -322,6 +334,19 @@ public class ToolController {
return CommonResult.success(updated);
}
+ // ==================== AI 营养填充(T06/T07) ====================
+
+ /**
+ * 根据饮食描述文本,用 AI 估算营养数据(热量、蛋白质、钾、磷)
+ */
+ @ApiOperation(value = "AI营养估算")
+ @PostMapping("/nutrition/fill-ai")
+ public CommonResult> fillNutritionByAi(@RequestBody Map data) {
+ String text = data != null && data.containsKey("text") ? String.valueOf(data.get("text")) : null;
+ Map result = toolNutritionFillService.fillFromText(text);
+ return CommonResult.success(result);
+ }
+
// ==================== 打卡社区相关 ====================
/**
@@ -352,6 +377,16 @@ public class ToolController {
return CommonResult.success(toolCommunityService.publish(data));
}
+ /**
+ * 帖子营养 AI 填充:根据帖子标题与内容用 AI 估算营养数据并更新帖子
+ */
+ @ApiOperation(value = "帖子营养AI填充")
+ @PostMapping("/community/post/{postId}/fill-nutrition")
+ public CommonResult> fillPostNutrition(
+ @ApiParam(value = "帖子ID", required = true) @PathVariable Long postId) {
+ return CommonResult.success(toolCommunityService.fillNutrition(postId));
+ }
+
/**
* 点赞/取消点赞
*/
@@ -548,10 +583,22 @@ public class ToolController {
@ApiOperation(value = "收藏/取消收藏食谱")
@PostMapping("/recipe/favorite")
public CommonResult toggleRecipeFavorite(@RequestBody Map data) {
- toolRecipeService.toggleFavorite((Long) data.get("recipeId"), (Boolean) data.get("isFavorite"));
+ Long recipeId = ((Number) data.get("recipeId")).longValue();
+ Boolean isFavorite = (Boolean) data.get("isFavorite");
+ toolRecipeService.toggleFavorite(recipeId, isFavorite);
return CommonResult.success("操作成功");
}
+ /**
+ * 食谱营养 AI 填充:根据食谱食材用 Coze AI 分析并填充营养数据(能量、蛋白质、钾、磷、钠),更新食谱并返回
+ */
+ @ApiOperation(value = "食谱营养AI填充")
+ @PostMapping("/recipe/{recipeId}/fill-nutrition")
+ public CommonResult> fillRecipeNutrition(
+ @ApiParam(value = "食谱ID", required = true) @PathVariable Long recipeId) {
+ return CommonResult.success(toolRecipeService.fillNutrition(recipeId));
+ }
+
// ==================== 文件上传相关 ====================
/**
diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/SystemConfigServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/SystemConfigServiceImpl.java
index 1f2c94d..4a57dfd 100644
--- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/SystemConfigServiceImpl.java
+++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/SystemConfigServiceImpl.java
@@ -163,7 +163,9 @@ public class SystemConfigServiceImpl extends ServiceImpl(){{put("id","log_"+System.currentTimeMillis()+"_b");put("timestamp",System.currentTimeMillis());put("location","ToolCheckinServiceImpl.java:135");put("message","enableAIVideo parsed");put("data",new java.util.HashMap(){{put("rawValue",data.get("enableAIVideo"));put("parsedBoolean",enableAIVideo);put("setToUserSign",enableAIVideo ? 1 : 0);}});put("hypothesisId","E");}}) + "\n").getBytes(), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND); } catch(Exception e) {}
- // #endregion
} else {
userSign.setEnableAiVideo(0);
- // #region agent log
- try { java.nio.file.Files.write(java.nio.file.Paths.get("/Users/apple/scott2026/msh-system/.cursor/debug.log"), (new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(new java.util.HashMap(){{put("id","log_"+System.currentTimeMillis()+"_c");put("timestamp",System.currentTimeMillis());put("location","ToolCheckinServiceImpl.java:138");put("message","enableAIVideo NOT present or null");put("data",new java.util.HashMap(){{put("containsKey",data.containsKey("enableAIVideo"));put("isNotNull",data.get("enableAIVideo")!=null);}});put("hypothesisId","E");}}) + "\n").getBytes(), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND); } catch(Exception e) {}
- // #endregion
}
if (data.containsKey("enableAIAnalysis") && ObjectUtil.isNotNull(data.get("enableAIAnalysis"))) {
boolean enableAIAnalysis = Boolean.parseBoolean(data.get("enableAIAnalysis").toString());
@@ -176,24 +189,68 @@ public class ToolCheckinServiceImpl implements ToolCheckinService {
userSignDao.insert(userSign);
- // 如果有taskId,尝试关联已存在的article记录(由Sora2服务创建的)
+ // enableAiVideo==1 且无 taskId 时,由后端主动调用 createImageToVideoTask 并回写 taskId
+ if (userSign.getEnableAiVideo() != null && userSign.getEnableAiVideo() == 1
+ && StrUtil.isNotBlank(userSign.getPhotosJson())
+ && StrUtil.isBlank(userSign.getTaskId())) {
+ try {
+ String field103 = null;
+ try {
+ field103 = systemConfigService.getValueByKey("field103");
+ } catch (Exception ignored) {
+ }
+ boolean useGrok = StrUtil.isNotBlank(field103) && "grok".equalsIgnoreCase(field103.trim());
+
+ String firstImageUrl = parseFirstImage(userSign.getPhotosJson());
+ if (StrUtil.isBlank(firstImageUrl)) {
+ log.warn("submit 内触发视频任务跳过:photosJson 解析不到有效图片");
+ } else {
+ String[] imageUrls = new String[]{firstImageUrl};
+ String mealTypeLabel = getMealTypeLabel(userSign.getMealType());
+ String videoPrompt = StrUtil.isNotBlank(userSign.getNotes())
+ ? userSign.getNotes()
+ : "健康" + mealTypeLabel + "打卡";
+ String videoTitle = videoPrompt;
+
+ String taskId = useGrok
+ ? toolGrokService.createImageToVideoTask(
+ null, videoTitle, videoPrompt, imageUrls,
+ "9:16", null, true,
+ kieAIConfig.getApiCallbackUrl(), null,
+ String.valueOf(userId), null)
+ : toolSora2Service.createImageToVideoTask(
+ null, videoTitle, videoPrompt, imageUrls,
+ "9:16", null, true,
+ kieAIConfig.getApiCallbackUrl(), null,
+ String.valueOf(userId), null);
+
+ if (StrUtil.isNotBlank(taskId)) {
+ userSign.setTaskId(taskId);
+ userSignDao.updateById(userSign);
+ log.info("submit 内触发视频任务成功,taskId: {}, checkInRecordId: {}", taskId, userSign.getId());
+ } else {
+ log.warn("submit 内触发视频任务返回空 taskId,checkInRecordId: {}", userSign.getId());
+ }
+ }
+ } catch (Exception e) {
+ log.error("submit 内触发视频任务失败,不影响打卡流程: {}", e.getMessage(), e);
+ }
+ }
+
+ // 如果有 taskId,尝试关联已存在的 article 记录(由前端或上文创建的视频任务)
if (StrUtil.isNotBlank(userSign.getTaskId())) {
try {
LambdaQueryWrapper articleQuery = new LambdaQueryWrapper<>();
articleQuery.eq(Article::getTaskId, userSign.getTaskId());
- articleQuery.isNull(Article::getCheckInRecordId); // 只更新未关联的
-
+ articleQuery.isNull(Article::getCheckInRecordId);
Article existingArticle = articleDao.selectOne(articleQuery);
-
if (existingArticle != null) {
existingArticle.setCheckInRecordId(userSign.getId());
articleDao.updateById(existingArticle);
- log.info("关联article记录成功,articleId: {}, checkInRecordId: {}",
- existingArticle.getId(), userSign.getId());
+ log.info("关联article记录成功,articleId: {}, checkInRecordId: {}", existingArticle.getId(), userSign.getId());
}
} catch (Exception e) {
log.error("关联article记录失败", e);
- // 不影响打卡流程
}
}
@@ -287,11 +344,12 @@ public class ToolCheckinServiceImpl implements ToolCheckinService {
Map result = new HashMap<>();
result.put("id", userSign.getId());
result.put("message", "打卡成功");
+ result.put("taskId", userSign.getTaskId() != null ? userSign.getTaskId() : "");
result.put("enableAIVideo", userSign.getEnableAiVideo() != null && userSign.getEnableAiVideo() == 1);
result.put("enableAIAnalysis", userSign.getEnableAiAnalysis() != null && userSign.getEnableAiAnalysis() == 1);
-
+
// TODO: 如果启用AI分析,可以在这里触发异步AI识别任务
-
+
return result;
}
@@ -571,6 +629,23 @@ public class ToolCheckinServiceImpl implements ToolCheckinService {
* @param mealType 餐次类型
* @return 中文标签
*/
+ /**
+ * 从 photosJson 解析第一张图片 URL。若为 JSON 数组取第一个元素,否则整体当作单张 URL。
+ */
+ private String parseFirstImage(String photosJson) {
+ if (StrUtil.isBlank(photosJson)) {
+ return "";
+ }
+ try {
+ if (photosJson.trim().startsWith("[")) {
+ com.alibaba.fastjson.JSONArray arr = com.alibaba.fastjson.JSON.parseArray(photosJson);
+ return (arr != null && !arr.isEmpty()) ? arr.getString(0) : "";
+ }
+ } catch (Exception ignored) {
+ }
+ return photosJson.trim();
+ }
+
private String getMealTypeLabel(String mealType) {
if (StrUtil.isBlank(mealType)) {
return "饮食";
diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolCommunityServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolCommunityServiceImpl.java
index 6887f42..6183074 100644
--- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolCommunityServiceImpl.java
+++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolCommunityServiceImpl.java
@@ -22,11 +22,14 @@ import com.zbkj.service.dao.tool.V2CommunityPostDao;
import com.zbkj.service.service.UserService;
import com.zbkj.service.service.UserSignService;
import com.zbkj.service.service.tool.ToolCommunityService;
+import com.zbkj.service.service.tool.ToolNutritionFillService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import com.alibaba.fastjson.JSON;
+
import javax.annotation.Resource;
import java.util.*;
import java.util.stream.Collectors;
@@ -65,6 +68,9 @@ public class ToolCommunityServiceImpl implements ToolCommunityService {
@Resource
private UserSignService userSignService;
+ @Resource
+ private ToolNutritionFillService toolNutritionFillService;
+
/**
* 获取社区内容列表
* @param pageParamRequest 分页参数
@@ -80,8 +86,7 @@ public class ToolCommunityServiceImpl implements ToolCommunityService {
lqw.eq(V2CommunityPost::getStatus, "published");
if ("latest".equals(tab)) {
- lqw.isNotNull(V2CommunityPost::getCheckInRecordId); // 只显示打卡帖子
- lqw.orderByDesc(V2CommunityPost::getPostId); // 按ID降序
+ lqw.orderByDesc(V2CommunityPost::getCreatedAt);
} else if ("hot".equals(tab)) {
lqw.orderByDesc(V2CommunityPost::getHotScore);
} else if ("recommend".equals(tab)) {
@@ -585,4 +590,33 @@ public class ToolCommunityServiceImpl implements ToolCommunityService {
result.put("message", "分享成功");
return result;
}
+
+ /**
+ * 根据帖子内容用 AI 填充营养数据并更新帖子
+ * @param postId 帖子ID
+ * @return 填充后的营养数据
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map fillNutrition(Long postId) {
+ Integer userId = frontTokenComponent.getUserId();
+ if (userId == null) throw new CrmebException("请登录");
+
+ V2CommunityPost post = v2CommunityPostDao.selectById(postId);
+ if (post == null) throw new CrmebException("帖子不存在");
+ if (userId.longValue() != post.getUserId()) throw new CrmebException("只能为自己的帖子填充营养数据");
+
+ StringBuilder text = new StringBuilder();
+ if (StrUtil.isNotBlank(post.getTitle())) text.append(post.getTitle()).append(" ");
+ if (StrUtil.isNotBlank(post.getContent())) text.append(post.getContent());
+ if (text.length() == 0) throw new CrmebException("帖子标题和内容为空,无法估算营养");
+
+ Map nutrition = toolNutritionFillService.fillFromText(text.toString());
+ if (nutrition.isEmpty()) throw new CrmebException("AI 未能估算出营养数据");
+
+ post.setNutritionDataJson(JSON.toJSONString(nutrition));
+ post.setUpdatedAt(new Date());
+ v2CommunityPostDao.updateById(post);
+ return nutrition;
+ }
}
diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolGrokServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolGrokServiceImpl.java
index ccf6f8c..de5fabb 100644
--- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolGrokServiceImpl.java
+++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolGrokServiceImpl.java
@@ -13,22 +13,55 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
+import java.util.Arrays;
+import java.util.HashSet;
import java.util.HashMap;
import java.util.Map;
+import java.util.Set;
+
+import cn.hutool.core.util.StrUtil;
/**
* KieAI Grok 视频生成服务实现类
* 对接 https://kie.ai/grok-imagine
* URL: /text-to-video 和 /image-to-video
* Model: grok-imagine-text-to-video 和 grok-imagine-image-to-video
+ * aspect_ratio 仅支持: 2:3, 3:2, 1:1, 16:9, 9:16,未传或非法时默认 9:16
*/
@Service
public class ToolGrokServiceImpl implements ToolGrokService {
private static final Logger logger = LoggerFactory.getLogger(ToolGrokServiceImpl.class);
- private static final String MODEL_TEXT_TO_VIDEO = "grok-imagine-text-to-video";
- private static final String MODEL_IMAGE_TO_VIDEO = "grok-imagine-image-to-video";
+ private static final String MODEL_TEXT_TO_VIDEO = "grok-imagine/text-to-video";
+ private static final String MODEL_IMAGE_TO_VIDEO = "grok-imagine/image-to-video";
+
+ /** Grok 支持的 aspect_ratio,未传或非法时默认使用 9:16 */
+ private static final String DEFAULT_ASPECT_RATIO = "9:16";
+ private static final Set ALLOWED_ASPECT_RATIOS = new HashSet<>(Arrays.asList("2:3", "3:2", "1:1", "16:9", "9:16"));
+
+ /**
+ * 规范化为 Grok 支持的 aspect_ratio(2:3, 3:2, 1:1, 16:9, 9:16),无参或非法时返回 9:16
+ */
+ private static String normalizeAspectRatio(String aspectRatio) {
+ if (StrUtil.isBlank(aspectRatio)) {
+ return DEFAULT_ASPECT_RATIO;
+ }
+ String trimmed = aspectRatio.trim();
+ if (ALLOWED_ASPECT_RATIOS.contains(trimmed)) {
+ return trimmed;
+ }
+ switch (trimmed.toLowerCase()) {
+ case "portrait":
+ return "9:16";
+ case "landscape":
+ return "16:9";
+ case "square":
+ return "1:1";
+ default:
+ return DEFAULT_ASPECT_RATIO;
+ }
+ }
@Autowired
private ArticleDao articleDao;
@@ -40,15 +73,11 @@ public class ToolGrokServiceImpl implements ToolGrokService {
public String createTextToVideoTask(String tenantId, String title, String prompt, String aspectRatio,
Integer nFrames, Boolean removeWatermark, String callBackUrl, String apiKey, String uid, String nickname) {
try {
- String baseUrl = kieAIConfig.getGrokBaseUrl();
- if (baseUrl == null || baseUrl.isEmpty()) {
- baseUrl = "https://kie.ai/grok-imagine";
- }
- String url = baseUrl + "/text-to-video";
+ String url = kieAIConfig.getBaseUrl() + "/api/v1/jobs/createTask";
Map input = new HashMap<>();
input.put("prompt", prompt);
- if (aspectRatio != null) input.put("aspect_ratio", aspectRatio);
+ input.put("aspect_ratio", normalizeAspectRatio(aspectRatio));
if (removeWatermark != null) input.put("remove_watermark", removeWatermark);
input.put("n_frames", "15");
@@ -68,6 +97,18 @@ public class ToolGrokServiceImpl implements ToolGrokService {
String response = HttpRequestUtils.postWithHeaders(url, jsonPayload, null, headers);
logger.info("Grok创建文生视频任务响应: {}", response);
+ if (response != null) {
+ String trimmed = response.trim();
+ if (trimmed.startsWith("<")) {
+ logger.error("Grok文生视频接口返回非JSON(可能404),URL: {}", url);
+ throw new IllegalArgumentException("Grok文生视频接口返回异常(非JSON,可能404): 请检查 KieAI 配置中的 Grok baseUrl 或 API 路径是否正确");
+ }
+ }
+ if (response == null || response.isEmpty()) {
+ logger.error("Grok文生视频接口返回为空, URL: {}", url);
+ return null;
+ }
+
JSONObject result = JSON.parseObject(response);
if (result != null && result.containsKey("data")) {
JSONObject data = result.getJSONObject("data");
@@ -99,6 +140,8 @@ public class ToolGrokServiceImpl implements ToolGrokService {
}
}
return null;
+ } catch (IllegalArgumentException e) {
+ throw e;
} catch (Exception e) {
logger.error("Grok创建文生视频任务失败: {}", e.getMessage(), e);
return null;
@@ -109,16 +152,12 @@ public class ToolGrokServiceImpl implements ToolGrokService {
public String createImageToVideoTask(String tenantId, String title, String prompt, String[] imageUrls,
String aspectRatio, Integer nFrames, Boolean removeWatermark, String callBackUrl, String apiKey, String uid, String nickname) {
try {
- String baseUrl = kieAIConfig.getGrokBaseUrl();
- if (baseUrl == null || baseUrl.isEmpty()) {
- baseUrl = "https://kie.ai/grok-imagine";
- }
- String url = baseUrl + "/image-to-video";
+ String url = kieAIConfig.getBaseUrl() + "/api/v1/jobs/createTask";
Map input = new HashMap<>();
input.put("prompt", prompt);
input.put("image_urls", imageUrls);
- if (aspectRatio != null) input.put("aspect_ratio", aspectRatio);
+ input.put("aspect_ratio", normalizeAspectRatio(aspectRatio));
if (removeWatermark != null) input.put("remove_watermark", removeWatermark);
input.put("n_frames", "15");
@@ -138,6 +177,19 @@ public class ToolGrokServiceImpl implements ToolGrokService {
String response = HttpRequestUtils.postWithHeaders(url, jsonPayload, null, headers);
logger.info("Grok创建图生视频任务响应: {}", response);
+ // 远程返回 404 等时可能是 HTML,直接 parseObject 会抛 JSONException
+ if (response != null) {
+ String trimmed = response.trim();
+ if (trimmed.startsWith("<")) {
+ logger.error("Grok图生视频接口返回非JSON(可能404),URL: {}, 响应前200字符: {}", url, trimmed.length() > 200 ? trimmed.substring(0, 200) + "..." : trimmed);
+ throw new IllegalArgumentException("Grok图生视频接口返回异常(非JSON,可能404): 请检查 KieAI 配置中的 Grok baseUrl 或 API 路径是否正确");
+ }
+ }
+ if (response == null || response.isEmpty()) {
+ logger.error("Grok图生视频接口返回为空, URL: {}", url);
+ return null;
+ }
+
JSONObject result = JSON.parseObject(response);
if (result != null && result.containsKey("data")) {
JSONObject data = result.getJSONObject("data");
@@ -169,6 +221,8 @@ public class ToolGrokServiceImpl implements ToolGrokService {
}
}
return null;
+ } catch (IllegalArgumentException e) {
+ throw e;
} catch (Exception e) {
logger.error("Grok创建图生视频任务失败: {}", e.getMessage(), e);
return null;
diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolKnowledgeServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolKnowledgeServiceImpl.java
index ed184ac..3a4e0bd 100644
--- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolKnowledgeServiceImpl.java
+++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolKnowledgeServiceImpl.java
@@ -149,4 +149,29 @@ public class ToolKnowledgeServiceImpl implements ToolKnowledgeService {
}
return "健康营养主题配图,标题:" + title + "。简洁现代风格,1:1 方图。";
}
+
+ @Override
+ public Map getStats() {
+ Map stats = new HashMap<>();
+ // 总数
+ long total = v2KnowledgeDao.selectCount(null);
+ stats.put("total", total);
+
+ Map byType = new HashMap<>();
+ for (String type : new String[]{"guide", "article", "nutrients", "recipe"}) {
+ LambdaQueryWrapper lqw = new LambdaQueryWrapper<>();
+ lqw.eq(V2Knowledge::getType, type);
+ byType.put(type, v2KnowledgeDao.selectCount(lqw));
+ }
+ stats.put("byType", byType);
+
+ Map byStatus = new HashMap<>();
+ for (String status : new String[]{"published", "draft", "deleted"}) {
+ LambdaQueryWrapper lqw = new LambdaQueryWrapper<>();
+ lqw.eq(V2Knowledge::getStatus, status);
+ byStatus.put(status, v2KnowledgeDao.selectCount(lqw));
+ }
+ stats.put("byStatus", byStatus);
+ return stats;
+ }
}
diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolNutritionFillServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolNutritionFillServiceImpl.java
new file mode 100644
index 0000000..d4dd82d
--- /dev/null
+++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolNutritionFillServiceImpl.java
@@ -0,0 +1,100 @@
+package com.zbkj.service.service.impl.tool;
+
+import cn.hutool.core.util.StrUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.zbkj.common.request.kieai.KieAIGeminiChatRequest;
+import com.zbkj.service.service.tool.ToolKieAIService;
+import com.zbkj.service.service.tool.ToolNutritionFillService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * AI 营养数据填充:用 Gemini 根据饮食描述估算热量、蛋白质、钾、磷
+ * +----------------------------------------------------------------------
+ * | Author:ScottPan
+ * +----------------------------------------------------------------------
+ */
+@Slf4j
+@Service
+public class ToolNutritionFillServiceImpl implements ToolNutritionFillService {
+
+ private static final String SYSTEM_PROMPT = "你是一个营养估算助手。用户会给出一段饮食描述(如一顿饭或几道菜),请仅输出一个 JSON 对象,不要其他文字或 markdown 标记。"
+ + " JSON 格式:{\"energyKcal\": 数字或null, \"proteinG\": 数字或null, \"potassiumMg\": 数字或null, \"phosphorusMg\": 数字或null}。"
+ + " 热量单位千卡,蛋白质克,钾和磷毫克。若无法估算某项则填 null。只输出这一行 JSON。";
+
+ @Resource
+ private ToolKieAIService toolKieAIService;
+
+ @Override
+ public Map fillFromText(String text) {
+ if (StrUtil.isBlank(text)) {
+ return new HashMap<>();
+ }
+ KieAIGeminiChatRequest request = new KieAIGeminiChatRequest();
+ KieAIGeminiChatRequest.Message systemMsg = new KieAIGeminiChatRequest.Message();
+ systemMsg.setRole("system");
+ systemMsg.setContent(SYSTEM_PROMPT);
+ KieAIGeminiChatRequest.Message userMsg = new KieAIGeminiChatRequest.Message();
+ userMsg.setRole("user");
+ userMsg.setContent("请根据以下饮食描述估算营养数据并只输出 JSON:\n\n" + text.trim());
+ request.setMessages(java.util.Arrays.asList(systemMsg, userMsg));
+ request.setStream(false);
+
+ try {
+ Map geminiResult = toolKieAIService.geminiChat(request);
+ String content = extractAssistantContent(geminiResult);
+ if (StrUtil.isBlank(content)) {
+ log.warn("AI nutrition fill: no content in response");
+ return new HashMap<>();
+ }
+ content = content.trim();
+ // 去掉可能的 markdown 代码块
+ if (content.startsWith("```")) {
+ int start = content.indexOf("\n");
+ if (start > 0) content = content.substring(start + 1);
+ int end = content.lastIndexOf("```");
+ if (end > 0) content = content.substring(0, end).trim();
+ }
+ JSONObject obj = JSON.parseObject(content);
+ Map out = new HashMap<>();
+ if (obj.containsKey("energyKcal")) out.put("energyKcal", obj.get("energyKcal"));
+ if (obj.containsKey("proteinG")) out.put("proteinG", obj.get("proteinG"));
+ if (obj.containsKey("potassiumMg")) out.put("potassiumMg", obj.get("potassiumMg"));
+ if (obj.containsKey("phosphorusMg")) out.put("phosphorusMg", obj.get("phosphorusMg"));
+ return out;
+ } catch (Exception e) {
+ log.warn("AI nutrition fill failed for text length {}: {}", text.length(), e.getMessage());
+ return new HashMap<>();
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private String extractAssistantContent(Map geminiResult) {
+ Object choices = geminiResult.get("choices");
+ if (!(choices instanceof List) || ((List>) choices).isEmpty()) {
+ return null;
+ }
+ Object first = ((List>) choices).get(0);
+ if (!(first instanceof Map)) return null;
+ Object message = ((Map) first).get("message");
+ if (!(message instanceof Map)) return null;
+ Object content = ((Map) message).get("content");
+ if (content instanceof String) return (String) content;
+ if (content instanceof List) {
+ for (Object part : (List>) content) {
+ if (part instanceof Map) {
+ Object type = ((Map, ?>) part).get("type");
+ Object text = ((Map, ?>) part).get("text");
+ if ("text".equals(type) && text != null) return text.toString();
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolRecipeServiceImpl.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolRecipeServiceImpl.java
index 5e4eb4e..4058b58 100644
--- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolRecipeServiceImpl.java
+++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/impl/tool/ToolRecipeServiceImpl.java
@@ -2,13 +2,24 @@ package com.zbkj.service.service.impl.tool;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.coze.openapi.client.chat.CreateChatResp;
+import com.coze.openapi.client.chat.RetrieveChatResp;
+import com.coze.openapi.client.chat.message.ListMessageResp;
import com.github.pagehelper.PageHelper;
+import com.zbkj.common.config.CozeConfig;
import com.zbkj.common.exception.CrmebException;
import com.zbkj.common.model.tool.V2Recipe;
import com.zbkj.common.request.PageParamRequest;
+import com.zbkj.common.request.coze.CozeChatRequest;
+import com.zbkj.common.request.coze.CozeListMessageRequest;
+import com.zbkj.common.request.coze.CozeRetrieveChatRequest;
+import com.zbkj.common.response.CozeBaseResponse;
import com.zbkj.common.token.FrontTokenComponent;
import com.zbkj.service.dao.tool.V2RecipeDao;
+import com.zbkj.service.service.tool.ToolCozeService;
import com.zbkj.service.service.tool.ToolRecipeService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@@ -16,7 +27,11 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
+import java.math.BigDecimal;
import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -30,12 +45,22 @@ import java.util.Map;
@Service
public class ToolRecipeServiceImpl implements ToolRecipeService {
+ private static final String NUTRITION_PROMPT_PREFIX = "你是一个营养估算助手。根据以下食谱信息,仅输出一个 JSON 对象,不要其他文字或 markdown。"
+ + " JSON 格式:{\"energyKcal\": 数字或null, \"proteinG\": 数字或null, \"potassiumMg\": 数字或null, \"phosphorusMg\": 数字或null, \"sodiumMg\": 数字或null}。"
+ + " 热量单位千卡,蛋白质克,钾、磷、钠单位毫克。若无法估算某项则填 null。只输出这一行 JSON。\n\n食谱:\n";
+
@Resource
private V2RecipeDao v2RecipeDao;
@Autowired
private FrontTokenComponent frontTokenComponent;
+ @Autowired(required = false)
+ private ToolCozeService toolCozeService;
+
+ @Autowired(required = false)
+ private CozeConfig cozeConfig;
+
/**
* 获取食谱列表
* @param pageParamRequest 分页参数
@@ -99,4 +124,250 @@ public class ToolRecipeServiceImpl implements ToolRecipeService {
v2RecipeDao.updateById(recipe);
}
}
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Map fillNutrition(Long recipeId) {
+ if (toolCozeService == null || cozeConfig == null) {
+ throw new CrmebException("Coze AI 未配置,无法填充营养数据");
+ }
+ V2Recipe recipe = v2RecipeDao.selectById(recipeId);
+ if (recipe == null) {
+ throw new CrmebException("食谱不存在");
+ }
+ StringBuilder text = new StringBuilder();
+ if (StrUtil.isNotBlank(recipe.getName())) text.append("名称:").append(recipe.getName()).append("\n");
+ if (StrUtil.isNotBlank(recipe.getDescription())) text.append("描述:").append(recipe.getDescription()).append("\n");
+ if (StrUtil.isNotBlank(recipe.getIngredientsJson())) text.append("食材:").append(recipe.getIngredientsJson()).append("\n");
+ if (text.length() == 0) {
+ throw new CrmebException("食谱名称、描述和食材为空,无法估算营养");
+ }
+ String userMessage = NUTRITION_PROMPT_PREFIX + text.toString();
+ CozeChatRequest chatReq = new CozeChatRequest();
+ chatReq.setBotId(cozeConfig.getDefaultBotId());
+ chatReq.setUserId(cozeConfig.getDefaultUserId());
+ chatReq.setAdditionalMessages(Collections.singletonList(Collections.singletonMap("content", userMessage)));
+ CozeBaseResponse chatResp = toolCozeService.chat(chatReq);
+ if (chatResp == null || chatResp.getCode() == null || chatResp.getCode() != 200 || chatResp.getData() == null) {
+ throw new CrmebException("Coze AI 调用失败:" + (chatResp != null ? chatResp.getMessage() : "无响应"));
+ }
+ Object data = chatResp.getData();
+ if (!(data instanceof CreateChatResp)) {
+ throw new CrmebException("Coze 返回格式异常");
+ }
+ CreateChatResp createResp = (CreateChatResp) data;
+ String conversationId = getCreateChatRespConversationId(createResp);
+ String chatId = getCreateChatRespId(createResp);
+ if (StrUtil.isBlank(conversationId) || StrUtil.isBlank(chatId)) {
+ throw new CrmebException("Coze 未返回会话信息");
+ }
+ for (int i = 0; i < 30; i++) {
+ try {
+ Thread.sleep(1500);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new CrmebException("等待 AI 响应被中断");
+ }
+ CozeRetrieveChatRequest retrieveReq = new CozeRetrieveChatRequest();
+ retrieveReq.setConversationId(conversationId);
+ retrieveReq.setChatId(chatId);
+ CozeBaseResponse retrieveResp = toolCozeService.retrieveChat(retrieveReq);
+ if (retrieveResp == null || retrieveResp.getCode() != 200 || retrieveResp.getData() == null) continue;
+ Object retrieveData = retrieveResp.getData();
+ if (!(retrieveData instanceof RetrieveChatResp)) continue;
+ RetrieveChatResp retrieve = (RetrieveChatResp) retrieveData;
+ String status = getRetrieveChatRespStatus(retrieve);
+ if ("completed".equalsIgnoreCase(status)) {
+ String content = getAssistantContentFromListMessages(conversationId, chatId);
+ if (StrUtil.isNotBlank(content)) {
+ Map nutrition = parseNutritionJson(content);
+ if (!nutrition.isEmpty()) {
+ applyNutritionToRecipe(recipe, nutrition);
+ recipe.setUpdatedAt(new Date());
+ v2RecipeDao.updateById(recipe);
+ return nutrition;
+ }
+ }
+ throw new CrmebException("AI 未能返回有效营养数据");
+ }
+ if ("failed".equalsIgnoreCase(status)) {
+ throw new CrmebException("Coze AI 任务执行失败");
+ }
+ }
+ throw new CrmebException("等待 Coze AI 响应超时");
+ }
+
+ private String getAssistantContentFromListMessages(String conversationId, String chatId) {
+ CozeListMessageRequest listReq = new CozeListMessageRequest();
+ listReq.setConversationId(conversationId);
+ listReq.setChatId(chatId);
+ CozeBaseResponse listResp = toolCozeService.listMessages(listReq);
+ if (listResp == null || listResp.getCode() != 200 || listResp.getData() == null) return null;
+ Object listData = listResp.getData();
+ if (!(listData instanceof ListMessageResp)) return null;
+ ListMessageResp list = (ListMessageResp) listData;
+ List> messages = getListMessageRespMessages(list);
+ if (messages == null) return null;
+ for (int i = messages.size() - 1; i >= 0; i--) {
+ Object msg = messages.get(i);
+ if (msg == null) continue;
+ String role = getMessageRole(msg);
+ if (!"assistant".equalsIgnoreCase(role)) continue;
+ String content = getMessageContent(msg);
+ if (StrUtil.isNotBlank(content)) return content;
+ }
+ return null;
+ }
+
+ @SuppressWarnings("unchecked")
+ private List> getListMessageRespMessages(ListMessageResp list) {
+ try {
+ try {
+ return (List>) ListMessageResp.class.getMethod("getData").invoke(list);
+ } catch (NoSuchMethodException e) {
+ return (List>) ListMessageResp.class.getMethod("getMessages").invoke(list);
+ }
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private String getMessageRole(Object msg) {
+ try {
+ if (msg instanceof Map) return (String) ((Map, ?>) msg).get("role");
+ return (String) msg.getClass().getMethod("getRole").invoke(msg);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private String getMessageContent(Object msg) {
+ try {
+ if (msg instanceof Map) {
+ Object content = ((Map, ?>) msg).get("content");
+ if (content instanceof List) {
+ for (Object part : (List>) content) {
+ if (part instanceof Map) {
+ Object type = ((Map, ?>) part).get("type");
+ Object text = ((Map, ?>) part).get("text");
+ if ("text".equals(type) && text != null) return text.toString();
+ }
+ }
+ }
+ return content != null ? content.toString() : null;
+ }
+ Object content = msg.getClass().getMethod("getContent").invoke(msg);
+ if (content instanceof List) {
+ for (Object part : (List>) content) {
+ if (part != null && part.getClass().getSimpleName().contains("Text")) {
+ try {
+ Object text = part.getClass().getMethod("getText").invoke(part);
+ if (text != null) return text.toString();
+ } catch (Exception ignored) { }
+ }
+ }
+ }
+ return content != null ? content.toString() : null;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private Map parseNutritionJson(String content) {
+ content = content.trim();
+ if (content.startsWith("```")) {
+ int start = content.indexOf("\n");
+ if (start > 0) content = content.substring(start + 1);
+ int end = content.lastIndexOf("```");
+ if (end > 0) content = content.substring(0, end).trim();
+ }
+ try {
+ JSONObject obj = JSON.parseObject(content);
+ Map out = new HashMap<>();
+ if (obj.containsKey("energyKcal")) out.put("energyKcal", obj.get("energyKcal"));
+ if (obj.containsKey("proteinG")) out.put("proteinG", obj.get("proteinG"));
+ if (obj.containsKey("potassiumMg")) out.put("potassiumMg", obj.get("potassiumMg"));
+ if (obj.containsKey("phosphorusMg")) out.put("phosphorusMg", obj.get("phosphorusMg"));
+ if (obj.containsKey("sodiumMg")) out.put("sodiumMg", obj.get("sodiumMg"));
+ return out;
+ } catch (Exception e) {
+ log.warn("Parse nutrition JSON failed: {}", e.getMessage());
+ return new HashMap<>();
+ }
+ }
+
+ private void applyNutritionToRecipe(V2Recipe recipe, Map nutrition) {
+ if (nutrition.containsKey("energyKcal")) {
+ Object v = nutrition.get("energyKcal");
+ if (v != null) recipe.setTotalEnergy(toInteger(v));
+ }
+ if (nutrition.containsKey("proteinG")) {
+ Object v = nutrition.get("proteinG");
+ if (v != null) recipe.setTotalProtein(toBigDecimal(v));
+ }
+ if (nutrition.containsKey("potassiumMg")) {
+ Object v = nutrition.get("potassiumMg");
+ if (v != null) recipe.setTotalPotassium(toInteger(v));
+ }
+ if (nutrition.containsKey("phosphorusMg")) {
+ Object v = nutrition.get("phosphorusMg");
+ if (v != null) recipe.setTotalPhosphorus(toInteger(v));
+ }
+ if (nutrition.containsKey("sodiumMg")) {
+ Object v = nutrition.get("sodiumMg");
+ if (v != null) recipe.setTotalSodium(toInteger(v));
+ }
+ }
+
+ private static String getCreateChatRespId(CreateChatResp r) {
+ try {
+ try {
+ return (String) CreateChatResp.class.getMethod("getID").invoke(r);
+ } catch (NoSuchMethodException e) {
+ return (String) CreateChatResp.class.getMethod("getId").invoke(r);
+ }
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static String getRetrieveChatRespStatus(RetrieveChatResp r) {
+ try {
+ return (String) RetrieveChatResp.class.getMethod("getStatus").invoke(r);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static String getCreateChatRespConversationId(CreateChatResp r) {
+ try {
+ try {
+ return (String) CreateChatResp.class.getMethod("getConversationID").invoke(r);
+ } catch (NoSuchMethodException e) {
+ return (String) CreateChatResp.class.getMethod("getConversationId").invoke(r);
+ }
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static Integer toInteger(Object v) {
+ if (v instanceof Number) return ((Number) v).intValue();
+ if (v instanceof String) {
+ try { return Integer.parseInt((String) v); } catch (NumberFormatException e) { return null; }
+ }
+ return null;
+ }
+
+ private static BigDecimal toBigDecimal(Object v) {
+ if (v instanceof BigDecimal) return (BigDecimal) v;
+ if (v instanceof Number) return BigDecimal.valueOf(((Number) v).doubleValue());
+ if (v instanceof String) {
+ try { return new BigDecimal((String) v); } catch (Exception e) { return null; }
+ }
+ return null;
+ }
+
}
diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolCommunityService.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolCommunityService.java
index a43fea3..8cff738 100644
--- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolCommunityService.java
+++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolCommunityService.java
@@ -77,5 +77,12 @@ public interface ToolCommunityService {
* @return 分享信息
*/
Map share(Long postId);
+
+ /**
+ * 根据帖子内容用 AI 填充营养数据并更新帖子
+ * @param postId 帖子ID
+ * @return 填充后的营养数据(energyKcal, proteinG, potassiumMg, phosphorusMg)
+ */
+ Map fillNutrition(Long postId);
}
diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolKnowledgeService.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolKnowledgeService.java
index 08bedff..5b934e5 100644
--- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolKnowledgeService.java
+++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolKnowledgeService.java
@@ -43,5 +43,12 @@ public interface ToolKnowledgeService {
* @return 成功更新条数
*/
int fillMissingCoverImages(int limit);
+
+ /**
+ * 统计 v2_knowledge 表:按 type、status 汇总条数,用于检查知识库数据
+ *
+ * @return 如 total, byType (guide/article/nutrients/recipe), byStatus (published/draft/deleted)
+ */
+ Map getStats();
}
diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolNutritionFillService.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolNutritionFillService.java
new file mode 100644
index 0000000..7eabed5
--- /dev/null
+++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolNutritionFillService.java
@@ -0,0 +1,20 @@
+package com.zbkj.service.service.tool;
+
+import java.util.Map;
+
+/**
+ * AI 营养数据填充服务:根据饮食描述文本,用大模型估算热量、蛋白质、钾、磷等
+ * +----------------------------------------------------------------------
+ * | Author:ScottPan
+ * +----------------------------------------------------------------------
+ */
+public interface ToolNutritionFillService {
+
+ /**
+ * 根据一段饮食/菜品描述文本,调用 AI 估算营养数据并返回结构化结果
+ *
+ * @param text 饮食描述(如「今天中午吃了一碗米饭、一份青椒肉丝、一碗紫菜蛋花汤」)
+ * @return 包含 energyKcal, proteinG, potassiumMg, phosphorusMg 的 Map,估算不出时为 null
+ */
+ Map fillFromText(String text);
+}
diff --git a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolRecipeService.java b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolRecipeService.java
index 4af1859..312ff94 100644
--- a/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolRecipeService.java
+++ b/msh_crmeb_22/crmeb-service/src/main/java/com/zbkj/service/service/tool/ToolRecipeService.java
@@ -34,5 +34,12 @@ public interface ToolRecipeService {
* @param isFavorite 是否收藏
*/
void toggleFavorite(Long recipeId, Boolean isFavorite);
+
+ /**
+ * 使用 Coze AI 根据食谱食材分析并填充营养数据(能量、蛋白质、钾、磷、钠),更新食谱并返回
+ * @param recipeId 食谱ID
+ * @return 填充后的营养数据 Map(energyKcal, proteinG, potassiumMg, phosphorusMg, sodiumMg)
+ */
+ java.util.Map fillNutrition(Long recipeId);
}
diff --git a/msh_single_uniapp/api/tool.js b/msh_single_uniapp/api/tool.js
index eb796f7..a46be5f 100644
--- a/msh_single_uniapp/api/tool.js
+++ b/msh_single_uniapp/api/tool.js
@@ -208,6 +208,8 @@ export function getFoodList(data) {
*/
export function getFoodDetail(id) {
const numId = typeof id === 'number' && !isNaN(id) ? id : parseInt(String(id), 10);
+ // 打印请求参数便于确认(后端仅接受 Long 类型 id,传 name 会 400)
+ console.log('[api/tool] getFoodDetail 请求参数:', { id, numId, type: typeof id });
if (isNaN(numId)) {
return Promise.reject(new Error('食物详情接口需要数字ID,当前传入: ' + id));
}
@@ -252,6 +254,15 @@ export function getNutrientDetail(name) {
return request.get('tool/knowledge/nutrient/' + name);
}
+/**
+ * AI 营养估算:根据饮食描述文本返回估算的热量、蛋白质、钾、磷(T06-T09)
+ * @param {String} text - 饮食描述
+ * @returns {Promise<{data: {energyKcal?, proteinG?, potassiumMg?, phosphorusMg?}}>}
+ */
+export function getAiNutritionFill(text) {
+ return request.post('tool/nutrition/fill-ai', { text: text || '' });
+}
+
// ==================== 打卡社区相关 ====================
/**
@@ -346,6 +357,16 @@ export function sharePost(postId) {
return request.post('tool/community/share', { postId });
}
+/**
+ * 填充帖子营养数据(服务端根据帖子内容/打卡补充营养并回写)
+ * @param {Number|String} postId - 帖子ID
+ * @returns {Promise<{data?: object}>} 返回更新后的帖子或营养数据
+ */
+export function fillPostNutrition(postId) {
+ const id = typeof postId === 'number' && !isNaN(postId) ? postId : parseInt(postId, 10);
+ return request.post('tool/community/post/' + id + '/fill-nutrition');
+}
+
// ==================== 积分系统相关 ====================
/**
@@ -458,6 +479,10 @@ export function getRecipeDetail(id) {
export function toggleRecipeFavorite(recipeId, isFavorite) {
return request.post('tool/recipe/favorite', { recipeId, isFavorite });
}
+// T09: 食谱营养 AI 回填
+export function fillRecipeNutrition(recipeId) {
+ return request.post("tool/recipe/" + recipeId + "/fill-nutrition")
+}
// ==================== 文件上传相关 ====================
diff --git a/msh_single_uniapp/pages/tool/ai-nutritionist.vue b/msh_single_uniapp/pages/tool/ai-nutritionist.vue
index 02a37c7..6a13767 100644
--- a/msh_single_uniapp/pages/tool/ai-nutritionist.vue
+++ b/msh_single_uniapp/pages/tool/ai-nutritionist.vue
@@ -623,7 +623,8 @@ export default {
async sendToAI(content, type) {
this.isLoading = true;
- // 文本对话必须走 KieAI Gemini:POST /api/front/kieai/gemini/chat,请求体 { messages: [{ role: 'user', content }], stream: false }
+ // BUG-005:文本/多模态必须走 KieAI Gemini,回复仅从 data.choices[0].message.content 取,不得使用固定话术
+ // 请求体: { messages: [{ role: 'user', content }], stream: false }
if (type === 'text' || type === 'multimodal') {
try {
const messages = [{ role: 'user', content: content }];
@@ -635,6 +636,7 @@ export default {
const msgObj = choice && choice.message;
const rawContent = msgObj && msgObj.content;
const reply = rawContent != null ? this.extractReplyContent(rawContent) : '';
+ // 成功时仅展示模型返回内容
this.messageList.push({ role: 'ai', content: reply || '未能获取到有效回复。' });
} else {
const msg = (response && response.message) || '发起对话失败';
diff --git a/msh_single_uniapp/pages/tool/calculator-result.vue b/msh_single_uniapp/pages/tool/calculator-result.vue
index 50378ba..e02082c 100644
--- a/msh_single_uniapp/pages/tool/calculator-result.vue
+++ b/msh_single_uniapp/pages/tool/calculator-result.vue
@@ -492,25 +492,23 @@ export default {
box-sizing: border-box;
color: #9ca3af;
font-weight: 400;
-
+ /* 未激活:明显变灰,无下划线 */
.tab-icon {
font-size: 28rpx;
color: #9ca3af;
font-weight: 400;
}
-
.tab-text {
font-size: 28rpx;
color: #9ca3af;
font-weight: 400;
}
-
+ /* 激活:加粗、主色、橙色底部下划线 */
&.active {
background: transparent;
border-bottom: 3px solid #f97316;
color: #f97316;
font-weight: 700;
-
.tab-text {
color: #f97316;
font-weight: 700;
diff --git a/msh_single_uniapp/pages/tool/checkin.vue b/msh_single_uniapp/pages/tool/checkin.vue
index eb1c75d..0919f1f 100644
--- a/msh_single_uniapp/pages/tool/checkin.vue
+++ b/msh_single_uniapp/pages/tool/checkin.vue
@@ -243,12 +243,14 @@ export default {
try {
const { setSignIntegral, getUserInfo } = await import('@/api/user.js');
const { getUserPoints } = await import('@/api/tool.js');
+
// 子问题 A:不得在 API 返回成功前修改 currentPoints,避免打卡前积分提前跳变
- // 打卡接口:GET /api/front/user/sign/integral(setSignIntegral)
+ // 子问题 B-1:打卡接口为 GET /api/front/user/sign/integral(setSignIntegral),与 /api/front/user/checkin 同属签到类接口
await setSignIntegral();
+
this.todaySigned = true;
- // 子问题 B:仅用服务端返回的积分更新 currentPoints,禁止前端本地 +30
- // 先 GET /api/front/user(getUserInfo)刷新用户信息,取 integral 赋给 currentPoints
+
+ // 子问题 B-2/B-3:仅用服务端返回值更新积分,禁止前端本地 +30。调用 GET /api/front/user(getUserInfo)刷新用户信息,将返回的 integral 赋给 currentPoints
const userRes = await getUserInfo();
if (userRes && userRes.data && (userRes.data.integral != null || userRes.data.points != null)) {
this.currentPoints = userRes.data.integral ?? userRes.data.points ?? 0;
diff --git a/msh_single_uniapp/pages/tool/food-detail.vue b/msh_single_uniapp/pages/tool/food-detail.vue
index 71dfe5b..f2de6ce 100644
--- a/msh_single_uniapp/pages/tool/food-detail.vue
+++ b/msh_single_uniapp/pages/tool/food-detail.vue
@@ -14,13 +14,13 @@
-
+
- {{ foodData.name }}
+ {{ foodData.name || '—' }}
- {{ foodData.category }}
- {{ foodData.safetyTag }}
+ {{ foodData.category || '—' }}
+ {{ foodData.safetyTag || '—' }}
@@ -193,6 +193,7 @@ export default {
try {
const res = await getFoodDetail(id)
const data = res.data || res
+ console.log('[food-detail] getFoodDetail 响应:', data ? { hasName: !!data.name, hasImage: !!data.image, keys: Object.keys(data) } : null)
if (data && data.name) {
// 解析API返回的食物数据
this.foodData = {
@@ -220,6 +221,7 @@ export default {
} catch (error) {
const errMsg = (error && (error.message || error.msg || error.errMsg || error)) ? String(error.message || error.msg || error.errMsg || error) : '未知错误'
console.error('[food-detail] 加载食物数据失败:', error)
+ console.error('[food-detail] loadError(用于调试):', errMsg)
// a. 将 loadError 置为具体错误信息(用于调试)
this.loadError = errMsg
// b. 使用 defaultFoodData 填充页面,保证用户能看到基础界面;不清空 loadError 以便展示「当前数据来自缓存」提示
@@ -230,6 +232,7 @@ export default {
this.foodData.name = decodeURIComponent(String(this.pageParams.name))
} catch (e) {}
}
+ // c. 页面已通过 v-if="loadError" 显示「当前数据来自缓存,可能不是最新」;再弹出轻提示
uni.showToast({
title: '数据加载失败',
icon: 'none'
diff --git a/msh_single_uniapp/pages/tool/food-encyclopedia.vue b/msh_single_uniapp/pages/tool/food-encyclopedia.vue
index ea552d7..2d1a510 100644
--- a/msh_single_uniapp/pages/tool/food-encyclopedia.vue
+++ b/msh_single_uniapp/pages/tool/food-encyclopedia.vue
@@ -105,7 +105,7 @@
@@ -126,11 +126,11 @@
- {{ nut.label }}
- {{ nut.value }}
+ {{ nut.label || '—' }}
+ {{ nut.value != null ? nut.value : '—' }}
@@ -320,8 +320,12 @@ export default {
colorClass: n.colorClass || 'green'
}));
} else {
+ // 后端列表仅返回扁平字段,无 nutrition/nutrients 数组,此处组装并始终展示主要项(空值显示 —)
nutrition = [];
- const push = (label, val, unit) => { if (val != null && val !== '') nutrition.push({ label, value: String(val) + (unit || ''), colorClass: 'green' }); };
+ const push = (label, val, unit) => {
+ const value = (val != null && val !== '') ? String(val) + (unit || '') : '—';
+ nutrition.push({ label, value, colorClass: 'green' });
+ };
push('能量', item.energy, 'kcal');
push('蛋白质', item.protein, 'g');
push('钾', item.potassium, 'mg');
@@ -330,9 +334,14 @@ export default {
push('钙', item.calcium, 'mg');
}
+ // 后端详情接口仅接受 Long 类型 id;若列表返回的 id 为非数字(如名称),不传 id,避免详情页请求 400
+ const rawId = item.id != null ? item.id : item.foodId;
+ const numericId = (rawId !== undefined && rawId !== null && rawId !== '' && !isNaN(Number(rawId)))
+ ? (typeof rawId === 'number' ? rawId : Number(rawId))
+ : undefined;
return {
...item,
- id: item.id != null ? item.id : item.foodId,
+ id: numericId,
image,
imageUrl: image || undefined,
category: item.category || '',
diff --git a/msh_single_uniapp/pages/tool/nutrition-knowledge.vue b/msh_single_uniapp/pages/tool/nutrition-knowledge.vue
index 524996e..e7fff13 100644
--- a/msh_single_uniapp/pages/tool/nutrition-knowledge.vue
+++ b/msh_single_uniapp/pages/tool/nutrition-knowledge.vue
@@ -187,7 +187,7 @@ export default {
// 有 id 时切换到科普文章 tab,switchTab 内会调用 loadKnowledgeList 加载列表
this.switchTab('articles');
} else {
- // 无 id 时默认当前 tab 为「营养素」;用户切换到「饮食指南」或「科普文章」时由 switchTab 触发 loadKnowledgeList
+ // 无 id 时默认当前 tab 为「营养素」,不请求接口;用户点击「饮食指南」或「科普文章」时由 switchTab 触发 loadKnowledgeList
this.currentTab = 'nutrients';
}
},
@@ -215,23 +215,30 @@ export default {
page: 1,
limit: 50
});
- // 兼容 result.data.list 或 result.data 为数组
+ // 兼容 CommonPage:result.data.list,或 result.data/result.data.records 为数组
let rawList = [];
if (result && result.data) {
if (Array.isArray(result.data.list)) {
rawList = result.data.list;
+ } else if (Array.isArray(result.data.records)) {
+ rawList = result.data.records;
} else if (Array.isArray(result.data)) {
rawList = result.data;
}
}
- const list = (rawList || []).map(item => ({
- ...item,
- desc: item.desc || item.summary || '',
- time: item.time || (item.publishedAt || item.createdAt ? this.formatKnowledgeTime(item.publishedAt || item.createdAt) : ''),
- views: item.views != null ? item.views : (item.viewCount != null ? item.viewCount : 0),
- icon: item.icon || '📄',
- coverImage: item.coverImage || item.cover_image || ''
- }));
+ // Normalize id: backend may return knowledgeId, id, or knowledge_id (BeanUtil/JSON)
+ const list = (rawList || []).map(item => {
+ const id = item.knowledgeId ?? item.id ?? item.knowledge_id;
+ return {
+ ...item,
+ id,
+ desc: item.desc || item.summary || '',
+ time: item.time || (item.publishedAt || item.createdAt ? this.formatKnowledgeTime(item.publishedAt || item.createdAt) : ''),
+ views: item.views != null ? item.views : (item.viewCount != null ? item.viewCount : 0),
+ icon: item.icon || '📄',
+ coverImage: item.coverImage || item.cover_image || ''
+ };
+ });
if (this.currentTab === 'guide') {
this.guideList = list;
} else if (this.currentTab === 'articles') {
@@ -239,16 +246,16 @@ export default {
}
} catch (error) {
console.error('加载知识列表失败:', error);
- const msg = (error && (error.message || error.msg)) || '加载列表失败';
+ const msg = (error && (typeof error === 'string' ? error : (error.message || error.msg))) || '加载列表失败';
uni.showToast({
title: String(msg),
icon: 'none'
});
- // 确保列表始终为数组,不设为 undefined
+ // 失败时清空当前 tab 列表并确保始终为数组,不设为 undefined
if (this.currentTab === 'guide') {
- this.guideList = Array.isArray(this.guideList) ? this.guideList : [];
+ this.guideList = [];
} else if (this.currentTab === 'articles') {
- this.articleList = Array.isArray(this.articleList) ? this.articleList : [];
+ this.articleList = [];
}
}
},
@@ -266,13 +273,12 @@ export default {
uni.showToast({ title: '暂无详情', icon: 'none' });
return;
}
- // 兼容后端 knowledgeId / id / knowledge_id
+ // 确保 knowledgeId 或 id 存在才跳转,否则提示暂无详情
const id = item.knowledgeId ?? item.id ?? item.knowledge_id;
- if (id === undefined || id === null || id === '') {
+ if (id === undefined || id === null || id === '' || (typeof id === 'number' && isNaN(id))) {
uni.showToast({ title: '暂无详情', icon: 'none' });
return;
}
- // 饮食指南、科普文章使用知识详情页(调用 tool/knowledge/detail 接口)
uni.navigateTo({
url: `/pages/tool/knowledge-detail?id=${id}`
});
diff --git a/msh_single_uniapp/pages/tool/post-detail.vue b/msh_single_uniapp/pages/tool/post-detail.vue
index 111cd33..83a988f 100644
--- a/msh_single_uniapp/pages/tool/post-detail.vue
+++ b/msh_single_uniapp/pages/tool/post-detail.vue
@@ -22,7 +22,7 @@
{{ getMealIcon(postData.mealType) }}
- {{ postData.mealType }}
+ {{ getMealTypeLabel(postData.mealType) }}
@@ -44,7 +44,7 @@
{{ getMealIcon(postData.mealType) }}
- {{ postData.mealType }}
+ {{ getMealTypeLabel(postData.mealType) }}
@@ -56,7 +56,7 @@
- {{ postData.mealType || '分享' }}
+ {{ getMealTypeLabel(postData.mealType) }}
@@ -129,6 +129,18 @@
+
+
+
+ 🤖 AI 补充营养
+ 估算中...
+
+
+