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
+161 -38
View File
@@ -3,8 +3,10 @@ import type { IonSearchbarCustomEvent, SearchbarInputEventDetail } from '@ionic/
import type { DbRecipeItem } from '~/utils/db'
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { useIndexedDB } from '~/composables/db'
import { favoriteRecipeIds } from '~/composables/store/favorite'
import { favoriteRecipeIds, toggleFavorite } from '~/composables/store/favorite'
import { addRecipeHistory } from '~/composables/store/history'
import { db } from '~/utils/db'
import { openRecipe } from '~/utils/recipe'
import { yunleHaptic } from '~/utils/yunleHost'
definePageMeta({
@@ -78,9 +80,10 @@ function onIdle(cb: () => void) {
: window.setTimeout(cb, 32)
}
function cancelIdle(handle: number) {
return typeof cancelIdleCallback === 'function'
? cancelIdleCallback(handle)
: window.clearTimeout(handle)
if (typeof cancelIdleCallback === 'function')
cancelIdleCallback(handle)
else
window.clearTimeout(handle)
}
let idleHandle = 0
@@ -91,6 +94,25 @@ watch(displayed, (list) => {
idleHandle = onIdle(() => (renderCount.value = list.length))
})
onBeforeUnmount(() => cancelIdle(idleHandle))
// ---------- 桌面端表格 ----------
/** 一次只渲染一屏的量,往下滚再继续渲染 */
const TABLE_CHUNK = 200
const tableLimit = ref(TABLE_CHUNK)
const tableRows = computed(() => displayed.value.slice(0, tableLimit.value))
watch(displayed, () => {
tableLimit.value = TABLE_CHUNK
})
function openDish(dish: DbRecipeItem) {
addRecipeHistory(dish)
openRecipe(dish)
}
function toggleStar(dish: DbRecipeItem) {
toggleFavorite(dish)
void yunleHaptic('success')
}
</script>
<template>
@@ -113,14 +135,6 @@ onBeforeUnmount(() => cancelIdle(idleHandle))
菜谱
</ion-title>
</ion-toolbar>
<ion-toolbar>
<ion-searchbar
placeholder="搜索菜谱"
:debounce="150"
show-clear-button="focus"
@ion-input="onInput"
/>
</ion-toolbar>
<ion-toolbar class="library-segment">
<ion-segment
:value="view"
@@ -136,35 +150,144 @@ onBeforeUnmount(() => cancelIdle(idleHandle))
</ion-toolbar>
</ion-header>
<ion-list v-if="loading" aria-busy="true" aria-label="正在加载菜谱">
<ion-item v-for="i in 12" :key="i">
<ion-label>
<ion-skeleton-text :animated="true" :style="{ width: `${40 + (i * 37) % 45}%` }" />
</ion-label>
</ion-item>
</ion-list>
<div class="ledger-page">
<PageHead title="菜谱库" subtitle="按菜名、食材或厨具翻找所有菜谱">
<button type="button" class="ledger-btn is-ghost is-sm" @click="navigateTo('/recipes/new')">
新建
</button>
</PageHead>
<ion-list v-else-if="displayed.length">
<div
v-for="(chunk, i) in chunks"
:key="i"
class="library-chunk"
:style="{ containIntrinsicSize: `auto ${chunk.length * 44}px` }"
>
<RecipeListRow
v-for="dish in chunk"
:key="dish.id ?? dish.name"
class="library-row"
:dish="dish"
@toggled="onToggled"
/>
<!-- 桌面端:表格 -->
<div class="ledger-desktop-block">
<div class="ledger-toolbar">
<div class="ledger-search">
<ion-searchbar
:value="keyword"
placeholder="搜菜名,如 番茄炒蛋"
aria-label="搜索菜谱"
:debounce="150"
show-clear-button="focus"
@ion-input="onInput"
/>
</div>
<div class="ledger-segment" role="tablist" aria-label="菜谱范围">
<button type="button" role="tab" :aria-selected="view === 'all'" :class="{ 'is-on': view === 'all' }" @click="view = 'all'">
全部
</button>
<button type="button" role="tab" :aria-selected="view === 'fav'" :class="{ 'is-on': view === 'fav' }" @click="view = 'fav'">
收藏
</button>
</div>
<span class="ledger-lib-count">共 <b>{{ displayed.length }}</b> 道</span>
</div>
<div v-if="loading" class="ledger-table-wrap" aria-busy="true">
<table class="ledger-table">
<tbody>
<tr v-for="i in 8" :key="i">
<td colspan="6">
<span class="ledger-skeleton-line" />
</td>
</tr>
</tbody>
</table>
</div>
<div v-else-if="tableRows.length" class="ledger-table-wrap">
<table class="ledger-table">
<thead>
<tr>
<th style="width: 30%;">
菜名
</th>
<th>食材</th>
<th>厨具</th>
<th>做法</th>
<th>难度</th>
<th style="width: 48px;" />
</tr>
</thead>
<tbody>
<tr v-for="dish in tableRows" :key="dish.id ?? dish.name">
<td>
<button type="button" class="ledger-table-dish ledger-table-dish-btn" @click="openDish(dish)">
<span class="ledger-table-thumb" aria-hidden="true">{{ dish.emojis?.[0] ?? '🍽️' }}</span>
<b>{{ dish.name }}</b>
</button>
</td>
<td class="ledger-table-sub">
{{ dish.stuff.join('、') }}
</td>
<td class="ledger-table-sub">
{{ dish.tools?.join('、') }}
</td>
<td class="ledger-table-sub">
{{ dish.methods?.join('、') }}
</td>
<td class="ledger-table-num">
{{ dish.difficulty || '—' }}
</td>
<td>
<button
type="button"
class="ledger-star"
:class="{ 'is-on': isFav(dish) }"
:aria-label="isFav(dish) ? '取消收藏' : '收藏'"
:aria-pressed="isFav(dish)"
@click="toggleStar(dish)"
>
<ion-icon :icon="isFav(dish) ? ioniconsStar : ioniconsStarOutline" aria-hidden="true" />
</button>
</td>
</tr>
</tbody>
</table>
<div v-if="displayed.length > tableLimit" class="ledger-table-more">
<button type="button" class="ledger-btn is-ghost is-sm" @click="tableLimit += TABLE_CHUNK">
再看 {{ Math.min(TABLE_CHUNK, displayed.length - tableLimit) }} 道(还有 {{ displayed.length - tableLimit }} 道)
</button>
</div>
</div>
<div v-else class="ledger-empty">
<h3>{{ keyword ? '没有找到相关菜谱' : '还没有收藏的菜谱' }}</h3>
<p>{{ keyword ? '换个关键词试试' : '在列表里点星标即可收藏' }}</p>
</div>
</div>
</ion-list>
<div v-else class="cook-empty">
<ion-icon :icon="view === 'fav' && !keyword ? ioniconsStarOutline : ioniconsSearchOutline" />
<h3>{{ keyword ? '没有找到相关菜谱' : '还没有收藏的菜谱' }}</h3>
<p>{{ keyword ? '换个关键词试试' : '左滑菜谱或点星标即可收藏' }}</p>
<!-- 移动端:列表 -->
<div class="ledger-mobile-only">
<ion-list v-if="loading" aria-busy="true" aria-label="正在加载菜谱">
<ion-item v-for="i in 12" :key="i">
<ion-label>
<ion-skeleton-text :animated="true" :style="{ width: `${40 + (i * 37) % 45}%` }" />
</ion-label>
</ion-item>
</ion-list>
<ion-list v-else-if="displayed.length">
<div
v-for="(chunk, i) in chunks"
:key="i"
class="library-chunk"
:style="{ containIntrinsicSize: `auto ${chunk.length * 44}px` }"
>
<RecipeListRow
v-for="dish in chunk"
:key="dish.id ?? dish.name"
class="library-row"
:dish="dish"
@toggled="onToggled"
/>
</div>
</ion-list>
<div v-else class="cook-empty">
<ion-icon :icon="view === 'fav' && !keyword ? ioniconsStarOutline : ioniconsSearchOutline" />
<h3>{{ keyword ? '没有找到相关菜谱' : '还没有收藏的菜谱' }}</h3>
<p>{{ keyword ? '换个关键词试试' : '左滑菜谱或点星标即可收藏' }}</p>
</div>
</div>
</div>
</ion-content>
<ion-toast