diff --git a/.trae/rules/design-ui.md b/.trae/rules/design-ui.md index 6501ba8..fb9edb1 100644 --- a/.trae/rules/design-ui.md +++ b/.trae/rules/design-ui.md @@ -7,10 +7,6 @@ - 阴影 - 按钮样式 -【修改规则】 -如果需要改布局: -必须重构整体 layout, -不能只修改一个组件。 @@ -33,16 +29,7 @@ 小屏幕:单列布局 中屏幕:双列布局 大屏幕:多列布局 -🪟 五、桌面专用页面模板 -如果某些页面 不需要响应式: - -该页面为 Desktop-only。 - -设计基准:1280px。 - -不需要 mobile 适配。 -不需要 media query。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 56de798..791a008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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修复+功能增强 diff --git a/README.md b/README.md index 74e0168..8d1c813 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# mom_kitchen - 妈妈厨房 +# mom_kitchen - 老妈厨房 一款基于 Flutter 的 iOS26 风格美食应用,支持多平台(Android/iOS/HarmonyOS/Web) diff --git a/docs/api/api.php b/docs/api/api.php index b885b4f..b3e38f7 100644 --- a/docs/api/api.php +++ b/docs/api/api.php @@ -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'], diff --git a/docs/api/api_check_duplicate.php b/docs/api/api_check_duplicate.php new file mode 100644 index 0000000..9082f92 --- /dev/null +++ b/docs/api/api_check_duplicate.php @@ -0,0 +1,306 @@ +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) + ) + ); +} diff --git a/docs/api/api_feed.php b/docs/api/api_feed.php index b6aaaf0..e2809da 100644 --- a/docs/api/api_feed.php +++ b/docs/api/api_feed.php @@ -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( diff --git a/docs/api/api_what_to_eat.php b/docs/api/api_what_to_eat.php index e852bef..e24c79a 100644 --- a/docs/api/api_what_to_eat.php +++ b/docs/api/api_what_to_eat.php @@ -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), diff --git a/docs/api/doc/API_DOC.md b/docs/api/doc/API_DOC.md index e0ba652..6c5684d 100644 --- a/docs/api/doc/API_DOC.md +++ b/docs/api/doc/API_DOC.md @@ -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%匹配时提前终止 + +**应用场景**: +- 📝 用户投稿前查重提醒 +- 🔒 防止重复内容录入 +- 📊 内容质量把控 +- 🛡️ 版权保护 + +--- + ## 功能扩展指南 本章节详细介绍每个接口返回字段的具体用途、应用场景和可实现的功能。 diff --git a/docs/api/doc/APP_GUIDE.md b/docs/api/doc/APP_GUIDE.md index d9401cd..6986d3a 100644 --- a/docs/api/doc/APP_GUIDE.md +++ b/docs/api/doc/APP_GUIDE.md @@ -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 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> batchCheckDuplicate({ + String? title, + String? ingredientName, + String? content, +}) async { + final results = {}; + + 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=` | diff --git a/docs/api/doc/act=full&id=32891 b/docs/api/doc/act=full&id=32891 new file mode 100644 index 0000000..b2b672f --- /dev/null +++ b/docs/api/doc/act=full&id=32891 @@ -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. 味精在常温下不易溶解,在 70~90度时溶解最好,鲜味最足,超过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" +} \ No newline at end of file diff --git a/docs/design/IOS26_UI_DESIGN.md b/docs/design/IOS26_UI_DESIGN.md index 07e6bdc..f5fb81d 100644 --- a/docs/design/IOS26_UI_DESIGN.md +++ b/docs/design/IOS26_UI_DESIGN.md @@ -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 导航栏 ├─────────────────────────────────────┤ │ │ │ ┌─────────────────────────────┐ │ diff --git a/lib/src/config/app_routes.dart b/lib/src/config/app_routes.dart index 3278894..3957af5 100644 --- a/lib/src/config/app_routes.dart +++ b/lib/src/config/app_routes.dart @@ -148,7 +148,7 @@ class AppRoutes { ), GetPage( name: recipeDetail, - page: () => RecipeDetailPage(recipeId: Get.arguments), + page: () => RecipeDetailPage(recipeId: '${Get.arguments ?? '1'}'), middlewares: [PageStandardsMiddleware()], ), GetPage( diff --git a/lib/src/models/recipe/recipe_model.dart b/lib/src/models/recipe/recipe_model.dart index 7e52e27..4228a99 100644 --- a/lib/src/models/recipe/recipe_model.dart +++ b/lib/src/models/recipe/recipe_model.dart @@ -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 categoryHierarchy; + final RecipeAuthor? author; final List tags; final List 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 _parseCategoryHierarchy(dynamic json) { + try { + if (json is! Map) return []; + final hierarchy = json['hierarchy']; + if (hierarchy is! List) return []; + return hierarchy + .whereType>() + .map((e) { + try { + return CategoryHierarchyItem.fromJson(e); + } catch (_) { + return null; + } + }) + .whereType() + .toList(); + } catch (_) { + return []; + } + } + + static RecipeAuthor? _parseAuthor(dynamic json) { + if (json is! Map) return null; + return RecipeAuthor.fromJson(json); + } } class TagItem { @@ -277,27 +310,48 @@ class IngredientItem { } class IngredientDetail { - final String? allergen; - final String? allergenType; + final List alias; + final List usageTip; + final String? introduction; final String? nutrition; - final String? usageTip; + final String? guidance; + final String? effect; + final String? other; + final List allergen; + final List 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 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 _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 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 eatingTime; + final Map 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 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 _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 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 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; + } +} diff --git a/lib/src/pages/discover/category_browse_page.dart b/lib/src/pages/discover/category_browse_page.dart index 0b898eb..d84440e 100644 --- a/lib/src/pages/discover/category_browse_page.dart +++ b/lib/src/pages/discover/category_browse_page.dart @@ -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 { ), 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 { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - recipe.title ?? '', + recipe.title, style: TextStyle( fontSize: DesignTokens.fontMd, fontWeight: FontWeight.w600, diff --git a/lib/src/pages/home/home_page.dart b/lib/src/pages/home/home_page.dart index a592b45..2698514 100644 --- a/lib/src/pages/home/home_page.dart +++ b/lib/src/pages/home/home_page.dart @@ -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 { 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 { ); } - 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: () { diff --git a/lib/src/pages/home/recipe_detail_page.dart b/lib/src/pages/home/recipe_detail_page.dart index 82277f8..d821d17 100644 --- a/lib/src/pages/home/recipe_detail_page.dart +++ b/lib/src/pages/home/recipe_detail_page.dart @@ -1,10 +1,5 @@ -// 菜谱详情页 -// 创建时间: 2026-04-09 -// 更新时间: 2026-04-11 -// 名称: recipe_detail_page.dart -// 作用: 展示菜谱详细信息 -// 上次更新内容: 添加热度标签(爆款/热门/受欢迎)+浏览量统计调用 - +// 2026-04-09 | recipe_detail_page.dart | 菜谱详情页 | 展示菜谱详细信息 +// 2026-04-11 | 重构: 显示API返回的所有字段,优化iOS风格布局 import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -19,6 +14,7 @@ import 'package:mom_kitchen/src/controllers/shopping_list_controller.dart'; import 'package:mom_kitchen/src/models/shopping_item_model.dart'; import 'package:mom_kitchen/src/services/ui/toast_service.dart'; import 'package:mom_kitchen/src/utils/common_utils.dart'; +import 'package:mom_kitchen/src/widgets/recipe_image.dart'; class RecipeDetailPage extends StatefulWidget { final String recipeId; @@ -66,9 +62,7 @@ class _RecipeDetailPageState extends State { } } catch (e) { if (mounted) { - setState(() { - _isLoading = false; - }); + setState(() => _isLoading = false); Get.snackbar('错误', '加载菜谱失败: $e'); } } @@ -77,11 +71,7 @@ class _RecipeDetailPageState extends State { void _checkFavorite() { if (_recipe != null) { final isFav = _favoritesController.isFavorited(_recipe!.id); - if (mounted) { - setState(() { - _isFavorite = isFav; - }); - } + if (mounted) setState(() => _isFavorite = isFav); } } @@ -95,9 +85,7 @@ class _RecipeDetailPageState extends State { if (_recipe != null) { final feedItem = FeedItemModel.fromRecipe(_recipe!); _favoritesController.toggleFavorite(feedItem); - setState(() { - _isFavorite = !_isFavorite; - }); + setState(() => _isFavorite = !_isFavorite); } } @@ -152,7 +140,7 @@ class _RecipeDetailPageState extends State { return CupertinoPageScaffold( navigationBar: CupertinoNavigationBar( - middle: Text(_recipe!.title ?? '菜谱详情'), + middle: Text(_recipe!.title), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -172,19 +160,21 @@ class _RecipeDetailPageState extends State { child: ListView( padding: const EdgeInsets.only(bottom: DesignTokens.space5), children: [ - _buildHeader(), - _buildInfo(), - _buildStatistics(isDark), - _buildTags(isDark), - _buildMetaInfo(isDark), - _buildIngredients(), + _buildCoverImage(), + _buildTitleSection(isDark), + _buildStatisticsBar(isDark), + _buildAuthorCard(isDark), + _buildCategoryBreadcrumb(isDark), + _buildMetaInfoCard(isDark), + _buildIndicesCard(isDark), + _buildTagsSection(isDark), _buildCategorizedIngredients(isDark), _buildAllergenWarning(isDark), _buildApiAllergens(isDark), - _buildScalerButton(isDark), - _buildSteps(), - _buildNutritionInfo(), - _buildNutritionItems(isDark), + _buildStepsSection(isDark), + _buildNutritionSummary(isDark), + _buildNutritionDetail(isDark), + _buildIngredientDetails(isDark), _buildTimeInfo(isDark), _buildActions(), ], @@ -192,35 +182,16 @@ class _RecipeDetailPageState extends State { ); } - Widget _buildHeader() { + Widget _buildCoverImage() { return Stack( children: [ - if (_recipe!.cover != null && _recipe!.cover!.isNotEmpty) - Image.network( - _recipe!.cover!, - height: 250, - width: double.infinity, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( - height: 250, - color: DesignTokens.text3.withValues(alpha: 0.1), - child: const Icon( - CupertinoIcons.photo, - size: 64, - color: DesignTokens.text3, - ), - ), - ) - else - Container( - height: 250, - color: DesignTokens.text3.withValues(alpha: 0.1), - child: const Icon( - CupertinoIcons.photo, - size: 64, - color: DesignTokens.text3, - ), - ), + RecipeImage.full( + recipeId: _recipe!.id, + coverUrl: _recipe!.cover, + height: 250, + width: double.infinity, + fit: BoxFit.cover, + ), Positioned( bottom: 0, left: 0, @@ -237,68 +208,50 @@ class _RecipeDetailPageState extends State { ], ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ + const Icon(CupertinoIcons.eye, size: 16, color: Colors.white70), + const SizedBox(width: 4), Text( - _recipe!.title ?? '', - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.white, - ), + '$_viewCount', + style: const TextStyle(color: Colors.white70), ), - const SizedBox(height: 8), - Row( - children: [ - const Icon( - CupertinoIcons.eye, - size: 16, - color: Colors.white70, + const SizedBox(width: 16), + const Icon( + CupertinoIcons.heart, + size: 16, + color: Colors.white70, + ), + const SizedBox(width: 4), + Text( + '$_likeCount', + style: const TextStyle(color: Colors.white70), + ), + if (_viewCount >= 1000 || _likeCount >= 50) ...[ + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, ), - const SizedBox(width: 4), - Text( - '$_viewCount', - style: const TextStyle(color: Colors.white70), + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(4), ), - const SizedBox(width: 16), - const Icon( - CupertinoIcons.heart, - size: 16, - color: Colors.white70, - ), - const SizedBox(width: 4), - Text( - '$_likeCount', - style: const TextStyle(color: Colors.white70), - ), - if (_viewCount >= 1000 || _likeCount >= 50) ...[ - const SizedBox(width: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: DesignTokens.orange.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - _viewCount >= 5000 - ? '🔥 爆款' - : _likeCount >= 100 - ? '❤️ 受欢迎' - : '📈 热门', - style: const TextStyle( - color: CupertinoColors.white, - fontSize: 10, - fontWeight: FontWeight.w600, - ), - ), + child: Text( + _viewCount >= 5000 + ? '🔥 爆款' + : _likeCount >= 100 + ? '❤️ 受欢迎' + : '📈 热门', + style: const TextStyle( + color: CupertinoColors.white, + fontSize: 10, + fontWeight: FontWeight.w600, ), - ], - ], - ), + ), + ), + ], ], ), ), @@ -307,60 +260,50 @@ class _RecipeDetailPageState extends State { ); } - Widget _buildInfo() { + Widget _buildTitleSection(bool isDark) { return Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(DesignTokens.space4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (_recipe!.intro != null && _recipe!.intro!.isNotEmpty) + Text( + _recipe!.title, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + if (_recipe!.intro != null && _recipe!.intro!.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space2), Text( _recipe!.intro!, - style: const TextStyle( - fontSize: 14, - color: DesignTokens.text2, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, height: 1.5, ), ), - const SizedBox(height: 16), - Row( - children: [ - if (_recipe!.meta?.time != null) - _buildInfoItem(CupertinoIcons.time, _recipe!.meta!.time!), - if (_recipe!.meta?.difficulty != null) ...[ - const SizedBox(width: 24), - _buildInfoItem( - CupertinoIcons.flame, - _recipe!.meta!.difficulty!, - ), - ], - ], - ), + ], + if (_recipe!.code != null && _recipe!.code!.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space1), + Text( + '🔢 ${_recipe!.code}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], ], ), ); } - Widget _buildInfoItem(IconData icon, String text) { - return Row( - children: [ - Icon(icon, size: 18, color: DesignTokens.text2), - const SizedBox(width: 4), - Text( - text, - style: const TextStyle(fontSize: 14, color: DesignTokens.text2), - ), - ], - ); - } - - Widget _buildStatistics(bool isDark) { + Widget _buildStatisticsBar(bool isDark) { final stats = _recipe?.statistics; return Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - vertical: DesignTokens.space2, - ), + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), child: Container( padding: const EdgeInsets.all(DesignTokens.space3), decoration: BoxDecoration( @@ -382,6 +325,7 @@ class _RecipeDetailPageState extends State { ), _buildStatItem('❤️', '${stats?.likes ?? _likeCount}', '点赞', isDark), _buildStatItem('⭐', '${stats?.recommends ?? 0}', '推荐', isDark), + _buildStatItem('💬', '${stats?.comments ?? 0}', '评论', isDark), ], ), ), @@ -412,7 +356,315 @@ class _RecipeDetailPageState extends State { ); } - Widget _buildTags(bool isDark) { + Widget _buildAuthorCard(bool isDark) { + final author = _recipe?.author; + if (author == null) return const SizedBox(); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: + (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + .withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + author.name.isNotEmpty ? author.name[0] : '?', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + ), + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + author.name, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + if (author.alias != null && author.alias!.isNotEmpty) + Text( + '@${author.alias}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + const Icon( + CupertinoIcons.person_crop_circle, + size: 20, + color: DesignTokens.text3, + ), + ], + ), + ), + ); + } + + Widget _buildCategoryBreadcrumb(bool isDark) { + final hierarchy = _recipe?.categoryHierarchy ?? []; + if (hierarchy.isEmpty && _recipe?.categoryName == null) { + return const SizedBox(); + } + + final items = []; + if (hierarchy.isNotEmpty) { + final sorted = List.from(hierarchy) + ..sort((a, b) => b.level.compareTo(a.level)); + for (final h in sorted) { + if (h.name.isNotEmpty) items.add(h.name); + } + } else if (_recipe?.categoryName != null) { + items.add(_recipe!.categoryName!); + } + + if (items.isEmpty) return const SizedBox(); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space1, + ), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: items.asMap().entries.map((entry) { + final isLast = entry.key == items.length - 1; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isLast + ? (isDark + ? DarkDesignTokens.primary + : DesignTokens.primary) + : (isDark ? DarkDesignTokens.card : DesignTokens.card), + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: isLast + ? (isDark + ? DarkDesignTokens.primary + : DesignTokens.primary) + : (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.2), + ), + ), + child: Text( + entry.value, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: isLast ? FontWeight.w600 : FontWeight.w400, + color: isLast + ? CupertinoColors.white + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ), + if (!isLast) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Icon( + CupertinoIcons.chevron_right, + size: 12, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ); + }).toList(), + ), + ); + } + + Widget _buildMetaInfoCard(bool isDark) { + final meta = _recipe?.meta; + final items = >[]; + if (meta?.process != null) items.add(MapEntry('🍳 做法', meta!.process!)); + if (meta?.taste != null) items.add(MapEntry('👅 口味', meta!.taste!)); + if (meta?.difficulty != null) { + items.add(MapEntry('📊 难度', meta!.difficulty!)); + } + if (meta?.time != null) items.add(MapEntry('⏱️ 时间', meta!.time!)); + if (meta?.eatingTime.isNotEmpty ?? false) { + items.add(MapEntry('🍽️ 用餐', meta!.eatingTime.join('、'))); + } + + if (items.isEmpty) return const SizedBox(); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Wrap( + spacing: DesignTokens.space3, + runSpacing: DesignTokens.space2, + children: items.map((entry) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: + (isDark ? DarkDesignTokens.primary : DesignTokens.primary) + .withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '${entry.key} ${entry.value}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), + ), + ), + ); + } + + Widget _buildIndicesCard(bool isDark) { + final indices = _recipe?.meta?.indices ?? {}; + if (indices.isEmpty) return const SizedBox(); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '📊 指数评分', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), + ...indices.entries.map((entry) { + final maxVal = 10; + final ratio = (entry.value / maxVal).clamp(0.0, 1.0); + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space1), + child: Row( + children: [ + SizedBox( + width: 48, + child: Text( + entry.key, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ), + Expanded( + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusSm, + child: LinearProgressIndicator( + value: ratio, + backgroundColor: + (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3) + .withValues(alpha: 0.1), + valueColor: AlwaysStoppedAnimation( + isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + ), + minHeight: 8, + ), + ), + ), + const SizedBox(width: 8), + Text( + '${entry.value}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + ), + ), + ], + ), + ); + }), + ], + ), + ), + ); + } + + Widget _buildTagsSection(bool isDark) { if (_recipe?.tags.isEmpty ?? true) return const SizedBox(); return Padding( @@ -448,79 +700,12 @@ class _RecipeDetailPageState extends State { ); } - Widget _buildMetaInfo(bool isDark) { - final meta = _recipe?.meta; - if (meta == null) return const SizedBox(); - - final items = >[]; - if (meta.process != null) items.add(MapEntry('🍳 做法', meta.process!)); - if (meta.taste != null) items.add(MapEntry('👅 口味', meta.taste!)); - if (meta.difficulty != null) items.add(MapEntry('📊 难度', meta.difficulty!)); - if (meta.time != null) items.add(MapEntry('⏱️ 时间', meta.time!)); - if (meta.eatingTime.isNotEmpty) { - items.add(MapEntry('🍽️ 用餐时段', meta.eatingTime.join('、'))); - } - - if (items.isEmpty) return const SizedBox(); - - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - vertical: DesignTokens.space2, - ), - child: Container( - padding: const EdgeInsets.all(DesignTokens.space3), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) - .withValues(alpha: 0.1), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: items.map((entry) { - return Padding( - padding: const EdgeInsets.only(bottom: DesignTokens.space1), - child: Row( - children: [ - SizedBox( - width: 80, - child: Text( - entry.key, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - fontWeight: FontWeight.w500, - ), - ), - ), - Expanded( - child: Text( - entry.value, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - ), - ), - ], - ), - ); - }).toList(), - ), - ), - ); - } - Widget _buildCategorizedIngredients(bool isDark) { final catIng = _recipe?.categorizedIngredients; - if (catIng == null || catIng.isEmpty) return const SizedBox(); + if (catIng == null || catIng.isEmpty) { + if (_recipe?.ingredients.isEmpty ?? true) return const SizedBox(); + return _buildFlatIngredients(isDark); + } return Padding( padding: const EdgeInsets.symmetric( @@ -540,6 +725,15 @@ class _RecipeDetailPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + '🥘 食材清单', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), if (catIng.main.isNotEmpty) ...[ _buildIngredientCategory('🥩 主料', catIng.main, isDark), const SizedBox(height: DesignTokens.space2), @@ -556,6 +750,69 @@ class _RecipeDetailPageState extends State { ); } + Widget _buildFlatIngredients(bool isDark) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '🥘 食材清单', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), + ..._recipe!.ingredients.map( + (ing) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Expanded( + child: Text( + ing.name, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + ), + Text( + ing.displayAmount, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + Widget _buildIngredientCategory( String title, List items, @@ -579,14 +836,35 @@ class _RecipeDetailPageState extends State { child: Row( children: [ Expanded( - child: Text( - ing.name, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), + child: Row( + children: [ + Text( + ing.name, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + if (ing.detail != null && ing.detail!.hasAllergen) ...[ + const SizedBox(width: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '⚠️', + style: TextStyle(fontSize: DesignTokens.fontXs), + ), + ), + ], + ], ), ), Text( @@ -604,6 +882,89 @@ class _RecipeDetailPageState extends State { ); } + Widget _buildAllergenWarning(bool isDark) { + if (_recipe!.ingredients.isEmpty) return const SizedBox(); + + final ingredientText = _recipe!.ingredients + .map((i) => '${i.name} ${i.amount ?? ''} ${i.unit ?? ''}') + .join(' '); + final detectedAllergens = _allergenChecker.checkAllergens(ingredientText); + if (detectedAllergens.isEmpty) return const SizedBox(); + + final warningMsg = _allergenChecker.generateWarningMessage( + detectedAllergens, + ); + final substitutions = _allergenChecker.getAllergenSubstitutions( + detectedAllergens, + ); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all(color: DesignTokens.orange.withValues(alpha: 0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('⚠️', style: TextStyle(fontSize: 18)), + const SizedBox(width: 8), + Expanded( + child: Text( + warningMsg, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.orange, + height: 1.4, + ), + ), + ), + ], + ), + if (substitutions.isNotEmpty) ...[ + const SizedBox(height: 8), + ...substitutions.entries.map( + (entry) => Padding( + padding: const EdgeInsets.only(left: 26, bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '🔄 ', + style: TextStyle(fontSize: DesignTokens.fontXs), + ), + Expanded( + child: Text( + '${entry.key} → 可替换为:${entry.value.join('、')}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + height: 1.3, + ), + ), + ), + ], + ), + ), + ), + ], + ], + ), + ), + ); + } + Widget _buildApiAllergens(bool isDark) { if (_recipe?.allergens.isEmpty ?? true) return const SizedBox(); @@ -636,14 +997,29 @@ class _RecipeDetailPageState extends State { ), ), const SizedBox(height: 2), - Text( - _recipe!.allergens.join('、'), - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - ), + Wrap( + spacing: 4, + runSpacing: 4, + children: _recipe!.allergens.map((a) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.12), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + a, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.orange, + fontWeight: FontWeight.w500, + ), + ), + ); + }).toList(), ), ], ), @@ -654,7 +1030,220 @@ class _RecipeDetailPageState extends State { ); } - Widget _buildNutritionItems(bool isDark) { + Widget _buildStepsSection(bool isDark) { + if (_recipe!.content == null || _recipe!.content!.isEmpty) { + return const SizedBox(); + } + + final steps = _recipe!.content!.split(RegExp(r'\n')); + final numberedSteps = []; + final textSteps = []; + + for (final step in steps) { + final trimmed = step.trim(); + if (trimmed.isEmpty) continue; + final match = RegExp(r'^(\d+)[.、.)\s]+(.*)').firstMatch(trimmed); + if (match != null) { + numberedSteps.add(match.group(2) ?? trimmed); + } else { + textSteps.add(trimmed); + } + } + + final hasNumbered = numberedSteps.isNotEmpty; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '👩‍🍳 烹饪步骤', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), + if (hasNumbered) + ...numberedSteps.asMap().entries.map( + (entry) => Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 24, + height: 24, + margin: const EdgeInsets.only(right: 8, top: 2), + decoration: BoxDecoration( + color: + (isDark + ? DarkDesignTokens.primary + : DesignTokens.primary) + .withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${entry.key + 1}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w700, + color: isDark + ? DarkDesignTokens.primary + : DesignTokens.primary, + ), + ), + ), + ), + Expanded( + child: Text( + entry.value, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + height: 1.5, + ), + ), + ), + ], + ), + ), + ) + else + ...textSteps.map( + (step) => Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space2), + child: Text( + step, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + height: 1.5, + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildNutritionSummary(bool isDark) { + if (_recipe!.nutrition == null) return const SizedBox(); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '🧬 营养概览', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildNutritionItem( + '🔥', + '${_recipe!.nutrition!.calories?.toInt() ?? 0}', + '千卡', + isDark, + ), + _buildNutritionItem( + '🥩', + '${_recipe!.nutrition!.protein?.toInt() ?? 0}', + '克蛋白质', + isDark, + ), + _buildNutritionItem( + '🧈', + '${_recipe!.nutrition!.fat?.toInt() ?? 0}', + '克脂肪', + isDark, + ), + _buildNutritionItem( + '🍞', + '${_recipe!.nutrition!.carbs?.toInt() ?? 0}', + '克碳水', + isDark, + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildNutritionItem( + String emoji, + String value, + String unit, + bool isDark, + ) { + return Column( + children: [ + Text(emoji, style: const TextStyle(fontSize: 20)), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.primary : DesignTokens.primary, + ), + ), + Text( + unit, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ); + } + + Widget _buildNutritionDetail(bool isDark) { final items = _recipe?.nutrition?.items; if (items == null || items.isEmpty) return const SizedBox(); @@ -721,6 +1310,174 @@ class _RecipeDetailPageState extends State { ); } + Widget _buildIngredientDetails(bool isDark) { + final ingredientsWithDetail = + _recipe?.categorizedIngredients?.all + .where((i) => i.detail != null) + .toList() ?? + _recipe?.ingredients.where((i) => i.detail != null).toList() ?? + []; + + if (ingredientsWithDetail.isEmpty) return const SizedBox(); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '📖 食材详解', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), + ...ingredientsWithDetail.map( + (ing) => + _buildIngredientDetailCard(ing.name, ing.detail!, isDark), + ), + ], + ), + ), + ); + } + + Widget _buildIngredientDetailCard( + String name, + IngredientDetail detail, + bool isDark, + ) { + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space3), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.background : DesignTokens.background, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.08), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + name, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + if (detail.hasAlias) ...[ + const SizedBox(width: 6), + Text( + '(${detail.alias.join('、')})', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ], + ), + if (detail.hasIntroduction) ...[ + const SizedBox(height: DesignTokens.space2), + _buildDetailRow('📝 介绍', detail.introduction!, isDark), + ], + if (detail.hasUsageTip) ...[ + const SizedBox(height: DesignTokens.space2), + _buildDetailRow('💡 用法提示', detail.usageTip.join('\n'), isDark), + ], + if (detail.hasNutrition) ...[ + const SizedBox(height: DesignTokens.space2), + _buildDetailRow('🧬 营养', detail.nutrition!, isDark), + ], + if (detail.hasGuidance) ...[ + const SizedBox(height: DesignTokens.space2), + _buildDetailRow('📋 指导', detail.guidance!, isDark), + ], + if (detail.hasEffect) ...[ + const SizedBox(height: DesignTokens.space2), + _buildDetailRow('💊 功效', detail.effect!, isDark), + ], + if (detail.hasAllergen) ...[ + const SizedBox(height: DesignTokens.space2), + Wrap( + spacing: 4, + runSpacing: 4, + children: detail.allergen.map((a) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '⚠️ $a', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.orange, + ), + ), + ); + }).toList(), + ), + ], + ], + ), + ), + ); + } + + Widget _buildDetailRow(String label, String content, bool isDark) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + const SizedBox(height: 2), + Text( + content, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + height: 1.5, + ), + ), + ], + ); + } + Widget _buildTimeInfo(bool isDark) { final hasCreated = _recipe?.createdAt != null; final hasUpdated = _recipe?.updatedAt != null; @@ -790,252 +1547,6 @@ class _RecipeDetailPageState extends State { ); } - Widget _buildIngredients() { - if (_recipe!.ingredients == null || _recipe!.ingredients!.isEmpty) { - return const SizedBox(); - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '食材', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: DesignTokens.text1, - ), - ), - const SizedBox(height: 12), - ...(_recipe!.ingredients!.map( - (ingredient) => Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - const Icon( - CupertinoIcons.circle_fill, - size: 8, - color: DesignTokens.primary, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - ingredient.name ?? '', - style: const TextStyle( - fontSize: 16, - color: DesignTokens.text1, - ), - ), - ), - Text( - '${ingredient.amount ?? ''} ${ingredient.unit ?? ''}', - style: const TextStyle( - fontSize: 14, - color: DesignTokens.text2, - ), - ), - ], - ), - ), - )), - ], - ), - ); - } - - Widget _buildScalerButton(bool isDark) { - return const SizedBox.shrink(); - } - - Widget _buildAllergenWarning(bool isDark) { - if (_recipe?.ingredients == null || _recipe!.ingredients!.isEmpty) { - return const SizedBox(); - } - - final ingredientText = _recipe!.ingredients! - .map((i) => '${i.name} ${i.amount ?? ''} ${i.unit ?? ''}') - .join(' '); - final detectedAllergens = _allergenChecker.checkAllergens(ingredientText); - - if (detectedAllergens.isEmpty) return const SizedBox(); - - final warningMsg = _allergenChecker.generateWarningMessage( - detectedAllergens, - ); - final substitutions = _allergenChecker.getAllergenSubstitutions( - detectedAllergens, - ); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: DesignTokens.orange.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all(color: DesignTokens.orange.withValues(alpha: 0.3)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('⚠️', style: TextStyle(fontSize: 18)), - const SizedBox(width: 8), - Expanded( - child: Text( - warningMsg, - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: DesignTokens.orange, - height: 1.4, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - ...substitutions.entries.map((entry) { - return Padding( - padding: const EdgeInsets.only(left: 26, bottom: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '🔄 ', - style: TextStyle(fontSize: DesignTokens.fontXs), - ), - Expanded( - child: Text( - '${entry.key} → 可替换为:${entry.value.join('、')}', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - height: 1.3, - ), - ), - ), - ], - ), - ); - }), - ], - ), - ), - ); - } - - Widget _buildSteps() { - if (_recipe!.content == null || _recipe!.content!.isEmpty) { - return const SizedBox(); - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '做法', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: DesignTokens.text1, - ), - ), - const SizedBox(height: 12), - Text( - _recipe!.content!, - style: const TextStyle( - fontSize: 16, - color: DesignTokens.text1, - height: 1.6, - ), - ), - ], - ), - ); - } - - Widget _buildNutritionInfo() { - if (_recipe!.nutrition == null) return const SizedBox(); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: DesignTokens.card, - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: DesignTokens.text3.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '营养信息', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: DesignTokens.text1, - ), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildNutritionItem( - '热量', - '${_recipe!.nutrition!.calories?.toInt() ?? 0} kcal', - ), - _buildNutritionItem( - '蛋白质', - '${_recipe!.nutrition!.protein?.toInt() ?? 0} g', - ), - _buildNutritionItem( - '脂肪', - '${_recipe!.nutrition!.fat?.toInt() ?? 0} g', - ), - _buildNutritionItem( - '碳水', - '${_recipe!.nutrition!.carbs?.toInt() ?? 0} g', - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildNutritionItem(String label, String value) { - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - value, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: DesignTokens.primary, - ), - ), - Text( - label, - style: const TextStyle(fontSize: 12, color: DesignTokens.text2), - ), - ], - ); - } - Widget _buildActions() { final isLiked = _actionController.isLiked(_recipe!.id); final isRecommended = _actionController.isRecommended(_recipe!.id); @@ -1063,9 +1574,7 @@ class _RecipeDetailPageState extends State { color: isLiked ? DesignTokens.red : null, onTap: () { final wasLiked = _actionController.isLiked(_recipe!.id); - setState(() { - _likeCount += wasLiked ? -1 : 1; - }); + setState(() => _likeCount += wasLiked ? -1 : 1); _actionController.likeItem(id: _recipe!.id, type: 'recipe'); }, ), @@ -1130,14 +1639,14 @@ class _RecipeDetailPageState extends State { label: '缩放', color: null, onTap: () { - final ingredients = _recipe!.ingredients?.map((ing) { + final ingredients = _recipe!.ingredients.map((ing) { return { 'name': ing.name, 'amount': ing.amount ?? '', 'unit': ing.unit ?? '', }; }).toList(); - if (ingredients != null && ingredients.isNotEmpty) { + if (ingredients.isNotEmpty) { Get.toNamed( '/serving-scaler', arguments: {'ingredients': ingredients, 'servings': 4}, @@ -1251,11 +1760,8 @@ class _RecipeDetailPageState extends State { children: List.generate(5, (index) { final starIndex = index + 1; return GestureDetector( - onTap: () { - setModalState(() { - selectedScore = starIndex; - }); - }, + onTap: () => + setModalState(() => selectedScore = starIndex), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Icon( @@ -1307,14 +1813,14 @@ class _RecipeDetailPageState extends State { } void _addToShoppingList() { - if (_recipe?.ingredients == null || _recipe!.ingredients!.isEmpty) { + if (_recipe!.ingredients.isEmpty) { ToastService.show(message: '该菜谱暂无食材信息 📋'); return; } try { final controller = Get.find(); final recipeId = int.tryParse(widget.recipeId) ?? 0; - final items = _recipe!.ingredients!.map((ing) { + final items = _recipe!.ingredients.map((ing) { return ShoppingItemModel( name: ing.name, amount: ing.amount, @@ -1323,7 +1829,7 @@ class _RecipeDetailPageState extends State { recipeId: recipeId, ); }).toList(); - controller.addItemsFromRecipe(recipeId, _recipe!.title ?? '', items); + controller.addItemsFromRecipe(recipeId, _recipe!.title, items); } catch (e) { ToastService.show(message: '添加到购物清单失败 😢'); } @@ -1332,15 +1838,13 @@ class _RecipeDetailPageState extends State { void _shareRecipe() { if (_recipe == null) return; - final ingredients = - _recipe!.ingredients - ?.map((i) { - final amount = i.amount ?? ''; - final unit = i.unit ?? ''; - return '• ${i.name}${amount.isNotEmpty ? ' $amount' : ''}${unit.isNotEmpty ? unit : ''}'; - }) - .join('\n') ?? - ''; + final ingredients = _recipe!.ingredients + .map((i) { + final amount = i.amount ?? ''; + final unit = i.unit ?? ''; + return '• ${i.name}${amount.isNotEmpty ? ' $amount' : ''}${unit.isNotEmpty ? unit : ''}'; + }) + .join('\n'); final shareText = StringBuffer(); shareText.writeln('🍳 ${_recipe!.title}'); @@ -1355,7 +1859,7 @@ class _RecipeDetailPageState extends State { shareText.writeln(_recipe!.content); } shareText.writeln(''); - shareText.write('— 来自 妈妈厨房 App 🍳'); + shareText.write('— 来自 老妈厨房 App 🍳'); CommonUtils.shareContent( shareText.toString(), diff --git a/lib/src/pages/home/search_page.dart b/lib/src/pages/home/search_page.dart index 54bd842..eea776c 100644 --- a/lib/src/pages/home/search_page.dart +++ b/lib/src/pages/home/search_page.dart @@ -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 { ), 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 { // 封面图 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 { ), ); } - - Widget _buildPlaceholderIcon(bool isDark) { - return Center(child: Text('🍽️', style: const TextStyle(fontSize: 32))); - } } diff --git a/lib/src/pages/profile/settings/personalization_page.dart b/lib/src/pages/profile/settings/personalization_page.dart index 7cce934..207fa08 100644 --- a/lib/src/pages/profile/settings/personalization_page.dart +++ b/lib/src/pages/profile/settings/personalization_page.dart @@ -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( diff --git a/lib/src/pages/tools/eating_times_page.dart b/lib/src/pages/tools/eating_times_page.dart index bfb1b38..d09a8b7 100644 --- a/lib/src/pages/tools/eating_times_page.dart +++ b/lib/src/pages/tools/eating_times_page.dart @@ -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 { 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, ), ), ), diff --git a/lib/src/standards/route_middleware.dart b/lib/src/standards/route_middleware.dart index 69b0541..761556a 100644 --- a/lib/src/standards/route_middleware.dart +++ b/lib/src/standards/route_middleware.dart @@ -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; } diff --git a/lib/src/widgets/glass/glass_feed_card.dart b/lib/src/widgets/glass/glass_feed_card.dart index 0dd681a..0152973 100644 --- a/lib/src/widgets/glass/glass_feed_card.dart +++ b/lib/src/widgets/glass/glass_feed_card.dart @@ -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), diff --git a/lib/src/widgets/recipe_card.dart b/lib/src/widgets/recipe_card.dart index f57a16c..26b4a49 100644 --- a/lib/src/widgets/recipe_card.dart +++ b/lib/src/widgets/recipe_card.dart @@ -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, diff --git a/lib/src/widgets/recipe_image.dart b/lib/src/widgets/recipe_image.dart index 239db59..e6d1918 100644 --- a/lib/src/widgets/recipe_image.dart +++ b/lib/src/widgets/recipe_image.dart @@ -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 createState() => _RecipeImageState(); } @@ -48,6 +72,7 @@ class _RecipeImageState extends State { bool _hasError = false; Uint8List? _imageBytes; String? _currentUrl; + bool _isShowingOriginal = false; List get _fallbackUrls { final id = widget.recipeId; @@ -64,6 +89,9 @@ class _RecipeImageState extends State { return urls; } + String get _cacheKeyPrefix => + widget.mode == RecipeImageMode.thumbnail ? 'thumb_' : 'full_'; + @override void initState() { super.initState(); @@ -74,11 +102,13 @@ class _RecipeImageState extends State { 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 { Future _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 { 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 { 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 { } } + Future _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 { } } - Uint8List? _getFromMemoryCache(String url) { - final entry = _memoryCache[url]; + Future _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 data) { + void _addToMemoryCache(String key, List 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 _getFromDiskCache(String url) async { + Future _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 { } } - Future _saveToDiskCache(String url, List data) async { + Future _saveToDiskCache(String key, List 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 { 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 { ); } + 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 { 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 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'; + } } diff --git a/linux/.gitignore b/linux/.gitignore deleted file mode 100644 index d3896c9..0000000 --- a/linux/.gitignore +++ /dev/null @@ -1 +0,0 @@ -flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt deleted file mode 100644 index 65fcdb7..0000000 --- a/linux/CMakeLists.txt +++ /dev/null @@ -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 "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>: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() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt deleted file mode 100644 index d5bd016..0000000 --- a/linux/flutter/CMakeLists.txt +++ /dev/null @@ -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} -) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index f6f23bf..0000000 --- a/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include - -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); -} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47..0000000 --- a/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake deleted file mode 100644 index df8d2f7..0000000 --- a/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -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 $) - 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) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt deleted file mode 100644 index e97dabc..0000000 --- a/linux/runner/CMakeLists.txt +++ /dev/null @@ -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}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc deleted file mode 100644 index e7c5c54..0000000 --- a/linux/runner/main.cc +++ /dev/null @@ -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); -} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc deleted file mode 100644 index 48e1fc0..0000000 --- a/linux/runner/my_application.cc +++ /dev/null @@ -1,144 +0,0 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#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)); -} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h deleted file mode 100644 index 72271d5..0000000 --- a/linux/runner/my_application.h +++ /dev/null @@ -1,18 +0,0 @@ -#ifndef FLUTTER_MY_APPLICATION_H_ -#define FLUTTER_MY_APPLICATION_H_ - -#include - -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_ diff --git a/scripts/NUTRITION_PERFORMANCE.md b/scripts/NUTRITION_PERFORMANCE.md deleted file mode 100644 index 3afff66..0000000 --- a/scripts/NUTRITION_PERFORMANCE.md +++ /dev/null @@ -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? _ctrl; -String? _error; - -@override -void initState() { - super.initState(); - try { - _ctrl = Get.find(); - } 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> 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+* diff --git a/scripts/verify_categories_detail.dart b/scripts/verify_categories_detail.dart deleted file mode 100644 index 9ae5a0a..0000000 --- a/scripts/verify_categories_detail.dart +++ /dev/null @@ -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; - final data = json['data'] as List; - - for (final topCat in data) { - final m = topCat as Map; - 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; - 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']}'); - } - } - } -} diff --git a/scripts/verify_eating_times.dart b/scripts/verify_eating_times.dart deleted file mode 100644 index ae58fba..0000000 --- a/scripts/verify_eating_times.dart +++ /dev/null @@ -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; - 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)); - } -} diff --git a/scripts/verify_filter_apply.dart b/scripts/verify_filter_apply.dart deleted file mode 100644 index 32fe2db..0000000 --- a/scripts/verify_filter_apply.dart +++ /dev/null @@ -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 testFilterApply({String? category, String? tag, required String label}) async { - print('▶ $label'); - final params = {'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; - 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; - 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(''); -} diff --git a/scripts/verify_nutrition_api.dart b/scripts/verify_nutrition_api.dart deleted file mode 100644 index 8627be2..0000000 --- a/scripts/verify_nutrition_api.dart +++ /dev/null @@ -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 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; - - print('${blue}JSON 结构分析:${reset}'); - print(' - code: ${jsonData['code']}'); - print(' - message: ${jsonData['message']}'); - - if (jsonData['data'] != null) { - final dataMap = jsonData['data'] as Map; - print(' - data keys: ${dataMap.keys.toList()}'); - - // 检查热门排行数据结构 - if (dataMap.containsKey('total')) { - final total = dataMap['total'] as Map; - 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; - 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 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; - - if (jsonData['code'] == 200) { - print( - ' ${green}✓ 状态码 200${reset} - ${stopwatch.elapsedMilliseconds}ms', - ); - - final dataMap = jsonData['data'] as Map?; - if (dataMap != null) { - // 检查不同可能的数据结构 - int recipeCount = 0; - - if (dataMap.containsKey(period) && dataMap[period] is Map) { - final periodData = dataMap[period] as Map; - 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; - 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 performanceBenchmark() async { - printSection('测试 3:性能基准测试'); - - final iterations = 5; - final results = []; - - 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'); -} diff --git a/scripts/verify_recipe_images.dart b/scripts/verify_recipe_images.dart deleted file mode 100644 index e14edbe..0000000 --- a/scripts/verify_recipe_images.dart +++ /dev/null @@ -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(); -} diff --git a/scripts/verify_what_to_eat_api.dart b/scripts/verify_what_to_eat_api.dart deleted file mode 100644 index e8b9f65..0000000 --- a/scripts/verify_what_to_eat_api.dart +++ /dev/null @@ -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?> apiGet(String endpoint, Map 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; - 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 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; - 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; - print(' first: id=${first['id']}, title=${first['title']}'); - } - } - } - print(''); -} - -Future 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; - 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; - print(' - id=${cm['id'] ?? cm['cate_id']}, name=${cm['name'] ?? cm['cate_name']}'); - } - } - } - } else { - print(' data type: ${data.runtimeType}'); - } - } - print(''); -} - -Future 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; - print(' - id=${m['id'] ?? m['tag_id']}, name=${m['name'] ?? m['tag_name']}'); - } - } else { - print(' data type: ${data.runtimeType}'); - } - } - print(''); -} - -Future 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; - 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 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; - 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'); -}