新增 ledger.css 设计令牌与桌面端布局,AppRail 左导航、PageHead 桌面页头、ResultsColumn 常驻结果栏;做菜/菜谱/发现/我的/设置/帮助/关于各页做桌面窄屏双形态适配,窄屏仍走原有底部标签栏与弹层交互。
392 lines
12 KiB
Vue
392 lines
12 KiB
Vue
<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>
|