chore: 导入清理前的完整工程状态(含 Capacitor 原生壳,作为回滚备份点)
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IonSearchbarCustomEvent, SearchbarInputEventDetail } from '@ionic/core'
|
||||
import type { DbRecipeItem } from '~/utils/db'
|
||||
import { getFavoriteTime, isFavorited, toggleFavorite } from '~/composables/store/favorite'
|
||||
import { db } from '~/utils/db'
|
||||
import { confirmDestructive } from '~/utils/dialog'
|
||||
import { openRecipe } from '~/utils/recipe'
|
||||
import { yunleHaptic } from '~/utils/yunleHost'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'child',
|
||||
title: '我的收藏',
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
const recipes = shallowRef<DbRecipeItem[]>([])
|
||||
const keyword = ref('')
|
||||
const sortKey = ref<'time' | 'name'>('time')
|
||||
|
||||
const displayed = computed(() => {
|
||||
const text = keyword.value.trim()
|
||||
let list = recipes.value
|
||||
if (text)
|
||||
list = list.filter(r => r.name.includes(text))
|
||||
|
||||
if (sortKey.value === 'name')
|
||||
return [...list].sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
// time: sort by recorded favorite timestamp (recent first)
|
||||
return [...list].sort((a, b) => {
|
||||
const ta = getFavoriteTime(a) ?? -Infinity
|
||||
const tb = getFavoriteTime(b) ?? -Infinity
|
||||
return tb - ta
|
||||
})
|
||||
})
|
||||
|
||||
async function loadFavorites() {
|
||||
try {
|
||||
const all = await db.recipes.toArray()
|
||||
recipes.value = all.filter(r => isFavorited(r))
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadFavorites)
|
||||
|
||||
async function clearAllFavorites() {
|
||||
if (!await confirmDestructive({ header: '将取消所有收藏', confirmText: '清空收藏' }))
|
||||
return
|
||||
for (const item of recipes.value)
|
||||
toggleFavorite(item)
|
||||
void yunleHaptic('success')
|
||||
await loadFavorites()
|
||||
}
|
||||
|
||||
function onToggleFavorite(item: DbRecipeItem) {
|
||||
toggleFavorite(item)
|
||||
void yunleHaptic('medium')
|
||||
// update list immediately
|
||||
recipes.value = recipes.value.filter(r => isFavorited(r))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ion-page>
|
||||
<ion-header :translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button default-href="/my" />
|
||||
</ion-buttons>
|
||||
<ion-title>我的收藏</ion-title>
|
||||
|
||||
<ion-buttons slot="end">
|
||||
<ion-button title="清空收藏" :disabled="!recipes.length" @click="clearAllFavorites">
|
||||
<ion-icon slot="icon-only" :icon="ioniconsTrashOutline" />
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
|
||||
<ion-toolbar>
|
||||
<ion-searchbar
|
||||
placeholder="搜索收藏"
|
||||
:debounce="150"
|
||||
show-clear-button="focus"
|
||||
@ion-input="(ev: IonSearchbarCustomEvent<SearchbarInputEventDetail>) => (keyword = ev.detail.value ?? '')"
|
||||
/>
|
||||
</ion-toolbar>
|
||||
|
||||
<ion-toolbar class="favorites-segment">
|
||||
<ion-segment
|
||||
:value="sortKey"
|
||||
@ion-change="e => (sortKey = (e.detail.value as 'time' | 'name') ?? 'time')"
|
||||
>
|
||||
<ion-segment-button value="time">
|
||||
<ion-label>按收藏时间</ion-label>
|
||||
</ion-segment-button>
|
||||
<ion-segment-button value="name">
|
||||
<ion-label>按名称</ion-label>
|
||||
</ion-segment-button>
|
||||
</ion-segment>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content :fullscreen="true">
|
||||
<ion-list v-if="loading" aria-busy="true" aria-label="正在加载收藏">
|
||||
<ion-item v-for="i in 6" :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">
|
||||
<TransitionGroup name="cook-row" tag="div" class="cook-row-group">
|
||||
<ion-item-sliding v-for="item in displayed" :key="item.id ?? item.name">
|
||||
<ion-item :button="true" :detail="false" @click="openRecipe(item)">
|
||||
<ion-label class="truncate">
|
||||
<DishLabel class="text-sm" :dish="item" />
|
||||
</ion-label>
|
||||
<ion-icon slot="end" :icon="ioniconsStar" color="warning" />
|
||||
</ion-item>
|
||||
|
||||
<ion-item-options side="end" @ion-swipe="onToggleFavorite(item)">
|
||||
<ion-item-option color="medium" :expandable="true" @click="onToggleFavorite(item)">
|
||||
取消收藏
|
||||
</ion-item-option>
|
||||
</ion-item-options>
|
||||
</ion-item-sliding>
|
||||
</TransitionGroup>
|
||||
</ion-list>
|
||||
|
||||
<div v-else class="cook-empty">
|
||||
<ion-icon :icon="keyword ? ioniconsSearchOutline : ioniconsStarOutline" />
|
||||
<h3>{{ keyword ? '没有找到相关收藏' : '还没有收藏的菜谱' }}</h3>
|
||||
<p>{{ keyword ? '换个关键词试试' : '在「菜谱」里左滑或点星标即可收藏' }}</p>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.favorites-segment {
|
||||
--padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts" setup>
|
||||
import type { RecipeHistoryItem } from '~/composables/store/history'
|
||||
import dayjs from 'dayjs'
|
||||
import { clearRecipeHistories, recipeHistories, removeRecipeHistory } from '~/composables/store/history'
|
||||
import { confirmDestructive } from '~/utils/dialog'
|
||||
import { openRecipe } from '~/utils/recipe'
|
||||
import { yunleHaptic } from '~/utils/yunleHost'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'child',
|
||||
title: '历史记录',
|
||||
})
|
||||
|
||||
/**
|
||||
* 按天分组,最近的在最前
|
||||
*/
|
||||
const sections = computed(() => {
|
||||
const today = dayjs().startOf('day')
|
||||
const groups: { label: string, items: RecipeHistoryItem[] }[] = []
|
||||
const sorted = [...recipeHistories.value].sort((a, b) => b.time - a.time)
|
||||
for (const history of sorted) {
|
||||
const day = dayjs(history.time).startOf('day')
|
||||
const diff = today.diff(day, 'day')
|
||||
const label = diff === 0 ? '今天' : diff === 1 ? '昨天' : day.year() === today.year() ? day.format('M 月 D 日') : day.format('YYYY 年 M 月 D 日')
|
||||
const last = groups.at(-1)
|
||||
if (last?.label === label)
|
||||
last.items.push(history)
|
||||
else
|
||||
groups.push({ label, items: [history] })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
async function clearAllHistory() {
|
||||
if (await confirmDestructive({ header: '历史记录清空后不可恢复', confirmText: '清空历史记录' })) {
|
||||
clearRecipeHistories()
|
||||
void yunleHaptic('success')
|
||||
}
|
||||
}
|
||||
|
||||
function clearOneHistory(history: RecipeHistoryItem) {
|
||||
removeRecipeHistory(history)
|
||||
void yunleHaptic('medium')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ion-page>
|
||||
<ion-header :translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button default-href="/my" />
|
||||
</ion-buttons>
|
||||
<ion-title>历史记录</ion-title>
|
||||
|
||||
<ion-buttons slot="end">
|
||||
<ion-button title="清空记录" :disabled="!recipeHistories.length" @click="clearAllHistory">
|
||||
<ion-icon slot="icon-only" :icon="ioniconsTrashOutline" />
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content :fullscreen="true">
|
||||
<ion-list v-if="sections.length">
|
||||
<ion-item-group v-for="section in sections" :key="section.label">
|
||||
<ion-item-divider sticky>
|
||||
<ion-label>{{ section.label }}</ion-label>
|
||||
</ion-item-divider>
|
||||
<TransitionGroup name="cook-row" tag="div" class="cook-row-group">
|
||||
<ion-item-sliding v-for="history in section.items" :key="`${history.time}-${history.recipe.name}`">
|
||||
<ion-item :button="true" :detail="false" @click="openRecipe(history.recipe)">
|
||||
<ion-label class="truncate">
|
||||
<DishLabel class="text-sm" :dish="history.recipe" />
|
||||
</ion-label>
|
||||
<ion-note slot="end" class="text-xs">
|
||||
{{ dayjs(history.time).format('HH:mm') }}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-options side="end" @ion-swipe="clearOneHistory(history)">
|
||||
<ion-item-option color="danger" :expandable="true" @click="clearOneHistory(history)">
|
||||
删除
|
||||
</ion-item-option>
|
||||
</ion-item-options>
|
||||
</ion-item-sliding>
|
||||
</TransitionGroup>
|
||||
</ion-item-group>
|
||||
</ion-list>
|
||||
|
||||
<div v-else class="cook-empty">
|
||||
<ion-icon :icon="ioniconsTimeOutline" />
|
||||
<h3>还没有历史记录</h3>
|
||||
<p>打开过的菜谱会出现在这里</p>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
ion-item-divider {
|
||||
--background: var(--ion-background-color);
|
||||
--color: var(--ion-color-step-600, #666);
|
||||
min-height: 32px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({
|
||||
layout: 'child',
|
||||
title: '菜谱 - ?',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
asd
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CustomRecipe } from '~/composables/store/customRecipe'
|
||||
import { customRecipes, removeCustomRecipe } from '~/composables/store/customRecipe'
|
||||
import { yunleHaptic } from '~/utils/yunleHost'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'child',
|
||||
title: '我的菜谱',
|
||||
})
|
||||
|
||||
/**
|
||||
* 自己添加的菜谱:点一下编辑,左滑删除
|
||||
*/
|
||||
const sorted = computed(() => [...customRecipes.value].sort((a, b) => b.updatedAt - a.updatedAt))
|
||||
|
||||
function remove(recipe: CustomRecipe) {
|
||||
removeCustomRecipe(recipe.customId)
|
||||
void yunleHaptic('medium')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ion-page>
|
||||
<ion-header :translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button default-href="/my" />
|
||||
</ion-buttons>
|
||||
<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-list v-if="sorted.length">
|
||||
<TransitionGroup name="cook-row" tag="div" class="cook-row-group">
|
||||
<ion-item-sliding v-for="recipe in sorted" :key="recipe.customId">
|
||||
<ion-item :router-link="`/recipes/new?id=${recipe.customId}`">
|
||||
<ion-label>
|
||||
<h3>{{ recipe.name }}</h3>
|
||||
<p>{{ recipe.stuff.join(' · ') }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-options side="end" @ion-swipe="remove(recipe)">
|
||||
<ion-item-option color="danger" :expandable="true" @click="remove(recipe)">
|
||||
删除
|
||||
</ion-item-option>
|
||||
</ion-item-options>
|
||||
</ion-item-sliding>
|
||||
</TransitionGroup>
|
||||
</ion-list>
|
||||
|
||||
<div v-else class="cook-empty">
|
||||
<ion-icon :icon="ioniconsBookOutline" />
|
||||
<h3>还没有自己的菜谱</h3>
|
||||
<p>添加家里常做的菜,搭配推荐和「吃什么」都会算上它</p>
|
||||
<ion-button fill="clear" router-link="/recipes/new">
|
||||
添加菜谱
|
||||
</ion-button>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-page>
|
||||
</template>
|
||||
@@ -0,0 +1,222 @@
|
||||
<script lang="ts" setup>
|
||||
import type { CustomRecipe } from '~/composables/store/customRecipe'
|
||||
import type { StuffItem } from '~/types'
|
||||
import { onIonViewWillEnter } from '@ionic/vue'
|
||||
import { uniqueStuff } from '@yunyoujun/cook/recommend'
|
||||
import { getCustomRecipe, removeCustomRecipe, saveCustomRecipe } from '~/composables/store/customRecipe'
|
||||
import { meat, staple, tools, vegetable } from '~/data/food'
|
||||
import { buildCustomRecipe, CUSTOM_RECIPE_NAME_MAX } from '~/utils/customRecipe'
|
||||
import { confirmDestructive } from '~/utils/dialog'
|
||||
import { getRecipeVideoLink } from '~/utils/recipe'
|
||||
import { yunleHaptic } from '~/utils/yunleHost'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'child',
|
||||
title: '添加菜谱',
|
||||
})
|
||||
|
||||
/**
|
||||
* 添加 / 编辑自己的菜谱:带 ?id= 时编辑已有的那道
|
||||
*/
|
||||
const router = useRouter()
|
||||
const ionRouter = useIonRouter()
|
||||
|
||||
const existing = shallowRef<CustomRecipe>()
|
||||
const name = ref('')
|
||||
const stuff = ref<string[]>([])
|
||||
const selectedTools = ref<string[]>([])
|
||||
const source = ref('')
|
||||
const note = ref('')
|
||||
const error = ref('')
|
||||
|
||||
// Ionic 会复用路由栈里的页面,所以每次进入都按当前地址重新填表;
|
||||
// 在 ion-router-outlet 里 useRoute 读不到这一页的 ?id=,直接读 router
|
||||
function fillForm() {
|
||||
const id = router.currentRoute.value.query.id
|
||||
const recipe = typeof id === 'string' ? getCustomRecipe(id) : undefined
|
||||
existing.value = recipe
|
||||
name.value = recipe?.name ?? ''
|
||||
stuff.value = recipe ? [...recipe.stuff] : []
|
||||
selectedTools.value = recipe ? [...recipe.tools] : []
|
||||
source.value = recipe ? getRecipeVideoLink(recipe) : ''
|
||||
note.value = recipe?.note ?? ''
|
||||
error.value = ''
|
||||
}
|
||||
fillForm()
|
||||
onIonViewWillEnter(fillForm)
|
||||
|
||||
const sections = [
|
||||
{ title: '蔬菜', items: vegetable },
|
||||
{ title: '肉和蛋', items: meat },
|
||||
{ title: '主食', items: staple },
|
||||
]
|
||||
|
||||
function selectedNote(items: StuffItem[]) {
|
||||
const count = items.filter(item => stuff.value.includes(item.name)).length
|
||||
return count ? `已选 ${count} 种` : ''
|
||||
}
|
||||
|
||||
function addStuff(text: string) {
|
||||
stuff.value = uniqueStuff([...stuff.value, text])
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function toggleStuff(item: string) {
|
||||
if (stuff.value.includes(item))
|
||||
stuff.value = stuff.value.filter(name => name !== item)
|
||||
else
|
||||
addStuff(item)
|
||||
void yunleHaptic('light')
|
||||
}
|
||||
|
||||
function toggleTool(item: string) {
|
||||
selectedTools.value = selectedTools.value.includes(item)
|
||||
? selectedTools.value.filter(name => name !== item)
|
||||
: [...selectedTools.value, item]
|
||||
void yunleHaptic('light')
|
||||
}
|
||||
|
||||
function save() {
|
||||
const result = buildCustomRecipe({
|
||||
name: name.value,
|
||||
stuff: stuff.value,
|
||||
tools: selectedTools.value,
|
||||
source: source.value,
|
||||
note: note.value,
|
||||
}, existing.value)
|
||||
if ('error' in result) {
|
||||
error.value = result.error
|
||||
void yunleHaptic('heavy')
|
||||
return
|
||||
}
|
||||
saveCustomRecipe(result.recipe)
|
||||
void yunleHaptic('success')
|
||||
ionRouter.navigate('/recipes/mine', 'back', 'replace')
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const recipe = existing.value
|
||||
if (!recipe)
|
||||
return
|
||||
if (await confirmDestructive({ header: `删除「${recipe.name}」后不可恢复`, confirmText: '删除菜谱' })) {
|
||||
removeCustomRecipe(recipe.customId)
|
||||
void yunleHaptic('success')
|
||||
ionRouter.navigate('/recipes/mine', 'back', 'replace')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ion-page>
|
||||
<ion-header :translucent="true">
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button default-href="/recipes/mine" />
|
||||
</ion-buttons>
|
||||
<ion-title>{{ existing ? '编辑菜谱' : '添加菜谱' }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button :strong="true" @click="save">
|
||||
保存
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content :fullscreen="true" class="custom-recipe-form">
|
||||
<ion-list :inset="true">
|
||||
<ion-item>
|
||||
<ion-input
|
||||
v-model="name"
|
||||
label="菜名"
|
||||
placeholder="例如:外婆的红烧肉"
|
||||
:maxlength="CUSTOM_RECIPE_NAME_MAX"
|
||||
:clear-input="true"
|
||||
@ion-input="error = ''"
|
||||
/>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<h2 class="cook-group-header">
|
||||
食材 · {{ stuff.length }} 种
|
||||
</h2>
|
||||
<IngredientInput :selected="stuff" @add="addStuff" @remove="toggleStuff" />
|
||||
<IngredientSection v-for="section in sections" :key="section.title" :title="section.title" :note="selectedNote(section.items)">
|
||||
<IngredientChip
|
||||
v-for="item in section.items"
|
||||
:key="item.name"
|
||||
:label="item.name"
|
||||
:emoji="item.emoji"
|
||||
:selected="stuff.includes(item.name)"
|
||||
@click="toggleStuff(item.name)"
|
||||
/>
|
||||
</IngredientSection>
|
||||
|
||||
<IngredientSection title="厨具(可选)">
|
||||
<IngredientChip
|
||||
v-for="item in tools"
|
||||
:key="item.name"
|
||||
:label="item.name === '一口大锅' ? '炒锅' : (item.label || item.name)"
|
||||
:icon="item.icon"
|
||||
:selected="selectedTools.includes(item.name)"
|
||||
@click="toggleTool(item.name)"
|
||||
/>
|
||||
</IngredientSection>
|
||||
|
||||
<h2 class="cook-group-header">
|
||||
做法(可选)
|
||||
</h2>
|
||||
<ion-list :inset="true" class="custom-recipe-fields">
|
||||
<ion-item>
|
||||
<ion-input
|
||||
v-model="source"
|
||||
label="链接"
|
||||
placeholder="B 站链接、BV 号或网址"
|
||||
inputmode="url"
|
||||
:clear-input="true"
|
||||
@ion-input="error = ''"
|
||||
/>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-textarea
|
||||
v-model="note"
|
||||
label="备注"
|
||||
label-placement="stacked"
|
||||
placeholder="没有视频的话,简单记一下步骤和用量"
|
||||
:auto-grow="true"
|
||||
:rows="3"
|
||||
/>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
|
||||
<p v-if="error" class="cook-group-footer custom-recipe-error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p class="cook-group-footer">
|
||||
自己添加的菜谱只保存在这台设备上,会和收录的菜谱一起参与搭配推荐和「吃什么」。
|
||||
</p>
|
||||
|
||||
<ion-list v-if="existing" :inset="true" class="custom-recipe-delete">
|
||||
<ion-item :button="true" :detail="false" @click="remove">
|
||||
<ion-label class="ion-text-center" color="danger">
|
||||
删除菜谱
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-list>
|
||||
</ion-content>
|
||||
</ion-page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-recipe-form .cook-group-header {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.custom-recipe-fields {
|
||||
margin-top: 0;
|
||||
}
|
||||
.custom-recipe-error {
|
||||
color: var(--ion-color-danger);
|
||||
}
|
||||
.custom-recipe-delete {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user