迷你卡片

This commit is contained in:
Developer
2026-04-14 05:35:30 +08:00
parent 5ce8759a18
commit c26ff354ee
195 changed files with 38057 additions and 1842 deletions

568
docs/api/doc/cookbook.html Normal file
View File

@@ -0,0 +1,568 @@
<!--
文件名称: cookbook.html
文件作用: 菜谱单页展示 - 每页一道菜品,沉浸式卡片浏览体验
创建时间: 2026-04-14
最后更新: 2026-04-14 - 初始版本创建
作者: AI Assistant
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>如何做饭 - 美味菜谱</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@400;600;700;900&family=Noto+Sans+SC:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#1a1614;--card-bg:#faf6f1;--text:#2d2420;--text-light:#6b5d52;
--accent:#c45c3e;--accent-light:#e8a090;--gold:#d4a853;--cream:#f5ede3;
--overlay:rgba(26,22,20,0.65);--radius:24px;--shadow:0 25px 80px rgba(0,0,0,.35);
--font-display:'Noto Serif SC',serif;--font-body:'Noto Sans SC',sans-serif;
}
html,body{height:100%;overflow:hidden;background:var(--bg);font-family:var(--font-body);
color:var(--text);-webkit-font-smoothing:antialiased}
.app{position:relative;width:100%;height:100vh;overflow:hidden}
.top-bar{
position:fixed;top:0;left:0;right:0;z-index:100;
display:flex;align-items:center;justify-content:space-between;
padding:16px 24px;padding-top:max(16px,env(safe-area-inset-top));
background:linear-gradient(to bottom,var(--bg) 40%,transparent);
transition:opacity .4s ease
}
.top-bar.hidden{opacity:0;pointer-events:none}
.logo{font-family:var(--font-display);font-size:18px;font-weight:700;color:var(--cream);
letter-spacing:2px;display:flex;align-items:center;gap:8px}
.logo-icon{width:28px;height:28px;border-radius:8px;background:var(--accent);
display:flex;align-items:center;justify-content:center;font-size:16px}
.counter{color:var(--text-light);font-size:13px;font-weight:500;
background:rgba(255,255,255,.08);padding:6px 14px;border-radius:20px;
backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.06)}
.category-nav{
position:fixed;top:64px;left:0;right:0;z-index:99;
display:flex;gap:8px;padding:0 20px;overflow-x:auto;
scrollbar-width:none;-ms-overflow-style:none;
transition:transform .4s cubic-bezier(.4,0,.2,1),opacity .4s ease
}
.category-nav::-webkit-scrollbar{display:none}
.category-nav.hidden{transform:translateY(-120%);opacity:0}
.cat-chip{
flex-shrink:0;padding:7px 18px;border-radius:20px;font-size:13px;
font-weight:500;cursor:pointer;white-space:nowrap;transition:all .3s ease;
background:rgba(255,255,255,.07);color:var(--cream);opacity:.7;
border:1px solid transparent
}
.cat-chip:hover{opacity:1;background:rgba(255,255,255,.12)}
.cat-chip.active{background:var(--accent);color:#fff;opacity:1;
border-color:var(--accent-light);box-shadow:0 4px 16px rgba(196,92,62,.35)}
.viewport{
position:relative;width:100%;height:100%;perspective:1400px;
cursor:grab;cursor:-webkit-grab
}
.viewport:active{cursor:grabbing;cursor:-webkit-grabbing}
.card-container{
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
padding:100px 20px 40px
}
.dish-card{
position:relative;width:100%;max-width:520px;height:calc(100vh - 160px);
max-height:800px;border-radius:var(--radius);overflow:hidden;
background:var(--card-bg);box-shadow:var(--shadow);
transform-style:preserve-3d;transition:all .55s cubic-bezier(.4,0,.2,1);
opacity:0;pointer-events:none
}
.dish-card.active{opacity:1;pointer-events:auto;transform:translateZ(0) scale(1)}
.dish-card.prev{transform:translateX(-110%) rotateY(12deg) scale(.88);opacity:.4}
.dish-card.next{transform:translateX(110%) rotateY(-12deg) scale(.88);opacity:.4}
.card-image-wrap{
position:relative;width:100%;height:62%;overflow:hidden;background:var(--cream)
}
.card-image{
width:100%;height:100%;object-fit:cover;
transition:transform .6s cubic-bezier(.4,0,.2,1),filter .4s ease
}
.dish-card.active .card-image{transform:scale(1.02)}
.dish-card.active:hover .card-image{transform:scale(1.06)}
.image-overlay{
position:absolute;bottom:0;left:0;right:0;height:50%;
background:linear-gradient(to top,rgba(0,0,0,.55) 0%,transparent 100%);
pointer-events:none
}
.card-badge{
position:absolute;top:18px;left:18px;z-index:3;
padding:6px 14px;border-radius:12px;font-size:11.5px;font-weight:600;
color:#fff;background:var(--accent);backdrop-filter:blur(12px);
letter-spacing:.5px;text-transform:uppercase;
box-shadow:0 4px 14px rgba(196,92,62,.4)
}
.fav-btn{
position:absolute;top:18px;right:18px;z-index:3;width:40px;height:40px;
border-radius:50%;background:rgba(255,255,255,.9);border:none;
cursor:pointer;display:flex;align-items:center;justify-content:center;
font-size:18px;transition:all .3s ease;box-shadow:0 3px 12px rgba(0,0,0,.15)
}
.fav-btn:hover{transform:scale(1.12)}
.fav-btn.favorited{background:var(--accent);color:#fff}
.fav-btn.favorited svg{fill:#fff}
.card-content{padding:28px 28px 24px;height:38%;display:flex;flex-direction:column}
.card-category{
display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:600;
color:var(--accent);text-transform:uppercase;letter-spacing:1.5px;margin-bottom:10px
}
.cat-dot{width:6px;height:6px;border-radius:50%;background:var(--accent)}
.card-title{
font-family:var(--font-display);font-size:30px;font-weight:900;line-height:1.25;
color:var(--text);margin-bottom:12px;letter-spacing:-.5px
}
.card-desc{
font-size:14.5px;line-height:1.75;color:var(--text-light);flex:1;
overflow:hidden;display:-webkit-box;-webkit-line-clamp:3;line-clamp:3;
-webkit-box-orient:vertical
}
.card-footer{
display:flex;align-items:center;justify-content:space-between;
padding-top:16px;border-top:1px solid rgba(0,0,0,.06);margin-top:auto
}
.card-meta{display:flex;gap:16px;font-size:12.5px;color:var(--text-light)}
.meta-item{display:flex;align-items:center;gap:5px}
.nav-hint{font-size:11px;color:var(--text-light);opacity:.5}
.nav-controls{
position:fixed;bottom:0;left:0;right:0;z-index:100;
display:flex;align-items:center;justify-content:center;gap:12px;
padding:16px 24px;padding-bottom:max(16px,env(safe-area-inset-bottom));
background:linear-gradient(to top,var(--bg) 50%,transparent)
}
.nav-btn{
width:48px;height:48px;border-radius:50%;border:none;background:rgba(255,255,255,.08);
color:var(--cream);font-size:20px;cursor:pointer;display:flex;
align-items:center;justify-content:center;transition:all .3s ease;
backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.06)
}
.nav-btn:hover:not(:disabled){background:rgba(255,255,255,.15);transform:scale(1.08)}
.nav-btn:disabled{opacity:.25;cursor:not-allowed}
.nav-btn:active:not(:disabled){transform:scale(.94)}
.progress-track{
width:120px;height:3px;background:rgba(255,255,255,.12);border-radius:2px;
overflow:hidden
}
.progress-fill{
height:100%;background:var(--accent);border-radius:2px;
transition:width .4s cubic-bezier(.4,0,.2,1)
}
.search-overlay{
position:fixed;inset:0;z-index:200;background:rgba(26,22,20,.95);
backdrop-filter:blur(20px);display:flex;flex-direction:column;
align-items:center;justify-content:flex-start;padding-top:120px;
opacity:0;pointer-events:none;transition:all .4s ease
}
.search-overlay.show{opacity:1;pointer-events:auto}
.search-overlay .search-input-wrap{
width:90%;max-width:480px;position:relative
}
.search-overlay input{
width:100%;padding:16px 56px 16px 24px;border-radius:16px;border:1px solid rgba(255,255,255,.15);
background:rgba(255,255,255,.07);color:var(--cream);font-size:17px;
font-family:var(--font-body);outline:none;transition:border-color .3s
}
.search-overlay input:focus{border-color:var(--accent)}
.search-overlay input::placeholder{color:rgba(255,255,255,.35)}
.search-close{
position:absolute;right:10px;top:50%;transform:translateY(-50%);
width:36px;height:36px;border-radius:50%;border:none;background:transparent;
color:var(--cream);font-size:18px;cursor:pointer;display:flex;
align-items:center;justify-content:center
}
.search-results{
width:90%;max-width:480px;margin-top:24px;max-height:50vh;overflow-y:auto
}
.search-item{
display:flex;align-items:center;gap:14px;padding:14px 16px;border-radius:14px;
cursor:pointer;transition:background .2s;margin-bottom:4px
}
.search-item:hover{background:rgba(255,255,255,.07)}
.search-item img{width:48px;height:48px;border-radius:10px;object-fit:cover}
.search-item-info h4{color:var(--cream);font-size:15px;font-weight:500}
.search-item-info span{color:rgba(255,255,255,.45);font-size:12px}
.search-btn{
width:40px;height:40px;border-radius:50%;border:none;background:transparent;
color:var(--cream);font-size:18px;cursor:pointer;display:flex;
align-items:center;justify-content:center;transition:all .3s ease
}
.search-btn:hover{background:rgba(255,255,255,.1);border-radius:50%}
.grid-toggle{
position:fixed;bottom:90px;right:24px;z-index:98;
width:46px;height:46px;border-radius:14px;border:none;
background:rgba(255,255,255,.09);color:var(--cream);
font-size:19px;cursor:pointer;display:flex;align-items:center;
justify-content:center;transition:all .3s ease;backdrop-filter:blur(10px);
border:1px solid rgba(255,255,255,.06)
}
.grid-toggle:hover{background:rgba(255,255,255,.15);transform:scale(1.06)}
.grid-view{
position:fixed;inset:0;z-index:150;background:var(--bg);
overflow-y:auto;padding:90px 16px 40px;
opacity:0;pointer-events:none;transition:opacity .4s ease
}
.grid-view.show{opacity:1;pointer-events:auto}
.grid-header{text-align:center;margin-bottom:32px}
.grid-header h2{
font-family:var(--font-display);font-size:28px;font-weight:700;color:var(--cream)
}
.grid-header p{color:var(--text-light);font-size:14px;margin-top:6px;opacity:.6}
.grid-cats{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-bottom:28px}
.grid-cat{
padding:6px 16px;border-radius:20px;font-size:13px;font-weight:500;
cursor:pointer;background:rgba(255,255,255,.07);color:var(--cream);
opacity:.7;transition:all .3s;border:1px solid transparent
}
.grid-cat:hover{opacity:1}
.grid-cat.active{background:var(--accent);color:#fff;opacity:1}
.grid-grid{
display:grid;grid-template-columns:repeat(auto-fill,minmax(155px,1fr));gap:14px;
max-width:900px;margin:0 auto
}
.grid-item{
border-radius:16px;overflow:hidden;background:var(--card-bg);
cursor:pointer;transition:all .3s ease;aspect-ratio:3/4
}
.grid-item:hover{transform:translateY(-4px);box-shadow:0 12px 32px rgba(0,0,0,.25)}
.grid-item img{width:100%;height:70%;object-fit:cover}
.grid-item-info{padding:10px 12px}
.grid-item-info h4{font-size:13.5px;font-weight:600;color:var(--text);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis
}
.grid-item-info span{font-size:11px;color:var(--text-light)}
.toast{
position:fixed;top:100px;left:50%;transform:translateX(-50%) translateY(-20px);
z-index:300;padding:12px 24px;border-radius:14px;background:var(--accent);
color:#fff;font-size:14px;font-weight:500;opacity:0;pointer-events:none;
transition:all .4s cubic-bezier(.4,0,.2,1);box-shadow:0 8px 30px rgba(196,92,62,.4)
}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
@keyframes fadeInUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}
.dish-card.active .card-content>*,.dish-card.active .card-badge,.dish-card.active .fav-btn{
animation:fadeInUp .5s cubic-bezier(.4,0,.2,1) both
}
.dish-card.active .card-category{animation-delay:.05s}
.dish-card.active .card-title{animation-delay:.1s}
.dish-card.active .card-desc{animation-delay:.15s}
.dish-card.active .card-footer{animation-delay:.2s}
.dish-card.active .card-badge{animation-delay:.05s}
.dish-card.active .fav-btn{animation-delay:.15s}
@media(max-width:600px){
:root{--radius:18px}
.card-container{padding:80px 12px 30px}
.dish-card{max-height:760px;border-radius:18px}
.card-title{font-size:25px}
.card-content{padding:22px 20px 18px}
.top-bar{padding:12px 16px}
.category-nav{top:56px;padding:0 12px}
.cat-chip{padding:5px 14px;font-size:12px}
.nav-btn{width:42px;height:42px}
.grid-grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}
}
</style>
</head>
<body>
<div class="app" id="app">
<div class="top-bar" id="topBar">
<div class="logo"><span class="logo-icon">🍳</span><span>如何做饭</span></div>
<div class="counter" id="counter">1 / 341</div>
<button class="search-btn" id="searchBtn" title="搜索">🔍</button>
</div>
<div class="category-nav" id="catNav"></div>
<div class="viewport" id="viewport">
<div class="card-container" id="cardContainer"></div>
</div>
<div class="nav-controls">
<button class="nav-btn" id="prevBtn" title="上一道"></button>
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
<button class="nav-btn" id="nextBtn" title="下一道"></button>
</div>
<button class="grid-toggle" id="gridToggle" title="网格视图"></button>
</div>
<div class="search-overlay" id="searchOverlay">
<div class="search-input-wrap">
<input type="text" id="searchInput" placeholder="搜索菜谱...">
<button class="search-close" id="searchClose"></button>
</div>
<div class="search-results" id="searchResults"></div>
</div>
<div class="grid-view" id="gridView">
<div class="grid-header">
<h2>全部菜谱</h2>
<p>共 341 道美味佳肴</p>
</div>
<div class="grid-cats" id="gridCats"></div>
<div class="grid-grid" id="gridGrid"></div>
</div>
<div class="toast" id="toast"></div>
<script>
let recipes=[],filteredRecipes=[],currentIndex=0,favorites=new Set(),touchStartX=0,activeCategory='all',CATEGORY_MAP={},DESC_MAP={};
async function init(){
try{
const res=await fetch('recipes.json');
const d=await res.json();
recipes=d.recipes||[];
CATEGORY_MAP=(d._meta&&d._meta.categories)||{};
DESC_MAP=(d._meta&&d._meta.descriptions)||{};
}catch(e){console.error('Failed to load recipes:',e);return}
filteredRecipes=[...recipes];
renderCategories();
renderCards();
bindEvents();
updateUI();
}
function renderCategories(){
const nav=document.getElementById('catNav');
nav.innerHTML='<div class="cat-chip active" data-cat="all">✨ 全部</div>';
Object.keys(CATEGORY_MAP).forEach(cat=>{
const c=CATEGORY_MAP[cat];
const count=recipes.filter(r=>r.category===cat).length;
nav.innerHTML+=`<div class="cat-chip" data-cat="${cat}">${c.icon} ${c.name} (${count})</div>`;
});
nav.querySelectorAll('.cat-chip').forEach(chip=>{
chip.addEventListener('click',()=>filterByCategory(chip.dataset.cat))
});
}
function filterByCategory(cat){
activeCategory=cat;
document.querySelectorAll('.cat-chip').forEach(c=>c.classList.toggle('active',c.dataset.cat===cat));
if(cat==='all')filteredRecipes=[...recipes];
else filteredRecipes=recipes.filter(r=>r.category===cat);
currentIndex=0;
renderCards();
updateUI();
}
function renderCards(){
const container=document.getElementById('cardContainer');
container.innerHTML='';
const total=filteredRecipes.length;
for(let i=0;i<Math.min(3,total);i++){
const idx=(currentIndex+i)%total;
const r=filteredRecipes[idx];
const pos=i===0?'active':i===1?'next':'prev';
container.appendChild(createCard(r,pos,idx));
}
}
function createCard(recipe,pos,index){
const cat=CATEGORY_MAP[recipe.category]||{};
const desc=DESC_MAP[recipe.category]||'一道精心制作的美味佳肴,等待你来品尝与发现。';
const isFav=favorites.has(recipe.name);
const div=document.createElement('div');
div.className='dish-card '+pos;
div.dataset.index=index;
div.innerHTML=`
<div class="card-image-wrap">
<img class="card-image" src="${recipe.image}" alt="${recipe.name}" loading="lazy"
onerror="this.style.background='linear-gradient(135deg,#f5e6d3,#e8d4bc)'">
<div class="image-overlay"></div>
<span class="card-badge">${cat.icon||''} ${cat.name||''}</span>
<button class="fav-btn ${isFav?'favorited':''}" data-name="${recipe.name}"
onclick="toggleFavorite('${recipe.name}',this)" title="收藏">
<svg width="18" height="18" viewBox="0 0 24 24" fill="${isFav?'#fff':'none'}" stroke="${isFav?'#fff':'currentColor'}" stroke-width="2">
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/>
</svg>
</button>
</div>
<div class="card-content">
<div class="card-category"><span class="cat-dot"></span>${cat.name||'菜谱'}</div>
<h2 class="card-title">${recipe.name}</h2>
<p class="card-desc">${desc}</p>
<div class="card-footer">
<div class="card-meta">
<span class="meta-item">⏱ 随时享用</span>
<span class="meta-item">👨‍🍳 家常做法</span>
</div>
<span class="nav-hint">← → 切换</span>
</div>
</div>`;
return div;
}
function updateUI(){
const total=filteredRecipes.length;
if(total===0)return;
document.getElementById('counter').textContent=`${currentIndex+1} / ${total}`;
document.getElementById('progressFill').style.width=((currentIndex+1)/total*100)+'%';
document.getElementById('prevBtn').disabled=currentIndex<=0;
document.getElementById('nextBtn').disabled=currentIndex>=total-1;
}
function go(dir){
const total=filteredRecipes.length;
if(total===0)return;
const newIndex=currentIndex+dir;
if(newIndex<0||newIndex>=total)return;
currentIndex=newIndex;
const cards=document.querySelectorAll('.dish-card');
cards.forEach(card=>{
card.classList.remove('active','prev','next');
const idx=parseInt(card.dataset.index);
if(idx===currentIndex)card.classList.add('active');
else if(idx<currentIndex)card.classList.add('prev');
else card.classList.add('next');
});
const container=document.getElementById('cardContainer');
const lastCard=container.querySelector('[data-index="'+(currentIndex-dir)+'"]');
if(lastCard)lastCard.remove();
const nextIdx=(currentIndex+(dir>0?2:-1)+total)%total;
const r=filteredRecipes[nextIdx];
const pos=dir>0?'next':'prev';
container.appendChild(createCard(r,pos,nextIdx));
updateUI();
}
function toggleFavorite(name,btn){
if(favorites.has(name)){favorites.delete(name);btn.classList.remove('favorited');
btn.querySelector('svg').setAttribute('fill','none');btn.querySelector('svg').setAttribute('stroke','currentColor');
showToast('已取消收藏')}else{favorites.add(name);btn.classList.add('favorited');
btn.querySelector('svg').setAttribute('fill','#fff');btn.querySelector('svg').setAttribute('stroke','#fff');
showToast('已收藏:'+name)}
}
function showToast(msg){
const t=document.getElementById('toast');t.textContent=msg;t.classList.add('show');
setTimeout(()=>t.classList.remove('show'),1800)
}
function openSearch(){document.getElementById('searchOverlay').classList.add('show');
setTimeout(()=>document.getElementById('searchInput').focus(),100)}
function closeSearch(){document.getElementById('searchOverlay').classList.remove('show');
document.getElementById('searchInput').value='';document.getElementById('searchResults').innerHTML=''}
function doSearch(q){
const results=document.getElementById('results');
const query=q.trim().toLowerCase();
if(!query){document.getElementById('searchResults').innerHTML='';return}
const found=recipes.filter(r=>r.name.toLowerCase().includes(query));
const html=found.slice(0,20).map(r=>`
<div class="search-item" onclick="jumpToDish('${r.name}')">
<img src="${r.image}" alt="${r.name}">
<div class="search-item-info">
<h4>${r.name}</h4><span>${CATEGORY_MAP[r.category]?.name||''}</span>
</div>
</div>`).join('');
document.getElementById('searchResults').innerHTML=html||
'<p style="color:rgba(255,255,255,.4);text-align:center;padding:20px">未找到相关菜谱</p>'
}
function jumpToDish(name){
const idx=filteredRecipes.findIndex(r=>r.name===name);
if(idx!==-1){closeSearch();if(idx!==currentIndex){
while(currentIndex<idx)go(1);while(currentIndex>idx)go(-1)}}else{
closeSearch();
activeCategory='all';document.querySelectorAll('.cat-chip').forEach(c=>c.classList.toggle('active',c.dataset.cat==='all'));
filteredRecipes=[...recipes];const ri=recipes.findIndex(r=>r.name===name);
if(ri!==-1){currentIndex=ri;renderCards();updateUI()}
}
}
function toggleGridView(){
const gv=document.getElementById('gridView');gv.classList.toggle('show');
if(gv.classList.contains('show'))renderGrid()
}
function renderGrid(){
const cats=document.getElementById('gridCats');
cats.innerHTML='<div class="grid-cat active" data-gcat="all">✨ 全部</div>';
Object.keys(CATEGORY_MAP).forEach(cat=>{
const c=CATEGORY_MAP[cat];
cats.innerHTML+=`<div class="grid-cat" data-gcat="${cat}">${c.icon} ${c.name}</div>`
});
cats.querySelectorAll('.grid-cat').forEach(c=>{
c.addEventListener('click',()=>{
cats.querySelectorAll('.grid-cat').forEach(x=>x.classList.remove('active'));
c.classList.add('active');renderGridItems(c.dataset.gcat)
})
});
renderGridItems('all')
}
function renderGridItems(filterCat){
const list=filterCat==='all'?recipes:recipes.filter(r=>r.category===filterCat);
const grid=document.getElementById('gridGrid');
grid.innerHTML=list.map(r=>`
<div class="grid-item" onclick="jumpToDish('${r.name}');toggleGridView()">
<img src="${r.image}" alt="${r.name}" loading="lazy">
<div class="grid-item-info"><h4>${r.name}</h4><span>${CATEGORY_MAP[r.category]?.name||''}</span></div>
</div>`).join('')
}
function bindEvents(){
document.getElementById('prevBtn').addEventListener('click',()=>go(-1));
document.getElementById('nextBtn').addEventListener('click',()=>go(1));
document.getElementById('searchBtn').addEventListener('click',openSearch);
document.getElementById('searchClose').addEventListener('click',closeSearch);
document.getElementById('searchInput').addEventListener('input',e=>doSearch(e.target.value));
document.getElementById('searchInput').addEventListener('keydown',e=>{if(e.key==='Escape')closeSearch()});
document.getElementById('searchOverlay').addEventListener('click',e=>{if(e.target.id==='searchOverlay')closeSearch()});
document.getElementById('gridToggle').addEventListener('click',toggleGridView);
const vp=document.getElementById('viewport');
let autoHideTop;
vp.addEventListener('mousemove',()=>{
document.getElementById('topBar').classList.remove('hidden');
document.getElementById('catNav').classList.remove('hidden');
clearTimeout(autoHideTop);
autoHideTop=setTimeout(()=>{
document.getElementById('topBar').classList.add('hidden');
document.getElementById('catNav').classList.add('hidden')
},3000)
});
vp.addEventListener('touchstart',e=>{touchStartX=e.touches[0].clientX},{passive:true});
vp.addEventListener('touchend',e=>{
const diff=e.changedTouches[0].clientX-touchStartX;
if(Math.abs(diff)>60)go(diff<0?1:-1)
},{passive:true});
document.addEventListener('keydown',e=>{
if(document.getElementById('searchOverlay').classList.contains('show'))return;
if(document.getElementById('gridView').classList.contains('show'))return;
if(e.key==='ArrowLeft'||e.key==='ArrowUp'){e.preventDefault();go(-1)}
if(e.key==='ArrowRight'||e.key==='ArrowDown'){e.preventDefault();go(1)}
if(e.key===' '||e.key==='Enter'){
e.preventDefault();
const fav=document.querySelector('.dish-card.active .fav-btn');
if(fav)fav.click()}
});
}
init();
</script>
</body>
</html>

2463
docs/api/doc/recipes.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -498,6 +498,7 @@
| 🌙 就寝 | `Get.toNamed` | 就寝提醒 | `/profile/bedtime-reminder` | - |
| 📋 营养 | `Get.toNamed` | 营养中心 | `/nutrition` | - |
| 💬 反馈 | `Get.toNamed` | 意见反馈 | `/chat` | - |
| ❤️ 迷你卡片 | `Get.toNamed` | 迷你卡片页 | `/mini-card` | - |
### 🎨 美观问题
| # | 问题 | 严重度 | 建议 |
@@ -825,6 +826,104 @@
---
## 🃏 迷你卡片页 (MiniCardPage)
```
┌─────────────────────────────┐
│ ← 返回 迷你卡片 🔍 │ ← CupertinoNavigationBar
├─────────────────────────────┤
│ [全部][素菜][荤菜][水产]... │ ← 横向分类标签
├─────────────────────────────┤
│ │
│ ┌─────────────────────┐ │
│ │ 🃏分类 ❤️ ⭐ 📤 │ │ ← 液态玻璃顶部操作栏
│ │ │ │
│ │ [菜品图片] │ │ ← _MiniCardImageView 独立组件
│ │ │ │
│ │ ┌─────────────────┐ │ │ ← 液态玻璃底部信息区
│ │ │ 菜名 │ │ │
│ │ │ 🏷️ 分类 详情 → │ │ │
│ │ └─────────────────┘ │ │
│ └─────────────────────┘ │
│ │
│ ┌─────────────────────┐ │
│ │ 👎 │ ❤️ │ ⭐ │ ↩️ │ │ ← 底部操作按钮
│ └─────────────────────┘ │
│ │
│ ████████░░░░ 12/341 │ ← 进度条
│ │
└─────────────────────────────┘
全屏图片查看器(点击卡片触发):
┌─────────────────────────────┐
│ ✕ 1/341 📤❤️ │ ← 液态玻璃顶栏
│ │
│ │
│ [全屏菜品图片] │ ← PageView 左右滑动
│ │
│ │
│ ┌─────────────────────────┐ │ ← 液态玻璃底部信息
│ │ 菜名 │ │
│ │ 🏷️ 分类 · 描述 详情 → │ │
│ └─────────────────────────┘ │
└─────────────────────────────┘
首页瀑布流迷你卡片横幅:
┌─────────────────────────────┐
│ 🃏 迷你卡片 │ ← 液态玻璃标签
│ │
│ [全宽菜品图片] │ ← MiniCardDiscoverCard
│ │
│ ┌─────────────────────────┐ │ ← 液态玻璃底部
│ │ 菜名 🏷️分类 查看 → │ │
│ └─────────────────────────┘ │
└─────────────────────────────┘
网格视图模式:
┌──────┐ ┌──────┐ ┌──────┐
│[图片]│ │[图片]│ │[图片]│
│菜名 │ │菜名 │ │菜名 │
│分类 │ │分类 │ │分类 │
└──────┘ └──────┘ └──────┘
```
### 📄 页面文件
- `lib/src/pages/discover/mini_card_page.dart` — 主页面+全屏查看器+图片组件
- `lib/src/models/mini_card_model.dart` — 数据模型
- `lib/src/services/data/mini_card_service.dart` — 数据服务(缓存优先)
- `lib/src/widgets/discover/mini_card_discover_card.dart` — 瀑布流横幅组件
### 🔗 跳转关系
| 触发元素 | 跳转方式 | 目标页面 | 路由 | 传参 |
|---------|---------|---------|------|------|
| 个人中心迷你卡片 | `Get.toNamed` | 迷你卡片页 | `/mini-card` | - |
| 首页瀑布流横幅 | `Get.toNamed` | 迷你卡片页 | `/mini-card` | initialRecipeId |
| 卡片点击 | 全屏查看器 | 本页弹窗 | - | - |
| 详情按钮 | `Get.toNamed` | 菜品详情页 | `/recipe-detail` | id |
| 搜索结果点击 | 本页跳转 | 本页定位 | - | index |
### 🎨 功能特性
| # | 功能 | 说明 | 状态 |
|---|------|------|------|
| 1 | 🃏 左右滑动 | 拖拽>80px触发滑动动画效果 | ✅ |
| 2 | ❤️ 喜欢/不喜欢 | 持久化到SharedPreferences | ✅ |
| 3 | ⭐ 收藏 | 与全局收藏系统联动 | ✅ |
| 4 | 🔍 搜索 | 搜索菜品名称,点击跳转定位 | ✅ |
| 5 | 📂 分类筛选 | 11个分类横向滚动标签 | ✅ |
| 6 | 📱 网格视图 | 卡片/网格双视图切换 | ✅ |
| 7 | 💾 本地缓存 | 5-10条记录不含图片 | ✅ |
| 8 | 🗑️ 缓存管理 | 缓存管理页可清理 | ✅ |
| 9 | 📊 进度条 | 显示浏览进度 | ✅ |
| 10 | 🏠 瀑布流嵌入 | 首页1:20比例插入迷你卡片横幅 | ✅ |
| 11 | 🖼️ 图片独立组件 | _MiniCardImageView 文本在图片内底部 | ✅ |
| 12 | 🪟 液态玻璃 | 顶部操作栏+底部信息区 GlassContainer | ✅ |
| 13 | 📱 全屏查看器 | PageView+异步预加载5张相邻图片 | ✅ |
| 14 | 📤 分享 | share_plus 分享菜品信息+图片URL | ✅ |
| 15 | 💾 缓存优先 | 查看过卡片存入缓存,先显示缓存 | ✅ |
| 16 | 🔗 路由参数 | initialRecipeId 从首页跳转指定卡片 | ✅ |
---
## 📊 全局美观问题汇总
| 优先级 | 问题 | 影响页面 | 建议 | 状态 |
@@ -888,20 +987,6 @@
> 以下功能在v0.92.0版本已全部实现
| # | 功能 | API接口 | 数据源 | 页面位置 | 开发复杂度 | 状态 |
|---|------|---------|--------|---------|-----------|------|
| 1 | 🕐 用餐时段推荐 | `api_filter.php?act=meal_times` | eating_times.json(34种) | 首页/工具中心 | ⭐⭐ | ✅v0.92.0 |
| 2 | ⚠️ 过敏原警示 | `api.php?act=full` allergens字段 | gmy.json(585种) | 菜品详情页 | ⭐⭐ | ✅v0.92.0 |
| 3 | 📊 营养可视化 | `api.php?act=full` nutrition字段 | nutrition_types.json(31种) | 菜品详情页/营养中心 | ⭐⭐⭐ | ✅v0.92.0 |
| 4 | 🏆 评分排行榜 | `api_hot.php?type=recipe&sort=rate` | api_hot.php | 热门排行页 | ⭐ | ✅v0.92.0 |
| 5 | 📱 迷你信息加载 | `api.php?act=mini&id=` | api.php | 列表页/卡片 | ⭐⭐ | ✅v0.92.0 |
| 6 | 🔍 排除筛选 | `api_filter.php?act=filter_recipes` exclude_*参数 | api_filter.php | 高级搜索页 | ⭐ | ✅v0.92.0 |
| 7 | 🌐 IP状态显示 | `api_action.php?act=ip_status` | api_action.php | 菜品详情页 | ⭐ | ✅v0.92.0 |
| 8 | 🏷️ 分类标签联动 | `api_filter.php?act=category_tags&category_id=` | api_filter.php | 分类浏览页/高级搜索 | ⭐⭐ | ✅v0.92.0 |
| 9 | 🥗 食材分类浏览 | `api_filter.php?act=ingredient_main_categories/sub_categories` | api_filter.php | 发现页/工具中心 | ⭐⭐ | ✅v0.92.0 |
| 10 | 📋 食谱子分类 | `api_filter.php?act=recipe_sub_categories&parent_id=` | api_filter.php | 分类浏览页 | ⭐ | ✅v0.92.0 |
| 11 | 🎲 筛选步骤引导 | `api_what_to_eat.php?act=filter_steps` | api_what_to_eat.php | 今天吃什么 | ⭐⭐ | ✅v0.92.0 |
| 12 | 🔢 编码/模糊查询 | `api_what_to_eat.php?act=detail&code=/title=` | api_what_to_eat.php | 搜索页 | ⭐ | ✅v0.92.0 |
### 🟡 需组合API开发P3优先级

View File

@@ -1,6 +1,6 @@
# 📋 未完成功能清单
> 创建: 2026-04-09 | 更新: 2026-04-13 v0.92.4 | 优先级: P1=核心 P2=重要 P3=增强 | 优先级值1-5(5=最高)
> 创建: 2026-04-09 | 更新: 2026-04-14 v0.92.6 | 优先级: P1=核心 P2=重要 P3=增强 | 优先级值1-5(5=最高)
---
@@ -174,28 +174,6 @@
## ✅ 已完成阶段(精简记录)
### 阶段三十五:食材详情页闪退修复 ✅
- 🐛 setState() after dispose() 修复4处添加mounted检查
- 🍃 CupertinoIcons.leaf→Icons.eco 修复
### 阶段三十四:食材详情本地缓存+缓存管理 ✅
- 🥬 IngredientModel.toJson + IngredientStatistics.toJson 序列化方法
- 💾 RecipeRepository.fetchIngredientDetail 缓存逻辑7天有效期优先读取本地
- 🗑️ CacheManagePage 食材缓存管理(概览+操作+列表+左滑删除)
### 阶段三十三全局UI统一圆角/颜色/空状态/加载) ✅
- 📐 统一圆角89处硬编码 → DesignTokens变量
- 🎨 统一颜色20处硬编码 → DesignTokens变量
- 💎 增强EmptyState毛玻璃卡片+图标圆形背景
- 🔄 增强LoadingIndicator毛玻璃卡片+主题色指示器
### 阶段三十二:主题色全局生效修复 ✅
- 🎨 批量替换为 DesignTokens.dynamicPrimary59个文件439处引用
### 阶段三十一:搜索功能修复与高级搜索 ✅
- 🔍 切换到global_search接口4-Tab结果
- ⚙️ 高级搜索页面
### 阶段三十六21项功能批量实现 ✅
- 🏆 评分排行榜HotPage sort=rate排序HotRepository+HotController+HotPage三层联动
- 🌐 IP状态显示菜品详情页评分前显示剩余次数
@@ -285,3 +263,35 @@
| 接口 | act | 功能 | 可开发功能 |
|------|-----|------|-----------|
| `api.php` | unified_* type=ingredient | 统一格式食材 | 食材数据标准化 |
---
## 🃏 阶段三十九迷你卡片功能v0.92.8
### ✅ 已完成
- 🃏 迷你卡片页面Tinder风格左右滑动浏览菜品
- 📂 数据模型 MiniCardModel341道菜11个分类
- 🔍 分类筛选+搜索+网格视图
- ❤️ 收藏集成+喜欢/不喜欢记录
- 💾 本地缓存5-10条记录SharedPreferences
- 🗑️ 缓存管理页面新增迷你卡片缓存清理
- 🖼️ 图片独立组件 _MiniCardImageView文本在图片内底部展示
- 🪟 液态玻璃效果(顶部操作栏+底部信息区 GlassContainer
- 📱 全屏图片查看器PageView+异步预加载5张相邻图片
- 📤 分享按钮share_plus分享菜品信息+图片URL
- 💾 缓存优先加载(查看过的卡片存入缓存,先显示缓存再内部加载)
- 🏠 首页瀑布流插入迷你卡片1:20比例全宽横幅
- 每20个瀑布流item后插入1个迷你卡片横幅
- MiniCardDiscoverCard 组件:液态玻璃+全宽图片+分类标签
- SliverMainAxisGroup 分组渲染
- 🔗 路由参数支持initialRecipeId从首页跳转到指定卡片
- 🔄 MiniCardService 独立数据服务(缓存优先,多页面复用)
### 🟡 待开发:迷你卡片交互增强
**优先级**P3增强功能| 优先级值3
**工作量**:⭐⭐(低)
- 迷你卡片横幅支持横向滑动预览多个菜品
- 迷你卡片瀑布流插入比例可配置当前固定1:20
- 迷你卡片数据自动刷新策略

View File

@@ -0,0 +1,613 @@
# 功能轮播组件实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在主页创建一个功能完整的轮播组件,替换现有的营养卡片,提供六个功能入口。
**Architecture:** 使用 PageView.builder + Timer 实现自动轮播,每个轮播项采用渐变背景卡片设计,支持手动滑动和自动播放切换。
**Tech Stack:** Flutter, GetX, Cupertino 组件, url_launcher
---
## 文件结构
```
lib/src/widgets/carousel/
└── feature_carousel_card.dart # 新建:主轮播组件
修改文件:
├── lib/src/pages/home/home_page.dart:656 # 替换 NutritionDashboardCard
└── CHANGELOG.md # 记录变更
```
---
### Task 1: 创建轮播组件基础结构
**Files:**
- Create: `lib/src/widgets/carousel/feature_carousel_card.dart`
- [ ] **Step 1: 创建轮播组件文件框架**
```dart
/*
* 文件: feature_carousel_card.dart
* 名称: 功能轮播卡片
* 作用: 首页功能入口轮播组件,包含今日营养、应用推荐等功能
* 创建: 2026-04-14
* 更新: 2026-04-14 初始创建
*/
import 'dart:async';
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/config/app_routes.dart';
import 'package:mom_kitchen/src/controllers/data/meal_record_controller.dart';
import 'package:mom_kitchen/src/repositories/recipe_repository.dart';
import 'package:mom_kitchen/src/widgets/custom_widgets.dart';
import 'package:mom_kitchen/src/services/ui/toast_service.dart';
class FeatureCarouselCard extends StatefulWidget {
const FeatureCarouselCard({super.key});
@override
State<FeatureCarouselCard> createState() => _FeatureCarouselCardState();
}
class _FeatureCarouselCardState extends State<FeatureCarouselCard> {
final PageController _pageController = PageController();
Timer? _autoPlayTimer;
int _currentPage = 0;
bool _isUserInteracting = false;
static const int _itemCount = 6;
static const Duration _autoPlayInterval = Duration(seconds: 3);
static const Duration _animationDuration = Duration(milliseconds: 350);
@override
void initState() {
super.initState();
_startAutoPlay();
}
@override
void dispose() {
_autoPlayTimer?.cancel();
_pageController.dispose();
super.dispose();
}
void _startAutoPlay() {
_autoPlayTimer = Timer.periodic(_autoPlayInterval, (_) {
if (!_isUserInteracting && mounted) {
final nextPage = (_currentPage + 1) % _itemCount;
_pageController.animateToPage(
nextPage,
duration: _animationDuration,
curve: Curves.easeInOutCubic,
);
}
});
}
void _onPageChanged(int page) {
setState(() => _currentPage = page);
}
void _onUserInteraction() {
if (!_isUserInteracting) {
setState(() => _isUserInteracting = true);
Future.delayed(const Duration(seconds: 3), () {
if (mounted) setState(() => _isUserInteracting = false);
});
}
}
@override
Widget build(BuildContext context) {
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
return Container(
margin: const EdgeInsets.symmetric(horizontal: DesignTokens.space4),
decoration: BoxDecoration(
color: isDark ? DarkDesignTokens.card : DesignTokens.card,
borderRadius: DesignTokens.borderRadiusLg,
boxShadow: DesignTokens.shadowsSm,
),
child: ClipRRect(
borderRadius: DesignTokens.borderRadiusLg,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 180,
child: GestureDetector(
onPanDown: (_) => _onUserInteraction(),
child: PageView.builder(
controller: _pageController,
onPageChanged: _onPageChanged,
itemCount: _itemCount,
itemBuilder: (context, index) => _buildCarouselItem(index, isDark),
),
),
),
_buildPageIndicator(isDark),
],
),
),
);
}
Widget _buildCarouselItem(int index, bool isDark) {
switch (index) {
case 0:
return _buildNutritionItem(isDark);
case 1:
return _buildAppRecommendItem(isDark);
case 2:
return _buildCookingTipsItem(isDark);
case 3:
return _buildStatsDashboardItem(isDark);
case 4:
return _buildRandomRecipeItem(isDark);
case 5:
return _buildComingSoonItem(isDark);
default:
return const SizedBox.shrink();
}
}
Widget _buildPageIndicator(bool isDark) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_itemCount, (index) {
final isActive = index == _currentPage;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: isActive ? 8 : 6,
height: isActive ? 8 : 6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive
? DesignTokens.dynamicPrimary
: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
.withValues(alpha: 0.4),
),
);
}),
),
);
}
Widget _buildNutritionItem(bool isDark) {
return _CarouselItemContainer(
gradient: const [Color(0xFF007AFF), Color(0xFF5AC8FA)],
child: _buildNutritionContent(isDark),
);
}
Widget _buildNutritionContent(bool isDark) {
try {
final controller = Get.find<MealRecordController>();
return Obx(() {
final calories = controller.dayNutrition['calories'] ?? 0;
final protein = controller.dayNutrition['protein'] ?? 0;
final fat = controller.dayNutrition['fat'] ?? 0;
final carbs = controller.dayNutrition['carbs'] ?? 0;
return Column(
children: [
Padding(
padding: const EdgeInsets.all(DesignTokens.space3),
child: Row(
children: [
const Text('📊', style: TextStyle(fontSize: 20)),
const SizedBox(width: DesignTokens.space2),
const Text(
'今日营养',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const Spacer(),
GestureDetector(
onTap: () => Get.toNamed('/nutrition'),
child: Row(
children: const [
Text(
'详情',
style: TextStyle(fontSize: 12, color: Colors.white70),
),
Icon(
CupertinoIcons.chevron_right,
size: 14,
color: Colors.white70,
),
],
),
),
],
),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
NutritionRing(
progress: controller.caloriesPercent,
size: 56,
strokeWidth: 6,
color: Colors.white,
centerLabel: 'kcal',
centerValue: '${calories.toInt()}',
bottomLabel: '热量',
),
NutritionRing(
progress: controller.proteinPercent,
size: 56,
strokeWidth: 6,
color: Colors.white,
centerLabel: 'g',
centerValue: '${protein.toInt()}',
bottomLabel: '蛋白质',
),
NutritionRing(
progress: controller.fatPercent,
size: 56,
strokeWidth: 6,
color: Colors.white,
centerLabel: 'g',
centerValue: '${fat.toInt()}',
bottomLabel: '脂肪',
),
NutritionRing(
progress: controller.carbsPercent,
size: 56,
strokeWidth: 6,
color: Colors.white,
centerLabel: 'g',
centerValue: '${carbs.toInt()}',
bottomLabel: '碳水',
),
],
),
),
],
);
});
} catch (_) {
return _buildNutritionPlaceholder(isDark);
}
}
Widget _buildNutritionPlaceholder(bool isDark) {
return _CarouselItemContainer(
gradient: const [Color(0xFF007AFF), Color(0xFF5AC8FA)],
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CupertinoActivityIndicator(radius: 12, color: Colors.white),
const SizedBox(width: DesignTokens.space3),
Text(
'正在加载营养数据…',
style: TextStyle(
fontSize: DesignTokens.fontMd,
color: Colors.white.withValues(alpha: 0.8),
),
),
],
),
),
);
}
Widget _buildAppRecommendItem(bool isDark) {
return _CarouselItemContainer(
gradient: const [Color(0xFF9C27B0), Color(0xFFE040FB)],
onTap: () => _showAppRecommendDialog(context),
child: _buildFeatureContent(
icon: '🌟',
title: '应用推荐',
subtitle: '发现更多优质应用',
badge: 'NEW',
),
);
}
void _showAppRecommendDialog(BuildContext context) {
showCupertinoDialog(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: const Text('应用推荐'),
content: const Text('即将跳转至浏览器访问推荐应用页面,是否继续?'),
actions: [
CupertinoDialogAction(
child: const Text('取消'),
onPressed: () => Navigator.pop(ctx),
),
CupertinoDialogAction(
isDefaultAction: true,
child: const Text('确认'),
onPressed: () async {
Navigator.pop(ctx);
final uri = Uri.parse('https://poe.vogov.cn/app.html');
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
ToastService.show(message: '无法打开链接 🥲');
}
},
),
],
),
);
}
Widget _buildCookingTipsItem(bool isDark) {
return _CarouselItemContainer(
gradient: const [Color(0xFFFF9500), Color(0xFFFFCC00)],
onTap: () => Get.toNamed(AppRoutes.cookingTips),
child: _buildFeatureContent(
icon: '👨‍🍳',
title: '厨艺技巧',
subtitle: '提升你的烹饪技能',
badge: null,
),
);
}
Widget _buildStatsDashboardItem(bool isDark) {
return _CarouselItemContainer(
gradient: const [Color(0xFF34C759), Color(0xFF30D158)],
onTap: () => Get.toNamed(AppRoutes.statsDashboard),
child: _buildFeatureContent(
icon: '📈',
title: '运营数据',
subtitle: '查看热门排行榜',
badge: null,
),
);
}
Widget _buildRandomRecipeItem(bool isDark) {
return _CarouselItemContainer(
gradient: const [Color(0xFFFF3B30), Color(0xFFFF6961)],
onTap: _randomRecipe,
child: _buildFeatureContent(
icon: '🎲',
title: '今天吃什么',
subtitle: '随机推荐一道美味',
badge: null,
),
);
}
Future<void> _randomRecipe() async {
try {
ToastService.show(message: '正在随机选择菜谱… 🎲');
final repo = RecipeRepository();
final result = await repo.fetchList(limit: 50);
if (result.items.isNotEmpty) {
final random = result.items[Random().nextInt(result.items.length)];
Get.toNamed('/recipe-detail', arguments: '${random.id}');
} else {
ToastService.show(message: '暂无菜谱数据 🥲');
}
} catch (e) {
ToastService.show(message: '随机推荐失败: $e 🔄');
}
}
Widget _buildComingSoonItem(bool isDark) {
return _CarouselItemContainer(
gradient: [
isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
],
child: _buildFeatureContent(
icon: '🎁',
title: '敬请期待',
subtitle: '更多功能正在开发中',
badge: null,
),
);
}
Widget _buildFeatureContent({
required String icon,
required String title,
required String subtitle,
String? badge,
}) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: const TextStyle(fontSize: 48)),
const SizedBox(height: DesignTokens.space2),
Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: DesignTokens.space1),
Text(
subtitle,
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: Colors.white.withValues(alpha: 0.8),
),
),
if (badge != null) ...[
const SizedBox(height: DesignTokens.space2),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(12),
),
child: Text(
badge,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
],
],
),
);
}
}
class _CarouselItemContainer extends StatelessWidget {
final List<Color> gradient;
final Widget child;
final VoidCallback? onTap;
const _CarouselItemContainer({
required this.gradient,
required this.child,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: gradient,
),
),
child: child,
),
);
}
}
```
- [ ] **Step 2: 检查语法错误**
运行: `flutter analyze lib/src/widgets/carousel/feature_carousel_card.dart`
预期: 无错误
---
### Task 2: 更新主页引用
**Files:**
- Modify: `lib/src/pages/home/home_page.dart:656`
- [ ] **Step 1: 更新 import 和替换组件**
找到第 656 行附近的代码:
```dart
SliverToBoxAdapter(child: const NutritionDashboardCard()),
```
替换为:
```dart
SliverToBoxAdapter(child: const FeatureCarouselCard()),
```
- [ ] **Step 2: 添加 import**
在文件顶部的 import 区域添加:
```dart
import 'package:mom_kitchen/src/widgets/carousel/feature_carousel_card.dart';
```
- [ ] **Step 3: 检查语法错误**
运行: `flutter analyze lib/src/pages/home/home_page.dart`
预期: 无错误
---
### Task 3: 更新 CHANGELOG
**Files:**
- Modify: `CHANGELOG.md`
- [ ] **Step 1: 添加版本变更记录**
在 CHANGELOG.md 顶部添加新版本记录:
```markdown
## [0.70.0] - 2026-04-14
### 新增
- 功能轮播组件 `FeatureCarouselCard`,替换原有的营养卡片
- 今日营养:展示营养数据环形图
- 应用推荐:弹窗确认后跳转浏览器
- 厨艺技巧:跳转烹饪技巧页面
- 运营数据:跳转数据大屏页面
- 今天吃什么:随机推荐菜谱
- 敬请期待:占位项
- 支持自动轮播3秒间隔和手动滑动
- 底部进度指示器
- 渐变背景卡片设计
### 变更
- 主页营养卡片替换为功能轮播组件
```
- [ ] **Step 2: 更新 pubspec.yaml 版本号**
`pubspec.yaml` 中的版本号从 `0.69.0+69` 更新为 `0.70.0+70`
---
### Task 4: 验证功能
- [ ] **Step 1: 运行应用**
运行: `flutter run`
预期: 应用正常启动,主页显示轮播组件
- [ ] **Step 2: 验证轮播功能**
手动验证:
1. 自动轮播是否每 3 秒切换
2. 手动滑动是否流畅
3. 底部指示器是否正确显示当前页
4. 深色模式是否正常显示
- [ ] **Step 3: 验证点击事件**
手动验证:
1. 点击"应用推荐"是否弹出确认对话框
2. 点击"厨艺技巧"是否跳转正确页面
3. 点击"运营数据"是否跳转正确页面
4. 点击"今天吃什么"是否随机推荐菜谱
---
## 完成标准
1. 轮播组件正常显示在主页
2. 六个轮播项都能正确展示
3. 自动轮播和手动滑动都正常工作
4. 所有点击事件正确触发
5. 深色模式适配正常
6. 无语法错误和运行时错误
7. CHANGELOG 已更新

View File

@@ -0,0 +1,186 @@
# 功能轮播组件设计文档
## 概述
在主页"今日营养"位置集成一个功能完整的轮播组件,替换现有的 `NutritionDashboardCard`,提供快捷功能入口和营养数据展示。
## 需求
### 功能需求
1. **轮播内容**:六个轮播项
- 第一项:今日营养(默认展示)
- 第二项:应用推荐 → 确认弹窗 → 打开浏览器
- 第三项:厨艺技巧 → 跳转烹饪技巧页面
- 第四项:运营数据 → 跳转数据大屏页面
- 第五项:今天吃什么 → 随机推荐菜谱
- 第六项:敬请期待(暂无点击事件)
2. **轮播功能**
- 自动轮播,间隔 3 秒
- 支持手动左右滑动
- 底部进度指示器
3. **交互体验**
- 平滑过渡动画
- 确认对话框
- 响应式布局
### 非功能需求
- 性能:流畅无卡顿
- 内存:避免泄漏,正确释放 Timer
- 兼容:支持深色模式
## 架构设计
### 文件结构
```
lib/src/widgets/carousel/
└── feature_carousel_card.dart # 主轮播组件
```
### 组件结构
```
FeatureCarouselCard (StatefulWidget)
├── _FeatureCarouselCardState
│ ├── PageView.builder # 轮播容器
│ ├── _CarouselItem # 单个轮播项
│ └── _PageIndicator # 底部指示器
```
### 数据模型
```dart
class CarouselItem {
final String title; // 标题
final String icon; // emoji 图标
final List<Color> gradient; // 渐变色
final VoidCallback? onTap; // 点击回调
final Widget? content; // 自定义内容(今日营养)
}
```
## 详细设计
### 1. 轮播项配置
| 序号 | 标题 | 图标 | 渐变色 | 点击行为 |
|------|------|------|--------|----------|
| 1 | 今日营养 | 📊 | [蓝, 浅蓝] | 无(展示营养数据) |
| 2 | 应用推荐 | 🌟 | [紫, 浅紫] | 弹窗 → 打开浏览器 |
| 3 | 厨艺技巧 | 👨‍🍳 | [橙, 浅橙] | Get.toNamed('/cooking-tips') |
| 4 | 运营数据 | 📈 | [绿, 浅绿] | Get.toNamed('/stats-dashboard') |
| 5 | 今天吃什么 | 🎲 | [红, 浅红] | 随机推荐菜谱 |
| 6 | 敬请期待 | 🎁 | [灰, 浅灰] | 无 |
### 2. 自动轮播逻辑
```dart
Timer? _autoPlayTimer;
int _currentPage = 0;
bool _isUserInteracting = false;
void _startAutoPlay() {
_autoPlayTimer = Timer.periodic(Duration(seconds: 3), (_) {
if (!_isUserInteracting && mounted) {
_pageController.nextPage(
duration: Duration(milliseconds: 350),
curve: Curves.easeInOutCubic,
);
}
});
}
void _onUserInteraction() {
setState(() => _isUserInteracting = true);
Future.delayed(Duration(seconds: 3), () {
if (mounted) setState(() => _isUserInteracting = false);
});
}
```
### 3. 页面指示器
- 位置:卡片底部居中
- 样式:小圆点
- 当前项:放大 + 主色
- 其他项:缩小 + 半透明
### 4. 应用推荐弹窗
```dart
showCupertinoDialog(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text('应用推荐'),
content: Text('即将跳转至浏览器访问推荐应用页面,是否继续?'),
actions: [
CupertinoDialogAction(
child: Text('取消'),
onPressed: () => Navigator.pop(context),
),
CupertinoDialogAction(
isDefaultAction: true,
child: Text('确认'),
onPressed: () {
Navigator.pop(context);
launchUrl(Uri.parse('https://poe.vogov.cn/app.html'));
},
),
],
),
);
```
### 5. 随机推荐菜谱
从菜谱库随机选择一道菜,跳转详情页:
```dart
void _randomRecipe() async {
final repo = RecipeRepository();
final recipes = await repo.fetchList();
if (recipes.items.isNotEmpty) {
final random = recipes.items[Random().nextInt(recipes.items.length)];
Get.toNamed('/recipe-detail', arguments: '${random.id}');
}
}
```
### 6. 今日营养内容
复用现有 `NutritionDashboardCard` 的核心内容:
- 四个营养环形图(热量、蛋白质、脂肪、碳水)
- 使用 `MealRecordController` 获取数据
## 响应式设计
- 卡片高度:固定 180px适配营养内容
- 内边距:使用 `DesignTokens.space4`
- 圆角:使用 `DesignTokens.borderRadiusLg`
- 深色模式:使用 `DarkDesignTokens`
## 性能优化
1. **状态保持**:使用 `AutomaticKeepAliveClientMixin`
2. **资源释放**:在 `dispose()` 中取消 Timer
3. **懒加载**PageView.builder 按需构建
4. **防抖**:用户交互后暂停自动轮播
## 测试要点
1. 自动轮播是否正常工作
2. 手动滑动是否流畅
3. 点击事件是否正确触发
4. 深色模式是否正常显示
5. Timer 是否正确释放
6. 响应式布局是否正常
## 变更记录
| 日期 | 版本 | 变更内容 |
|------|------|----------|
| 2026-04-14 | v1.0 | 初始设计 |