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

314 lines
11 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 { IonSearchbarCustomEvent, SearchbarInputEventDetail } from '@ionic/core'
import type { DbRecipeItem } from '~/utils/db'
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { useIndexedDB } from '~/composables/db'
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({
alias: ['/library'],
})
const keyword = ref('')
const loading = ref(true)
const recipes = shallowRef<DbRecipeItem[]>([])
const view = ref<'all' | 'fav'>('all')
const favoriteIdSet = computed(() => new Set(favoriteRecipeIds.value))
const isFav = (r: DbRecipeItem) => r.id != null && favoriteIdSet.value.has(r.id)
const filtered = computed(() => {
const text = keyword.value.trim()
return text ? recipes.value.filter(r => r.name.includes(text)) : recipes.value
})
const displayed = computed(() => view.value === 'fav' ? filtered.value.filter(isFav) : filtered.value)
const favCount = computed(() => recipes.value.filter(isFav).length)
const showToast = ref(false)
const toastMessage = ref('')
onMounted(async () => {
try {
await useIndexedDB().init()
recipes.value = await db.recipes.toArray()
}
finally {
loading.value = false
}
})
function onViewChange(value: unknown) {
view.value = value === 'fav' ? 'fav' : 'all'
void yunleHaptic('light')
}
function onInput(ev: IonSearchbarCustomEvent<SearchbarInputEventDetail>) {
keyword.value = ev.detail.value ?? ''
}
function onToggled(favorited: boolean) {
toastMessage.value = favorited ? '已添加到收藏' : '已从收藏移除'
showToast.value = true
}
// 先渲染首屏的行,其余在空闲时一次补齐(分成很多小批反而因为反复 diff 整个列表更慢)。
// 不做虚拟滚动:行全部留在 DOM 里,滚动时不需要任何 JS 和重新布局;
// 每行是独立组件,页面重新渲染(例如切换 tab)时行不会跟着更新。
const FIRST_BATCH = 30
const renderCount = ref(FIRST_BATCH)
const rendered = computed(() => displayed.value.slice(0, renderCount.value))
// 每 50 行一组,组外用 content-visibility 跳过布局:切回这个 tab 时不用重新布局整张列表,
// 滚动时也只在进入新的一组时布局一次,而不是每一帧都布局
const CHUNK_SIZE = 50
const chunks = computed(() => {
const list = rendered.value
const result: DbRecipeItem[][] = []
for (let i = 0; i < list.length; i += CHUNK_SIZE)
result.push(list.slice(i, i + CHUNK_SIZE))
return result
})
// iOS Safari 没有 requestIdleCallback
function onIdle(cb: () => void) {
return typeof requestIdleCallback === 'function'
? requestIdleCallback(cb, { timeout: 300 })
: window.setTimeout(cb, 32)
}
function cancelIdle(handle: number) {
if (typeof cancelIdleCallback === 'function')
cancelIdleCallback(handle)
else
window.clearTimeout(handle)
}
let idleHandle = 0
watch(displayed, (list) => {
cancelIdle(idleHandle)
renderCount.value = FIRST_BATCH
if (list.length > FIRST_BATCH)
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>
<ion-page>
<ion-header :translucent="true">
<ion-toolbar>
<ion-title>菜谱</ion-title>
<ion-buttons slot="end">
<ion-button title="添加菜谱" router-link="/recipes/new">
<ion-icon slot="icon-only" :icon="ioniconsAddCircleOutline" />
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true">
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">
菜谱
</ion-title>
</ion-toolbar>
<ion-toolbar class="library-segment">
<ion-segment
:value="view"
@ion-change="onViewChange($event.detail.value)"
>
<ion-segment-button value="all">
<ion-label>全部</ion-label>
</ion-segment-button>
<ion-segment-button value="fav">
<ion-label>收藏 ({{ favCount }})</ion-label>
</ion-segment-button>
</ion-segment>
</ion-toolbar>
</ion-header>
<div class="ledger-page">
<PageHead title="菜谱库" subtitle="按菜名、食材或厨具翻找所有菜谱">
<button type="button" class="ledger-btn is-ghost is-sm" @click="navigateTo('/recipes/new')">
新建
</button>
</PageHead>
<!-- 桌面端:表格 -->
<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>
<!-- 移动端:列表 -->
<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
:is-open="showToast"
:message="toastMessage"
duration="1200"
position="top"
@did-dismiss="showToast = false"
/>
</ion-page>
</template>
<style scoped>
.library-segment {
--padding-bottom: 8px;
}
.library-chunk {
content-visibility: auto;
}
.library-row {
height: 44px;
}
</style>