Files
what-to-eat/apps/web/app/components/cook/RecipeDetailSheet.vue
T

336 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import type { RecipeMatch } from '@yunyoujun/cook'
import type { RecipeItem } from '~/types'
import { actionSheetController, alertController, toastController } from '@ionic/vue'
import { uniqueStuff } from '@yunyoujun/cook/recommend'
import { isFavorited, toggleFavorite } from '~/composables/store/favorite'
import { addRecipeHistory } from '~/composables/store/history'
import { getEmojisFromStuff } from '~/utils'
import { trackCookEvent } from '~/utils/analytics'
import { isCustomRecipe } from '~/utils/customRecipe'
import { promptText } from '~/utils/dialog'
import { feedbackKinds } from '~/utils/feedback'
import { getRecipeFeedbackLink, getRecipeLink, getRecipeVideoLink, openRecipe } from '~/utils/recipe'
import { yunleHaptic } from '~/utils/yunleHost'
/**
* 菜品详情:已有 / 缺少 / 常备分开列出,可以收藏或去 B 站看做法
*/
const props = defineProps<{
recipe?: RecipeItem
match?: RecipeMatch
}>()
const open = defineModel<boolean>('open', { required: true })
const stuff = computed(() => props.recipe ? uniqueStuff(props.recipe.stuff) : [])
const thumb = computed(() => props.recipe?.tags?.includes('杂烩') ? '🍲' : getEmojisFromStuff(stuff.value)[0] ?? '🍽️')
const meta = computed(() => {
if (!props.recipe)
return ''
const tools = props.recipe.tools?.map(tool => tool === '一口大锅' ? '炒锅' : tool).join(' / ') || '厨具未记录'
return props.recipe.difficulty ? `${tools} · ${props.recipe.difficulty}` : tools
})
const groups = computed(() => {
const match = props.match
if (!match)
return [{ label: '食材', items: stuff.value, tone: 'neutral' }]
return [
{ label: '已有', items: match.matchedStuff, tone: 'have' },
{ label: '还缺', items: match.missingStuff, tone: 'missing' },
{ label: '常备调料', items: match.pantryStuff, tone: 'neutral' },
{ label: '还能加', items: match.optionalStuff, tone: 'neutral' },
].filter(group => group.items.length)
})
const custom = computed(() => props.recipe && isCustomRecipe(props.recipe) ? props.recipe : undefined)
const link = computed(() => props.recipe ? getRecipeLink(props.recipe) : '')
const videoLink = computed(() => props.recipe ? getRecipeVideoLink(props.recipe) : '')
const linkLabel = '去 B 站搜做法'
function watchOriginalVideo() {
if (!videoLink.value)
return
trackCookEvent('click_recipe_video')
window.open(videoLink.value, '_blank')
}
const canFavorite = computed(() => typeof (props.recipe as { id?: number } | undefined)?.id === 'number')
const favorited = computed(() => props.recipe ? isFavorited(props.recipe) : false)
function onFavorite() {
if (!props.recipe)
return
toggleFavorite(props.recipe)
void yunleHaptic(favorited.value ? 'success' : 'light')
}
const feedback = useRecipeFeedback()
/** 选择问题类型后直接提交;提交不了时引导去 GitHub 填表 */
async function onFeedback() {
const recipe = props.recipe
if (!recipe)
return
if (typeof recipe.id !== 'number') {
window.open(getRecipeFeedbackLink(recipe), '_blank')
return
}
const sheet = await actionSheetController.create({
header: '这道菜有什么问题?',
buttons: [
...feedbackKinds.map(item => ({ text: item.label, data: item.kind })),
{ text: '取消', role: 'cancel' },
],
})
await sheet.present()
const { data: kind, role } = await sheet.onDidDismiss()
if (role === 'cancel' || !kind)
return
let note = ''
if (kind === 'other') {
const text = await promptText({ header: '具体是什么问题?', placeholder: '比如:用量不对、步骤有误', okText: '提交' })
if (text === undefined)
return
note = text
}
try {
await feedback.report({ recipeId: recipe.id, recipeName: recipe.name, kind, note })
void yunleHaptic('success')
const toast = await toastController.create({ message: '收到了,谢谢反馈', duration: 1800, position: 'top' })
await toast.present()
}
catch {
const alert = await alertController.create({
header: '暂时没能提交',
message: '可以到 GitHub 填写反馈表单,菜名和视频已经帮你填好。',
buttons: [
{ text: '取消', role: 'cancel' },
{ text: '去填写', handler: () => { window.open(getRecipeFeedbackLink(recipe), '_blank') } },
],
})
await alert.present()
}
}
function watchVideo() {
if (!props.recipe)
return
addRecipeHistory(props.recipe)
trackCookEvent('click_dish')
openRecipe(props.recipe)
}
function onPresented() {
if (open.value && props.recipe)
trackCookEvent('view_recipe_detail')
}
</script>
<template>
<ion-modal :is-open="open" :breakpoints="[0, 0.62, 1]" :initial-breakpoint="0.62" @did-present="onPresented" @did-dismiss="open = false">
<ion-header>
<ion-toolbar>
<ion-buttons slot="end">
<ion-button :strong="true" @click="open = false">
完成
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content v-if="recipe">
<div class="recipe-detail-hero">
<span class="recipe-detail-thumb" aria-hidden="true">{{ thumb }}</span>
<h2>{{ recipe.name }}</h2>
<p>{{ meta }}</p>
</div>
<div class="recipe-detail-actions">
<ion-button v-if="link" expand="block" class="recipe-detail-primary" @click="watchVideo">
<ion-icon slot="start" :icon="ioniconsPlayCircle" />
{{ linkLabel }}
</ion-button>
<ion-button v-if="videoLink" fill="clear" class="recipe-detail-fav" aria-label="看原视频" @click="watchOriginalVideo">
<ion-icon slot="icon-only" :icon="ioniconsPlayCircle" />
</ion-button>
<ion-button
v-if="custom"
:expand="link ? undefined : 'block'"
:fill="link ? 'clear' : 'solid'"
:class="link ? 'recipe-detail-fav' : 'recipe-detail-primary'"
:router-link="`/recipes/new?id=${custom.customId}`"
aria-label="编辑菜谱"
@click="open = false"
>
<ion-icon :slot="link ? 'icon-only' : 'start'" :icon="ioniconsCreateOutline" />
<template v-if="!link">
编辑菜谱
</template>
</ion-button>
<ion-button v-if="canFavorite" fill="clear" class="recipe-detail-fav" :aria-pressed="favorited" :aria-label="favorited ? '取消收藏' : '收藏'" @click="onFavorite">
<ion-icon slot="icon-only" :icon="favorited ? ioniconsStar : ioniconsStarOutline" color="warning" />
</ion-button>
</div>
<section v-for="group in groups" :key="group.label" :aria-label="group.label">
<h3 class="cook-group-header">
{{ group.label }} · {{ group.items.length }} 种
</h3>
<div class="recipe-detail-chips">
<span v-for="name in group.items" :key="name" class="recipe-detail-chip" :class="`is-${group.tone}`">{{ name }}</span>
</div>
</section>
<section v-if="custom?.note" aria-label="做法备注">
<h3 class="cook-group-header">
做法备注
</h3>
<p class="recipe-detail-text">
{{ custom.note }}
</p>
</section>
<p class="cook-group-footer recipe-detail-note">
<template v-if="custom">
这是你自己添加的菜谱,只保存在这台设备上。
</template>
<template v-else>
食材来自菜谱记录,用量和调味以视频为准。
<button type="button" class="recipe-detail-feedback" @click="onFeedback">
视频打不开或信息不对?反馈一下
</button>
</template>
</p>
</ion-content>
</ion-modal>
</template>
<style scoped>
.recipe-detail-text {
margin: 0 16px;
padding: 12px 16px;
border-radius: 10px;
background: var(--ion-item-background, #fff);
font-size: 16px;
line-height: 22px;
white-space: pre-wrap;
}
.recipe-detail-hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 24px 0;
text-align: center;
}
.recipe-detail-thumb {
display: inline-flex;
align-items: center;
justify-content: center;
width: 72px;
height: 72px;
border-radius: 18px;
background: var(--ion-item-background, #fff);
font-size: 40px;
animation: recipe-detail-pop 0.42s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.recipe-detail-hero h2 {
margin: 12px 0 4px;
font-size: 22px;
font-weight: 700;
line-height: 28px;
}
.recipe-detail-hero p {
margin: 0;
font-size: 15px;
color: var(--ion-color-medium);
}
.recipe-detail-actions {
display: flex;
align-items: center;
gap: 8px;
margin: 20px 16px 4px;
}
.recipe-detail-primary {
flex: 1;
margin: 0;
--border-radius: 12px;
min-height: 50px;
font-size: 17px;
font-weight: 600;
}
.recipe-detail-fav {
flex: none;
width: 50px;
height: 50px;
margin: 0;
--border-radius: 12px;
--background: var(--ion-item-background, #fff);
font-size: 22px;
}
.recipe-detail-fav[aria-pressed='true'] ion-icon {
animation: recipe-detail-pop 0.36s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.recipe-detail-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 16px;
padding: 12px;
border-radius: 10px;
background: var(--ion-item-background, #fff);
}
.recipe-detail-chip {
display: inline-flex;
align-items: center;
height: 30px;
padding: 0 12px;
border-radius: 15px;
font-size: 15px;
}
.recipe-detail-chip.is-have {
background: rgba(var(--ion-color-primary-rgb), 0.14);
color: var(--ion-color-primary);
font-weight: 500;
}
.recipe-detail-chip.is-missing {
background: color-mix(in srgb, var(--cook-orange) 14%, transparent);
color: var(--cook-orange);
font-weight: 500;
}
.recipe-detail-chip.is-neutral {
background: var(--cook-fill);
}
.recipe-detail-note {
margin-top: 8px;
padding-bottom: 32px;
}
.recipe-detail-feedback {
display: block;
margin: 4px auto 0;
padding: 4px 8px;
border: 0;
background: none;
color: var(--ion-color-primary);
font: inherit;
}
@keyframes recipe-detail-pop {
0% {
transform: scale(0.7);
}
60% {
transform: scale(1.08);
}
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.recipe-detail-thumb,
.recipe-detail-fav[aria-pressed='true'] ion-icon {
animation: none;
}
}
</style>