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

392 lines
12 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 { MaybeElementRef } from '@vueuse/core'
import type { StuffItem } from '~/types'
import { CUISINE_GROUPS, DIFFICULTY_LEVELS, METHOD_GROUPS } from '@yunyoujun/cook/recommend'
import { useEmojiAnimation } from '~/composables/animation'
import { useIncompatibleFoods } from '~/composables/incompatible-foods'
import { meat, staple, tools, vegetable } from '~/data/food'
import { pantryOptions } from '~/data/pantry'
import { trackCookEvent } from '~/utils/analytics'
import { yunleHaptic } from '~/utils/yunleHost'
const props = defineProps<{
/** 选中食材时 emoji 飞向的位置(结果预览条) */
flightTarget?: MaybeElementRef
}>()
const rStore = useRecipeStore()
const selected = computed(() => rStore.selectedStuff)
const pantryOpen = ref(false)
const avoidOpen = ref(false)
/** 桌面端「筛选条件」默认收起 */
const filtersOpen = ref(false)
// 食物相克检测
const { warningMessage, hasWarning, checkIncompatibility } = useIncompatibleFoods()
watch(selected, checkIncompatibility, { deep: true })
onMounted(() => {
if (selected.value.length)
checkIncompatibility(selected.value)
})
const { playAnimation } = useEmojiAnimation(computed(() => unref(props.flightTarget)))
const sections = [
{ title: '蔬菜', category: 'vegetable', items: vegetable },
{ title: '肉和蛋', category: 'meat', items: meat },
{ title: '主食(可选)', category: 'staple', items: staple },
]
function selectedNote(items: StuffItem[]) {
const count = items.filter(item => selected.value.includes(item.name)).length
return count ? `已选 ${count} 种` : ''
}
function toggleStuff(item: StuffItem, category: string, e: Event) {
rStore.toggleStuff(item.name)
if (selected.value.includes(item.name)) {
const origin = e.currentTarget as Element | null
void nextTick(() => playAnimation(item.emoji, origin))
void yunleHaptic('light')
}
trackCookEvent('click_stuff')
}
function clickTool(item: StuffItem) {
rStore.clickTool(item)
void yunleHaptic('light')
}
const filterCount = computed(() => rStore.methodGroups.length + rStore.cuisines.length + rStore.difficulty.length)
</script>
<template>
<div class="choose-food">
<IngredientInput :selected="selected" @add="rStore.addStuff" @remove="rStore.toggleStuff" />
<IngredientSection v-for="section in sections" :key="section.category" :title="section.title" :note="selectedNote(section.items)">
<IngredientChip
v-for="item in section.items"
:key="item.name"
:label="item.name"
:emoji="item.emoji"
:selected="selected.includes(item.name)"
@click="toggleStuff(item, section.category, $event)"
/>
</IngredientSection>
<IngredientSection title="厨具(可选)" :note="rStore.curTool ? '只看用它做的菜' : ''" grid>
<IngredientChip
v-for="item in tools"
:key="item.name"
:label="item.name === '一口大锅' ? '炒锅' : (item.label || item.name)"
:icon="item.icon"
:selected="rStore.curTool === item.name"
@click="clickTool(item)"
/>
</IngredientSection>
<div class="choose-food-filters">
<button
type="button"
class="ledger-filter-bar"
:aria-expanded="filtersOpen"
@click="filtersOpen = !filtersOpen"
>
<span>筛选条件</span>
<span v-if="filterCount" class="badge-count">{{ filterCount }}</span>
<ion-icon class="caret" :icon="ioniconsChevronDown" aria-hidden="true" />
</button>
<div class="ledger-filter-body" :class="{ 'is-open': filtersOpen }">
<div class="ledger-filter-group">
<h3>
做法<span class="ledger-filter-hint">(可选)</span>
</h3>
<div class="ledger-chips">
<IngredientChip
v-for="group in METHOD_GROUPS"
:key="group.label"
:label="group.label"
:selected="rStore.methodGroups.includes(group.label)"
@click="rStore.toggleMethodGroup(group.label)"
/>
</div>
</div>
<div class="ledger-filter-group">
<h3>
菜系<span class="ledger-filter-hint">(可选)</span>
</h3>
<div class="ledger-chips">
<IngredientChip
v-for="group in CUISINE_GROUPS"
:key="group.label"
:label="group.label"
:selected="rStore.cuisines.includes(group.label)"
@click="rStore.toggleCuisine(group.label)"
/>
</div>
</div>
<div class="ledger-filter-group">
<h3>
难度<span class="ledger-filter-hint">(可选)</span>
</h3>
<div class="ledger-chips">
<IngredientChip
v-for="level in DIFFICULTY_LEVELS"
:key="level"
:label="level"
:selected="rStore.difficulty.includes(level)"
@click="rStore.toggleDifficulty(level)"
/>
</div>
</div>
<div v-if="filterCount || rStore.curTool" class="ledger-active-filters">
<button v-if="rStore.curTool" type="button" class="ledger-tag-chip" @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="ledger-tag-chip" @click="rStore.toggleMethodGroup(label)">
做法:{{ label }}<ion-icon :icon="ioniconsClose" aria-hidden="true" />
</button>
<button v-for="label in rStore.cuisines" :key="label" type="button" class="ledger-tag-chip" @click="rStore.toggleCuisine(label)">
菜系:{{ label }}<ion-icon :icon="ioniconsClose" aria-hidden="true" />
</button>
<button v-for="level in rStore.difficulty" :key="level" type="button" class="ledger-tag-chip is-accent" @click="rStore.toggleDifficulty(level)">
难度:{{ level }}<ion-icon :icon="ioniconsClose" aria-hidden="true" />
</button>
</div>
</div>
</div>
<!-- 桌面端:调料和忌口直接铺在卡片里 -->
<section class="ledger-desktop-block choose-food-seasoning">
<div class="ledger-section-head">
<h2>调料和忌口</h2>
<span class="hint">常备调料算作已有</span>
</div>
<div class="ledger-card ledger-card-pad">
<div class="choose-food-stack">
<div>
<h3 class="choose-food-sub-title">
家里常备
</h3>
<div class="ledger-chips">
<IngredientChip
v-for="name in pantryOptions"
:key="name"
:label="name"
:selected="rStore.selectedPantry.includes(name)"
@click="rStore.togglePantry(name)"
/>
</div>
</div>
<div>
<h3 class="choose-food-sub-title">
忌口
</h3>
<div class="ledger-chips">
<span v-for="name in rStore.selectedAvoid" :key="name" class="ledger-tag-chip is-accent">
{{ name }}
<button type="button" class="choose-food-tag-remove" :aria-label="`移除忌口${name}`" @click="rStore.toggleAvoid(name)">
<ion-icon :icon="ioniconsClose" aria-hidden="true" />
</button>
</span>
<button type="button" class="ledger-btn is-ghost is-sm" @click="avoidOpen = true">
选择忌口
</button>
</div>
</div>
<div class="choose-food-toggle">
<span class="label">不吃辣</span>
<ion-toggle v-model="rStore.noSpicy" justify="space-between" />
</div>
</div>
</div>
</section>
<!-- 移动端:调料和忌口用 iOS 分组列表 -->
<ion-list :inset="true" class="choose-food-pantry">
<ion-item :button="true" @click="pantryOpen = true">
<ion-label>家里常备</ion-label>
<ion-note slot="end" class="choose-food-pantry-value">
{{ rStore.selectedPantry.length ? rStore.selectedPantry.join('、') : '未选择' }}
</ion-note>
</ion-item>
<ion-item :button="true" @click="avoidOpen = true">
<ion-label>忌口</ion-label>
<ion-note slot="end" class="choose-food-pantry-value">
{{ rStore.selectedAvoid.length ? rStore.selectedAvoid.join('、') : '无' }}
</ion-note>
</ion-item>
<ion-item>
<ion-toggle v-model="rStore.noSpicy" justify="space-between">
不吃辣
</ion-toggle>
</ion-item>
</ion-list>
<PantrySheet
v-model:open="pantryOpen"
:selected="rStore.selectedPantry"
@toggle="rStore.togglePantry"
@clear="rStore.clearPantry"
@reset="rStore.resetPantry"
/>
<AvoidSheet
v-model:open="avoidOpen"
:selected="rStore.selectedAvoid"
@toggle="rStore.toggleAvoid"
@clear="rStore.clearAvoid"
/>
<ion-toast
class="incompatible-warning-toast"
header="食物相克提醒"
:message="warningMessage"
:is-open="hasWarning"
position="top"
:icon="ioniconsWarningOutline"
/>
</div>
</template>
<style scoped>
.choose-food {
padding-bottom: 24px;
}
.choose-food-pantry-value {
max-width: 60vw;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 17px;
}
/* 筛选条件:窄屏是一组 iOS 分组,桌面收进一张可折叠的卡片 */
.ledger-filter-bar {
display: none;
}
.ledger-filter-body {
display: block;
}
.ledger-filter-group {
margin-bottom: 8px;
}
.ledger-filter-group h3 {
margin: 24px 0 0;
padding: 0 calc(16px + 20px) 6px;
font-size: 13px;
font-weight: 400;
line-height: 18px;
color: var(--ion-color-step-600, #6d6d72);
}
.ledger-filter-group .ledger-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 16px;
padding: 12px;
border-radius: 10px;
background: var(--ion-item-background, #fff);
}
.ledger-active-filters {
display: none;
}
@media (min-width: 1024px) {
/* 桌面端用原型的筛选卡片 */
.choose-food-filters {
margin-bottom: 24px;
border: 1px solid var(--ledger-border);
border-radius: var(--ledger-r-lg);
background: var(--ledger-surface);
box-shadow: var(--ledger-sh-sm);
overflow: hidden;
}
.ledger-filter-bar {
display: flex;
}
.ledger-filter-body {
display: none;
padding: 0 16px 16px;
}
.ledger-filter-body.is-open {
display: block;
}
.ledger-filter-group h3 {
margin: 16px 0 8px;
padding: 0;
color: var(--ledger-text-sub);
font: 500 var(--ledger-t-13) / 1.3 var(--ledger-font-body);
}
.ledger-filter-group .ledger-chips {
margin: 0;
padding: 0;
background: transparent;
}
.ledger-filter-hint {
display: none;
}
.ledger-active-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed var(--ledger-border);
}
/* 桌面端不再需要 iOS 分组列表 */
.choose-food-pantry {
display: none;
}
.choose-food-stack > * + * {
margin-top: 12px;
}
.choose-food-sub-title {
margin: 0 0 8px;
color: var(--ledger-text-sub);
font: 500 var(--ledger-t-13) / 1.3 var(--ledger-font-body);
}
.choose-food-toggle {
display: flex;
align-items: center;
gap: 12px;
min-height: 40px;
}
.choose-food-toggle .label {
flex: 1;
font-size: var(--ledger-t-14);
}
.choose-food-tag-remove {
display: inline-flex;
align-items: center;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
}
}
</style>
<style>
ion-toast.incompatible-warning-toast {
--background: rgba(255, 237, 213, 0.96);
--color: #7c2d12;
--box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.ion-palette-dark ion-toast.incompatible-warning-toast {
--background: rgba(67, 32, 10, 0.96);
--color: #fed7aa;
}
</style>