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

195 lines
7.1 KiB
Vue
Raw 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 { 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>