feat: 落地 Warm Kitchen Ledger 原型,宽屏左导航 + 双栏布局
新增 ledger.css 设计令牌与桌面端布局,AppRail 左导航、PageHead 桌面页头、ResultsColumn 常驻结果栏;做菜/菜谱/发现/我的/设置/帮助/关于各页做桌面窄屏双形态适配,窄屏仍走原有底部标签栏与弹层交互。
This commit is contained in:
@@ -5,6 +5,7 @@ import { CUISINE_GROUPS, DIFFICULTY_LEVELS, METHOD_GROUPS } from '@yunyoujun/coo
|
||||
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'
|
||||
|
||||
@@ -17,6 +18,8 @@ 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()
|
||||
@@ -54,6 +57,8 @@ function clickTool(item: StuffItem) {
|
||||
rStore.clickTool(item)
|
||||
void yunleHaptic('light')
|
||||
}
|
||||
|
||||
const filterCount = computed(() => rStore.methodGroups.length + rStore.cuisines.length + rStore.difficulty.length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -71,7 +76,7 @@ function clickTool(item: StuffItem) {
|
||||
/>
|
||||
</IngredientSection>
|
||||
|
||||
<IngredientSection title="厨具(可选)" :note="rStore.curTool ? '只看用它做的菜' : ''">
|
||||
<IngredientSection title="厨具(可选)" :note="rStore.curTool ? '只看用它做的菜' : ''" grid>
|
||||
<IngredientChip
|
||||
v-for="item in tools"
|
||||
:key="item.name"
|
||||
@@ -82,39 +87,130 @@ function clickTool(item: StuffItem) {
|
||||
/>
|
||||
</IngredientSection>
|
||||
|
||||
<IngredientSection title="做法(可选)" :note="rStore.methodGroups.length ? '只看这些做法' : ''">
|
||||
<IngredientChip
|
||||
v-for="group in METHOD_GROUPS"
|
||||
:key="group.label"
|
||||
:label="group.label"
|
||||
:selected="rStore.methodGroups.includes(group.label)"
|
||||
@click="rStore.toggleMethodGroup(group.label)"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<IngredientSection title="菜系(可选)" :note="rStore.cuisines.length ? '只看这些菜系' : ''">
|
||||
<IngredientChip
|
||||
v-for="group in CUISINE_GROUPS"
|
||||
:key="group.label"
|
||||
:label="group.label"
|
||||
:selected="rStore.cuisines.includes(group.label)"
|
||||
@click="rStore.toggleCuisine(group.label)"
|
||||
/>
|
||||
</IngredientSection>
|
||||
<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>
|
||||
|
||||
<IngredientSection title="难度(可选)" :note="rStore.difficulty.length ? '没标难度的菜会被隐藏' : ''">
|
||||
<IngredientChip
|
||||
v-for="level in DIFFICULTY_LEVELS"
|
||||
:key="level"
|
||||
:label="level"
|
||||
:selected="rStore.difficulty.includes(level)"
|
||||
@click="rStore.toggleDifficulty(level)"
|
||||
/>
|
||||
</IngredientSection>
|
||||
<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>
|
||||
|
||||
<h2 class="cook-group-header">
|
||||
调料和忌口
|
||||
</h2>
|
||||
<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>
|
||||
@@ -172,6 +268,114 @@ function clickTool(item: StuffItem) {
|
||||
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>
|
||||
|
||||
@@ -66,6 +66,33 @@ defineProps<{
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
/* 桌面端用原型的白底描边 chip */
|
||||
.ingredient-chip {
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--ledger-border);
|
||||
border-radius: var(--ledger-r-pill);
|
||||
background: var(--ledger-surface);
|
||||
color: var(--ledger-text);
|
||||
font: 400 var(--ledger-t-15) / 1 var(--ledger-font-body);
|
||||
}
|
||||
.ingredient-chip:hover {
|
||||
border-color: var(--ledger-border-strong);
|
||||
}
|
||||
.ingredient-chip[aria-pressed='true'] {
|
||||
border-color: var(--ledger-primary);
|
||||
background: var(--ledger-primary);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ingredient-chip:focus-visible {
|
||||
outline: 2px solid var(--ledger-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ingredient-chip {
|
||||
transition: none;
|
||||
|
||||
@@ -59,7 +59,7 @@ function remove(name: string) {
|
||||
@ion-input="onInput"
|
||||
@keydown.enter.prevent="add()"
|
||||
/>
|
||||
<div v-if="suggestions.length" class="ingredient-input-row" role="listbox" aria-label="食材建议">
|
||||
<div v-if="suggestions.length" class="ingredient-input-row ingredient-input-suggestions" role="listbox" aria-label="食材建议">
|
||||
<button v-for="item in suggestions" :key="item.name" type="button" class="ingredient-suggestion" role="option" @click="add(item.name)">
|
||||
<span aria-hidden="true">{{ item.emoji }}</span> {{ item.name }}
|
||||
</button>
|
||||
@@ -67,7 +67,10 @@ function remove(name: string) {
|
||||
<p v-else-if="message" class="ingredient-input-message" role="status">
|
||||
{{ message }}
|
||||
</p>
|
||||
<TransitionGroup v-if="selected.length" tag="div" name="ingredient-token" class="ingredient-input-row" aria-label="已选食材">
|
||||
<p v-if="!selected.length" class="ingredient-input-empty">
|
||||
还没选食材 —— 下面挑几样,或者直接搜名字
|
||||
</p>
|
||||
<TransitionGroup v-if="selected.length" tag="div" name="ingredient-token" class="ingredient-input-row ingredient-input-tokens" aria-label="已选食材">
|
||||
<button v-for="name in selected" :key="name" type="button" class="ingredient-token" :aria-label="`移除${name}`" @click="remove(name)">
|
||||
{{ name }}<ion-icon :icon="ioniconsClose" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -128,6 +131,9 @@ function remove(name: string) {
|
||||
font-size: 13px;
|
||||
color: var(--ion-color-medium);
|
||||
}
|
||||
.ingredient-input-empty {
|
||||
display: none;
|
||||
}
|
||||
.ingredient-token-enter-active,
|
||||
.ingredient-token-leave-active {
|
||||
transition:
|
||||
@@ -145,6 +151,86 @@ function remove(name: string) {
|
||||
.ingredient-token-move {
|
||||
transition: transform 0.24s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
/* 桌面端:已选食材在上面的托盘里,搜索框在下面 */
|
||||
.ingredient-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.ingredient-input-field {
|
||||
order: 3;
|
||||
padding-inline: 0;
|
||||
padding-bottom: 0;
|
||||
--background: var(--ledger-surface);
|
||||
--box-shadow: inset 0 0 0 1px var(--ledger-border);
|
||||
--border-radius: var(--ledger-r-pill);
|
||||
--color: var(--ledger-text);
|
||||
--icon-color: var(--ledger-text-mute);
|
||||
--placeholder-color: var(--ledger-text-mute);
|
||||
--placeholder-opacity: 1;
|
||||
--height: 44px;
|
||||
border-radius: var(--ledger-r-pill);
|
||||
background: var(--ledger-surface);
|
||||
}
|
||||
.ingredient-input-suggestions {
|
||||
order: 4;
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
.ingredient-input-message {
|
||||
order: 5;
|
||||
padding: 0;
|
||||
}
|
||||
.ingredient-input-tokens {
|
||||
order: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 62px;
|
||||
padding: 12px;
|
||||
overflow: visible;
|
||||
border: 1px solid var(--ledger-border);
|
||||
border-radius: var(--ledger-r-lg);
|
||||
background: var(--ledger-surface);
|
||||
box-shadow: var(--ledger-sh-sm);
|
||||
}
|
||||
.ingredient-input-empty {
|
||||
order: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 62px;
|
||||
padding: 12px;
|
||||
margin: 0;
|
||||
border: 1px solid var(--ledger-border);
|
||||
border-radius: var(--ledger-r-lg);
|
||||
background: var(--ledger-surface);
|
||||
box-shadow: var(--ledger-sh-sm);
|
||||
color: var(--ledger-text-mute);
|
||||
font-size: var(--ledger-t-13);
|
||||
}
|
||||
.ingredient-token {
|
||||
height: 34px;
|
||||
padding: 0 8px 0 12px;
|
||||
border-radius: var(--ledger-r-pill);
|
||||
background: var(--ledger-primary-soft);
|
||||
color: var(--ledger-primary-ink);
|
||||
font-size: var(--ledger-t-14);
|
||||
}
|
||||
.ingredient-suggestion {
|
||||
height: 34px;
|
||||
border: 1px solid var(--ledger-border);
|
||||
border-radius: var(--ledger-r-pill);
|
||||
background: var(--ledger-surface);
|
||||
color: var(--ledger-text);
|
||||
font-size: var(--ledger-t-14);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ingredient-token-enter-active,
|
||||
.ingredient-token-leave-active,
|
||||
|
||||
@@ -6,6 +6,8 @@ defineProps<{
|
||||
title: string
|
||||
/** 标题右侧的说明,例如「已选 2 种」 */
|
||||
note?: string
|
||||
/** 桌面端把按钮排成网格(厨具一栏用) */
|
||||
grid?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -15,7 +17,7 @@ defineProps<{
|
||||
<span>{{ title }}</span>
|
||||
<span v-if="note" class="ingredient-section-note">{{ note }}</span>
|
||||
</h2>
|
||||
<div class="ingredient-section-card">
|
||||
<div class="ingredient-section-card" :class="{ 'is-grid': grid }">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
@@ -38,4 +40,31 @@ defineProps<{
|
||||
border-radius: 10px;
|
||||
background: var(--ion-item-background, #fff);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.ingredient-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.ingredient-section-header {
|
||||
margin: 0 0 12px;
|
||||
padding: 0;
|
||||
color: var(--ledger-text);
|
||||
font: 600 var(--ledger-t-18) / 1.3 var(--ledger-font-display);
|
||||
}
|
||||
.ingredient-section-note {
|
||||
color: var(--ledger-text-mute);
|
||||
font: 400 var(--ledger-t-13) / 1.3 var(--ledger-font-body);
|
||||
}
|
||||
.ingredient-section-card {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.ingredient-section-card.is-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts" setup>
|
||||
import type { RecipeMatch } from '@yunyoujun/cook'
|
||||
import type { SearchMode } from '~/composables/store/recipe'
|
||||
import type { RecipeItem } from '~/types'
|
||||
import { uniqueStuff } from '@yunyoujun/cook/recommend'
|
||||
import { getEmojisFromStuff } from '~/utils'
|
||||
|
||||
/**
|
||||
* 桌面端常驻的结果栏(原型 .cook-results):左栏选食材,右栏一直显示能做的菜。
|
||||
* 窄屏由 ResultsPanel 底部弹层承担,这里只在 >=1024px 渲染。
|
||||
*/
|
||||
const emit = defineEmits<{ detail: [recipe: RecipeItem, match?: RecipeMatch] }>()
|
||||
|
||||
const rStore = useRecipeStore()
|
||||
|
||||
const groupLabels = ['齐全', '差一种', '差两种']
|
||||
const groups = computed(() => groupLabels.map((label, missing) => ({
|
||||
label,
|
||||
items: rStore.recipeMatches.filter(match => match.missingStuff.length === missing),
|
||||
})))
|
||||
|
||||
const active = ref(0)
|
||||
watch(() => rStore.recipeMatches, () => {
|
||||
if (!groups.value[active.value]?.items.length)
|
||||
active.value = Math.max(0, groups.value.findIndex(group => group.items.length))
|
||||
})
|
||||
|
||||
const toolMode = computed(() => !rStore.selectedStuff.length && Boolean(rStore.curTool))
|
||||
const matches = computed(() => groups.value[active.value]?.items ?? [])
|
||||
const toolRecipes = computed(() => rStore.toolRecipes)
|
||||
const count = computed(() => rStore.resultCount)
|
||||
const hasSelection = computed(() => rStore.selectedStuff.length > 0 || Boolean(rStore.curTool))
|
||||
|
||||
const modeOptions: { value: SearchMode, label: string }[] = [
|
||||
{ value: 'loose', label: '全部推荐' },
|
||||
{ value: 'strict', label: '用上所有已选' },
|
||||
{ value: 'survival', label: '只看食材齐全' },
|
||||
]
|
||||
|
||||
function onModeChange(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value as SearchMode
|
||||
rStore.setMode(modeOptions.some(option => option.value === value) ? value : 'loose')
|
||||
}
|
||||
|
||||
function addSuggestion(name: string) {
|
||||
rStore.addStuff(name)
|
||||
}
|
||||
|
||||
// 结果行和移动端保持一致:缩略图 + 菜名 + 已有食材 + 还缺什么 + 厨具
|
||||
function thumbOf(recipe: RecipeItem) {
|
||||
const stuff = uniqueStuff(recipe.stuff)
|
||||
return recipe.tags?.includes('杂烩') ? '🍲' : getEmojisFromStuff(stuff)[0] ?? '🍽️'
|
||||
}
|
||||
|
||||
function ingredientsOf(recipe: RecipeItem, missing?: string[]) {
|
||||
return uniqueStuff(recipe.stuff).filter(name => !missing?.includes(name)).join(' · ')
|
||||
}
|
||||
|
||||
function toolOf(recipe: RecipeItem) {
|
||||
return recipe.tools?.[0] === '一口大锅' ? '炒锅' : recipe.tools?.[0]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ledger-cook-results" aria-label="可以做的菜">
|
||||
<div class="ledger-results-head">
|
||||
<h2 class="ledger-results-title">
|
||||
{{ hasSelection ? '能做的菜' : '选好食材,看看能做什么' }}
|
||||
</h2>
|
||||
<p class="ledger-results-sub">
|
||||
{{ hasSelection ? '厨具可选,不选也能找菜谱' : '先挑几样手边的食材' }}
|
||||
</p>
|
||||
|
||||
<div v-if="!toolMode" class="ledger-results-toolbar">
|
||||
<div class="ledger-segment" role="tablist" aria-label="按缺料分组">
|
||||
<button
|
||||
v-for="(group, index) in groups"
|
||||
:key="group.label"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="active === index"
|
||||
:class="{ 'is-on': active === index }"
|
||||
@click="active = index"
|
||||
>
|
||||
{{ group.label }}<span class="n">{{ group.items.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select class="ledger-select-pill" aria-label="匹配范围" :value="rStore.curMode" @change="onModeChange">
|
||||
<option v-for="option in modeOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<span class="ledger-results-count">{{ count }} 道</span>
|
||||
</div>
|
||||
<div v-else class="ledger-results-toolbar">
|
||||
<span class="ledger-results-count">{{ count }} 道</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ledger-results-scroll">
|
||||
<div v-if="rStore.unlockSuggestions.length && !toolMode" class="ledger-unlock">
|
||||
<span class="ledger-unlock-label">再补一样</span>
|
||||
<div class="ledger-unlock-row">
|
||||
<button
|
||||
v-for="item in rStore.unlockSuggestions"
|
||||
:key="item.name"
|
||||
type="button"
|
||||
class="ledger-unlock-chip"
|
||||
@click="addSuggestion(item.name)"
|
||||
>
|
||||
<ion-icon :icon="ioniconsAdd" aria-hidden="true" />{{ item.name }}<span>多 {{ item.count }} 道</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="rStore.isSearching" class="ledger-results-scroll" aria-busy="true">
|
||||
<div v-for="i in 6" :key="i" class="ledger-dish-row" aria-hidden="true">
|
||||
<span class="ledger-dish-thumb" />
|
||||
<span class="ledger-dish-main">
|
||||
<span class="ledger-skeleton-line" />
|
||||
<span class="ledger-skeleton-line is-short" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="toolMode" class="ledger-list">
|
||||
<button
|
||||
v-for="recipe in toolRecipes"
|
||||
:key="`${recipe.bv}-${recipe.name}`"
|
||||
type="button"
|
||||
class="ledger-dish-row"
|
||||
@click="emit('detail', recipe)"
|
||||
>
|
||||
<span class="ledger-dish-thumb" aria-hidden="true">{{ thumbOf(recipe) }}</span>
|
||||
<span class="ledger-dish-main">
|
||||
<span class="ledger-dish-name">{{ recipe.name }}</span>
|
||||
<span class="ledger-dish-meta">{{ ingredientsOf(recipe) }}</span>
|
||||
</span>
|
||||
<span class="ledger-dish-tool">{{ toolOf(recipe) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="matches.length" class="ledger-list">
|
||||
<button
|
||||
v-for="match in matches"
|
||||
:key="match.sourceIndex"
|
||||
type="button"
|
||||
class="ledger-dish-row"
|
||||
@click="emit('detail', match.recipe, match)"
|
||||
>
|
||||
<span class="ledger-dish-thumb" aria-hidden="true">{{ thumbOf(match.recipe) }}</span>
|
||||
<span class="ledger-dish-main">
|
||||
<span class="ledger-dish-name">{{ match.recipe.name }}</span>
|
||||
<span class="ledger-dish-meta">
|
||||
<span v-if="match.missingStuff.length" class="ledger-dish-missing">缺 {{ match.missingStuff.join('、') }}</span>
|
||||
<span>{{ ingredientsOf(match.recipe, match.missingStuff) }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="ledger-dish-tool">{{ toolOf(match.recipe) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="ledger-results-empty">
|
||||
<h3>{{ hasSelection ? '这一组暂时没有菜' : '还没选食材' }}</h3>
|
||||
<p>{{ hasSelection ? '换个分组,或者再加几样食材' : '左边挑几样手边的食材,这里就会列出能做的菜' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="ledger-results-foot">
|
||||
只核对菜谱里记录的食材,西红柿算番茄、鸡翅算鸡肉;常备调料只在菜谱写明时算作已有。用量和调味以视频为准。
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ledger-results-empty {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--ledger-text-sub);
|
||||
}
|
||||
|
||||
.ledger-results-empty h3 {
|
||||
margin: 0 0 6px;
|
||||
font: 600 var(--ledger-t-18) / 1.4 var(--ledger-font-display);
|
||||
color: var(--ledger-text);
|
||||
}
|
||||
|
||||
.ledger-results-empty p {
|
||||
margin: 0;
|
||||
font-size: var(--ledger-t-13);
|
||||
}
|
||||
</style>
|
||||
@@ -581,4 +581,11 @@ const sheetStyle = computed(() => ({
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 桌面端改用常驻的结果栏(ResultsColumn),底部弹层不再出现 */
|
||||
@media (min-width: 1024px) {
|
||||
.results-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 桌面端左侧固定导航栏(原型 prototype/cook-desktop 的 .app-rail)。
|
||||
* 窄屏由 .ledger-rail 的 display:none 隐藏,导航仍走底部标签栏。
|
||||
*/
|
||||
/**
|
||||
* 左导航挂在 app.vue 上,不在页面组件内,
|
||||
* 而 useRoute() 在页面外拿到的是 nuxtApp._route,直接进页面(如 /library)时还是旧路径。
|
||||
* 所以这里改用 router.currentRoute,它始终是当前路由。
|
||||
*/
|
||||
const router = useRouter()
|
||||
const currentPath = computed(() => router.currentRoute.value.path)
|
||||
const { isDark, toggleDark } = useDarkMode()
|
||||
const rStore = useRecipeStore()
|
||||
|
||||
const primary = [
|
||||
{ key: 'cook', label: '做菜', path: '/home', icon: ioniconsBookOutline },
|
||||
{ key: 'library', label: '菜谱', path: '/library', icon: ioniconsLibraryOutline },
|
||||
{ key: 'discover', label: '发现', path: '/apps', icon: ioniconsRestaurantOutline },
|
||||
{ key: 'me', label: '我的', path: '/my', icon: ioniconsPersonOutline },
|
||||
]
|
||||
|
||||
const more = [
|
||||
{ key: 'settings', label: '设置', path: '/settings', icon: ioniconsSettingsOutline },
|
||||
{ key: 'help', label: '帮助', path: '/help', icon: ioniconsHelpCircleOutline },
|
||||
{ key: 'about', label: '关于', path: '/about', icon: ioniconsInformationCircleOutline },
|
||||
]
|
||||
|
||||
/** 菜谱库和「做菜」都有别名,这里按前缀归类,保证左侧高亮和底部标签一致 */
|
||||
function isActive(item: { key: string, path: string }) {
|
||||
const path = currentPath.value
|
||||
if (item.key === 'cook')
|
||||
return path === '/' || path === '/home' || path === '/tabs'
|
||||
if (item.key === 'library')
|
||||
return path.startsWith('/library') || path.startsWith('/recipes') || path.startsWith('/cookbooks')
|
||||
if (item.key === 'discover')
|
||||
return path.startsWith('/apps')
|
||||
if (item.key === 'me')
|
||||
return path.startsWith('/my')
|
||||
return path.startsWith(item.path)
|
||||
}
|
||||
|
||||
/** 是否正停留在这一页(做菜页有 / 、/home、/tabs 三个等价路径) */
|
||||
function isCurrentPage(item: { key: string, path: string }) {
|
||||
if (item.key === 'cook')
|
||||
return currentPath.value === '/' || currentPath.value === '/home' || currentPath.value === '/tabs'
|
||||
return currentPath.value === item.path
|
||||
}
|
||||
|
||||
/** 已经在这一页时,拦下跳转改成滚回顶部,和底部标签栏的行为一致 */
|
||||
function onLinkClick(item: { key: string, path: string }, event: MouseEvent) {
|
||||
if (!isCurrentPage(item))
|
||||
return
|
||||
event.preventDefault()
|
||||
scrollTop()
|
||||
}
|
||||
|
||||
/** 和底部标签栏一样:再点一次当前项,页面滚回顶部 */
|
||||
function scrollTop() {
|
||||
const content = document.querySelector<HTMLIonContentElement>('.ion-page:not(.ion-page-hidden) ion-content')
|
||||
void content?.scrollToTop(300)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="ledger-rail" aria-label="主导航">
|
||||
<div class="ledger-rail-brand">
|
||||
<span class="ledger-rail-mark">
|
||||
<ion-icon :icon="ioniconsRestaurantOutline" aria-hidden="true" />
|
||||
</span>
|
||||
<span class="ledger-rail-brand-text">
|
||||
<b>食用手册</b>
|
||||
<small>冰箱里有什么就做什么</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav class="ledger-rail-nav">
|
||||
<NuxtLink
|
||||
v-for="item in primary"
|
||||
:key="item.key"
|
||||
:to="item.path"
|
||||
class="ledger-rail-link"
|
||||
:class="{ 'is-on': isActive(item) }"
|
||||
:aria-current="isActive(item) ? 'page' : undefined"
|
||||
@click="onLinkClick(item, $event)"
|
||||
>
|
||||
<ion-icon :icon="item.icon" aria-hidden="true" />
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<nav class="ledger-rail-nav">
|
||||
<span class="ledger-rail-label">更多</span>
|
||||
<NuxtLink
|
||||
v-for="item in more"
|
||||
:key="item.key"
|
||||
:to="item.path"
|
||||
class="ledger-rail-link"
|
||||
:class="{ 'is-on': isActive(item) }"
|
||||
:aria-current="isActive(item) ? 'page' : undefined"
|
||||
@click="onLinkClick(item, $event)"
|
||||
>
|
||||
<ion-icon :icon="item.icon" aria-hidden="true" />
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<div class="ledger-rail-foot">
|
||||
<button type="button" class="ledger-rail-theme" @click="toggleDark()">
|
||||
<ion-icon :icon="isDark ? ioniconsSunnyOutline : ioniconsMoonOutline" aria-hidden="true" />
|
||||
<span>{{ isDark ? '浅色模式' : '深色模式' }}</span>
|
||||
</button>
|
||||
<p class="ledger-rail-stat">
|
||||
菜谱库收录 <b>{{ rStore.recipesLength }}</b> 道菜
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 桌面端页面标题区(原型 .page-head)。窄屏仍用 Ionic 顶部栏的标题,这里隐藏。
|
||||
*/
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ledger-page-head ledger-desktop-only">
|
||||
<div class="ledger-page-titles">
|
||||
<h1 class="ledger-page-title">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="subtitle" class="ledger-page-sub">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.default" class="ledger-page-actions">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user