Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/publish-ghcr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Publish Docker image (GHCR)

on:
push:
branches: [main]
tags:
- 'v*'
workflow_dispatch:

permissions:
contents: read
packages: write

jobs:
build-and-push:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository_owner }}/whattoeat
tags: |
type=ref,event=tag
type=sha
type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
FROM node:20-alpine AS build-stage

WORKDIR /app

RUN corepack enable

COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
Expand All @@ -15,6 +16,12 @@ FROM node:20-alpine AS production-stage

WORKDIR /app

ENV NODE_ENV=production
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=3000

LABEL org.opencontainers.image.source="https://github.com/ryanuo/whatToEat"

COPY --from=build-stage /app/.output ./.output

EXPOSE 3000
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,31 @@
[![Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/ryanuo/whatToEat)
[![Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/ryanuo/whatToEat)

### Docker 部署

默认使用公开菜单(与原项目一致):`https://eat.ryanuo.cc/recipes.json`。

```bash
docker compose up -d --build
```

### Docker 部署(服务器不构建:从 GHCR 拉镜像运行)

仓库提交到 GitHub 后,会通过 GitHub Actions 自动构建并发布镜像到 GHCR。

在服务器上部署(无需构建):

```bash
docker pull ghcr.io/ryanuo/whattoeat:latest

docker run -d --name whattoeat \
-p 3000:3000 \

Check failure on line 77 in README.md

View workflow job for this annotation

GitHub Actions / lint

Unexpected tab character
ghcr.io/ryanuo/whattoeat:latest

Check failure on line 78 in README.md

View workflow job for this annotation

GitHub Actions / lint

Unexpected tab character
```

## 数据来源

菜谱数据来源于远程 JSON 接口,通过 `server/api/recipes.ts` 进行获取和处理。
菜谱数据来源于远程 JSON 接口,通过 `server/routes/api/recipes.ts` 进行获取和处理。

## 参考

Expand Down
115 changes: 78 additions & 37 deletions app/components/Eat.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@
const isPlaying = ref(false)
const currentFood = ref<CurrentFood>()
const shakeTitle = ref(false)
const { data } = await useFetch<RecipeResponse>('/api/recipes')
const { data, error, pending, refresh } = await useFetch<RecipeResponse>('/api/recipes', {
retry: 3,
retryDelay: 1000,
timeout: 10000,
})

// 检查数据是否成功加载
const isDataReady = computed(() => {
return !pending.value && !error.value && data.value && data.value.recipes && data.value.recipes.length > 0
})

const categories = computed(() => (data.value?.categories || []) as string[])
const selectedCategories = useStorage<string[]>('selected-categories', [...categories.value])
const isAllSelected = computed(() => selectedCategories.value.length === categories.value.length)
Expand All @@ -22,36 +32,20 @@
}
})

// 颜色配置数组
const colorClasses = [
{ bg: 'bg-blue-100', text: 'text-blue-800', border: 'border-blue-400', active: 'bg-blue-400', activeText: 'text-white', activeBorder: 'border-blue-500' },
{ bg: 'bg-red-100', text: 'text-red-800', border: 'border-red-400', active: 'bg-red-400', activeText: 'text-white', activeBorder: 'border-red-500' },
{ bg: 'bg-green-100', text: 'text-green-800', border: 'border-green-400', active: 'bg-green-400', activeText: 'text-white', activeBorder: 'border-green-500' },
{ bg: 'bg-yellow-100', text: 'text-yellow-800', border: 'border-yellow-300', active: 'bg-yellow-400', activeText: 'text-white', activeBorder: 'border-yellow-500' },
{ bg: 'bg-indigo-100', text: 'text-indigo-800', border: 'border-indigo-400', active: 'bg-indigo-400', activeText: 'text-white', activeBorder: 'border-indigo-500' },
{ bg: 'bg-purple-100', text: 'text-purple-800', border: 'border-purple-400', active: 'bg-purple-400', activeText: 'text-white', activeBorder: 'border-purple-500' },
{ bg: 'bg-pink-100', text: 'text-pink-800', border: 'border-pink-400', active: 'bg-pink-400', activeText: 'text-white', activeBorder: 'border-pink-500' },
{ bg: 'bg-gray-100', text: 'text-gray-800', border: 'border-gray-500', active: 'bg-gray-500', activeText: 'text-white', activeBorder: 'border-gray-600' },
{ bg: 'bg-orange-100', text: 'text-orange-800', border: 'border-orange-400', active: 'bg-orange-400', activeText: 'text-white', activeBorder: 'border-orange-500' },
]

// 获取标签的emoji
function getEmoji(tag: string) {
return emojiMap[tag] || ''
}

// const randomIndex = computed(() => {
// return Math.floor(Math.random() * colorClasses.length)
// })

// 获取标签的颜色类
function getTagColorClasses(index: number, isActive: boolean) {
// const colorConfig
// = index === -1
// ? colorClasses[colorClasses.length - 2]!
// : colorClasses[randomIndex.value]!
const colorConfig = colorClasses[colorClasses.length - 2]!

const colorConfig = {
bg: 'bg-[#FEFCFA]',
text: 'text-[#B9B5B0]',
border: 'border-[#E8E4E2]',
active: 'bg-[#DFDCDA]',
activeText: 'text-[#41465E]',
activeBorder: 'border-[#E8E4E2]',
}
const { bg, text, border, active, activeText, activeBorder } = colorConfig

return isActive
Expand All @@ -74,6 +68,12 @@
if (!import.meta.client)
return

// 确保数据已加载
if (!data.value?.recipes || data.value.recipes.length === 0) {
console.warn('菜单数据未加载,无法开始随机')
return
}

currentFood.value = undefined
shakeTitle.value = true

Expand Down Expand Up @@ -161,47 +161,84 @@
</script>

<template>
<FluidCursor v-if="isPC()" />
<div class="bg-[#E9E9E9] min-h-screen relative overflow-hidden">
<FluidCursor v-if="isPC()" class="absolute z-11!" />

<Header />
<div
class="bg-[#E9E9E9] bg-[url('/pic/bg2.png')] transition-all inset-0 absolute z-0 bg-center"
:class="{ 'animate-paused': isPlaying }" :style="{ animation: `flow 16s linear infinite` }"
/>

<div id="temp_container" class="inset-0 absolute z-10 overflow-hidden" />
<div id="temp_container" class="inset-0 absolute z-12 overflow-hidden" />

<!-- 加载状态 -->
<div v-if="pending" class="px-4 flex flex-col min-h-screen items-center justify-center relative z-20">
<div class="text-center">
<Loading />
<p class="text-gray-600 mt-4 animate-pulse">正在加载菜单数据...</p>

Check warning on line 179 in app/components/Eat.vue

View workflow job for this annotation

GitHub Actions / lint

Expected 1 line break before closing tag (`</p>`), but no line breaks found

Check warning on line 179 in app/components/Eat.vue

View workflow job for this annotation

GitHub Actions / lint

Expected 1 line break after opening tag (`<p>`), but no line breaks found
</div>
</div>

<!-- 错误状态 -->
<div v-else-if="error" class="px-4 flex flex-col min-h-screen items-center justify-center relative z-20">
<div class="text-center">
<p class="text-red-600 mb-4">😞 菜单数据加载失败</p>

Check warning on line 186 in app/components/Eat.vue

View workflow job for this annotation

GitHub Actions / lint

Expected 1 line break before closing tag (`</p>`), but no line breaks found

Check warning on line 186 in app/components/Eat.vue

View workflow job for this annotation

GitHub Actions / lint

Expected 1 line break after opening tag (`<p>`), but no line breaks found
<p class="text-gray-600 text-sm mb-4">{{ error.message }}</p>

Check warning on line 187 in app/components/Eat.vue

View workflow job for this annotation

GitHub Actions / lint

Expected 1 line break before closing tag (`</p>`), but no line breaks found

Check warning on line 187 in app/components/Eat.vue

View workflow job for this annotation

GitHub Actions / lint

Expected 1 line break after opening tag (`<p>`), but no line breaks found

Check warning on line 187 in app/components/Eat.vue

View workflow job for this annotation

GitHub Actions / lint

UnoCSS utilities are not ordered
<button

Check failure on line 188 in app/components/Eat.vue

View workflow job for this annotation

GitHub Actions / typecheck

Argument of type '{ class: string; onClick: (opts?: AsyncDataExecuteOptions) => Promise<void>; }' is not assignable to parameter of type 'ButtonHTMLAttributes & ReservedProps & Record<string, unknown>'.
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"

Check warning on line 189 in app/components/Eat.vue

View workflow job for this annotation

GitHub Actions / lint

UnoCSS utilities are not ordered
@click="refresh"
>
重新加载
</button>
</div>
</div>

<div class="px-4 flex flex-col min-h-screen items-center justify-center relative z-20">
<!-- 数据为空状态 -->
<div v-else-if="!isDataReady" class="px-4 flex flex-col min-h-screen items-center justify-center relative z-20">
<div class="text-center">
<p class="text-gray-600 mb-4">📭 暂无菜单数据</p>

Check warning on line 200 in app/components/Eat.vue

View workflow job for this annotation

GitHub Actions / lint

Expected 1 line break before closing tag (`</p>`), but no line breaks found

Check warning on line 200 in app/components/Eat.vue

View workflow job for this annotation

GitHub Actions / lint

Expected 1 line break after opening tag (`<p>`), but no line breaks found
<button

Check failure on line 201 in app/components/Eat.vue

View workflow job for this annotation

GitHub Actions / typecheck

Argument of type '{ class: string; onClick: (opts?: AsyncDataExecuteOptions) => Promise<void>; }' is not assignable to parameter of type 'ButtonHTMLAttributes & ReservedProps & Record<string, unknown>'.
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
@click="refresh"
>
重新加载
</button>
</div>
</div>

<!-- 正常内容显示 -->
<div v-else class="px-4 flex flex-col min-h-screen items-center justify-center relative z-20">
<div class="mb-4 flex flex-wrap gap-3 items-center top-15 justify-center absolute">
<div class="flex flex-wrap gap-2 justify-center">
<button
key="all"
type="button"
class="text-xs font-medium px-2.5 py-0.5 border rounded-sm inline-flex gap-1 cursor-pointer select-none transition-all items-center"
:class="getTagColorClasses(-1, isAllSelected)" @click="toggleTag('all')"
key="all" type="button" class="btn-cate" :class="getTagColorClasses(-1, isAllSelected)"
@click="toggleTag('all')"
>
<span>全部</span>
<span flex items-center justify-center><span i-carbon:categories mr-1 inline-block />全部</span>
</button>
<button
v-for="(c, index) in categories" :key="c" type="button"
class="text-xs font-medium px-2.5 py-0.5 border rounded-sm inline-flex gap-1 cursor-pointer select-none transition-all items-center"
v-for="(c, index) in categories" :key="c" type="button" class="btn-cate"
:class="getTagColorClasses(index, selectedCategories.includes(c))" @click="toggleTag(c)"
>
<span v-if="getEmoji(c)">{{ getEmoji(c) }}</span>
{{ c }}
</button>
</div>
</div>
<div class="text-center w-full -mt-20">
<div class="text-center w-full">
<h1
class="text-[clamp(2rem,5vw,3rem)] text-gray-800 font-normal mb-6 whitespace-nowrap text-ellipsis overflow-hidden"
class="text-[#141D37] text-[clamp(2rem,4vw,2.8rem)] leading-tight font-semibold mb-5"
:class="{ 'animate-shake': shakeTitle }"
>
<span class="today">今天</span>
<span class="eat">吃</span>
<FoodItem :current-food="currentFood" />
<span class="punctuation">?</span>
</h1>
<p class="text-[15px] text-[#707486]">
发现美味灵感,开启今日好胃口
</p>

<button id="start" class="outline-none cursor-pointer" @click="togglePlay">
<FancyButton :text="isPlaying ? '停止' : '开始'" />
Expand All @@ -212,6 +249,10 @@
</template>

<style>
.btn-cate {
@apply text-xs font-medium px-3 py-2 border rounded-sm inline-flex gap-1 cursor-pointer select-none transition-all items-center;
}

/* 动画定义 */
@keyframes shake {
0% {
Expand Down
2 changes: 1 addition & 1 deletion app/components/FoodItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const props = defineProps<{
const nameText = computed(() => replaceText(props.currentFood?.name))

function onClickVideo() {
window.open(`https://search.bilibili.com/all?keyword=${props.currentFood?.name}`, '_blank')
window.open(`https://search.bilibili.com/all?keyword=${props.currentFood?.name}的做法`, '_blank')
}
</script>

Expand Down
15 changes: 15 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
services:
whattoeat:
build: .
container_name: whattoeat
ports:
- "3000:3000"

Check failure on line 6 in docker-compose.yml

View workflow job for this annotation

GitHub Actions / lint

Strings must use singlequote
environment:
# 可选:指向你自己的菜谱服务(支持 base URL 或完整 recipes.json URL)
# - RECIPES_URL=http://your-recipes-host:5000
# - RECIPES_URL=http://your-recipes-host:5000/recipes.json
- RECIPES_URL=${RECIPES_URL:-}
# 容器内监听地址(Docker 环境建议显式设置)
- NITRO_HOST=0.0.0.0
- NITRO_PORT=3000
restart: unless-stopped
61 changes: 41 additions & 20 deletions server/routes/api/recipes.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,57 @@
// server/api/recipes.ts
import type { Recipe } from '~/types'

// 获取远程菜谱
// 获取菜谱
async function fetchRecipes(): Promise<Recipe[]> {
try {
const baseURL = import.meta.dev ? 'http://localhost:3000' : 'https://eat.ryanuo.cc'
const recipes = await $fetch<Recipe[]>(`${baseURL}/recipes.json`)
return recipes as Recipe[]
}
catch (error) {
console.error('获取远程菜谱数据失败:', error)
return []
const baseURL = import.meta.dev
? 'http://localhost:3000'
: 'https://eat.ryanuo.cc'

const maxRetries = 3
const retryDelay = 1000

for (let i = 0; i < maxRetries; i++) {
try {
const recipes = await $fetch<Recipe[]>(
`${baseURL}/recipes.json`,
{
timeout: 10000,
},
)

if (!Array.isArray(recipes))
throw new Error('返回的数据格式不正确')

return recipes
}
catch (error) {
console.error(
`获取菜谱数据失败 (${i + 1}/${maxRetries})`,
error,
)

if (i < maxRetries - 1)
await new Promise(resolve => setTimeout(resolve, retryDelay))
}
}
}

// 获取所有分类
function getAllCategories(recipes: Recipe[]): string[] {
const categories = new Set<string>()
recipes?.forEach((r) => {
if (r.category)
categories.add(r.category)
throw createError({
statusCode: 502,
statusMessage: '上游菜谱服务不可用',
})
return [...categories]
}

export default defineEventHandler(async () => {
const recipes = await fetchRecipes()
const categories = getAllCategories(recipes)

return {
count: recipes.length,
categories,
categories: [
...new Set(
recipes
.map(r => r.category)
.filter(Boolean),
),
],
recipes,
}
})
Loading