新增 ledger.css 设计令牌与桌面端布局,AppRail 左导航、PageHead 桌面页头、ResultsColumn 常驻结果栏;做菜/菜谱/发现/我的/设置/帮助/关于各页做桌面窄屏双形态适配,窄屏仍走原有底部标签栏与弹层交互。
314 lines
11 KiB
Vue
314 lines
11 KiB
Vue
<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>
|