Files
what-to-eat/apps/web/app/pages/tabs/home/index.vue
T
toor 7408591fac feat: 落地 Warm Kitchen Ledger 原型,宽屏左导航 + 双栏布局
新增 ledger.css 设计令牌与桌面端布局,AppRail 左导航、PageHead 桌面页头、ResultsColumn 常驻结果栏;做菜/菜谱/发现/我的/设置/帮助/关于各页做桌面窄屏双形态适配,窄屏仍走原有底部标签栏与弹层交互。
2026-10-22 03:30:56 +08:00

130 lines
3.9 KiB
Vue

<script lang="ts" setup>
import type { RecipeMatch } from '@yunyoujun/cook'
import type { RecipeItem } from '~/types'
import { onIonViewDidEnter, onIonViewWillLeave } from '@ionic/vue'
import { yunleHaptic } from '~/utils/yunleHost'
definePageMeta({
alias: ['/', '/home', '/tabs'],
})
useHead({
title: '食用手册',
})
const rStore = useRecipeStore()
const active = shallowRef(false)
onIonViewDidEnter(() => {
active.value = true
})
onIonViewWillLeave(() => {
active.value = false
})
const panelRef = ref<{ $el: HTMLElement }>()
const flightTarget = computed(() => panelRef.value?.$el)
const contentRef = ref<{ $el: HTMLIonContentElement }>()
const reducedMotion = usePreferredReducedMotion()
/** 结果弹层遮住的高度,留作页面底部空白 */
const panelInset = ref(0)
const hasSelection = computed(() => rStore.selectedStuff.length > 0 || Boolean(rStore.curTool))
function reset() {
rStore.reset()
void yunleHaptic('medium')
}
function scrollTop() {
void contentRef.value?.$el.scrollToTop(reducedMotion.value === 'reduce' ? 0 : 300)
}
const feedback = useRecipeFeedback()
const selection = () => ({ stuff: rStore.selectedStuff, tool: rStore.curTool || undefined, mode: rStore.curMode })
const detail = shallowRef<{ recipe: RecipeItem, match?: RecipeMatch }>()
const detailOpen = ref(false)
function openDetail(recipe: RecipeItem, match?: RecipeMatch) {
detail.value = { recipe, match }
detailOpen.value = true
// 匿名记录「这组食材下点开了第几个结果」,用来评估和调整排序
const rank = match ? rStore.recipeMatches.indexOf(match) : -1
if (rank >= 0 && typeof recipe.id === 'number')
feedback.signal({ type: 'open', ...selection(), recipeId: recipe.id, rank })
}
// 停在一组没有结果的食材上时记一次,用来发现缺哪些菜
watchDebounced(
() => [rStore.isSearching, rStore.recipesLength, rStore.selectedStuff.join('、'), rStore.curTool, rStore.curMode, rStore.keyword, rStore.recipeMatches.length],
() => {
if (!rStore.isSearching && rStore.recipesLength && rStore.selectedStuff.length && !rStore.keyword && !rStore.recipeMatches.length)
feedback.signal({ type: 'empty', ...selection() })
},
{ debounce: 3000 },
)
</script>
<template>
<ion-page>
<ion-header :translucent="true">
<ion-toolbar>
<ion-title>做菜</ion-title>
<ion-buttons slot="end">
<ion-button :disabled="!hasSelection" @click="reset">
清空
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content
ref="contentRef"
class="cook-content"
:fullscreen="true"
:style="{ '--cook-panel-inset': `${panelInset}px` }"
>
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">
做菜
</ion-title>
</ion-toolbar>
</ion-header>
<div class="ledger-page ledger-cook-page">
<PageHead title="今天做点什么" subtitle="选好食材,看看能做什么">
<button type="button" class="ledger-btn is-ghost is-sm" :disabled="!hasSelection" @click="reset">
清空
</button>
</PageHead>
<div class="ledger-cook-split">
<div class="ledger-cook-side">
<ChooseFood :flight-target="flightTarget" />
</div>
<div class="ledger-desktop-block">
<ResultsColumn @detail="openDetail" />
</div>
</div>
</div>
</ion-content>
<ResultsPanel ref="panelRef" v-model:inset="panelInset" :active="active" @scroll-top="scrollTop" @detail="openDetail" />
<RecipeDetailSheet v-model:open="detailOpen" :recipe="detail?.recipe" :match="detail?.match" />
</ion-page>
</template>
<style scoped>
.cook-content {
--padding-bottom: calc(var(--cook-panel-inset, 0px) + 16px);
}
@media (min-width: 1024px) {
.cook-content {
--padding-bottom: 48px;
}
}
</style>