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

592 lines
19 KiB
Vue
Raw Permalink 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.
<script lang="ts" setup>
import type { IonSearchbarCustomEvent, SearchbarInputEventDetail } from '@ionic/core'
import type { Gesture, GestureDetail } from '@ionic/vue'
import type { RecipeMatch } from '@yunyoujun/cook'
import type { SearchMode } from '~/composables/store/recipe'
import type { RecipeItem } from '~/types'
import { createGesture } from '@ionic/vue'
import { useCookFunnel } from '~/composables/useCookFunnel'
import { getEmojisFromStuff } from '~/utils'
import { yunleHaptic } from '~/utils/yunleHost'
/**
* 常驻底部弹层(类似「地图」App):收起时是一条结果预览,拖到一半能边选食材边看菜,拖到顶是完整列表
*/
const props = defineProps<{ active: boolean }>()
const emit = defineEmits<{ scrollTop: [], detail: [recipe: RecipeItem, match?: RecipeMatch] }>()
/** 弹层当前遮住的高度,页面用它留出底部空白,保证最下面的分组能滚出来 */
const inset = defineModel<number>('inset', { default: 0 })
type Detent = 'collapsed' | 'half' | 'full'
/** 收起时露出的高度:把手 + 一行预览 */
const COLLAPSED = 92
const HALF_RATIO = 0.52
/** 完全展开时和页面顶部导航栏之间留的空隙 */
const TOP_GAP = 8
const PAGE = 30
const rStore = useRecipeStore()
const reducedMotion = usePreferredReducedMotion()
// ---------- 尺寸与分档 ----------
const panelRef = ref<HTMLElement>()
const pageEl = shallowRef<HTMLElement | null>(null)
const pageHeight = ref(0)
const headerHeight = ref(0)
const sheetHeight = computed(() => Math.max(COLLAPSED, pageHeight.value - headerHeight.value - TOP_GAP))
const detentOffsets = computed<Record<Detent, number>>(() => ({
full: 0,
half: Math.max(0, sheetHeight.value - Math.round(pageHeight.value * HALF_RATIO)),
collapsed: Math.max(0, sheetHeight.value - COLLAPSED),
}))
const detent = ref<Detent>('collapsed')
const offset = ref(0)
const dragging = ref(false)
/** 首次量好尺寸之前不做过渡,否则冷启动时弹层会从完全展开滑到收起 */
const settled = ref(false)
function measure() {
const page = pageEl.value
if (!page)
return
pageHeight.value = page.clientHeight
headerHeight.value = (page.querySelector(':scope > ion-header') as HTMLElement | null)?.offsetHeight ?? 0
if (!dragging.value)
snapTo(detent.value)
}
function snapTo(next: Detent) {
if (next !== detent.value)
void yunleHaptic('light')
detent.value = next
offset.value = detentOffsets.value[next]
inset.value = next === 'full' ? 0 : sheetHeight.value - offset.value
}
// ---------- 拖动 ----------
let gesture: Gesture | undefined
let startOffset = 0
function onStart() {
dragging.value = true
startOffset = offset.value
}
function onMove(detail: GestureDetail) {
const { full, collapsed } = detentOffsets.value
let next = startOffset + detail.deltaY
// 超出两端时加阻尼,和系统弹层一样有一点橡皮筋手感
if (next < full)
next = full - (full - next) * 0.25
else if (next > collapsed)
next = collapsed + (next - collapsed) * 0.25
offset.value = next
}
function onEnd(detail: GestureDetail) {
dragging.value = false
// 按松手时的速度往前预估一段,再吸附到最近的分档
const projected = offset.value + detail.velocityY * 180
const entries = Object.entries(detentOffsets.value) as [Detent, number][]
const [nearest] = entries.reduce((best, entry) => Math.abs(entry[1] - projected) < Math.abs(best[1] - projected) ? entry : best)
snapTo(nearest)
}
useResizeObserver(pageEl, measure)
onMounted(() => {
pageEl.value = panelRef.value?.closest('.ion-page') as HTMLElement | null
measure()
requestAnimationFrame(() => requestAnimationFrame(() => settled.value = true))
if (!panelRef.value)
return
gesture = createGesture({
el: panelRef.value,
gestureName: 'cook-results-panel',
direction: 'y',
threshold: 8,
// 展开到顶时列表要能正常滚动,只允许从顶部把手区域拖动
canStart: detail => detent.value !== 'full' || Boolean((detail.event.target as Element | null)?.closest('.results-panel-head')),
onStart,
onMove,
onEnd,
})
gesture.enable()
})
onBeforeUnmount(() => gesture?.destroy())
function onHeadClick() {
snapTo(detent.value === 'collapsed' ? 'full' : 'collapsed')
}
function goAdjust() {
snapTo('collapsed')
emit('scrollTop')
}
// 清空食材后收起;选食材时不自动展开,免得挡住正在点的按钮
watch(() => rStore.selectedStuff.length > 0 || Boolean(rStore.curTool), (has) => {
if (!has)
snapTo('collapsed')
})
// ---------- 结果数据 ----------
const toolMode = computed(() => !rStore.selectedStuff.length && Boolean(rStore.curTool))
const hasSelection = computed(() => rStore.selectedStuff.length > 0 || Boolean(rStore.curTool))
const groupLabels = ['齐全', '差一种', '差两种']
const groupHints = ['食材都有', '补一种就能做', '补两种就能做']
const groups = computed(() => groupLabels.map((label, missing) => ({
label,
items: rStore.recipeMatches.filter(match => match.missingStuff.length === missing),
})))
const active = ref(0)
const slideDirection = ref(1)
const limit = ref(PAGE)
watch(() => rStore.recipeMatches, () => {
if (!groups.value[active.value]?.items.length)
active.value = Math.max(0, groups.value.findIndex(group => group.items.length))
limit.value = PAGE
})
const currentMatches = computed(() => groups.value[active.value]?.items ?? [])
const total = computed(() => toolMode.value ? rStore.toolRecipes.length : currentMatches.value.length)
const visibleMatches = computed(() => currentMatches.value.slice(0, limit.value))
const visibleToolRecipes = computed(() => rStore.toolRecipes.slice(0, limit.value))
function onGroupChange(value: unknown) {
const next = Number(value) || 0
slideDirection.value = next >= active.value ? 1 : -1
active.value = next
limit.value = PAGE
void yunleHaptic('light')
}
const modeOptions: { value: SearchMode, label: string }[] = [
{ value: 'loose', label: '全部推荐' },
{ value: 'strict', label: '用上所有已选' },
{ value: 'survival', label: '只看食材齐全' },
]
function onModeChange(value: unknown) {
rStore.setMode(modeOptions.some(option => option.value === value) ? value as SearchMode : 'loose')
void yunleHaptic('light')
}
function onKeyword(ev: IonSearchbarCustomEvent<SearchbarInputEventDetail>) {
rStore.keyword = ev.detail.value ?? ''
}
function addSuggestion(name: string) {
rStore.addStuff(name)
void yunleHaptic('success')
}
// ---------- 收起时的预览 ----------
const count = computed(() => rStore.resultCount)
const selectedEmojis = computed(() => {
const emojis = getEmojisFromStuff(rStore.selectedStuff)
return emojis.length ? emojis.slice(0, 3) : ['🧺']
})
const selectedSummary = computed(() => {
const names = [...rStore.selectedStuff]
if (rStore.curTool)
names.push(rStore.curTool === '一口大锅' ? '炒锅' : rStore.curTool)
return names.join('、')
})
// 收起时内容区移到页面以下,会从半透明的标签栏后面透出来;随展开程度淡入淡出(只改 opacity)
const bodyOpacity = computed(() => Math.min(1, Math.max(0, (sheetHeight.value - offset.value - COLLAPSED) / 120)))
const bodyRef = useTemplateRef<HTMLElement>('bodyRef')
const bodyInViewport = useElementVisibility(bodyRef)
const documentVisibility = useDocumentVisibility()
// Do not build Ionic result rows while the user is still choosing ingredients.
const bodyVisible = computed(() => props.active && hasSelection.value && (detent.value !== 'collapsed' || dragging.value))
useCookFunnel(computed(() => ({
active: props.active && documentVisibility.value === 'visible',
hasIngredients: rStore.selectedStuff.length > 0,
results: !bodyVisible.value || !bodyInViewport.value || !settled.value || dragging.value || rStore.loadError
? 'hidden'
: rStore.isSearching ? 'loading' : count.value > 0 ? 'ready' : 'empty',
})))
function tryExample() {
rStore.addStuff('番茄、鸡蛋')
snapTo('full')
}
const sheetStyle = computed(() => ({
'height': `${sheetHeight.value}px`,
'transform': `translate3d(0, ${offset.value}px, 0)`,
'transition': !settled.value || dragging.value || reducedMotion.value === 'reduce' ? 'none' : undefined,
'--sheet-visible': `${sheetHeight.value - offset.value}px`,
}))
</script>
<template>
<div
ref="panelRef"
class="results-panel"
:class="[`is-${detent}`, { 'is-dragging': dragging }]"
:style="sheetStyle"
role="region"
aria-label="可以做的菜"
>
<div class="results-panel-head" @click="onHeadClick">
<div class="results-panel-grabber" aria-hidden="true" />
<ResultsPreview
:loading="rStore.isSearching"
:error="rStore.loadError"
:has-selection="hasSelection"
:count="count"
:summary="selectedSummary"
:emojis="selectedEmojis"
:expanded="detent !== 'collapsed'"
@toggle="onHeadClick"
@adjust="goAdjust"
@example="tryExample"
@retry="rStore.loadRecipes"
/>
</div>
<div id="cook-results-body" ref="bodyRef" class="results-panel-body" :aria-busy="bodyVisible && rStore.isSearching" :style="{ opacity: bodyOpacity, visibility: bodyOpacity ? 'visible' : 'hidden' }">
<ResultsSkeleton v-if="bodyVisible && rStore.isSearching" />
<template v-else-if="bodyVisible && !rStore.loadError">
<div v-if="rStore.unlockSuggestions.length && !toolMode" class="results-panel-unlock" aria-label="再补一样">
<span class="results-panel-unlock-label">再补一样</span>
<TransitionGroup name="unlock" tag="div" class="results-panel-unlock-row">
<button v-for="item in rStore.unlockSuggestions" :key="item.name" type="button" class="results-panel-unlock-chip" @click="addSuggestion(item.name)">
<ion-icon :icon="ioniconsAdd" aria-hidden="true" />{{ item.name }}<span>多 {{ item.count }} 道</span>
</button>
</TransitionGroup>
</div>
<ion-segment v-if="!toolMode" class="results-panel-segment" :value="String(active)" @ion-change="onGroupChange($event.detail.value)">
<ion-segment-button v-for="(group, index) in groups" :key="group.label" :value="String(index)">
<ion-label>{{ group.label }} {{ group.items.length }}</ion-label>
</ion-segment-button>
</ion-segment>
<div class="results-panel-scroll">
<ion-searchbar
v-if="detent === 'full'"
class="results-panel-search"
:value="rStore.keyword"
placeholder="在结果里找菜名"
:debounce="150"
show-clear-button="focus"
@ion-input="onKeyword"
/>
<div class="results-panel-summary">
<span>{{ total }} 道{{ toolMode ? '' : ` · ${groupHints[active]}` }}</span>
<ion-select
v-if="!toolMode"
class="results-panel-mode"
aria-label="匹配范围"
interface="popover"
:value="rStore.curMode"
@ion-change="onModeChange($event.detail.value)"
>
<ion-select-option v-for="option in modeOptions" :key="option.value" :value="option.value">
{{ option.label }}
</ion-select-option>
</ion-select>
</div>
<div v-if="(rStore.curTool && !toolMode) || rStore.methodGroups.length || rStore.difficulty.length" class="results-panel-filter">
<button v-if="rStore.curTool && !toolMode" type="button" class="results-panel-filter-chip" :aria-label="`取消厨具筛选:${rStore.curTool}`" @click="rStore.toggleTools(rStore.curTool)">
厨具:{{ rStore.curTool }}<ion-icon :icon="ioniconsClose" aria-hidden="true" />
</button>
<button v-for="label in rStore.methodGroups" :key="label" type="button" class="results-panel-filter-chip" :aria-label="`取消做法筛选:${label}`" @click="rStore.toggleMethodGroup(label)">
做法:{{ label }}<ion-icon :icon="ioniconsClose" aria-hidden="true" />
</button>
<button v-for="level in rStore.difficulty" :key="level" type="button" class="results-panel-filter-chip" :aria-label="`取消难度筛选:${level}`" @click="rStore.toggleDifficulty(level)">
难度:{{ level }}<ion-icon :icon="ioniconsClose" aria-hidden="true" />
</button>
</div>
<Transition :name="slideDirection > 0 ? 'results-slide-left' : 'results-slide-right'" mode="out-in">
<ion-list v-if="total" :key="toolMode ? 'tool' : active" :inset="true" class="results-panel-list">
<template v-if="toolMode">
<RecipeResultRow
v-for="(recipe, i) in visibleToolRecipes"
:key="`${recipe.bv}-${recipe.name}-${i}`"
:recipe="recipe"
:style="{ '--i': Math.min(i, 8) }"
@select="emit('detail', recipe)"
/>
</template>
<template v-else>
<RecipeResultRow
v-for="(match, i) in visibleMatches"
:key="match.sourceIndex"
:recipe="match.recipe"
:missing="match.missingStuff"
:style="{ '--i': Math.min(i, 8) }"
@select="emit('detail', match.recipe, match)"
/>
</template>
<ion-item v-if="total > limit" :button="true" :detail="false" @click="limit += PAGE">
<ion-label color="primary">
再看 {{ Math.min(PAGE, total - limit) }} 道(还有 {{ total - limit }} 道)
</ion-label>
</ion-item>
</ion-list>
<div v-else :key="`empty-${active}`" class="results-panel-empty">
<h3>这一组暂时没有菜</h3>
<p>{{ rStore.keyword || rStore.curMode !== 'loose' ? '试试清除菜名或放宽匹配范围' : count ? '换个分组,或者再加几样食材' : '试试换成常见食材,或清除厨具筛选' }}</p>
<ion-button fill="clear" @click="goAdjust">
调整食材
</ion-button>
</div>
</Transition>
<p class="cook-group-footer results-panel-footnote">
只核对菜谱里记录的食材,西红柿算番茄、鸡翅算鸡肉;常备调料只在菜谱写明时算作已有。用量和调味以视频为准。
</p>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.results-panel {
position: absolute;
z-index: 20;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
border-radius: 12px 12px 0 0;
background: var(--ion-background-color, #f2f2f6);
box-shadow:
0 -0.5px 0 var(--ion-color-step-150, rgba(0, 0, 0, 0.12)),
0 -6px 24px rgba(0, 0, 0, 0.08);
/* iOS 弹层的减速曲线 */
transition: transform 0.42s cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform;
}
.results-panel-head {
flex: none;
padding: 6px 16px 10px;
cursor: grab;
}
.results-panel-grabber {
width: 36px;
height: 5px;
margin: 0 auto 8px;
border-radius: 3px;
background: var(--ion-color-step-300, rgba(118, 118, 128, 0.4));
}
.results-panel-body {
transition: opacity 0.3s ease;
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.results-panel-unlock {
display: flex;
align-items: center;
gap: 8px;
padding: 0 0 10px 16px;
}
.results-panel-unlock-label {
flex: none;
font-size: 13px;
color: var(--ion-color-medium);
}
.results-panel-unlock-row {
position: relative;
display: flex;
gap: 8px;
padding-inline-end: 16px;
overflow-x: auto;
scrollbar-width: none;
}
.results-panel-unlock-row::-webkit-scrollbar {
display: none;
}
.results-panel-unlock-chip {
display: inline-flex;
flex: none;
align-items: center;
gap: 3px;
height: 30px;
padding: 0 12px 0 8px;
border-radius: 15px;
background: rgba(var(--ion-color-primary-rgb), 0.14);
color: var(--ion-color-primary);
font-size: 15px;
font-weight: 500;
white-space: nowrap;
}
.results-panel-unlock-chip span {
margin-inline-start: 4px;
font-size: 12px;
font-weight: 400;
opacity: 0.8;
}
.results-panel-unlock-chip:active {
transform: scale(0.94);
}
.results-panel-segment {
flex: none;
width: auto;
margin: 0 16px;
}
.results-panel-scroll {
flex: 1;
min-height: 0;
overflow: hidden;
overscroll-behavior: contain;
}
.results-panel.is-dragging .results-panel-body {
transition: none;
}
.results-panel.is-full .results-panel-scroll {
overflow-y: auto;
}
.results-panel-search {
padding: 10px 12px 0;
}
.results-panel-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 32px 0;
font-size: 13px;
color: var(--ion-color-step-600, #6d6d72);
white-space: nowrap;
}
.results-panel-mode {
flex: none;
--padding-end: 0;
min-height: 32px;
font-size: 13px;
color: var(--ion-color-primary);
}
.results-panel-filter {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 4px 20px 0;
}
.results-panel-filter-chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 28px;
padding: 0 10px 0 12px;
border-radius: 14px;
background: rgba(var(--ion-color-primary-rgb), 0.14);
color: var(--ion-color-primary);
font-size: 13px;
font-weight: 500;
}
.results-panel-list {
margin-top: 8px;
}
/* 展开或切换分组时,前几行依次浮现 */
.results-panel-list :deep(.recipe-result-row) {
animation: results-row-in 0.36s cubic-bezier(0.2, 0.8, 0.2, 1) both;
animation-delay: calc(var(--i, 0) * 24ms);
}
@keyframes results-row-in {
from {
opacity: 0;
transform: translateY(8px);
}
}
.results-panel-empty {
padding: 32px;
text-align: center;
color: var(--ion-color-medium);
}
.results-panel-empty h3 {
margin: 0 0 6px;
font-size: 17px;
font-weight: 600;
color: var(--ion-text-color, #000);
}
.results-panel-empty p {
margin: 0;
font-size: 15px;
}
.results-panel-footnote {
margin-top: 0;
padding-bottom: 24px;
}
/* 分段切换:列表按方向滑入 */
.results-slide-left-enter-active,
.results-slide-left-leave-active,
.results-slide-right-enter-active,
.results-slide-right-leave-active {
transition:
opacity 0.16s ease,
transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.results-slide-left-enter-from,
.results-slide-right-leave-to {
opacity: 0;
transform: translateX(24px);
}
.results-slide-left-leave-to,
.results-slide-right-enter-from {
opacity: 0;
transform: translateX(-24px);
}
.unlock-enter-active,
.unlock-leave-active {
transition:
opacity 0.2s ease,
transform 0.24s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.unlock-enter-from,
.unlock-leave-to {
opacity: 0;
transform: scale(0.85);
}
.unlock-leave-active {
position: absolute;
}
@media (prefers-reduced-motion: reduce) {
.results-panel,
.unlock-enter-active,
.unlock-leave-active {
transition: none;
}
.results-panel-list :deep(.recipe-result-row) {
animation: none;
}
.results-slide-left-enter-active,
.results-slide-left-leave-active,
.results-slide-right-enter-active,
.results-slide-right-leave-active {
transition: opacity 0.12s ease;
}
.results-slide-left-enter-from,
.results-slide-left-leave-to,
.results-slide-right-enter-from,
.results-slide-right-leave-to {
transform: none;
}
}
/* 桌面端改用常驻的结果栏(ResultsColumn),底部弹层不再出现 */
@media (min-width: 1024px) {
.results-panel {
display: none;
}
}
</style>