Files
what-to-eat/apps/web/app/composables/store/recipe.ts
T

237 lines
8.3 KiB
TypeScript

import type { Difficulty, RecommendationMode } from '@yunyoujun/cook'
import type { RecipeItem, StuffItem } from '~/types'
import { StorageSerializers, useStorage } from '@vueuse/core'
import { createRecipeRecommender, hasAvoidedStuff, matchesTaste, uniqueStuff } from '@yunyoujun/cook/recommend'
import { acceptHMRUpdate, defineStore } from 'pinia'
import { computed, onMounted, ref, shallowRef } from 'vue'
import { useIndexedDB } from '~/composables/db'
import { defaultPantry, pantryOptions } from '~/data/pantry'
import { trackCookEvent } from '~/utils/analytics'
import { db } from '../../utils/db'
import { useAppStore } from './app'
import { customRecipes } from './customRecipe'
const namespace = 'cook'
export type SearchMode = RecommendationMode
export const useRecipeStore = defineStore('recipe', () => {
const { settings } = useAppStore()
const indexedDB = useIndexedDB()
const keyword = shallowRef('')
const curStuff = settings.keepLocalData
? useStorage(`${namespace}:stuff`, new Set<string>(), undefined, { serializer: StorageSerializers.set })
: ref(new Set<string>())
const curTool = settings.keepLocalData ? useStorage(`${namespace}:tool`, '') : shallowRef('')
const curMode = settings.keepLocalData ? useStorage<SearchMode>(`${namespace}:mode`, 'loose') : shallowRef<SearchMode>('loose')
const pantry = settings.keepLocalData ? useStorage<string[]>(`${namespace}:pantry`, [...defaultPantry]) : ref([...defaultPantry])
// 忌口是个人偏好,和常备调料一样跨会话保留,「清空」不会动它
const avoid = settings.keepLocalData ? useStorage<string[]>(`${namespace}:avoid`, []) : ref<string[]>([])
const noSpicy = settings.keepLocalData ? useStorage(`${namespace}:no-spicy`, false) : shallowRef(false)
const methodGroups = settings.keepLocalData ? useStorage<string[]>(`${namespace}:method-groups`, []) : ref<string[]>([])
const cuisines = settings.keepLocalData ? useStorage<string[]>(`${namespace}:cuisines`, []) : ref<string[]>([])
const difficulty = settings.keepLocalData ? useStorage<Difficulty[]>(`${namespace}:difficulty`, []) : ref<Difficulty[]>([])
const selectedStuff = computed(() => uniqueStuff([...curStuff.value]))
const selectedPantry = computed(() => uniqueStuff(pantry.value).filter(item => pantryOptions.includes(item)))
const selectedAvoid = computed(() => uniqueStuff(avoid.value))
function toggleStuff(name: string) {
const names = uniqueStuff([name])
const next = new Set(selectedStuff.value)
for (const item of names) {
if (next.has(item))
next.delete(item)
else
next.add(item)
}
curStuff.value = next
}
function addStuff(name: string) {
curStuff.value = new Set(uniqueStuff([...selectedStuff.value, name]))
}
function togglePantry(name: string) {
if (!pantryOptions.includes(name))
return
pantry.value = selectedPantry.value.includes(name)
? selectedPantry.value.filter(item => item !== name)
: [...selectedPantry.value, name]
}
function toggleAvoid(name: string) {
avoid.value = selectedAvoid.value.includes(name)
? selectedAvoid.value.filter(item => item !== name)
: [...selectedAvoid.value, name]
}
function toggleMethodGroup(label: string) {
methodGroups.value = methodGroups.value.includes(label)
? methodGroups.value.filter(item => item !== label)
: [...methodGroups.value, label]
}
function toggleCuisine(label: string) {
cuisines.value = cuisines.value.includes(label)
? cuisines.value.filter(item => item !== label)
: [...cuisines.value, label]
}
function toggleDifficulty(level: Difficulty) {
difficulty.value = difficulty.value.includes(level)
? difficulty.value.filter(item => item !== level)
: [...difficulty.value, level]
}
/** 忌口、做法、菜系、口味、难度这几个对所有列表都生效的条件 */
function passesPreferences(recipe: RecipeItem) {
return !hasAvoidedStuff(recipe, selectedAvoid.value)
&& matchesTaste(recipe, { methodGroups: methodGroups.value, cuisines: cuisines.value, noSpicy: noSpicy.value, difficulty: difficulty.value })
}
function clearFilters() {
keyword.value = ''
curTool.value = ''
curMode.value = 'loose'
methodGroups.value = []
cuisines.value = []
difficulty.value = []
}
function reset() {
curStuff.value = new Set()
clearFilters()
}
function toggleTools(name: string) {
curTool.value = curTool.value === name ? '' : name
}
function setMode(mode: SearchMode) {
curMode.value = mode
}
const recipes = shallowRef<RecipeItem[]>([])
const isSearching = shallowRef(true)
const loadError = shallowRef('')
let loading: Promise<void> | undefined
function loadRecipes() {
if (loading)
return loading
isSearching.value = true
loadError.value = ''
loading = (async () => {
try {
// Await initialization so first visits and saved selections cannot read an empty DB.
await indexedDB.init()
recipes.value = await db.recipes.toArray()
}
catch {
loadError.value = '菜谱暂时没有加载成功,请重试。'
}
finally {
isSearching.value = false
loading = undefined
}
})()
return loading
}
// 自己添加的菜谱排在前面:匹配程度相同时优先推荐
const allRecipes = computed<RecipeItem[]>(() => [...customRecipes.value, ...recipes.value])
const recommend = computed(() => createRecipeRecommender(allRecipes.value))
const recipeMatches = computed(() => recommend.value({
stuff: selectedStuff.value,
pantry: selectedPantry.value,
tool: curTool.value,
keyword: keyword.value,
mode: curMode.value,
avoid: selectedAvoid.value,
methodGroups: methodGroups.value,
cuisines: cuisines.value,
noSpicy: noSpicy.value,
difficulty: difficulty.value,
}))
/** 只选了厨具、还没选食材时,列出用这个厨具能做的菜(保留原来经典视图的用法) */
const toolRecipes = computed(() => {
if (selectedStuff.value.length || !curTool.value)
return []
const text = keyword.value.trim()
return allRecipes.value.filter(recipe => recipe.tools?.includes(curTool.value)
&& (!text || recipe.name.includes(text))
&& passesPreferences(recipe))
})
/** 「吃什么」随机的候选:选了食材就从匹配结果里抽,否则按厨具和忌口从全部菜谱里抽 */
const randomPool = computed(() => {
if (selectedStuff.value.length)
return recipeMatches.value.map(match => match.recipe)
return allRecipes.value.filter(recipe => (!curTool.value || recipe.tools?.includes(curTool.value))
&& passesPreferences(recipe))
})
/** 「再补一样」建议:差一种的菜里缺得最多的食材,补上后能直接多做几道 */
const unlockSuggestions = computed(() => {
const counts = new Map<string, number>()
for (const match of recipeMatches.value) {
const [missing] = match.missingStuff
if (match.missingStuff.length === 1 && missing)
counts.set(missing, (counts.get(missing) ?? 0) + 1)
}
return [...counts]
.filter(([, count]) => count >= 2)
.sort((a, b) => b[1] - a[1])
.slice(0, 4)
.map(([name, count]) => ({ name, count }))
})
const resultCount = computed(() => selectedStuff.value.length ? recipeMatches.value.length : toolRecipes.value.length)
const recipesLength = computed(() => allRecipes.value.length)
function clickTool(item: StuffItem) {
toggleTools(item.name)
trackCookEvent('click_tool')
}
onMounted(loadRecipes)
return {
recipesLength,
keyword,
curTool,
curMode,
selectedStuff,
selectedPantry,
selectedAvoid,
methodGroups,
cuisines,
noSpicy,
difficulty,
isSearching,
loadError,
loadRecipes,
clearKeyWord: () => { keyword.value = '' },
clearFilters,
toggleStuff,
togglePantry,
clearPantry: () => { pantry.value = [] },
resetPantry: () => { pantry.value = [...defaultPantry] },
toggleAvoid,
clearAvoid: () => { avoid.value = [] },
toggleMethodGroup,
toggleCuisine,
toggleDifficulty,
toggleTools,
reset,
setMode,
addStuff,
recipeMatches,
toolRecipes,
randomPool,
unlockSuggestions,
resultCount,
clickTool,
}
})
if (import.meta.hot)
import.meta.hot.accept(acceptHMRUpdate(useRecipeStore, import.meta.hot))