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(), undefined, { serializer: StorageSerializers.set }) : ref(new Set()) const curTool = settings.keepLocalData ? useStorage(`${namespace}:tool`, '') : shallowRef('') const curMode = settings.keepLocalData ? useStorage(`${namespace}:mode`, 'loose') : shallowRef('loose') const pantry = settings.keepLocalData ? useStorage(`${namespace}:pantry`, [...defaultPantry]) : ref([...defaultPantry]) // 忌口是个人偏好,和常备调料一样跨会话保留,「清空」不会动它 const avoid = settings.keepLocalData ? useStorage(`${namespace}:avoid`, []) : ref([]) const noSpicy = settings.keepLocalData ? useStorage(`${namespace}:no-spicy`, false) : shallowRef(false) const methodGroups = settings.keepLocalData ? useStorage(`${namespace}:method-groups`, []) : ref([]) const cuisines = settings.keepLocalData ? useStorage(`${namespace}:cuisines`, []) : ref([]) const difficulty = settings.keepLocalData ? useStorage(`${namespace}:difficulty`, []) : ref([]) 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([]) const isSearching = shallowRef(true) const loadError = shallowRef('') let loading: Promise | 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(() => [...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() 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))