24 Commits
v1.1.8 ... ai

Author SHA1 Message Date
YunYouJun
5efd7b0533 feat: add generate cook & intro 2024-03-22 19:55:33 +08:00
YunYouJun
a80d9163b8 feat: add ai generate img 2024-03-13 17:22:02 +08:00
YunYouJun
d25148c731 feat: add ai generate recipe 2024-03-13 16:43:58 +08:00
YunYouJun
d42e3cf65d chore: revert if commitSha buildDate 2024-01-27 03:46:30 +08:00
YunYouJun
f59c34499a test: fix relative path 2024-01-27 03:45:50 +08:00
YunYouJun
e741f5d5a0 chore: upgrade deps 2024-01-27 03:25:20 +08:00
KazariEX
99d7d660bb fix: div in span (#61) 2023-11-16 14:10:34 +08:00
YunYouJun
87025cd17e chore: release v1.2.2 2023-11-13 00:41:24 +08:00
YunYouJun
68a4b2dbeb chore: upgrade deps 2023-11-13 00:41:15 +08:00
YunYouJun
1ab402af8b feat: add random recipes localStorage 2023-11-13 00:31:29 +08:00
YunYouJun
b2cf053446 chore: update lock 2023-11-11 19:53:44 +08:00
YunYouJun
37bab9269c chore: release v1.2.1 2023-11-11 19:53:07 +08:00
YunYouJun
44f9b40ee5 feat: add 麻婆豆腐, close #55 2023-11-11 19:51:51 +08:00
YunYouJun
4ddd5ac2d2 chore: fix data updated time 2023-11-11 19:46:44 +08:00
YunYouJun
9df47e977e chore: fix 可乐饼 link 2023-11-11 19:43:43 +08:00
KazariEX
5fb76f24dc fix: keyword filter (#57) 2023-11-07 15:33:16 +08:00
YunYouJun
014f129b8e chore: fix style details cursor size 2023-11-07 02:18:49 +08:00
YunYouJun
2949c45839 chore: release v1.2.0 2023-11-07 02:00:11 +08:00
YunYouJun
81586158d5 chore: fix lint 2023-11-07 02:00:03 +08:00
YunYouJun
bfb6ea3e14 feat: add history 2023-11-07 01:59:29 +08:00
YunYouJun
7d26f9ba84 feat: add search keyword 2023-11-07 01:06:30 +08:00
YunYouJun
9f12401922 chore: release v1.1.9 2023-11-07 00:25:00 +08:00
YunYouJun
69c689df67 fix: curStuff watch & remove useless data 2023-11-07 00:20:18 +08:00
YunYouJun
8ca8c4aac8 fix: curStuff watch 2023-11-07 00:12:56 +08:00
57 changed files with 5770 additions and 3485 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
SD_API_BASE_URL=
OPENAI_API_KEY=

20
.vscode/settings.json vendored
View File

@@ -1,5 +1,20 @@
{ {
"cSpell.words": ["Vitesse", "Vite", "unocss", "vitest", "vueuse", "pinia", "demi", "antfu", "iconify", "intlify", "vitejs", "unplugin", "pnpm"], "cSpell.words": [
"antfu",
"demi",
"iconify",
"intlify",
"nuxi",
"pinia",
"pnpm",
"unocss",
"unplugin",
"Vite",
"vitejs",
"Vitesse",
"vitest",
"vueuse"
],
"files.associations": { "files.associations": {
"*.css": "postcss" "*.css": "postcss"
}, },
@@ -65,5 +80,6 @@
"json", "json",
"jsonc", "jsonc",
"yaml" "yaml"
] ],
"typescript.tsdk": "node_modules/typescript/lib"
} }

View File

@@ -73,8 +73,6 @@ docker stop cook
感谢以下小伙伴为本项目提供的数据支持和 QA 感谢以下小伙伴为本项目提供的数据支持和 QA
- [Runny](https://weibo.com/runny) - [Runny](https://weibo.com/runny)
- 山竹太凉
- leo
- 麒麟 - 麒麟
- 晴方啾 - 晴方啾
- 课代表阿伟 - 课代表阿伟

5
app.config.ts Normal file
View File

@@ -0,0 +1,5 @@
export default defineAppConfig({
theme: {
primaryColor: '#ababab',
},
})

View File

@@ -10,7 +10,7 @@ defineProps<{
<template> <template>
<Disclosure v-slot="{ open }" :default-open="defaultOpen" as="div" class="mt-2"> <Disclosure v-slot="{ open }" :default-open="defaultOpen" as="div" class="mt-2">
<DisclosureButton <DisclosureButton
class="w-full flex justify-between rounded-lg bg-blue-100 px-4 py-2 text-left text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring focus-visible:ring-blue-500 focus-visible:ring-opacity-75" class="w-full flex justify-between rounded-lg bg-blue-100 px-4 py-2 text-left text-sm text-blue-900 font-medium hover:bg-blue-200 focus:outline-none focus-visible:ring focus-visible:ring-blue-500 focus-visible:ring-opacity-75"
> >
<span>{{ title }}</span> <span>{{ title }}</span>
<div <div

View File

@@ -16,11 +16,12 @@ const { random, randomRecipes } = useRandomRecipe(count)
</button> </button>
</div> </div>
<button cursor-pointer class="inline-flex inline-flex items-center justify-center rounded-md border-none bg-blue-600 px-3 py-1.5 text-sm text-white font-semibold leading-6 shadow-sm hover:bg-blue-500 focus-visible:outline-2 focus-visible:outline-blue-600 focus-visible:outline-offset-2 focus-visible:outline" @click="random">
<div class="transition" hover="text-blue-500" i-ri-refresh-line mr-1 inline-flex />
<div>随机一下</div>
</button>
<div v-show="randomRecipes.length > 0"> <div v-show="randomRecipes.length > 0">
<button cursor-pointer class="inline-flex inline-flex items-center justify-center rounded-md border-none bg-blue-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-blue-500 focus-visible:outline-2 focus-visible:outline-blue-600 focus-visible:outline-offset-2 focus-visible:outline" @click="random">
<div class="transition" hover="text-blue-500" i-ri-refresh-line mr-1 inline-flex />
<div>随机一下</div>
</button>
<div m="t-8" flex="~ col"> <div m="t-8" flex="~ col">
<template v-for="recipe, i in randomRecipes" :key="i"> <template v-for="recipe, i in randomRecipes" :key="i">
<DishTag v-if="recipe" :dish="recipe" /> <DishTag v-if="recipe" :dish="recipe" />

View File

@@ -56,7 +56,7 @@ const showTooltip = computed(() => !selectedStuff.value.length && !curTool.value
<br> <br>
<div m="t-1"> <div m="t-1">
<span>欢迎来</span> <span>欢迎来</span>
<a class="font-bold text-blue-600 dark:text-blue-400" href="https://docs.qq.com/sheet/DQk1vdkhFV0twQVNS?tab=uykkic" target="_blank">这里</a> <a class="text-blue-600 font-bold dark:text-blue-400" href="https://docs.qq.com/sheet/DQk1vdkhFV0twQVNS?tab=uykkic" target="_blank">这里</a>
<span>反馈新的菜谱</span> <span>反馈新的菜谱</span>
</div> </div>
</div> </div>

View File

@@ -43,22 +43,22 @@ defineProps<{
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
-webkit-transition: .4s; -webkit-transition: 0.4s;
transition: .4s; transition: 0.4s;
} }
$size: 20px; $size: 20px;
.slider:before { .slider:before {
position: absolute; position: absolute;
content: ""; content: '';
height: $size; height: $size;
width: $size; width: $size;
left: 4px; left: 4px;
bottom: 4px; bottom: 4px;
background-color: white; background-color: white;
-webkit-transition: .4s; -webkit-transition: 0.4s;
transition: .4s; transition: 0.4s;
} }
input:checked + .slider { input:checked + .slider {

View File

@@ -0,0 +1,178 @@
<script lang="ts" setup>
import type { StuffItem } from '~/types'
import { meat, staple, vegetable } from '~/data/food'
import { useEmojiAnimation } from '~/composables/animation'
import type { AIRecipeInfo } from '~/packages/ai/src'
import { generateRecipeInfo, getRecipeImage } from '~/utils/api'
const rStore = useRecipeStore()
const curStuff = computed(() => rStore.selectedStuff)
const recipeBtnRef = ref<HTMLButtonElement>()
const { playAnimation } = useEmojiAnimation(recipeBtnRef)
const gtm = useGtm()
const recipePanelRef = ref()
const { isVisible, show } = useInvisibleElement(recipePanelRef)
function toggleStuff(item: StuffItem, category = '', _e?: Event) {
rStore.toggleStuff(item.name)
if (curStuff.value.includes(item.name))
playAnimation(item.emoji)
gtm?.trackEvent({
event: 'click',
category: `${category}_${item.name}`,
action: 'click_stuff',
label: '食材',
})
gtm?.trackEvent({
event: 'click_stuff',
action: item.name,
})
}
// cook recipe
const cooking = ref(false)
const recipeImg = ref('')
const aiRecipeInfo = ref<AIRecipeInfo>({
名称: '名称',
介绍: '介绍',
})
async function cook() {
cooking.value = true
const foods = rStore.selectedStuff
// reset
aiRecipeInfo.value = ({
名称: '起名中...',
介绍: '正在思考怎么介绍...',
})
recipeImg.value = ''
// generate
const [info, img] = await Promise.all([generateRecipeInfo(foods), getRecipeImage(foods)])
aiRecipeInfo.value = info
recipeImg.value = img
cooking.value = false
}
</script>
<template>
<div>
<h2 m="t-4" text="xl" font="bold" p="1">
🥘 先选一下食材
</h2>
<div>
<h2 opacity="90" text="base" font="bold" p="1">
🥬 菜菜们
</h2>
<div>
<VegetableTag
v-for="item, i in vegetable" :key="i"
:active="curStuff.includes(item.name)"
@click="toggleStuff(item, 'vegetable')"
>
<span v-if="item.emoji" class="inline-flex">{{ item.emoji }}</span>
<span v-else-if="item.image" class="inline-flex">
<img class="inline-flex" w="2" h="2" width="10" height="10" :src="item.image" :alt="item.name">
</span>
<span class="inline-flex" m="l-1">{{ item.name }}</span>
</VegetableTag>
</div>
</div>
<div m="y-4">
<h2 opacity="90" text="base" font="bold" p="1">
🥩 肉肉们
</h2>
<div>
<MeatTag
v-for="item, i in meat" :key="i"
:active="curStuff.includes(item.name)"
@click="toggleStuff(item, 'meat')"
>
<span>{{ item.emoji }}</span>
<span m="l-1">{{ item.name }}</span>
</MeatTag>
</div>
</div>
<div m="y-4">
<h2 opacity="90" text="base" font="bold" p="1">
🍚 主食
</h2>
<div>
<StapleTag
v-for="item, i in staple" :key="i"
:active="curStuff.includes(item.name)"
@click="toggleStuff(item, 'staple')"
>
<span>{{ item.emoji }}</span>
<span m="l-1">{{ item.name }}</span>
</StapleTag>
</div>
</div>
<!-- <div m="t-4">
<h2 text="xl" font="bold" p="1">
🍳 再选一下厨具
</h2>
<div>
<ToolTag
v-for="item, i in tools" :key="i"
:active="curTool === item.name"
@click="rStore.clickTool(item)"
>
<span v-if="item.emoji" class="inline-flex">
{{ item.emoji }}
</span>
<span v-else-if="item.icon" class="inline-flex">
<div :class="item.icon" />
</span>
<span class="inline-flex" m="l-1">{{ item.label || item.name }}</span>
</ToolTag>
</div>
</div> -->
<Transition>
<BasketButton ref="recipeBtnRef" :is-visible="isVisible" @click="show" />
</Transition>
<button
m-auto
flex items-center justify-center
class="rounded bg-yellow px-4 py-2 text-orange-900 font-black shadow hover:shadow-md active:shadow-inset"
@click="cook()"
>
<div v-if="cooking" class="mr-2 inline-flex" i-svg-spinners:clock />
<span>做黑暗料理 🥘</span>
</button>
<div
class="recipe-panel relative shadow transition"
m="x-2 y-4" p="2"
bg="gray-400/8"
>
<div text="xl" font="bold" p="1">
{{ aiRecipeInfo['名称'] }}
</div>
<div class="cook-recipes text-center" p="2">
<img
v-if="recipeImg"
class="m-auto w-25 rounded shadow transition hover:shadow-md"
:src="recipeImg"
alt="recipes"
>
<div v-else class="m-auto h-25 w-25 rounded bg-gray shadow transition hover:shadow-md" />
</div>
<div>
{{ aiRecipeInfo['介绍'] }}
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,105 @@
<script setup lang="ts">
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue'
import { db } from '~/utils/db'
const isOpen = ref(false)
function closeModal() {
isOpen.value = false
}
function openModal() {
isOpen.value = true
}
const keyword = ref('')
async function getFilterRecipes(keyword: string) {
return db.recipes.filter((recipe) => {
return recipe.name.includes(keyword)
}).toArray()
}
const filteredRecipes = computedAsync(async () => {
return await getFilterRecipes(keyword.value)
})
</script>
<template>
<YlfIconButton
absolute right-3 top-4
class="icon-btn hover:text-yellow-400 !outline-none"
text-xl
title="切换" @click="openModal"
>
<div i="ri-search-line" />
</YlfIconButton>
<TransitionRoot appear :show="isOpen" as="template">
<Dialog as="div" class="relative z-10" @close="closeModal">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black/10" />
</TransitionChild>
<div class="fixed inset-0 overflow-y-auto">
<div
class="h-full flex justify-center text-center"
>
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel
class="h-full max-w-xl w-full transform overflow-hidden bg-white p-4 text-left align-middle shadow-xl transition-all dark:bg-dark-600"
md="rounded-2xl"
overflow="auto"
flex="~ col"
>
<DialogTitle
as="h3"
class="flex items-center justify-center text-lg font-medium leading-6"
>
<div relative inline-flex flex="grow">
<div
i-ri-search-line
class="absolute left-3 top-2 cursor-pointer text-gray-400"
/>
<input
v-model="keyword"
type="text"
class="w-full rounded-full bg-transparent text-sm focus:outline-none focus:ring-1 focus:ring-blue-500 placeholder-gray-400"
border="~ rounded-full gray-300 op-50 focus:border-blue-500"
placeholder="搜索菜谱"
autofocus py-2 pl-10 pr-3
>
<div
v-if="keyword" i-ri-close-line
class="absolute right-3 top-2 cursor-pointer text-gray-400"
@click="keyword = ''"
/>
</div>
<div op="70" ml-2 inline-flex cursor-pointer text-base @click="closeModal">
取消
</div>
</DialogTitle>
<div flex="~ col grow" overflow="auto" class="mt-2" text-xs>
<DishTag v-for="item, i in filteredRecipes" :key="i" :dish="item" />
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>

View File

@@ -3,7 +3,7 @@ import pkg from '~/package.json'
const commitSha = (import.meta.env.VITE_COMMIT_REF || '').slice(0, 7) const commitSha = (import.meta.env.VITE_COMMIT_REF || '').slice(0, 7)
const now = import.meta.env.VITE_APP_BUILD_TIME const now = import.meta.env.VITE_APP_BUILD_TIME
const buildDate = (new Date(Number.parseInt(now) * 1000)).toLocaleDateString() const buildDate = (new Date(Number.parseInt(now))).toLocaleDateString()
</script> </script>
<template> <template>

View File

@@ -40,7 +40,9 @@ defineProps<{
font-size: 0.8rem; font-size: 0.8rem;
} }
tr, th, td { tr,
th,
td {
border: 1px solid black; border: 1px solid black;
} }
</style> </style>

View File

@@ -3,6 +3,7 @@ import type { DbRecipeItem } from '~/utils/db'
import { tools } from '~/data/food' import { tools } from '~/data/food'
import type { RecipeItem } from '~/types' import type { RecipeItem } from '~/types'
import { getEmojisFromStuff } from '~/utils' import { getEmojisFromStuff } from '~/utils'
import { recipeHistories } from '~/composables/store/history'
const props = defineProps<{ const props = defineProps<{
dish: RecipeItem | DbRecipeItem dish: RecipeItem | DbRecipeItem
@@ -10,16 +11,21 @@ const props = defineProps<{
const gtm = useGtm() const gtm = useGtm()
function triggerGtm(val: string) { function triggerGtm(dish: RecipeItem) {
recipeHistories.value.push({
recipe: dish,
time: Date.now(),
})
gtm?.trackEvent({ gtm?.trackEvent({
event: 'click', event: 'click',
category: `dish_${val}`, category: `dish_${dish.name}`,
action: 'click_recipe', action: 'click_recipe',
label: '跳转菜谱', label: '跳转菜谱',
}) })
gtm?.trackEvent({ gtm?.trackEvent({
event: 'click_dish', event: 'click_dish',
action: val, action: dish.name,
}) })
} }
@@ -34,13 +40,13 @@ const dishLabel = computed(() => {
:href="dish.link || `https://www.bilibili.com/video/${dish.bv}`" target="_blank" class="dish-tag rounded tag" p="x-2" :href="dish.link || `https://www.bilibili.com/video/${dish.bv}`" target="_blank" class="dish-tag rounded tag" p="x-2"
border="~ blue-200 dark:blue-800" border="~ blue-200 dark:blue-800"
bg="blue-300 opacity-20" bg="blue-300 opacity-20"
@click="triggerGtm(dish.name)" @click="triggerGtm(dish)"
> >
<span m="r-1" class="inline-flex items-center justify-center" text="sm blue-700 dark:blue-200"> <span m="r-1" text="sm blue-700 dark:blue-200">
{{ dishLabel }} {{ dishLabel }}
</span> </span>
<span v-for="tool, i in tools" :key="i" inline-flex> <template v-for="tool, i in tools">
<div v-if="dish.tools?.includes(tool.name)" :class="tool.icon" /> <span v-if="dish.tools?.includes(tool.name)" :key="i" :class="tool.icon" />
</span> </template>
</a> </a>
</template> </template>

View File

@@ -16,11 +16,11 @@ defineProps<{
:is="to ? NuxtLink : 'div'" :is="to ? NuxtLink : 'div'"
:to="to" :to="to"
class="ylf-form-item" class="ylf-form-item"
w-full flex cursor-pointer items-center justify-between p-3 w-full flex cursor-pointer items-center justify-between p-2
hover:bg-gray-100 hover:bg-gray-100
dark:hover:bg-dark-400 dark:hover:bg-dark-400
> >
<div v-if="label" class="text-md" inline-flex items-center justify-center> <div v-if="label" class="text-sm" inline-flex items-center justify-center>
<div v-if="icon" :class="icon" mr-2 inline-flex /> <div v-if="icon" :class="icon" mr-2 inline-flex />
<span>{{ label }}</span> <span>{{ label }}</span>
</div> </div>

View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
defineProps<{
icon: string
label: string
to: string
}>()
</script>
<template>
<NuxtLink
:to="to"
flex="~ col"
border="~ solid dark:$ylf-c-border"
bg="$ylf-c-bg-alt"
class="inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium decoration-none hover-bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500 dark:hover:bg-dark-400"
>
<div :class="icon" inline-flex text-lg />
<div mt-2 inline-flex text-xs>
{{ label }}
</div>
</NuxtLink>
</template>

View File

@@ -1,3 +1,5 @@
import { useStorage } from '@vueuse/core'
import { namespace } from '~/constants'
import type { DbRecipeItem } from '~/utils/db' import type { DbRecipeItem } from '~/utils/db'
/** /**
@@ -5,7 +7,7 @@ import type { DbRecipeItem } from '~/utils/db'
* @param total * @param total
*/ */
export function useRandomRecipe(total: Ref<number>) { export function useRandomRecipe(total: Ref<number>) {
const randomRecipes = ref<(DbRecipeItem | undefined)[]>([]) const randomRecipes = useStorage<(DbRecipeItem | undefined)[]>(`${namespace}:random:recipes`, [])
async function random() { async function random() {
const length = await db.recipes.count() const length = await db.recipes.count()
const randomArr = generateRandomArray(length, total.value) const randomArr = generateRandomArray(length, total.value)
@@ -19,7 +21,9 @@ export function useRandomRecipe(total: Ref<number>) {
}) })
onMounted(() => { onMounted(() => {
random() // 如果没有随机菜谱,就生成一次
if (randomRecipes.value.length <= 0)
random()
}) })
return { return {

View File

@@ -1,8 +1,8 @@
import { acceptHMRUpdate, defineStore } from 'pinia' import { acceptHMRUpdate, defineStore } from 'pinia'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { ref } from 'vue' import { ref } from 'vue'
import { defaultSettings } from '~/utils/settings' import { defaultSettings } from '../../utils/settings'
import { namespace } from '~/constants' import { namespace } from '../../constants'
export const useAppStore = defineStore('app', () => { export const useAppStore = defineStore('app', () => {
const deferredPrompt = ref<Event | any>() const deferredPrompt = ref<Event | any>()

View File

@@ -0,0 +1,10 @@
import { useStorage } from '@vueuse/core'
import { namespace } from '~/constants'
import type { RecipeItem } from '~/types'
export interface RecipeHistoryItem {
recipe: RecipeItem
time: number
}
export const recipeHistories = useStorage<RecipeHistoryItem[]>(`${namespace}:history`, [])

View File

@@ -78,8 +78,6 @@ export const useRecipeStore = defineStore('recipe', () => {
async function searchRecipes() { async function searchRecipes() {
isSearching.value = true isSearching.value = true
let result: RecipeItem[] = [] let result: RecipeItem[] = []
if (keyword.value)
result = await db.recipes.filter(item => item.name.includes(keyword.value)).toArray()
if (curMode.value === 'strict') { if (curMode.value === 'strict') {
result = await db.recipes.filter((item) => { result = await db.recipes.filter((item) => {
@@ -116,14 +114,18 @@ export const useRecipeStore = defineStore('recipe', () => {
}).toArray() }).toArray()
} }
if (keyword.value)
result = result.filter(item => item.name.includes(keyword.value))
isSearching.value = false isSearching.value = false
return result return result
} }
// 默认严格模式 // 默认严格模式
const displayedRecipe = ref<RecipeItem[]>([]) const displayedRecipe = ref<RecipeItem[]>([])
watch([keyword, curStuff, curTool, curMode], async () => { // fix curStuff watch
displayedRecipe.value = await searchRecipes() watch(() => [keyword.value, selectedStuff.value, curTool.value, curMode.value], async () => {
displayedRecipe.value = [...(await searchRecipes())]
}) })
/** /**

View File

@@ -2,6 +2,6 @@ export const appName = '食用手册'
export const appDescription = '好的,今天我们来做菜!' export const appDescription = '好的,今天我们来做菜!'
export const namespace = 'cook' export const namespace = 'cook'
export const lastDbUpdated = '2022-07-27 03:05:02' export const lastDbUpdated = '2023-11-11 19:51:02'
export * from './links' export * from './links'

View File

@@ -7,63 +7,78 @@ export const vegetable: StuffItem[] = [
{ {
name: '土豆', name: '土豆',
emoji: '🥔', emoji: '🥔',
en: 'potato',
}, },
{ {
name: '胡萝卜', name: '胡萝卜',
emoji: '🥕', emoji: '🥕',
en: 'carrot',
}, },
{ {
name: '花菜', name: '花菜',
emoji: '🥦', emoji: '🥦',
en: 'cauliflower',
}, },
{ {
name: '白萝卜', name: '白萝卜',
emoji: '🥣', emoji: '🥣',
en: 'radish',
}, },
{ {
name: '西葫芦', name: '西葫芦',
emoji: '🥒', emoji: '🥒',
en: 'zucchini',
}, },
{ {
name: '番茄', name: '番茄',
emoji: '🍅', emoji: '🍅',
alias: '西红柿', alias: '西红柿',
en: 'tomato',
}, },
{ {
name: '芹菜', name: '芹菜',
emoji: '🥬', emoji: '🥬',
en: 'celery',
}, },
{ {
name: '黄瓜', name: '黄瓜',
emoji: '🥒', emoji: '🥒',
en: 'cucumber',
}, },
{ {
name: '洋葱', name: '洋葱',
emoji: '🧅', emoji: '🧅',
en: 'onion',
}, },
{ {
name: '莴笋', name: '莴笋',
emoji: '🎍', emoji: '🎍',
en: 'lettuce',
}, },
{ {
name: '菌菇', name: '菌菇',
emoji: '🍄', emoji: '🍄',
en: 'mushroom',
}, },
{ {
name: '茄子', name: '茄子',
emoji: '🍆', emoji: '🍆',
en: 'eggplant',
}, },
{ {
name: '豆腐', name: '豆腐',
emoji: '🍲', emoji: '🍲',
en: 'tofu',
}, },
{ {
name: '包菜', name: '包菜',
emoji: '🥗', emoji: '🥗',
en: 'cabbage',
}, },
{ {
name: '白菜', name: '白菜',
emoji: '🥬', emoji: '🥬',
en: 'cabbage',
}, },
] ]
@@ -74,42 +89,52 @@ export const meat: StuffItem[] = [
{ {
name: '午餐肉', name: '午餐肉',
emoji: '🥓', emoji: '🥓',
en: 'bacon',
}, },
{ {
name: '香肠', name: '香肠',
emoji: '🌭', emoji: '🌭',
en: 'sausage',
}, },
{ {
name: '腊肠', name: '腊肠',
emoji: '🌭', emoji: '🌭',
en: 'sausage',
}, },
{ {
name: '鸡肉', name: '鸡肉',
emoji: '🐤', emoji: '🐤',
en: 'chicken',
}, },
{ {
name: '猪肉', name: '猪肉',
emoji: '🐷', emoji: '🐷',
en: 'pork',
}, },
{ {
name: '鸡蛋', name: '鸡蛋',
emoji: '🥚', emoji: '🥚',
en: 'egg',
}, },
{ {
name: '虾', name: '虾',
emoji: '🦐', emoji: '🦐',
en: 'shrimp',
}, },
{ {
name: '牛肉', name: '牛肉',
emoji: '🐮', emoji: '🐮',
en: 'beef',
}, },
{ {
name: '骨头', name: '骨头',
emoji: '🦴', emoji: '🦴',
en: 'bone',
}, },
{ {
name: '鱼Todo', name: '鱼Todo',
emoji: '🐟', emoji: '🐟',
en: 'fish',
}, },
] ]
@@ -120,18 +145,22 @@ export const staple: StuffItem[] = [
{ {
name: '面食', name: '面食',
emoji: '🍝', emoji: '🍝',
en: 'noodles',
}, },
{ {
name: '面包', name: '面包',
emoji: '🍞', emoji: '🍞',
en: 'bread',
}, },
{ {
name: '米', name: '米',
emoji: '🍚', emoji: '🍚',
en: 'rice',
}, },
{ {
name: '方便面', name: '方便面',
emoji: '🍜', emoji: '🍜',
en: 'instant noodles',
}, },
] ]

View File

@@ -217,7 +217,6 @@ BBQ烟熏手撕猪肉,猪肉,BV1DV411x7SH,复杂,,烤,烤箱,
微波炉版照烧鸡腿饭,米、鸡肉、胡萝卜、花菜,BV16Z4y1V774,,,,微波炉, 微波炉版照烧鸡腿饭,米、鸡肉、胡萝卜、花菜,BV16Z4y1V774,,,,微波炉,
微波炉版蒸蛋羹,鸡蛋,BV19T4y1D7Zd,,,,微波炉, 微波炉版蒸蛋羹,鸡蛋,BV19T4y1D7Zd,,,,微波炉,
微波炉版煮米饭,,BV193411W7nb,,,,微波炉, 微波炉版煮米饭,,BV193411W7nb,,,,微波炉,
豉油鸡翅,鸡肉,BV1vz4y1d775,普通,茶餐厅,,一口大锅,
水煮肉片,猪肉、芹菜、莴笋,BV1ZZ4y1379N,普通,川菜,,一口大锅, 水煮肉片,猪肉、芹菜、莴笋,BV1ZZ4y1379N,普通,川菜,,一口大锅,
脆口黄瓜,黄瓜,BV1Tb4y1X7ow,简单,脆口,凉拌,一口大锅, 脆口黄瓜,黄瓜,BV1Tb4y1X7ow,简单,脆口,凉拌,一口大锅,
白萝卜汤,白萝卜,BV1HJ411L7xA,简单,单一食材,,一口大锅, 白萝卜汤,白萝卜,BV1HJ411L7xA,简单,单一食材,,一口大锅,
@@ -297,7 +296,7 @@ BBQ烟熏手撕猪肉,猪肉,BV1DV411x7SH,复杂,,烤,烤箱,
名古屋鸡翅,鸡肉,BV1ET4y1A7Xd,普通,日式,,一口大锅, 名古屋鸡翅,鸡肉,BV1ET4y1A7Xd,普通,日式,,一口大锅,
日式炖白萝,白萝卜,BV17b411B7H1,简单,日式,,一口大锅, 日式炖白萝,白萝卜,BV17b411B7H1,简单,日式,,一口大锅,
炸虾天妇罗,,BV1e5411t7LY,困难,日式,,一口大锅, 炸虾天妇罗,,BV1e5411t7LY,困难,日式,,一口大锅,
可乐饼,土豆、洋葱、肉、鸡蛋,BV1yW411Q7sa,普通,日式菜,,一口大锅, 可乐饼,土豆、洋葱、肉、鸡蛋,BV17x411U75q,普通,日式菜,,一口大锅,
清炒莴笋丝,莴笋、胡萝卜,BV1qK411H7RL,简单,爽口,,一口大锅, 清炒莴笋丝,莴笋、胡萝卜,BV1qK411H7RL,简单,爽口,,一口大锅,
莴笋泡菜,莴笋,BV1h741127rS,简单,爽口,泡菜,一口大锅, 莴笋泡菜,莴笋,BV1h741127rS,简单,爽口,泡菜,一口大锅,
口蘑汤,菌菇,BV1e64y1h776,简单,,煎、炖,一口大锅, 口蘑汤,菌菇,BV1e64y1h776,简单,,煎、炖,一口大锅,
@@ -598,3 +597,4 @@ biangbiang面,面食,BV1844y157GL,简单,,油泼,一口大锅,
电饭煲番茄牛肉焖饭,番茄、牛肉、米,BV1Bv411C7X3,普通,,,一口大锅、电饭煲, 电饭煲番茄牛肉焖饭,番茄、牛肉、米,BV1Bv411C7X3,普通,,,一口大锅、电饭煲,
电饭煲排骨土豆焖饭,猪肉、土豆、米,BV1Bv411C7X3,普通,,,一口大锅、电饭煲, 电饭煲排骨土豆焖饭,猪肉、土豆、米,BV1Bv411C7X3,普通,,,一口大锅、电饭煲,
米布丁,米、鸡蛋,BV1hr4y1k7A5,简单,,,一口大锅、电饭煲, 米布丁,米、鸡蛋,BV1hr4y1k7A5,简单,,,一口大锅、电饭煲,
麻婆豆腐,豆腐,BV1it4y1X75m,简单,,,一口大锅,
1 name stuff bv difficulty tags methods tools
217 微波炉版照烧鸡腿饭 米、鸡肉、胡萝卜、花菜 BV16Z4y1V774 微波炉
218 微波炉版蒸蛋羹 鸡蛋 BV19T4y1D7Zd 微波炉
219 微波炉版煮米饭 BV193411W7nb 微波炉
豉油鸡翅 鸡肉 BV1vz4y1d775 普通 茶餐厅 一口大锅
220 水煮肉片 猪肉、芹菜、莴笋 BV1ZZ4y1379N 普通 川菜 一口大锅
221 脆口黄瓜 黄瓜 BV1Tb4y1X7ow 简单 脆口 凉拌 一口大锅
222 白萝卜汤 白萝卜 BV1HJ411L7xA 简单 单一食材 一口大锅
296 名古屋鸡翅 鸡肉 BV1ET4y1A7Xd 普通 日式 一口大锅
297 日式炖白萝 白萝卜 BV17b411B7H1 简单 日式 一口大锅
298 炸虾天妇罗 BV1e5411t7LY 困难 日式 一口大锅
299 可乐饼 土豆、洋葱、肉、鸡蛋 BV1yW411Q7sa BV17x411U75q 普通 日式菜 一口大锅
300 清炒莴笋丝 莴笋、胡萝卜 BV1qK411H7RL 简单 爽口 一口大锅
301 莴笋泡菜 莴笋 BV1h741127rS 简单 爽口 泡菜 一口大锅
302 口蘑汤 菌菇 BV1e64y1h776 简单 煎、炖 一口大锅
597 电饭煲番茄牛肉焖饭 番茄、牛肉、米 BV1Bv411C7X3 普通 一口大锅、电饭煲
598 电饭煲排骨土豆焖饭 猪肉、土豆、米 BV1Bv411C7X3 普通 一口大锅、电饭煲
599 米布丁 米、鸡蛋 BV1hr4y1k7A5 简单 一口大锅、电饭煲
600 麻婆豆腐 豆腐 BV1it4y1X75m 简单 一口大锅

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,8 @@
import antfu from '@antfu/eslint-config' import antfu from '@antfu/eslint-config'
import unocss from '@unocss/eslint-config/flat'
export default antfu( export default antfu(
{}, {
unocss, unocss: true,
formatters: true,
},
) )

View File

@@ -6,9 +6,9 @@ By default, `default.vue` will be used unless an alternative is specified in the
```html ```html
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ definePageMeta({
layout: 'home', layout: 'home',
}) })
</script> </script>
``` ```

View File

@@ -10,7 +10,7 @@ const route = useRoute()
<main class="text-center text-gray-700 dark:text-gray-200" p="t-5 b-15"> <main class="text-center text-gray-700 dark:text-gray-200" p="t-5 b-15">
<div flex items-center justify-between> <div flex items-center justify-between>
<BackBtn ml-3 /> <BackBtn ml-3 />
<h2 flex items-center justify-center text-lg> <h2 flex items-center justify-center text-lg font="bold">
{{ route.meta.title }} {{ route.meta.title }}
</h2> </h2>
<DarkToggle mr-3 /> <DarkToggle mr-3 />

View File

@@ -1,7 +1,8 @@
<template> <template>
<main class="cook-main text-center text-gray-700 dark:text-gray-200" p="t-8 b-$cook-bottom-menu-height"> <main class="cook-main text-center text-gray-700 dark:text-gray-200" p="t-8 b-$cook-bottom-menu-height">
<slot /> <slot />
<DarkToggle absolute right-3 top-5 /> <DarkToggle absolute left-3 top-5 />
<SearchRecipe />
<TheBottomMenu fixed bottom-0 left-0 right-0 /> <TheBottomMenu fixed bottom-0 left-0 right-0 />
</main> </main>
</template> </template>

6
layouts/preview.vue Normal file
View File

@@ -0,0 +1,6 @@
<template>
<main class="cook-main text-center text-gray-700 dark:text-gray-200" p="t-8 b-$cook-bottom-menu-height">
<slot />
<DarkToggle absolute left-3 top-4 />
</main>
</template>

View File

@@ -1,11 +1,11 @@
[build.environment]
NODE_VERSION = "16"
[build] [build]
publish = "dist" publish = "dist"
command = "pnpm run build" command = "pnpm run build"
[build.environment]
NODE_VERSION = "16"
[[redirects]] [[redirects]]
from = "/*" from = "/*"
to = "/index.html" to = "/index.html"
status = 200 status = 200

View File

@@ -6,6 +6,8 @@ Object.assign(process.env, {
VITE_COMMIT_REF: process.env.CF_PAGES_COMMIT_SHA || '', VITE_COMMIT_REF: process.env.CF_PAGES_COMMIT_SHA || '',
}) })
// add build time to env
import.meta.env.VITE_APP_BUILD_TIME = new Date().getTime().toString()
export default defineNuxtConfig({ export default defineNuxtConfig({
ssr: false, ssr: false,
@@ -13,9 +15,10 @@ export default defineNuxtConfig({
'@vueuse/nuxt', '@vueuse/nuxt',
'@unocss/nuxt', '@unocss/nuxt',
'@pinia/nuxt', '@pinia/nuxt',
'@nuxt/test-utils/module',
'@nuxtjs/color-mode', '@nuxtjs/color-mode',
'@vite-pwa/nuxt', '@vite-pwa/nuxt',
'nuxt-vitest',
'@zadigetvoltaire/nuxt-gtm', '@zadigetvoltaire/nuxt-gtm',
@@ -26,7 +29,8 @@ export default defineNuxtConfig({
// when using generate, payload js assets included in sw precache manifest // when using generate, payload js assets included in sw precache manifest
// but missing on offline, disabling extraction it until fixed // but missing on offline, disabling extraction it until fixed
payloadExtraction: false, payloadExtraction: false,
inlineSSRStyles: false, // inlineSSRStyles: false,
renderJsonPayloads: true,
typedPages: true, typedPages: true,
}, },

View File

@@ -1,66 +1,73 @@
{ {
"type": "module", "type": "module",
"version": "1.1.8", "version": "1.2.2",
"private": true, "private": true,
"packageManager": "pnpm@8.10.2", "packageManager": "pnpm@8.15.5",
"engines": { "engines": {
"node": ">=14" "node": ">=16"
}, },
"scripts": { "scripts": {
"build": "npm run convert && cross-env VITE_APP_BUILD_TIME=$(date +%s) nuxi build", "build": "npm run convert && nuxt build",
"build:static": "npm run convert && nuxt generate",
"convert": "tsx scripts/convert.ts", "convert": "tsx scripts/convert.ts",
"dev": "cross-env VITE_APP_BUILD_TIME=$(date +%s) nuxi dev --host", "dev": "nuxt dev --host",
"dev:pwa": "VITE_PLUGIN_PWA=true nuxi dev", "dev:pwa": "VITE_PLUGIN_PWA=true nuxi dev",
"generate": "nuxi generate", "generate": "nuxt generate",
"start:generate": "npx serve .output/public", "start:generate": "npx serve .output/public",
"start": "node .output/server/index.mjs", "start": "node .output/server/index.mjs",
"lint": "eslint .", "lint": "eslint .",
"postinstall": "nuxi prepare && npm run convert", "postinstall": "nuxt prepare && npm run convert",
"preview": "serve dist",
"preview-https": "serve dist", "preview-https": "serve dist",
"release": "bumpp", "release": "bumpp",
"test": "vitest", "test": "vitest",
"typecheck": "vue-tsc --noEmit" "typecheck": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"dayjs": "^1.11.10",
"vue-about-me": "^1.2.7" "vue-about-me": "^1.2.7"
}, },
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^1.1.0", "@antfu/eslint-config": "^2.9.0",
"@headlessui/vue": "^1.7.16", "@headlessui/vue": "^1.7.19",
"@iconify-json/carbon": "^1.1.21", "@iconify-json/carbon": "^1.1.31",
"@iconify-json/fe": "^1.1.7", "@iconify-json/fe": "^1.1.10",
"@iconify-json/gg": "^1.1.6", "@iconify-json/gg": "^1.1.9",
"@iconify-json/ic": "^1.1.14", "@iconify-json/ic": "^1.1.17",
"@iconify-json/mdi": "^1.1.55", "@iconify-json/mdi": "^1.1.64",
"@iconify-json/ri": "^1.1.12", "@iconify-json/ri": "^1.1.20",
"@iconify-json/twemoji": "^1.1.12", "@iconify-json/svg-spinners": "^1.1.2",
"@nuxt/devtools": "^1.0.0", "@iconify-json/twemoji": "^1.1.15",
"@nuxtjs/color-mode": "^3.3.0", "@nuxt/devtools": "^1.1.3",
"@nuxt/test-utils": "^3.12.0",
"@nuxtjs/color-mode": "^3.3.3",
"@pinia/nuxt": "^0.5.1", "@pinia/nuxt": "^0.5.1",
"@pinia/testing": "^0.1.3", "@pinia/testing": "^0.1.3",
"@unocss/eslint-config": "^0.57.2", "@unocss/eslint-config": "^0.58.6",
"@unocss/nuxt": "^0.57.2", "@unocss/nuxt": "^0.58.6",
"@vite-pwa/nuxt": "^0.1.1", "@vite-pwa/nuxt": "^0.6.0",
"@vue/test-utils": "^2.4.1", "@vue/test-utils": "^2.4.5",
"@vueuse/nuxt": "^10.5.0", "@vueuse/nuxt": "^10.9.0",
"@yunlefun/vue": "0.0.8-beta.4", "@yunlefun/vue": "^0.1.1",
"@zadigetvoltaire/nuxt-gtm": "^0.0.13", "@zadigetvoltaire/nuxt-gtm": "^0.0.13",
"bumpp": "^9.4.0",
"consola": "^3.2.3", "consola": "^3.2.3",
"cross-env": "^7.0.3", "dexie": "^3.2.7",
"dexie": "^3.2.4", "eslint": "^8.57.0",
"eslint": "^8.53.0", "eslint-plugin-format": "^0.1.0",
"fake-indexeddb": "^5.0.1", "fake-indexeddb": "^5.0.2",
"happy-dom": "^12.10.3", "happy-dom": "^14.3.1",
"jsdom": "^22.1.0", "jsdom": "^24.0.0",
"nuxt": "^3.8.0", "nuxt": "^3.9.3",
"nuxt-vitest": "^0.11.2", "nuxt-vitest": "^0.11.5",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"sass": "^1.69.5", "sass": "^1.72.0",
"serve": "^14.2.1",
"star-markdown-css": "^0.4.2", "star-markdown-css": "^0.4.2",
"tsx": "^3.14.0", "tsx": "^4.7.1",
"typescript": "^5.2.2", "typescript": "^5.4.3",
"unocss": "^0.57.2", "unocss": "^0.58.6",
"vitest": "^0.34.6", "vitest": "^1.4.0",
"vue-tsc": "^1.8.22" "vue-tsc": "^2.0.7"
} }
} }

1
packages/ai/.env.example Normal file
View File

@@ -0,0 +1 @@
OPENAI_API_KEY=

5
packages/ai/package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"devDependencies": {
"openai": "^4.29.2"
}
}

197
packages/ai/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,197 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
devDependencies:
openai:
specifier: ^4.29.2
version: 4.29.2
packages:
/@types/node-fetch@2.6.11:
resolution: {integrity: sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==}
dependencies:
'@types/node': 18.19.26
form-data: 4.0.0
dev: true
/@types/node@18.19.26:
resolution: {integrity: sha512-+wiMJsIwLOYCvUqSdKTrfkS8mpTp+MPINe6+Np4TAGFWWRWiBQ5kSq9nZGCSPkzx9mvT+uEukzpX4MOSCydcvw==}
dependencies:
undici-types: 5.26.5
dev: true
/abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
dependencies:
event-target-shim: 5.0.1
dev: true
/agentkeepalive@4.5.0:
resolution: {integrity: sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==}
engines: {node: '>= 8.0.0'}
dependencies:
humanize-ms: 1.2.1
dev: true
/asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: true
/base-64@0.1.0:
resolution: {integrity: sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA==}
dev: true
/charenc@0.0.2:
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
dev: true
/combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
dependencies:
delayed-stream: 1.0.0
dev: true
/crypt@0.0.2:
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
dev: true
/delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dev: true
/digest-fetch@1.3.0:
resolution: {integrity: sha512-CGJuv6iKNM7QyZlM2T3sPAdZWd/p9zQiRNS9G+9COUCwzWFTs0Xp8NF5iePx7wtvhDykReiRRrSeNb4oMmB8lA==}
dependencies:
base-64: 0.1.0
md5: 2.3.0
dev: true
/event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
dev: true
/form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
dev: true
/form-data@4.0.0:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
dev: true
/formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
dev: true
/humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
dependencies:
ms: 2.1.3
dev: true
/is-buffer@1.1.6:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
dev: true
/md5@2.3.0:
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
dependencies:
charenc: 0.0.2
crypt: 0.0.2
is-buffer: 1.1.6
dev: true
/mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
dev: true
/mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.52.0
dev: true
/ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
dev: true
/node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
dev: true
/node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
dependencies:
whatwg-url: 5.0.0
dev: true
/openai@4.29.2:
resolution: {integrity: sha512-cPkT6zjEcE4qU5OW/SoDDuXEsdOLrXlAORhzmaguj5xZSPlgKvLhi27sFWhLKj07Y6WKNWxcwIbzm512FzTBNQ==}
hasBin: true
dependencies:
'@types/node': 18.19.26
'@types/node-fetch': 2.6.11
abort-controller: 3.0.0
agentkeepalive: 4.5.0
digest-fetch: 1.3.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
web-streams-polyfill: 3.3.3
transitivePeerDependencies:
- encoding
dev: true
/tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: true
/undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
dev: true
/web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
dev: true
/web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
dev: true
/webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: true
/whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
dev: true

53
packages/ai/src/api.ts Normal file
View File

@@ -0,0 +1,53 @@
import consola from 'consola'
import type OpenAI from 'openai'
import { baseChatCompletionCreateParams, baseModel, config, openai } from './config'
// TODO: pass params
export async function getCompletion(msg: string) {
const chatCompletion = await openai.chat.completions.create({
...baseChatCompletionCreateParams,
messages: [{ role: 'user', content: msg }],
model: baseModel,
})
return chatCompletion.choices
}
/**
* 获取 ai 生成的菜谱信息
*/
export async function getAIRecipeInfo(zhFoods: string[]) {
/**
* 限制输入长度
*/
const promptFoods = zhFoods.join('、').slice(0, config.inputMaxLength)
// 尽可能少的 token
const tooltip = `
使用以下材料【${promptFoods}】做一道菜,请为这道菜起个名字,最好带有文化底蕴。
不要使用生僻字和标点符号。
并给出一个有趣的不超过100字的介绍。
格式类型:{
"名称": "",
"介绍": ""
}
直接给出可以被 JSON.parse 解析的字符串,不需要解释内容。`
const messages: OpenAI.ChatCompletionMessageParam[] = [
{
role: 'system',
content: tooltip,
},
]
const chatCompletion = await openai.chat.completions.create({
...baseChatCompletionCreateParams,
messages,
model: baseModel,
// stream: true
})
consola.debug(chatCompletion)
return chatCompletion.choices[0].message
}

26
packages/ai/src/config.ts Normal file
View File

@@ -0,0 +1,26 @@
import 'dotenv/config'
import process from 'node:process'
import OpenAI from 'openai'
const deepseekApiUrl = 'https://api.deepseek.com/v1'
const aiServiceUrl = process.env.AI_SERVICE_URL || deepseekApiUrl
export const config = {
inputMaxLength: 300,
}
export const openai = new OpenAI({
baseURL: aiServiceUrl,
apiKey: process.env.OPENAI_API_KEY, // This is the default and can be omitted
})
export const baseModel = process.env.MODEL_NAME || 'deepseek-chat'
export const baseChatCompletionCreateParams: Partial<OpenAI.ChatCompletionCreateParamsNonStreaming> = {
max_tokens: 100,
// TODO: for use control
// presence_penalty: 0,
// frequency_penalty: 0,
// stream: true
}

3
packages/ai/src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './api'
export * from './config'
export * from './types'

7
packages/ai/src/types.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* AI 食谱
*/
export interface AIRecipeInfo {
名称: string
介绍: string
}

25
pages/ai.vue Normal file
View File

@@ -0,0 +1,25 @@
<script lang="ts" setup>
definePageMeta({
layout: 'preview',
})
const rStore = useRecipeStore()
</script>
<template>
<div text-4xl>
<button
class="cursor-pointer transition active:text-green-800 hover:(text-green-600)"
title="重置"
@click="rStore.reset"
>
<div v-if="rStore.selectedStuff.length" i-mdi-pot-steam-outline />
<div v-else i-mdi-pot-mix-outline />
</button>
</div>
<p text="sm" m="b-4">
好的今天我们来做菜
</p>
<AiChooseFood />
</template>

View File

@@ -137,12 +137,6 @@
<li> <li>
<a href="https://weibo.com/runny" target="_blank">Runny</a> <a href="https://weibo.com/runny" target="_blank">Runny</a>
</li> </li>
<li>
山竹太凉
</li>
<li>
leo
</li>
<li> <li>
麒麟 麒麟
</li> </li>

12
pages/recipes/collect.vue Normal file
View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
definePageMeta({
layout: 'child',
title: '我的收藏',
})
</script>
<template>
<div>
施工中...
</div>
</template>

38
pages/recipes/history.vue Normal file
View File

@@ -0,0 +1,38 @@
<script lang="ts" setup>
import dayjs from 'dayjs'
import { recipeHistories } from '~/composables/store/history'
definePageMeta({
layout: 'child',
title: '历史记录',
})
// todo
// clear one history
function clearAllHistory() {
recipeHistories.value = []
}
</script>
<template>
<div pt-2>
<div
text="blue-900 dark:blue-200"
bg="blue-300 op-20 hover:(blue-800 op-20) dark:hover:(blue-200 op-20)"
class="inline-flex items-center justify-center border border-transparent rounded-md px-4 py-2 text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500"
@click="clearAllHistory"
>
<div i-ri-eraser-line />
<span class="ml-1">清空记录</span>
</div>
<div flex="~ col">
<div v-for="history in recipeHistories" :key="history.recipe.name" mt-2>
<StapleTag :active="false">
{{ dayjs(history.time).format('YYYY-MM-DD HH:mm:ss') }}
</StapleTag>
<DishTag :dish="history.recipe" />
</div>
</div>
</div>
</template>

View File

@@ -13,6 +13,12 @@ import { links } from '~/constants'
px-2 px-2
text-left text-left
> >
<div mt-2 gap="3" grid="~ cols-3">
<YlfIconItem to="/recipes/history" icon="i-ri-history-line" label="历史记录" />
<YlfIconItem to="/recipes/collect" icon="i-ri-star-line" label="我的收藏" />
<YlfIconItem to="/cookbooks" icon="i-ri-article-line" label="自定义菜谱" />
</div>
<YlfForm> <YlfForm>
<YlfFormItem icon="i-ri-feedback-line" label="立即反馈" :to="links.feedback" target="_blank" /> <YlfFormItem icon="i-ri-feedback-line" label="立即反馈" :to="links.feedback" target="_blank" />
<YlfFormItem icon="i-ri-mail-send-line" label="立即投稿" :to="links.contribute" target="_blank" /> <YlfFormItem icon="i-ri-mail-send-line" label="立即投稿" :to="links.contribute" target="_blank" />
@@ -22,9 +28,9 @@ import { links } from '~/constants'
<YlfFormItem icon="i-ri-settings-line" label="设置" to="/settings" /> <YlfFormItem icon="i-ri-settings-line" label="设置" to="/settings" />
</YlfForm> </YlfForm>
<YlfForm> <!-- <YlfForm>
<YlfFormItem icon="i-ri-article-line" label="自定义菜谱" to="/cookbooks/" /> <YlfFormItem icon="i-ri-article-line" label="自定义菜谱 TODO" to="/cookbooks/" />
</YlfForm> </YlfForm> -->
<YlfForm> <YlfForm>
<YlfFormItem icon="i-ri-question-line" label="帮助" to="/help" /> <YlfFormItem icon="i-ri-question-line" label="帮助" to="/help" />

8099
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- packages/*

3
server/api/README.md Normal file
View File

@@ -0,0 +1,3 @@
# SD API
- [SD WebUI API](https://github.com/AUTOMATIC1111/stable-diffusion-webui/wiki/API)

View File

@@ -0,0 +1,74 @@
// http://localhost:3001/api/recipes/image/generate
import process from 'node:process'
import { meat, staple, vegetable } from '~/data/food'
// internal temp
// const sdBaseUrl = 'http://30.30.168.63:7860/'
const sdBaseUrl = process.env.SD_API_BASE_URL
const payload = {
// denoising_strength: 0,
prompt: 'food', // 提示词
negative_prompt: '', // 反向提示词
seed: -1, // 种子,随机数
batch_size: 2, // 每次张数
n_iter: 1, // 生成批次
steps: 50, // 生成步数
cfg_scale: 7, // 关键词相关性
width: 512, // 宽度
height: 512, // 高度
restore_faces: false, // 脸部修复
tiling: false, // 可平埔
// override_settings: {
// sd_model_checkpoint: 'wlop-any.ckpt [7331f3bc87]',
// }, // 一般用于修改本次的生成图片的stable diffusion 模型,用法需保持一致
// script_args: [
// 0,
// true,
// true,
// 'LoRA',
// 'dingzhenlora_v1(fa7c1732cc95)',
// 1,
// 1,
// ], // 一般用于lora模型或其他插件参数如示例我放入了一个lora模型 11为两个权重值一般只用到前面的权重值1
sampler_index: 'Euler', // 采样方法
}
// with api /sdapi/v1/txt2img
export interface Txt2ImgResponse {
images: string[]
}
const stuffItems = [
...vegetable,
...meat,
...staple,
]
export default defineEventHandler(async (e) => {
const body = await readBody(e)
const zhFoods = body.foods as string[]
const enFoods = zhFoods.map((food) => {
const item = stuffItems.find(item => item.name === food)
if (item)
return item.en
return ''
})
// TODO: 过滤 prompt 只能是食材
// <lora:TODO:1>,
payload.prompt = `food focus,transparent background,${enFoods.join(',')}`
console.log(payload.prompt)
const data = await $fetch<Txt2ImgResponse>('/sdapi/v1/txt2img', {
baseURL: sdBaseUrl,
body: payload,
method: 'POST',
})
console.log(data)
return data
})

View File

@@ -0,0 +1,40 @@
import consola from 'consola'
import type { AIRecipeInfo } from '~/packages/ai/src'
import { getAIRecipeInfo } from '~/packages/ai/src'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const zhFoods = body.foods as string[]
consola.debug(zhFoods)
const data = await getAIRecipeInfo(zhFoods)
const { content } = data
let unWrapperContent = content || ''
const startPos = unWrapperContent.indexOf('{')
const endPos = unWrapperContent.lastIndexOf('}')
if (startPos === -1 || endPos === -1) {
// eslint-disable-next-line no-console
console.log(content)
return
}
unWrapperContent = unWrapperContent.slice(startPos, endPos + 1)
unWrapperContent = (unWrapperContent || '{}')?.replace('```json\n', '').replace('```', '')
unWrapperContent = unWrapperContent.endsWith('}') ? unWrapperContent : `${unWrapperContent}}`
let coupletData: AIRecipeInfo | undefined
try {
coupletData = JSON.parse(unWrapperContent) as AIRecipeInfo
}
catch (e) {
// eslint-disable-next-line no-console
console.log(content)
console.error(e)
}
return coupletData
})

View File

@@ -0,0 +1,7 @@
const startAt = Date.now()
let count = 0
export default defineEventHandler(() => ({
pageview: count++,
startAt,
}))

File diff suppressed because one or more lines are too long

View File

@@ -26,5 +26,7 @@
// cook custom // cook custom
:root { :root {
--cook-bottom-menu-padding-bottom: 20px; --cook-bottom-menu-padding-bottom: 20px;
--cook-bottom-menu-height: calc(64px + var(--cook-bottom-menu-padding-bottom)); --cook-bottom-menu-height: calc(
64px + var(--cook-bottom-menu-padding-bottom)
);
} }

View File

@@ -12,7 +12,6 @@ body,
background: var(--c-bg); background: var(--c-bg);
} }
input:focus { input:focus {
outline: none; outline: none;
} }
@@ -22,7 +21,7 @@ input:focus {
} }
#nprogress .bar { #nprogress .bar {
background: rgb(13,148,136); background: rgb(13, 148, 136);
opacity: 0.75; opacity: 0.75;
position: fixed; position: fixed;
z-index: 1031; z-index: 1031;
@@ -37,7 +36,7 @@ html {
background-color: var(--c-bg); background-color: var(--c-bg);
scroll-behavior: smooth; scroll-behavior: smooth;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
@@ -48,18 +47,19 @@ a {
button { button {
outline: none; outline: none;
&:focus, &:focus,
&:active { &:active {
outline: none; outline: none;
} }
} }
hr {opacity: 0.1;} hr {
opacity: 0.1;
}
.tag { .tag {
margin: 4px; margin: 4px;
padding: 2px 4px; padding: 2px 4px;
// border: 1px solid var(--c-text); // border: 1px solid var(--c-text);
} }

View File

@@ -10,7 +10,7 @@ blockquote {
border-left: 0.25em solid #ddd; border-left: 0.25em solid #ddd;
padding: 0 1em; padding: 0 1em;
color: #777; color: #777;
quotes: '\\201C''\\201D''\\2018''\\2019'; quotes: '\\201C' '\\201D' '\\2018' '\\2019';
} }
ol { ol {

View File

@@ -66,4 +66,9 @@ export interface StuffItem {
* 显示标签 * 显示标签
*/ */
label?: string label?: string
/**
* 英文名称, for ai keyword
* @example 'potato'
*/
en?: string
} }

0
utils/api/ai.ts Normal file
View File

24
utils/api/index.ts Normal file
View File

@@ -0,0 +1,24 @@
// filter prompt
export async function generateRecipeImage(foods: string[]) {
return $fetch('/api/recipes/image/generate', {
method: 'POST',
body: {
foods,
},
})
}
export async function generateRecipeInfo(foods: string[]) {
console.log(foods)
return $fetch('/api/recipes/text/generate', {
method: 'POST',
body: {
foods,
},
})
}
export async function getRecipeImage(foods: string[]) {
const data = await generateRecipeImage(foods)
return `data:image/png;base64,${data.images[0]}`
}

View File

@@ -1,6 +1,13 @@
import { defineVitestConfig } from 'nuxt-vitest/config' import path from 'node:path'
import { defineVitestConfig } from '@nuxt/test-utils/config'
export default defineVitestConfig({ export default defineVitestConfig({
resolve: {
alias: {
'~/': path.resolve(__dirname, './'),
},
},
test: { test: {
include: ['test/**/*.test.ts'], include: ['test/**/*.test.ts'],
environment: 'nuxt', environment: 'nuxt',