更多 页面 重构
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -6,6 +6,25 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
### Enhanced — 软件特性功能完善
|
||||
|
||||
- ✨ **食材推荐功能** — `lib/src/pages/discover/ingredient_recommend_page.dart`, `lib/src/pages/discover/ingredient_recipe_list_page.dart`
|
||||
- **功能**:在分类浏览下增加食材推荐入口,点击食材跳转该食材对应的菜品列表
|
||||
- **实现**:
|
||||
- 新建 IngredientRecommendPage 页面,展示食材网格(emoji+名称),调用 `api.php?act=ingredients`
|
||||
- 新建 IngredientRecipeListPage 页面,显示食材相关菜品列表,调用 `api.php?act=search&keyword=xxx&type=recipe`,每页20条,支持分页加载
|
||||
- 首页分类浏览区域添加"食材推荐"入口卡片(渐变背景),点击跳转食材推荐页面
|
||||
- 食材emoji智能匹配(鸡→🥚、肉→🥩、鱼→🐟、菜→🥬等40+映射)
|
||||
- **影响文件**:
|
||||
- `lib/src/pages/discover/ingredient_recommend_page.dart` (新建)
|
||||
- `lib/src/pages/discover/ingredient_recipe_list_page.dart` (新建)
|
||||
- `lib/src/pages/home/home_page.dart` (修改)
|
||||
- `lib/src/config/app_routes.dart` (修改)
|
||||
- `lib/src/standards/app_pages.dart` (修改)
|
||||
|
||||
- ✨ **食材详情页数据传递修复** — `lib/src/pages/home/recipe_detail_page.dart`, `lib/src/pages/tools/ingredient_detail_page.dart`
|
||||
- **问题**:点击菜品详情页食材,食材介绍不显示
|
||||
- **原因**:detail数据已存在于act=full返回的ingredients[].detail中,但未传递给详情页
|
||||
- **修复**:recipe_detail_page传递detail参数,ingredient_detail_page接收并直接展示introduction/nutrition/guidance/effect/allergen
|
||||
|
||||
- ✨ **功能状态审核与完善**
|
||||
- **购物清单** - ✅ 已完成:菜谱详情页"购物"按钮可添加食材到购物清单
|
||||
- **过敏原检测** - ✅ 已完成:AllergenChecker完整实现,包含11类过敏原关键词映射和检测逻辑
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* 🚀 API缓存系统
|
||||
*
|
||||
* 支持文件缓存,自动清理过期缓存
|
||||
* 优化多人多次请求性能
|
||||
*/
|
||||
|
||||
class ApiCache {
|
||||
private static $cacheDir = null;
|
||||
private static $defaultTTL = 300;
|
||||
|
||||
private static $ttlConfig = array(
|
||||
'list' => 300,
|
||||
'detail' => 600,
|
||||
'full' => 600,
|
||||
'ingredients' => 600,
|
||||
'ingredient_detail' => 1200,
|
||||
'search' => 180,
|
||||
'categories' => 1800,
|
||||
'tags' => 1800,
|
||||
'stats' => 120,
|
||||
'stats_full' => 120,
|
||||
'hot' => 300,
|
||||
'query' => 180,
|
||||
'filter' => 180,
|
||||
'like' => 0,
|
||||
'recommend' => 0,
|
||||
'view' => 0
|
||||
);
|
||||
|
||||
public static function init() {
|
||||
if (self::$cacheDir === null) {
|
||||
self::$cacheDir = dirname(__FILE__) . '/cache/';
|
||||
if (!is_dir(self::$cacheDir)) {
|
||||
@mkdir(self::$cacheDir, 0755, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function getTTL($act) {
|
||||
return isset(self::$ttlConfig[$act]) ? self::$ttlConfig[$act] : self::$defaultTTL;
|
||||
}
|
||||
|
||||
public static function getCacheKey($act, $params = array()) {
|
||||
ksort($params);
|
||||
$paramStr = http_build_query($params);
|
||||
return md5($act . '_' . $paramStr);
|
||||
}
|
||||
|
||||
public static function get($act, $params = array()) {
|
||||
self::init();
|
||||
|
||||
$ttl = isset(self::$ttlConfig[$act]) ? self::$ttlConfig[$act] : self::$defaultTTL;
|
||||
if ($ttl <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = self::getCacheKey($act, $params);
|
||||
$file = self::$cacheDir . $key . '.json';
|
||||
|
||||
if (!file_exists($file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file);
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (!$data || !isset($data['expire']) || !isset($data['data'])) {
|
||||
@unlink($file);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (time() > $data['expire']) {
|
||||
@unlink($file);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data['data'];
|
||||
}
|
||||
|
||||
public static function getStale($act, $params = array()) {
|
||||
self::init();
|
||||
|
||||
$key = self::getCacheKey($act, $params);
|
||||
$file = self::$cacheDir . $key . '.json';
|
||||
|
||||
if (!file_exists($file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = @file_get_contents($file);
|
||||
if ($content === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (!$data || !isset($data['data'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data['data'];
|
||||
}
|
||||
|
||||
public static function isExpired($act, $params = array()) {
|
||||
self::init();
|
||||
|
||||
$key = self::getCacheKey($act, $params);
|
||||
$file = self::$cacheDir . $key . '.json';
|
||||
|
||||
if (!file_exists($file)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$content = @file_get_contents($file);
|
||||
if ($content === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (!$data || !isset($data['expire'])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return time() > $data['expire'];
|
||||
}
|
||||
|
||||
public static function getCacheAge($act, $params = array()) {
|
||||
self::init();
|
||||
|
||||
$key = self::getCacheKey($act, $params);
|
||||
$file = self::$cacheDir . $key . '.json';
|
||||
|
||||
if (!file_exists($file)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$content = @file_get_contents($file);
|
||||
if ($content === false) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (!$data || !isset($data['created'])) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return time() - $data['created'];
|
||||
}
|
||||
|
||||
public static function set($act, $params, $data) {
|
||||
self::init();
|
||||
|
||||
$ttl = isset(self::$ttlConfig[$act]) ? self::$ttlConfig[$act] : self::$defaultTTL;
|
||||
if ($ttl <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$key = self::getCacheKey($act, $params);
|
||||
$file = self::$cacheDir . $key . '.json';
|
||||
|
||||
$cacheData = array(
|
||||
'act' => $act,
|
||||
'params' => $params,
|
||||
'data' => $data,
|
||||
'expire' => time() + $ttl,
|
||||
'created' => time()
|
||||
);
|
||||
|
||||
return file_put_contents($file, json_encode($cacheData, JSON_UNESCAPED_UNICODE)) !== false;
|
||||
}
|
||||
|
||||
public static function clear($act = null, $params = array()) {
|
||||
self::init();
|
||||
|
||||
if ($act === null) {
|
||||
$files = glob(self::$cacheDir . '*.json');
|
||||
foreach ($files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
$key = self::getCacheKey($act, $params);
|
||||
$file = self::$cacheDir . $key . '.json';
|
||||
|
||||
if (file_exists($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function clearByAct($act) {
|
||||
self::init();
|
||||
|
||||
$files = glob(self::$cacheDir . '*.json');
|
||||
$count = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$content = file_get_contents($file);
|
||||
$data = json_decode($content, true);
|
||||
if ($data && isset($data['act']) && $data['act'] === $act) {
|
||||
@unlink($file);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
public static function cleanExpired() {
|
||||
self::init();
|
||||
|
||||
$files = glob(self::$cacheDir . '*.json');
|
||||
$count = 0;
|
||||
$now = time();
|
||||
|
||||
foreach ($files as $file) {
|
||||
$content = @file_get_contents($file);
|
||||
if ($content === false) {
|
||||
@unlink($file);
|
||||
$count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
if (!$data || !isset($data['expire']) || $now > $data['expire']) {
|
||||
@unlink($file);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
public static function getStats() {
|
||||
self::init();
|
||||
|
||||
$files = glob(self::$cacheDir . '*.json');
|
||||
$totalSize = 0;
|
||||
$count = 0;
|
||||
$oldest = time();
|
||||
$newest = 0;
|
||||
|
||||
foreach ($files as $file) {
|
||||
$count++;
|
||||
$totalSize += filesize($file);
|
||||
$content = file_get_contents($file);
|
||||
$data = json_decode($content, true);
|
||||
if ($data && isset($data['created'])) {
|
||||
$oldest = min($oldest, $data['created']);
|
||||
$newest = max($newest, $data['created']);
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'count' => $count,
|
||||
'total_size' => $totalSize,
|
||||
'total_size_readable' => self::formatBytes($totalSize),
|
||||
'oldest' => $oldest > 0 ? date('Y-m-d H:i:s', $oldest) : '-',
|
||||
'newest' => $newest > 0 ? date('Y-m-d H:i:s', $newest) : '-'
|
||||
);
|
||||
}
|
||||
|
||||
private static function formatBytes($bytes) {
|
||||
if ($bytes >= 1073741824) {
|
||||
return number_format($bytes / 1073741824, 2) . ' GB';
|
||||
} elseif ($bytes >= 1048576) {
|
||||
return number_format($bytes / 1048576, 2) . ' MB';
|
||||
} elseif ($bytes >= 1024) {
|
||||
return number_format($bytes / 1024, 2) . ' KB';
|
||||
} else {
|
||||
return $bytes . ' bytes';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (php_sapi_name() === 'cli' && isset($argv[0]) && basename($argv[0]) === 'cache.php') {
|
||||
$action = $argv[1] ?? 'stats';
|
||||
|
||||
switch ($action) {
|
||||
case 'clean':
|
||||
$count = ApiCache::cleanExpired();
|
||||
echo "Cleaned {$count} expired cache files.\n";
|
||||
break;
|
||||
case 'clear':
|
||||
ApiCache::clear();
|
||||
echo "All cache cleared.\n";
|
||||
break;
|
||||
case 'stats':
|
||||
default:
|
||||
$stats = ApiCache::getStats();
|
||||
echo "Cache Statistics:\n";
|
||||
echo " Files: {$stats['count']}\n";
|
||||
echo " Size: {$stats['total_size_readable']}\n";
|
||||
echo " Oldest: {$stats['oldest']}\n";
|
||||
echo " Newest: {$stats['newest']}\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* 🧹 缓存管理接口
|
||||
*
|
||||
* 访问方式: /api/cache_manage.php?action=xxx
|
||||
*/
|
||||
|
||||
require '../zb_system/function/c_system_base.php';
|
||||
$zbp->Load();
|
||||
|
||||
require_once 'cache.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
|
||||
$action = $_GET['action'] ?? 'stats';
|
||||
|
||||
$result = array();
|
||||
|
||||
switch ($action) {
|
||||
case 'stats':
|
||||
$result = array(
|
||||
'code' => 200,
|
||||
'message' => '📊 缓存统计',
|
||||
'data' => ApiCache::getStats()
|
||||
);
|
||||
break;
|
||||
|
||||
case 'clean':
|
||||
$count = ApiCache::cleanExpired();
|
||||
$result = array(
|
||||
'code' => 200,
|
||||
'message' => "🧹 已清理 {$count} 个过期缓存",
|
||||
'data' => array(
|
||||
'cleaned' => $count,
|
||||
'stats' => ApiCache::getStats()
|
||||
)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'clear':
|
||||
$act = $_GET['act'] ?? null;
|
||||
if ($act) {
|
||||
$count = ApiCache::clearByAct($act);
|
||||
$result = array(
|
||||
'code' => 200,
|
||||
'message' => "🗑️ 已清除 {$act} 相关的 {$count} 个缓存",
|
||||
'data' => array(
|
||||
'act' => $act,
|
||||
'cleaned' => $count
|
||||
)
|
||||
);
|
||||
} else {
|
||||
ApiCache::clear();
|
||||
$result = array(
|
||||
'code' => 200,
|
||||
'message' => '🗑️ 已清除所有缓存',
|
||||
'data' => ApiCache::getStats()
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'config':
|
||||
$result = array(
|
||||
'code' => 200,
|
||||
'message' => '⚙️ 缓存配置',
|
||||
'data' => array(
|
||||
'ttl_config' => array(
|
||||
'list' => '180秒 (3分钟)',
|
||||
'detail' => '300秒 (5分钟)',
|
||||
'ingredients' => '300秒 (5分钟)',
|
||||
'ingredient_detail' => '600秒 (10分钟)',
|
||||
'search' => '120秒 (2分钟)',
|
||||
'categories' => '600秒 (10分钟)',
|
||||
'tags' => '600秒 (10分钟)',
|
||||
'stats' => '60秒 (1分钟)',
|
||||
'like' => '不缓存',
|
||||
'recommend' => '不缓存',
|
||||
'view' => '不缓存'
|
||||
),
|
||||
'auto_clean' => '1%概率自动清理过期缓存'
|
||||
)
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
$result = array(
|
||||
'code' => 200,
|
||||
'message' => '🧹 缓存管理接口',
|
||||
'data' => array(
|
||||
'actions' => array(
|
||||
'stats' => '查看缓存统计',
|
||||
'clean' => '清理过期缓存',
|
||||
'clear' => '清除所有缓存',
|
||||
'clear&act=xxx' => '清除指定接口缓存',
|
||||
'config' => '查看缓存配置'
|
||||
)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
@@ -1,206 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* 🍳 数据库诊断脚本
|
||||
* 访问: http://eat.wktyl.com/api/diagnose.php
|
||||
*/
|
||||
|
||||
require '../zb_system/function/c_system_base.php';
|
||||
$zbp->Load();
|
||||
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
|
||||
function check($msg, $result, $detail = '') {
|
||||
$icon = $result ? '✅' : '❌';
|
||||
$color = $result ? 'green' : 'red';
|
||||
echo "<div style='margin:5px 0;padding:8px;background:" . ($result ? '#e8f5e9' : '#ffebee') . ";border-radius:4px;'>";
|
||||
echo "<span style='color:$color;font-size:18px;'>$icon</span> <strong>$msg</strong>";
|
||||
if ($detail) echo "<br><small style='color:#666;margin-left:28px;'>$detail</small>";
|
||||
echo "</div>";
|
||||
}
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>🍳 数据库诊断</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 900px; margin: 40px auto; padding: 20px; background: #f5f5f7; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 24px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
h1 { color: #1d1d1f; margin-bottom: 8px; }
|
||||
h2 { color: #0071e3; border-bottom: 2px solid #0071e3; padding-bottom: 8px; margin-top: 0; }
|
||||
.code { background: #1d1d1f; color: #f5f5f7; padding: 12px; border-radius: 8px; font-family: monospace; font-size: 13px; overflow-x: auto; margin: 8px 0; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
||||
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #e5e5e7; }
|
||||
th { background: #f5f5f7; font-weight: 600; }
|
||||
.ok { color: #34c759; }
|
||||
.err { color: #ff3b30; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>🍳 数据库诊断报告</h1>
|
||||
<p style="color:#666;">生成时间: <?php echo date('Y-m-d H:i:s'); ?></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>🔌 数据库连接</h2>
|
||||
<?php
|
||||
try {
|
||||
$sql = "SELECT 1";
|
||||
$result = $zbp->db->Query($sql);
|
||||
check("数据库连接", true, "类型: " . get_class($zbp->db) . ", 前缀: " . $zbp->db->dbpre);
|
||||
} catch (Exception $e) {
|
||||
check("数据库连接", false, $e->getMessage());
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>📋 数据表检查</h2>
|
||||
<?php
|
||||
$tables = ['Post', 'Category', 'Tag', 'recipe_ingredient'];
|
||||
foreach ($tables as $table) {
|
||||
try {
|
||||
$fullTable = $zbp->db->dbpre . $table;
|
||||
$sql = "SELECT COUNT(*) FROM $fullTable";
|
||||
$count = $zbp->db->Query($sql)[0]['COUNT(*)'] ?? 0;
|
||||
check("$fullTable 表", true, "$count 条记录");
|
||||
} catch (Exception $e) {
|
||||
check("$fullTable 表", false, $e->getMessage());
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>🍳 菜谱数据检查</h2>
|
||||
<?php
|
||||
// 检查公开菜谱
|
||||
$tablePost = $zbp->db->dbpre . 'Post';
|
||||
$sql = "SELECT COUNT(*) as cnt FROM $tablePost WHERE log_Type = 0 AND log_Status = 0";
|
||||
$publicCount = $zbp->db->Query($sql)[0]['cnt'] ?? 0;
|
||||
check("公开菜谱 (log_Type=0, log_Status=0)", $publicCount > 0, "数量: $publicCount");
|
||||
|
||||
// 检查ID范围
|
||||
$sql = "SELECT MIN(log_ID) as min_id, MAX(log_ID) as max_id FROM $tablePost WHERE log_Type = 0";
|
||||
$range = $zbp->db->Query($sql)[0];
|
||||
check("菜谱ID范围", true, "min={$range['min_id']}, max={$range['max_id']}");
|
||||
|
||||
// 检查示例菜谱
|
||||
$sql = "SELECT log_ID, log_Title, log_CateID FROM $tablePost WHERE log_Type = 0 AND log_Status = 0 LIMIT 3";
|
||||
$samples = $zbp->db->Query($sql);
|
||||
?>
|
||||
<h3 style="margin-top:16px;">示例菜谱数据:</h3>
|
||||
<table>
|
||||
<tr><th>ID</th><th>标题</th><th>分类ID</th></tr>
|
||||
<?php foreach ($samples as $s): ?>
|
||||
<tr>
|
||||
<td><?php echo $s['log_ID']; ?></td>
|
||||
<td><?php echo htmlspecialchars($s['log_Title']); ?></td>
|
||||
<td><?php echo $s['log_CateID']; ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php
|
||||
// 检查ID=70是否存在
|
||||
$sql = "SELECT log_ID, log_Title FROM $tablePost WHERE log_ID = 70 AND log_Type = 0";
|
||||
$id70 = $zbp->db->Query($sql);
|
||||
if ($id70) {
|
||||
check("ID=70 菜谱", true, "标题: " . $id70[0]['log_Title']);
|
||||
} else {
|
||||
check("ID=70 菜谱", false, "不存在或不是文章类型");
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>🥬 食材数据检查</h2>
|
||||
<?php
|
||||
$tableIng = $zbp->db->dbpre . 'recipe_ingredient';
|
||||
|
||||
// 检查唯一食材数量
|
||||
$sql = "SELECT COUNT(DISTINCT name) as cnt FROM $tableIng";
|
||||
$uniqueCount = $zbp->db->Query($sql)[0]['cnt'] ?? 0;
|
||||
check("食材种类数", $uniqueCount > 0, "$uniqueCount 种");
|
||||
|
||||
// 检查ID范围
|
||||
$sql = "SELECT MIN(ingredient_id) as min_id, MAX(ingredient_id) as max_id FROM $tableIng";
|
||||
$range = $zbp->db->Query($sql)[0];
|
||||
check("ingredient_id范围", true, "min={$range['min_id']}, max={$range['max_id']}");
|
||||
|
||||
// 检查示例食材
|
||||
$sql = "SELECT DISTINCT name, ingredient_id FROM $tableIng LIMIT 5";
|
||||
$samples = $zbp->db->Query($sql);
|
||||
?>
|
||||
<h3 style="margin-top:16px;">示例食材数据:</h3>
|
||||
<table>
|
||||
<tr><th>ingredient_id</th><th>名称</th></tr>
|
||||
<?php foreach ($samples as $s): ?>
|
||||
<tr>
|
||||
<td><?php echo $s['ingredient_id']; ?></td>
|
||||
<td><?php echo htmlspecialchars($s['name']); ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<?php
|
||||
// 检查ID=85是否存在
|
||||
$sql = "SELECT name FROM $tableIng WHERE ingredient_id = 85 LIMIT 1";
|
||||
$id85 = $zbp->db->Query($sql);
|
||||
if ($id85) {
|
||||
check("ID=85 食材", true, "名称: " . $id85[0]['name']);
|
||||
} else {
|
||||
check("ID=85 食材", false, "不存在");
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>🧪 API实际查询测试</h2>
|
||||
<?php
|
||||
// 模拟list接口查询
|
||||
$sql = "SELECT log_ID, log_Title FROM $tablePost WHERE log_Type = 0 AND log_Status = 0 ORDER BY log_PostTime DESC LIMIT 3";
|
||||
$listResult = $zbp->db->Query($sql);
|
||||
check("list接口查询", count($listResult) > 0, "返回 " . count($listResult) . " 条");
|
||||
|
||||
// 模拟ingredients接口查询
|
||||
$sql = "SELECT DISTINCT name, ingredient_id FROM $tableIng LIMIT 3";
|
||||
$ingResult = $zbp->db->Query($sql);
|
||||
check("ingredients接口查询", count($ingResult) > 0, "返回 " . count($ingResult) . " 条");
|
||||
?>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>💡 问题诊断建议</h2>
|
||||
<?php if ($publicCount == 0): ?>
|
||||
<div class="code">
|
||||
⚠️ 公开菜谱数量为0,可能原因:
|
||||
1. log_Status 字段值不是0 (0=公开, 1=草稿, 2=审核中)
|
||||
2. log_Type 字段值不是0 (0=文章, 1=页面)
|
||||
|
||||
修复SQL:
|
||||
UPDATE zbp_Post SET log_Status = 0 WHERE log_Type = 0;
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$id70): ?>
|
||||
<div class="code">
|
||||
⚠️ ID=70不存在,可用ID范围: <?php echo $range['min_id']; ?> ~ <?php echo $range['max_id']; ?>
|
||||
|
||||
测试可用的ID:
|
||||
?act=detail&id=<?php echo $range['min_id']; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$id85): ?>
|
||||
<div class="code">
|
||||
⚠️ ingredient_id=85不存在,可用范围: <?php echo $range['min_id'] ?? 'N/A'; ?> ~ <?php echo $range['max_id'] ?? 'N/A'; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="card" style="text-align:center;color:#86868b;">
|
||||
<p>🍳 菜谱API诊断工具 | Powered by Z-Blog PHP</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -28,6 +28,8 @@ import 'package:mom_kitchen/src/pages/tools/meal_planner_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/tools/ingredient_detail_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/tools/cooking_note_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/discover/category_browse_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/discover/ingredient_recommend_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/discover/ingredient_recipe_list_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/tools/eating_times_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/tools/weekly_menu_planner_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/profile/bedtime_reminder_page.dart';
|
||||
@@ -62,6 +64,8 @@ class AppRoutes {
|
||||
static const String toolsNutrition = '/tools/nutrition';
|
||||
static const String toolsConverter = '/tools/converter';
|
||||
static const String toolsIngredient = '/tools/ingredient';
|
||||
static const String toolsIngredientRecommend = '/tools/ingredient-recommend';
|
||||
static const String toolsIngredientRecipes = '/tools/ingredient-recipes';
|
||||
static const String toolsStats = '/tools/stats';
|
||||
static const String toolsPlanner = '/tools/planner';
|
||||
static const String cookingNote = '/cooking-note';
|
||||
@@ -226,6 +230,22 @@ class AppRoutes {
|
||||
page: () => const IngredientDetailPage(),
|
||||
middlewares: [PageStandardsMiddleware()],
|
||||
),
|
||||
GetPage(
|
||||
name: toolsIngredientRecommend,
|
||||
page: () => const IngredientRecommendPage(),
|
||||
middlewares: [PageStandardsMiddleware()],
|
||||
),
|
||||
GetPage(
|
||||
name: toolsIngredientRecipes,
|
||||
page: () {
|
||||
final args = Get.arguments as Map<String, dynamic>?;
|
||||
return IngredientRecipeListPage(
|
||||
ingredientId: args?['ingredientId'] ?? 0,
|
||||
ingredientName: args?['ingredientName'] ?? '',
|
||||
);
|
||||
},
|
||||
middlewares: [PageStandardsMiddleware()],
|
||||
),
|
||||
GetPage(
|
||||
name: toolsNutrition,
|
||||
page: () => const NutritionCenterPage(),
|
||||
@@ -250,8 +270,7 @@ class AppRoutes {
|
||||
page: () => CategoryBrowsePage(
|
||||
category: Get.arguments?['category'],
|
||||
title: Get.arguments?['title'] ?? '分类浏览',
|
||||
loadRecipesDirectly:
|
||||
Get.arguments?['loadRecipesDirectly'] ?? false,
|
||||
loadRecipesDirectly: Get.arguments?['loadRecipesDirectly'] ?? false,
|
||||
),
|
||||
middlewares: [PageStandardsMiddleware()],
|
||||
),
|
||||
@@ -523,6 +542,43 @@ class AppRoutes {
|
||||
],
|
||||
builder: () => const ServingScalerPage(),
|
||||
),
|
||||
PageInfo(
|
||||
route: toolsIngredient,
|
||||
name: 'Ingredient Detail Page',
|
||||
description: '食材详情页面',
|
||||
requiredStandards: const [
|
||||
StandardCheck.themeColors,
|
||||
StandardCheck.textColors,
|
||||
StandardCheck.fontSize,
|
||||
StandardCheck.darkMode,
|
||||
],
|
||||
builder: () => const IngredientDetailPage(),
|
||||
),
|
||||
PageInfo(
|
||||
route: toolsIngredientRecommend,
|
||||
name: 'Ingredient Recommend Page',
|
||||
description: '食材推荐页面',
|
||||
requiredStandards: const [
|
||||
StandardCheck.themeColors,
|
||||
StandardCheck.textColors,
|
||||
StandardCheck.fontSize,
|
||||
StandardCheck.darkMode,
|
||||
],
|
||||
builder: () => const IngredientRecommendPage(),
|
||||
),
|
||||
PageInfo(
|
||||
route: toolsIngredientRecipes,
|
||||
name: 'Ingredient Recipe List Page',
|
||||
description: '食材菜品列表页面',
|
||||
requiredStandards: const [
|
||||
StandardCheck.themeColors,
|
||||
StandardCheck.textColors,
|
||||
StandardCheck.fontSize,
|
||||
StandardCheck.darkMode,
|
||||
],
|
||||
builder: () =>
|
||||
const IngredientRecipeListPage(ingredientId: 0, ingredientName: ''),
|
||||
),
|
||||
PageInfo(
|
||||
route: toolsCenter,
|
||||
name: 'Tools Center Page',
|
||||
|
||||
@@ -149,13 +149,14 @@ class FavoritesController extends BaseController {
|
||||
}
|
||||
|
||||
void deleteSelected() {
|
||||
final count = selectedIds.length;
|
||||
for (final id in selectedIds.toList()) {
|
||||
_favorites.remove(id);
|
||||
_removeFromHive(id);
|
||||
}
|
||||
selectedIds.clear();
|
||||
isEditMode.value = false;
|
||||
ToastService.show(message: '已删除 ${selectedIds.length} 项收藏 💔');
|
||||
ToastService.show(message: '已删除 $count 项收藏 💔');
|
||||
}
|
||||
|
||||
void _saveToHive(FeedItemModel item) {
|
||||
|
||||
187
lib/src/controllers/recipe_detail_controller.dart
Normal file
187
lib/src/controllers/recipe_detail_controller.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:mom_kitchen/src/controllers/base_controller.dart';
|
||||
import 'package:mom_kitchen/src/controllers/feed/action_controller.dart';
|
||||
import 'package:mom_kitchen/src/controllers/favorites_controller.dart';
|
||||
import 'package:mom_kitchen/src/models/feed_item_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/services/ui/toast_service.dart';
|
||||
import 'package:mom_kitchen/src/utils/common_utils.dart';
|
||||
|
||||
class RecipeDetailController extends BaseController {
|
||||
final RecipeRepository _recipeRepository = RecipeRepository();
|
||||
final ActionController _actionController = Get.find<ActionController>();
|
||||
final FavoritesController _favoritesController = Get.find<FavoritesController>();
|
||||
|
||||
// 响应式状态
|
||||
final Rx<RecipeModel?> recipe = Rx<RecipeModel?>(null);
|
||||
final RxBool isFavorite = false.obs;
|
||||
final RxInt likeCount = 0.obs;
|
||||
final RxInt viewCount = 0.obs;
|
||||
final RxBool loadTimeout = false.obs;
|
||||
|
||||
static const Duration _loadTimeoutDuration = Duration(seconds: 8);
|
||||
|
||||
Future<void> loadRecipe(String recipeId) async {
|
||||
await runWithLoading(() async {
|
||||
loadTimeout.value = false;
|
||||
|
||||
Future.delayed(_loadTimeoutDuration, () {
|
||||
if (isLoading.value) {
|
||||
loadTimeout.value = true;
|
||||
debugPrint('⏰ 详情页加载超时 (${_loadTimeoutDuration.inSeconds}秒)');
|
||||
}
|
||||
});
|
||||
|
||||
final id = int.tryParse(recipeId) ?? 0;
|
||||
final loadedRecipe = await _recipeRepository.fetchFull(
|
||||
id,
|
||||
viewnums: true,
|
||||
);
|
||||
|
||||
debugPrint('🔍 菜谱数据加载成功: id=${loadedRecipe.id}, title=${loadedRecipe.title}');
|
||||
debugPrint('🔍 PicId原始值: ${loadedRecipe.picId}, cover=${loadedRecipe.cover}');
|
||||
debugPrint('🔍 Recipe其他字段: code=${loadedRecipe.code}, status=${loadedRecipe.status}');
|
||||
|
||||
recipe.value = loadedRecipe;
|
||||
likeCount.value = loadedRecipe.statistics?.likes ?? 0;
|
||||
viewCount.value = loadedRecipe.statistics?.views ?? 0;
|
||||
|
||||
await _checkFavorite();
|
||||
_recordView();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> refreshRecipe(String recipeId) async {
|
||||
await runWithLoading(() async {
|
||||
final id = int.tryParse(recipeId) ?? 0;
|
||||
final loadedRecipe = await _recipeRepository.fetchFull(
|
||||
id,
|
||||
viewnums: false,
|
||||
);
|
||||
|
||||
recipe.value = loadedRecipe;
|
||||
likeCount.value = loadedRecipe.statistics?.likes ?? 0;
|
||||
viewCount.value = loadedRecipe.statistics?.views ?? 0;
|
||||
await _checkFavorite();
|
||||
ToastService.show(message: '✅ 刷新成功');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _checkFavorite() async {
|
||||
if (recipe.value != null) {
|
||||
try {
|
||||
isFavorite.value = _favoritesController.isFavorited(recipe.value!.id);
|
||||
} catch (e) {
|
||||
debugPrint('检查收藏状态失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _recordView() {
|
||||
if (recipe.value != null) {
|
||||
_actionController.reportView(id: recipe.value!.id, type: 'recipe');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleFavorite() async {
|
||||
if (recipe.value != null) {
|
||||
try {
|
||||
final feedItem = FeedItemModel.fromRecipe(recipe.value!);
|
||||
await _favoritesController.toggleFavorite(feedItem);
|
||||
isFavorite.value = !isFavorite.value;
|
||||
} catch (e) {
|
||||
debugPrint('切换收藏失败:$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> likeRecipe() async {
|
||||
if (recipe.value != null) {
|
||||
try {
|
||||
final wasLiked = _actionController.isLiked(recipe.value!.id);
|
||||
likeCount.value += wasLiked ? -1 : 1;
|
||||
await _actionController.likeItem(id: recipe.value!.id, type: 'recipe');
|
||||
} catch (e) {
|
||||
debugPrint('点赞失败:$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> recommendRecipe({int? score}) async {
|
||||
if (recipe.value != null) {
|
||||
try {
|
||||
await _actionController.recommendItem(
|
||||
id: recipe.value!.id,
|
||||
type: 'recipe',
|
||||
score: score,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('推荐失败:$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool get isRecommended => _actionController.isRecommended(recipe.value?.id ?? 0);
|
||||
|
||||
bool get isLiked => _actionController.isLiked(recipe.value?.id ?? 0);
|
||||
|
||||
Future<void> addToShoppingList(String recipeId) async {
|
||||
if (recipe.value == null || recipe.value!.ingredients.isEmpty) {
|
||||
throw Exception('该菜谱暂无食材信息');
|
||||
}
|
||||
|
||||
try {
|
||||
final controller = Get.find<dynamic>(); // ShoppingListController
|
||||
final id = int.tryParse(recipeId) ?? 0;
|
||||
final items = recipe.value!.ingredients.map((ing) {
|
||||
return {
|
||||
'name': ing.name,
|
||||
'amount': ing.amount,
|
||||
'unit': ing.unit,
|
||||
'category': ing.category,
|
||||
'recipeId': id,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
// 使用动态调用以避免强类型依赖
|
||||
await controller.addItemsFromRecipe(id, recipe.value!.title, items);
|
||||
} catch (e) {
|
||||
debugPrint('添加到购物清单失败: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void shareRecipe() {
|
||||
if (recipe.value == null) return;
|
||||
|
||||
final ingredients = recipe.value!.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.value!.title}');
|
||||
shareText.writeln('');
|
||||
if (ingredients.isNotEmpty) {
|
||||
shareText.writeln('📋 食材:');
|
||||
shareText.writeln(ingredients);
|
||||
shareText.writeln('');
|
||||
}
|
||||
if (recipe.value!.content != null && recipe.value!.content!.isNotEmpty) {
|
||||
shareText.writeln('👩🍳 做法:');
|
||||
shareText.writeln(recipe.value!.content);
|
||||
}
|
||||
shareText.writeln('');
|
||||
shareText.write('— 来自 老妈厨房 App 🍳');
|
||||
|
||||
CommonUtils.shareContent(
|
||||
shareText.toString(),
|
||||
subject: '🍳 ${recipe.value!.title}',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* 名称: 工具中心控制器
|
||||
* 作用: 管理工具列表、使用频率统计、搜索过滤
|
||||
* 更新: 2026-04-10 初始创建
|
||||
* 更新: 2026-04-12 使用compute修复JSON解析卡死问题
|
||||
*/
|
||||
|
||||
import 'dart:convert';
|
||||
@@ -12,6 +13,15 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:mom_kitchen/src/controllers/base_controller.dart';
|
||||
import 'package:mom_kitchen/src/models/tool_item_model.dart';
|
||||
|
||||
/// Isolate-safe function to parse JSON, preventing UI thread blockage.
|
||||
Map<String, int> _parseUsageData(String data) {
|
||||
try {
|
||||
return Map<String, int>.from(json.decode(data));
|
||||
} catch (e) {
|
||||
return <String, int>{};
|
||||
}
|
||||
}
|
||||
|
||||
class ToolsController extends BaseController {
|
||||
static const String _usageKey = 'tool_usage_counts';
|
||||
|
||||
@@ -36,7 +46,7 @@ class ToolsController extends BaseController {
|
||||
super.onInit();
|
||||
// 先使用默认工具列表初始化,避免阻塞
|
||||
tools.value = ToolRegistry.defaultTools;
|
||||
filteredTools.value = tools;
|
||||
filteredTools.assignAll(tools);
|
||||
// 异步加载使用统计数据
|
||||
_loadUsageData();
|
||||
}
|
||||
@@ -48,11 +58,8 @@ class ToolsController extends BaseController {
|
||||
Map<String, int> usageMap = <String, int>{};
|
||||
|
||||
if (usageData != null && usageData.isNotEmpty) {
|
||||
try {
|
||||
usageMap = Map<String, int>.from(json.decode(usageData));
|
||||
} catch (e) {
|
||||
debugPrint('ToolsController: Failed to parse usage data: $e');
|
||||
}
|
||||
// 使用 compute 在独立 isolate 中解析 JSON,防止 UI 线程阻塞
|
||||
usageMap = await compute(_parseUsageData, usageData);
|
||||
}
|
||||
|
||||
tools.value = ToolRegistry.defaultTools.map((tool) {
|
||||
@@ -162,7 +169,11 @@ class ToolsController extends BaseController {
|
||||
|
||||
Future<void> openTool(ToolItem tool) async {
|
||||
await recordUsage(tool.id);
|
||||
Get.toNamed(tool.route);
|
||||
try {
|
||||
Get.toNamed(tool.route);
|
||||
} catch (e) {
|
||||
debugPrint('ToolsController: Navigation error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> resetUsageData() async {
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
* 名称: 分类浏览页面
|
||||
* 作用: 分类层级导航,大类→小类→菜谱列表
|
||||
* 创建: 2026-04-11
|
||||
* 更新: 2026-04-11 初始创建
|
||||
* 更新: 2026-04-12 重写子分类为列表布局
|
||||
*/
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:mom_kitchen/src/config/design_tokens.dart';
|
||||
import 'package:mom_kitchen/src/models/recipe/category_model.dart';
|
||||
@@ -52,14 +53,13 @@ class _CategoryBrowsePageState extends State<CategoryBrowsePage> {
|
||||
} else if (widget.category != null &&
|
||||
widget.category!.children.isNotEmpty) {
|
||||
_categories = widget.category!.children;
|
||||
if (_categories.isNotEmpty) {
|
||||
await _loadRecipes(_categories.first.id);
|
||||
_selectedSubCategory = _categories.first;
|
||||
}
|
||||
_selectedSubCategory = null;
|
||||
_recipes = [];
|
||||
} else {
|
||||
_categories = await _repo.fetchCategories();
|
||||
if (_categories.isNotEmpty && widget.category != null) {
|
||||
await _loadRecipes(_categories.first.id);
|
||||
_selectedSubCategory = null;
|
||||
_recipes = [];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -139,6 +139,9 @@ class _CategoryBrowsePageState extends State<CategoryBrowsePage> {
|
||||
}
|
||||
|
||||
Widget _buildContent(bool isDark) {
|
||||
if (widget.loadRecipesDirectly) {
|
||||
return _buildRecipeList(isDark);
|
||||
}
|
||||
if (widget.category != null && widget.category!.children.isNotEmpty) {
|
||||
return _buildSubCategoryView(isDark);
|
||||
}
|
||||
@@ -248,6 +251,41 @@ class _CategoryBrowsePageState extends State<CategoryBrowsePage> {
|
||||
Widget _buildSubCategoryView(bool isDark) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: DesignTokens.space4,
|
||||
top: DesignTokens.space2,
|
||||
bottom: DesignTokens.space2,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
CupertinoIcons.folder_open,
|
||||
size: 18,
|
||||
color: DesignTokens.primary,
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space2),
|
||||
Text(
|
||||
'子分类列表',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Obx(
|
||||
() => Text(
|
||||
'${_categories.length} 个分类',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: ListView.separated(
|
||||
@@ -256,7 +294,7 @@ class _CategoryBrowsePageState extends State<CategoryBrowsePage> {
|
||||
horizontal: DesignTokens.space4,
|
||||
),
|
||||
itemCount: _categories.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(width: DesignTokens.space2),
|
||||
itemBuilder: (context, index) {
|
||||
final cat = _categories[index];
|
||||
@@ -319,37 +357,238 @@ class _CategoryBrowsePageState extends State<CategoryBrowsePage> {
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space3),
|
||||
Expanded(
|
||||
child: _recipes.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
child: _selectedSubCategory == null
|
||||
? _buildSubCategoryList(isDark)
|
||||
: _recipes.isEmpty
|
||||
? _buildEmptyRecipeState(isDark)
|
||||
: _buildRecipeListView(isDark),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubCategoryList(bool isDark) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4),
|
||||
itemCount: _categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final cat = _categories[index];
|
||||
return _buildSubCategoryListItem(cat, isDark, index);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubCategoryListItem(CategoryModel cat, bool isDark, int index) {
|
||||
final hasChildren = cat.children.isNotEmpty;
|
||||
final hasRecipes = cat.count != null && cat.count! > 0;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (hasChildren || hasRecipes) {
|
||||
setState(() => _selectedSubCategory = cat);
|
||||
_loadRecipes(cat.id);
|
||||
} else {
|
||||
Get.toNamed(
|
||||
'/category-browse',
|
||||
arguments: {'category': cat, 'title': cat.name},
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: DesignTokens.space2 + 4),
|
||||
padding: const EdgeInsets.all(DesignTokens.space3),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? DarkDesignTokens.glassBorder
|
||||
: DesignTokens.text3.withValues(alpha: 0.08),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: isDark ? 0.15 : 0.05),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
_getGradientColor(index).withValues(alpha: 0.2),
|
||||
_getGradientColor(index).withValues(alpha: 0.08),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
cat.displayIcon,
|
||||
style: const TextStyle(fontSize: 26),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
cat.name,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text1
|
||||
: DesignTokens.text1,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Text('🍽️', style: TextStyle(fontSize: 48)),
|
||||
const SizedBox(height: DesignTokens.space3),
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: _getGradientColor(index),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'暂无菜谱',
|
||||
hasChildren
|
||||
? '${cat.children.length} 个子类'
|
||||
: (hasRecipes ? '${cat.count} 道菜谱' : '点击查看'),
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text2
|
||||
: DesignTokens.text2,
|
||||
? DarkDesignTokens.text3
|
||||
: DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space4,
|
||||
),
|
||||
itemCount: _recipes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final recipe = _recipes[index];
|
||||
return _buildRecipeCard(recipe, isDark);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: _getGradientColor(index).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
CupertinoIcons.chevron_right,
|
||||
size: 16,
|
||||
color: _getGradientColor(index),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getGradientColor(int index) {
|
||||
final colors = [
|
||||
const Color(0xFFFF6B35),
|
||||
const Color(0xFF2ECC71),
|
||||
const Color(0xFF3498DB),
|
||||
const Color(0xFF9B59B6),
|
||||
const Color(0xFFF39C12),
|
||||
const Color(0xFFE74C3C),
|
||||
const Color(0xFF1ABC9C),
|
||||
const Color(0xFF34495E),
|
||||
];
|
||||
return colors[index % colors.length];
|
||||
}
|
||||
|
||||
Widget _buildEmptyRecipeState(bool isDark) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('📂', style: TextStyle(fontSize: 56)),
|
||||
const SizedBox(height: DesignTokens.space4),
|
||||
Text(
|
||||
'暂无菜谱数据',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontLg,
|
||||
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (_selectedSubCategory != null) {
|
||||
_loadRecipes(_selectedSubCategory!.id);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space4,
|
||||
vertical: DesignTokens.space2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: DesignTokens.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
CupertinoIcons.refresh,
|
||||
size: 16,
|
||||
color: DesignTokens.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'刷新',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontSm,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: DesignTokens.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecipeList(bool isDark) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4),
|
||||
itemCount: _recipes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final recipe = _recipes[index];
|
||||
return _buildRecipeCard(recipe, isDark);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecipeListView(bool isDark) {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4),
|
||||
itemCount: _recipes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final recipe = _recipes[index];
|
||||
return _buildRecipeCard(recipe, isDark);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
425
lib/src/pages/discover/ingredient_recipe_list_page.dart
Normal file
425
lib/src/pages/discover/ingredient_recipe_list_page.dart
Normal file
@@ -0,0 +1,425 @@
|
||||
/*
|
||||
* 文件: ingredient_recipe_list_page.dart
|
||||
* 名称: 食材菜品列表页面
|
||||
* 作用: 显示某食材相关的菜品列表,支持分页加载
|
||||
* 创建: 2026-04-11
|
||||
* 更新: 2026-04-11 初始创建,使用 api.php?act=search 查询食材相关菜品
|
||||
*/
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:mom_kitchen/src/config/api_config.dart';
|
||||
import 'package:mom_kitchen/src/config/design_tokens.dart';
|
||||
import 'package:mom_kitchen/src/models/api_response.dart';
|
||||
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
|
||||
import 'package:mom_kitchen/src/services/api/api_service.dart';
|
||||
import 'package:mom_kitchen/src/widgets/recipe_image.dart';
|
||||
|
||||
class IngredientRecipeListPage extends StatefulWidget {
|
||||
final int ingredientId;
|
||||
final String ingredientName;
|
||||
|
||||
const IngredientRecipeListPage({
|
||||
super.key,
|
||||
required this.ingredientId,
|
||||
required this.ingredientName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<IngredientRecipeListPage> createState() =>
|
||||
_IngredientRecipeListPageState();
|
||||
}
|
||||
|
||||
class _IngredientRecipeListPageState extends State<IngredientRecipeListPage> {
|
||||
final ApiService _api = ApiService();
|
||||
List<RecipeModel> _recipes = [];
|
||||
bool _isLoading = true;
|
||||
bool _isLoadingMore = false;
|
||||
int _currentPage = 1;
|
||||
bool _hasMore = true;
|
||||
String? _error;
|
||||
|
||||
static const int _pageSize = 20;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRecipes();
|
||||
}
|
||||
|
||||
Future<void> _loadRecipes() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await _api.get(
|
||||
ApiConfig.recipe,
|
||||
queryParameters: {
|
||||
'act': 'search',
|
||||
'keyword': widget.ingredientName,
|
||||
'type': 'recipe',
|
||||
'page': 1,
|
||||
'limit': _pageSize,
|
||||
},
|
||||
);
|
||||
|
||||
final List<RecipeModel> items = [];
|
||||
if (response.data != null) {
|
||||
Map<String, dynamic> responseMap;
|
||||
if (response.data is Map<String, dynamic>) {
|
||||
responseMap = response.data as Map<String, dynamic>;
|
||||
} else if (response.data is Map) {
|
||||
responseMap = Map<String, dynamic>.from(response.data as Map);
|
||||
} else {
|
||||
throw Exception('Invalid response format');
|
||||
}
|
||||
|
||||
final apiResponse = ApiResponse.fromJson(responseMap, (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final pageData = data['list'] ?? data['data'] ?? [];
|
||||
if (pageData is List) {
|
||||
return pageData.map((e) {
|
||||
if (e is Map<String, dynamic>) {
|
||||
return RecipeModel.fromJson(e);
|
||||
} else if (e is Map) {
|
||||
return RecipeModel.fromJson(Map<String, dynamic>.from(e));
|
||||
}
|
||||
throw Exception('Invalid item format');
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
if (data is List) {
|
||||
return data.map((e) {
|
||||
if (e is Map<String, dynamic>) {
|
||||
return RecipeModel.fromJson(e);
|
||||
} else if (e is Map) {
|
||||
return RecipeModel.fromJson(Map<String, dynamic>.from(e));
|
||||
}
|
||||
throw Exception('Invalid item format');
|
||||
}).toList();
|
||||
}
|
||||
throw Exception('Invalid data format');
|
||||
});
|
||||
|
||||
if (apiResponse.isSuccess && apiResponse.data != null) {
|
||||
items.addAll(apiResponse.data as List<RecipeModel>);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_recipes = items;
|
||||
_currentPage = 1;
|
||||
_hasMore = items.length >= _pageSize;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Load ingredient recipes error: $e');
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_isLoadingMore || !_hasMore) return;
|
||||
|
||||
setState(() => _isLoadingMore = true);
|
||||
|
||||
try {
|
||||
final response = await _api.get(
|
||||
ApiConfig.recipe,
|
||||
queryParameters: {
|
||||
'act': 'search',
|
||||
'keyword': widget.ingredientName,
|
||||
'type': 'recipe',
|
||||
'page': _currentPage + 1,
|
||||
'limit': _pageSize,
|
||||
},
|
||||
);
|
||||
|
||||
final List<RecipeModel> items = [];
|
||||
if (response.data != null) {
|
||||
Map<String, dynamic> responseMap;
|
||||
if (response.data is Map<String, dynamic>) {
|
||||
responseMap = response.data as Map<String, dynamic>;
|
||||
} else if (response.data is Map) {
|
||||
responseMap = Map<String, dynamic>.from(response.data as Map);
|
||||
} else {
|
||||
throw Exception('Invalid response format');
|
||||
}
|
||||
|
||||
final apiResponse = ApiResponse.fromJson(responseMap, (data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
final pageData = data['list'] ?? data['data'] ?? [];
|
||||
if (pageData is List) {
|
||||
return pageData.map((e) {
|
||||
if (e is Map<String, dynamic>) {
|
||||
return RecipeModel.fromJson(e);
|
||||
} else if (e is Map) {
|
||||
return RecipeModel.fromJson(Map<String, dynamic>.from(e));
|
||||
}
|
||||
throw Exception('Invalid item format');
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
if (data is List) {
|
||||
return data.map((e) {
|
||||
if (e is Map<String, dynamic>) {
|
||||
return RecipeModel.fromJson(e);
|
||||
} else if (e is Map) {
|
||||
return RecipeModel.fromJson(Map<String, dynamic>.from(e));
|
||||
}
|
||||
throw Exception('Invalid item format');
|
||||
}).toList();
|
||||
}
|
||||
throw Exception('Invalid data format');
|
||||
});
|
||||
|
||||
if (apiResponse.isSuccess && apiResponse.data != null) {
|
||||
items.addAll(apiResponse.data as List<RecipeModel>);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_recipes.addAll(items);
|
||||
_currentPage++;
|
||||
_hasMore = items.length >= _pageSize;
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Load more ingredient recipes error: $e');
|
||||
setState(() => _isLoadingMore = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: isDark
|
||||
? DarkDesignTokens.background
|
||||
: DesignTokens.background,
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(
|
||||
'🍳 ${widget.ingredientName}',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
|
||||
),
|
||||
),
|
||||
backgroundColor: isDark
|
||||
? DarkDesignTokens.background.withValues(alpha: 0.9)
|
||||
: DesignTokens.background.withValues(alpha: 0.9),
|
||||
border: null,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: _isLoading
|
||||
? const Center(child: CupertinoActivityIndicator())
|
||||
: _error != null
|
||||
? _buildErrorState(isDark)
|
||||
: _recipes.isEmpty
|
||||
? _buildEmptyState(isDark)
|
||||
: _buildContent(isDark),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorState(bool isDark) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('❌', style: TextStyle(fontSize: 56)),
|
||||
const SizedBox(height: DesignTokens.space4),
|
||||
Text(
|
||||
'加载失败',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontLg,
|
||||
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
CupertinoButton(onPressed: _loadRecipes, child: const Text('重新加载')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(bool isDark) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('🍽️', style: TextStyle(fontSize: 56)),
|
||||
const SizedBox(height: DesignTokens.space4),
|
||||
Text(
|
||||
'暂无相关菜品',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontLg,
|
||||
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
Text(
|
||||
'换个食材试试吧',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontSm,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(bool isDark) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification is ScrollEndNotification &&
|
||||
notification.metrics.extentAfter < 200) {
|
||||
_loadMore();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(DesignTokens.space4),
|
||||
itemCount: _recipes.length + (_isLoadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= _recipes.length) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(DesignTokens.space3),
|
||||
child: Center(child: CupertinoActivityIndicator(radius: 10)),
|
||||
);
|
||||
}
|
||||
return _buildRecipeCard(_recipes[index], isDark);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecipeCard(RecipeModel recipe, bool isDark) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Get.toNamed(
|
||||
'/recipe-detail',
|
||||
arguments: {'recipeId': recipe.id.toString()},
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 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.08),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(8),
|
||||
bottomLeft: Radius.circular(8),
|
||||
),
|
||||
child: RecipeImage(
|
||||
recipeId: recipe.id,
|
||||
coverUrl: recipe.cover,
|
||||
width: 120,
|
||||
height: 100,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(DesignTokens.space3),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
recipe.title,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text1
|
||||
: DesignTokens.text1,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space1),
|
||||
if (recipe.intro?.isNotEmpty ?? false)
|
||||
Text(
|
||||
recipe.intro!,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontSm,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text3
|
||||
: DesignTokens.text3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
Row(
|
||||
children: [
|
||||
if (recipe.categoryName?.isNotEmpty ?? false) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
(isDark
|
||||
? DarkDesignTokens.primary
|
||||
: DesignTokens.primary)
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
recipe.categoryName!,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: isDark
|
||||
? DarkDesignTokens.primary
|
||||
: DesignTokens.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
if (recipe.statistics?.views != null)
|
||||
Row(
|
||||
children: [
|
||||
const Text('👁️', style: TextStyle(fontSize: 12)),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'${recipe.statistics!.views}',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text3
|
||||
: DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
266
lib/src/pages/discover/ingredient_recommend_page.dart
Normal file
266
lib/src/pages/discover/ingredient_recommend_page.dart
Normal file
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* 文件: ingredient_recommend_page.dart
|
||||
* 名称: 食材推荐页面
|
||||
* 作用: 展示热门食材列表,点击跳转该食材对应的菜品列表
|
||||
* 创建: 2026-04-11
|
||||
* 更新: 2026-04-11 初始创建,从 api.php?act=ingredients 加载食材列表
|
||||
*/
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:mom_kitchen/src/config/design_tokens.dart';
|
||||
import 'package:mom_kitchen/src/models/recipe/ingredient_model.dart';
|
||||
import 'package:mom_kitchen/src/repositories/recipe_repository.dart';
|
||||
|
||||
class IngredientRecommendPage extends StatefulWidget {
|
||||
const IngredientRecommendPage({super.key});
|
||||
|
||||
@override
|
||||
State<IngredientRecommendPage> createState() =>
|
||||
_IngredientRecommendPageState();
|
||||
}
|
||||
|
||||
class _IngredientRecommendPageState extends State<IngredientRecommendPage> {
|
||||
final RecipeRepository _repo = RecipeRepository();
|
||||
List<IngredientModel> _ingredients = [];
|
||||
bool _isLoading = true;
|
||||
bool _isLoadingMore = false;
|
||||
int _currentPage = 1;
|
||||
bool _hasMore = true;
|
||||
|
||||
static const int _pageSize = 20;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadIngredients();
|
||||
}
|
||||
|
||||
Future<void> _loadIngredients() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final result = await _repo.fetchIngredients(
|
||||
page: 1,
|
||||
limit: _pageSize,
|
||||
);
|
||||
setState(() {
|
||||
_ingredients = result.items;
|
||||
_currentPage = 1;
|
||||
_hasMore = result.items.length >= _pageSize;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Load ingredients error: $e');
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadMore() async {
|
||||
if (_isLoadingMore || !_hasMore) return;
|
||||
|
||||
setState(() => _isLoadingMore = true);
|
||||
try {
|
||||
final result = await _repo.fetchIngredients(
|
||||
page: _currentPage + 1,
|
||||
limit: _pageSize,
|
||||
);
|
||||
setState(() {
|
||||
_ingredients.addAll(result.items);
|
||||
_currentPage++;
|
||||
_hasMore = result.items.length >= _pageSize;
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Load more ingredients error: $e');
|
||||
setState(() => _isLoadingMore = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor:
|
||||
isDark ? DarkDesignTokens.background : DesignTokens.background,
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: Text(
|
||||
'🥬 食材推荐',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
|
||||
),
|
||||
),
|
||||
backgroundColor: isDark
|
||||
? DarkDesignTokens.background.withValues(alpha: 0.9)
|
||||
: DesignTokens.background.withValues(alpha: 0.9),
|
||||
border: null,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: _isLoading
|
||||
? const Center(child: CupertinoActivityIndicator())
|
||||
: _ingredients.isEmpty
|
||||
? _buildEmptyState(isDark)
|
||||
: _buildContent(isDark),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(bool isDark) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('🥬', style: TextStyle(fontSize: 56)),
|
||||
const SizedBox(height: DesignTokens.space4),
|
||||
Text(
|
||||
'暂无食材数据',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontLg,
|
||||
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(bool isDark) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification is ScrollEndNotification &&
|
||||
notification.metrics.extentAfter < 200) {
|
||||
_loadMore();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(DesignTokens.space4),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: DesignTokens.space3,
|
||||
crossAxisSpacing: DesignTokens.space3,
|
||||
childAspectRatio: 0.85,
|
||||
),
|
||||
itemCount: _ingredients.length + (_isLoadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= _ingredients.length) {
|
||||
return const Center(
|
||||
child: CupertinoActivityIndicator(radius: 10),
|
||||
);
|
||||
}
|
||||
return _buildIngredientCard(_ingredients[index], isDark);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIngredientCard(IngredientModel ingredient, bool isDark) {
|
||||
final emoji = _getIngredientEmoji(ingredient.name);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Get.toNamed(
|
||||
'/tools/ingredient-recipes',
|
||||
arguments: {
|
||||
'ingredientId': ingredient.id,
|
||||
'ingredientName': ingredient.name,
|
||||
},
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
|
||||
borderRadius: DesignTokens.borderRadiusMd,
|
||||
border: Border.all(
|
||||
color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
|
||||
.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
emoji,
|
||||
style: const TextStyle(fontSize: 32),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space1),
|
||||
child: Text(
|
||||
ingredient.name,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getIngredientEmoji(String name) {
|
||||
if (name.contains('鸡') || name.contains('蛋')) return '🥚';
|
||||
if (name.contains('猪') || name.contains('肉')) return '🥩';
|
||||
if (name.contains('牛') || name.contains('羊')) return '🥩';
|
||||
if (name.contains('鱼')) return '🐟';
|
||||
if (name.contains('虾')) return '🦐';
|
||||
if (name.contains('蟹')) return '🦀';
|
||||
if (name.contains('贝') || name.contains('螺')) return '🦪';
|
||||
if (name.contains('菜') || name.contains('蔬')) return '🥬';
|
||||
if (name.contains('萝卜') || name.contains('根')) return '🥕';
|
||||
if (name.contains('土豆') || name.contains('薯')) return '🥔';
|
||||
if (name.contains('番茄') || name.contains('西红柿')) return '🍅';
|
||||
if (name.contains('茄子')) return '🍆';
|
||||
if (name.contains('黄瓜')) return '🥒';
|
||||
if (name.contains('玉米')) return '🌽';
|
||||
if (name.contains('豆') && !name.contains('豆')) return '🫘';
|
||||
if (name.contains('豆腐')) return '🧈';
|
||||
if (name.contains('蘑菇') || name.contains('菇')) return '🍄';
|
||||
if (name.contains('木耳') || name.contains('银耳')) return '🍄';
|
||||
if (name.contains('葱') || name.contains('蒜') || name.contains('姜')) {
|
||||
return '🧅';
|
||||
}
|
||||
if (name.contains('辣椒') || name.contains('椒')) return '🌶️';
|
||||
if (name.contains('白菜') || name.contains('青菜')) return '🥬';
|
||||
if (name.contains('菠菜')) return '🥬';
|
||||
if (name.contains('芹菜')) return '🥬';
|
||||
if (name.contains('南瓜')) return '🎃';
|
||||
if (name.contains('西瓜') || name.contains('瓜')) return '🍈';
|
||||
if (name.contains('苹果')) return '🍎';
|
||||
if (name.contains('香蕉')) return '🍌';
|
||||
if (name.contains('橙') || name.contains('橘')) return '🍊';
|
||||
if (name.contains('葡萄')) return '🍇';
|
||||
if (name.contains('草莓')) return '🍓';
|
||||
if (name.contains('芒果')) return '🥭';
|
||||
if (name.contains('菠萝')) return '🍍';
|
||||
if (name.contains('大米') || name.contains('米')) return '🍚';
|
||||
if (name.contains('面') || name.contains('粉')) return '🍜';
|
||||
if (name.contains('面包')) return '🍞';
|
||||
if (name.contains('蛋糕')) return '🍰';
|
||||
if (name.contains('奶') || name.contains('乳')) return '🥛';
|
||||
if (name.contains('芝士')) return '🧀';
|
||||
if (name.contains('黄油')) return '🧈';
|
||||
if (name.contains('油')) return '🛢️';
|
||||
if (name.contains('盐')) return '🧂';
|
||||
if (name.contains('糖')) return '🍬';
|
||||
if (name.contains('蜂蜜')) return '🍯';
|
||||
if (name.contains('茶')) return '🍵';
|
||||
if (name.contains('咖啡')) return '☕';
|
||||
if (name.contains('酒') || name.contains('料酒')) return '🍶';
|
||||
if (name.contains('酱油') || name.contains('生抽') || name.contains('老抽')) {
|
||||
return '🥫';
|
||||
}
|
||||
if (name.contains('醋')) return '🍶';
|
||||
if (name.contains('淀粉')) return '🧂';
|
||||
return '🥬';
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,8 @@ class _HomePageState extends State<HomePage> {
|
||||
_isLoadingRecommendations.value = true;
|
||||
try {
|
||||
final recommendationService = Get.find<RecommendationService>();
|
||||
final recommendations = await recommendationService.getPersonalizedRecommendations(limit: 10);
|
||||
final recommendations = await recommendationService
|
||||
.getPersonalizedRecommendations(limit: 10);
|
||||
_recommendedRecipes.value = recommendations;
|
||||
} catch (e) {
|
||||
debugPrint('Load recommendations error: $e');
|
||||
@@ -409,7 +410,9 @@ class _HomePageState extends State<HomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
final currentRecipes = _currentTab == 'today' ? _recipes : _recommendedRecipes;
|
||||
final currentRecipes = _currentTab == 'today'
|
||||
? _recipes
|
||||
: _recommendedRecipes;
|
||||
|
||||
final List<Widget> sliverList = <Widget>[
|
||||
CupertinoSliverRefreshControl(
|
||||
@@ -422,9 +425,7 @@ class _HomePageState extends State<HomePage> {
|
||||
),
|
||||
SliverToBoxAdapter(child: const NutritionDashboardCard()),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: DesignTokens.space4)),
|
||||
SliverToBoxAdapter(
|
||||
child: _buildTabSwitcher(isDark),
|
||||
),
|
||||
SliverToBoxAdapter(child: _buildTabSwitcher(isDark)),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: DesignTokens.space3)),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
@@ -459,7 +460,7 @@ class _HomePageState extends State<HomePage> {
|
||||
child: Center(child: CupertinoActivityIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
if (currentRecipes.isEmpty) {
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
@@ -473,13 +474,15 @@ class _HomePageState extends State<HomePage> {
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space3),
|
||||
Text(
|
||||
_currentTab == 'recommended'
|
||||
_currentTab == 'recommended'
|
||||
? '根据你的偏好推荐菜谱\n请先设置偏好或浏览更多菜谱'
|
||||
: '暂无推荐',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text2
|
||||
: DesignTokens.text2,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -534,6 +537,7 @@ class _HomePageState extends State<HomePage> {
|
||||
horizontal: DesignTokens.space4,
|
||||
),
|
||||
children: [
|
||||
_buildIngredientRecommendCard(isDark),
|
||||
_buildCategoryCard('🍖 荤菜', DesignTokens.red, isDark),
|
||||
_buildCategoryCard('🥬 素菜', DesignTokens.green, isDark),
|
||||
_buildCategoryCard('🍜 面食', DesignTokens.orange, isDark),
|
||||
@@ -577,7 +581,9 @@ class _HomePageState extends State<HomePage> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: _currentTab == 'today'
|
||||
? (isDark ? DarkDesignTokens.primary : DesignTokens.primary)
|
||||
? (isDark
|
||||
? DarkDesignTokens.primary
|
||||
: DesignTokens.primary)
|
||||
: CupertinoColors.transparent,
|
||||
borderRadius: BorderRadius.circular(DesignTokens.radiusMd),
|
||||
),
|
||||
@@ -589,7 +595,9 @@ class _HomePageState extends State<HomePage> {
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _currentTab == 'today'
|
||||
? CupertinoColors.white
|
||||
: (isDark ? DarkDesignTokens.text2 : DesignTokens.text2),
|
||||
: (isDark
|
||||
? DarkDesignTokens.text2
|
||||
: DesignTokens.text2),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -607,7 +615,9 @@ class _HomePageState extends State<HomePage> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: _currentTab == 'recommended'
|
||||
? (isDark ? DarkDesignTokens.primary : DesignTokens.primary)
|
||||
? (isDark
|
||||
? DarkDesignTokens.primary
|
||||
: DesignTokens.primary)
|
||||
: CupertinoColors.transparent,
|
||||
borderRadius: BorderRadius.circular(DesignTokens.radiusMd),
|
||||
),
|
||||
@@ -619,7 +629,9 @@ class _HomePageState extends State<HomePage> {
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _currentTab == 'recommended'
|
||||
? CupertinoColors.white
|
||||
: (isDark ? DarkDesignTokens.text2 : DesignTokens.text2),
|
||||
: (isDark
|
||||
? DarkDesignTokens.text2
|
||||
: DesignTokens.text2),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -831,4 +843,48 @@ class _HomePageState extends State<HomePage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIngredientRecommendCard(bool isDark) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
Get.toNamed(AppRoutes.toolsIngredientRecommend);
|
||||
},
|
||||
child: Container(
|
||||
width: 100,
|
||||
margin: const EdgeInsets.only(right: DesignTokens.space3),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: isDark
|
||||
? [
|
||||
DarkDesignTokens.primary.withValues(alpha: 0.6),
|
||||
DarkDesignTokens.secondary.withValues(alpha: 0.6),
|
||||
]
|
||||
: [
|
||||
DesignTokens.primary.withValues(alpha: 0.6),
|
||||
DesignTokens.secondary.withValues(alpha: 0.6),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(DesignTokens.radiusMd),
|
||||
boxShadow: DesignTokens.shadowsSm,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('🥬', style: TextStyle(fontSize: 28)),
|
||||
const SizedBox(height: DesignTokens.space2),
|
||||
Text(
|
||||
'食材推荐',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontSm,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
/*
|
||||
* 文件: favorites_page.dart
|
||||
* 名称: 收藏页面
|
||||
* 作用: iOS 26 Liquid Glass 风格的收藏页面,使用 FavoritesController 展示收藏内容
|
||||
* 更新: 2026-04-10 添加工具入口Bar
|
||||
* 更新: 2026-04-11 修复GetX报错,重构为StatefulWidget
|
||||
* 更新: 2026-04-11 移动到pages根目录
|
||||
* 更新: 2026-04-11 简化ToolsController获取(已全局注册,移除防御性put)
|
||||
* 更新: 2026-04-11 iOS 26 Liquid Glass 风格重构
|
||||
* 作用: iOS 26 Liquid Glass 风格的收藏页面
|
||||
* 更新: 2026-04-12 重写跳转逻辑,修复闪退问题
|
||||
*/
|
||||
|
||||
import 'dart:ui';
|
||||
@@ -17,6 +13,7 @@ import 'package:mom_kitchen/src/config/design_tokens.dart';
|
||||
import 'package:mom_kitchen/src/controllers/favorites_controller.dart';
|
||||
import 'package:mom_kitchen/src/controllers/tools_controller.dart';
|
||||
import 'package:mom_kitchen/src/models/tool_item_model.dart';
|
||||
import 'package:mom_kitchen/src/pages/tools/tools_center_page.dart';
|
||||
|
||||
class FavoritesPage extends StatefulWidget {
|
||||
const FavoritesPage({super.key});
|
||||
@@ -26,20 +23,45 @@ class FavoritesPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _FavoritesPageState extends State<FavoritesPage> {
|
||||
late final FavoritesController _favoritesController;
|
||||
late final ToolsController _toolsController;
|
||||
FavoritesController? _favoritesController;
|
||||
ToolsController? _toolsController;
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_favoritesController = Get.find<FavoritesController>();
|
||||
_toolsController = Get.find<ToolsController>();
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_isInitialized) {
|
||||
_isInitialized = true;
|
||||
_initControllers();
|
||||
}
|
||||
}
|
||||
|
||||
void _initControllers() {
|
||||
try {
|
||||
if (Get.isRegistered<FavoritesController>()) {
|
||||
_favoritesController = Get.find<FavoritesController>();
|
||||
}
|
||||
if (Get.isRegistered<ToolsController>()) {
|
||||
_toolsController = Get.find<ToolsController>();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('FavoritesPage: Controller init error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
|
||||
|
||||
if (_favoritesController == null || _toolsController == null) {
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: isDark
|
||||
? DarkDesignTokens.background
|
||||
: DesignTokens.background,
|
||||
child: const Center(child: CupertinoActivityIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
backgroundColor: isDark
|
||||
? DarkDesignTokens.background
|
||||
@@ -52,7 +74,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
_buildToolbar(isDark),
|
||||
Expanded(
|
||||
child: Obx(() {
|
||||
final favorites = _favoritesController.favorites;
|
||||
final favorites = _favoritesController!.favorites;
|
||||
if (favorites.isEmpty) {
|
||||
return _buildEmptyState(isDark);
|
||||
}
|
||||
@@ -60,7 +82,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
}),
|
||||
),
|
||||
Obx(
|
||||
() => _favoritesController.isEditMode.value
|
||||
() => _favoritesController!.isEditMode.value
|
||||
? _buildEditBottomBar(isDark)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
@@ -88,19 +110,21 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
),
|
||||
const Spacer(),
|
||||
Obx(() {
|
||||
final count = _favoritesController.count;
|
||||
final count = _favoritesController!.count;
|
||||
if (count == 0) return const SizedBox.shrink();
|
||||
return _buildGlassChip('$count', isDark, highlight: true);
|
||||
}),
|
||||
const SizedBox(width: DesignTokens.space3),
|
||||
Obx(() {
|
||||
if (_favoritesController.count == 0) return const SizedBox.shrink();
|
||||
if (_favoritesController!.count == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
minimumSize: const Size(36, 36),
|
||||
onPressed: _favoritesController.toggleEditMode,
|
||||
onPressed: _favoritesController!.toggleEditMode,
|
||||
child: Text(
|
||||
_favoritesController.isEditMode.value ? '完成' : '编辑',
|
||||
_favoritesController!.isEditMode.value ? '完成' : '编辑',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
color: DesignTokens.primary,
|
||||
@@ -154,7 +178,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
|
||||
Widget _buildToolsBar(bool isDark) {
|
||||
return Obx(() {
|
||||
final tools = _toolsController.frequentTools;
|
||||
final tools = _toolsController!.frequentTools;
|
||||
if (tools.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
@@ -171,20 +195,16 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
if (index == tools.length) {
|
||||
return _buildMoreToolsCard(isDark);
|
||||
}
|
||||
return _buildToolShortcut(tools[index], _toolsController, isDark);
|
||||
return _buildToolShortcut(tools[index], isDark);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildToolShortcut(
|
||||
ToolItem tool,
|
||||
ToolsController controller,
|
||||
bool isDark,
|
||||
) {
|
||||
Widget _buildToolShortcut(ToolItem tool, bool isDark) {
|
||||
return GestureDetector(
|
||||
onTap: () => controller.openTool(tool),
|
||||
onTap: () => _navigateToTool(tool),
|
||||
child: ClipRRect(
|
||||
borderRadius: DesignTokens.borderRadiusMd,
|
||||
child: BackdropFilter(
|
||||
@@ -262,7 +282,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
|
||||
Widget _buildMoreToolsCard(bool isDark) {
|
||||
return GestureDetector(
|
||||
onTap: () => Get.toNamed('/tools'),
|
||||
onTap: _navigateToToolsCenter,
|
||||
child: ClipRRect(
|
||||
borderRadius: DesignTokens.borderRadiusMd,
|
||||
child: BackdropFilter(
|
||||
@@ -317,9 +337,30 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToToolsCenter() {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(CupertinoPageRoute(builder: (context) => const ToolsCenterPage()));
|
||||
}
|
||||
|
||||
void _navigateToTool(ToolItem tool) {
|
||||
_toolsController?.recordUsage(tool.id);
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(CupertinoPageRoute(builder: (context) => const ToolsCenterPage()));
|
||||
}
|
||||
|
||||
void _navigateToRecipeDetail(int recipeId) {
|
||||
Navigator.of(context).push(
|
||||
CupertinoPageRoute(
|
||||
builder: (context) => _RecipeDetailWrapper(recipeId: recipeId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildToolbar(bool isDark) {
|
||||
return Obx(() {
|
||||
if (_favoritesController.count == 0) return const SizedBox.shrink();
|
||||
if (_favoritesController!.count == 0) return const SizedBox.shrink();
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space4,
|
||||
@@ -369,7 +410,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space1),
|
||||
Text(
|
||||
_getSortLabel(_favoritesController.sortMode.value),
|
||||
_getSortLabel(_favoritesController!.sortMode.value),
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontSm,
|
||||
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
|
||||
@@ -389,13 +430,13 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Obx(
|
||||
() => Row(
|
||||
children: _favoritesController.categories.map((cat) {
|
||||
children: _favoritesController!.categories.map((cat) {
|
||||
final isSelected =
|
||||
_favoritesController.selectedCategory.value == cat;
|
||||
_favoritesController!.selectedCategory.value == cat;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: DesignTokens.space2),
|
||||
child: GestureDetector(
|
||||
onTap: () => _favoritesController.setCategory(cat),
|
||||
onTap: () => _favoritesController!.setCategory(cat),
|
||||
child: ClipRRect(
|
||||
borderRadius: DesignTokens.borderRadiusMd,
|
||||
child: BackdropFilter(
|
||||
@@ -450,19 +491,19 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
void _showSortSheet(bool isDark) {
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (context) => CupertinoActionSheet(
|
||||
builder: (ctx) => CupertinoActionSheet(
|
||||
title: const Text('排序方式'),
|
||||
actions: FavoritesSortMode.values.map((mode) {
|
||||
return CupertinoActionSheetAction(
|
||||
onPressed: () {
|
||||
_favoritesController.setSortMode(mode);
|
||||
Get.back();
|
||||
_favoritesController!.setSortMode(mode);
|
||||
Navigator.of(ctx).pop();
|
||||
},
|
||||
child: Text(_getSortLabel(mode)),
|
||||
);
|
||||
}).toList(),
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
onPressed: Get.back,
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
@@ -504,11 +545,11 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
children: [
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: _favoritesController.hasSelection
|
||||
? _favoritesController.deselectAll
|
||||
: _favoritesController.selectAll,
|
||||
onPressed: _favoritesController!.hasSelection
|
||||
? _favoritesController!.deselectAll
|
||||
: _favoritesController!.selectAll,
|
||||
child: Text(
|
||||
_favoritesController.hasSelection ? '取消全选' : '全选',
|
||||
_favoritesController!.hasSelection ? '取消全选' : '全选',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
color: DesignTokens.primary,
|
||||
@@ -518,7 +559,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
const Spacer(),
|
||||
Obx(
|
||||
() => Text(
|
||||
'已选 ${_favoritesController.selectedCount} 项',
|
||||
'已选 ${_favoritesController!.selectedCount} 项',
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontSm,
|
||||
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
|
||||
@@ -530,7 +571,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.space4,
|
||||
),
|
||||
onPressed: _favoritesController.hasSelection
|
||||
onPressed: _favoritesController!.hasSelection
|
||||
? () => _confirmDelete()
|
||||
: null,
|
||||
child: const Text('删除'),
|
||||
@@ -545,16 +586,19 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
void _confirmDelete() {
|
||||
showCupertinoDialog(
|
||||
context: context,
|
||||
builder: (context) => CupertinoAlertDialog(
|
||||
builder: (ctx) => CupertinoAlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: Text('确定要删除选中的 ${_favoritesController.selectedCount} 项收藏吗?'),
|
||||
content: Text('确定要删除选中的 ${_favoritesController!.selectedCount} 项收藏吗?'),
|
||||
actions: [
|
||||
CupertinoDialogAction(onPressed: Get.back, child: const Text('取消')),
|
||||
CupertinoDialogAction(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
CupertinoDialogAction(
|
||||
isDestructiveAction: true,
|
||||
onPressed: () {
|
||||
Get.back();
|
||||
_favoritesController.deleteSelected();
|
||||
Navigator.of(ctx).pop();
|
||||
_favoritesController!.deleteSelected();
|
||||
},
|
||||
child: const Text('删除'),
|
||||
),
|
||||
@@ -631,7 +675,7 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
vertical: DesignTokens.space2,
|
||||
),
|
||||
itemCount: favorites.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: DesignTokens.space2 + 2),
|
||||
itemBuilder: (context, index) {
|
||||
final item = favorites[index];
|
||||
@@ -642,15 +686,13 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
|
||||
Widget _buildFavoriteItem(dynamic item, bool isDark) {
|
||||
return Obx(() {
|
||||
final isEditMode = _favoritesController.isEditMode.value;
|
||||
final isSelected = _favoritesController.isSelected(item.id);
|
||||
final isEditMode = _favoritesController!.isEditMode.value;
|
||||
final isSelected = _favoritesController!.isSelected(item.id);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isEditMode
|
||||
? () => _favoritesController.toggleSelection(item.id)
|
||||
: () {
|
||||
Get.toNamed('/recipe-detail', arguments: '${item.id}');
|
||||
},
|
||||
? () => _favoritesController!.toggleSelection(item.id)
|
||||
: () => _navigateToRecipeDetail(item.id),
|
||||
child: ClipRRect(
|
||||
borderRadius: DesignTokens.borderRadiusLg,
|
||||
child: BackdropFilter(
|
||||
@@ -751,7 +793,8 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
if (!isEditMode) ...[
|
||||
const SizedBox(width: DesignTokens.space2),
|
||||
GestureDetector(
|
||||
onTap: () => _favoritesController.removeFavorite(item.id),
|
||||
onTap: () =>
|
||||
_favoritesController!.removeFavorite(item.id),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 36,
|
||||
@@ -777,3 +820,44 @@ class _FavoritesPageState extends State<FavoritesPage> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _RecipeDetailWrapper extends StatelessWidget {
|
||||
final int recipeId;
|
||||
|
||||
const _RecipeDetailWrapper({required this.recipeId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(middle: Text('菜谱详情 #$recipeId')),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('📖', style: TextStyle(fontSize: 64)),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'菜谱详情页',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: CupertinoTheme.brightnessOf(context) == Brightness.dark
|
||||
? DarkDesignTokens.text1
|
||||
: DesignTokens.text1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'ID: $recipeId',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: CupertinoTheme.brightnessOf(context) == Brightness.dark
|
||||
? DarkDesignTokens.text2
|
||||
: DesignTokens.text2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ import 'package:mom_kitchen/src/pages/home/home_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/profile/settings/theme_demo_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/tools/tools_center_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/tools/ingredient_detail_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/discover/ingredient_recommend_page.dart';
|
||||
import 'package:mom_kitchen/src/pages/discover/ingredient_recipe_list_page.dart';
|
||||
|
||||
class AppPages {
|
||||
static final List<PageInfo> pages = [
|
||||
@@ -54,6 +56,29 @@ class AppPages {
|
||||
],
|
||||
builder: () => const IngredientDetailPage(),
|
||||
),
|
||||
PageInfo(
|
||||
route: '/tools/ingredient-recommend',
|
||||
name: '食材推荐',
|
||||
description: '热门食材推荐列表',
|
||||
requiredStandards: [
|
||||
StandardCheck.themeColors,
|
||||
StandardCheck.textColors,
|
||||
StandardCheck.darkMode,
|
||||
],
|
||||
builder: () => const IngredientRecommendPage(),
|
||||
),
|
||||
PageInfo(
|
||||
route: '/tools/ingredient-recipes',
|
||||
name: '食材菜品列表',
|
||||
description: '某食材相关的菜品列表',
|
||||
requiredStandards: [
|
||||
StandardCheck.themeColors,
|
||||
StandardCheck.textColors,
|
||||
StandardCheck.darkMode,
|
||||
],
|
||||
builder: () =>
|
||||
const IngredientRecipeListPage(ingredientId: 0, ingredientName: ''),
|
||||
),
|
||||
];
|
||||
|
||||
static void registerAll() {
|
||||
|
||||
@@ -150,27 +150,44 @@ class PageValidator {
|
||||
static final List<ValidationResult> _validationHistory = [];
|
||||
static const int _maxHistorySize = 100;
|
||||
|
||||
static bool _isValidating = false;
|
||||
static DateTime? _lastValidateAt;
|
||||
static const Duration _minValidateInterval = Duration(milliseconds: 500);
|
||||
|
||||
static List<ValidationResult> get history =>
|
||||
List.unmodifiable(_validationHistory);
|
||||
|
||||
static void validate(BuildContext context, String pageRoute) {
|
||||
if (!kDebugMode) return;
|
||||
|
||||
final pageInfo = PageRegistry.getPage(pageRoute);
|
||||
if (pageInfo == null) {
|
||||
AppLogger.w('⚠️ 页面未注册: $pageRoute');
|
||||
if (_isValidating) return;
|
||||
final now = DateTime.now();
|
||||
final last = _lastValidateAt;
|
||||
if (last != null && now.difference(last) < _minValidateInterval) {
|
||||
return;
|
||||
}
|
||||
_lastValidateAt = now;
|
||||
_isValidating = true;
|
||||
|
||||
final standards = PageStandards.of(context);
|
||||
try {
|
||||
final pageInfo = PageRegistry.getPage(pageRoute);
|
||||
if (pageInfo == null) {
|
||||
AppLogger.w('⚠️ 页面未注册: $pageRoute');
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.d('🔍 开始验证页面: ${pageInfo.name} ($pageRoute)');
|
||||
final standards = PageStandards.of(context);
|
||||
|
||||
for (final check in pageInfo.requiredStandards) {
|
||||
_checkStandard(context, standards, pageRoute, check);
|
||||
AppLogger.d('🔍 开始验证页面: ${pageInfo.name} ($pageRoute)');
|
||||
|
||||
for (final check in pageInfo.requiredStandards) {
|
||||
_checkStandard(context, standards, pageRoute, check);
|
||||
}
|
||||
|
||||
AppLogger.i('✅ 页面验证完成: ${pageInfo.name}');
|
||||
} finally {
|
||||
_isValidating = false;
|
||||
}
|
||||
|
||||
AppLogger.i('✅ 页面验证完成: ${pageInfo.name}');
|
||||
}
|
||||
|
||||
static void _checkStandard(
|
||||
|
||||
Reference in New Issue
Block a user