chore: 导入清理前的完整工程状态(含 Capacitor 原生壳,作为回滚备份点)

This commit is contained in:
toor
2026-10-21 11:50:10 +08:00
committed by trae-solo-agent
commit 8ded87a3a8
389 changed files with 52242 additions and 0 deletions
+147
View File
@@ -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>
+109
View File
@@ -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>
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts" setup>
definePageMeta({
layout: 'child',
title: '菜谱 - ?',
})
</script>
<template>
<div>
asd
</div>
</template>
+68
View File
@@ -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>
+222
View File
@@ -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>