Files
oil-formula-calculator/frontend/src/components/RecipeDetailOverlay.vue
YoYo 81ec5987b3 feat: 配方卡片加入上传个人二维码功能
- RecipeDetailOverlay: 未上传二维码/背景图时,卡片上方显示提示横幅,下方出现「上传我的二维码」按钮,点击跳转到 MyDiary 品牌设置页并记录来源配方
- MyDiary: 新增二维码图片上传区域(直接上传图片文件,存为 base64 → PUT /api/brand qr_code 字段);上传成功后若有待返回配方则自动跳回配方卡片;修复 loadBrandSettings 字段名与后端不一致的问题
- RecipeSearch: 支持 ?openRecipe= 查询参数,页面挂载时自动打开指定配方卡片,实现从 MyDiary 上传后无缝返回

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:25:10 +00:00

1657 lines
42 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
<template>
<div class="detail-overlay" @click.self="$emit('close')">
<div class="detail-panel">
<!-- ==================== CARD VIEW ==================== -->
<div v-if="viewMode === 'card'" class="detail-card-view">
<!-- Top bar with close + edit -->
<div class="card-header">
<div class="card-top-actions">
<button class="action-btn action-btn-fav action-btn-sm" @click="handleToggleFavorite">
{{ isFav ? ' 已收藏' : ' 收藏' }}
</button>
<button v-if="!recipe._diary_id" class="action-btn action-btn-diary action-btn-sm" @click="saveToDiary">
📔 存为我的
</button>
</div>
<div style="flex:1"></div>
<button
v-if="canEditThisRecipe"
class="action-btn action-btn-sm"
@click="viewMode = 'editor'"
>编辑</button>
<button class="detail-close-btn" @click="$emit('close')"></button>
</div>
<!-- Language toggle -->
<div class="card-lang-toggle">
<button
class="lang-btn"
:class="{ active: cardLang === 'zh' }"
@click="switchLang('zh')"
>中文</button>
<button
class="lang-btn"
:class="{ active: cardLang === 'en' }"
@click="switchLang('en')"
>English</button>
</div>
<!-- QR / brand upload hint -->
<div v-if="showBrandHint" class="brand-upload-hint">
<span class="hint-icon"></span>
<span>上传你的专属二维码生成属于自己的配方卡片</span>
</div>
<!-- Card image (rendered by html2canvas) -->
<div v-show="!cardImageUrl" ref="cardRef" class="export-card">
<!-- Brand overlay layers -->
<img
v-if="brand.brand_bg"
:src="brand.brand_bg"
class="card-brand-bg"
crossorigin="anonymous"
/>
<img
v-if="brand.qr_code"
:src="brand.qr_code"
class="card-qr"
crossorigin="anonymous"
/>
<img
v-if="brand.brand_logo"
:src="brand.brand_logo"
class="card-logo"
crossorigin="anonymous"
/>
<div class="card-content">
<div class="card-brand-text">
{{ cardLang === 'en' ? 'doTERRA · Gifts of the Earth' : 'doTERRA · 来自大地的礼物' }}
</div>
<div class="card-title">
{{ getCardRecipeName() }}
</div>
<div class="card-divider"></div>
<!-- Ingredients (excluding coconut oil) -->
<ul class="card-ingredients">
<li v-for="(ing, i) in cardIngredients" :key="i">
<span class="card-oil-name">
{{ getCardOilName(ing.oil) }}
</span>
<span class="card-oil-drops">
{{ ing.drops }} {{ cardLang === 'en' ? 'drops' : '滴' }}
</span>
<span class="card-oil-cost">
{{ oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * ing.drops) }}
</span>
<span
v-if="hasRetailForOil(ing.oil) && retailPerDrop(ing.oil) > oilsStore.pricePerDrop(ing.oil)"
class="card-retail-strike"
>{{ oilsStore.fmtPrice(retailPerDrop(ing.oil) * ing.drops) }}</span>
</li>
</ul>
<!-- Dilution description -->
<div v-if="dilutionDesc" class="card-dilution">{{ dilutionDesc }}</div>
<!-- Note -->
<div v-if="recipe.note" class="card-note">
{{ cardLang === 'en' ? '📝 ' + recipe.note : '📝 ' + recipe.note }}
</div>
<!-- Total cost bar -->
<div class="card-total">
<div class="card-total-label">
{{ cardLang === 'en' ? 'Total Cost' : '配方总成本' }}
</div>
<div class="card-total-price">
{{ priceInfo.cost }}
<span v-if="priceInfo.hasRetail" class="card-total-retail">{{ priceInfo.retail }}</span>
</div>
</div>
<!-- Date -->
<div class="card-footer">
{{ cardLang === 'en' ? 'Date: ' : '制作日期:' }}{{ todayStr }}
</div>
</div>
</div>
<!-- Rendered card image -->
<div v-if="cardImageUrl" class="card-image-wrapper">
<img :src="cardImageUrl" class="card-rendered-image" />
</div>
<!-- Bottom action buttons -->
<div class="card-bottom-actions">
<button class="action-btn" @click="saveImage">💾 保存图片</button>
<button class="action-btn" @click="copyText">📋 复制文字</button>
<button
v-if="cardLang === 'en' && authStore.canManage"
class="action-btn"
@click="showTranslationEditor = true"
> 修改翻译</button>
<button
v-if="showBrandHint"
class="action-btn action-btn-qr"
@click="goUploadQr"
>📲 上传我的二维码</button>
</div>
<!-- Translation editor (inline) -->
<div v-if="showTranslationEditor" class="translation-editor">
<div class="translation-title">修改英文翻译</div>
<div class="translation-row">
<label>配方名:</label>
<input v-model="customRecipeNameEn" class="editor-input" />
</div>
<div v-for="(ing, i) in cardIngredients" :key="'tr-'+i" class="translation-row">
<label>{{ ing.oil }}:</label>
<input v-model="customOilNameEn[ing.oil]" class="editor-input" />
</div>
<div class="translation-actions">
<button class="action-btn" @click="showTranslationEditor = false">取消</button>
<button class="action-btn action-btn-primary" @click="applyTranslation">应用</button>
</div>
</div>
</div>
<!-- ==================== EDITOR VIEW ==================== -->
<div v-if="viewMode === 'editor'" class="detail-editor-view">
<!-- Header -->
<div class="editor-header">
<div style="flex:1;min-width:0">
<input v-model="editName" type="text" class="editor-name-input" placeholder="配方名称" />
</div>
<div class="editor-header-actions">
<button class="action-btn action-btn-primary action-btn-sm" @click="saveRecipe">💾 保存</button>
<button class="action-btn action-btn-sm" @click="previewFromEditor">👁 预览</button>
<button class="detail-close-btn" @click="$emit('close')"></button>
</div>
</div>
<!-- Tip -->
<div class="editor-tip">
💡 推荐按照单次用量椰子油10~20添加纯精油系统会根据容量和稀释比例自动计算
</div>
<!-- Ingredients table -->
<div class="editor-section">
<table class="editor-table">
<thead>
<tr>
<th>精油</th>
<th>滴数</th>
<th>单价/</th>
<th>小计</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(ing, i) in editIngredients" :key="i">
<td>
<select v-model="ing.oil" class="editor-select">
<option value="">选择精油</option>
<option v-for="name in oilsStore.oilNames" :key="name" :value="name">{{ name }}</option>
</select>
</td>
<td>
<input
v-model.number="ing.drops"
type="number"
min="0.5"
step="0.5"
class="editor-drops"
/>
</td>
<td class="ing-ppd">
{{ ing.oil ? oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil)) : '-' }}
</td>
<td class="ing-cost">
{{ ing.oil ? oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * (ing.drops || 0)) : '-' }}
</td>
<td>
<button class="remove-row-btn" @click="removeIngredient(i)"></button>
</td>
</tr>
</tbody>
</table>
<!-- Add ingredient row -->
<div v-if="showAddRow" class="add-ingredient-row">
<select v-model="newIngOil" class="editor-select">
<option value=""> 选择精油 </option>
<option v-for="name in oilsStore.oilNames" :key="name" :value="name">{{ name }}</option>
</select>
<input
v-model.number="newIngDrops"
type="number"
placeholder="滴数"
min="0.5"
step="0.5"
class="editor-drops"
/>
<button class="action-btn action-btn-primary action-btn-sm" @click="confirmAddIngredient">确认</button>
<button class="action-btn action-btn-sm" @click="showAddRow = false">取消</button>
</div>
<button v-else class="add-row-btn" @click="showAddRow = true">+ 添加精油</button>
</div>
<!-- Volume & Dilution controls -->
<div class="editor-section">
<label class="editor-label">容量与稀释</label>
<div class="volume-controls">
<button
class="volume-btn"
:class="{ active: selectedVolume === 'single' }"
@click="selectedVolume = 'single'"
>单次</button>
<button
class="volume-btn"
:class="{ active: selectedVolume === '5' }"
@click="selectedVolume = '5'"
>5ml</button>
<button
class="volume-btn"
:class="{ active: selectedVolume === '10' }"
@click="selectedVolume = '10'"
>10ml</button>
<button
class="volume-btn"
:class="{ active: selectedVolume === '30' }"
@click="selectedVolume = '30'"
>30ml</button>
<button
class="volume-btn"
:class="{ active: selectedVolume === 'custom' }"
@click="selectedVolume = 'custom'"
>自定义</button>
</div>
<!-- Custom volume input -->
<div v-if="selectedVolume === 'custom'" class="custom-volume-row">
<input
v-model.number="customVolumeValue"
type="number"
min="1"
class="editor-drops"
placeholder="数量"
/>
<select v-model="customVolumeUnit" class="editor-select" style="width:80px">
<option value="drops"></option>
<option value="ml">ml</option>
</select>
</div>
<!-- Dilution ratio -->
<div class="dilution-row">
<span class="dilution-label">稀释比例 1:</span>
<select v-model.number="dilutionRatio" class="editor-select" style="width:70px">
<option v-for="n in 20" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<button class="action-btn action-btn-primary action-btn-sm" @click="applyVolumeDilution" style="margin-top:8px">
应用到配方
</button>
<div class="hint" style="margin-top:8px">
{{ dilutionHint }}
</div>
</div>
<!-- Notes -->
<div class="editor-section">
<label class="editor-label">备注</label>
<textarea v-model="editNote" class="editor-textarea" rows="2" placeholder="配方备注..."></textarea>
</div>
<!-- Tags -->
<div class="editor-section">
<label class="editor-label">标签</label>
<div class="editor-tags">
<span v-for="tag in editTags" :key="tag" class="editor-tag">
{{ tag }}
<span class="tag-remove" @click="removeTag(tag)">×</span>
</span>
</div>
<!-- Candidate tags (from allTags, excluding already selected) -->
<div class="candidate-tags" v-if="candidateTags.length">
<span
v-for="tag in candidateTags"
:key="tag"
class="candidate-tag"
@click="addTag(tag)"
>+ {{ tag }}</span>
</div>
<!-- Manual tag input -->
<div class="tag-input-row">
<input
v-model="newTagInput"
type="text"
class="editor-input"
placeholder="添加新标签..."
@keydown.enter="addNewTag"
style="flex:1"
/>
<button class="action-btn action-btn-sm" @click="addNewTag" :disabled="!newTagInput.trim()">+</button>
</div>
</div>
<!-- Total cost -->
<div class="editor-total">
总计: {{ editPriceInfo.cost }}
<span v-if="editPriceInfo.hasRetail" class="editor-retail">
零售 {{ editPriceInfo.retail }}
</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
import html2canvas from 'html2canvas'
import { useOilsStore, DROPS_PER_ML, VOLUME_DROPS } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
import { useDiaryStore } from '../stores/diary'
import { api } from '../composables/useApi'
import { oilEn, recipeNameEn } from '../composables/useOilTranslation'
// TagPicker replaced with inline tag editing
const props = defineProps({
recipeIndex: { type: Number, required: true },
})
const emit = defineEmits(['close'])
const oilsStore = useOilsStore()
const recipesStore = useRecipesStore()
const authStore = useAuthStore()
const ui = useUiStore()
const diaryStore = useDiaryStore()
const router = useRouter()
// ---- View state ----
const viewMode = ref('card')
const cardRef = ref(null)
const cardImageUrl = ref(null)
const cardLang = ref('zh')
const showTranslationEditor = ref(false)
const customRecipeNameEn = ref('')
const customOilNameEn = ref({})
const generatingImage = ref(false)
// ---- Source recipe ----
const recipe = computed(() =>
recipesStore.recipes[props.recipeIndex] || { name: '', ingredients: [], tags: [], note: '' }
)
const canEditThisRecipe = computed(() => {
if (authStore.canEdit) return true
if (authStore.isLoggedIn && recipe.value._owner_id === authStore.user.id) return true
return false
})
const isFav = computed(() => recipesStore.isFavorite(recipe.value))
// Card ingredients: exclude coconut oil
const cardIngredients = computed(() =>
recipe.value.ingredients.filter(ing => ing.oil !== '椰子油')
)
// Coconut oil drops
const coconutDrops = computed(() => {
const coco = recipe.value.ingredients.find(ing => ing.oil === '椰子油')
return coco ? coco.drops : 0
})
// Total EO drops
const totalEoDrops = computed(() =>
cardIngredients.value.reduce((s, ing) => s + (ing.drops || 0), 0)
)
// Dilution description for card
const dilutionDesc = computed(() => {
if (coconutDrops.value <= 0 || totalEoDrops.value <= 0) return ''
const totalDrops = coconutDrops.value + totalEoDrops.value
const vol = Math.round(totalDrops / DROPS_PER_ML)
const ratio = Math.round(coconutDrops.value / totalEoDrops.value)
const isEn = cardLang.value === 'en'
if (vol >= 5) {
return isEn
? `For ${vol}ml bottle: ${totalEoDrops.value} drops essential oils, fill with coconut oil. Dilution 1:${ratio}`
: `该配方适用于 ${vol}ml 瓶,其中纯精油 ${totalEoDrops.value} 滴,其余用椰子油填满,稀释比例为 1:${ratio}`
}
return isEn
? `Single use (${totalDrops} drops): ${totalEoDrops.value} drops essential oils + ${coconutDrops.value} drops coconut oil. Dilution 1:${ratio}`
: `该配方适用于单次用量(共${totalDrops}滴),其中纯精油 ${totalEoDrops.value} 滴,椰子油 ${coconutDrops.value} 滴,稀释比例为 1:${ratio}`
})
const priceInfo = computed(() => oilsStore.fmtCostWithRetail(recipe.value.ingredients))
// Today string
const todayStr = computed(() => {
const d = new Date()
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`
})
// Retail helpers
function hasRetailForOil(name) {
const meta = oilsStore.oilsMeta[name]
return meta && meta.retailPrice && meta.dropCount
}
function retailPerDrop(name) {
const meta = oilsStore.oilsMeta[name]
if (meta && meta.retailPrice && meta.dropCount) {
return meta.retailPrice / meta.dropCount
}
return oilsStore.pricePerDrop(name)
}
// ---- Brand data ----
const brand = ref({})
async function loadBrand() {
try {
brand.value = await api.get('/api/brand')
} catch {
brand.value = {}
}
}
// Whether to show the brand/QR upload hint
const showBrandHint = computed(() =>
authStore.isLoggedIn && !!brand.value && !brand.value.qr_code && !brand.value.brand_bg
)
function goUploadQr() {
if (recipe.value._id) {
localStorage.setItem('oil_return_recipe_id', recipe.value._id)
}
router.push('/mydiary')
}
// ---- Card image generation ----
async function generateCardImage() {
if (!cardRef.value || generatingImage.value) return
generatingImage.value = true
cardImageUrl.value = null
await nextTick()
// Small delay for rendering
await new Promise(r => setTimeout(r, 100))
try {
const canvas = await html2canvas(cardRef.value, {
backgroundColor: null,
scale: 3,
useCORS: true,
allowTaint: false,
})
cardImageUrl.value = canvas.toDataURL('image/png')
} catch (e) {
console.error('Card generation failed:', e)
ui.showToast('卡片生成失败')
} finally {
generatingImage.value = false
}
}
function switchLang(lang) {
cardLang.value = lang
cardImageUrl.value = null
nextTick(() => generateCardImage())
}
async function saveImage() {
if (!cardImageUrl.value) {
await generateCardImage()
}
if (!cardImageUrl.value) return
const link = document.createElement('a')
link.download = `${recipe.value.name || '配方'}_配方卡.png`
link.href = cardImageUrl.value
link.click()
ui.showToast('已保存图片')
}
function copyText() {
const ings = cardIngredients.value
const lines = ings.map(ing => {
const cost = oilsStore.pricePerDrop(ing.oil) * ing.drops
return `${ing.oil} ${ing.drops}${oilsStore.fmtPrice(cost)}`
})
const total = priceInfo.value.cost
const text = [
recipe.value.name,
'---',
...lines,
'---',
`总成本: ${total}`,
recipe.value.note ? `备注: ${recipe.value.note}` : '',
].filter(Boolean).join('\n')
navigator.clipboard.writeText(text).then(() => {
ui.showToast('已复制到剪贴板')
}).catch(() => {
ui.showToast('复制失败')
})
}
async function applyTranslation() {
showTranslationEditor.value = false
// Persist en_name to backend
if (recipe.value._id && customRecipeNameEn.value) {
try {
await api.put(`/api/recipes/${recipe.value._id}`, {
en_name: customRecipeNameEn.value,
version: recipe.value._version,
})
ui.showToast('翻译已保存')
} catch (e) {
ui.showToast('翻译保存失败')
}
}
cardImageUrl.value = null
nextTick(() => generateCardImage())
}
// Override translation getters for card rendering
function getCardOilName(name) {
if (cardLang.value === 'en') {
return customOilNameEn.value[name] || oilEn(name) || name
}
return name
}
function getCardRecipeName() {
if (cardLang.value === 'en') {
return customRecipeNameEn.value || recipe.value.en_name || recipeNameEn(recipe.value.name)
}
return recipe.value.name
}
// ---- Favorite ----
async function handleToggleFavorite() {
if (!authStore.isLoggedIn) {
ui.openLogin()
return
}
if (!recipe.value._id) {
ui.showToast('该配方无法收藏')
return
}
const wasFav = isFav.value
try {
await recipesStore.toggleFavorite(recipe.value._id)
ui.showToast(wasFav ? '已取消收藏' : '已收藏')
} catch (e) {
ui.showToast('操作失败')
}
}
// ---- Save to diary ----
async function saveToDiary() {
if (!authStore.isLoggedIn) {
ui.openLogin()
return
}
const name = prompt('保存为我的配方,名称:', recipe.value.name)
if (!name) return
try {
await api.post('/api/diary', {
name,
source_recipe_id: recipe.value._id || null,
ingredients: recipe.value.ingredients.map(i => ({ oil: i.oil, drops: i.drops })),
note: recipe.value.note || '',
})
ui.showToast('已保存到「我的配方日记」')
} catch (e) {
ui.showToast('保存失败: ' + (e?.message || '未知错误'))
}
}
// ==================== EDITOR ====================
const editName = ref('')
const editNote = ref('')
const editTags = ref([])
const editIngredients = ref([])
const showAddRow = ref(false)
const newIngOil = ref('')
const newIngDrops = ref(1)
const newTagInput = ref('')
// Volume & dilution
const selectedVolume = ref('single')
const customVolumeValue = ref(100)
const customVolumeUnit = ref('drops')
const dilutionRatio = ref(3)
const editPriceInfo = computed(() =>
oilsStore.fmtCostWithRetail(editIngredients.value.filter(i => i.oil))
)
const candidateTags = computed(() =>
recipesStore.allTags.filter(t => !editTags.value.includes(t))
)
const dilutionHint = computed(() => {
const vol = selectedVolume.value
let totalDrops
if (vol === 'single') {
const cocoDrops = 10 * dilutionRatio.value
const eoDrops = 10
totalDrops = cocoDrops + eoDrops
return `单次用量:椰子油约 ${cocoDrops} 滴 + 纯精油约 ${eoDrops} 滴,共 ${totalDrops} 滴 (${(totalDrops / DROPS_PER_ML).toFixed(1)}ml),稀释比例 1:${dilutionRatio.value}`
}
if (vol === 'custom') {
if (customVolumeUnit.value === 'ml') {
totalDrops = Math.round(customVolumeValue.value * DROPS_PER_ML)
} else {
totalDrops = customVolumeValue.value
}
} else if (vol === '5') {
totalDrops = Math.round(5 * DROPS_PER_ML)
} else if (vol === '10') {
totalDrops = Math.round(10 * DROPS_PER_ML)
} else if (vol === '30') {
totalDrops = Math.round(30 * DROPS_PER_ML)
} else {
totalDrops = 100
}
const eoDrops = Math.round(totalDrops / (1 + dilutionRatio.value))
const cocoDrops = totalDrops - eoDrops
return `总容量 ${totalDrops} 滴 (${(totalDrops / DROPS_PER_ML).toFixed(1)}ml),纯精油约 ${eoDrops} 滴 + 椰子油约 ${cocoDrops} 滴,稀释比例 1:${dilutionRatio.value}`
})
onMounted(() => {
const r = recipe.value
editName.value = r.name
editNote.value = r.note || ''
editTags.value = [...(r.tags || [])]
editIngredients.value = (r.ingredients || []).map(i => ({ oil: i.oil, drops: i.drops }))
// Init translation defaults
customRecipeNameEn.value = r.en_name || recipeNameEn(r.name)
const enMap = {}
;(r.ingredients || []).forEach(ing => {
enMap[ing.oil] = oilEn(ing.oil) || ing.oil
})
customOilNameEn.value = enMap
loadBrand()
nextTick(() => generateCardImage())
})
function addIngredient() {
editIngredients.value.push({ oil: '', drops: 1 })
}
function removeIngredient(index) {
editIngredients.value.splice(index, 1)
}
function confirmAddIngredient() {
if (!newIngOil.value) {
ui.showToast('请选择精油')
return
}
if (!newIngDrops.value || newIngDrops.value <= 0) {
ui.showToast('请输入滴数')
return
}
editIngredients.value.push({ oil: newIngOil.value, drops: newIngDrops.value })
newIngOil.value = ''
newIngDrops.value = 1
showAddRow.value = false
}
function removeTag(tag) {
editTags.value = editTags.value.filter(t => t !== tag)
}
function addTag(tag) {
if (!editTags.value.includes(tag)) {
editTags.value.push(tag)
}
}
function addNewTag() {
const tag = newTagInput.value.trim()
if (!tag) return
if (!editTags.value.includes(tag)) {
editTags.value.push(tag)
}
newTagInput.value = ''
}
// Volume / dilution application
function applyVolumeDilution() {
// Get essential oil ingredients only (not coconut)
const eoIngs = editIngredients.value.filter(i => i.oil && i.oil !== '椰子油')
if (eoIngs.length === 0) {
ui.showToast('请先添加精油')
return
}
let targetTotalDrops
const vol = selectedVolume.value
if (vol === 'single') {
// Single use: coconut = 10 drops * ratio, EO total = 10 drops
const targetEoDrops = 10
const currentEoTotal = eoIngs.reduce((s, i) => s + (i.drops || 0), 0)
if (currentEoTotal <= 0) return
const scale = targetEoDrops / currentEoTotal
eoIngs.forEach(ing => {
ing.drops = Math.max(0.5, Math.round(ing.drops * scale * 2) / 2)
})
// Set coconut oil
const actualEoTotal = eoIngs.reduce((s, i) => s + i.drops, 0)
setCoconutDrops(actualEoTotal * dilutionRatio.value)
ui.showToast('已应用单次用量')
return
}
if (vol === 'custom') {
if (customVolumeUnit.value === 'ml') {
targetTotalDrops = Math.round(customVolumeValue.value * DROPS_PER_ML)
} else {
targetTotalDrops = customVolumeValue.value
}
} else if (vol === '5') {
targetTotalDrops = Math.round(5 * DROPS_PER_ML)
} else if (vol === '10') {
targetTotalDrops = Math.round(10 * DROPS_PER_ML)
} else if (vol === '30') {
targetTotalDrops = Math.round(30 * DROPS_PER_ML)
} else {
return
}
// Target EO drops based on dilution ratio
const targetEoDrops = Math.round(targetTotalDrops / (1 + dilutionRatio.value))
const currentEoTotal = eoIngs.reduce((s, i) => s + (i.drops || 0), 0)
if (currentEoTotal <= 0) return
const scale = targetEoDrops / currentEoTotal
eoIngs.forEach(ing => {
ing.drops = Math.max(0.5, Math.round(ing.drops * scale * 2) / 2)
})
const actualEoTotal = eoIngs.reduce((s, i) => s + i.drops, 0)
setCoconutDrops(targetTotalDrops - actualEoTotal)
ui.showToast('已应用容量设置')
}
function setCoconutDrops(drops) {
drops = Math.max(0, Math.round(drops))
const idx = editIngredients.value.findIndex(i => i.oil === '椰子油')
if (idx >= 0) {
editIngredients.value[idx].drops = drops
} else if (drops > 0) {
editIngredients.value.push({ oil: '椰子油', drops })
}
}
function previewFromEditor() {
// Temporarily update recipe view with editor data, switch to card
// We just switch to card mode; the card shows the saved recipe
viewMode.value = 'card'
cardImageUrl.value = null
nextTick(() => generateCardImage())
}
async function saveRecipe() {
const ingredients = editIngredients.value.filter(i => i.oil && i.drops > 0)
if (!editName.value.trim()) {
ui.showToast('请输入配方名称')
return
}
if (ingredients.length === 0) {
ui.showToast('请至少添加一种精油')
return
}
try {
const payload = {
...recipe.value,
name: editName.value.trim(),
note: editNote.value.trim(),
tags: editTags.value,
ingredients: ingredients.map(i => ({ oil_name: i.oil, drops: i.drops })),
}
await recipesStore.saveRecipe(payload)
// Reload recipes so the data is fresh when re-opened
await recipesStore.loadRecipes()
ui.showToast('保存成功')
emit('close')
} catch (e) {
ui.showToast('保存失败: ' + (e?.message || '未知错误'))
}
}
</script>
<style scoped>
.detail-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 5500;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.detail-panel {
background: #fff;
border-radius: 18px;
width: 520px;
max-width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
position: relative;
}
/* Card header */
.card-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
}
.detail-close-btn {
background: rgba(0, 0, 0, 0.06);
border: none;
font-size: 14px;
color: var(--text-light, #9a8570);
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
border-radius: 50%;
transition: all 0.2s;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.detail-close-btn:hover {
color: var(--text-dark, #2c2416);
background: rgba(0, 0, 0, 0.1);
}
/* ==================== CARD VIEW ==================== */
.detail-card-view {
padding: 20px;
}
.card-top-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.action-btn-fav {
background: var(--gold-light, #f0e4c0);
color: var(--brown, #6b4f3a);
border-color: var(--gold, #c9a84c);
}
.action-btn-fav:hover {
background: var(--gold, #c9a84c);
color: #fff;
}
.action-btn-diary {
background: var(--sage-mist, #eef4ee);
color: var(--sage-dark, #5a7d5e);
border-color: var(--sage-light, #c8ddc9);
}
.action-btn-diary:hover {
background: var(--sage-light, #c8ddc9);
}
/* Export card (for html2canvas) */
.export-card {
background: linear-gradient(145deg, #faf7f0 0%, #f5ede0 100%);
border-radius: 20px;
padding: 36px;
font-family: 'Noto Serif SC', serif;
max-width: 480px;
border: 1px solid #e0ccaa;
position: relative;
overflow: hidden;
}
.export-card::before {
content: '';
position: absolute;
top: -40px;
right: -40px;
width: 180px;
height: 180px;
background: radial-gradient(circle, rgba(122, 158, 126, 0.15) 0%, transparent 70%);
border-radius: 50%;
}
.export-card::after {
content: '';
position: absolute;
bottom: -30px;
left: -30px;
width: 140px;
height: 140px;
background: radial-gradient(circle, rgba(201, 168, 76, 0.12) 0%, transparent 70%);
border-radius: 50%;
}
.card-content {
position: relative;
z-index: 2;
}
/* Brand overlays */
.card-brand-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.06;
z-index: 0;
pointer-events: none;
}
.card-qr {
position: absolute;
top: 16px;
right: 16px;
width: 64px;
height: 64px;
object-fit: contain;
z-index: 3;
opacity: 0.85;
}
.card-logo {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
height: 28px;
object-fit: contain;
z-index: 3;
opacity: 0.3;
}
.card-brand-text {
font-size: 11px;
letter-spacing: 3px;
color: var(--sage, #7a9e7e);
margin-bottom: 8px;
font-family: 'Noto Sans SC', sans-serif;
}
.card-title {
font-size: 26px;
font-weight: 700;
color: var(--text-dark, #2c2416);
margin-bottom: 6px;
line-height: 1.3;
}
.card-divider {
width: 48px;
height: 2px;
background: linear-gradient(90deg, var(--sage, #7a9e7e), var(--gold, #c9a84c));
border-radius: 2px;
margin: 14px 0;
}
.card-ingredients {
list-style: none;
margin: 0 0 20px;
padding: 0;
}
.card-ingredients li {
display: flex;
align-items: center;
padding: 9px 0;
border-bottom: 1px solid rgba(180, 150, 100, 0.15);
font-size: 14px;
}
.card-ingredients li:last-child {
border-bottom: none;
}
.card-oil-name {
flex: 1;
color: var(--text-dark, #2c2416);
font-weight: 500;
}
.card-oil-drops {
width: 60px;
text-align: right;
color: var(--sage-dark, #5a7d5e);
font-size: 13px;
}
.card-oil-cost {
width: 60px;
text-align: right;
color: var(--text-light, #9a8570);
font-size: 12px;
}
.card-retail-strike {
width: 55px;
text-align: right;
text-decoration: line-through;
color: var(--text-light, #9a8570);
font-size: 10px;
opacity: 0.6;
}
.card-dilution {
font-size: 12px;
color: var(--brown-light, #c4a882);
margin-bottom: 14px;
line-height: 1.6;
padding: 8px 12px;
background: rgba(201, 168, 76, 0.08);
border-radius: 8px;
}
.card-note {
font-size: 12px;
color: var(--brown-light, #c4a882);
margin-bottom: 18px;
line-height: 1.5;
}
.card-total {
background: linear-gradient(135deg, var(--sage, #7a9e7e), #5a7d5e);
border-radius: 12px;
padding: 14px 20px;
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
}
.card-total-label {
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
letter-spacing: 1px;
font-family: 'Noto Sans SC', sans-serif;
}
.card-total-price {
color: white;
font-size: 20px;
font-weight: 700;
}
.card-total-retail {
text-decoration: line-through;
opacity: 0.6;
font-size: 13px;
font-weight: 400;
margin-left: 6px;
}
.card-footer {
margin-top: 16px;
text-align: center;
font-size: 11px;
color: var(--text-light, #9a8570);
letter-spacing: 1px;
font-family: 'Noto Sans SC', sans-serif;
}
/* Card image display */
.card-image-wrapper {
text-align: center;
}
.card-rendered-image {
max-width: 100%;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
/* Language toggle */
.card-lang-toggle {
display: flex;
justify-content: center;
gap: 0;
margin-bottom: 12px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 10px;
overflow: hidden;
align-self: center;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
.lang-btn {
padding: 6px 16px;
border: none;
border-radius: 0;
background: #fff;
font-size: 12px;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
color: var(--text-mid, #5a4a35);
color: var(--text-mid, #5a4a35);
}
.lang-btn.active {
background: var(--sage, #7a9e7e);
color: #fff;
border-color: var(--sage, #7a9e7e);
}
.lang-btn:hover:not(.active) {
background: var(--sage-mist, #eef4ee);
}
/* Brand upload hint banner */
.brand-upload-hint {
display: flex;
align-items: center;
gap: 8px;
background: linear-gradient(135deg, #eef4ee, #f5f0e8);
border: 1.5px dashed var(--sage-light, #c8ddc9);
border-radius: 10px;
padding: 10px 16px;
margin-bottom: 14px;
font-size: 13px;
color: var(--sage-dark, #5a7d5e);
font-weight: 500;
animation: hint-pop 0.3s ease;
}
.hint-icon {
font-size: 18px;
flex-shrink: 0;
}
@keyframes hint-pop {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.action-btn-qr {
background: linear-gradient(135deg, #c8ddc9, var(--sage, #7a9e7e));
color: #fff;
border-color: transparent;
}
.action-btn-qr:hover {
opacity: 0.88;
}
/* Card bottom actions */
.card-bottom-actions {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
/* Translation editor */
.translation-editor {
margin-top: 16px;
padding: 16px;
background: var(--cream, #faf6f0);
border-radius: 12px;
border: 1px solid var(--border, #e0d4c0);
}
.translation-title {
font-size: 14px;
font-weight: 600;
color: var(--text-dark, #2c2416);
margin-bottom: 12px;
}
.translation-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.translation-row label {
font-size: 13px;
color: var(--text-mid, #5a4a35);
min-width: 70px;
text-align: right;
}
.translation-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
/* ==================== EDITOR VIEW ==================== */
.detail-editor-view {
padding: 20px;
}
.editor-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.editor-name-input {
flex: 1;
padding: 10px 14px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 10px;
font-size: 16px;
font-weight: 600;
font-family: 'Noto Serif SC', serif;
outline: none;
color: var(--text-dark, #2c2416);
box-sizing: border-box;
}
.editor-name-input:focus {
border-color: var(--sage, #7a9e7e);
}
.editor-header-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.editor-tip {
font-size: 12px;
color: var(--text-light, #9a8570);
background: var(--gold-light, #f0e4c0);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 16px;
line-height: 1.5;
}
.editor-section {
margin-bottom: 18px;
}
.editor-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-light, #9a8570);
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.editor-input {
width: 100%;
padding: 10px 14px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 10px;
font-size: 14px;
outline: none;
font-family: inherit;
box-sizing: border-box;
color: var(--text-dark, #2c2416);
}
.editor-input:focus {
border-color: var(--sage, #7a9e7e);
}
.editor-textarea {
width: 100%;
padding: 10px 14px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 10px;
font-size: 14px;
outline: none;
font-family: inherit;
box-sizing: border-box;
resize: vertical;
color: var(--text-dark, #2c2416);
}
.editor-textarea:focus {
border-color: var(--sage, #7a9e7e);
}
.editor-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.editor-table th {
text-align: left;
font-weight: 600;
color: var(--text-light, #9a8570);
padding: 8px 6px;
font-size: 12px;
border-bottom: 2px solid var(--border, #e0d4c0);
letter-spacing: 0.5px;
}
.editor-table td {
padding: 8px 6px;
border-bottom: 1px solid var(--border, #e0d4c0);
vertical-align: middle;
}
.editor-table tr:hover td {
background: var(--sage-mist, #eef4ee);
}
.editor-select {
width: 100%;
padding: 7px 8px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 8px;
font-size: 13px;
font-family: inherit;
background: #fff;
outline: none;
color: var(--text-dark, #2c2416);
}
.editor-select:focus {
border-color: var(--sage, #7a9e7e);
}
.editor-drops {
width: 70px;
padding: 7px 10px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 8px;
font-size: 13px;
text-align: center;
font-family: inherit;
outline: none;
color: var(--text-dark, #2c2416);
}
.editor-drops:focus {
border-color: var(--sage, #7a9e7e);
}
.ing-ppd {
font-size: 12px;
color: var(--text-light, #9a8570);
white-space: nowrap;
}
.ing-cost {
font-size: 12px;
color: var(--sage-dark, #5a7d5e);
white-space: nowrap;
font-weight: 500;
}
.remove-row-btn {
background: none;
border: none;
color: #c0392b;
font-size: 16px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: background 0.2s;
}
.remove-row-btn:hover {
background: #fdf0ee;
}
.add-ingredient-row {
display: flex;
gap: 10px;
align-items: center;
margin-top: 12px;
flex-wrap: wrap;
}
.add-row-btn {
margin-top: 10px;
background: none;
border: 1.5px dashed var(--border, #e0d4c0);
border-radius: 8px;
padding: 9px 16px;
font-size: 13px;
color: var(--text-light, #9a8570);
cursor: pointer;
font-family: inherit;
width: 100%;
transition: all 0.15s;
}
.add-row-btn:hover {
border-color: var(--sage, #7a9e7e);
color: var(--sage-dark, #5a7d5e);
}
/* Volume controls */
.volume-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.volume-btn {
padding: 7px 16px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 10px;
background: #fff;
font-size: 13px;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
color: var(--text-mid, #5a4a35);
}
.volume-btn.active {
background: var(--sage, #7a9e7e);
color: #fff;
border-color: var(--sage, #7a9e7e);
}
.volume-btn:hover:not(.active) {
border-color: var(--sage, #7a9e7e);
}
.custom-volume-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 10px;
}
.dilution-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.dilution-label {
font-size: 13px;
color: var(--text-mid, #5a4a35);
font-weight: 500;
}
.hint {
font-size: 12px;
color: var(--text-light, #9a8570);
line-height: 1.6;
}
/* Tags */
.editor-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-bottom: 8px;
}
.editor-tag {
font-size: 12px;
padding: 4px 10px;
border-radius: 20px;
background: var(--sage, #7a9e7e);
color: #fff;
display: inline-flex;
align-items: center;
gap: 4px;
}
.tag-remove {
cursor: pointer;
font-size: 14px;
opacity: 0.7;
line-height: 1;
}
.tag-remove:hover {
opacity: 1;
}
.candidate-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.candidate-tag {
font-size: 12px;
padding: 4px 10px;
border-radius: 20px;
background: var(--sage-mist, #eef4ee);
color: var(--sage-dark, #5a7d5e);
cursor: pointer;
transition: all 0.15s;
border: 1px solid transparent;
}
.candidate-tag:hover {
border-color: var(--sage, #7a9e7e);
background: var(--sage-light, #c8ddc9);
}
.tag-input-row {
display: flex;
gap: 8px;
align-items: center;
}
/* Total */
.editor-total {
font-size: 18px;
font-weight: 700;
color: var(--sage-dark, #5a7d5e);
padding: 14px 0;
border-top: 2px solid var(--border, #e0d4c0);
}
.editor-retail {
color: var(--text-light, #9a8570);
font-size: 13px;
font-weight: 400;
margin-left: 8px;
}
/* Shared action buttons */
.action-btn {
padding: 9px 20px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 10px;
background: #fff;
font-size: 13px;
cursor: pointer;
font-family: inherit;
color: var(--text-mid, #5a4a35);
transition: all 0.15s;
white-space: nowrap;
}
.action-btn:hover {
background: var(--cream, #faf6f0);
}
.action-btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, var(--sage, #7a9e7e) 100%);
color: #fff;
border-color: transparent;
}
.action-btn-primary:hover {
opacity: 0.9;
background: linear-gradient(135deg, #7ec6a4 0%, var(--sage, #7a9e7e) 100%);
}
.action-btn-sm {
padding: 6px 14px;
font-size: 12px;
}
.action-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 600px) {
.detail-panel {
width: 100%;
max-height: 95vh;
border-radius: 14px;
}
.export-card {
padding: 24px 16px;
}
.card-title {
font-size: 20px;
}
.editor-header {
flex-direction: column;
align-items: stretch;
}
.editor-header-actions {
justify-content: flex-end;
}
}
</style>