chore: 导入清理前的完整工程状态(含 Capacitor 原生壳,作为回滚备份点)
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { links } from '~/constants'
|
||||
|
||||
definePageMeta({
|
||||
alias: ['/apps'],
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: '发现',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ion-page>
|
||||
<ion-header :translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-title>发现</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content :fullscreen="true">
|
||||
<ion-header collapse="condense">
|
||||
<ion-toolbar>
|
||||
<ion-title size="large">
|
||||
发现
|
||||
</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-list :inset="true">
|
||||
<ion-item router-link="/apps/random">
|
||||
<CookListIcon slot="start" :icon="ioniconsShuffleOutline" color="#ff9500" />
|
||||
<ion-label>今天吃什么</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<ion-list :inset="true">
|
||||
<ion-item :href="links.githubIssue" target="_blank" :detail-icon="ioniconsOpenOutline">
|
||||
<CookListIcon slot="start" :icon="ioniconsBulbOutline" color="#34c759" />
|
||||
<ion-label>新功能建议</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
</ion-page>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts" setup>
|
||||
import type { RecipeMatch } from '@yunyoujun/cook'
|
||||
import type { RecipeItem } from '~/types'
|
||||
import { onIonViewDidEnter, onIonViewWillLeave } from '@ionic/vue'
|
||||
import { yunleHaptic } from '~/utils/yunleHost'
|
||||
|
||||
definePageMeta({
|
||||
alias: ['/', '/home', '/tabs'],
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: '食用手册',
|
||||
})
|
||||
|
||||
const rStore = useRecipeStore()
|
||||
const active = shallowRef(false)
|
||||
onIonViewDidEnter(() => {
|
||||
active.value = true
|
||||
})
|
||||
onIonViewWillLeave(() => {
|
||||
active.value = false
|
||||
})
|
||||
const panelRef = ref<{ $el: HTMLElement }>()
|
||||
const flightTarget = computed(() => panelRef.value?.$el)
|
||||
const contentRef = ref<{ $el: HTMLIonContentElement }>()
|
||||
const reducedMotion = usePreferredReducedMotion()
|
||||
|
||||
/** 结果弹层遮住的高度,留作页面底部空白 */
|
||||
const panelInset = ref(0)
|
||||
|
||||
const hasSelection = computed(() => rStore.selectedStuff.length > 0 || Boolean(rStore.curTool))
|
||||
|
||||
function reset() {
|
||||
rStore.reset()
|
||||
void yunleHaptic('medium')
|
||||
}
|
||||
|
||||
function scrollTop() {
|
||||
void contentRef.value?.$el.scrollToTop(reducedMotion.value === 'reduce' ? 0 : 300)
|
||||
}
|
||||
|
||||
const feedback = useRecipeFeedback()
|
||||
const selection = () => ({ stuff: rStore.selectedStuff, tool: rStore.curTool || undefined, mode: rStore.curMode })
|
||||
|
||||
const detail = shallowRef<{ recipe: RecipeItem, match?: RecipeMatch }>()
|
||||
const detailOpen = ref(false)
|
||||
function openDetail(recipe: RecipeItem, match?: RecipeMatch) {
|
||||
detail.value = { recipe, match }
|
||||
detailOpen.value = true
|
||||
|
||||
// 匿名记录「这组食材下点开了第几个结果」,用来评估和调整排序
|
||||
const rank = match ? rStore.recipeMatches.indexOf(match) : -1
|
||||
if (rank >= 0 && typeof recipe.id === 'number')
|
||||
feedback.signal({ type: 'open', ...selection(), recipeId: recipe.id, rank })
|
||||
}
|
||||
|
||||
// 停在一组没有结果的食材上时记一次,用来发现缺哪些菜
|
||||
watchDebounced(
|
||||
() => [rStore.isSearching, rStore.recipesLength, rStore.selectedStuff.join('、'), rStore.curTool, rStore.curMode, rStore.keyword, rStore.recipeMatches.length],
|
||||
() => {
|
||||
if (!rStore.isSearching && rStore.recipesLength && rStore.selectedStuff.length && !rStore.keyword && !rStore.recipeMatches.length)
|
||||
feedback.signal({ type: 'empty', ...selection() })
|
||||
},
|
||||
{ debounce: 3000 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ion-page>
|
||||
<ion-header :translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-title>做菜</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button :disabled="!hasSelection" @click="reset">
|
||||
清空
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content ref="contentRef" :fullscreen="true" :style="{ '--padding-bottom': `${panelInset + 16}px` }">
|
||||
<ion-header collapse="condense">
|
||||
<ion-toolbar>
|
||||
<ion-title size="large">
|
||||
做菜
|
||||
</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ChooseFood :flight-target="flightTarget" />
|
||||
</ion-content>
|
||||
|
||||
<ResultsPanel ref="panelRef" v-model:inset="panelInset" :active="active" @scroll-top="scrollTop" @detail="openDetail" />
|
||||
<RecipeDetailSheet v-model:open="detailOpen" :recipe="detail?.recipe" :match="detail?.match" />
|
||||
</ion-page>
|
||||
</template>
|
||||
@@ -0,0 +1,190 @@
|
||||
<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 } from '~/composables/store/favorite'
|
||||
import { db } from '~/utils/db'
|
||||
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) {
|
||||
return typeof cancelIdleCallback === 'function'
|
||||
? cancelIdleCallback(handle)
|
||||
: 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))
|
||||
</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>
|
||||
<ion-searchbar
|
||||
placeholder="搜索菜谱"
|
||||
:debounce="150"
|
||||
show-clear-button="focus"
|
||||
@ion-input="onInput"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
@@ -0,0 +1,218 @@
|
||||
<script lang="ts" setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { customRecipes } from '~/composables/store/customRecipe'
|
||||
import { links } from '~/constants'
|
||||
|
||||
definePageMeta({
|
||||
alias: ['/my'],
|
||||
})
|
||||
|
||||
const {
|
||||
account,
|
||||
configurationMessage,
|
||||
errorMessage,
|
||||
isAuthenticated,
|
||||
signIn,
|
||||
signOut,
|
||||
status,
|
||||
} = useYunlefunAuth()
|
||||
|
||||
const authBusy = computed(() => status.value === 'checking' || status.value === 'signing-in')
|
||||
|
||||
const {
|
||||
errorMessage: syncErrorMessage,
|
||||
lastSyncedAt,
|
||||
status: syncStatus,
|
||||
syncNow,
|
||||
} = useUserDataSync()
|
||||
|
||||
const syncText = computed(() => {
|
||||
switch (syncStatus.value) {
|
||||
case 'syncing':
|
||||
return '正在同步…'
|
||||
case 'synced':
|
||||
return `已同步 ${dayjs(lastSyncedAt.value).format('HH:mm')}`
|
||||
case 'error':
|
||||
return `${syncErrorMessage.value},点击重试`
|
||||
default:
|
||||
return '等待同步'
|
||||
}
|
||||
})
|
||||
|
||||
function handleSyncClick(): void {
|
||||
if (syncStatus.value === 'error')
|
||||
syncNow()
|
||||
}
|
||||
|
||||
async function handleSignIn(): Promise<void> {
|
||||
await signIn()
|
||||
}
|
||||
|
||||
async function handleSignOut(): Promise<void> {
|
||||
await signOut()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ion-page>
|
||||
<ion-header :translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-title>我的</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content :fullscreen="true">
|
||||
<ion-header collapse="condense">
|
||||
<ion-toolbar>
|
||||
<ion-title size="large">
|
||||
我的
|
||||
</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-list :inset="true">
|
||||
<ion-item v-if="isAuthenticated && account" class="my-account" lines="full">
|
||||
<ion-avatar v-if="account.avatarUrl" slot="start">
|
||||
<img :alt="account.displayName" :src="account.avatarUrl">
|
||||
</ion-avatar>
|
||||
<ion-icon v-else slot="start" class="my-account-placeholder" :icon="ioniconsPersonCircle" />
|
||||
<ion-label>
|
||||
<h2>{{ account.displayName }}</h2>
|
||||
<p>云乐坊账号</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
v-if="isAuthenticated"
|
||||
:button="syncStatus === 'error'"
|
||||
:detail="false"
|
||||
@click="handleSyncClick"
|
||||
>
|
||||
<span v-if="syncStatus === 'syncing'" slot="start" class="my-sync-spinner">
|
||||
<ion-spinner name="crescent" />
|
||||
</span>
|
||||
<CookListIcon
|
||||
v-else
|
||||
slot="start"
|
||||
:icon="syncStatus === 'error' ? ioniconsCloudOfflineOutline : ioniconsCloudDoneOutline"
|
||||
:color="syncStatus === 'error' ? '#ff3b30' : '#34c759'"
|
||||
/>
|
||||
<ion-label>
|
||||
<h2>收藏和历史云同步</h2>
|
||||
<p>{{ syncText }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
v-if="!isAuthenticated"
|
||||
:button="true"
|
||||
:disabled="authBusy || Boolean(configurationMessage)"
|
||||
@click="handleSignIn"
|
||||
>
|
||||
<span v-if="authBusy" slot="start" class="my-sync-spinner">
|
||||
<ion-spinner name="crescent" />
|
||||
</span>
|
||||
<CookListIcon v-else slot="start" :icon="ioniconsLogInOutline" color="#007aff" />
|
||||
<ion-label>
|
||||
<h2>{{ authBusy ? '正在检查登录状态' : '使用云乐坊账号登录' }}</h2>
|
||||
<p>安全授权,无需在 Cook 输入密码</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item v-if="configurationMessage || errorMessage" lines="none">
|
||||
<ion-icon slot="start" color="danger" :icon="ioniconsAlertCircleOutline" />
|
||||
<ion-label class="ion-text-wrap" color="danger">
|
||||
{{ configurationMessage || errorMessage }}
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<ion-list :inset="true">
|
||||
<ion-item router-link="/recipes/history">
|
||||
<CookListIcon slot="start" :icon="ioniconsTimeOutline" color="#5856d6" />
|
||||
<ion-label>历史记录</ion-label>
|
||||
</ion-item>
|
||||
<ion-item router-link="/recipes/favorites">
|
||||
<CookListIcon slot="start" :icon="ioniconsStarOutline" color="#ff9500" />
|
||||
<ion-label>我的收藏</ion-label>
|
||||
</ion-item>
|
||||
<ion-item router-link="/recipes/mine">
|
||||
<CookListIcon slot="start" :icon="ioniconsBookOutline" color="#a2845e" />
|
||||
<ion-label>我的菜谱</ion-label>
|
||||
<ion-note v-if="customRecipes.length" slot="end">
|
||||
{{ customRecipes.length }}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<ion-list :inset="true">
|
||||
<ion-item :href="links.githubIssue" target="_blank" :detail-icon="ioniconsOpenOutline">
|
||||
<CookListIcon slot="start" :icon="ioniconsChatbubbleEllipsesOutline" color="#34c759" />
|
||||
<ion-label>问题反馈</ion-label>
|
||||
</ion-item>
|
||||
<ion-item :href="links.githubDiscussions" target="_blank" :detail-icon="ioniconsOpenOutline">
|
||||
<CookListIcon slot="start" :icon="ioniconsChatbubblesOutline" color="#30b0c7" />
|
||||
<ion-label>参与讨论</ion-label>
|
||||
</ion-item>
|
||||
<ion-item :href="links.contribute" target="_blank" :detail-icon="ioniconsOpenOutline">
|
||||
<CookListIcon slot="start" :icon="ioniconsMailOutline" color="#ff2d55" />
|
||||
<ion-label>菜谱投稿</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<ion-list :inset="true">
|
||||
<ion-item router-link="/settings">
|
||||
<CookListIcon slot="start" :icon="ioniconsSettingsOutline" color="#8e8e93" />
|
||||
<ion-label>设置</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<ion-list :inset="true">
|
||||
<ion-item router-link="/changelog">
|
||||
<CookListIcon slot="start" :icon="ioniconsDocumentTextOutline" color="#af52de" />
|
||||
<ion-label>更新日志</ion-label>
|
||||
</ion-item>
|
||||
<ion-item router-link="/help">
|
||||
<CookListIcon slot="start" :icon="ioniconsHelpCircleOutline" color="#007aff" />
|
||||
<ion-label>帮助</ion-label>
|
||||
</ion-item>
|
||||
<ion-item router-link="/about">
|
||||
<CookListIcon slot="start" :icon="ioniconsInformationCircleOutline" color="#5ac8fa" />
|
||||
<ion-label>关于</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<ion-list v-if="isAuthenticated" :inset="true">
|
||||
<ion-item :button="true" :detail="false" :disabled="authBusy" @click="handleSignOut">
|
||||
<ion-label class="ion-text-center" color="danger">
|
||||
退出登录
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
</ion-page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.my-account {
|
||||
--padding-top: 6px;
|
||||
--padding-bottom: 6px;
|
||||
}
|
||||
.my-account ion-avatar {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-inline-end: 14px;
|
||||
}
|
||||
.my-account-placeholder {
|
||||
font-size: 56px;
|
||||
margin-inline-end: 14px;
|
||||
color: var(--ion-color-medium);
|
||||
}
|
||||
.my-account h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.my-sync-spinner {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
width: 29px;
|
||||
margin-inline-end: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user