feat: 落地 Warm Kitchen Ledger 原型,宽屏左导航 + 双栏布局
新增 ledger.css 设计令牌与桌面端布局,AppRail 左导航、PageHead 桌面页头、ResultsColumn 常驻结果栏;做菜/菜谱/发现/我的/设置/帮助/关于各页做桌面窄屏双形态适配,窄屏仍走原有底部标签栏与弹层交互。
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user