43 Commits
v1.1.4 ... 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
YunYouJun
5ab297cc47 chore: release v1.1.8 2023-11-06 14:34:40 +08:00
YunYouJun
7f87c47320 fix: icon padding bottom & remove useless recipe 2023-11-06 14:34:19 +08:00
YunYouJun
dd2de09325 ci: add check for dev 2023-11-06 02:30:08 +08:00
YunYouJun
e55d9a59c3 chore: fix lint 2023-11-06 02:28:14 +08:00
YunYouJun
0232595083 test: fix nuxt vitest 2023-11-06 02:26:45 +08:00
YunYouJun
d3ab8fb2c1 fix: add css var safe padding bottom 2023-11-06 02:00:56 +08:00
YunYouJun
85c8f4fcd1 chore: update simple copyright 2023-11-06 01:46:37 +08:00
YunYouJun
f2f878e2f4 chore: add simple copyright 2023-11-06 01:19:43 +08:00
YunYouJun
246a65599a fix: lint & add no scalable 2023-11-06 00:36:09 +08:00
YunYouJun
203ab27496 chore: upgrade deps & fix lint 2023-11-05 21:57:05 +08:00
YunYouJun
e861c5bd65 chore: fix typecheck 2023-08-20 05:02:20 +08:00
YunYouJun
94a9ff5007 feat: show custom cookbook 2023-08-20 04:59:14 +08:00
YunYouJun
98123b0039 feat: add settings page 2023-08-20 02:28:00 +08:00
YunYouJun
95ce2c4dac chore: fix readme & title 2023-08-03 23:19:37 +08:00
YunYouJun
1bd0a6721e chore: release before upload 2023-07-30 21:23:14 +08:00
YunYouJun
974d523f9d chore: release v1.1.6 2023-07-30 21:20:58 +08:00
YunYouJun
e9427f7fb0 chore: add release with files 2023-07-30 21:20:53 +08:00
YunYouJun
fdfe1c4622 chore: release v1.1.5 2023-07-30 21:04:42 +08:00
YunYouJun
f6b285788f chore: add release badge 2023-07-30 21:04:26 +08:00
98 changed files with 7289 additions and 4492 deletions

2
.env.example Normal file
View File

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

View File

@@ -1,2 +0,0 @@
dist
public

View File

@@ -1,7 +0,0 @@
{
"extends": [
"@antfu",
"@unocss"
],
"ignorePatterns": "*.json"
}

View File

@@ -4,10 +4,12 @@ on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
- dev
jobs:
lint:

View File

@@ -28,10 +28,17 @@ jobs:
run: pnpm install
- run: npm run generate --if-present
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
files: .output/public/
- uses: actions/upload-artifact@v3
with:
name: Cook Dist
path: dist/
path: .output/public/
- run: npx changelogithub # or changelogithub@0.12 if ensure the stable result
env:

3
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# auto generate
src/data/recipe.json
data/recipe.json
.DS_Store
.vite-ssg-dist
@@ -7,6 +7,7 @@ src/data/recipe.json
*.local
esbuild-kit
tsx-*
# nuxt
node_modules

86
.vscode/settings.json vendored
View File

@@ -1,11 +1,85 @@
{
"cSpell.words": ["Vitesse", "Vite", "unocss", "vitest", "vueuse", "pinia", "demi", "antfu", "iconify", "intlify", "vitejs", "unplugin", "pnpm"],
"prettier.enable": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
},
"cSpell.words": [
"antfu",
"demi",
"iconify",
"intlify",
"nuxi",
"pinia",
"pnpm",
"unocss",
"unplugin",
"Vite",
"vitejs",
"Vitesse",
"vitest",
"vueuse"
],
"files.associations": {
"*.css": "postcss",
"*.css": "postcss"
},
// Enable the ESlint flat config support
"eslint.experimental.useFlatConfig": true,
// Disable the default formatter, use eslint instead
"prettier.enable": false,
"editor.formatOnSave": false,
// Auto fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
// Silent the stylistic rules in you IDE, but still auto fix them
"eslint.rules.customizations": [
{
"rule": "style/*",
"severity": "off"
},
{
"rule": "*-indent",
"severity": "off"
},
{
"rule": "*-spacing",
"severity": "off"
},
{
"rule": "*-spaces",
"severity": "off"
},
{
"rule": "*-order",
"severity": "off"
},
{
"rule": "*-dangle",
"severity": "off"
},
{
"rule": "*-newline",
"severity": "off"
},
{
"rule": "*quotes",
"severity": "off"
},
{
"rule": "*semi",
"severity": "off"
}
],
// Enable eslint for all supported languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml"
],
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -5,6 +5,8 @@
## 版本
[![Release](https://github.com/YunYouJun/cook/actions/workflows/release.yml/badge.svg)](https://github.com/YunYouJun/cook/actions/workflows/release.yml)
### 网页版本
- 网站链接:[cook.yunyoujun.cn](https://cook.yunyoujun.cn)
@@ -71,8 +73,6 @@ docker stop cook
感谢以下小伙伴为本项目提供的数据支持和 QA
- [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

@@ -1,6 +1,5 @@
<script lang="ts" setup>
import { isClient } from '@vueuse/core'
import pkg from '~/package.json'
const displayICP = ref(true)
@@ -8,24 +7,11 @@ onBeforeMount(() => {
if (isClient)
displayICP.value = ['cook.yunyoujun.cn', 'localhost', '127.0.0.1'].includes(window.location.hostname)
})
const commitSha = (import.meta.env.VITE_COMMIT_REF || '').slice(0, 7)
const now = import.meta.env.VITE_APP_BUILD_TIME
const buildDate = (new Date(Number.parseInt(now) * 1000)).toLocaleDateString()
</script>
<template>
<div p="4 t-2" class="flex flex-col items-center justify-center" text="sm">
<div v-if="commitSha && buildDate" mb-2>
<span>
当前版本 v{{ pkg.version }}{{ buildDate }}:
</span>
<span>
<a border="b-1 dashed" :href="`https://github.com/YunYouJun/cook/commit/${commitSha}`" target="_blank" alt="Cook | GitHub Commit">
{{ commitSha }}
</a>
</span>
</div>
<CurrentVersion />
<a v-if="displayICP" opacity="80" class="flex" href="https://beian.miit.gov.cn/" target="_blank">
苏ICP备17038157号
</a>

View File

@@ -18,7 +18,8 @@ const showBasketBtn = computed(async () => {
v-show="showBasketBtn"
class="fixed z-9 inline-flex cursor-pointer items-center justify-center rounded rounded-full shadow hover:shadow-md"
bg="green-50 dark:green-900" w="10" h="10"
bottom="18" right="4"
bottom="22"
right="4"
text="green-600 dark:green-300"
>
<span v-if="displayedRecipe.length > 0">

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
import { storeToRefs } from 'pinia'
import type { StuffItem } from '~/data/food'
import type { StuffItem } from '~/types'
import { meat, staple, tools, vegetable } from '~/data/food'
import { useEmojiAnimation } from '~/composables/animation'

View File

@@ -3,13 +3,14 @@ import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue'
defineProps<{
title: string
defaultOpen?: boolean
}>()
</script>
<template>
<Disclosure v-slot="{ open }" as="div" class="mt-2">
<Disclosure v-slot="{ open }" :default-open="defaultOpen" as="div" class="mt-2">
<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>
<div

View File

@@ -16,11 +16,12 @@ const { random, randomRecipes } = useRandomRecipe(count)
</button>
</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">
<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">
<template v-for="recipe, i in randomRecipes" :key="i">
<DishTag v-if="recipe" :dish="recipe" />

View File

@@ -29,37 +29,35 @@ const showTooltip = computed(() => !selectedStuff.value.length && !curTool.value
<SearchFoodInput v-if="showSearchInput" />
<Transition mode="out-in">
<div class="cook-filter-recipes">
<span v-if="showTooltip" text="sm" p="2">
你要先选食材或工具哦
</span>
<span v-if="showTooltip" text="sm" p="2">
你要先选食材或工具哦
</span>
<div
v-else-if="rStore.isSearching"
relative flex items-center justify-center p-6
text-xl
>
<div class="magnifying-glass" i-ri-search-line inline-flex />
</div>
<div
v-else-if="rStore.isSearching"
relative flex items-center justify-center p-6
text-xl
>
<div class="magnifying-glass" i-ri-search-line inline-flex />
</div>
<div v-else-if="rStore.displayedRecipe.length">
<DishTag v-for="item, i in rStore.displayedRecipe" :key="i" :dish="item" />
</div>
<div v-else-if="rStore.displayedRecipe.length">
<DishTag v-for="item, i in rStore.displayedRecipe" :key="i" :dish="item" />
</div>
<div v-else text="sm">
<span>还没有完美匹配的菜谱呢</span>
<br>
<span>大胆尝试一下或者</span>
<a href="#" @click="rStore.reset()">
<strong>换个组合</strong>
</a>
<span></span>
<br>
<div m="t-1">
<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>
<span>反馈新的菜谱</span>
</div>
<div v-else text="sm">
<span>还没有完美匹配的菜谱呢</span>
<br>
<span>大胆尝试一下或者</span>
<a href="#" @click="rStore.reset()">
<strong>换个组合</strong>
</a>
<span></span>
<br>
<div m="t-1">
<span>欢迎来</span>
<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>
</div>
</div>
</Transition>

View File

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

View File

@@ -1,6 +1,5 @@
<script lang="ts" setup>
import type { BottomMenuItem } from '@yunlefun/vue'
import { ref } from 'vue'
const items: BottomMenuItem[] = [
{
@@ -38,20 +37,20 @@ const items: BottomMenuItem[] = [
const route = useRoute()
const router = useRouter()
const active = ref(route.path)
function onClick(item: BottomMenuItem) {
active.value = item.to || ''
router.push(item.to || '/')
// router.push(item.to || '/')
router.replace(item.to || '/')
}
</script>
<template>
<YlfBottomMenu shadow-2xl>
<YlfBottomMenu shadow-2xl pb="$cook-bottom-menu-padding-bottom">
<YlfBottomMenuItem
v-for="item in items"
:key="item.to"
:item="item"
:active="active === item.to"
:active="route.path === item.to"
class="pt-3"
@click="onClick"
/>
</YlfBottomMenu>

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,13 @@
<script lang="ts" setup>
const router = useRouter()
function back() {
router.back()
}
</script>
<template>
<YlfIconButton
icon="i-ri-arrow-left-s-line"
@click="back"
/>
</template>

View File

@@ -15,7 +15,11 @@ function toggleDark() {
</script>
<template>
<button class="mx-2 icon-btn hover:text-yellow-400 !outline-none" title="切换" @click="toggleDark()">
<YlfIconButton
class="icon-btn hover:text-yellow-400 !outline-none"
text-xl
title="切换" @click="toggleDark()"
>
<div i="ri-sun-line dark:ri-moon-line" />
</button>
</YlfIconButton>
</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

@@ -1,18 +1,18 @@
<template>
<div>
<NuxtLink class="mx-2 icon-btn hover:text-orange-400" to="/help" title="帮助">
<NuxtLink class="icon-btn mx-2 hover:text-orange-400" to="/help" title="帮助">
<div i-ri-question-line />
</NuxtLink>
<NuxtLink class="mx-2 icon-btn hover:text-blue-400" to="/about" title="关于">
<NuxtLink class="icon-btn mx-2 hover:text-blue-400" to="/about" title="关于">
<div i-ri-information-line />
</NuxtLink>
<a class="mx-2 icon-btn hover:text-pink-400" rel="noreferrer" href="https://space.bilibili.com/1579790" target="_blank" title="BiliBili">
<a class="icon-btn mx-2 hover:text-pink-400" rel="noreferrer" href="https://space.bilibili.com/1579790" target="_blank" title="BiliBili">
<div i-ri-bilibili-line />
</a>
<a class="hover:text-black-400 mx-2 icon-btn" rel="noreferrer" href="https://github.com/YunYouJun/cook" target="_blank" title="GitHub">
<a class="icon-btn hover:text-black-400 mx-2" rel="noreferrer" href="https://github.com/YunYouJun/cook" target="_blank" title="GitHub">
<div i-ri-github-line />
</a>
</div>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import type { Cookbook } from '~/types'
defineProps<{
cookbook: Cookbook
}>()
const showDetail = ref(false)
</script>
<template>
<button
class="bg-$c-bg-alt"
h-36 w-full inline-flex cursor-pointer items-center justify-center shadow
@click="showDetail = true"
>
<slot />
</button>
<CookbookDetail
v-if="showDetail"
absolute bottom-17 left-2 right-2 top-2 z-1 overflow-hidden shadow
:cookbook="cookbook"
>
<YlfIconButton
icon="i-ri-close-line"
class="absolute right-2 top-2"
@click="showDetail = false"
/>
</CookbookDetail>
</template>

View File

@@ -0,0 +1,27 @@
<script lang="ts" setup>
import type { Cookbook } from '~/types'
const props = defineProps<{
cookbook: Cookbook
}>()
const recipes = ref<Cookbook['recipes']>(props.cookbook.recipes)
onMounted(async () => {
recipes.value = ((await import('../../data/recipe.json')).default) as unknown as Cookbook['recipes']
})
</script>
<template>
<div class="bg-$c-bg-alt" flex="~ col">
<h3 mt-4 font-bold>
{{ cookbook.title }}
</h3>
<sub op="90" my-3>
{{ cookbook.description }}
</sub>
<div mx-auto mt-2 p-0 border="1px" overflow-y="scroll">
<RecipeTable h="full" :recipes="recipes" />
</div>
<slot />
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
definePageMeta({
layout: 'child',
title: '新建食谱书',
})
</script>
<template>
<NuxtLink
class="bg-$c-bg-alt"
h-36 w-full inline-flex cursor-pointer items-center justify-center shadow
to="/cookbooks/new"
>
<slot>
<div i-ri-add-line />
</slot>
</NuxtLink>
</template>

View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
import pkg from '~/package.json'
const commitSha = (import.meta.env.VITE_COMMIT_REF || '').slice(0, 7)
const now = import.meta.env.VITE_APP_BUILD_TIME
const buildDate = (new Date(Number.parseInt(now))).toLocaleDateString()
</script>
<template>
<div v-if="commitSha && buildDate" mb-2 text-sm>
<span>
当前版本 v{{ pkg.version }}{{ buildDate }}:
</span>
<span>
<a border="b-1 dashed" :href="`https://github.com/YunYouJun/cook/commit/${commitSha}`" target="_blank" alt="Cook | GitHub Commit">
{{ commitSha }}
</a>
</span>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<template>
<div text="center sm" my-3>
<CurrentVersion />
<div flex="~" items-center justify-center gap="2">
<a
href="https://github.com/YunYouJun/cook" target="_blank"
class="inline-flex items-center justify-center"
>
<div i-ri-github-line mr-1 />
<span>Code</span>
</a>
by
<a
href="https://www.bilibili.com/opus/649847454294868008" target="_blank"
class="inline-flex items-center justify-center"
>
<div i-ri-bilibili-line mr-1 class="text-pink-400" />
<span>云游君</span>
</a>
</div>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script lang="ts" setup>
import type { Recipes } from '~/types'
defineProps<{
recipes: Recipes
}>()
</script>
<template>
<table
class="recipe-table bg-$c-bg"
overflow="auto" h="full"
>
<thead>
<tr>
<th />
<th>
名称
</th>
<th>
工具
</th>
<th>
材料
</th>
</tr>
</thead>
<tbody>
<RecipeTableItem
v-for="(recipe, i) in recipes"
:key="recipe.name"
:index="i" :recipe="recipe"
/>
</tbody>
</table>
</template>
<style lang="scss">
.recipe-table {
font-size: 0.8rem;
}
tr,
th,
td {
border: 1px solid black;
}
</style>

View File

@@ -0,0 +1,31 @@
<script lang="ts" setup>
import type { RecipeItem } from '~/types'
defineProps<{
index: number
recipe: RecipeItem
}>()
</script>
<template>
<tr>
<td>
{{ index }}
</td>
<td>
<a
class="text-blue-500" font-bold
:href="recipe.link || `https://www.bilibili.com/video/${recipe.bv}`"
target="_blank"
>
{{ recipe.name }}
</a>
</td>
<td>
{{ recipe.tools.join('、') }}
</td>
<td>
{{ recipe.stuff.join('、') }}
</td>
</tr>
</template>

View File

@@ -1,8 +1,9 @@
<script lang="ts" setup>
import type { DbRecipeItem } from 'utils/db'
import type { DbRecipeItem } from '~/utils/db'
import { tools } from '~/data/food'
import type { RecipeItem } from '~/types'
import { getEmojisFromStuff } from '~/utils'
import { recipeHistories } from '~/composables/store/history'
const props = defineProps<{
dish: RecipeItem | DbRecipeItem
@@ -10,16 +11,21 @@ const props = defineProps<{
const gtm = useGtm()
function triggerGtm(val: string) {
function triggerGtm(dish: RecipeItem) {
recipeHistories.value.push({
recipe: dish,
time: Date.now(),
})
gtm?.trackEvent({
event: 'click',
category: `dish_${val}`,
category: `dish_${dish.name}`,
action: 'click_recipe',
label: '跳转菜谱',
})
gtm?.trackEvent({
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"
border="~ blue-200 dark:blue-800"
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 }}
</span>
<span v-for="tool, i in tools" :key="i" inline-flex>
<div v-if="dish.tools?.includes(tool.name)" :class="tool.icon" />
</span>
<template v-for="tool, i in tools">
<span v-if="dish.tools?.includes(tool.name)" :key="i" :class="tool.icon" />
</template>
</a>
</template>

View File

@@ -0,0 +1,24 @@
<template>
<div class="ylf-form" flex="~ col" rounded-md>
<slot />
</div>
</template>
<style lang="scss">
.ylf-form {
background-color: var(--ylf-c-bg-alt);
border: 1px solid var(--ylf-c-border);
margin: 10px 0;
overflow: hidden;
.ylf-form-item {
border-bottom: 1px solid var(--ylf-c-border);
&:last-child {
border-bottom: none;
}
}
}
</style>

View File

@@ -0,0 +1,33 @@
<script lang="ts" setup>
import { NuxtLink } from '#components'
defineProps<{
icon?: string
label?: string
/**
* Router link
*/
to?: string
}>()
</script>
<template>
<component
:is="to ? NuxtLink : 'div'"
:to="to"
class="ylf-form-item"
w-full flex cursor-pointer items-center justify-between p-2
hover:bg-gray-100
dark:hover:bg-dark-400
>
<div v-if="label" class="text-sm" inline-flex items-center justify-center>
<div v-if="icon" :class="icon" mr-2 inline-flex />
<span>{{ label }}</span>
</div>
<div inline-flex>
<slot>
<div v-if="to" i-ri-arrow-right-s-line />
</slot>
</div>
</component>
</template>

View File

@@ -0,0 +1,16 @@
<script lang="ts" setup>
defineProps<{
icon?: string
}>()
</script>
<template>
<button
class="ylf-icon-button hover:(bg-blue-300 bg-opacity-20)"
h-10 w-10 inline-flex items-center justify-center rounded-full
>
<slot>
<div v-if="icon" text-xl :class="icon" />
</slot>
</button>
</template>

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

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { Switch } from '@headlessui/vue'
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits(['update:modelValue'])
function updateModelValue(value: boolean) {
emit('update:modelValue', value)
}
</script>
<template>
<Switch
:model-value="modelValue"
:class="modelValue ? 'bg-blue-600' : 'bg-gray'"
class="relative h-6 w-11 inline-flex shrink-0 cursor-pointer border-2 border-transparent rounded-full transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75"
@update:model-value="updateModelValue"
>
<span class="sr-only">Use setting</span>
<span
aria-hidden="true"
:class="modelValue ? 'translate-x-5' : 'translate-x-0'"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out"
/>
</Switch>
</template>

View File

@@ -4,7 +4,6 @@ import { isClient, useElementBounding } from '@vueuse/core'
/**
* trigger show invisible element
* @param target
* @returns
*/
export function useInvisibleElement(target: MaybeComputedElementRef<HTMLElement>) {
const { top } = useElementBounding(target)

View File

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

View File

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

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

@@ -2,9 +2,9 @@ import { acceptHMRUpdate, defineStore } from 'pinia'
import { useStorage } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
import { useGtm } from '@gtm-support/vue-gtm'
import type { RecipeItem } from 'types'
import type { StuffItem } from '../../data/food'
import { db } from '../../utils/db'
import { useAppStore } from './app'
import type { RecipeItem, StuffItem } from '~/types'
const namespace = 'cook'
@@ -17,6 +17,7 @@ export type SearchMode = 'survival' | 'loose' | 'strict'
export const useRecipeStore = defineStore('recipe', () => {
const gtm = useGtm()
const { settings } = useAppStore()
/**
* 搜索关键字
@@ -24,10 +25,10 @@ export const useRecipeStore = defineStore('recipe', () => {
const keyword = ref('')
// can not exported
const curStuff = useStorage(`${namespace}:stuff`, new Set<string>())
const curStuff = settings.keepLocalData ? useStorage(`${namespace}:stuff`, new Set<string>()) : ref(new Set<string>())
// const curTools = ref(new Set<string>())
const curTool = useStorage(`${namespace}:tool`, '')
const curMode = useStorage<SearchMode>(`${namespace}:mode`, 'loose')
const curTool = settings.keepLocalData ? useStorage(`${namespace}:tool`, '') : ref('')
const curMode = settings.keepLocalData ? useStorage<SearchMode>(`${namespace}:mode`, 'loose') : ref<SearchMode>('loose')
const selectedStuff = computed(() => Array.from(curStuff.value))
// const selectedTools = computed(() => Array.from(curTools.value))
@@ -73,13 +74,10 @@ export const useRecipeStore = defineStore('recipe', () => {
const isSearching = ref(false)
/**
* 搜索菜谱
* @returns
*/
async function searchRecipes() {
isSearching.value = true
let result: RecipeItem[] = []
if (keyword.value)
result = await db.recipes.filter(item => item.name.includes(keyword.value)).toArray()
if (curMode.value === 'strict') {
result = await db.recipes.filter((item) => {
@@ -116,14 +114,18 @@ export const useRecipeStore = defineStore('recipe', () => {
}).toArray()
}
if (keyword.value)
result = result.filter(item => item.name.includes(keyword.value))
isSearching.value = false
return result
}
// 默认严格模式
const displayedRecipe = ref<RecipeItem[]>([])
watch([keyword, curStuff, curTool, curMode], async () => {
displayedRecipe.value = await searchRecipes()
// fix curStuff watch
watch(() => [keyword.value, selectedStuff.value, curTool.value, curMode.value], async () => {
displayedRecipe.value = [...(await searchRecipes())]
})
/**

View File

@@ -1,7 +1,7 @@
export const appName = '隔离食用手册'
export const appName = '食用手册'
export const appDescription = '好的,今天我们来做菜!'
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'

View File

@@ -1,29 +1,4 @@
export interface StuffItem {
/**
* 食材名称
*/
name: string
/**
* 例如:🥔
*/
emoji: string
/**
* 图片链接
*/
image?: string
/**
* 别名,譬如:西红柿/番茄
*/
alias?: string
/**
* 图标名称
*/
icon?: string
/**
* 显示标签
*/
label?: string
}
import type { StuffItem } from '../types'
/**
* 素菜
@@ -32,63 +7,78 @@ export const vegetable: StuffItem[] = [
{
name: '土豆',
emoji: '🥔',
en: 'potato',
},
{
name: '胡萝卜',
emoji: '🥕',
en: 'carrot',
},
{
name: '花菜',
emoji: '🥦',
en: 'cauliflower',
},
{
name: '白萝卜',
emoji: '🥣',
en: 'radish',
},
{
name: '西葫芦',
emoji: '🥒',
en: 'zucchini',
},
{
name: '番茄',
emoji: '🍅',
alias: '西红柿',
en: 'tomato',
},
{
name: '芹菜',
emoji: '🥬',
en: 'celery',
},
{
name: '黄瓜',
emoji: '🥒',
en: 'cucumber',
},
{
name: '洋葱',
emoji: '🧅',
en: 'onion',
},
{
name: '莴笋',
emoji: '🎍',
en: 'lettuce',
},
{
name: '菌菇',
emoji: '🍄',
en: 'mushroom',
},
{
name: '茄子',
emoji: '🍆',
en: 'eggplant',
},
{
name: '豆腐',
emoji: '🍲',
en: 'tofu',
},
{
name: '包菜',
emoji: '🥗',
en: 'cabbage',
},
{
name: '白菜',
emoji: '🥬',
en: 'cabbage',
},
]
@@ -99,42 +89,52 @@ export const meat: StuffItem[] = [
{
name: '午餐肉',
emoji: '🥓',
en: 'bacon',
},
{
name: '香肠',
emoji: '🌭',
en: 'sausage',
},
{
name: '腊肠',
emoji: '🌭',
en: 'sausage',
},
{
name: '鸡肉',
emoji: '🐤',
en: 'chicken',
},
{
name: '猪肉',
emoji: '🐷',
en: 'pork',
},
{
name: '鸡蛋',
emoji: '🥚',
en: 'egg',
},
{
name: '虾',
emoji: '🦐',
en: 'shrimp',
},
{
name: '牛肉',
emoji: '🐮',
en: 'beef',
},
{
name: '骨头',
emoji: '🦴',
en: 'bone',
},
{
name: '鱼Todo',
emoji: '🐟',
en: 'fish',
},
]
@@ -145,18 +145,22 @@ export const staple: StuffItem[] = [
{
name: '面食',
emoji: '🍝',
en: 'noodles',
},
{
name: '面包',
emoji: '🍞',
en: 'bread',
},
{
name: '米',
emoji: '🍚',
en: 'rice',
},
{
name: '方便面',
emoji: '🍜',
en: 'instant noodles',
},
]

View File

@@ -184,10 +184,8 @@ BBQ烟熏手撕猪肉,猪肉,BV1DV411x7SH,复杂,,烤,烤箱,
微波炉版土豆浓汤,土豆、胡萝卜,BV1HL411E7MM,简单,早饭,微波加热,微波炉,
微波炉版煲仔饭,米、香肠、鸡蛋,BV145411f7R4,简单,主食,微波加热,微波炉,
微波炉版燕麦粥,,BV1cF411Y7pd,,主食,微波加热,微波炉,
微波炉版抱蛋水饺,面食、鸡蛋,BV1zL411x7CU,,,,微波炉,
微波炉版叉烧肉,猪肉、米,BV1Hi4y1s7Sc,,,微波加热,微波炉,
微波炉版蛋炒饭,米、鸡蛋、香肠,BV1T34y147qU,简单,,微波加热,微波炉,
微波炉版番茄炒蛋,鸡蛋、番茄、米,BV1zL411x7CU,,,,微波炉,
微波炉版番茄鸡蛋汤,番茄、鸡蛋,BV1qx411n7QF,,,,微波炉,
微波炉版番茄肉盒,番茄、猪肉、青椒,BV194411R7aH,,,微波加热,微波炉,
微波炉版肥牛饭,牛肉、洋葱、鸡蛋、米,BV1gh411Y7TU,,,,微波炉,
@@ -207,13 +205,10 @@ BBQ烟熏手撕猪肉,猪肉,BV1DV411x7SH,复杂,,烤,烤箱,
微波炉版牛奶炖饭,,BV1W7411c7As,简单,,微波加热,微波炉,
微波炉版日式嫩蒸鸡胸肉,鸡肉,BV1xu41197tU,,,,微波炉,
微波炉版烧烤茄子,茄子,BV19x411H7ig,,,,微波炉,
微波炉版蔬菜烘蛋,鸡蛋、猪肉、胡萝卜,BV1zL411x7CU,,,,微波炉,
微波炉版酸辣土豆丝,土豆,BV1zL411x7CU,,,,微波炉,
微波炉版蒜香烤茄子,茄子,BV19x411H7ig,简单,,微波加热,微波炉,
微波炉版蒜香琵琶腿,鸡肉,BV1qx411777p,,,,微波炉,
微波炉版吐司杯,鸡蛋、面包,BV1CS4y1k7Xi,,,微波加热,微波炉,
微波炉版五花肉,猪肉、米,BV1hL4y137Zk,,,微波加热,微波炉,
微波炉版鲜虾面,面食、虾、胡萝卜、方便面,BV1zL411x7CU,,,,微波炉,
微波炉版香干,豆腐,BV1F44y1T7sQ,,,微波加热,微波炉,
微波炉版香辣豆腐,豆腐、米,BV1bT4y1o7dv,,,,微波炉,
微波炉版香嫩鸡胸肉,鸡肉,BV1xu41197tU,,,,微波炉,
@@ -222,8 +217,6 @@ BBQ烟熏手撕猪肉,猪肉,BV1DV411x7SH,复杂,,烤,烤箱,
微波炉版照烧鸡腿饭,米、鸡肉、胡萝卜、花菜,BV16Z4y1V774,,,,微波炉,
微波炉版蒸蛋羹,鸡蛋,BV19T4y1D7Zd,,,,微波炉,
微波炉版煮米饭,,BV193411W7nb,,,,微波炉,
微波炉版孜然豆腐丁,豆腐,BV1zL411x7CU,,,,微波炉,
豉油鸡翅,鸡肉,BV1vz4y1d775,普通,茶餐厅,,一口大锅,
水煮肉片,猪肉、芹菜、莴笋,BV1ZZ4y1379N,普通,川菜,,一口大锅,
脆口黄瓜,黄瓜,BV1Tb4y1X7ow,简单,脆口,凉拌,一口大锅,
白萝卜汤,白萝卜,BV1HJ411L7xA,简单,单一食材,,一口大锅,
@@ -303,7 +296,7 @@ BBQ烟熏手撕猪肉,猪肉,BV1DV411x7SH,复杂,,烤,烤箱,
名古屋鸡翅,鸡肉,BV1ET4y1A7Xd,普通,日式,,一口大锅,
日式炖白萝,白萝卜,BV17b411B7H1,简单,日式,,一口大锅,
炸虾天妇罗,,BV1e5411t7LY,困难,日式,,一口大锅,
可乐饼,土豆、洋葱、肉、鸡蛋,BV1yW411Q7sa,普通,日式菜,,一口大锅,
可乐饼,土豆、洋葱、肉、鸡蛋,BV17x411U75q,普通,日式菜,,一口大锅,
清炒莴笋丝,莴笋、胡萝卜,BV1qK411H7RL,简单,爽口,,一口大锅,
莴笋泡菜,莴笋,BV1h741127rS,简单,爽口,泡菜,一口大锅,
口蘑汤,菌菇,BV1e64y1h776,简单,,煎、炖,一口大锅,
@@ -604,3 +597,4 @@ biangbiang面,面食,BV1844y157GL,简单,,油泼,一口大锅,
电饭煲番茄牛肉焖饭,番茄、牛肉、米,BV1Bv411C7X3,普通,,,一口大锅、电饭煲,
电饭煲排骨土豆焖饭,猪肉、土豆、米,BV1Bv411C7X3,普通,,,一口大锅、电饭煲,
米布丁,米、鸡蛋,BV1hr4y1k7A5,简单,,,一口大锅、电饭煲,
麻婆豆腐,豆腐,BV1it4y1X75m,简单,,,一口大锅,
1 name stuff bv difficulty tags methods tools
184 微波炉版土豆浓汤 土豆、胡萝卜 BV1HL411E7MM 简单 早饭 微波加热 微波炉
185 微波炉版煲仔饭 米、香肠、鸡蛋 BV145411f7R4 简单 主食 微波加热 微波炉
186 微波炉版燕麦粥 BV1cF411Y7pd 主食 微波加热 微波炉
微波炉版抱蛋水饺 面食、鸡蛋 BV1zL411x7CU 微波炉
187 微波炉版叉烧肉 猪肉、米 BV1Hi4y1s7Sc 微波加热 微波炉
188 微波炉版蛋炒饭 米、鸡蛋、香肠 BV1T34y147qU 简单 微波加热 微波炉
微波炉版番茄炒蛋 鸡蛋、番茄、米 BV1zL411x7CU 微波炉
189 微波炉版番茄鸡蛋汤 番茄、鸡蛋 BV1qx411n7QF 微波炉
190 微波炉版番茄肉盒 番茄、猪肉、青椒 BV194411R7aH 微波加热 微波炉
191 微波炉版肥牛饭 牛肉、洋葱、鸡蛋、米 BV1gh411Y7TU 微波炉
205 微波炉版牛奶炖饭 BV1W7411c7As 简单 微波加热 微波炉
206 微波炉版日式嫩蒸鸡胸肉 鸡肉 BV1xu41197tU 微波炉
207 微波炉版烧烤茄子 茄子 BV19x411H7ig 微波炉
微波炉版蔬菜烘蛋 鸡蛋、猪肉、胡萝卜 BV1zL411x7CU 微波炉
微波炉版酸辣土豆丝 土豆 BV1zL411x7CU 微波炉
208 微波炉版蒜香烤茄子 茄子 BV19x411H7ig 简单 微波加热 微波炉
209 微波炉版蒜香琵琶腿 鸡肉 BV1qx411777p 微波炉
210 微波炉版吐司杯 鸡蛋、面包 BV1CS4y1k7Xi 微波加热 微波炉
211 微波炉版五花肉 猪肉、米 BV1hL4y137Zk 微波加热 微波炉
微波炉版鲜虾面 面食、虾、胡萝卜、方便面 BV1zL411x7CU 微波炉
212 微波炉版香干 豆腐 BV1F44y1T7sQ 微波加热 微波炉
213 微波炉版香辣豆腐 豆腐、米 BV1bT4y1o7dv 微波炉
214 微波炉版香嫩鸡胸肉 鸡肉 BV1xu41197tU 微波炉
217 微波炉版照烧鸡腿饭 米、鸡肉、胡萝卜、花菜 BV16Z4y1V774 微波炉
218 微波炉版蒸蛋羹 鸡蛋 BV19T4y1D7Zd 微波炉
219 微波炉版煮米饭 BV193411W7nb 微波炉
微波炉版孜然豆腐丁 豆腐 BV1zL411x7CU 微波炉
豉油鸡翅 鸡肉 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

8
eslint.config.js Normal file
View File

@@ -0,0 +1,8 @@
import antfu from '@antfu/eslint-config'
export default antfu(
{
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
<script setup lang="ts">
definePageMeta({
layout: 'home',
})
definePageMeta({
layout: 'home',
})
</script>
```

22
layouts/child.vue Normal file
View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
defineProps<{
title?: string
}>()
const route = useRoute()
</script>
<template>
<main class="text-center text-gray-700 dark:text-gray-200" p="t-5 b-15">
<div flex items-center justify-between>
<BackBtn ml-3 />
<h2 flex items-center justify-center text-lg font="bold">
{{ route.meta.title }}
</h2>
<DarkToggle mr-3 />
</div>
<slot />
<TheBottomMenu fixed bottom-0 left-0 right-0 />
</main>
</template>

View File

@@ -1,7 +1,8 @@
<template>
<main class="text-center text-gray-700 dark:text-gray-200" p="x-2 t-8 b-15">
<main class="cook-main text-center text-gray-700 dark:text-gray-200" p="t-8 b-$cook-bottom-menu-height">
<slot />
<DarkToggle absolute right-5 top-5 />
<DarkToggle absolute left-3 top-5 />
<SearchRecipe />
<TheBottomMenu fixed bottom-0 left-0 right-0 />
</main>
</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]
publish = "dist"
command = "pnpm run build"
publish = "dist"
command = "pnpm run build"
[build.environment]
NODE_VERSION = "16"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
from = "/*"
to = "/index.html"
status = 200

View File

@@ -6,6 +6,8 @@ Object.assign(process.env, {
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({
ssr: false,
@@ -13,6 +15,8 @@ export default defineNuxtConfig({
'@vueuse/nuxt',
'@unocss/nuxt',
'@pinia/nuxt',
'@nuxt/test-utils/module',
'@nuxtjs/color-mode',
'@vite-pwa/nuxt',
@@ -25,7 +29,7 @@ export default defineNuxtConfig({
// when using generate, payload js assets included in sw precache manifest
// but missing on offline, disabling extraction it until fixed
payloadExtraction: false,
inlineSSRStyles: false,
// inlineSSRStyles: false,
renderJsonPayloads: true,
typedPages: true,
},
@@ -48,7 +52,7 @@ export default defineNuxtConfig({
},
prerender: {
crawlLinks: false,
routes: ['/'],
routes: ['/', '/random', '/help', '/user', '/404', '/settings'],
ignore: ['/hi'],
},
},
@@ -61,7 +65,7 @@ export default defineNuxtConfig({
{ rel: 'apple-touch-icon', href: '/apple-touch-icon.png' },
],
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no' },
{ name: 'description', content: appDescription },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
],

View File

@@ -1,63 +1,73 @@
{
"type": "module",
"version": "1.2.2",
"private": true,
"version": "1.1.4",
"packageManager": "pnpm@8.6.10",
"packageManager": "pnpm@8.15.5",
"engines": {
"node": ">=14"
"node": ">=16"
},
"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",
"dev": "cross-env VITE_APP_BUILD_TIME=$(date +%s) nuxi dev",
"dev": "nuxt dev --host",
"dev:pwa": "VITE_PLUGIN_PWA=true nuxi dev",
"generate": "nuxi generate",
"generate": "nuxt generate",
"start:generate": "npx serve .output/public",
"start": "node .output/server/index.mjs",
"lint": "eslint .",
"postinstall": "nuxi prepare && npm run convert",
"postinstall": "nuxt prepare && npm run convert",
"preview": "serve dist",
"preview-https": "serve dist",
"release": "bumpp",
"test": "vitest",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"dayjs": "^1.11.10",
"vue-about-me": "^1.2.7"
},
"devDependencies": {
"@antfu/eslint-config": "^0.39.8",
"@headlessui/vue": "^1.7.15",
"@iconify-json/carbon": "^1.1.18",
"@iconify-json/fe": "^1.1.6",
"@iconify-json/gg": "^1.1.5",
"@iconify-json/ic": "^1.1.13",
"@iconify-json/mdi": "^1.1.53",
"@iconify-json/ri": "^1.1.10",
"@iconify-json/twemoji": "^1.1.11",
"@nuxt/devtools": "^0.7.1",
"@nuxtjs/color-mode": "^3.3.0",
"@pinia/nuxt": "^0.4.11",
"@antfu/eslint-config": "^2.9.0",
"@headlessui/vue": "^1.7.19",
"@iconify-json/carbon": "^1.1.31",
"@iconify-json/fe": "^1.1.10",
"@iconify-json/gg": "^1.1.9",
"@iconify-json/ic": "^1.1.17",
"@iconify-json/mdi": "^1.1.64",
"@iconify-json/ri": "^1.1.20",
"@iconify-json/svg-spinners": "^1.1.2",
"@iconify-json/twemoji": "^1.1.15",
"@nuxt/devtools": "^1.1.3",
"@nuxt/test-utils": "^3.12.0",
"@nuxtjs/color-mode": "^3.3.3",
"@pinia/nuxt": "^0.5.1",
"@pinia/testing": "^0.1.3",
"@unocss/eslint-config": "^0.54.0",
"@unocss/nuxt": "^0.54.0",
"@vite-pwa/nuxt": "^0.1.0",
"@vue/test-utils": "^2.4.1",
"@vueuse/nuxt": "^10.2.1",
"@yunlefun/vue": "^0.0.7",
"@unocss/eslint-config": "^0.58.6",
"@unocss/nuxt": "^0.58.6",
"@vite-pwa/nuxt": "^0.6.0",
"@vue/test-utils": "^2.4.5",
"@vueuse/nuxt": "^10.9.0",
"@yunlefun/vue": "^0.1.1",
"@zadigetvoltaire/nuxt-gtm": "^0.0.13",
"bumpp": "^9.4.0",
"consola": "^3.2.3",
"cross-env": "^7.0.3",
"dexie": "^3.2.4",
"eslint": "^8.46.0",
"fake-indexeddb": "^4.0.2",
"jsdom": "^22.1.0",
"nuxt": "^3.6.5",
"pinia": "^2.1.6",
"sass": "^1.64.1",
"dexie": "^3.2.7",
"eslint": "^8.57.0",
"eslint-plugin-format": "^0.1.0",
"fake-indexeddb": "^5.0.2",
"happy-dom": "^14.3.1",
"jsdom": "^24.0.0",
"nuxt": "^3.9.3",
"nuxt-vitest": "^0.11.5",
"pinia": "^2.1.7",
"sass": "^1.72.0",
"serve": "^14.2.1",
"star-markdown-css": "^0.4.2",
"tsx": "^3.12.7",
"typescript": "^5.1.6",
"unocss": "^0.54.0",
"vitest": "^0.33.0",
"vue-tsc": "^1.8.8"
"tsx": "^4.7.1",
"typescript": "^5.4.3",
"unocss": "^0.58.6",
"vitest": "^1.4.0",
"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
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
const router = useRouter()
// const router = useRouter()
</script>
<template>
@@ -9,9 +9,9 @@ const router = useRouter()
</div>
<div>菜谱消失了</div>
<div>
<button text-sm btn m="3 t8" @click="router.back()">
返回
</button>
<NuxtLink text-sm btn m="3 t8" to="/">
返回主页
</NuxtLink>
</div>
</main>
</template>

5
pages/about.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<div>
关于
</div>
</template>

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>

22
pages/cookbooks/index.vue Normal file
View File

@@ -0,0 +1,22 @@
<script lang="ts" setup>
definePageMeta({
layout: 'child',
title: '自定义菜谱',
})
</script>
<template>
<div>
<h3>
开发中敬请期待
</h3>
<div grid="~ cols-3" gap="4" p="4">
<CookbookCard :cookbook="defaultCookbook">
默认菜谱
</CookbookCard>
<NewCookbookCard />
</div>
</div>
</template>

5
pages/cookbooks/new.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<div>
新建 Cookbook
</div>
</template>

View File

@@ -9,6 +9,10 @@
<FeedbackActions />
<div class="mx-auto max-w-md w-full rounded-2xl p-2" text-left>
<FAQItem title="未来计划?">
计划增加新功能如自定义菜谱与使用其他用户分享的菜谱
</FAQItem>
<FAQItem title="什么是模式?">
<ul>
<li><b>模糊匹配</b>展示所有含当前选中任意食材的菜谱</li>
@@ -80,6 +84,81 @@
</li>
</ol>
</FAQItem>
<hr h="1" my="4" bg-black>
<FAQItem :default-open="true" title="关于">
<div text-left>
<ul>
<li>
它诞生于 2022 4 时值疫情风控期间希望能帮助期间的伙伴根据现有食材寻找到合适的菜谱故原名隔离食用手册
</li>
<li>
如今那个时期已离我们远去故去掉隔离二字但也很高兴能在这里继续与你相遇希望它能继续发光发热在日常生活中帮助到大家
</li>
<li>
<div class="inline-flex items-center justify-center">
代码仓库<a class="inline-flex items-center justify-center" href="https://github.com/YunYouJun/cook" target="_blank">
<div m="r-1" i-ri-github-line inline-flex />YunYouJun/cook</a>
</div>
</li>
<li>
<div class="inline-flex items-center justify-center">
菜谱视频来源
<a class="inline-flex items-center text-sm text-blue-600 dark:text-blue-400" href="https://docs.qq.com/sheet/DQk1vdkhFV0twQVNS" target="_blank">
<div m="r-1" i-ri-bilibili-line inline-flex />
<span class="inline-flex">隔离食用手册大全</span>
</a>
</div>
</li>
</ul>
</div>
</FAQItem>
<FAQItem title="关于我">
<div text-left>
我的个人微信公众号云游君会分享一些生活和写的<a href="https://sponsors.yunyoujun.cn/projects" target="_blank">
小玩具们
</a>
<a inline-flex py-4 href="https://cdn.yunyoujun.cn/img/about/white-qrcode-and-search.jpg" target="_blank">
<img src="https://cdn.yunyoujun.cn/img/about/white-qrcode-and-search.jpg">
</a>
</div>
<AboutMe />
</FAQItem>
<FAQItem title="致谢">
<p>
感谢以下小伙伴为本项目提供的数据支持和 QA
</p>
<ul mt-2 text-left text-sm>
<li>
<a href="https://weibo.com/runny" target="_blank">Runny</a>
</li>
<li>
麒麟
</li>
<li>
晴方啾
</li>
<li>
课代表阿伟
</li>
</ul>
</FAQItem>
<FAQItem title="赞助者们">
<div>
感谢至今以来所有的<a href="https://afdian.net/a/yunyoujun" class="text-purple" target="_blank">赞助者</a>你们的支持是我持续维护和开发新项目的动力
</div>
<div pt-2>
<a href="https://sponsors.yunyoujun.cn" target="_blank">
<img src="https://sponsors.yunyoujun.cn/sponsors.svg">
</a>
</div>
</FAQItem>
</div>
</div>
<BaseFooter mt-4 />

View File

@@ -18,4 +18,5 @@ const rStore = useRecipeStore()
</p>
<ChooseFood />
<SimpleCopyright />
</template>

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>

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

@@ -0,0 +1,12 @@
<script lang="ts" setup>
definePageMeta({
layout: 'child',
title: '菜谱 - ?',
})
</script>
<template>
<div>
asd
</div>
</template>

5
pages/recipes/new.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<div>
新建 Recipe
</div>
</template>

View File

@@ -1,7 +1,28 @@
<script lang="ts" setup>
const app = useAppStore()
definePageMeta({
layout: 'child',
})
</script>
<template>
<div>
<button>
<CommonHeader>
设置
</button>
</CommonHeader>
<div
class="mx-auto max-w-md w-full"
px-2
text-left
>
<YlfForm>
<YlfFormItem label="离开网页后保留选中数据">
<YlfSwitch v-model="app.settings.keepLocalData" />
</YlfFormItem>
<YlfFormItem label="更多设置,敬请期待" />
</YlfForm>
</div>
</div>
</template>

View File

@@ -1,83 +1,41 @@
<script lang="ts" setup>
import { links } from '~/constants'
</script>
<template>
<div px-2>
<div>
<CommonHeader>
我的
</CommonHeader>
<FeedbackActions />
<div
class="mx-auto max-w-md w-full"
px-2
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>
<div class="mx-auto max-w-md w-full rounded-2xl p-2" text-left>
<p my-6 text-left text-base>
很高兴能在这里与你相遇也很希望这个网站可以真的帮助到你
</p>
<YlfForm>
<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" />
</YlfForm>
<FAQItem title="关于">
<div text-left>
<ul>
<li>
<div class="inline-flex items-center justify-center">
代码仓库<a class="inline-flex items-center justify-center" href="https://github.com/YunYouJun/cook" target="_blank">
<div m="r-1" i-ri-github-line inline-flex />YunYouJun/cook</a>
</div>
</li>
<li>
<div class="inline-flex items-center justify-center">
菜谱视频来源
<a class="inline-flex items-center text-sm text-blue-600 dark:text-blue-400" href="https://docs.qq.com/sheet/DQk1vdkhFV0twQVNS" target="_blank">
<div m="r-1" i-ri-bilibili-line inline-flex />
<span class="inline-flex">隔离食用手册大全</span>
</a>
</div>
</li>
</ul>
</div>
</FAQItem>
<YlfForm>
<YlfFormItem icon="i-ri-settings-line" label="设置" to="/settings" />
</YlfForm>
<FAQItem title="关于我">
<div text-left>
我的个人微信公众号云游君会分享一些生活和写的<a href="https://sponsors.yunyoujun.cn/projects" target="_blank">
小玩具们
</a>
<!-- <YlfForm>
<YlfFormItem icon="i-ri-article-line" label="自定义菜谱 TODO" to="/cookbooks/" />
</YlfForm> -->
<a inline-flex py-4 href="https://cdn.yunyoujun.cn/img/about/white-qrcode-and-search.jpg" target="_blank">
<img src="https://cdn.yunyoujun.cn/img/about/white-qrcode-and-search.jpg">
</a>
</div>
<AboutMe />
</FAQItem>
<FAQItem title="致谢">
<p>
感谢以下小伙伴为本项目提供的数据支持和 QA
</p>
<ul mt-2 text-left text-sm>
<li>
<a href="https://weibo.com/runny" target="_blank">Runny</a>
</li>
<li>
山竹太凉
</li>
<li>
leo
</li>
<li>
麒麟
</li>
<li>
晴方啾
</li>
<li>
课代表阿伟
</li>
</ul>
</FAQItem>
<FAQItem title="赞助者们">
<a href="https://sponsors.yunyoujun.cn" target="_blank">
<img src="https://sponsors.yunyoujun.cn/sponsors.svg">
</a>
</FAQItem>
<YlfForm>
<YlfFormItem icon="i-ri-question-line" label="帮助" to="/help" />
<YlfFormItem icon="i-ri-information-line" label="关于" to="/help" />
</YlfForm>
</div>
<BaseFooter mt-4 />

9676
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,
}))

10
shims.d.ts vendored
View File

@@ -9,13 +9,15 @@ declare interface Window {
// with vite-plugin-vue-markdown, markdowns can be treat as Vue components
declare module '*.md' {
import { type DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, any>
export default component
}
declare module '*.vue' {
import { type DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, any>
export default component
}

View File

@@ -2,11 +2,31 @@
--c-primary: #5fc178;
--c-text: #333;
--c-bg: #fff;
--c-bg: white;
--c-bg-alt: #f9fbfd;
}
.dark {
--c-text: #fafafa;
--c-bg: #121212;
--c-bg-alt: #333;
}
// ylf
:root {
--ylf-c-bg-alt: var(--c-bg-alt);
--ylf-c-border: #eaeaea;
}
.dark {
--ylf-c-border: #666;
}
// cook custom
:root {
--cook-bottom-menu-padding-bottom: 20px;
--cook-bottom-menu-height: calc(
64px + var(--cook-bottom-menu-padding-bottom)
);
}

View File

@@ -8,10 +8,8 @@ body,
height: 100%;
margin: 0;
padding: 0;
}
html.dark {
background: #121212;
background: var(--c-bg);
}
input:focus {
@@ -23,7 +21,7 @@ input:focus {
}
#nprogress .bar {
background: rgb(13,148,136);
background: rgb(13, 148, 136);
opacity: 0.75;
position: fixed;
z-index: 1031;
@@ -56,11 +54,12 @@ button {
}
}
hr {opacity: 0.1;}
hr {
opacity: 0.1;
}
.tag {
margin: 4px;
padding: 2px 4px;
// border: 1px solid var(--c-text);
}

View File

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

View File

@@ -5,7 +5,7 @@ import { describe, it } from 'vitest'
// import { createTestingPinia } from '@pinia/testing'
// import ChooseFood from '../src/components/ChooseFood.vue'
describe('ChooseFood.vue', () => {
describe('chooseFood.vue', () => {
it('should render', async () => {
// const pinia = createTestingPinia({
// createSpy: vi.fn,

22
types/cookbook.ts Normal file
View File

@@ -0,0 +1,22 @@
import type { Recipes } from './recipe'
export interface Cookbook {
/**
* 菜谱 ID自定义唯一标识符
*/
id: string
cover?: string
/**
* 菜谱名称
*/
title: string
description: string
author: string | string[]
/**
* 菜谱
*/
recipes: Recipes
createdAt: string
updatedAt: string
}

View File

@@ -1 +1,2 @@
export * from './cookbook'
export * from './recipe'

View File

@@ -40,3 +40,35 @@ export interface RecipeItem {
}
export type Recipes = RecipeItem[]
export interface StuffItem {
/**
* 食材名称
*/
name: string
/**
* 例如:🥔
*/
emoji: string
/**
* 图片链接
*/
image?: string
/**
* 别名,譬如:西红柿/番茄
*/
alias?: string
/**
* 图标名称
*/
icon?: string
/**
* 显示标签
*/
label?: string
/**
* 英文名称, for ai keyword
* @example 'potato'
*/
en?: string
}

View File

@@ -21,7 +21,6 @@ export default defineConfig({
shortcuts: [
['tag', 'text-sm cursor-pointer inline-flex justify-center items-center transition shadow hover:shadow-md'],
['btn', 'text-sm px-4 py-1 rounded inline-block bg-blue-600 text-white cursor-pointer hover:bg-blue-700 disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50'],
['icon-btn', 'text-[0.9em] inline-block cursor-pointer select-none opacity-75 transition duration-200 ease-in-out hover:opacity-100 hover:text-blue-600'],
],
presets: [
presetUno(),

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]}`
}

11
utils/cookbook.ts Normal file
View File

@@ -0,0 +1,11 @@
import type { Cookbook } from '~/types'
export const defaultCookbook: Cookbook = {
id: 'default',
title: '默认菜谱',
description: '记录了一些特殊时期常用的菜谱',
author: [''],
recipes: [],
updatedAt: '',
createdAt: '2021-04-04',
}

View File

@@ -9,7 +9,6 @@ foodItems.forEach((item) => {
/**
* get emojis from stuff name array
* @param stuff
* @returns
*/
export function getEmojisFromStuff(stuff: string[]) {
const emojis: string[] = stuff.map(name => foodEmojiMap.get(name)).filter(item => !!item)

View File

@@ -3,7 +3,6 @@ import { isClient } from '@vueuse/core'
/**
* - https://web.dev/customize-install/#detect-install
* - [Trigger installation from your PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Trigger_install_prompt)
* @returns
*/
export function installPrompt() {
if (!isClient)

View File

@@ -1,7 +1,5 @@
/**
* 生成随机数组
* @param recipes
* @returns
*/
export function generateRandomArray(length: number, total = 1) {
const randomArr: number[] = []

10
utils/settings.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface UserSettings {
/**
* 保留本地数据
*/
keepLocalData: boolean
}
export const defaultSettings: UserSettings = {
keepLocalData: true,
}

View File

@@ -4,10 +4,16 @@ export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'jsdom',
deps: {
inline: ['@vue', '@vueuse', 'vue-demi'],
server: {
deps: {
inline: ['@vue', '@vueuse', 'vue-demi'],
},
},
setupFiles: ['test/setup.ts'],
alias: {
'~': './',
},
},
})

17
vitest.nuxt.config.ts Normal file
View File

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