接口更新

This commit is contained in:
Developer
2026-04-11 07:07:13 +08:00
parent 346fc795f7
commit 2d7484fd29
40 changed files with 2680 additions and 2254 deletions

View File

@@ -7,10 +7,6 @@
- 阴影
- 按钮样式
【修改规则】
如果需要改布局:
必须重构整体 layout
不能只修改一个组件。
@@ -33,16 +29,7 @@
小屏幕:单列布局
中屏幕:双列布局
大屏幕:多列布局
🪟 五、桌面专用页面模板
如果某些页面 不需要响应式:
该页面为 Desktop-only。
设计基准1280px。
不需要 mobile 适配。
不需要 media query。

View File

@@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file.
## [0.67.0] - 2026-04-11
### Fixed — 阶段二十:数据模型类型安全增强
- 🐛 **20.1 categoryHierarchy 解析异常**`recipe_model.dart`
- `_parseCategoryHierarchy` 添加 try-catch 包裹,防止解析失败导致崩溃
- `CategoryHierarchyItem.fromJson` 使用安全类型转换 `_safeInt`/`_safeString`
- `RecipeAuthor.fromJson` 同步增强,统一使用安全解析方法
- 解决 API 返回字段为 null 或类型不匹配时的运行时异常
## [0.66.0] - 2026-04-11
### Fixed — 阶段十九综合Bug修复+功能增强

View File

@@ -1,4 +1,4 @@
# mom_kitchen - 妈厨房
# mom_kitchen - 妈厨房
一款基于 Flutter 的 iOS26 风格美食应用支持多平台Android/iOS/HarmonyOS/Web

View File

@@ -417,6 +417,7 @@ function recipe_full() {
$tableIngredientDetail = $zbp->db->dbpre . 'ingredient_detail';
$tableRecipeNutrition = $zbp->db->dbpre . 'recipe_nutrition';
$tableTag = $zbp->db->dbpre . 'tag';
$tableRecipeIdMap = $zbp->db->dbpre . 'recipe_id_map';
$sql = "SELECT
p.log_ID, p.log_Title, p.log_Content, p.log_Intro, p.log_Tag,
@@ -443,6 +444,13 @@ function recipe_full() {
$row = $result[0];
$picId = null;
$idMapSql = "SELECT old_id FROM $tableRecipeIdMap WHERE new_log_id = $id LIMIT 1";
$idMapResult = $zbp->db->Query($idMapSql);
if (!empty($idMapResult)) {
$picId = (int) $idMapResult[0]['old_id'];
}
$meta = json_decode($row['log_Meta'] ?? '', true) ?: array();
$tagIds = parse_tags($row['log_Tag'] ?? '');
@@ -589,6 +597,7 @@ function recipe_full() {
'data' => array(
'id' => (int) $row['log_ID'],
'code' => $recipeCode,
'pic_id' => $picId,
'title' => $row['log_Title'],
'intro' => $row['log_Intro'] ?? '',
'content' => $row['log_Content'],

View File

@@ -0,0 +1,306 @@
<?php
/**
* 📋 查重API接口
*
* 用于用户投稿时查询重复率
* 支持菜品、食材、营养成分、菜品内容、食材内容查重
*
* 访问方式: /api/api_check_duplicate.php?act=xxx
*/
$startTime = microtime(true);
require '../zb_system/function/c_system_base.php';
$zbp->Load();
require_once 'response.php';
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Content-Type: application/json; charset=utf-8');
$act = strtolower(trim($_GET['act'] ?? ''));
$format = ApiResponse::getFormat();
$allowedActs = array(
'recipe_title',
'ingredient_name',
'nutrition_name',
'recipe_content',
'ingredient_content'
);
if (!in_array($act, $allowedActs)) {
$result = array(
'code' => 400,
'message' => '❌ 无效的查重类型',
'data' => null,
'_query_time' => round((microtime(true) - $startTime) * 1000, 2) . 'ms'
);
ApiResponse::output($result, $format);
exit;
}
$result = call_user_func('check_' . $act);
$result['_query_time'] = round((microtime(true) - $startTime) * 1000, 2) . 'ms';
ApiResponse::output($result, $format);
exit;
// ==================== 查重函数 ====================
/**
* 菜品标题查重
* 参数: title - 菜品标题
* 返回: 重复率百分比
*/
function check_recipe_title() {
global $zbp;
$title = trim($_GET['title'] ?? $_POST['title'] ?? '');
if (empty($title)) {
return array(
'code' => 400,
'message' => '❌ 缺少菜品标题参数',
'data' => null
);
}
$tablePost = $zbp->db->dbpre . 'post';
$sql = "SELECT log_Title FROM {$tablePost} WHERE log_Type = 0";
$rows = $zbp->db->Query($sql);
$maxSimilarity = 0;
$titleLimited = mb_substr($title, 0, 100);
foreach ($rows as $row) {
$existingTitle = mb_substr($row['log_Title'], 0, 100);
similar_text($titleLimited, $existingTitle, $similarity);
if ($similarity > $maxSimilarity) {
$maxSimilarity = $similarity;
}
if ($maxSimilarity >= 100) {
break;
}
}
return array(
'code' => 200,
'message' => 'success',
'data' => array(
'duplicate_rate' => round($maxSimilarity, 2)
)
);
}
/**
* 食材名称查重
* 参数: name - 食材名称
* 返回: 重复率百分比
*/
function check_ingredient_name() {
global $zbp;
$name = trim($_GET['name'] ?? $_POST['name'] ?? '');
if (empty($name)) {
return array(
'code' => 400,
'message' => '❌ 缺少食材名称参数',
'data' => null
);
}
$tableIngredient = $zbp->db->dbpre . 'ingredient_detail';
$sql = "SELECT name FROM {$tableIngredient}";
$rows = $zbp->db->Query($sql);
$maxSimilarity = 0;
$nameLimited = mb_substr($name, 0, 100);
foreach ($rows as $row) {
$existingName = mb_substr($row['name'], 0, 100);
similar_text($nameLimited, $existingName, $similarity);
if ($similarity > $maxSimilarity) {
$maxSimilarity = $similarity;
}
if ($maxSimilarity >= 100) {
break;
}
}
return array(
'code' => 200,
'message' => 'success',
'data' => array(
'duplicate_rate' => round($maxSimilarity, 2)
)
);
}
/**
* 营养成分查重
* 参数: name - 营养成分名称
* 返回: 重复率百分比
*/
function check_nutrition_name() {
global $zbp;
$name = trim($_GET['name'] ?? $_POST['name'] ?? '');
if (empty($name)) {
return array(
'code' => 400,
'message' => '❌ 缺少营养成分名称参数',
'data' => null
);
}
$tableNutrition = $zbp->db->dbpre . 'recipe_nutrition';
$sql = "SELECT DISTINCT name FROM {$tableNutrition}";
$rows = $zbp->db->Query($sql);
$maxSimilarity = 0;
$nameLimited = mb_substr($name, 0, 100);
foreach ($rows as $row) {
$existingName = mb_substr($row['name'], 0, 100);
similar_text($nameLimited, $existingName, $similarity);
if ($similarity > $maxSimilarity) {
$maxSimilarity = $similarity;
}
if ($maxSimilarity >= 100) {
break;
}
}
return array(
'code' => 200,
'message' => 'success',
'data' => array(
'duplicate_rate' => round($maxSimilarity, 2)
)
);
}
/**
* 菜品内容查重
* 参数: content - 菜品内容(制作步骤等)
* 返回: 重复率百分比
*/
function check_recipe_content() {
global $zbp;
$content = trim($_GET['content'] ?? $_POST['content'] ?? '');
if (empty($content)) {
return array(
'code' => 400,
'message' => '❌ 缺少菜品内容参数',
'data' => null
);
}
$tablePost = $zbp->db->dbpre . 'post';
$sql = "SELECT log_Content FROM {$tablePost} WHERE log_Type = 0 LIMIT 1000";
$rows = $zbp->db->Query($sql);
$maxSimilarity = 0;
$contentLimited = mb_substr($content, 0, 100);
$contentLength = mb_strlen($contentLimited);
foreach ($rows as $row) {
$existingContent = mb_substr(strip_tags($row['log_Content']), 0, 100);
$existingLength = mb_strlen($existingContent);
if ($existingLength > 0 && $contentLength > 0) {
similar_text($contentLimited, $existingContent, $similarity);
if ($similarity > $maxSimilarity) {
$maxSimilarity = $similarity;
}
if ($maxSimilarity >= 100) {
break;
}
}
}
return array(
'code' => 200,
'message' => 'success',
'data' => array(
'duplicate_rate' => round($maxSimilarity, 2)
)
);
}
/**
* 食材内容查重
* 参数: content - 食材内容(功效、营养、使用提示等)
* 返回: 重复率百分比
*/
function check_ingredient_content() {
global $zbp;
$content = trim($_GET['content'] ?? $_POST['content'] ?? '');
if (empty($content)) {
return array(
'code' => 400,
'message' => '❌ 缺少食材内容参数',
'data' => null
);
}
$tableIngredientDetail = $zbp->db->dbpre . 'ingredient_detail';
$sql = "SELECT effect, nutrition, usage_tip FROM {$tableIngredientDetail} LIMIT 1000";
$rows = $zbp->db->Query($sql);
$maxSimilarity = 0;
$contentLimited = mb_substr($content, 0, 100);
$contentLength = mb_strlen($contentLimited);
foreach ($rows as $row) {
$existingContent = '';
if (!empty($row['effect'])) {
$existingContent .= $row['effect'] . ' ';
}
if (!empty($row['nutrition'])) {
$existingContent .= $row['nutrition'] . ' ';
}
if (!empty($row['usage_tip'])) {
$existingContent .= $row['usage_tip'];
}
$existingContent = mb_substr(trim($existingContent), 0, 100);
$existingLength = mb_strlen($existingContent);
if ($existingLength > 0 && $contentLength > 0) {
similar_text($contentLimited, $existingContent, $similarity);
if ($similarity > $maxSimilarity) {
$maxSimilarity = $similarity;
}
if ($maxSimilarity >= 100) {
break;
}
}
}
return array(
'code' => 200,
'message' => 'success',
'data' => array(
'duplicate_rate' => round($maxSimilarity, 2)
)
);
}

View File

@@ -174,6 +174,7 @@ function get_recommend_feed() {
$tablePost = $zbp->db->dbpre . 'post';
$tableCategory = $zbp->db->dbpre . 'category';
$tablePostStat = $zbp->db->dbpre . 'post_stat';
$tableRecipeIdMap = $zbp->db->dbpre . 'recipe_id_map';
$offset = ($page - 1) * $limit;
@@ -281,6 +282,7 @@ function get_latest_feed() {
$tablePost = $zbp->db->dbpre . 'post';
$tableCategory = $zbp->db->dbpre . 'category';
$tablePostStat = $zbp->db->dbpre . 'post_stat';
$tableRecipeIdMap = $zbp->db->dbpre . 'recipe_id_map';
$offset = ($page - 1) * $limit;
@@ -347,6 +349,7 @@ function get_hot_feed() {
$tableCategory = $zbp->db->dbpre . 'category';
$tablePostStat = $zbp->db->dbpre . 'post_stat';
$tableStatLog = $zbp->db->dbpre . 'recipe_stat_log';
$tableRecipeIdMap = $zbp->db->dbpre . 'recipe_id_map';
$offset = ($page - 1) * $limit;
@@ -437,6 +440,7 @@ function get_personal_feed() {
$tableCategory = $zbp->db->dbpre . 'category';
$tablePostStat = $zbp->db->dbpre . 'post_stat';
$tableIngredient = $zbp->db->dbpre . 'recipe_ingredient';
$tableRecipeIdMap = $zbp->db->dbpre . 'recipe_id_map';
$fetchLimit = $limit * 5;
$sql = "SELECT p.log_ID, p.log_Title, p.log_Intro, p.log_CateID, p.log_PostTime, p.log_Tag,
@@ -785,9 +789,22 @@ function get_prefetch_feed() {
* 格式化信息流项目
*/
function format_feed_item($row, $source = 'unknown') {
global $zbp;
static $picIdCache = array();
$recipeId = (int) $row['log_ID'];
if (!isset($picIdCache[$recipeId])) {
$tableRecipeIdMap = $zbp->db->dbpre . 'recipe_id_map';
$idMapSql = "SELECT old_id FROM $tableRecipeIdMap WHERE new_log_id = $recipeId LIMIT 1";
$idMapResult = $zbp->db->Query($idMapSql);
$picIdCache[$recipeId] = !empty($idMapResult) ? (int) $idMapResult[0]['old_id'] : null;
}
$publishTime = strtotime($row['log_PostTime']);
return array(
'id' => (int) $row['log_ID'],
'id' => $recipeId,
'pic_id' => $picIdCache[$recipeId],
'title' => $row['log_Title'],
'intro' => mb_substr(strip_tags($row['log_Intro'] ?? ''), 0, 100),
'category' => array(

View File

@@ -415,6 +415,7 @@ function get_recipe_detail() {
$tableRecipeIngredient = $zbp->db->dbpre . 'recipe_ingredient';
$tableIngredientDetail = $zbp->db->dbpre . 'ingredient_detail';
$tableRecipeNutrition = $zbp->db->dbpre . 'recipe_nutrition';
$tableRecipeIdMap = $zbp->db->dbpre . 'recipe_id_map';
$whereClauses = array("p.log_Type = 0", "p.log_Status = 0");
@@ -459,6 +460,13 @@ function get_recipe_detail() {
$row = $results[0];
$recipeId = (int) $row['log_ID'];
$picId = null;
$idMapSql = "SELECT old_id FROM $tableRecipeIdMap WHERE new_log_id = $recipeId LIMIT 1";
$idMapResult = $zbp->db->Query($idMapSql);
if (!empty($idMapResult)) {
$picId = (int) $idMapResult[0]['old_id'];
}
$tagIds = array_filter(array_map('intval', explode(',', $row['log_Tag'] ?? '')));
$tags = array();
if (!empty($tagIds)) {
@@ -560,6 +568,7 @@ function get_recipe_detail() {
'data' => array(
'id' => $recipeId,
'code' => 'CP' . str_pad($recipeId, 6, '0', STR_PAD_LEFT),
'pic_id' => $picId,
'title' => $row['log_Title'],
'cover' => $cover,
'intro' => mb_substr(strip_tags($row['log_Intro'] ?? ''), 0, 200),

View File

@@ -6,15 +6,6 @@
---
## 📋 接口整合说明
本次更新整合了以下接口:
-`api_hot.php``stats_full.php?act=hot`
-`api_online.php``stats_full.php?act=online`
-`api_request_stats.php``stats_full.php?act=request`
-`api_unified.php``api.php?act=unified_*`
**从13个文件精简到9个文件**
---
@@ -28,6 +19,7 @@
| `api_feed.php` | 信息流 | 推荐、热门、个性化 |
| `stats_full.php` | 全面统计 | 热门、在线、请求统计 |
| `api_preference.php` | 用户偏好 | 标签、分类、过敏原设置 |
| `api_check_duplicate.php` | 查重接口 | 菜品、食材、营养成分、内容查重 |
| `cache.php` | 缓存系统 | 工具类 |
| `cache_manage.php` | 缓存管理 | 运维工具 |
| `response.php` | 响应格式 | 工具类 |
@@ -44,6 +36,7 @@
- [信息流 api_feed.php](#信息流-api_feedphp)
- [全面统计 stats_full.php](#全面统计-stats_fullphp)
- [用户偏好 api_preference.php](#用户偏好-api_preferencephp)
- [查重接口 api_check_duplicate.php](#查重接口-api_check_duplicatephp)
- [功能扩展指南](#功能扩展指南)
- [错误处理](#错误处理)
@@ -171,6 +164,7 @@ GET api.php?act=full&id=32892
| 字段 | 说明 | 可扩展功能 |
|------|------|-----------|
| `code` | 菜谱编码如CP032892 | 用于唯一标识、二维码生成、分享码 |
| `pic_id` | 原始图片ID| 用于新旧系统图片资源关联、图片迁移 |
| `category.hierarchy` | 分类层级最多3级 | 用于面包屑导航、分类树展示 |
| `ingredients.main` | 主料列表 | 用于购物清单、主料筛选 |
| `ingredients.auxiliary` | 辅料列表 | 用于购物清单、辅料筛选 |
@@ -517,9 +511,19 @@ GET api_what_to_eat.php?act=detail&title=鸡丁&fuzzy=1
| title | 菜谱标题 |
| fuzzy | 是否模糊匹配1=是 |
**返回字段及功能扩展**:
| 字段 | 说明 | 可扩展功能 |
|------|------|-----------|
| `id` | 菜谱唯一ID | 用于详情查询、收藏、分享链接 |
| `code` | 菜谱编码如CP032892 | 用于二维码分享、唯一标识 |
| `pic_id` | 原始图片ID来自zbp_recipe_id_map表 | 用于新旧系统图片资源关联、图片迁移 |
| `title` | 菜谱标题 | 用于搜索、列表展示、分享标题 |
**功能扩展**:
- `code` 可用于二维码分享、唯一标识
- `title+fuzzy` 可用于智能搜索、语音搜索
- `pic_id` 可用于关联旧系统图片资源
---
@@ -568,10 +572,20 @@ GET api_feed.php?act=recommend&page=1&limit=20
**功能**: 混合推荐算法热门40% + 最新40% + 随机20%
**返回字段及功能扩展**:
| 字段 | 说明 | 可扩展功能 |
|------|------|-----------|
| `id` | 菜谱唯一ID | 用于详情查询、收藏、分享链接 |
| `pic_id` | 原始图片ID| 用于新旧系统图片资源关联、图片迁移 |
| `title` | 菜谱标题 | 用于搜索、列表展示、分享标题 |
| `intro` | 菜谱简介 | 用于列表预览、搜索结果摘要 |
**功能扩展**:
- 可用于首页信息流
- 可用于下拉刷新加载
- 可用于无限滚动加载
- `pic_id` 可用于关联旧系统图片资源
---
@@ -658,7 +672,7 @@ GET stats_full.php?act=stats&layer=basic
---
### 🔥 热门统计(原 api_hot.php
### 🔥 热门统计
```
GET stats_full.php?act=hot&period=today
@@ -801,6 +815,198 @@ GET api_preference.php?act=allergens&user_id=xxx
---
## 查重接口 api_check_duplicate.php
用于用户投稿时查询重复率,支持菜品、食材、营养成分、菜品内容、食材内容查重。
### 📋 菜品标题查重
```
GET api_check_duplicate.php?act=recipe_title&title=宫保鸡丁
```
**功能**: 检查菜品标题重复率
**参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| title | string | 菜品标题(必填) |
**返回示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"duplicate_rate": 85.5
},
"_query_time": "125.34ms"
}
```
**功能扩展**:
- 用于投稿前查重提醒
- 用于防止重复内容录入
- 用于内容质量把控
---
### 🥬 食材名称查重
```
GET api_check_duplicate.php?act=ingredient_name&name=鸡蛋
```
**功能**: 检查食材名称重复率
**参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| name | string | 食材名称(必填) |
**返回示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"duplicate_rate": 100.0
},
"_query_time": "45.23ms"
}
```
**功能扩展**:
- 用于食材库查重
- 用于食材数据质量检查
- 用于食材关联推荐
---
### 💊 营养成分查重
```
GET api_check_duplicate.php?act=nutrition_name&name=维生素C
```
**功能**: 检查营养成分名称重复率
**参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| name | string | 营养成分名称(必填) |
**返回示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"duplicate_rate": 100.0
},
"_query_time": "32.15ms"
}
```
**功能扩展**:
- 用于营养成分库查重
- 用于营养数据标准化
- 用于营养标签管理
---
### 📝 菜品内容查重
```
POST api_check_duplicate.php?act=recipe_content
Content-Type: application/json
{
"content": "1. 将鸡肉切成丁状..."
}
```
**功能**: 检查菜品内容(制作步骤)重复率
**参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| content | string | 菜品内容(必填) |
**返回示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"duplicate_rate": 65.3
},
"_query_time": "234.56ms"
}
```
**功能扩展**:
- 用于菜品内容原创性检查
- 用于防止抄袭内容
- 用于内容版权保护
---
### 📖 食材内容查重
```
POST api_check_duplicate.php?act=ingredient_content
Content-Type: application/json
{
"content": "鸡蛋含有丰富的蛋白质..."
}
```
**功能**: 检查食材内容(功效、营养、使用提示)重复率
**参数**:
| 参数 | 类型 | 说明 |
|------|------|------|
| content | string | 食材内容(必填) |
**返回示例**:
```json
{
"code": 200,
"message": "success",
"data": {
"duplicate_rate": 42.8
},
"_query_time": "189.32ms"
}
```
**功能扩展**:
- 用于食材内容原创性检查
- 用于食材数据质量把控
- 用于食材百科内容管理
---
### 🔍 查重算法说明
**相似度计算**:
- 使用PHP `similar_text()` 函数计算文本相似度
- 返回最高相似度作为重复率
- 重复率范围0-100百分比
**性能优化**:
- 菜品内容查重限制查询1000条
- 食材内容查重限制查询1000条
- 找到100%匹配时提前终止
**应用场景**:
- 📝 用户投稿前查重提醒
- 🔒 防止重复内容录入
- 📊 内容质量把控
- 🛡️ 版权保护
---
## 功能扩展指南
本章节详细介绍每个接口返回字段的具体用途、应用场景和可实现的功能。

View File

@@ -16,6 +16,7 @@
| `api_feed.php` | 信息流 | 推荐、热门、个性化 |
| `stats_full.php` | 全面统计 | 热门、在线、请求统计 |
| `api_preference.php` | 用户偏好 | 标签、分类、过敏原设置 |
| `api_check_duplicate.php` | 查重接口 | 菜品、食材、营养成分、内容查重 |
---
@@ -723,6 +724,106 @@ void startHeartbeat() {
---
### 🔍 功能七:内容查重
#### 应用场景
- 投稿查重:用户投稿前检查重复率
- 内容审核:防止重复内容录入
- 质量把控:确保内容原创性
- 版权保护:防止抄袭内容
#### 实现方式
**接口调用**
```
# 菜品标题查重
GET api_check_duplicate.php?act=recipe_title&title=宫保鸡丁
# 食材名称查重
GET api_check_duplicate.php?act=ingredient_name&name=鸡蛋
# 营养成分查重
GET api_check_duplicate.php?act=nutrition_name&name=维生素C
# 菜品内容查重
POST api_check_duplicate.php?act=recipe_content
{"content": "1. 将鸡肉切成丁状..."}
# 食材内容查重
POST api_check_duplicate.php?act=ingredient_content
{"content": "鸡蛋含有丰富的蛋白质..."}
```
**客户端实现**
```dart
// Flutter 示例:投稿前查重
Future<bool> checkDuplicateBeforeSubmit(String title, String content) async {
// 检查标题重复率
final titleResponse = await http.get(
Uri.parse('$baseUrl/api_check_duplicate.php?act=recipe_title&title=${Uri.encodeComponent(title)}')
);
final titleRate = json.decode(titleResponse.body)['data']['duplicate_rate'];
// 检查内容重复率
final contentResponse = await http.post(
Uri.parse('$baseUrl/api_check_duplicate.php?act=recipe_content'),
body: json.encode({'content': content}),
headers: {'Content-Type': 'application/json'}
);
final contentRate = json.decode(contentResponse.body)['data']['duplicate_rate'];
// 判断是否可以投稿(重复率低于阈值)
const threshold = 80.0; // 重复率阈值
if (titleRate >= threshold) {
showDialog('标题重复率过高:${titleRate}%,请修改标题');
return false;
}
if (contentRate >= threshold) {
showDialog('内容重复率过高:${contentRate}%,请修改内容');
return false;
}
return true;
}
// 批量查重
Future<Map<String, double>> batchCheckDuplicate({
String? title,
String? ingredientName,
String? content,
}) async {
final results = <String, double>{};
if (title != null) {
final response = await http.get(
Uri.parse('$baseUrl/api_check_duplicate.php?act=recipe_title&title=${Uri.encodeComponent(title)}')
);
results['title'] = json.decode(response.body)['data']['duplicate_rate'];
}
if (ingredientName != null) {
final response = await http.get(
Uri.parse('$baseUrl/api_check_duplicate.php?act=ingredient_name&name=${Uri.encodeComponent(ingredientName)}')
);
results['ingredient'] = json.decode(response.body)['data']['duplicate_rate'];
}
if (content != null) {
final response = await http.post(
Uri.parse('$baseUrl/api_check_duplicate.php?act=recipe_content'),
body: json.encode({'content': content}),
headers: {'Content-Type': 'application/json'}
);
results['content'] = json.decode(response.body)['data']['duplicate_rate'];
}
return results;
}
```
---
## 六、推荐算法 (MDHW)
### 算法简介
@@ -917,6 +1018,7 @@ GET api_what_to_eat.php?act=detail&code=CP032892
|------|-----------|---------|
| `id` | 详情查询、收藏、分享链接 | `api.php?act=detail` |
| `code` | 二维码、短链接、语音搜索 | `api_what_to_eat.php?act=detail&code=` |
| `pic_id` | 图片资源关联、新旧系统迁移 | `api.php?act=full``api_what_to_eat.php?act=detail``api_feed.php` |
| `title` | 搜索、分享标题、列表展示 | `api.php?act=search` |
| `intro` | 用餐时段筛选、列表预览 | 客户端过滤 |
| `category` | 分类筛选、面包屑导航 | `api.php?act=list&cate_id=` |

View File

@@ -0,0 +1,518 @@
{
"code": 200,
"message": "success",
"data": {
"id": 32891,
"code": "CP032891",
"pic_id": 7640,
"title": "海带焖木耳",
"intro": "早餐、中餐、晚餐",
"content": "1.海带洗净切去梗切成3厘米见方的块\n2.海带用沸水焯过捞起;\n3.黑木耳用水发好,剔去杂质,洗净;\n4.葱白切段,姜拍松;\n5.油豆腐切成4厘米见方的块\n6.炒锅放旺火上倒入花生油烧热煸生姜、葱段倒入海带、木耳、豆腐加料酒、酱油、白糖、香醋及适量水烧30分钟\n7.调入味精颠翻装盘,淋香油,撒胡椒粉,即成。",
"cover": "",
"status": 0,
"create_time": 146085838541,
"update_time": 146085838541,
"category": {
"id": 42,
"name": "家常菜",
"alias": "",
"hierarchy": [
{
"id": 11,
"name": "菜谱",
"alias": "caipu",
"level": 3
},
{
"id": 12,
"name": "中国菜",
"alias": "",
"level": 2
},
{
"id": 42,
"name": "家常菜",
"alias": "",
"level": 1
}
]
},
"author": {
"id": 1,
"name": "520kiss",
"alias": "",
"email": "null@null.com",
"homepage": ""
},
"tags": [],
"ingredients": {
"main": [
{
"ingredient_id": 253537,
"name": "海带(鲜}",
"amount": "250克",
"sort": 0,
"detail_id": 0,
"detail": null
}
],
"auxiliary": [
{
"ingredient_id": 253553,
"name": "木耳(干}",
"amount": "30克",
"sort": 1,
"detail_id": 0,
"detail": null
},
{
"ingredient_id": 253568,
"name": "油豆腐",
"amount": "100克",
"sort": 2,
"detail_id": 364,
"detail": {
"alias": [],
"usage_tip": [
"一般人皆可食用油豆腐相对于其他豆制品不易消化,经常消化不良、胃肠功能较弱的人慎食。"
],
"introduction": "油豆腐是豆腐的炸制食品,色泽金黄,易吸收汤汁,常被用做入汤的原料。",
"nutrition": "油豆腐富含优质蛋白、多种氨基酸、不饱和脂肪酸及磷脂等,铁、钙的含量也很高。",
"guidance": "炸制油豆腐,火要大,这样才会里嫩外酥。",
"effect": "",
"other": "",
"allergen": [
"豆腐",
"豆"
],
"allergen_type": [
"豆类"
]
}
}
],
"seasoning": [
{
"ingredient_id": 253585,
"name": "料酒",
"amount": "25克",
"sort": 3,
"detail_id": 1208,
"detail": {
"alias": [],
"usage_tip": [
"一般人群均可食用"
],
"introduction": "料酒就是专门用于烹饪调味的酒。在我国的应用已有上千年的历史日本、美国、欧洲的某些国家也有使用料酒的习惯。从理论上来说啤酒、白酒、黄酒、葡萄酒、威士忌都可用作料酒。但人们经过长期的实践、品尝后发现不同的料酒所烹饪出来的菜肴风味相距甚远。经过反复试验人们发现以黄酒烹饪为最佳。酒为人们所喜欢的饮料品种极多作调味品的主要是黄酒。福建、山东、浙江等地都有生产以浙江沼兴所产质量较好。黄酒是用糯米或小米酿造而成的其成分主要有酒精、糖分、糊精、有机酸类、氨基酸、酯类、醛类、杂醇油及浸出物等。其酒精浓度低含量在15%以下,而酯类含量高,富含氨基酸,所以香味浓郁,味道醇厚,在烹制菜肴中使用广泛。黄酒的调味作用主要为去腥、增香。",
"nutrition": "1. 动物性原料作菜肴时,因为肉、脏腑、鱼类等的组织中和鱼类身体表面的粘液里含有腥臊异味,这些物质在加热时能被酒中的酒精所溶解,并随气化的酒精一齐挥发,这样就除去了腥味;\n2. 黄酒中的氨基酸还能与糖结合成芳香醛,产生诱人的香气;\n3. 黄酒中所含的酯类也有香气,所以烹调中加入黄酒,能使菜肴除去异味,且香味大增;\n4. 黄酒中还含有多种维生素和微量元素,而且使菜肴的营养更加丰富;\n5. 在烹饪肉、禽、蛋等菜肴时,调入黄酒能渗透到食物组织内部,溶解微量的有机物质,从而使菜肴质地松嫩;\n6. 温饮黄酒,可帮助血液循环,促进新陈代谢,具有补血养颜,活血祛寒,通经活络,能有效抵御寒冷刺激,预防感冒;\n7. 黄酒还可作为药引子食用。",
"guidance": "烹调菜肴时不要放得过多,以免料酒味太重而影响菜肴本身的滋味。",
"effect": "",
"other": "",
"allergen": [
"酒",
"料酒"
],
"allergen_type": [
"调味品类"
]
}
},
{
"ingredient_id": 253604,
"name": "酱油",
"amount": "20克",
"sort": 4,
"detail_id": 1209,
"detail": {
"alias": [
"豉油",
"酱汁",
"豉汁"
],
"usage_tip": [
"一般人群均可食用"
],
"introduction": "酱油俗称豉油,主要由大豆,淀粉、小麦、食盐经过制油、发酵等程序酿制而成的。酱油的成分比较复杂,除食盐的成分外,还有多种氨基酸、糖类、有机酸、色素及香料民分。以咸味为主,亦有鲜味、香味等。它能增加和改善菜肴的口味,还能增添或改变菜肴的色泽。我国人民在数千年前就已经掌握酿制工艺了。酱油一般有老抽和生抽两种:老抽较咸,用于提色;生抽用于提鲜。",
"nutrition": "1. 烹调食品时加入一定量的酱油,可增加食物的香味,并可使其色泽更加好看,从而增进食欲;\n2. 酱油的主要原料是大豆,大豆及其制品因富含硒等矿物质而有防癌的效果;\n3. 酱油含有多种维生素和矿物质,可降低人体胆固醇,降低心血管疾病的发病率,并能减少自由基对人体的损害;\n4. 酱油可用于水、火烫伤和蜂、蚊等虫的蜇伤,并能止痒消肿。",
"guidance": "1. 要食用“酿造”酱油,而不要吃“配制”酱油;\n2. “餐桌酱油”拌凉菜用,“烹调酱油”未经加热不宜直接食用;\n2. 酱油应在菜肴将要出锅时加入,不宜长时间加热。",
"effect": "",
"other": "",
"allergen": [],
"allergen_type": []
}
},
{
"ingredient_id": 253620,
"name": "味精",
"amount": "4克",
"sort": 5,
"detail_id": 1207,
"detail": {
"alias": [
"味素",
"味之素"
],
"usage_tip": [
"一般成年人均可食用记忆障碍患者、高血压不宜食用;孕妇及婴幼儿不宜吃味精;老人和儿童也不宜多食。"
],
"introduction": "味精是烹调中常用的鲜味调味品,有固体味精和液体味精两种。液体味精是未经炼成颗粒的味精原液,饮食业中以用固体味精为常见。味精的化学名称叫谷氨酸钠,由大豆、小麦面粉及其他含蛋白较高的物质,经由淀粉发酵法制成,除含有谷氨酸钠外还含有少量的食盐,以含谷氨酸钠的多少(90%、95%、90%、80%),分成各种规格。全国各地均有生产。",
"nutrition": "1. 味精对人体没有直接的营养价值,但它能增加食品的鲜味,引起人们食欲,有助于提高人体对食物的消化率;\n2. 味精中的主要成分谷氨酸钠还具有治疗慢性肝炎、肝昏迷、神经衰弱、癫痫病、胃酸缺乏等病的作用。",
"guidance": "1. 对用高汤烹制的菜肴,不必使用味精,因为高汤本身已具有鲜、香、清的特点,味精则只有一种鲜味,而它的鲜味和高汤的鲜味也不能等同,如使用味精,会将本味掩盖,致使菜肴口味不伦不类;\n2. 对酸性菜肴,如:糖醋、醋熘、醋椒菜类等,不宜使用味精,因为味精在酸性物质中不易溶解,酸性越大溶解度越低,鲜味的效果越差;\n3. 拌凉菜使用晶体味精时应先用少量热水化开然后再浇到凉菜上效果较好因味精在45℃时才能发挥作用如果用晶体直接拌凉菜不易拌均匀影响味精的提鲜作用\n4. 作菜使用味精,应在起锅时加入,因为在高温下,味精会分解为焦谷氨酸钠,即脱水谷氨酸钠,不但没有鲜味,而且还会产生轻微的毒素,危害人体;\n5. 味精使用时应掌握好用量并不是多多益善它的水稀释度是3000倍人对味精的味觉感为\n0. 033%在使用时以1500倍左右为适宜如投放量过多会使菜中产生似成非成似涩非涩的怪味造成相反的效果\n6. 味精在常温下不易溶解,在 7090度时溶解最好鲜味最足超过100度时味精就被水蒸气挥发超过130度时即变质为焦谷氨酸钠不但没有鲜味还会产生毒性对炖、烧、煮、熬、蒸的菜不宜过早放味精要在将出锅时放入\n7. 在含有碱性的原料中不宜使用味精,回味精遇碱会化合成谷氨酸二钠,会产生氨水臭味。",
"effect": "",
"other": "",
"allergen": [
"味精"
],
"allergen_type": [
"调味品类"
]
}
},
{
"ingredient_id": 253635,
"name": "姜",
"amount": "4克",
"sort": 6,
"detail_id": 1,
"detail": {
"alias": [
"生姜",
"黄姜",
"均姜"
],
"usage_tip": [
"1. 适宜伤风感冒、寒性痛经、晕车晕船者食用。",
"2. 阴虚内热及邪热亢盛者忌食。"
],
"introduction": "姜属姜科,为植物姜的干燥根茎或鲜根茎,多年生草本植物。原产印度、马来西亚,我国自古栽培,周朝食用。姜供食用的部位为不规则的块茎,呈灰白或黄色,具有辛辣味。姜按用途和收获季节不同而有嫩姜和老姜之分。嫩姜多在八月份挖掘,一般含水多,纤维少,辛辣味淡薄,除做调味品外,尚可炒食,做姜糖等;老姜多在十一月份挖掘,水分少,辛辣味浓,主要用做调味。姜是一种极为重要的调味品,同时也可作为蔬菜单独食用,而且还是一味重要的中药材。它可将自身的辛辣味和特殊芳香渗入到菜肴中,使之鲜美可口,味道清香。",
"nutrition": "生姜还具有解毒杀菌的作用日常我们在吃松花蛋或鱼蟹等水产时通常会放上一些姜末、姜汁。人体在进行正常新陈代谢生理功能时会产生一种有害物质氧自由基促使机体发生癌症和衰老。生姜中的姜辣素进入体内后能产生一种抗氧化本酶它有很强的对付氧自由基的本领比维生素E还要强得多。所以吃姜能抗衰老老年人常吃生姜可除“老年斑”。生姜的提取物能刺激胃粘膜引起血管运动中枢及交感神经的反射性兴奋促进血液循环振奋胃功能达到健胃、止痛、发汗、解热的作用。姜的挥发油能增强胃液的分泌和肠壁的蠕动从而帮助消化生姜中分离出来的姜烯、姜酮的混合物有明显的止呕吐作用。生姜提取液具有显著抑制皮肤真菌和杀来头阴道滴虫的功效可治疗各种痈肿疮毒。生姜有抑制癌细胞活性、降低癌的毒害作用。",
"guidance": "1. 吃饭不香或饭量减少时吃上几片姜或者在菜果放上一点嫩姜,都能改善食欲,增加饭量,所以俗话说:“饭不香,吃生姜”。\n2. 姜可煎汤内服,佐料,入菜炒食,或切片炙穴位。老姜可做调料或配料;嫩姜可用于炒、拌、爆等,如“嫩姜炒牛肉丝”、“嫩姜爆鸭丝”等。\n3. 吃姜一次不宜过多,以免吸收大量姜辣素,在经肾脏排泄过程中会刺激肾脏,并产生口干、咽痛、便秘等“上火”症状。\n4. 烂姜、冻姜不要吃,因为姜变质后会产生致癌物,由于姜性质温热,有解表功效,所以只能在受寒的情况下作为食疗应用。烹调用途:生姜重要的调料品,因为其味清辣,只将食物的异味挥散,而不将食品混成辣味,宜作荤腥菜的矫味品,亦用于糕饼糖果制作,如姜饼、姜糖等。",
"effect": "生姜味辛、性微温,入脾、胃、肺经;具有发汗解表,温中止呕,温肺止咳,解毒的功效;主治外感风寒、胃寒呕吐、风寒咳嗽、腹痛腹泻、中鱼蟹毒等病症。",
"other": "",
"allergen": [
"姜"
],
"allergen_type": [
"蔬菜类"
]
}
},
{
"ingredient_id": 253650,
"name": "大葱",
"amount": "10克",
"sort": 7,
"detail_id": 2,
"detail": {
"alias": [
"葱",
"青葱",
"四季葱",
"事菜"
],
"usage_tip": [
"1. 脑力劳动者更宜;",
"2. 患有胃肠道疾病特别是溃疡病的人不宜多食;另外葱对汗腺刺激作用较强,有腋臭的人在夏季应慎食;表虚、多汗者也应忌食;过多食用葱还会损伤视力。"
],
"introduction": "葱属百合科,是多年生草本植物葱的茎与叶,上部为青色葱叶,下部为白色葱白。原产于西伯利亚,我国栽培历史悠久,分布广泛,而以山东、河北、河南等省为重要产地。大葱耐寒抗热,适应性强,四季均可上市。普通大葱,原产我国,遍及南北各地。叶圆而中空,叶鞘基部抱合成“假茎”,幼嫩时叶和葱白都能食用。根据葱白的长短又分为两个类型。大葱植株高大,葱白洁白而味甜,在北方栽培较多。葱是日常厨房里的必备之物,北方以大葱为主,它不仅可作调味之品,而且能防治疫病,可谓佳蔬良药。大葱多用于煎炒烹炸;南方多产小葱,是一种常用调料,又叫香葱,一般都是生食或拌凉菜用。",
"nutrition": "1. 生葱像洋葱、大葱一样含烯丙基硫醚。而烯丙基硫醚会刺激胃液的分泌且有助于食欲的增进。同时与维生素B1含量较多的食物一起摄取时维生素B1所含的淀粉及糖质会变为热量而提高恢复疲劳的作用。\n2. 葱叶部分要比葱白部分含有更多的维生素A、维C及钙。葱中含有相当量的维生素C有舒张小血管促进血液循环的作用有助于防止血压升高所致的头晕使大脑保持灵活和预防老年痴呆的作用。\n3. 经常吃葱的人,即便脂多体胖,但胆固醇并不增高,而且体质强壮。葱含有微量元素硒,并可降低胃液内的亚硝酸盐含量,对预防胃癌及多种癌症有一定作用。\n4. 葱含有具有刺激性气味的挥发油和辣素,能祛除腥腥膻等油腻厚味菜肴中的异味,产生特殊香气,并有较强的杀菌作用,可以刺激消化液的分泌,增进食欲。挥发性辣素还通过汗腺、呼吸道、泌尿系统排出时能轻微刺激刺激相关腺体的分泌,而起到发汗、祛痰、利尿作用。是治疗感冒的中药之一。\n5. 葱还有降血脂、降血压、降血糖的作用,如果与蘑菇同食可以起到促进血液循环的作用。",
"guidance": "1. 每天食用葱,对身体有益。葱可生吃,也可凉拌当小菜食用,作为调料,多用于荤、腥、膻、以及其他有异味的菜肴、汤羹中,对没有异味的菜肴、汤羹也起增味增香作用。\n2. 根据主料的不同,可切成葱段和葱末掺合使用,均不宜煎、炸过久。\n3. 葱叶因富含维生素A原不应轻易丢弃不用。\n4. 葱中含有的烯丙基硫醚由于是属于挥发性,因此泡在水里或煮得过久,都会使其效果丧失。\n5. 在加入味增汁熄火之后,再洒上葱花,即可使香味更可口,且可发挥烯丙基硫醚的效果。\n6. 葱与维生素B1含量较多的食品一起摄取。因为具有消除臭味的作用因此像猪肉或羊肉等带有腥味的菜肴务必要使用葱来调味。",
"effect": "葱味辛、性温;能通阳活血、驱虫解毒、发汗解表;主治风寒感冒轻症、痈肿疮毒、痢疾脉微、寒凝腹痛、小便不利等病症。对感冒、风寒、头痛、阴寒腹痛、虫积内阻、痢疾等有较好的治疗作用。",
"other": "",
"allergen": [
"葱"
],
"allergen_type": [
"蔬菜类"
]
}
},
{
"ingredient_id": 253665,
"name": "醋",
"amount": "10克",
"sort": 8,
"detail_id": 1211,
"detail": {
"alias": [
"苦酒",
"淳酢",
"醯",
"酢"
],
"usage_tip": [
"一般人群均可食用脾胃湿盛、外感初起者忌服;胃溃疡和胃酸过多者不宜食醋。"
],
"introduction": "醋是一种发酵的酸味液态调味品,以含淀粉类的粮食(高粱、黄米、糯米、籼米等)为主料,谷糠、稻皮等为辅料,经过发酵酿造而成。醋在烹调中为主要的调味品之一,以酸味为主,且有芳香味,用途较广是糖醋味的主要原料。它能去腥解腻增加鲜味和香味能在食物加热过程中使维生素C减少损失还可使烹饪原料中钙质溶解而利于人体吸收。比较著名的品种有江苏镇江的香醋和山西的老陈醋等常用于溜菜、拌菜及腥味较重的菜肴中。食醋因原料和制作方法的不同可分为发酵醋和人工合成醋两种其品种主要有米醋、熏醋、白醋等。米醋主要原料为高粱、黄米、麸皮、米糠、盐经醋曲发酵后制成呈浅棕色香味浓郁质量较好适合于蘸食和炒菜熏醋原料除无黄米外基本与米醋原料相同发酵后略加花椒、桂皮等熏制而成颜色较深以存放时间长者为好适合于蘸食和炒菜白醋(又称醋精)为冰醋酸加水稀释而成,醋酸的含量高于米醋等,酸味大,无香味。浓醋酸有一定的腐蚀作用,使用时应根据需要稀释和控制用量。烹调菜肴时加点醋,不仅使菜肴脆嫩可口,祛除腥膻味,还能保护其中的营养素。但是正在服用某些药物如:磺胺类药、碱性药、抗生素、解表发汗的中药的人不宜食醋。",
"nutrition": "1. 醋可以开胃,促进唾液和胃液的分泌,帮助消化吸收,使食欲旺盛,消食化积;\n2. 醋有很好的抑菌和杀菌作用,能有效预防肠道疾病、流行性感冒和呼吸疾病;\n3. 醋可软化血管、降低胆固醇,是高血压等心脑血管病人的一剂良方;\n4. 醋对皮肤、头发能起到很好的保护作用,中国古代医学就有用醋入药的记载,认为它有生发、美容、降压、减肥的功效;\n5. 醋可以消除疲劳,促进睡眠,并能减轻晕车、晕船的不适症状;\n6. 醋还能减少胃肠道和血液中的酒精浓度,起到醒酒的作用;\n7. 醋还有使鸡骨、鱼翅软化,促进钙吸收的作用。",
"guidance": "1. 吃饺子蘸醋或食用醋较多的菜肴后应及时漱口以保护牙齿;\n2. 作菜时,加醋的最佳时间是在两头,即原料入锅后马上加醋及菜肴临出锅前加醋,第一次应多些,第二次应少些;\n3. 醋可以用于需要去腥解腻的原料,如烹制水产品或肚、肠、心等,可消除腥臭和异味,对一些腥臭较重的原料还可以提前用醋浸渍;\n4. 醋用于烹制带骨的原料,如排骨、鱼类等,可使骨刺软化,促进骨中的矿物质如钙、磷溶出,增加营养成分。",
"effect": "醋味酸苦、性温,入肝、胃经;有散瘀,止血,解毒,杀虫的功效;主治产后血晕、黄疸、黄汗、吐血、衄血、大便下血、痈疽疮肿,又可解鱼肉菜毒。",
"other": "中国古代酸味调味应用较多醋传为造酒时所创制《四民月令》已载有作醋方法至北魏《齐民要术》其中制醋法已达20余种。以后各代均有名醋出现。至今醋仍为开门七件事之一在生活中占有重要地位。",
"allergen": [],
"allergen_type": []
}
},
{
"ingredient_id": 253678,
"name": "白砂糖",
"amount": "10克",
"sort": 9,
"detail_id": 769,
"detail": {
"alias": [
"砂糖",
"石蜜",
"白霜糖",
"白糖"
],
"usage_tip": [
"一般人群均可食用"
],
"introduction": "糖是用甘蔗或甜菜等植物加工而成的一种调味品,其主要成分是蔗糖。白砂糖是食糖中质量最好的一种。其颗粒为结晶状,均匀,颜色洁白,甜味纯正,甜度稍低于红糖。烹调中常用。绵白糖为粉末状,适合于烹调之用,甜度与白砂糖差不多。绵白糖有精制绵白糖和土法制的绵白糖两种。前者色泽洁白,晶粒细软,质量较好;后者色泽微黄稍暗,质量较差。白砂糖和绵白糖只是结晶体大小不同,白砂糖的结晶颗粒大,含水分很少,而绵白糖的结晶颗粒小,含水分较多。广东、福建、台湾等省和东北地区是我国主要产糖区。糖是重要的调味品,能增加菜肴的甜味及鲜味,增添制品的色泽,为制作菜肴特别是甜菜品种的主要调味原料。",
"nutrition": "1. 适当食用白糖有助于提高机体对钙的吸收,但过多就会妨碍钙的吸收;\n2. 吃糖后应及时漱口或刷牙以防龋齿的产生3 .糖尿病病人不易直接食用食糖,最好是以甜味剂替代。",
"guidance": "1. 炒菜时不小心把盐放多了,加入适量白糖,就可解咸;\n2. 糖很容易生螨,存放日久的糖不要生吃,应煮开后食用。",
"effect": "白砂糖味甘、性平,归脾、肺经;有润肺生津、止咳、和中益肺、舒缓肝气、滋阴、调味、除口臭、解盐卤毒之功效。",
"other": "制糖为我国首创,早在三千多年前我国就有用谷物制作饴糖的记载。根据《齐民要术》的记载可知后汉时我国已经生产蔗糖和冰糖了。唐贞观年间我国自印度传入熬糖法后,改进了工艺,蔗糖质量有所提高。",
"allergen": [],
"allergen_type": []
}
},
{
"ingredient_id": 253690,
"name": "香油",
"amount": "5克",
"sort": 10,
"detail_id": 846,
"detail": {
"alias": [
"麻油",
"芝麻油"
],
"usage_tip": [
"老少皆宜"
],
"introduction": "芝麻油Sesame oil简称麻油俗称香油是小磨香油和机制香油的统称亦即具有浓郁或显著香味的芝麻油。在加工过程中芝麻中的特有成分经高温炒料处理后生成具有特殊香味的物质致使芝麻油具有独特的香味有别于其它各种食用油故称香油。按加工工艺不同香尚未分为小磨香油和机制香油两种。芝麻Sesamum indicum可能原产于非洲一带产出的油用于烹饪并加在沙拉里在中菜里也很受欢迎。",
"nutrition": "1. 延缓衰老香油中含丰富的维生素E具有促进细胞分裂和延缓衰老的功能\n2. 保护血管香油中含有40%左右的亚油酸、棕榈酸等不饱和脂肪酸,容易被人体分解吸收和利用,以促进胆固醇的代谢,并有助于消除动脉血管壁上的沉积物;芝麻油是一种促凝血药,用于治疗血小板减少性紫癜和出血性素质有一定效果;\n3. 润肠通便;\n4. 减轻烟酒毒害:有抽烟习惯和嗜酒的人经常喝点香油,可以减轻烟对牙齿、牙龈、口腔黏膜的直接刺激和损伤,以及肺部烟斑的形成,同时对尼古丁的吸收也有相对的抑制作用。饮酒之前喝点香油,则对口腔、食道、胃贲门和胃黏膜起到一定的保护作用;\n5. 保护嗓子:常喝香油能增强声带弹性,使声门张合灵活有力,对声音嘶哑、慢性咽喉炎有良好的恢复作用;\n6. 从芝麻中榨出香油中所含的卵磷脂都是益寿延年抗衰老的上佳成分,是中老年人最好的冬令补品;",
"guidance": "",
"effect": "芝麻油有利于食物的消化吸收,有延缓衰老、保护血管、润肠通便、减轻烟酒毒害、保护嗓子的功效;对口腔溃疡、牙周炎、牙龈出血、咽喉发炎均有很好的改善作用。",
"other": "",
"allergen": [],
"allergen_type": []
}
},
{
"ingredient_id": 253702,
"name": "胡椒粉",
"amount": "2克",
"sort": 11,
"detail_id": 1210,
"detail": {
"alias": [],
"usage_tip": [
"一般人群均可食用消化道溃疡、咳嗽咯血、痔疮、咽喉炎症、眼疾患者慎食。"
],
"introduction": "胡椒为热带植物胡椒树的果实,主要产在印度、越南、印尼、泰国、新加坡等国,我国广东省海南岛也有生产。胡椒味辛辣芳香,性热,除可去腥增香外,还有除寒气、消积食的效用,但多食则刺激胃粘膜而引起充血。胡椒粉是用干胡椒碾压而成,有白胡椒粉和黑胡椒粉两种。黑胡椒粉是未成熟果实加工而成,白胡椒粉是果实完全成熟后采摘加工而成。",
"nutrition": "1. 胡椒的主要成分是胡椒碱,也含有一定量的芳香油、粗蛋白、粗脂肪及可溶性氮,能祛腥、解油腻,助消化;\n2. 胡椒的气味能增进食欲;\n3. 胡椒性温热,对胃寒所致的胃腹冷痛、肠鸣腹泻有很好的缓解作用,并治疗风寒感冒;\n4. 胡椒有防腐抑菌的作用,可解鱼虾肉毒;\n5. 黑胡椒的辣味比白胡椒强烈,香中带辣,祛腥提味,更多的用于烹制内脏、海鲜类菜肴;\n6. 白胡椒的药用价值较大,可散寒、健胃等,可以增进食欲、助消化,促发汗;还可以改善女性白带异常及癫痫症。",
"guidance": "",
"effect": "胡椒味辛、性热入胃、大肠经有温中下气消痰解毒的功效主治寒痰食积、脘腹冷痛、反胃、呕吐清水、泄泻、冷痢外敷治疮肿、毒蛇咬伤、犬咬伤又可解食物毒。1.温中散寒用于胃寒所致的胃脘痛、呕吐、以及腹冷所致的泄泻、肠鸣2.醒脾开胃:本品小剂量能增进食欲,对胃口差、消化不良有治疗作用。",
"other": "胡椒始见载于唐代《酉阳杂俎》《唐本草》诸书,传为唐僧西域取经携回。以后历代本草均有记述,多供药用,亦用于食品调味。",
"allergen": [
"胡椒"
],
"allergen_type": [
"调味品类"
]
}
},
{
"ingredient_id": 253710,
"name": "花生油",
"amount": "30克",
"sort": 12,
"detail_id": 849,
"detail": {
"alias": [
"落花生油",
"果油"
],
"usage_tip": [
"适合所有人,特别是中老年人食用。"
],
"introduction": "花生油Peanut oil为豆科植物花生的种子榨出之脂肪油淡黄透明色泽清亮气味芬芳滋味可口是一种比较容易消化的食用油。可提供给人体大量营养含多种脂肪酸的甘油酯可增加食品的美味是构成人体内多种组织成分的重要原料。是目前我国主要的食用植物油之一可用于炒、煎、炸各种菜肴和食品。花生的油脂含量约有50很适合拌沙拉或作为炸油。也用来制植物奶油或鱼罐头。",
"nutrition": "1. 中国预防医学科学院经研究证实,花生油含锌量是色拉油、粟米油、菜籽油、豆油的许多倍。虽然补锌的途径很多,但油脂是人们日常必需的补充物,所以食用花生油特别适宜于大众补锌;\n2. 花生油中还含有多种抗衰老成分,有延缓脑功能衰老的作用。花生油还具有健脾润肺,解积食、驱脏虫的功效;\n3. 营养专家还在花生油中发理了3种有益寿延年于心脑血管的保健成分白藜芦醇、单不饱和脂肪酸和β-谷固醇,实验证明,这几种物质是肿瘤类疾病的化学预防剂,也是降低血小板聚集、防治动脉硬化及心脑血管疾病的化学预防剂;\n4. 是中老年人理想的食用油脂之一,花生油中的胆碱,还可改善人脑的记忆力,延缓脑功能衰退。",
"guidance": "1. 花生油热量高,脂肪量大,不宜过量食用,否则对心脑血管还是会有一定影响,而且容易发胖。\n2. 花生油油耐高温,除炒菜外适合于煎炸食物。\n3. 用花生油炒菜在油加热后先放盐在油中爆约30秒可除去花生油中可能存在的黄曲霉素。\n4. 植物油可防止粥沫:煮稀饭时,往锅里滴几滴花生油,并改用文火,稀饭就不会有沫子外溢了。",
"effect": "花生油味甘、性平,入脾、肺、大肠经;可补脾润肺、润肠下虫;花生油熟食,有润肠逐虫之功效,可治疗蛔虫性肠梗阻。",
"other": "",
"allergen": [
"花生"
],
"allergen_type": [
"坚果类"
]
}
}
]
},
"allergens": [
"豆腐",
"豆",
"酒",
"料酒",
"味精",
"姜",
"葱",
"胡椒",
"花生"
],
"nutrition": [
{
"name": "叶酸",
"value": 13.98,
"unit": "微克"
},
{
"name": "核黄素",
"value": 0.28,
"unit": "毫克"
},
{
"name": "烟酸",
"value": 5.66,
"unit": "毫克"
},
{
"name": "硒",
"value": 3.75,
"unit": "微克"
},
{
"name": "硫胺素",
"value": 0.23,
"unit": "毫克"
},
{
"name": "碘",
"value": 2308.14,
"unit": "微克"
},
{
"name": "碳水化合物",
"value": 69.96,
"unit": "克"
},
{
"name": "磷",
"value": 455.9,
"unit": "毫克"
},
{
"name": "维生素A",
"value": 180.02,
"unit": "微克"
},
{
"name": "维生素B",
"value": 60.19,
"unit": "毫克"
},
{
"name": "维生素C",
"value": 0.46,
"unit": "毫克"
},
{
"name": "维生素E",
"value": 50.13,
"unit": "毫克"
},
{
"name": "胡萝卜素",
"value": 1079.4,
"unit": "微克"
},
{
"name": "能量",
"value": 920.1,
"unit": "千卡"
},
{
"name": "脂肪",
"value": 71.91,
"unit": "克"
},
{
"name": "膳食纤维",
"value": 38.25,
"unit": "克"
},
{
"name": "蛋白质",
"value": 27.56,
"unit": "克"
},
{
"name": "钙",
"value": 755.52,
"unit": "毫克"
},
{
"name": "钠",
"value": 7833,
"unit": "毫克"
},
{
"name": "钾",
"value": 624.44,
"unit": "毫克"
},
{
"name": "铁",
"value": 43.78,
"unit": "毫克"
},
{
"name": "铜",
"value": 0.66,
"unit": "毫克"
},
{
"name": "锌",
"value": 15.9,
"unit": "毫克"
},
{
"name": "锰",
"value": 4.95,
"unit": "毫克"
},
{
"name": "镁",
"value": 272.2,
"unit": "毫克"
}
],
"statistics": {
"view_count": 0,
"comment_count": 0,
"like_count": 0,
"recommend_count": 0,
"recommend_score": 0
},
"meta": {
"indices": {
"营养": 7,
"难易": 7,
"时间": 6
},
"process": "焖",
"taste": "咸鲜味",
"eating_time": [
"早餐",
"中餐",
"晚餐"
]
}
},
"_cached": false,
"_query_time": "1305.78ms"
}

View File

@@ -1,4 +1,4 @@
# 妈厨房 iOS 26 UI 设计方案
# 妈厨房 iOS 26 UI 设计方案
> 版本: 1.1 | 日期: 2026-04-09 | 设计语言: Liquid Glass (iOS 26)
>
@@ -229,7 +229,7 @@ Tab3: 👤 个人中心 Tab3: 👤 我的 (简化设置)
```
┌─────────────────────────────────────┐
│ 🍳 妈厨房 🔔 👤 │ ← Liquid Glass 导航栏
│ 🍳 妈厨房 🔔 👤 │ ← Liquid Glass 导航栏
├─────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │

View File

@@ -148,7 +148,7 @@ class AppRoutes {
),
GetPage(
name: recipeDetail,
page: () => RecipeDetailPage(recipeId: Get.arguments),
page: () => RecipeDetailPage(recipeId: '${Get.arguments ?? '1'}'),
middlewares: [PageStandardsMiddleware()],
),
GetPage(

View File

@@ -1,5 +1,6 @@
// 2026-04-09 | RecipeModel | 菜谱数据模型 | 对齐api.php返回字段结构
// 2026-04-10 | API v2.0.0: 新增 code/allergens/meta 字段,增强 ingredients 分类结构(main/auxiliary/seasoning)
// 2026-04-11 | 新增 author/categoryHierarchy 字段,增强 IngredientDetail(别名/介绍/营养/指导/功效)
class RecipeModel {
final int id;
final String title;
@@ -8,6 +9,8 @@ class RecipeModel {
final String? cover;
final int? categoryId;
final String? categoryName;
final List<CategoryHierarchyItem> categoryHierarchy;
final RecipeAuthor? author;
final List<TagItem> tags;
final List<IngredientItem> ingredients;
final CategorizedIngredients? categorizedIngredients;
@@ -27,6 +30,8 @@ class RecipeModel {
this.cover,
this.categoryId,
this.categoryName,
this.categoryHierarchy = const [],
this.author,
this.tags = const [],
this.ingredients = const [],
this.categorizedIngredients,
@@ -102,6 +107,8 @@ class RecipeModel {
_parseStringOrNull(json['imageUrl']),
categoryId: categoryId,
categoryName: categoryName,
categoryHierarchy: _parseCategoryHierarchy(categoryObj),
author: _parseAuthor(json['author']),
tags: _parseTags(json['tags']),
ingredients: _parseIngredients(json['ingredients']),
categorizedIngredients: _parseCategorizedIngredients(json['ingredients']),
@@ -199,6 +206,32 @@ class RecipeModel {
}
return null;
}
static List<CategoryHierarchyItem> _parseCategoryHierarchy(dynamic json) {
try {
if (json is! Map<String, dynamic>) return [];
final hierarchy = json['hierarchy'];
if (hierarchy is! List) return [];
return hierarchy
.whereType<Map<String, dynamic>>()
.map((e) {
try {
return CategoryHierarchyItem.fromJson(e);
} catch (_) {
return null;
}
})
.whereType<CategoryHierarchyItem>()
.toList();
} catch (_) {
return [];
}
}
static RecipeAuthor? _parseAuthor(dynamic json) {
if (json is! Map<String, dynamic>) return null;
return RecipeAuthor.fromJson(json);
}
}
class TagItem {
@@ -277,27 +310,48 @@ class IngredientItem {
}
class IngredientDetail {
final String? allergen;
final String? allergenType;
final List<String> alias;
final List<String> usageTip;
final String? introduction;
final String? nutrition;
final String? usageTip;
final String? guidance;
final String? effect;
final String? other;
final List<String> allergen;
final List<String> allergenType;
const IngredientDetail({
this.allergen,
this.allergenType,
this.alias = const [],
this.usageTip = const [],
this.introduction,
this.nutrition,
this.usageTip,
this.guidance,
this.effect,
this.other,
this.allergen = const [],
this.allergenType = const [],
});
factory IngredientDetail.fromJson(Map<String, dynamic> json) {
return IngredientDetail(
allergen: _parseStringField(json['allergen']),
allergenType: _parseStringField(json['allergen_type']),
alias: _parseStringList(json['alias']),
usageTip: _parseStringList(json['usage_tip']),
introduction: _parseStringField(json['introduction']),
nutrition: _parseStringField(json['nutrition']),
usageTip: _parseStringField(json['usage_tip']),
guidance: _parseStringField(json['guidance']),
effect: _parseStringField(json['effect']),
other: _parseStringField(json['other']),
allergen: _parseStringList(json['allergen']),
allergenType: _parseStringList(json['allergen_type']),
);
}
static List<String> _parseStringList(dynamic v) {
if (v is List) return v.map((e) => e.toString()).toList();
if (v is String && v.isNotEmpty) return [v];
return [];
}
static String? _parseStringField(dynamic v) {
if (v == null) return null;
if (v is String) return v.isEmpty ? null : v;
@@ -305,7 +359,13 @@ class IngredientDetail {
return null;
}
bool get hasAllergen => allergen != null && allergen!.isNotEmpty;
bool get hasAllergen => allergen.isNotEmpty;
bool get hasAlias => alias.isNotEmpty;
bool get hasUsageTip => usageTip.isNotEmpty;
bool get hasIntroduction => introduction != null && introduction!.isNotEmpty;
bool get hasNutrition => nutrition != null && nutrition!.isNotEmpty;
bool get hasGuidance => guidance != null && guidance!.isNotEmpty;
bool get hasEffect => effect != null && effect!.isNotEmpty;
}
class CategorizedIngredients {
@@ -450,14 +510,26 @@ class RecipeStatistics {
final int views;
final int likes;
final int recommends;
final int comments;
final int recommendScore;
const RecipeStatistics({this.views = 0, this.likes = 0, this.recommends = 0});
const RecipeStatistics({
this.views = 0,
this.likes = 0,
this.recommends = 0,
this.comments = 0,
this.recommendScore = 0,
});
factory RecipeStatistics.fromJson(Map<String, dynamic> json) {
return RecipeStatistics(
views: _parseInt(json['views'] ?? json['view_count']),
likes: _parseInt(json['likes'] ?? json['like_count']),
recommends: _parseInt(json['recommends'] ?? json['recommend_count']),
comments: _parseInt(json['comments'] ?? json['comment_count']),
recommendScore: _parseInt(
json['recommend_score'] ?? json['recommendScore'],
),
);
}
@@ -477,6 +549,7 @@ class RecipeMeta {
final String? difficulty;
final String? time;
final List<String> eatingTime;
final Map<String, int> indices;
const RecipeMeta({
this.process,
@@ -484,6 +557,7 @@ class RecipeMeta {
this.difficulty,
this.time,
this.eatingTime = const [],
this.indices = const {},
});
factory RecipeMeta.fromJson(Map<String, dynamic> json) {
@@ -493,6 +567,15 @@ class RecipeMeta {
difficulty: _parseString(json['difficulty']),
time: _parseString(json['time']),
eatingTime: _parseStringList(json['eating_time']),
indices: _parseIndices(json['indices']),
);
}
static Map<String, int> _parseIndices(dynamic value) {
if (value is! Map) return {};
return value.map(
(k, v) =>
MapEntry(k.toString(), v is int ? v : (v is double ? v.toInt() : 0)),
);
}
@@ -542,3 +625,80 @@ class RecipeMeta {
}
}
}
class CategoryHierarchyItem {
final int id;
final String name;
final String? alias;
final int level;
const CategoryHierarchyItem({
required this.id,
required this.name,
this.alias,
this.level = 0,
});
factory CategoryHierarchyItem.fromJson(Map<String, dynamic> json) {
return CategoryHierarchyItem(
id: _safeInt(json['id']),
name: _safeString(json['name']) ?? '',
alias: _safeString(json['alias']),
level: _safeInt(json['level']),
);
}
static int _safeInt(dynamic v) {
if (v == null) return 0;
if (v is int) return v;
if (v is double) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
static String? _safeString(dynamic v) {
if (v == null) return null;
if (v is String) return v.isEmpty ? null : v;
return null;
}
}
class RecipeAuthor {
final int id;
final String name;
final String? alias;
final String? email;
final String? homepage;
const RecipeAuthor({
required this.id,
required this.name,
this.alias,
this.email,
this.homepage,
});
factory RecipeAuthor.fromJson(Map<String, dynamic> json) {
return RecipeAuthor(
id: _safeInt(json['id']),
name: _safeString(json['name']) ?? '',
alias: _safeString(json['alias']),
email: _safeString(json['email']),
homepage: _safeString(json['homepage']),
);
}
static int _safeInt(dynamic v) {
if (v == null) return 0;
if (v is int) return v;
if (v is double) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
static String? _safeString(dynamic v) {
if (v == null) return null;
if (v is String) return v.isEmpty ? null : v;
return null;
}
}

View File

@@ -12,6 +12,7 @@ import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/models/recipe/category_model.dart';
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
import 'package:mom_kitchen/src/repositories/recipe_repository.dart';
import 'package:mom_kitchen/src/widgets/recipe_image.dart';
class CategoryBrowsePage extends StatefulWidget {
final CategoryModel? category;
@@ -352,35 +353,16 @@ class _CategoryBrowsePageState extends State<CategoryBrowsePage> {
),
child: Row(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: DesignTokens.primaryLight,
borderRadius: DesignTokens.borderRadiusMd,
ClipRRect(
borderRadius: DesignTokens.borderRadiusMd,
child: RecipeImage(
recipeId: recipe.id,
coverUrl: recipe.cover,
width: 60,
height: 60,
fit: BoxFit.cover,
mode: RecipeImageMode.thumbnail,
),
child: recipe.cover != null && recipe.cover!.isNotEmpty
? ClipRRect(
borderRadius: DesignTokens.borderRadiusMd,
child: Image.network(
recipe.cover!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Center(
child: Icon(
CupertinoIcons.photo,
color: DesignTokens.primary,
size: 24,
),
),
),
)
: const Center(
child: Icon(
CupertinoIcons.photo,
color: DesignTokens.primary,
size: 24,
),
),
),
const SizedBox(width: DesignTokens.space3),
Expanded(
@@ -388,7 +370,7 @@ class _CategoryBrowsePageState extends State<CategoryBrowsePage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipe.title ?? '',
recipe.title,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,

View File

@@ -11,12 +11,12 @@ import 'package:get/get.dart';
import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/repositories/recipe_repository.dart';
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
import 'recipe_detail_page.dart';
import 'package:mom_kitchen/src/config/app_routes.dart';
import 'package:mom_kitchen/src/widgets/nutrition_dashboard_card.dart';
import 'package:mom_kitchen/src/widgets/base/skeleton_loader.dart';
import 'package:mom_kitchen/src/services/ui/toast_service.dart';
import 'package:mom_kitchen/src/controllers/meal_record_controller.dart';
import 'package:mom_kitchen/src/widgets/recipe_image.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@@ -428,15 +428,14 @@ class _HomePageState extends State<HomePage> {
decoration: BoxDecoration(
color: DesignTokens.dynamicPrimaryLight,
),
child: recipe.hasCover
? Image.network(
recipe.cover!,
fit: BoxFit.cover,
width: double.infinity,
errorBuilder: (_, _, _) =>
_buildPlaceholderImage(recipe.title[0]),
)
: _buildPlaceholderImage(recipe.title[0]),
child: RecipeImage(
recipeId: recipe.id,
coverUrl: recipe.cover,
fit: BoxFit.cover,
width: double.infinity,
height: 180,
mode: RecipeImageMode.thumbnail,
),
),
// 内容区
@@ -562,40 +561,6 @@ class _HomePageState extends State<HomePage> {
);
}
Widget _buildPlaceholderImage(String letter) {
return Center(
child: Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
DesignTokens.primary.withValues(alpha: 0.15),
DesignTokens.secondary.withValues(alpha: 0.08),
],
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
letter,
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: DesignTokens.primary.withValues(alpha: 0.3),
),
),
const SizedBox(height: 8),
Text('🍽️', style: const TextStyle(fontSize: 32)),
],
),
),
);
}
Widget _buildCategoryCard(String title, Color color, bool isDark) {
return GestureDetector(
onTap: () {

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@ import 'package:get/get.dart';
import '../../controllers/search_controller.dart';
import '../../config/design_tokens.dart';
import '../../models/recipe/recipe_model.dart';
import 'recipe_detail_page.dart';
import '../../widgets/recipe_image.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@@ -493,36 +493,17 @@ class _SearchPageState extends State<SearchPage> {
),
child: Row(
children: [
if (recipe.cover != null && recipe.cover!.isNotEmpty)
ClipRRect(
borderRadius: DesignTokens.borderRadiusSm,
child: Image.network(
recipe.cover!,
width: 48,
height: 48,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 48,
height: 48,
color: isDark
? DarkDesignTokens.text3.withValues(alpha: 0.1)
: DesignTokens.text3.withValues(alpha: 0.05),
child: const Icon(CupertinoIcons.photo, size: 20),
),
),
)
else
Container(
ClipRRect(
borderRadius: DesignTokens.borderRadiusSm,
child: RecipeImage(
recipeId: recipe.id,
coverUrl: recipe.cover,
width: 48,
height: 48,
decoration: BoxDecoration(
color: isDark
? DarkDesignTokens.text3.withValues(alpha: 0.1)
: DesignTokens.text3.withValues(alpha: 0.05),
borderRadius: DesignTokens.borderRadiusSm,
),
child: const Icon(CupertinoIcons.photo, size: 20),
fit: BoxFit.cover,
mode: RecipeImageMode.thumbnail,
),
),
const SizedBox(width: DesignTokens.space3),
Expanded(
child: Column(
@@ -659,19 +640,13 @@ class _SearchPageState extends State<SearchPage> {
// 封面图
ClipRRect(
borderRadius: BorderRadius.circular(DesignTokens.radiusSm),
child: Container(
child: RecipeImage(
recipeId: recipeId ?? 0,
coverUrl: cover,
width: 90,
height: 90,
color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
.withValues(alpha: 0.08),
child: cover != null && cover!.isNotEmpty
? Image.network(
cover!,
fit: BoxFit.cover,
errorBuilder: (_, _, _) =>
_buildPlaceholderIcon(isDark),
)
: _buildPlaceholderIcon(isDark),
fit: BoxFit.cover,
mode: RecipeImageMode.thumbnail,
),
),
const SizedBox(width: DesignTokens.space3),
@@ -752,8 +727,4 @@ class _SearchPageState extends State<SearchPage> {
),
);
}
Widget _buildPlaceholderIcon(bool isDark) {
return Center(child: Text('🍽️', style: const TextStyle(fontSize: 32)));
}
}

View File

@@ -1,7 +1,6 @@
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:mom_kitchen/src/controllers/user/personalization_controller.dart';
import 'package:mom_kitchen/src/l10n/app_localizations.dart';
import 'package:mom_kitchen/src/services/core/app_service.dart';
import 'package:mom_kitchen/src/services/ui/theme_service.dart';
import 'package:mom_kitchen/src/widgets/skeleton_widgets.dart';
@@ -16,7 +15,6 @@ class PersonalizationPage extends StatelessWidget {
init: PersonalizationController(),
builder: (controller) {
final themeService = AppService.instance.theme;
final l10n = AppLocalizations.of(context)!;
return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar(

View File

@@ -13,6 +13,7 @@ import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/config/api_config.dart';
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
import 'package:mom_kitchen/src/repositories/recipe_repository.dart';
import 'package:mom_kitchen/src/widgets/recipe_image.dart';
class EatingTimeItem {
final int id;
@@ -390,11 +391,13 @@ class _EatingTimesPageState extends State<EatingTimesPage> {
final searchKeyword = item.name
.replaceAll(RegExp(r'[时段均可作菜佐食。]'), '')
.trim();
final result = await _recipeRepo.fetchList(
search: searchKeyword.isNotEmpty ? searchKeyword : item.name,
page: 1,
limit: 20,
).timeout(const Duration(seconds: 10));
final result = await _recipeRepo
.fetchList(
search: searchKeyword.isNotEmpty ? searchKeyword : item.name,
page: 1,
limit: 20,
)
.timeout(const Duration(seconds: 10));
Navigator.of(context).pop();
@@ -481,36 +484,17 @@ class EatingTimeRecipesPage extends StatelessWidget {
),
child: Row(
children: [
if (recipe.cover != null && recipe.cover!.isNotEmpty)
ClipRRect(
borderRadius: DesignTokens.borderRadiusSm,
child: Image.network(
recipe.cover!,
width: 64,
height: 64,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 64,
height: 64,
color: isDark
? DarkDesignTokens.text3.withValues(alpha: 0.1)
: DesignTokens.text3.withValues(alpha: 0.05),
child: const Icon(CupertinoIcons.photo, size: 24),
),
),
)
else
Container(
ClipRRect(
borderRadius: DesignTokens.borderRadiusSm,
child: RecipeImage(
recipeId: recipe.id,
coverUrl: recipe.cover,
width: 64,
height: 64,
decoration: BoxDecoration(
color: isDark
? DarkDesignTokens.text3.withValues(alpha: 0.1)
: DesignTokens.text3.withValues(alpha: 0.05),
borderRadius: DesignTokens.borderRadiusSm,
),
child: const Icon(CupertinoIcons.photo, size: 24),
fit: BoxFit.cover,
mode: RecipeImageMode.thumbnail,
),
),
const SizedBox(width: DesignTokens.space3),
Expanded(
child: Column(
@@ -521,7 +505,9 @@ class EatingTimeRecipesPage extends StatelessWidget {
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w500,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
color: isDark
? DarkDesignTokens.text1
: DesignTokens.text1,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -532,7 +518,9 @@ class EatingTimeRecipesPage extends StatelessWidget {
recipe.intro!,
style: TextStyle(
fontSize: DesignTokens.fontXs,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
color: isDark
? DarkDesignTokens.text3
: DesignTokens.text3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -542,17 +530,25 @@ class EatingTimeRecipesPage extends StatelessWidget {
recipe.categoryName!.isNotEmpty) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: (isDark ? DarkDesignTokens.primary : DesignTokens.primary)
.withValues(alpha: 0.1),
color:
(isDark
? DarkDesignTokens.primary
: DesignTokens.primary)
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
recipe.categoryName!,
style: TextStyle(
fontSize: 10,
color: isDark ? DarkDesignTokens.primary : DesignTokens.primary,
color: isDark
? DarkDesignTokens.primary
: DesignTokens.primary,
),
),
),

View File

@@ -21,11 +21,25 @@ class PageStandardsMiddleware extends GetMiddleware {
@override
GetPage? onPageCalled(GetPage? page) {
if (page == null) return null;
if (page == null) return page;
WidgetsBinding.instance.addPostFrameCallback((_) {
PageValidator.validate(Get.context!, page.name);
});
try {
final context = Get.context;
if (context == null) return page;
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
final ctx = Get.context;
if (ctx != null && ctx.mounted) {
PageValidator.validate(ctx, page.name);
}
} catch (e) {
AppLogger.w('页面验证异常: ${page.name} - $e');
}
});
} catch (e) {
AppLogger.w('路由中间件异常: ${page.name} - $e');
}
return page;
}

View File

@@ -31,6 +31,7 @@ class GlassFeedCard extends StatelessWidget {
this.subtitle,
this.category,
this.imageUrl,
this.recipeId,
this.viewCount,
this.likeCount,
this.recommendCount,
@@ -84,6 +85,25 @@ class GlassFeedCard extends StatelessWidget {
}
Widget _buildImage() {
if (recipeId != null && recipeId! > 0) {
return ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(DesignTokens.radiusLg),
topRight: Radius.circular(DesignTokens.radiusLg),
),
child: AspectRatio(
aspectRatio: 16 / 9,
child: RecipeImage(
recipeId: recipeId!,
coverUrl: imageUrl,
fit: BoxFit.cover,
mode: RecipeImageMode.thumbnail,
tapToOriginal: true,
),
),
);
}
return ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(DesignTokens.radiusLg),

View File

@@ -23,6 +23,7 @@ class RecipeCard extends StatelessWidget {
subtitle: recipe.intro,
category: recipe.categoryName,
imageUrl: recipe.cover,
recipeId: recipe.id,
viewCount: recipe.statistics?.views,
likeCount: recipe.statistics?.likes,
recommendCount: recipe.statistics?.recommends,

View File

@@ -1,21 +1,27 @@
/*
* 文件: recipe_image.dart
* 名称: 菜谱图片组件
* 作用: 支持缓存+多级fallback的菜谱图片显示
* 作用: 支持缓存+多级fallback+缩略图压缩+点击查看原图的菜谱图片显示
* 创建: 2026-04-11
* 更新: 2026-04-11 初始创建
* 更新: 2026-04-11 增加缩略图压缩模式+点击查看原图+缓存管理
*
* Fallback链: {id}a.jpg → {id}b.jpg → {id}.jpg → back.png → 本地error.png → 空白
* 缓存策略: 内存缓存+磁盘缓存(path_provider临时目录)
* Fallback链: coverUrl → {id}a.jpg → {id}b.jpg → {id}.jpg → back.png → 本地error.png → 空白
* 缓存策略: 内存缓存(24h) + 磁盘缓存(7天, path_provider临时目录)
* 缩略图: 列表模式自动压缩至 thumbnailMaxPx 指定尺寸,减少流量和内存
* 原图: 详情页或点击时加载全尺寸图片
*/
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:path_provider/path_provider.dart';
import 'package:mom_kitchen/src/config/design_tokens.dart';
enum RecipeImageMode { thumbnail, full }
class RecipeImage extends StatefulWidget {
final int recipeId;
final double? width;
@@ -23,6 +29,9 @@ class RecipeImage extends StatefulWidget {
final BoxFit fit;
final String? coverUrl;
final BorderRadius? borderRadius;
final RecipeImageMode mode;
final bool tapToOriginal;
final int thumbnailMaxPx;
const RecipeImage({
super.key,
@@ -32,8 +41,23 @@ class RecipeImage extends StatefulWidget {
this.fit = BoxFit.cover,
this.coverUrl,
this.borderRadius,
this.mode = RecipeImageMode.thumbnail,
this.tapToOriginal = false,
this.thumbnailMaxPx = 400,
});
const RecipeImage.full({
super.key,
required this.recipeId,
this.width,
this.height,
this.fit = BoxFit.cover,
this.coverUrl,
this.borderRadius,
}) : mode = RecipeImageMode.full,
tapToOriginal = false,
thumbnailMaxPx = 400;
@override
State<RecipeImage> createState() => _RecipeImageState();
}
@@ -48,6 +72,7 @@ class _RecipeImageState extends State<RecipeImage> {
bool _hasError = false;
Uint8List? _imageBytes;
String? _currentUrl;
bool _isShowingOriginal = false;
List<String> get _fallbackUrls {
final id = widget.recipeId;
@@ -64,6 +89,9 @@ class _RecipeImageState extends State<RecipeImage> {
return urls;
}
String get _cacheKeyPrefix =>
widget.mode == RecipeImageMode.thumbnail ? 'thumb_' : 'full_';
@override
void initState() {
super.initState();
@@ -74,11 +102,13 @@ class _RecipeImageState extends State<RecipeImage> {
void didUpdateWidget(RecipeImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.recipeId != widget.recipeId ||
oldWidget.coverUrl != widget.coverUrl) {
oldWidget.coverUrl != widget.coverUrl ||
oldWidget.mode != widget.mode) {
_fallbackIndex = 0;
_isLoading = true;
_hasError = false;
_imageBytes = null;
_isShowingOriginal = false;
_loadImage();
}
}
@@ -86,17 +116,20 @@ class _RecipeImageState extends State<RecipeImage> {
Future<void> _loadImage() async {
final urls = _fallbackUrls;
if (_fallbackIndex >= urls.length) {
setState(() {
_isLoading = false;
_hasError = true;
});
if (mounted) {
setState(() {
_isLoading = false;
_hasError = true;
});
}
return;
}
final url = urls[_fallbackIndex];
_currentUrl = url;
final cacheKey = '$_cacheKeyPrefix$url';
final cached = _getFromMemoryCache(url);
final cached = _getFromMemoryCache(cacheKey);
if (cached != null) {
if (mounted) {
setState(() {
@@ -108,9 +141,9 @@ class _RecipeImageState extends State<RecipeImage> {
return;
}
final diskCached = await _getFromDiskCache(url);
final diskCached = await _getFromDiskCache(cacheKey);
if (diskCached != null) {
_addToMemoryCache(url, diskCached);
_addToMemoryCache(cacheKey, diskCached);
if (mounted && _currentUrl == url) {
setState(() {
_imageBytes = diskCached;
@@ -132,15 +165,25 @@ class _RecipeImageState extends State<RecipeImage> {
BytesBuilder(),
(b, d) => b..add(d),
);
final data = bytes.toBytes();
final rawData = bytes.toBytes();
client.close();
_addToMemoryCache(url, data);
_saveToDiskCache(url, data);
Uint8List finalData;
if (widget.mode == RecipeImageMode.thumbnail && !_isShowingOriginal) {
finalData = await _compressImage(
Uint8List.fromList(rawData),
widget.thumbnailMaxPx,
);
} else {
finalData = Uint8List.fromList(rawData);
}
_addToMemoryCache(cacheKey, finalData);
_saveToDiskCache(cacheKey, finalData);
if (mounted && _currentUrl == url) {
setState(() {
_imageBytes = Uint8List.fromList(data);
_imageBytes = finalData;
_isLoading = false;
_hasError = false;
});
@@ -155,6 +198,52 @@ class _RecipeImageState extends State<RecipeImage> {
}
}
Future<Uint8List> _compressImage(Uint8List data, int maxPx) async {
try {
final codec = await ui.instantiateImageCodecFromBuffer(
await ui.ImmutableBuffer.fromUint8List(data),
);
final frame = await codec.getNextFrame();
final image = frame.image;
final srcW = image.width;
final srcH = image.height;
final srcPx = srcW * srcH;
if (srcPx <= maxPx) {
image.dispose();
codec.dispose();
return data;
}
final scale = (maxPx / srcPx);
final targetW = (srcW * scale).ceil();
final targetH = (srcH * scale).ceil();
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
canvas.drawImageRect(
image,
Rect.fromLTWH(0, 0, srcW.toDouble(), srcH.toDouble()),
Rect.fromLTWH(0, 0, targetW.toDouble(), targetH.toDouble()),
Paint()..filterQuality = FilterQuality.medium,
);
final picture = recorder.endRecording();
final resized = await picture.toImage(targetW, targetH);
final byteData = await resized.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
codec.dispose();
resized.dispose();
if (byteData == null) return data;
return byteData.buffer.asUint8List();
} catch (e) {
debugPrint('RecipeImage compress error: $e');
return data;
}
}
void _tryNextFallback() {
_fallbackIndex++;
if (mounted) {
@@ -162,33 +251,44 @@ class _RecipeImageState extends State<RecipeImage> {
}
}
Uint8List? _getFromMemoryCache(String url) {
final entry = _memoryCache[url];
Future<void> _loadOriginalImage() async {
if (_isShowingOriginal) return;
setState(() {
_isShowingOriginal = true;
_isLoading = true;
_fallbackIndex = 0;
_imageBytes = null;
});
_loadImage();
}
Uint8List? _getFromMemoryCache(String key) {
final entry = _memoryCache[key];
if (entry == null) return null;
if (DateTime.now().difference(entry.cachedAt).inHours > 24) {
_memoryCache.remove(url);
_memoryCache.remove(key);
return null;
}
return entry.data;
}
void _addToMemoryCache(String url, List<int> data) {
void _addToMemoryCache(String key, List<int> data) {
if (_memoryCache.length > 200) {
final oldest = _memoryCache.entries.reduce(
(a, b) => a.value.cachedAt.isBefore(b.value.cachedAt) ? a : b,
);
_memoryCache.remove(oldest.key);
}
_memoryCache[url] = _CacheEntry(Uint8List.fromList(data), DateTime.now());
_memoryCache[key] = _CacheEntry(Uint8List.fromList(data), DateTime.now());
}
Future<Uint8List?> _getFromDiskCache(String url) async {
Future<Uint8List?> _getFromDiskCache(String key) async {
try {
final dir = await getTemporaryDirectory();
final cacheDir = Directory('${dir.path}/recipe_images');
if (!cacheDir.existsSync()) return null;
final fileName = _urlToFileName(url);
final fileName = _urlToFileName(key);
final file = File('${cacheDir.path}/$fileName');
if (!file.existsSync()) return null;
@@ -204,21 +304,21 @@ class _RecipeImageState extends State<RecipeImage> {
}
}
Future<void> _saveToDiskCache(String url, List<int> data) async {
Future<void> _saveToDiskCache(String key, List<int> data) async {
try {
final dir = await getTemporaryDirectory();
final cacheDir = Directory('${dir.path}/recipe_images');
if (!cacheDir.existsSync()) {
cacheDir.createSync(recursive: true);
}
final fileName = _urlToFileName(url);
final fileName = _urlToFileName(key);
final file = File('${cacheDir.path}/$fileName');
file.writeAsBytesSync(data);
} catch (_) {}
}
String _urlToFileName(String url) {
var name = url.replaceAll(RegExp(r'[/:.]'), '_');
String _urlToFileName(String key) {
var name = key.replaceAll(RegExp(r'[/:.]'), '_');
if (name.length > 120) name = name.substring(name.length - 120);
return name;
}
@@ -230,18 +330,7 @@ class _RecipeImageState extends State<RecipeImage> {
Widget child;
if (_isLoading) {
child = Container(
width: widget.width,
height: widget.height,
color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
.withValues(alpha: 0.06),
child: Center(
child: CupertinoActivityIndicator(
radius: 12,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
),
);
child = _buildLoadingWidget(isDark);
} else if (_hasError || _imageBytes == null) {
child = _buildErrorWidget(isDark);
} else {
@@ -254,6 +343,53 @@ class _RecipeImageState extends State<RecipeImage> {
);
}
if (widget.tapToOriginal &&
widget.mode == RecipeImageMode.thumbnail &&
!_isShowingOriginal) {
child = GestureDetector(
onTap: _loadOriginalImage,
child: Stack(
fit: StackFit.passthrough,
children: [
child,
if (!_isLoading && !_hasError && _imageBytes != null)
Positioned(
right: 6,
bottom: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: CupertinoColors.black.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
CupertinoIcons.zoom_in,
size: 12,
color: CupertinoColors.white,
),
SizedBox(width: 2),
Text(
'原图',
style: TextStyle(
fontSize: 10,
color: CupertinoColors.white,
),
),
],
),
),
),
],
),
);
}
if (widget.borderRadius != null) {
child = ClipRRect(borderRadius: widget.borderRadius!, child: child);
}
@@ -261,6 +397,22 @@ class _RecipeImageState extends State<RecipeImage> {
return child;
}
Widget _buildLoadingWidget(bool isDark) {
return Container(
width: widget.width,
height: widget.height,
color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3).withValues(
alpha: 0.06,
),
child: Center(
child: CupertinoActivityIndicator(
radius: 12,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
),
);
}
Widget _buildErrorWidget(bool isDark) {
return Container(
width: widget.width,
@@ -324,4 +476,11 @@ class RecipeImageCache {
} catch (_) {}
return size;
}
static Future<String> getCacheSizeText() async {
final bytes = await getCacheSize();
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}

1
linux/.gitignore vendored
View File

@@ -1 +0,0 @@
flutter/ephemeral

View File

@@ -1,128 +0,0 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "mom_kitchen")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.mom_kitchen")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
cmake_policy(SET CMP0063 NEW)
# Load bundled libraries from the lib/ directory relative to the binary.
set(CMAKE_INSTALL_RPATH "$ORIGIN/lib")
# Root filesystem for cross-building.
if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT})
set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif()
# Define build configuration options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE)
set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS
"Debug" "Profile" "Release")
endif()
# Compilation settings that should be applied to most targets.
#
# Be cautious about adding new options here, as plugins use this function by
# default. In most cases, you should add new options to specific targets instead
# of modifying this function.
function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_features(${TARGET} PUBLIC cxx_std_14)
target_compile_options(${TARGET} PRIVATE -Wall -Werror)
target_compile_options(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:-O3>")
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR})
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
# Application build; see runner/CMakeLists.txt.
add_subdirectory("runner")
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)
# Only the install-generated bundle's copy of the executable will launch
# correctly, since the resources must in the right relative locations. To avoid
# people trying to run the unbundled copy, put it in a subdirectory instead of
# the default top-level location.
set_target_properties(${BINARY_NAME}
PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run"
)
# Generated plugin build rules, which manage building the plugins and adding
# them to the application.
include(flutter/generated_plugins.cmake)
# === Installation ===
# By default, "installing" just makes a relocatable bundle in the build
# directory.
set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle")
if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE)
endif()
# Start with a clean build bundle directory every time.
install(CODE "
file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\")
" COMPONENT Runtime)
set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data")
set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib")
install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}"
COMPONENT Runtime)
install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}"
COMPONENT Runtime)
install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES})
install(FILES "${bundled_library}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endforeach(bundled_library)
# Copy the native assets provided by the build.dart from all packages.
set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/")
install(DIRECTORY "${NATIVE_ASSETS_DIR}"
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
# Fully re-copy the assets directory on each build to avoid having stale files
# from a previous install.
set(FLUTTER_ASSET_DIR_NAME "flutter_assets")
install(CODE "
file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\")
" COMPONENT Runtime)
install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}"
DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime)
# Install the AOT library on non-Debug builds only.
if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
COMPONENT Runtime)
endif()

View File

@@ -1,88 +0,0 @@
# This file controls Flutter-level build steps. It should not be edited.
cmake_minimum_required(VERSION 3.10)
set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral")
# Configuration provided via flutter tool.
include(${EPHEMERAL_DIR}/generated_config.cmake)
# TODO: Move the rest of this into files in ephemeral. See
# https://github.com/flutter/flutter/issues/57146.
# Serves the same purpose as list(TRANSFORM ... PREPEND ...),
# which isn't available in 3.10.
function(list_prepend LIST_NAME PREFIX)
set(NEW_LIST "")
foreach(element ${${LIST_NAME}})
list(APPEND NEW_LIST "${PREFIX}${element}")
endforeach(element)
set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE)
endfunction()
# === Flutter Library ===
# System-level dependencies.
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0)
pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0)
set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so")
# Published to parent scope for install step.
set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE)
set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE)
set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE)
set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE)
list(APPEND FLUTTER_LIBRARY_HEADERS
"fl_basic_message_channel.h"
"fl_binary_codec.h"
"fl_binary_messenger.h"
"fl_dart_project.h"
"fl_engine.h"
"fl_json_message_codec.h"
"fl_json_method_codec.h"
"fl_message_codec.h"
"fl_method_call.h"
"fl_method_channel.h"
"fl_method_codec.h"
"fl_method_response.h"
"fl_plugin_registrar.h"
"fl_plugin_registry.h"
"fl_standard_message_codec.h"
"fl_standard_method_codec.h"
"fl_string_codec.h"
"fl_value.h"
"fl_view.h"
"flutter_linux.h"
)
list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/")
add_library(flutter INTERFACE)
target_include_directories(flutter INTERFACE
"${EPHEMERAL_DIR}"
)
target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}")
target_link_libraries(flutter INTERFACE
PkgConfig::GTK
PkgConfig::GLIB
PkgConfig::GIO
)
add_dependencies(flutter flutter_assemble)
# === Flutter tool backend ===
# _phony_ is a non-existent file to force this command to run every time,
# since currently there's no way to get a full input/output list from the
# flutter tool.
add_custom_command(
OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS}
${CMAKE_CURRENT_BINARY_DIR}/_phony_
COMMAND ${CMAKE_COMMAND} -E env
${FLUTTER_TOOL_ENVIRONMENT}
"${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh"
${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE}
VERBATIM
)
add_custom_target(flutter_assemble DEPENDS
"${FLUTTER_LIBRARY}"
${FLUTTER_LIBRARY_HEADERS}
)

View File

@@ -1,15 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
}

View File

@@ -1,15 +0,0 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View File

@@ -1,25 +0,0 @@
#
# Generated file, do not edit.
#
list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST
jni
)
set(PLUGIN_BUNDLED_LIBRARIES)
foreach(plugin ${FLUTTER_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin})
target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin)
list(APPEND PLUGIN_BUNDLED_LIBRARIES $<TARGET_FILE:${plugin}_plugin>)
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries})
endforeach(plugin)
foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST})
add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin})
list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries})
endforeach(ffi_plugin)

View File

@@ -1,26 +0,0 @@
cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX)
# Define the application target. To change its name, change BINARY_NAME in the
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
# work.
#
# Any new source files that you add to the application should be added here.
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
)
# Apply the standard set of build settings. This can be removed for applications
# that need different build settings.
apply_standard_settings(${BINARY_NAME})
# Add preprocessor definitions for the application ID.
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Add dependency libraries. Add any application-specific dependencies here.
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}")

View File

@@ -1,6 +0,0 @@
#include "my_application.h"
int main(int argc, char** argv) {
g_autoptr(MyApplication) app = my_application_new();
return g_application_run(G_APPLICATION(app), argc, argv);
}

View File

@@ -1,144 +0,0 @@
#include "my_application.h"
#include <flutter_linux/flutter_linux.h>
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#include "flutter/generated_plugin_registrant.h"
struct _MyApplication {
GtkApplication parent_instance;
char** dart_entrypoint_arguments;
};
G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION)
// Called when first Flutter frame received.
static void first_frame_cb(MyApplication* self, FlView *view)
{
gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view)));
}
// Implements GApplication::activate.
static void my_application_activate(GApplication* application) {
MyApplication* self = MY_APPLICATION(application);
GtkWindow* window =
GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application)));
// Use a header bar when running in GNOME as this is the common style used
// by applications and is the setup most users will be using (e.g. Ubuntu
// desktop).
// If running on X and not using GNOME then just use a traditional title bar
// in case the window manager does more exotic layout, e.g. tiling.
// If running on Wayland assume the header bar will work (may need changing
// if future cases occur).
gboolean use_header_bar = TRUE;
#ifdef GDK_WINDOWING_X11
GdkScreen* screen = gtk_window_get_screen(window);
if (GDK_IS_X11_SCREEN(screen)) {
const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen);
if (g_strcmp0(wm_name, "GNOME Shell") != 0) {
use_header_bar = FALSE;
}
}
#endif
if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "mom_kitchen");
gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else {
gtk_window_set_title(window, "mom_kitchen");
}
gtk_window_set_default_size(window, 1280, 720);
g_autoptr(FlDartProject) project = fl_dart_project_new();
fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments);
FlView* view = fl_view_new(project);
GdkRGBA background_color;
// Background defaults to black, override it here if necessary, e.g. #00000000 for transparent.
gdk_rgba_parse(&background_color, "#000000");
fl_view_set_background_color(view, &background_color);
gtk_widget_show(GTK_WIDGET(view));
gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view));
// Show the window when Flutter renders.
// Requires the view to be realized so we can start rendering.
g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self);
gtk_widget_realize(GTK_WIDGET(view));
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
gtk_widget_grab_focus(GTK_WIDGET(view));
}
// Implements GApplication::local_command_line.
static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) {
MyApplication* self = MY_APPLICATION(application);
// Strip out the first argument as it is the binary name.
self->dart_entrypoint_arguments = g_strdupv(*arguments + 1);
g_autoptr(GError) error = nullptr;
if (!g_application_register(application, nullptr, &error)) {
g_warning("Failed to register: %s", error->message);
*exit_status = 1;
return TRUE;
}
g_application_activate(application);
*exit_status = 0;
return TRUE;
}
// Implements GApplication::startup.
static void my_application_startup(GApplication* application) {
//MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application startup.
G_APPLICATION_CLASS(my_application_parent_class)->startup(application);
}
// Implements GApplication::shutdown.
static void my_application_shutdown(GApplication* application) {
//MyApplication* self = MY_APPLICATION(object);
// Perform any actions required at application shutdown.
G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application);
}
// Implements GObject::dispose.
static void my_application_dispose(GObject* object) {
MyApplication* self = MY_APPLICATION(object);
g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev);
G_OBJECT_CLASS(my_application_parent_class)->dispose(object);
}
static void my_application_class_init(MyApplicationClass* klass) {
G_APPLICATION_CLASS(klass)->activate = my_application_activate;
G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line;
G_APPLICATION_CLASS(klass)->startup = my_application_startup;
G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown;
G_OBJECT_CLASS(klass)->dispose = my_application_dispose;
}
static void my_application_init(MyApplication* self) {}
MyApplication* my_application_new() {
// Set the program name to the application ID, which helps various systems
// like GTK and desktop environments map this running application to its
// corresponding .desktop file. This ensures better integration by allowing
// the application to be recognized beyond its binary name.
g_set_prgname(APPLICATION_ID);
return MY_APPLICATION(g_object_new(my_application_get_type(),
"application-id", APPLICATION_ID,
"flags", G_APPLICATION_NON_UNIQUE,
nullptr));
}

View File

@@ -1,18 +0,0 @@
#ifndef FLUTTER_MY_APPLICATION_H_
#define FLUTTER_MY_APPLICATION_H_
#include <gtk/gtk.h>
G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION,
GtkApplication)
/**
* my_application_new:
*
* Creates a new Flutter-based application.
*
* Returns: a new #MyApplication.
*/
MyApplication* my_application_new();
#endif // FLUTTER_MY_APPLICATION_H_

View File

@@ -1,406 +0,0 @@
# 营养中心性能优化报告
## 📊 接口验证结果
### 测试时间
**2026-04-10** - 使用实际 API 接口测试
### 测试项目
1.**营养报告接口连通性测试** - 通过
2.**热门排行数据验证** - 通过
3.**性能基准测试** - 平均 1393ms
### 测试脚本
```bash
dart scripts/verify_nutrition_api.dart
```
### 测试结果摘要
| 测试项 | 状态 | 响应时间 | 说明 |
|--------|------|----------|------|
| 总排行接口 | ✅ 通过 | 1363ms | 获取 10 条数据 |
| 月排行接口 | ✅ 通过 | 1391ms | 获取 5 条数据 |
| 今日排行 | ⚠️ 部分通过 | 1373ms | 数据结构不匹配 |
| 性能评级 | 🟡 一般 | 平均 1393ms | 100% 成功率 |
---
## 🔍 API 接口文档
**基础地址**: `http://eat.wktyl.com/api/`
### 核心接口
| 接口文件 | 功能 | 使用场景 |
|---------|------|---------|
| `api.php` | 主接口 | 菜谱列表、详情、搜索 |
| `stats_full.php` | 全面统计 | 热门排行、在线统计 |
| `api_what_to_eat.php` | 智能选择 | 今天吃什么、随机推荐 |
| `api_feed.php` | 信息流 | 推荐、热门、个性化 |
| `api_action.php` | 动态交互 | 点赞、推荐、浏览量 |
### 热门排行接口详解
```
GET stats_full.php?act=hot&period=total&limit=10
```
**返回数据结构**:
```json
{
"code": 200,
"message": "success",
"data": {
"today": { ... },
"month": { ... },
"total": {
"recipe_view": [...],
"recipe_like": [...],
"ingredient_view": [...]
}
}
}
```
---
## 📈 实际性能测试结果
### 基准测试5 次请求)
| 指标 | 数值 | 评级 |
|------|------|------|
| 平均响应时间 | 1393ms | 🟡 一般 |
| 最快响应 | 1353ms | 良好 |
| 最慢响应 | 1522ms | 一般 |
| 成功率 | 100% | ✅ 优秀 |
### 性能分析
**优势**:
- ✅ 接口稳定性高100% 成功率)
- ✅ 响应时间波动小(标准差 < 100ms
- ✅ 数据格式规范
**待优化**:
- 🟡 响应时间 > 1000ms建议优化到 500ms 以内)
- 🟡 无缓存机制(重复请求相同数据)
- 🟡 无压缩传输(数据量较大)
### 优化建议
#### 1. 实施缓存策略(优先级 P0
```dart
// 建议缓存时间
- 热门排行5 分钟
- 营养数据1 小时
- 菜谱详情30 分钟
```
#### 2. 启用 Gzip 压缩(优先级 P1
```
添加参数_format=gzip
预计节省75%+ 流量
```
#### 3. 预加载策略(优先级 P2
```dart
// 在应用启动时预加载
- 热门排行数据
- 分类列表
- 标签数据
```
---
## 🔍 发现的问题
### 1. 控制器初始化问题
**问题描述:**
- `MealRecordController` 未正确初始化导致页面卡死
- 缺少错误处理机制
**解决方案:**
```dart
// ❌ 错误写法
late final MealRecordController _ctrl;
@override
void initState() {
super.initState();
_ctrl = Get.find<MealRecordController>(); // 可能抛出异常
}
// ✅ 正确写法
MealRecordController? _ctrl;
String? _error;
@override
void initState() {
super.initState();
try {
_ctrl = Get.find<MealRecordController>();
} catch (e) {
debugPrint('MealRecordController not found: $e');
_error = '控制器初始化失败';
_ctrl = null;
}
}
```
### 2. 空指针保护缺失
**问题描述:**
- 访问控制器数据时未检查 null
- 导航时未捕获异常
**解决方案:**
```dart
// 添加 null 检查
if (_error != null || _ctrl == null) {
return CupertinoPageScaffold(
// 错误提示页面
);
}
// 导航时添加错误处理
onTap: () {
try {
Get.toNamed(AppRoutes.nutritionReport);
} catch (e) {
debugPrint('Navigate error: $e');
ToastService.show(message: '打开报告失败:$e 🔄');
}
}
```
### 3. API 响应时间优化
**当前问题:**
- 无超时保护
- 无缓存机制
- 重复请求
**优化建议:**
#### a) 添加超时保护
```dart
final results = await _repository.fetchData()
.timeout(
const Duration(seconds: 12),
onTimeout: () {
debugPrint('API timeout');
return [];
},
);
```
#### b) 实现缓存策略
```dart
// 使用 Hive 缓存营养数据
class MealRecordRepository {
Future<List<MealRecordModel>> fetchRecords(String date) async {
// 1. 检查缓存
final cached = await _cacheService.get('nutrition_$date');
if (cached != null) {
return cached;
}
// 2. 从 API 获取
final data = await _api.get('/nutrition/records?date=$date');
// 3. 保存缓存
await _cacheService.set('nutrition_$date', data);
return data;
}
}
```
#### c) 防抖处理
```dart
// 防止频繁请求
Timer? _debounceTimer;
void onDateChanged(String date) {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
_ctrl.selectDate(date);
});
}
```
---
## 📈 性能指标
### 目标性能标准
| 指标 | 优秀 | 良好 | 一般 | 较差 |
|------|------|------|------|------|
| 冷启动时间 | < 2s | 2-3s | 3-5s | > 5s |
| 页面切换 | < 200ms | 200-400ms | 400-800ms | > 800ms |
| API 响应 | < 500ms | 500-1000ms | 1000-2000ms | > 2000ms |
| 列表滚动 FPS | 60fps | 50-60fps | 30-50fps | < 30fps |
### 优化建议
#### 1. 减少 initState 中的同步操作
```dart
// ❌ 避免在 initState 中执行耗时操作
@override
void initState() {
super.initState();
_loadData(); // 同步加载大量数据
}
// ✅ 使用异步加载
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadDataAsync();
});
}
```
#### 2. 优化 Obx 使用
```dart
// ❌ 避免在大范围 rebuild
Obx(() => ListView(
children: controller.items.map((item) => ComplexWidget(item)).toList(),
))
// ✅ 使用独立 Observer
items.map((item) => Obx(() => ComplexWidget(item))).toList()
```
#### 3. 图片懒加载
```dart
// 使用 CachedNetworkImage
CachedNetworkImage(
imageUrl: recipe.imageUrl,
placeholder: (context, url) => SkeletonLoader(),
errorWidget: (context, url, error) => Icon(Icons.error),
)
```
---
## 🛠️ 已实施的修复
### 文件修改清单
1. **nutrition_report_page.dart**
- ✅ 添加控制器初始化错误处理
- ✅ 添加 null 检查
- ✅ 添加错误提示页面
2. **nutrition_center_page.dart**
- ✅ 添加控制器初始化错误处理
- ✅ 导航时添加 try-catch
- ✅ 添加空状态处理
3. **hot_repository.dart**
- ✅ 添加调试日志
- ✅ 添加详细的错误信息
- ✅ 优化数据结构兼容性
---
## 📝 测试清单
### 功能测试
- [ ] 打开营养中心页面
- [ ] 点击报告按钮
- [ ] 切换周/月视图
- [ ] 添加饮食记录
- [ ] 删除饮食记录
- [ ] 日期选择器
- [ ] 今天按钮跳转
### 性能测试
```bash
# 运行接口验证脚本
dart scripts/verify_nutrition_api.dart
# 检查响应时间
# - 平均 < 1000ms ✓
# - 成功率 > 95% ✓
```
### 边界测试
- [ ] 无网络状态
- [ ] 控制器未初始化
- [ ] 空数据状态
- [ ] 异常数据处理
---
## 🎯 下一步优化计划
### 短期P0
1. ~~修复控制器初始化问题~~ ✅
2. ~~添加错误处理~~ ✅
3. 添加加载状态指示器
4. 优化内存使用
### 中期P1
1. 实现数据缓存
2. 添加离线模式
3. 优化图表渲染性能
4. 减少不必要的 rebuild
### 长期P2
1. 实现预加载策略
2. 添加数据预取
3. 优化动画性能
4. 实现增量更新
---
## 📞 调试工具
### 日志查看
```dart
// 在控制器中添加调试日志
debugPrint('MealRecordController: loading data for $date');
debugPrint('MealRecordController: got ${records.length} records');
```
### 性能监控
```dart
// 使用 PerformanceOverlay
import 'package:flutter/scheduler.dart';
SchedulerBinding.instance.addPostFrameCallback((Duration duration) {
debugPrint('Frame time: ${duration.inMilliseconds}ms');
});
```
### 内存分析
```bash
# Flutter 性能工具
flutter pub global activate devtools
flutter pub global run devtools
```
---
## ✅ 验收标准
- [x] 营养中心页面正常打开
- [x] 报告按钮正常响应
- [x] 无卡死闪退现象
- [x] 错误提示友好
- [x] 接口响应时间 < 2s
- [x] 数据展示正确
- [ ] 缓存机制实现(待开发)
- [ ] 离线模式支持(待开发)
---
*最后更新2026-04-10*
*测试环境Dart 3.0+*

View File

@@ -1,32 +0,0 @@
// 2026-04-11 | verify_categories_detail.dart | 分类数据详细验证 | 检查子分类parent_id字段
import 'dart:convert';
import 'dart:io';
const String baseUrl = 'http://eat.wktyl.com/api';
void main() async {
final uri = Uri.parse('$baseUrl/api.php').replace(queryParameters: {'act': 'categories'});
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 12);
final request = await client.getUrl(uri);
final response = await request.close();
final body = await response.transform(utf8.decoder).join();
client.close();
final json = jsonDecode(body) as Map<String, dynamic>;
final data = json['data'] as List;
for (final topCat in data) {
final m = topCat as Map<String, dynamic>;
print('=== Top: id=${m['id']}, name=${m['name']} ===');
final children = m['children'] as List?;
if (children != null && children.isNotEmpty) {
print(' children count: ${children.length}');
for (final child in children.take(5)) {
final cm = child as Map<String, dynamic>;
print(' child keys: ${cm.keys.join(', ')}');
print(' child: id=${cm['id'] ?? cm['cate_id']}, name=${cm['name'] ?? cm['cate_name']}, parent_id=${cm['parent_id']}');
}
}
}
}

View File

@@ -1,27 +0,0 @@
// 2026-04-11 | verify_eating_times.dart | 用餐时段数据验证 | 获取eating_times.json数据结构
import 'dart:convert';
import 'dart:io';
void main() async {
final uri = Uri.parse('http://eat.wktyl.com/api/assets/eating_times.json');
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 12);
final request = await client.getUrl(uri);
final response = await request.close();
final body = await response.transform(utf8.decoder).join();
client.close();
final json = jsonDecode(body);
if (json is List) {
print('Total items: ${json.length}');
for (final item in json.take(5)) {
final m = item as Map<String, dynamic>;
print('keys: ${m.keys.join(', ')}');
print('item: ${jsonEncode(m)}');
print('');
}
} else if (json is Map) {
print('Top-level keys: ${json.keys.join(', ')}');
print(jsonEncode(json).substring(0, 500));
}
}

View File

@@ -1,54 +0,0 @@
// 2026-04-11 | verify_filter_apply.dart | filter_apply接口验证 | 测试不同分类ID的筛选
import 'dart:convert';
import 'dart:io';
const String baseUrl = 'http://eat.wktyl.com/api';
void main() async {
await testFilterApply(category: '12', label: '中国菜(id=12)');
await testFilterApply(category: '13', label: '粤菜(id=13)');
await testFilterApply(category: '11', label: '菜谱(id=11)');
await testFilterApply(tag: '74', label: '粉蒸(tag=74)');
await testFilterApply(category: '12', tag: '74', label: '中国菜+粉蒸');
}
Future<void> testFilterApply({String? category, String? tag, required String label}) async {
print('$label');
final params = <String, String>{'act': 'filter_apply', 'count': '3'};
if (category != null) params['category'] = category;
if (tag != null) params['tag'] = tag;
final uri = Uri.parse('$baseUrl/api_what_to_eat.php').replace(queryParameters: params);
try {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 12);
final request = await client.getUrl(uri);
final response = await request.close();
final body = await response.transform(utf8.decoder).join();
client.close();
final json = jsonDecode(body) as Map<String, dynamic>;
final code = json['code'];
final data = json['data'];
if (code == 200 && data != null) {
if (data is Map) {
final recipes = data['recipes'] as List?;
print(' ✅ code=$code, recipes=${recipes?.length ?? 0}');
if (recipes != null && recipes.isNotEmpty) {
for (final r in recipes.take(2)) {
final m = r as Map<String, dynamic>;
print(' - id=${m['id']}, title=${m['title']}');
}
}
} else if (data is List) {
print(' ✅ code=$code, data is List, count=${data.length}');
}
} else {
print(' ❌ code=$code, message=${json['message']}');
}
} catch (e) {
print(' ❌ error: $e');
}
print('');
}

View File

@@ -1,298 +0,0 @@
/**
* 文件verify_nutrition_api.dart
* 名称:营养中心接口验证脚本
* 作用:验证营养中心相关接口的连通性和性能
* 使用dart scripts/verify_nutrition_api.dart
* 更新2026-04-10 创建,用于测试 API 响应和数据格式
*/
import 'dart:async';
import 'dart:convert';
import 'dart:io';
// 配置
const String baseUrl = 'http://eat.wktyl.com/api';
const String statsFullEndpoint = '/stats_full.php';
const int timeoutSeconds = 12;
// 颜色常量
const String reset = '\x1B[0m';
const String green = '\x1B[32m';
const String red = '\x1B[31m';
const String yellow = '\x1B[33m';
const String blue = '\x1B[34m';
const String cyan = '\x1B[36m';
void main() async {
printHeader();
// 测试 1验证营养报告接口总览
await testNutritionOverview();
// 测试 2验证热门排行接口
await testHotRanking();
// 测试 3性能基准测试
await performanceBenchmark();
printFooter();
}
void printHeader() {
print(
'\n${cyan}╔════════════════════════════════════════════════════════╗${reset}',
);
print(
'${cyan}${reset} ${green}🔬 营养中心接口验证脚本${reset} ${cyan}${reset}',
);
print(
'${cyan}${reset} ${yellow}验证 API 连通性、数据格式、响应时间${reset} ${cyan}${reset}',
);
print(
'${cyan}╚════════════════════════════════════════════════════════╝${reset}\n',
);
}
void printFooter() {
print(
'\n${cyan}═══════════════════════════════════════════════════════${reset}',
);
print('${green}✅ 验证完成${reset}');
print(
'${cyan}═══════════════════════════════════════════════════════${reset}\n',
);
}
Future<void> testNutritionOverview() async {
printSection('测试 1营养报告接口 (stats_full.php)');
final url = Uri.parse(
'$baseUrl$statsFullEndpoint?act=hot&period=total&limit=10',
);
print('${blue}请求 URL:${reset} $url');
try {
final stopwatch = Stopwatch()..start();
final response = await HttpClient()
.getUrl(url)
.timeout(Duration(seconds: timeoutSeconds));
final data = await response.close();
final body = await utf8.decoder.bind(data).join();
stopwatch.stop();
print('${green}✓ 响应状态码:${reset} ${data.statusCode}');
print('${green}✓ 响应时间:${reset} ${stopwatch.elapsedMilliseconds}ms');
// 解析 JSON
final jsonData = jsonDecode(body) as Map<String, dynamic>;
print('${blue}JSON 结构分析:${reset}');
print(' - code: ${jsonData['code']}');
print(' - message: ${jsonData['message']}');
if (jsonData['data'] != null) {
final dataMap = jsonData['data'] as Map<String, dynamic>;
print(' - data keys: ${dataMap.keys.toList()}');
// 检查热门排行数据结构
if (dataMap.containsKey('total')) {
final total = dataMap['total'] as Map<String, dynamic>;
print(' - total 字段存在 ✓');
if (total.containsKey('recipe_view')) {
final recipes = total['recipe_view'] as List;
print(' - recipe_view: ${recipes.length} 条记录');
if (recipes.isNotEmpty) {
final first = recipes.first as Map<String, dynamic>;
print(' 示例数据:');
print(' id: ${first['id']}');
print(' name: ${first['name']}');
print(' value: ${first['value']}');
}
}
if (total.containsKey('recipe_like')) {
final likes = total['recipe_like'] as List;
print(' - recipe_like: ${likes.length} 条记录');
}
if (total.containsKey('ingredient_view')) {
final ingredients = total['ingredient_view'] as List;
print(' - ingredient_view: ${ingredients.length} 条记录');
}
} else {
print('${yellow}⚠ 警告total 字段不存在${reset}');
}
} else {
print('${red}✗ data 字段为空${reset}');
}
print('${green}✓ 接口连通性测试通过${reset}\n');
} on TimeoutException catch (e) {
print('${red}✗ 请求超时:${e.message}${reset}');
print('${yellow}建议:检查网络连接或 API 服务器状态${reset}\n');
} catch (e) {
print('${red}✗ 请求失败:$e${reset}');
print('${yellow}建议:检查 URL 是否正确${reset}\n');
}
}
Future<void> testHotRanking() async {
printSection('测试 2热门排行数据验证');
final testCases = [
{'period': 'total', 'name': '总排行'},
{'period': 'month', 'name': '月排行'},
{'period': 'today', 'name': '今日排行'},
];
for (final testCase in testCases) {
final period = testCase['period']!;
final name = testCase['name']!;
print('${blue}测试 $name ($period):${reset}');
final url = Uri.parse(
'$baseUrl$statsFullEndpoint?act=hot&period=$period&limit=5',
);
try {
final stopwatch = Stopwatch()..start();
final response = await HttpClient()
.getUrl(url)
.timeout(Duration(seconds: timeoutSeconds));
final data = await response.close();
final body = await utf8.decoder.bind(data).join();
stopwatch.stop();
final jsonData = jsonDecode(body) as Map<String, dynamic>;
if (jsonData['code'] == 200) {
print(
' ${green}✓ 状态码 200${reset} - ${stopwatch.elapsedMilliseconds}ms',
);
final dataMap = jsonData['data'] as Map<String, dynamic>?;
if (dataMap != null) {
// 检查不同可能的数据结构
int recipeCount = 0;
if (dataMap.containsKey(period) && dataMap[period] is Map) {
final periodData = dataMap[period] as Map<String, dynamic>;
if (periodData.containsKey('recipe_view')) {
recipeCount = (periodData['recipe_view'] as List).length;
}
} else if (dataMap.containsKey('recipe_view')) {
recipeCount = (dataMap['recipe_view'] as List).length;
} else {
// 检查嵌套结构
for (final key in ['total', 'month', 'today']) {
if (dataMap.containsKey(key) && dataMap[key] is Map) {
final sub = dataMap[key] as Map<String, dynamic>;
if (sub.containsKey('recipe_view')) {
recipeCount = (sub['recipe_view'] as List).length;
break;
}
}
}
}
if (recipeCount > 0) {
print(' ${green}✓ 获取到 $recipeCount 条菜谱数据${reset}');
} else {
print(' ${yellow}⚠ 未获取到有效数据${reset}');
}
}
} else {
print(
' ${red}✗ 状态码 ${jsonData['code']}: ${jsonData['message']}${reset}',
);
}
} catch (e) {
print(' ${red}✗ 请求失败:$e${reset}');
}
}
print('');
}
Future<void> performanceBenchmark() async {
printSection('测试 3性能基准测试');
final iterations = 5;
final results = <int>[];
print('${blue}执行 $iterations 次连续请求测试...${reset}\n');
for (int i = 0; i < iterations; i++) {
final url = Uri.parse(
'$baseUrl$statsFullEndpoint?act=hot&period=total&limit=5',
);
try {
final stopwatch = Stopwatch()..start();
final response = await HttpClient()
.getUrl(url)
.timeout(Duration(seconds: timeoutSeconds));
await response.close();
stopwatch.stop();
results.add(stopwatch.elapsedMilliseconds);
print(' 请求 #${i + 1}: ${stopwatch.elapsedMilliseconds}ms');
} catch (e) {
print(' 请求 #${i + 1}: ${red}失败 ($e)${reset}');
results.add(-1);
}
}
// 计算统计信息
final validResults = results.where((r) => r > 0).toList();
if (validResults.isNotEmpty) {
final avg = validResults.reduce((a, b) => a + b) / validResults.length;
final min = validResults.reduce((a, b) => a < b ? a : b);
final max = validResults.reduce((a, b) => a > b ? a : b);
print('\n${blue}性能统计:${reset}');
print(' - 平均响应时间:${avg.toStringAsFixed(0)}ms');
print(' - 最快响应时间:${min}ms');
print(' - 最慢响应时间:${max}ms');
print(
' - 成功率:${validResults.length}/${iterations} (${(validResults.length / iterations * 100).toStringAsFixed(0)}%)',
);
// 性能评级
String rating;
if (avg < 500) {
rating = '${green}优秀 🌟${reset}';
} else if (avg < 1000) {
rating = '${green}良好 ✓${reset}';
} else if (avg < 2000) {
rating = '${yellow}一般 ⚠${reset}';
} else {
rating = '${red}较差 ✗${reset}';
}
print(' - 性能评级:$rating');
} else {
print('\n${red}所有请求均失败,无法计算性能统计${reset}');
}
print('');
}
void printSection(String title) {
print('\n${cyan}───────────────────────────────────────────────────${reset}');
print('${green}$title${reset}');
print('${cyan}───────────────────────────────────────────────────${reset}\n');
}

View File

@@ -1,40 +0,0 @@
// 2026-04-11 | verify_recipe_images.dart | 菜谱图片URL验证 | 测试fallback链
import 'dart:io';
void main() async {
final client = HttpClient();
client.connectionTimeout = const Duration(seconds: 8);
final testIds = [1, 150, 1585];
final base = 'http://eat.wktyl.com/api/assets';
for (final id in testIds) {
print('\n--- Testing id=$id ---');
final urls = [
'$base/pic/${id}a.jpg',
'$base/pic/${id}b.jpg',
'$base/pic/$id.jpg',
];
for (final url in urls) {
try {
final req = await client.headUrl(Uri.parse(url));
final resp = await req.close();
print(' ${resp.statusCode == 200 ? "" : ""} $url${resp.statusCode}');
} catch (e) {
print('$url → error: $e');
}
}
}
// Test back.png
try {
final req = await client.headUrl(Uri.parse('$base/back.png'));
final resp = await req.close();
print('\n${resp.statusCode == 200 ? "" : ""} $base/back.png → ${resp.statusCode}');
} catch (e) {
print('\n❌ back.png → error: $e');
}
client.close();
}

View File

@@ -1,185 +0,0 @@
// 2026-04-11 | verify_what_to_eat_api.dart | 今天吃什么接口验证脚本 | 验证filter_apply/categories/tags接口连通性和数据格式
import 'dart:async';
import 'dart:convert';
import 'dart:io';
const String baseUrl = 'http://eat.wktyl.com/api';
const int timeoutSeconds = 12;
const String reset = '\x1B[0m';
const String green = '\x1B[32m';
const String red = '\x1B[31m';
const String yellow = '\x1B[33m';
const String blue = '\x1B[34m';
const String cyan = '\x1B[36m';
void main() async {
printHeader();
await testFilterApply();
await testCategories();
await testTags();
await testFilterSteps();
await testFilterApplyWithCategory();
printSummary();
}
void printHeader() {
print('$cyan═══════════════════════════════════════════════════$reset');
print('$cyan 🎲 今天吃什么 API 接口验证$reset');
print('$cyan═══════════════════════════════════════════════════$reset');
print('');
}
Future<Map<String, dynamic>?> apiGet(String endpoint, Map<String, String> params) async {
final uri = Uri.parse('$baseUrl$endpoint').replace(queryParameters: params);
final stopwatch = Stopwatch()..start();
try {
final client = HttpClient();
client.connectionTimeout = Duration(seconds: timeoutSeconds);
final request = await client.getUrl(uri);
final response = await request.close();
final body = await response.transform(utf8.decoder).join();
stopwatch.stop();
client.close();
if (response.statusCode != 200) {
print('$red ❌ HTTP ${response.statusCode} (${stopwatch.elapsedMilliseconds}ms)$reset');
return null;
}
final json = jsonDecode(body) as Map<String, dynamic>;
print('$green${stopwatch.elapsedMilliseconds}ms | code=${json['code']}$reset');
return json;
} on TimeoutException {
stopwatch.stop();
print('$red ❌ 超时 (${stopwatch.elapsedMilliseconds}ms)$reset');
return null;
} catch (e) {
stopwatch.stop();
print('$red ❌ 错误: $e (${stopwatch.elapsedMilliseconds}ms)$reset');
return null;
}
}
Future<void> testFilterApply() async {
print('$yellow▶ 测试 1: filter_apply (无筛选随机推荐)$reset');
final result = await apiGet('/api_what_to_eat.php', {'act': 'filter_apply', 'count': '5'});
if (result != null) {
final data = result['data'];
if (data is Map) {
final recipes = data['recipes'] as List?;
print(' recipes count: ${recipes?.length ?? 0}');
if (recipes != null && recipes.isNotEmpty) {
final first = recipes.first as Map<String, dynamic>;
print(' first recipe: id=${first['id']}, title=${first['title']}');
print(' fields: ${first.keys.take(15).join(', ')}...');
}
print(' total_matched: ${data['total_matched']}');
print(' filters_applied: ${data['filters_applied']}');
} else if (data is List) {
print(' data is List, count: ${data.length}');
if (data.isNotEmpty) {
final first = data.first as Map<String, dynamic>;
print(' first: id=${first['id']}, title=${first['title']}');
}
}
}
print('');
}
Future<void> testCategories() async {
print('$yellow▶ 测试 2: categories (分类列表)$reset');
final result = await apiGet('/api.php', {'act': 'categories'});
if (result != null) {
final data = result['data'];
if (data is List) {
print(' categories count: ${data.length}');
for (final cat in data.take(5)) {
final m = cat as Map<String, dynamic>;
print(' - id=${m['id'] ?? m['cate_id']}, name=${m['name'] ?? m['cate_name']}, parent_id=${m['parent_id']}');
final children = m['children'] as List?;
if (children != null && children.isNotEmpty) {
print(' children: ${children.length}');
for (final child in children.take(3)) {
final cm = child as Map<String, dynamic>;
print(' - id=${cm['id'] ?? cm['cate_id']}, name=${cm['name'] ?? cm['cate_name']}');
}
}
}
} else {
print(' data type: ${data.runtimeType}');
}
}
print('');
}
Future<void> testTags() async {
print('$yellow▶ 测试 3: tags (标签列表)$reset');
final result = await apiGet('/api.php', {'act': 'tags'});
if (result != null) {
final data = result['data'];
if (data is List) {
print(' tags count: ${data.length}');
for (final tag in data.take(5)) {
final m = tag as Map<String, dynamic>;
print(' - id=${m['id'] ?? m['tag_id']}, name=${m['name'] ?? m['tag_name']}');
}
} else {
print(' data type: ${data.runtimeType}');
}
}
print('');
}
Future<void> testFilterSteps() async {
print('$yellow▶ 测试 4: filter_steps (筛选步骤)$reset');
final result = await apiGet('/api_what_to_eat.php', {'act': 'filter_steps'});
if (result != null) {
final data = result['data'];
if (data is Map) {
print(' keys: ${data.keys.join(', ')}');
final steps = data['steps'] as List?;
if (steps != null) {
print(' steps count: ${steps.length}');
for (final step in steps.take(3)) {
final m = step as Map<String, dynamic>;
print(' - step: ${m['step']}, title: ${m['title']}, type: ${m['type']}');
final options = m['options'] as List? ?? m['available_options'] as List? ?? [];
print(' options: ${options.length}');
}
}
final available = data['available_options'] as List?;
if (available != null) {
print(' available_options count: ${available.length}');
}
}
}
print('');
}
Future<void> testFilterApplyWithCategory() async {
print('$yellow▶ 测试 5: filter_apply (带分类筛选)$reset');
final result = await apiGet('/api_what_to_eat.php', {'act': 'filter_apply', 'category': '1', 'count': '3'});
if (result != null) {
final data = result['data'];
if (data is Map) {
final recipes = data['recipes'] as List?;
print(' recipes count: ${recipes?.length ?? 0}');
if (recipes != null && recipes.isNotEmpty) {
for (final r in recipes.take(3)) {
final m = r as Map<String, dynamic>;
print(' - id=${m['id']}, title=${m['title']}');
}
}
} else if (data is List) {
print(' data is List, count: ${data.length}');
}
}
print('');
}
void printSummary() {
print('$cyan═══════════════════════════════════════════════════$reset');
print('$cyan 验证完成$reset');
print('$cyan═══════════════════════════════════════════════════$reset');
}