Files
kitchen/docs/api/recipe_share.php
Developer ceb11d9aac feat: 新增口味偏好服务和菜谱分享功能
- 新增 TastePreferenceService 用于管理用户口味偏好设置
- 实现菜谱分享功能,包括 RecipeShareService 和分享页面
- 更新平台工具类以支持鸿蒙系统检测
- 优化收藏页和农场商店页面的UI交互
- 添加新的参考文献和关于页面内容
- 更新API文档至v3.3.0版本
2026-04-18 08:29:31 +08:00

1402 lines
39 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 🍳 菜谱分享页面
*
* 二维码扫码后展示菜谱详情的网页
* 支持通过 code 或 id 参数获取菜谱数据并渲染美观的分享页面
* 使用本地JSON文件存储菜谱数据不依赖外部API
*
* 接口列表:
* GET ?code=CP00001 或 ?id=123 查看菜谱分享页面
* GET ?act=api&code=CP00001 获取菜谱JSON数据
* POST ?act=create 创建菜谱分享数据
* POST ?act=update 更新菜谱分享数据
* GET ?act=delete&id=xxx 删除菜谱分享数据
* GET ?act=list 菜谱分享列表
* GET ?act=stats 查看分享统计
* GET ?act=log 查看访问日志
* GET ?act=index 接口说明
*
* @file recipe_share.php
* @date 2026-04-18
* @version 1.3.0
* @desc 菜谱分享页面扫码展示菜谱详情本地JSON存储支持统计和管理数据3天后自动过期
* @lastUpdate 2026-04-18 适配完整菜谱数据传输,新增食材/营养/步骤等完整内容显示
*/
// ─── CORS预检请求处理 ───
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Max-Age: 86400');
http_response_code(204);
exit;
}
$startTime = microtime(true);
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Content-Type: text/html; charset=utf-8');
// ─── POST请求参数解析 ───
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
$postData = array();
if (stripos($contentType, 'application/json') !== false) {
$rawBody = file_get_contents('php://input');
$json = json_decode($rawBody, true);
if (is_array($json)) {
$postData = $json;
}
} else {
$postData = $_POST;
}
foreach ($postData as $key => $value) {
if (!isset($_GET[$key])) {
$_GET[$key] = $value;
}
}
}
$act = strtolower(trim($_GET['act'] ?? 'share'));
// ─── 数据目录配置与kitchen.php共享 ───
$dataDir = dirname(__FILE__) . '/cache/kitchen/';
if (!is_dir($dataDir)) {
if (!@mkdir($dataDir, 0755, true)) {
$tmpDir = sys_get_temp_dir() . '/kitchen/';
if (!is_dir($tmpDir)) {
@mkdir($tmpDir, 0755, true);
}
$dataDir = $tmpDir;
}
}
if (!is_writable($dataDir)) {
$tmpDir = sys_get_temp_dir() . '/kitchen/';
if (!is_dir($tmpDir)) {
@mkdir($tmpDir, 0755, true);
}
$dataDir = $tmpDir;
}
$statsFile = $dataDir . 'share_stats.json';
$logFile = $dataDir . 'access_log.json';
// ─── 路由分发 ───
switch ($act) {
case 'create':
header('Content-Type: application/json; charset=utf-8');
echo json_encode(create_recipe_share(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
break;
case 'update':
header('Content-Type: application/json; charset=utf-8');
echo json_encode(update_recipe_share(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
break;
case 'delete':
header('Content-Type: application/json; charset=utf-8');
echo json_encode(delete_recipe_share(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
break;
case 'list':
header('Content-Type: application/json; charset=utf-8');
echo json_encode(list_recipe_data(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
break;
case 'cleanup':
header('Content-Type: application/json; charset=utf-8');
echo json_encode(cleanup_expired_recipes(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
break;
case 'stats':
header('Content-Type: application/json; charset=utf-8');
echo json_encode(get_share_stats(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
break;
case 'log':
header('Content-Type: application/json; charset=utf-8');
echo json_encode(get_access_log(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
break;
case 'api':
header('Content-Type: application/json; charset=utf-8');
echo json_encode(fetch_recipe_data(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
break;
case 'index':
header('Content-Type: application/json; charset=utf-8');
echo json_encode(array(
'code' => 200,
'message' => '🍳 菜谱分享页面',
'data' => array(
'version' => '1.3.0',
'description' => '菜谱分享页面本地JSON存储支持CRUD和统计数据3天后自动过期删除完整数据传输',
'endpoints' => array(
'share' => '?code=CP00001 或 ?id=123',
'api' => '?act=api&code=CP00001 (JSON数据)',
'create' => 'POST ?act=create {recipe JSON}',
'update' => 'POST ?act=update {recipe JSON}',
'delete' => 'GET ?act=delete&id=xxx',
'list' => 'GET ?act=list',
'cleanup' => 'GET ?act=cleanup (清理过期数据)',
'stats' => '?act=stats (分享统计)',
'log' => '?act=log (访问日志)',
'index' => '?act=index (接口说明)',
),
'storage' => 'JSON文件 (' . $dataDir . 'recipe_*.json)',
'expire_policy' => '数据3天后自动过期删除读取时自动检查并清理',
'display' => '完整显示:封面/标题/简介/标签/食材/营养/过敏原/步骤',
),
), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
break;
case 'share':
default:
render_share_page();
break;
}
exit;
// ══════════════════════════════════════════════════════════════
// 数据获取函数
// ══════════════════════════════════════════════════════════════
/**
* 读取菜谱分享数据本地JSON文件
* 自动检查过期过期则删除并返回null
*/
function read_recipe_data($id) {
global $dataDir;
$file = $dataDir . 'recipe_' . (int)$id . '.json';
if (!file_exists($file)) {
return null;
}
// 检查是否过期
if (is_recipe_expired($file)) {
@unlink($file);
return null;
}
$content = file_get_contents($file);
$data = json_decode($content, true);
return is_array($data) ? $data : null;
}
/**
* 检查菜谱数据是否过期
*/
function is_recipe_expired($file) {
if (!file_exists($file)) return true;
$content = file_get_contents($file);
$data = json_decode($content, true);
if (!is_array($data)) return true;
$expiresAt = $data['expiresAt'] ?? null;
if (empty($expiresAt)) {
// 没有过期时间默认3天后过期从创建时间算起
$createdAt = $data['createdAt'] ?? $data['updatedAt'] ?? '';
if (empty($createdAt)) return false;
$expiresAt = date('c', strtotime($createdAt . ' +3 days'));
}
return strtotime($expiresAt) < time();
}
/**
* 清理所有过期的菜谱分享数据
*/
function cleanup_expired_recipes() {
global $dataDir;
$files = glob($dataDir . 'recipe_*.json');
if ($files === false) return array('deleted' => 0, 'errors' => array());
$deleted = 0;
$errors = array();
foreach ($files as $file) {
if (is_recipe_expired($file)) {
if (@unlink($file)) {
$deleted++;
} else {
$errors[] = basename($file);
}
}
}
return array(
'deleted' => $deleted,
'errors' => $errors,
);
}
/**
* 写入菜谱分享数据本地JSON文件
* 注意数据3天后自动过期删除
*/
function write_recipe_data($id, $data) {
global $dataDir;
$file = $dataDir . 'recipe_' . (int)$id . '.json';
// 添加过期时间3天后
$data['expiresAt'] = date('c', strtotime('+3 days'));
$data['updatedAt'] = date('c');
$fp = fopen($file, 'c');
if (flock($fp, LOCK_EX)) {
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
flock($fp, LOCK_UN);
}
fclose($fp);
}
/**
* 删除菜谱分享数据
*/
function delete_recipe_data($id) {
global $dataDir;
$file = $dataDir . 'recipe_' . (int)$id . '.json';
if (file_exists($file)) {
return @unlink($file);
}
return false;
}
/**
* 获取菜谱分享列表
*/
function list_recipe_data() {
global $dataDir;
$recipes = array();
$files = glob($dataDir . 'recipe_*.json');
if ($files === false) return $recipes;
foreach ($files as $file) {
$content = file_get_contents($file);
$data = json_decode($content, true);
if (is_array($data)) {
$recipes[] = $data;
}
}
// 按更新时间倒序
usort($recipes, function($a, $b) {
$timeA = $a['updatedAt'] ?? $a['createdAt'] ?? '';
$timeB = $b['updatedAt'] ?? $b['createdAt'] ?? '';
return strcmp($timeB, $timeA);
});
return $recipes;
}
/**
* 将code解析为ID支持多种格式
* 支持格式: CP00001, CP123, cp123, cpxxxx, 123
*/
function resolve_code_to_id($code) {
// 支持 CP/cp/cpxxxx 格式,提取数字部分
if (preg_match('/^CPX?(\d+)$/i', $code, $matches)) {
return (int)$matches[1];
}
return (int)$code;
}
/**
* 获取API基础URL用于页面链接
*/
function get_api_base_url() {
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'eat.wktyl.com';
return $protocol . '://' . $host;
}
/**
* 通过本地JSON文件获取菜谱数据
*/
function fetch_recipe_data() {
$code = trim($_GET['code'] ?? '');
$id = (int)($_GET['id'] ?? 0);
if (empty($code) && $id <= 0) {
return array('code' => 400, 'message' => '缺少 code 或 id 参数');
}
$resolvedId = $id > 0 ? $id : resolve_code_to_id($code);
$recipe = read_recipe_data($resolvedId);
if ($recipe === null) {
return array('code' => 404, 'message' => '菜谱不存在');
}
record_share_access($recipe, $code, $id);
return array(
'code' => 200,
'message' => 'success',
'data' => $recipe,
);
}
/**
* 获取菜谱完整数据(用于页面渲染)
*/
function fetch_recipe_full($id) {
$recipe = read_recipe_data((int)$id);
if ($recipe === null) return array();
return $recipe;
}
/**
* 创建菜谱分享数据
* POST ?act=create
* Body: {recipe JSON}
*/
function create_recipe_share() {
$recipeJson = $_GET['recipe'] ?? $_GET['data'] ?? null;
if ($recipeJson === null) {
$rawBody = file_get_contents('php://input');
$recipeData = json_decode($rawBody, true);
} else {
$recipeData = json_decode($recipeJson, true);
}
if (!is_array($recipeData)) {
return array('code' => 400, 'message' => '无效的菜谱数据需要JSON格式');
}
// 补全字段
$id = (int)($recipeData['id'] ?? 0);
if ($id <= 0) {
return array('code' => 400, 'message' => '缺少菜谱ID');
}
if (empty($recipeData['createdAt'])) {
$recipeData['createdAt'] = date('c');
}
$recipeData['updatedAt'] = date('c');
$recipeData['createdBy'] = get_client_ip();
write_recipe_data($id, $recipeData);
return array(
'code' => 200,
'message' => '创建成功',
'data' => $recipeData,
);
}
/**
* 更新菜谱分享数据
* POST ?act=update
* Body: {recipe JSON with id}
*/
function update_recipe_share() {
$recipeJson = $_GET['recipe'] ?? $_GET['data'] ?? null;
if ($recipeJson === null) {
$rawBody = file_get_contents('php://input');
$recipeData = json_decode($rawBody, true);
} else {
$recipeData = json_decode($recipeJson, true);
}
if (!is_array($recipeData) || empty($recipeData['id'])) {
return array('code' => 400, 'message' => '无效的菜谱数据缺少id字段');
}
$id = (int)$recipeData['id'];
$existing = read_recipe_data($id);
if ($existing === null) {
return array('code' => 404, 'message' => '菜谱不存在');
}
// 合并数据
$recipeData = array_merge($existing, $recipeData);
$recipeData['updatedAt'] = date('c');
write_recipe_data($id, $recipeData);
return array(
'code' => 200,
'message' => '更新成功',
'data' => $recipeData,
);
}
/**
* 删除菜谱分享数据
* GET ?act=delete&id=xxx
*/
function delete_recipe_share() {
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
return array('code' => 400, 'message' => '缺少菜谱ID参数');
}
if (!delete_recipe_data($id)) {
return array('code' => 404, 'message' => '菜谱不存在');
}
return array(
'code' => 200,
'message' => '删除成功',
'data' => array('deleted_id' => $id),
);
}
// ══════════════════════════════════════════════════════════════
// 统计和日志函数
// ══════════════════════════════════════════════════════════════
/**
* 记录分享访问
*/
function record_share_access($recipe, $code, $id) {
global $statsFile, $logFile;
$recipeId = $recipe['id'] ?? $id;
$recipeTitle = $recipe['title'] ?? '未知菜谱';
// 更新统计
$stats = read_json_file($statsFile, array(
'total_views' => 0,
'today_views' => 0,
'today_date' => date('Y-m-d'),
'recipes' => array(),
));
$today = date('Y-m-d');
if ($stats['today_date'] !== $today) {
$stats['today_views'] = 0;
$stats['today_date'] = $today;
}
$stats['total_views']++;
$stats['today_views']++;
$key = 'r_' . $recipeId;
if (!isset($stats['recipes'][$key])) {
$stats['recipes'][$key] = array(
'id' => $recipeId,
'title' => $recipeTitle,
'code' => $code,
'views' => 0,
'first_access' => date('c'),
'last_access' => date('c'),
);
}
$stats['recipes'][$key]['views']++;
$stats['recipes'][$key]['last_access'] = date('c');
if (!empty($recipeTitle) && $recipeTitle !== '未知菜谱') {
$stats['recipes'][$key]['title'] = $recipeTitle;
}
write_json_file($statsFile, $stats);
// 记录访问日志保留最近200条
$log = read_json_file($logFile, array());
array_unshift($log, array(
'time' => date('c'),
'recipe_id' => $recipeId,
'title' => $recipeTitle,
'code' => $code,
'ip' => get_client_ip(),
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'referer' => $_SERVER['HTTP_REFERER'] ?? '',
));
$log = array_slice($log, 0, 200);
write_json_file($logFile, $log);
}
/**
* 获取分享统计
*/
function get_share_stats() {
global $statsFile;
$stats = read_json_file($statsFile, array(
'total_views' => 0,
'today_views' => 0,
'today_date' => date('Y-m-d'),
'recipes' => array(),
));
return array(
'code' => 200,
'data' => $stats,
);
}
/**
* 获取访问日志
*/
function get_access_log() {
global $logFile;
$log = read_json_file($logFile, array());
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = min(50, max(1, (int)($_GET['limit'] ?? 20)));
$offset = ($page - 1) * $limit;
return array(
'code' => 200,
'data' => array(
'list' => array_slice($log, $offset, $limit),
'total' => count($log),
'page' => $page,
'limit' => $limit,
),
);
}
// ══════════════════════════════════════════════════════════════
// 工具函数
// ══════════════════════════════════════════════════════════════
function read_json_file($file, $default = array()) {
if (!file_exists($file)) return $default;
$content = file_get_contents($file);
$data = json_decode($content, true);
return is_array($data) ? $data : $default;
}
function write_json_file($file, $data) {
$fp = fopen($file, 'c');
if (flock($fp, LOCK_EX)) {
ftruncate($fp, 0);
rewind($fp);
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
flock($fp, LOCK_UN);
}
fclose($fp);
}
function get_client_ip() {
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($ips[0]);
}
if (!empty($_SERVER['HTTP_X_REAL_IP'])) {
return $_SERVER['HTTP_X_REAL_IP'];
}
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
function safe_html($str) {
return htmlspecialchars($str ?? '', ENT_QUOTES, 'UTF-8');
}
function format_time_ago($timestamp) {
if (empty($timestamp)) return '';
$now = time();
$ts = is_numeric($timestamp) ? (int)$timestamp : strtotime($timestamp);
$diff = $now - $ts;
if ($diff < 60) return '刚刚';
if ($diff < 3600) return floor($diff / 60) . '分钟前';
if ($diff < 86400) return floor($diff / 3600) . '小时前';
if ($diff < 604800) return floor($diff / 86400) . '天前';
return date('Y-m-d', $ts);
}
// ══════════════════════════════════════════════════════════════
// 页面渲染
// ══════════════════════════════════════════════════════════════
/**
* 渲染分享页面
*/
function render_share_page() {
$code = trim($_GET['code'] ?? '');
$id = (int)($_GET['id'] ?? 0);
$recipe = array();
$error = '';
if (!empty($code) || $id > 0) {
$resolvedId = $id > 0 ? $id : resolve_code_to_id($code);
$recipe = fetch_recipe_full($resolvedId);
if (!empty($recipe)) {
record_share_access($recipe, $code, $id);
} else {
$error = '菜谱不存在或暂时无法加载';
}
} else {
$error = '缺少菜谱参数';
}
// 提取数据用于显示
$title = !empty($recipe) ? safe_html($recipe['title'] ?? '菜谱分享') : '🍳 菜谱分享';
$cover = !empty($recipe) ? ($recipe['cover'] ?? '') : '';
$intro = !empty($recipe) ? ($recipe['intro'] ?? '') : '';
$content = !empty($recipe) ? ($recipe['content'] ?? '') : '';
$categoryName = !empty($recipe) ? ($recipe['category_name'] ?? ($recipe['categoryName'] ?? '')) : '';
$recipeCode = !empty($recipe) ? ($recipe['code'] ?? $code) : $code;
$viewCount = !empty($recipe) ? (($recipe['statistics']['views'] ?? 0) ?: ($recipe['view_count'] ?? 0)) : 0;
$likeCount = !empty($recipe) ? (($recipe['statistics']['likes'] ?? 0) ?: ($recipe['like_count'] ?? 0)) : 0;
$ratingScore = !empty($recipe) ? (($recipe['rating']['score'] ?? 0) ?: 0) : 0;
$ratingNums = !empty($recipe) ? (($recipe['rating']['nums'] ?? 0) ?: 0) : 0;
$author = !empty($recipe) ? ($recipe['author'] ?? null) : null;
$tags = !empty($recipe) ? ($recipe['tags'] ?? array()) : array();
$ingredients = !empty($recipe) ? ($recipe['ingredients'] ?? array()) : array();
$nutrition = !empty($recipe) ? ($recipe['nutrition'] ?? null) : null;
$meta = !empty($recipe) ? ($recipe['meta'] ?? null) : null;
$allergens = !empty($recipe) ? ($recipe['allergens'] ?? array()) : array();
$queryTime = round((microtime(true) - $GLOBALS['startTime']) * 1000, 2);
echo '<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>' . $title . ' - 小妈厨房</title>
<meta name="description" content="' . safe_html($intro) . '">
<meta name="theme-color" content="#FF6B35">
<meta property="og:title" content="' . $title . '">
<meta property="og:description" content="' . safe_html($intro) . '">
<meta property="og:type" content="article">
' . (!empty($cover) ? '<meta property="og:image" content="' . safe_html($cover) . '">' : '') . '
<meta property="og:site_name" content="小妈厨房">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍳</text></svg>">
<style>
:root {
--primary: #FF6B35;
--primary-light: #FF8F5E;
--primary-dark: #E55A2B;
--bg: #F5F5F7;
--surface: #FFFFFF;
--text1: #1D1D1F;
--text2: #6E6E73;
--text3: #AEAEB2;
--border: rgba(0,0,0,0.06);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
--shadow-lg: 0 8px 30px rgba(0,0,0,0.12);
--font: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif;
--space1: 4px; --space2: 8px; --space3: 16px; --space4: 24px; --space5: 32px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #000000;
--surface: #1C1C1E;
--text1: #F5F5F7;
--text2: #98989D;
--text3: #636366;
--border: rgba(255,255,255,0.08);
--shadow-sm: 0 1px 3px rgba(0,0,0,0.2);
--shadow-md: 0 4px 12px rgba(0,0,0,0.3);
--shadow-lg: 0 8px 30px rgba(0,0,0,0.4);
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text1);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.container {
max-width: 640px;
margin: 0 auto;
padding: var(--space3);
}
/* 顶部导航 */
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space3) 0;
margin-bottom: var(--space2);
}
.nav-brand {
display: flex;
align-items: center;
gap: var(--space2);
text-decoration: none;
color: var(--text1);
}
.nav-brand-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--primary), var(--primary-light));
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.nav-brand-text {
font-size: 17px;
font-weight: 600;
letter-spacing: -0.2px;
}
.nav-badge {
font-size: 11px;
color: var(--text3);
background: var(--surface);
padding: var(--space1) var(--space2);
border-radius: 20px;
border: 1px solid var(--border);
}
/* 封面图 */
.cover-section {
position: relative;
border-radius: var(--radius-xl);
overflow: hidden;
margin-bottom: var(--space4);
aspect-ratio: 4/3;
background: var(--surface);
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cover-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary-light), var(--primary));
color: white;
}
.cover-placeholder-emoji {
font-size: 64px;
margin-bottom: var(--space3);
}
.cover-placeholder-text {
font-size: 18px;
font-weight: 500;
opacity: 0.9;
}
.cover-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--space5) var(--space4) var(--space4);
background: linear-gradient(transparent, rgba(0,0,0,0.6));
}
.cover-category {
display: inline-block;
background: rgba(255,255,255,0.2);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
color: white;
font-size: 12px;
font-weight: 500;
padding: var(--space1) var(--space2);
border-radius: 20px;
margin-bottom: var(--space2);
}
.cover-title {
color: white;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.3px;
line-height: 1.3;
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
/* 信息卡片 */
.info-card {
background: var(--surface);
border-radius: var(--radius-lg);
padding: var(--space4);
margin-bottom: var(--space3);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
.info-row {
display: flex;
align-items: center;
gap: var(--space3);
padding: var(--space2) 0;
}
.info-row + .info-row {
border-top: 1px solid var(--border);
}
.info-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.info-icon.views { background: rgba(59,130,246,0.1); }
.info-icon.rating { background: rgba(245,158,11,0.1); }
.info-icon.code { background: rgba(139,92,246,0.1); }
.info-content {
flex: 1;
min-width: 0;
}
.info-label {
font-size: 12px;
color: var(--text3);
margin-bottom: 2px;
}
.info-value {
font-size: 15px;
font-weight: 500;
color: var(--text1);
}
/* 评分星星 */
.stars {
display: inline-flex;
gap: 2px;
margin-right: var(--space1);
}
.star { color: #F59E0B; font-size: 14px; }
.star.empty { color: var(--text3); }
/* 简介 */
.intro-text {
font-size: 15px;
line-height: 1.7;
color: var(--text2);
}
/* 区块标题 */
.section-title {
font-size: 16px;
font-weight: 600;
color: var(--text1);
margin-bottom: var(--space3);
display: flex;
align-items: center;
gap: var(--space2);
}
/* 标签列表 */
.tags-list {
display: flex;
flex-wrap: wrap;
gap: var(--space2);
}
.tag-item {
display: inline-block;
padding: var(--space1) var(--space2);
background: var(--primary);
color: white;
font-size: 13px;
font-weight: 500;
border-radius: 20px;
}
/* 食材列表 */
.ingredients-list {
display: flex;
flex-direction: column;
gap: var(--space2);
}
.ingredient-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space2) var(--space3);
background: var(--bg);
border-radius: var(--radius-sm);
}
.ingredient-name {
font-size: 14px;
color: var(--text1);
font-weight: 500;
}
.ingredient-amount {
font-size: 13px;
color: var(--text2);
}
/* 营养成分网格 */
.nutrition-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space2);
}
.nutrition-item {
display: flex;
align-items: center;
gap: var(--space2);
padding: var(--space2) var(--space3);
background: var(--bg);
border-radius: var(--radius-sm);
}
.nutrition-icon {
font-size: 18px;
}
.nutrition-label {
font-size: 13px;
color: var(--text2);
flex: 1;
}
.nutrition-value {
font-size: 14px;
font-weight: 600;
color: var(--text1);
}
/* 过敏原警告 */
.warning-card {
background: linear-gradient(135deg, #FEF3C7, #FDE68A);
border: 1px solid #F59E0B;
}
@media (prefers-color-scheme: dark) {
.warning-card {
background: linear-gradient(135deg, #78350F, #92400E);
border-color: #D97706;
}
}
.allergens-list {
display: flex;
flex-wrap: wrap;
gap: var(--space2);
}
.allergen-item {
display: inline-block;
padding: var(--space1) var(--space2);
background: rgba(245, 158, 11, 0.2);
color: #B45309;
font-size: 13px;
font-weight: 500;
border-radius: 20px;
border: 1px solid rgba(245, 158, 11, 0.3);
}
@media (prefers-color-scheme: dark) {
.allergen-item {
color: #FCD34D;
background: rgba(245, 158, 11, 0.15);
border-color: rgba(245, 158, 11, 0.3);
}
}
/* 步骤内容 */
.steps-content {
font-size: 14px;
line-height: 1.8;
color: var(--text2);
white-space: pre-wrap;
}
/* CTA按钮 */
.cta-section {
margin-top: var(--space4);
margin-bottom: var(--space5);
}
.cta-button {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space2);
width: 100%;
padding: var(--space3) var(--space4);
background: linear-gradient(135deg, var(--primary), var(--primary-light));
color: white;
font-size: 16px;
font-weight: 600;
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 14px rgba(255,107,53,0.3);
}
.cta-button:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(255,107,53,0.4);
}
.cta-button:active {
transform: translateY(0);
}
/* 页脚 */
.footer {
text-align: center;
padding: var(--space5) 0 var(--space4);
color: var(--text3);
font-size: 12px;
}
.footer a {
color: var(--primary);
text-decoration: none;
}
/* 错误页面 */
.error-section {
text-align: center;
padding: var(--space5) var(--space4);
}
.error-emoji {
font-size: 64px;
margin-bottom: var(--space3);
}
.error-title {
font-size: 20px;
font-weight: 600;
color: var(--text1);
margin-bottom: var(--space2);
}
.error-desc {
font-size: 15px;
color: var(--text2);
margin-bottom: var(--space4);
line-height: 1.5;
}
/* 加载动画 */
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space5);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: var(--space3);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
font-size: 14px;
color: var(--text3);
}
/* 动画 */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeInUp 0.5s ease-out forwards;
}
.animate-delay-1 { animation-delay: 0.1s; opacity: 0; }
.animate-delay-2 { animation-delay: 0.2s; opacity: 0; }
.animate-delay-3 { animation-delay: 0.3s; opacity: 0; }
</style>
</head>
<body>
<div class="container">';
if (!empty($error)) {
echo '
<div class="nav">
<a href="' . get_api_base_url() . '/api/" class="nav-brand">
<div class="nav-brand-icon">🍳</div>
<span class="nav-brand-text">小妈厨房</span>
</a>
</div>
<div class="error-section animate-in">
<div class="error-emoji">😕</div>
<div class="error-title">无法加载菜谱</div>
<div class="error-desc">' . safe_html($error) . '</div>
<a href="' . get_api_base_url() . '/api/" class="cta-button" style="max-width:240px;margin:0 auto;">
🍳 返回首页
</a>
</div>';
} else {
$starsHtml = '';
$fullStars = floor($ratingScore);
$halfStar = ($ratingScore - $fullStars) >= 0.5;
for ($i = 0; $i < 5; $i++) {
if ($i < $fullStars) {
$starsHtml .= '<span class="star">★</span>';
} elseif ($i === $fullStars && $halfStar) {
$starsHtml .= '<span class="star">★</span>';
} else {
$starsHtml .= '<span class="star empty">★</span>';
}
}
echo '
<div class="nav animate-in">
<a href="' . get_api_base_url() . '/api/" class="nav-brand">
<div class="nav-brand-icon">🍳</div>
<span class="nav-brand-text">小妈厨房</span>
</a>
<span class="nav-badge">扫码访问</span>
</div>
<div class="cover-section animate-in animate-delay-1">';
if (!empty($cover)) {
echo '<img class="cover-img" src="' . safe_html($cover) . '" alt="' . $title . '" loading="lazy" onerror="this.parentElement.innerHTML=\'<div class=cover-placeholder><div class=cover-placeholder-emoji>🍳</div><div class=cover-placeholder-text>' . $title . '</div></div>\'">';
} else {
echo '<div class="cover-placeholder">
<div class="cover-placeholder-emoji">🍳</div>
<div class="cover-placeholder-text">' . $title . '</div>
</div>';
}
echo '
<div class="cover-overlay">';
if (!empty($categoryName)) {
echo '<span class="cover-category">' . safe_html($categoryName) . '</span>';
}
echo '<h1 class="cover-title">' . $title . '</h1>
</div>
</div>
<div class="info-card animate-in animate-delay-2">';
if ($viewCount > 0) {
echo '
<div class="info-row">
<div class="info-icon views">👁️</div>
<div class="info-content">
<div class="info-label">浏览量</div>
<div class="info-value">' . number_format($viewCount) . ' 次</div>
</div>
</div>';
}
if ($ratingScore > 0) {
echo '
<div class="info-row">
<div class="info-icon rating">⭐</div>
<div class="info-content">
<div class="info-label">评分</div>
<div class="info-value">
<span class="stars">' . $starsHtml . '</span>
' . number_format($ratingScore, 1) . ' 分' . ($ratingNums > 0 ? ' · ' . $ratingNums . ' 人评' : '') . '
</div>
</div>
</div>';
}
if (!empty($recipeCode)) {
echo '
<div class="info-row">
<div class="info-icon code">🔢</div>
<div class="info-content">
<div class="info-label">菜谱编码</div>
<div class="info-value">' . safe_html($recipeCode) . '</div>
</div>
</div>';
}
// 显示作者信息
if (!empty($author) && is_array($author)) {
$authorName = $author['name'] ?? ($author['nickname'] ?? '');
$authorAvatar = $author['avatar'] ?? '';
if (!empty($authorName)) {
echo '
<div class="info-row">
<div class="info-icon">👨‍🍳</div>
<div class="info-content">
<div class="info-label">作者</div>
<div class="info-value">' . safe_html($authorName) . '</div>
</div>
</div>';
}
}
// 显示烹饪时间和难度
if (!empty($meta) && is_array($meta)) {
$difficulty = $meta['difficulty'] ?? '';
$time = $meta['time'] ?? '';
if (!empty($time)) {
echo '
<div class="info-row">
<div class="info-icon">⏱️</div>
<div class="info-content">
<div class="info-label">烹饪时间</div>
<div class="info-value">' . safe_html($time) . '</div>
</div>
</div>';
}
if (!empty($difficulty)) {
echo '
<div class="info-row">
<div class="info-icon">📊</div>
<div class="info-content">
<div class="info-label">难度</div>
<div class="info-value">' . safe_html($difficulty) . '</div>
</div>
</div>';
}
}
echo '
</div>';
// 显示简介
if (!empty($intro)) {
echo '
<div class="info-card animate-in animate-delay-3">
<h3 class="section-title">📝 简介</h3>
<p class="intro-text">' . nl2br(safe_html($intro)) . '</p>
</div>';
}
// 显示标签
if (!empty($tags) && is_array($tags) && count($tags) > 0) {
echo '
<div class="info-card animate-in animate-delay-3">
<h3 class="section-title">🏷️ 标签</h3>
<div class="tags-list">';
foreach ($tags as $tag) {
$tagName = is_array($tag) ? ($tag['name'] ?? '') : $tag;
if (!empty($tagName)) {
echo '<span class="tag-item">' . safe_html($tagName) . '</span>';
}
}
echo '
</div>
</div>';
}
// 显示食材列表
if (!empty($ingredients) && is_array($ingredients) && count($ingredients) > 0) {
echo '
<div class="info-card animate-in animate-delay-3">
<h3 class="section-title">🥗 食材清单</h3>
<div class="ingredients-list">';
foreach ($ingredients as $ing) {
$ingName = is_array($ing) ? ($ing['name'] ?? '') : $ing;
$ingAmount = is_array($ing) ? ($ing['amount'] ?? '') : '';
$ingUnit = is_array($ing) ? ($ing['unit'] ?? '') : '';
if (!empty($ingName)) {
echo '
<div class="ingredient-item">
<span class="ingredient-name">' . safe_html($ingName) . '</span>
<span class="ingredient-amount">' . safe_html(trim($ingAmount . ' ' . $ingUnit)) . '</span>
</div>';
}
}
echo '
</div>
</div>';
}
// 显示营养信息
if (!empty($nutrition) && is_array($nutrition)) {
$hasNutrition = false;
$nutritionItems = array();
$nutritionFields = array(
'calories' => array('label' => '热量', 'unit' => 'kcal', 'icon' => '🔥'),
'protein' => array('label' => '蛋白质', 'unit' => 'g', 'icon' => '💪'),
'fat' => array('label' => '脂肪', 'unit' => 'g', 'icon' => '🧈'),
'carbs' => array('label' => '碳水', 'unit' => 'g', 'icon' => '🍞'),
'fiber' => array('label' => '膳食纤维', 'unit' => 'g', 'icon' => '🥬'),
);
foreach ($nutritionFields as $key => $info) {
$value = $nutrition[$key] ?? null;
if ($value !== null && $value !== '') {
$hasNutrition = true;
$nutritionItems[] = '<div class="nutrition-item"><span class="nutrition-icon">' . $info['icon'] . '</span><span class="nutrition-label">' . $info['label'] . '</span><span class="nutrition-value">' . $value . ' ' . $info['unit'] . '</span></div>';
}
}
if ($hasNutrition) {
echo '
<div class="info-card animate-in animate-delay-3">
<h3 class="section-title">📊 营养成分</h3>
<div class="nutrition-grid">' . implode('', $nutritionItems) . '</div>
</div>';
}
}
// 显示过敏原警告
if (!empty($allergens) && is_array($allergens) && count($allergens) > 0) {
echo '
<div class="info-card warning-card animate-in animate-delay-3">
<h3 class="section-title">⚠️ 过敏原提示</h3>
<div class="allergens-list">';
foreach ($allergens as $allergen) {
echo '<span class="allergen-item">' . safe_html($allergen) . '</span>';
}
echo '
</div>
</div>';
}
// 显示步骤内容
if (!empty($content)) {
echo '
<div class="info-card animate-in animate-delay-3">
<h3 class="section-title">👨‍🍳 制作步骤</h3>
<div class="steps-content">' . nl2br(safe_html($content)) . '</div>
</div>';
}
echo '
<div class="cta-section animate-in animate-delay-3">
<a href="' . get_api_base_url() . '/api/api.php?act=detail&id=' . ($recipe['id'] ?? $id) . '&viewnums=true" class="cta-button">
🍳 在小妈厨房中查看完整菜谱
</a>
</div>';
}
echo '
<div class="footer">
🍳 小妈厨房 · 妈妈的味道<br>
<a href="' . get_api_base_url() . '/api/">' . get_api_base_url() . '/api/</a>
<br><span style="color:var(--text3);font-size:11px;">' . $queryTime . 'ms</span>
</div>
</div>
</body>
</html>';
}