Files
oil-formula-calculator/frontend/src/components/RecipeDetailOverlay.vue
Hera Zhao b82ba10ea5
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 14s
Test / e2e-test (push) Successful in 52s
fix: 英文配方1滴显示 drop 而非 drops
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:52:07 +00:00

2036 lines
56 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" @mousedown.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">
<template v-if="!props.isDiary">
<button class="action-btn action-btn-fav action-btn-sm" @click="handleToggleFavorite">
{{ isFav ? ' 已收藏' : ' 收藏' }}
</button>
<button class="action-btn action-btn-diary action-btn-sm" @click="saveToDiary">
📔 存为我的
</button>
</template>
</div>
<div style="flex:1"></div>
<button class="detail-close-btn" @click="handleClose"></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>
<!-- Volume selector (only in editor mode) -->
<div v-if="viewMode === 'editor'" class="card-volume-toggle">
<button
v-for="(drops, ml) in VOLUME_DROPS"
:key="ml"
class="volume-btn"
:class="{ active: selectedCardVolume === ml }"
@click="onCardVolumeChange(ml)"
>{{ ml === '单次' ? '单次' : ml + 'ml' }}</button>
</div>
<!-- Card image (rendered by html2canvas) -->
<div v-show="!cardImageUrl" ref="cardRef" class="export-card">
<!-- Background image overlay -->
<div v-if="brand.brand_bg" style="position:absolute;inset:0;width:100%;height:100%;background-size:cover;background-position:center;opacity:0.12;pointer-events:none;z-index:0" :style="{ backgroundImage: `url('${brand.brand_bg}')` }"></div>
<!-- QR: top-right -->
<div v-if="brand.qr_code" style="position:absolute;top:36px;right:36px;display:flex;flex-direction:column;gap:3px;z-index:3" :style="{ alignItems: (brand.brand_align === 'left' ? 'flex-start' : brand.brand_align === 'right' ? 'flex-end' : 'center') }">
<img :src="brand.qr_code" crossorigin="anonymous" style="width:54px;height:54px;object-fit:cover;border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,0.1)" />
<div v-if="brand.brand_name" :style="{ textAlign: brand.brand_align || 'center' }" style="font-size:7px;color:var(--text-light);line-height:1.3;max-width:68px;white-space:pre-line">{{ brand.brand_name }}</div>
</div>
<!-- Card content -->
<div style="position:relative;z-index:2">
<div class="ec-subtitle">
{{ cardLang === 'en' ? 'doTERRA · Gifts of the Earth' : 'doTERRA · 来自大地的礼物' }}
</div>
<div class="ec-title" :style="{ fontSize: cardTitleSize }">{{ getCardRecipeName() }}</div>
<div style="width:80px;height:2px;background:linear-gradient(90deg,var(--sage),var(--gold));border-radius:2px;margin:14px 0"></div>
<ul style="list-style:none;margin-bottom:20px;padding:0">
<li v-for="(ing, i) in cardIngredients" :key="i" class="ec-ing">
<span class="ec-oil-name">{{ getCardOilName(ing.oil) }}</span>
<span class="ec-drops">{{ ing.drops }} {{ cardLang === 'en' ? (ing.drops === 1 ? 'drop' : 'drops') : '滴' }}</span>
<span class="ec-cost">{{ oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * ing.drops) }}</span>
<span v-if="hasRetailForOil(ing.oil) && retailPerDrop(ing.oil) > oilsStore.pricePerDrop(ing.oil)" class="ec-retail">{{ oilsStore.fmtPrice(retailPerDrop(ing.oil) * ing.drops) }}</span>
</li>
</ul>
<div v-if="dilutionDesc" style="padding:10px 14px;background:rgba(180,150,100,0.08);border-radius:10px;font-size:12px;color:var(--text-mid);margin-bottom:12px">{{ dilutionDesc }}</div>
<div v-if="displayRecipe.note" style="font-size:12px;color:var(--brown-light);margin-bottom:12px;font-style:italic">📝 {{ displayRecipe.note }}</div>
<div class="ec-total-bar">
<span style="color:rgba(255,255,255,0.85);font-size:12px;letter-spacing:1px">{{ cardLang === 'en' ? 'Total Cost' : '配方总成本' }}</span>
<span style="color:white;font-size:17px;font-weight:700">{{ priceInfo.cost }}<span v-if="priceInfo.hasRetail" style="text-decoration:line-through;opacity:0.6;font-size:11px;margin-left:4px">{{ priceInfo.retail }}</span></span>
</div>
<!-- Logo left + Date right -->
<div class="ec-bottom">
<img v-if="brand.brand_logo" :src="brand.brand_logo" crossorigin="anonymous" class="ec-logo" />
<span v-else></span>
<span class="ec-date">{{ cardLang === 'en' ? 'Date: ' : '制作日期:' }}{{ todayStr }}</span>
</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.isAdmin"
class="action-btn"
@click="openTranslationEditor"
> 修改翻译</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="handleEditorClose"></button>
</div>
</div>
<!-- Volume selector -->
<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 === '15' }" @click="selectedVolume = '15'">15ml</button>
<button class="volume-btn" :class="{ active: selectedVolume === '20' }" @click="selectedVolume = '20'">20ml</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>
<div v-if="selectedVolume === 'custom'" class="custom-volume-row">
<input v-model.number="customVolumeValue" type="number" min="1" class="editor-drops" placeholder="ml" />
<span style="font-size:12px;color:#999">ml</span>
</div>
<div class="dilution-row">
<span class="dilution-label">参考比例 1:</span>
<select v-model.number="dilutionRatio" class="editor-select" style="width:60px">
<option v-for="n in [3,4,5,6,7,8,9,10,12,15,20]" :key="n" :value="n">{{ n }}</option>
</select>
<span class="ratio-hint">纯精油总数约为 {{ editorSuggestedEo }} 现在为 {{ editorEoDrops }} </span>
</div>
</div>
<!-- Ingredients table (EO only, coconut at bottom) -->
<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 editEoIngredients" :key="'eo-'+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="editIngredients.splice(editIngredients.indexOf(ing), 1)"></button></td>
</tr>
<!-- Coconut oil row -->
<tr v-if="editCocoRow" class="coco-row">
<td><span class="coco-label">椰子油</span></td>
<td>
<template v-if="selectedVolume === 'single'">
<input v-model.number="editCocoRow.drops" type="number" min="0" class="editor-drops" />
</template>
<template v-else>
<span class="coco-fill">填满 ({{ editorCocoFillMl }}ml)</span>
</template>
</td>
<td class="ing-ppd">{{ oilsStore.fmtPrice(oilsStore.pricePerDrop('椰子油')) }}</td>
<td class="ing-cost">{{ oilsStore.fmtPrice(oilsStore.pricePerDrop('椰子油') * editorCocoActualDrops) }}</td>
<td><button class="remove-row-btn" @click="editCocoRow = null"></button></td>
</tr>
</tbody>
</table>
<button class="add-row-btn" @click="addEoRow">+ 添加精油</button>
</div>
<!-- Real-time summary -->
<div class="recipe-summary">{{ editorSummaryText }}</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>
<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>
<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">
总计: {{ editorTotalCost }}
</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 { showConfirm, showPrompt } from '../composables/useDialog'
import { oilEn, recipeNameEn } from '../composables/useOilTranslation'
import { matchesPinyinInitials } from '../composables/usePinyinMatch'
// TagPicker replaced with inline tag editing
const props = defineProps({
recipeIndex: { type: Number, default: null },
recipeData: { type: Object, default: null },
isDiary: { type: Boolean, default: false },
})
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 selectedCardVolume = ref('单次')
const showTranslationEditor = ref(false)
const customRecipeNameEn = ref('')
const customOilNameEn = ref({})
const generatingImage = ref(false)
// ---- Preview override: holds unsaved editor state when user clicks "预览" ----
const previewOverride = ref(null)
// ---- Source recipe ----
const recipe = computed(() => {
if (props.recipeData) return props.recipeData
return recipesStore.recipes[props.recipeIndex] || { name: '', ingredients: [], tags: [], note: '' }
})
// ---- Display recipe: previewOverride when in preview mode, otherwise saved recipe ----
const displayRecipe = computed(() => {
if (!previewOverride.value) return recipe.value
return { ...recipe.value, ...previewOverride.value }
})
const canEditThisRecipe = computed(() => {
if (props.isDiary) return false
if (authStore.canEdit) return true
return false
})
const isFav = computed(() => recipesStore.isFavorite(recipe.value))
// Scale ingredients proportionally to target volume; '单次' = no scaling
function scaleIngredients(ingredients, volume) {
const targetDrops = VOLUME_DROPS[volume]
if (!targetDrops) return ingredients // 单次:不缩放
const totalDrops = ingredients.reduce((sum, ing) => sum + (ing.drops || 0), 0)
if (totalDrops === 0) return ingredients
return ingredients.map(ing => ({
...ing,
drops: Math.round(ing.drops * targetDrops / totalDrops),
}))
}
// Card ingredients: scaled to selected volume, coconut oil excluded from display
const scaledCardIngredients = computed(() =>
scaleIngredients(displayRecipe.value.ingredients, selectedCardVolume.value)
)
const cardIngredients = computed(() =>
scaledCardIngredients.value.filter(ing => ing.oil !== '椰子油')
)
// Coconut oil drops (from scaled set)
const coconutDrops = computed(() => {
const coco = scaledCardIngredients.value.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(scaledCardIngredients.value))
// 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 = {}
}
// Prompt QR upload: logged-in users once per month, anonymous every time
if (showBrandHint.value) {
let shouldPrompt = true
if (authStore.isLoggedIn) {
const lastPrompt = localStorage.getItem('qr_upload_prompt_time')
const oneMonth = 30 * 24 * 60 * 60 * 1000
if (lastPrompt && Date.now() - Number(lastPrompt) < oneMonth) {
shouldPrompt = false
} else {
localStorage.setItem('qr_upload_prompt_time', String(Date.now()))
}
}
if (shouldPrompt) {
const ok = await showConfirm(
'上传你的专属二维码,让配方卡片更专业 ✨',
{ okText: '去上传', cancelText: '下次再说' }
)
if (ok) goUploadQr()
}
}
}
// Whether to show the brand/QR upload hint (show to all users who haven't set up brand assets)
const showBrandHint = computed(() =>
!!brand.value && !brand.value.qr_code && !brand.value.brand_bg
)
function goUploadQr() {
if (!authStore.isLoggedIn) {
ui.openLogin(() => goUploadQr())
return
}
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())
}
function onCardVolumeChange(ml) {
selectedCardVolume.value = ml
cardImageUrl.value = null
nextTick(() => generateCardImage())
}
async function saveImage() {
if (!cardImageUrl.value) {
await generateCardImage()
}
if (!cardImageUrl.value) return
const filename = `${recipe.value.name || '配方'}_配方卡`
try {
const { saveImageFromUrl } = await import('../composables/useSaveImage')
await saveImageFromUrl(cardImageUrl.value, filename)
ui.showToast('已保存图片')
} catch {
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 = [
displayRecipe.value.name,
'---',
...lines,
'---',
`总成本: ${total}`,
displayRecipe.value.note ? `备注: ${displayRecipe.value.note}` : '',
].filter(Boolean).join('\n')
navigator.clipboard.writeText(text).then(() => {
ui.showToast('已复制')
}).catch(() => {
ui.showToast('复制失败')
})
}
function openTranslationEditor() {
// Pre-populate from single source of truth: oilsMeta.enName (DB)
const map = {}
for (const ing of cardIngredients.value) {
map[ing.oil] = getOilEnglish(ing.oil)
}
customOilNameEn.value = map
customRecipeNameEn.value = recipe.value.en_name || ''
showTranslationEditor.value = true
}
async function applyTranslation() {
showTranslationEditor.value = false
let saved = 0
let failed = 0
// 1. Save recipe English name to recipes table
if (recipe.value._id && customRecipeNameEn.value.trim()) {
try {
await api.put(`/api/recipes/${recipe.value._id}`, {
en_name: customRecipeNameEn.value.trim(),
})
saved++
} catch (e) {
console.error('Save recipe en_name failed:', e)
failed++
}
}
// 2. Save each oil's English name to oils table
// This is THE single source of truth — both oil reference page and recipe card read from here
for (const [oilName, enName] of Object.entries(customOilNameEn.value)) {
if (!enName?.trim()) continue
const meta = oilsStore.oilsMeta[oilName]
if (!meta) continue
if (meta.enName === enName.trim()) continue // no change
try {
await oilsStore.saveOil(oilName, meta.bottlePrice, meta.dropCount, meta.retailPrice, enName.trim())
saved++
} catch (e) {
console.error('Save oil en_name failed:', oilName, e)
failed++
}
}
// 3. Reload ALL data — this updates oilsMeta.enName and recipe.en_name
// So the next render reads fresh data from the single source
await Promise.all([
oilsStore.loadOils(),
recipesStore.loadRecipes(),
])
if (saved > 0) {
ui.showToast(`翻译已保存(${saved}项)` + (failed > 0 ? `${failed}项失败` : ''))
} else if (failed > 0) {
ui.showToast(`保存失败 ${failed}`)
} else {
ui.showToast('没有修改')
}
// Regenerate card image with updated names from store
cardImageUrl.value = null
nextTick(() => generateCardImage())
}
// Override translation getters for card rendering
function getOilEnglish(name) {
return oilsStore.oilsMeta[name]?.enName || oilEn(name) || ''
}
function getCardOilName(name) {
if (cardLang.value === 'en') {
// During editing, use customOilNameEn; otherwise read from store (single source of truth)
if (showTranslationEditor.value && customOilNameEn.value[name]) {
return customOilNameEn.value[name]
}
return getOilEnglish(name) || name
}
return name
}
function getCardRecipeName() {
if (cardLang.value === 'en') {
return customRecipeNameEn.value || displayRecipe.value.en_name || recipeNameEn(displayRecipe.value.name)
}
return displayRecipe.value.name
}
const cardTitleSize = computed(() => {
const name = getCardRecipeName()
const len = name.length
if (len <= 6) return '26px'
if (len <= 10) return '22px'
if (len <= 15) return '18px'
return '16px'
})
// ---- Favorite ----
async function handleToggleFavorite() {
if (!authStore.isLoggedIn) {
ui.openLogin(() => handleToggleFavorite())
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(() => saveToDiary())
return
}
const name = await showPrompt('保存为我的配方,名称:', recipe.value.name)
if (name === null) return
if (!name.trim()) {
ui.showToast('请输入配方名称')
return
}
const trimmed = name.trim()
const dupDiary = diaryStore.userDiary.some(d => d.name === trimmed)
if (dupDiary) {
ui.showToast('我的配方中已有同名配方「' + trimmed + '」')
return
}
try {
const payload = {
name: name.trim(),
note: recipe.value.note || '',
ingredients: recipe.value.ingredients.map(i => ({ oil: i.oil, drops: i.drops })),
tags: recipe.value.tags || [],
source_recipe_id: recipe.value._id || null,
}
await diaryStore.createDiary(payload)
ui.showToast('已保存!可在「配方查询 → 我的配方」查看')
} catch (e) {
console.error('[saveToDiary] failed:', e)
ui.showToast('保存失败:' + (e?.message || e?.status || '未知错误'), 3000)
}
}
// ==================== 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('')
// Oil autocomplete for add-ingredient row
const oilSearchQuery = ref('')
const showOilDropdown = ref(false)
const filteredOilsForAdd = computed(() => {
const q = oilSearchQuery.value.trim().toLowerCase()
if (!q) return oilsStore.oilNames
return oilsStore.oilNames.filter(n => {
const en = oilEn(n).toLowerCase()
return n.includes(q) || en.startsWith(q) || en.includes(q) || matchesPinyinInitials(n, q)
})
})
function selectNewOil(name) {
newIngOil.value = name
oilSearchQuery.value = name
showOilDropdown.value = false
}
function closeOilDropdown() {
setTimeout(() => { showOilDropdown.value = false }, 150)
}
function cancelAddRow() {
showAddRow.value = false
newIngOil.value = ''
oilSearchQuery.value = ''
newIngDrops.value = 1
}
// Volume & dilution
const selectedVolume = ref('single')
const customVolumeValue = ref(100)
const customVolumeUnit = ref('drops')
const dilutionRatio = ref(6)
const editCocoRow = ref({ oil: '椰子油', drops: 10 })
const editEoIngredients = computed(() =>
editIngredients.value.filter(i => i.oil !== '椰子油')
)
const editorEoDrops = computed(() =>
editEoIngredients.value.filter(i => i.oil && i.drops > 0).reduce((s, i) => s + i.drops, 0)
)
const editorTargetDrops = computed(() => {
if (selectedVolume.value === 'single') return null
if (selectedVolume.value === 'custom') return Math.round((customVolumeValue.value || 0) * DROPS_PER_ML)
return Math.round(Number(selectedVolume.value) * DROPS_PER_ML)
})
const editorCocoActualDrops = computed(() => {
if (!editCocoRow.value) return 0
if (selectedVolume.value === 'single') return editCocoRow.value.drops || 0
if (!editorTargetDrops.value) return 0
return Math.max(0, editorTargetDrops.value - editorEoDrops.value)
})
const editorCocoFillMl = computed(() => Math.round(editorCocoActualDrops.value / DROPS_PER_ML))
const editorSuggestedEo = computed(() => {
if (selectedVolume.value === 'single') {
const coco = editCocoRow.value ? (editCocoRow.value.drops || 10) : 10
return Math.round(coco / dilutionRatio.value)
}
return Math.round((editorTargetDrops.value || 0) / (1 + dilutionRatio.value))
})
const editorSummaryText = computed(() => {
const eo = editorEoDrops.value
const coco = editorCocoActualDrops.value
const ratio = eo > 0 ? Math.round(coco / eo) : 0
if (selectedVolume.value === 'single') {
return `该配方为单次用量,纯精油 ${eo} 滴,椰子油 ${coco} 滴,稀释比例 1:${ratio}`
}
const vol = selectedVolume.value === 'custom' ? (customVolumeValue.value || 0) : Number(selectedVolume.value)
return `该配方总容量 ${vol}ml纯精油 ${eo} 滴,剩余用椰子油填满,稀释比例 1:${ratio}`
})
const editorTotalCost = computed(() => {
let cost = editEoIngredients.value.filter(i => i.oil && i.drops > 0)
.reduce((s, i) => s + oilsStore.pricePerDrop(i.oil) * i.drops, 0)
cost += oilsStore.pricePerDrop('椰子油') * editorCocoActualDrops.value
return oilsStore.fmtPrice(cost)
})
function addEoRow() {
editIngredients.value.push({ oil: '', drops: 1 })
}
function goEditInManager() {
const r = recipe.value
// Store recipe id for manager to pick up
localStorage.setItem('oil_edit_recipe_id', String(r._id))
emit('close')
router.push('/manage')
}
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 || [])]
const allIngs = (r.ingredients || [])
editIngredients.value = allIngs.filter(i => i.oil !== '椰子油').map(i => ({ oil: i.oil, drops: i.drops }))
const coco = allIngs.find(i => i.oil === '椰子油')
editCocoRow.value = coco ? { oil: '椰子油', drops: coco.drops } : { oil: '椰子油', drops: 10 }
// 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
// Calculate current dilution ratio and volume from ingredients
const cocoIng = allIngs.find(i => i.oil === '椰子油')
const eoTotal = allIngs.filter(i => i.oil && i.oil !== '椰子油').reduce((s, i) => s + (i.drops || 0), 0)
const cocoTotal = cocoIng ? (cocoIng.drops || 0) : 0
const totalDrops = eoTotal + cocoTotal
if (eoTotal > 0 && cocoTotal > 0) {
dilutionRatio.value = Math.round(cocoTotal / eoTotal)
}
const ml = totalDrops / DROPS_PER_ML
if (ml <= 1.5) selectedVolume.value = 'single'
else if (Math.abs(ml - 5) < 1.5) selectedVolume.value = '5'
else if (Math.abs(ml - 10) < 3) selectedVolume.value = '10'
else if (Math.abs(ml - 15) < 3) selectedVolume.value = '15'
else if (Math.abs(ml - 20) < 4) selectedVolume.value = '20'
else if (Math.abs(ml - 30) < 8) selectedVolume.value = '30'
else { selectedVolume.value = 'custom'; customVolumeValue.value = Math.round(ml) }
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 = ''
oilSearchQuery.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 })
}
}
// Handle close from card view — if previewing unsaved data, ask user
async function handleClose() {
if (previewOverride.value !== null) {
const save = await showConfirm('还有未保存的修改,是否保存?', { okText: '保存', cancelText: '不保存' })
if (save) {
viewMode.value = 'editor'
await nextTick()
await saveRecipe()
return
}
previewOverride.value = null
}
emit('close')
}
// Handle close from editor view
async function handleEditorClose() {
if (previewOverride.value !== null) {
// Came from preview, back to preview without saving
previewOverride.value = null
viewMode.value = 'card'
cardImageUrl.value = null
nextTick(() => generateCardImage())
return
}
emit('close')
}
function previewFromEditor() {
// Capture current editor state and show it in card view
previewOverride.value = {
name: editName.value.trim() || recipe.value.name,
note: editNote.value.trim(),
tags: [...editTags.value],
ingredients: editIngredients.value
.filter(i => i.oil && i.drops > 0)
.map(i => ({ oil: i.oil, drops: i.drops })),
}
viewMode.value = 'card'
cardImageUrl.value = null
nextTick(() => generateCardImage())
}
async function saveRecipe() {
const eoIngs = editIngredients.value.filter(i => i.oil && i.oil !== '椰子油' && i.drops > 0)
if (!editName.value.trim()) {
ui.showToast('请输入配方名称')
return
}
if (eoIngs.length === 0) {
ui.showToast('请至少添加一种精油')
return
}
const allIngs = eoIngs.map(i => ({ oil_name: i.oil, drops: i.drops }))
if (editCocoRow.value && editorCocoActualDrops.value > 0) {
allIngs.push({ oil_name: '椰子油', drops: editorCocoActualDrops.value })
}
try {
const payload = {
...recipe.value,
name: editName.value.trim(),
note: editNote.value.trim(),
tags: editTags.value,
ingredients: allIngs,
}
await recipesStore.saveRecipe(payload)
// Reload recipes so the data is fresh when re-opened
await recipesStore.loadRecipes()
ui.showToast('保存成功')
// Go to card view instead of closing
previewOverride.value = null
viewMode.value = 'card'
cardImageUrl.value = null
nextTick(() => generateCardImage())
} 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%;
}
/* ===== Export Card Content (responsive) ===== */
.ec-subtitle {
font-size: 11px;
letter-spacing: 3px;
color: var(--sage);
margin-bottom: 6px;
margin-top: -4px;
white-space: nowrap;
}
.ec-title {
font-weight: 700;
color: var(--text-dark);
margin-bottom: 6px;
line-height: 1.35;
max-width: calc(100% - 70px);
overflow-wrap: break-word;
text-wrap: balance;
}
.ec-ing {
display: flex;
align-items: center;
padding: 9px 0;
border-bottom: 1px solid rgba(180,150,100,0.15);
font-size: 14px;
}
.ec-oil-name {
flex: 1;
color: var(--text-dark);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.ec-drops {
width: 50px;
text-align: right;
color: var(--sage-dark);
font-size: 13px;
white-space: nowrap;
flex-shrink: 0;
}
.ec-cost {
width: 60px;
text-align: right;
color: var(--text-light);
font-size: 12px;
white-space: nowrap;
flex-shrink: 0;
}
.ec-retail {
width: 55px;
text-align: right;
color: var(--text-light);
font-size: 10px;
text-decoration: line-through;
white-space: nowrap;
flex-shrink: 0;
}
.ec-total-bar {
background: linear-gradient(135deg, var(--sage), #5a7d5e);
border-radius: 12px;
padding: 10px 16px;
display: flex;
justify-content: space-between;
align-items: center;
white-space: nowrap;
}
.ec-bottom {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
.ec-logo {
height: 36px;
object-fit: contain;
opacity: 1;
}
.ec-date {
font-size: 11px;
color: var(--text-light);
letter-spacing: 1px;
}
/* Mobile: smaller card text */
@media (max-width: 420px) {
.export-card { padding: 24px; }
.ec-subtitle { font-size: 9px; letter-spacing: 2px; }
.ec-title { max-width: calc(100% - 60px); }
.ec-ing { font-size: 12px; padding: 7px 0; }
.ec-drops { width: 42px; font-size: 11px; }
.ec-cost { width: 50px; font-size: 10px; }
.ec-retail { width: 45px; font-size: 9px; }
.ec-total-bar { padding: 10px 14px; }
.ec-total-bar span:first-child { font-size: 11px; }
.ec-total-bar span:last-child { font-size: 16px; }
.ec-date { font-size: 9px; }
.ec-logo { height: 28px; }
}
/* Brand overlays */
.card-brand-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: 0.12;
z-index: 0;
pointer-events: none;
}
.card-qr-wrapper {
position: absolute;
top: 36px;
right: 36px;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
z-index: 3;
}
.card-qr {
width: 54px;
height: 54px;
object-fit: cover;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.card-qr-name {
font-size: 7px;
color: var(--text-light, #8a7a6a);
text-align: center;
line-height: 1.3;
max-width: 68px;
white-space: pre-line;
}
.card-logo {
height: 28px;
object-fit: contain;
opacity: 0.6;
}
.card-logo-placeholder {
/* keeps footer right-aligned even without logo */
}
.card-bottom-row {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: 16px;
margin-right: -80px; /* counteract card-content padding-right to span full width */
padding-right: 0;
}
.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;
margin-right: -80px; /* counteract card-content padding-right */
}
.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 {
text-align: right;
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);
}
.coco-row { background: #f8faf8; }
.coco-label { font-weight: 600; color: #4a9d7e; font-size: 13px; }
.coco-fill { font-size: 12px; color: #4a9d7e; font-weight: 500; }
.recipe-summary {
padding: 10px 14px; background: #f0faf5; border-radius: 10px; border-left: 3px solid #7ec6a4;
font-size: 13px; color: #2e7d5a; margin-bottom: 12px; line-height: 1.6;
}
.ratio-hint { font-size: 12px; color: #4a9d7e; font-weight: 500; white-space: nowrap; }
/* Volume controls */
.volume-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.card-volume-controls {
margin: 8px 0 12px;
}
.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: 12px;
color: #4a9d7e;
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;
}
/* Card volume toggle */
.card-volume-toggle {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: center;
margin-bottom: 12px;
}
/* Oil autocomplete */
.oil-autocomplete {
position: relative;
flex: 1;
min-width: 140px;
}
.oil-search-input {
width: 100%;
box-sizing: border-box;
}
.oil-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
border: 1.5px solid var(--sage, #7a9e7e);
border-radius: 10px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1);
max-height: 220px;
overflow-y: auto;
z-index: 100;
margin-top: 4px;
}
.oil-dropdown-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 9px 14px;
cursor: pointer;
font-size: 13px;
color: var(--text-dark, #2c2416);
border-bottom: 1px solid var(--border, #e0d4c0);
transition: background 0.1s;
}
.oil-dropdown-item:last-child {
border-bottom: none;
}
.oil-dropdown-item:hover,
.oil-dropdown-item.is-selected {
background: var(--sage-mist, #eef4ee);
}
.oil-dropdown-en {
font-size: 11px;
color: var(--text-light, #9a8570);
margin-left: 8px;
white-space: nowrap;
}
/* 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>