- RecipeDetailOverlay: 未上传二维码/背景图时,卡片上方显示提示横幅,下方出现「上传我的二维码」按钮,点击跳转到 MyDiary 品牌设置页并记录来源配方 - MyDiary: 新增二维码图片上传区域(直接上传图片文件,存为 base64 → PUT /api/brand qr_code 字段);上传成功后若有待返回配方则自动跳回配方卡片;修复 loadBrandSettings 字段名与后端不一致的问题 - RecipeSearch: 支持 ?openRecipe= 查询参数,页面挂载时自动打开指定配方卡片,实现从 MyDiary 上传后无缝返回 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1657 lines
42 KiB
Vue
1657 lines
42 KiB
Vue
<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>
|