feat: 落地 Warm Kitchen Ledger 原型,宽屏左导航 + 双栏布局

新增 ledger.css 设计令牌与桌面端布局,AppRail 左导航、PageHead 桌面页头、ResultsColumn 常驻结果栏;做菜/菜谱/发现/我的/设置/帮助/关于各页做桌面窄屏双形态适配,窄屏仍走原有底部标签栏与弹层交互。
This commit is contained in:
toor
2026-10-22 03:30:56 +08:00
committed by trae-solo-agent
parent 7049eb02a8
commit 7408591fac
18 changed files with 3604 additions and 383 deletions
@@ -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>