Files
oil-formula-calculator/frontend/src/components/RecipeDetailOverlay.vue
Hera Zhao 1018c1db11
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 4s
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 4s
Test / e2e-test (push) Has been cancelled
PR Preview / deploy-preview (pull_request) Successful in 15s
fix: QR上传提醒改为每月一次,按钮始终显示
- 用 localStorage 记录上次弹窗时间,30天内不再弹
- 📲 上传二维码按钮始终显示(只要用户没上传QR)
- 弹窗取消按钮文案改为「下次再说」

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:12:30 +00:00

1902 lines
48 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="detail-overlay" @click.self="$emit('close')">
<div class="detail-panel">
<!-- ==================== CARD VIEW ==================== -->
<div v-if="viewMode === 'card'" class="detail-card-view">
<!-- Top bar with close + edit -->
<div class="card-header">
<div class="card-top-actions">
<button class="action-btn action-btn-fav action-btn-sm" @click="handleToggleFavorite">
{{ isFav ? ' 已收藏' : ' 收藏' }}
</button>
<button v-if="!recipe._diary_id" class="action-btn action-btn-diary action-btn-sm" @click="saveToDiary">
📔 存为我的
</button>
</div>
<div style="flex:1"></div>
<button
v-if="canEditThisRecipe"
class="action-btn action-btn-sm"
@click="viewMode = 'editor'"
>编辑</button>
<button class="detail-close-btn" @click="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 -->
<div 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">
<!-- Brand overlay layers -->
<div
v-if="brand.brand_bg"
class="card-brand-bg"
:style="{ backgroundImage: `url('${brand.brand_bg}')` }"
/>
<div v-if="brand.qr_code" class="card-qr-wrapper">
<img
:src="brand.qr_code"
class="card-qr"
crossorigin="anonymous"
/>
<div v-if="brand.brand_name" class="card-qr-name">{{ brand.brand_name }}</div>
</div>
<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="displayRecipe.note" class="card-note">
{{ '📝 ' + displayRecipe.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="handleEditorClose"></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">
<div class="oil-autocomplete">
<input
v-model="oilSearchQuery"
@focus="showOilDropdown = true"
@blur="closeOilDropdown"
@input="newIngOil = ''"
class="editor-input oil-search-input"
placeholder="搜索精油名称或英文..."
autocomplete="off"
/>
<div v-if="showOilDropdown && filteredOilsForAdd.length" class="oil-dropdown">
<div
v-for="name in filteredOilsForAdd"
:key="name"
class="oil-dropdown-item"
:class="{ 'is-selected': newIngOil === name }"
@mousedown.prevent="selectNewOil(name)"
>
<span>{{ name }}</span>
<span class="oil-dropdown-en">{{ oilEn(name) }}</span>
</div>
</div>
</div>
<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="cancelAddRow">取消</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 { showConfirm, showPrompt } from '../composables/useDialog'
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 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(() =>
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 (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))
// 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 at most once per month
if (showBrandHint.value) {
const lastPrompt = localStorage.getItem('qr_upload_prompt_time')
const oneMonth = 30 * 24 * 60 * 60 * 1000
if (!lastPrompt || Date.now() - Number(lastPrompt) > oneMonth) {
localStorage.setItem('qr_upload_prompt_time', String(Date.now()))
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('复制失败')
})
}
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 || displayRecipe.value.en_name || recipeNameEn(displayRecipe.value.name)
}
return displayRecipe.value.name
}
// ---- 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)
// null = user cancelled (clicked 取消)
if (name === null) return
// empty string = user cleared the name field
if (!name.trim()) {
ui.showToast('请输入配方名称')
return
}
try {
const payload = {
name: name.trim(),
note: recipe.value.note || '',
ingredients: recipe.value.ingredients.map(i => ({ oil_name: i.oil, drops: i.drops })),
tags: recipe.value.tags || [],
}
console.log('[saveToDiary] saving recipe:', payload)
await recipesStore.saveRecipe(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)
})
})
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(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 = ''
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 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('保存成功')
// 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%;
}
.card-content {
position: relative;
z-index: 2;
}
/* 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: 24px;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
z-index: 2;
}
.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 {
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
height: 60px;
object-fit: contain;
z-index: 1;
opacity: 0.2;
pointer-events: none;
}
.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;
}
.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: 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;
}
/* 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>