Files
oil-formula-calculator/frontend/src/views/OilReference.vue
Hera Zhao 86db3e1868
Some checks failed
PR Preview / teardown-preview (pull_request) Successful in 13s
PR Preview / test (pull_request) Has been skipped
PR Preview / deploy-preview (pull_request) Has been skipped
Deploy Production / test (push) Successful in 5s
Test / unit-test (push) Successful in 4s
Test / build-check (push) Successful in 4s
Deploy Production / deploy (push) Successful in 5s
Test / e2e-test (push) Failing after 1m26s
fix: emoji按钮缩小(13px/4px padding),工具栏不换行(nowrap)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:38:02 +00:00

1696 lines
53 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="oil-reference">
<!-- Knowledge Cards at Top -->
<div style="display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap">
<div @click="showDilution = true" style="flex:1;min-width:140px;background:linear-gradient(135deg,#e8f5e9,#c8e6c9);border-radius:12px;padding:12px 16px;cursor:pointer;transition:transform 0.2s;display:flex;align-items:center;gap:10px" @mouseover="$event.currentTarget.style.transform='translateY(-2px)'" @mouseout="$event.currentTarget.style.transform=''">
<span style="font-size:22px">💧</span>
<div>
<div style="font-size:14px;font-weight:600;color:#2e7d32">稀释比例</div>
<div style="font-size:10px;color:#558b2f;margin-top:2px;white-space:nowrap">不同年龄段的稀释指南</div>
</div>
</div>
<div @click="showContra = true" style="flex:1;min-width:140px;background:linear-gradient(135deg,#fff8e1,#ffecb3);border-radius:12px;padding:12px 16px;cursor:pointer;transition:transform 0.2s;display:flex;align-items:center;gap:10px" @mouseover="$event.currentTarget.style.transform='translateY(-2px)'" @mouseout="$event.currentTarget.style.transform=''">
<span style="font-size:22px"></span>
<div>
<div style="font-size:14px;font-weight:600;color:#f57f17">使用禁忌</div>
<div style="font-size:10px;color:#ff8f00;margin-top:2px;white-space:nowrap">安全使用精油的注意事项</div>
</div>
</div>
</div>
<!-- Dilution Ratio Modal -->
<div v-if="showDilution" class="modal-overlay" @click.self="showDilution = false">
<div ref="dilutionCardRef" style="position:relative;z-index:1;background:white;border-radius:20px;max-width:420px;width:100%;max-height:88vh;overflow-y:auto;box-shadow:0 16px 56px rgba(0,0,0,0.25)" @click.stop>
<div style="background:linear-gradient(135deg,#2e7d32,#66bb6a);border-radius:20px 20px 0 0;padding:28px 24px;color:white;text-align:center;position:relative">
<button @click="showDilution = false" style="position:absolute;top:12px;right:16px;background:rgba(255,255,255,0.2);border:none;color:white;width:30px;height:30px;border-radius:50%;cursor:pointer;font-size:16px">×</button>
<div style="font-size:48px;margin-bottom:8px">💧</div>
<div style="font-family:'Noto Serif SC',serif;font-size:22px;font-weight:700">精油稀释比例指南</div>
<div style="font-size:13px;opacity:0.85;margin-top:4px">安全使用科学稀释</div>
</div>
<div style="padding:24px">
<table style="width:100%;border-collapse:collapse;font-size:14px">
<tr style="border-bottom:2px solid #e8f5e9"><th style="text-align:left;padding:10px 8px;color:#2e7d32">适用人群</th><th style="text-align:right;padding:10px 8px;color:#2e7d32">精油 : 椰子油</th></tr>
<tr style="border-bottom:1px solid #f0f0f0"><td style="padding:10px 8px">👶 1岁以下</td><td style="text-align:right;padding:10px 8px;font-weight:600">1 : 200</td></tr>
<tr style="border-bottom:1px solid #f0f0f0"><td style="padding:10px 8px">🧒 1 2 </td><td style="text-align:right;padding:10px 8px;font-weight:600">1 : 100</td></tr>
<tr style="border-bottom:1px solid #f0f0f0"><td style="padding:10px 8px">👦 2 6 </td><td style="text-align:right;padding:10px 8px;font-weight:600">1 : 50</td></tr>
<tr style="border-bottom:1px solid #f0f0f0"><td style="padding:10px 8px">🧑 6 12 </td><td style="text-align:right;padding:10px 8px;font-weight:600">1 : 10</td></tr>
<tr style="border-bottom:1px solid #f0f0f0"><td style="padding:10px 8px">🧴 成人敏感肌</td><td style="text-align:right;padding:10px 8px;font-weight:600">1 : 5~10</td></tr>
<tr><td style="padding:10px 8px">🔥 强刺激精油<br><span style="font-size:11px;color:var(--text-light)">牛至/肉桂/丁香/桂皮等</span></td><td style="text-align:right;padding:10px 8px;font-weight:600">1 : 6~10</td></tr>
</table>
<div style="margin-top:16px;padding:12px;background:#e8f5e9;border-radius:10px;font-size:12px;color:#2e7d32;text-align:center">
💡 稀释比例 = 1滴精油 : N滴椰子油<br>比例越大越温和新手建议从高稀释比例开始
</div>
<div style="text-align:center;margin-top:12px">
<button @click="saveDilutionImage" style="padding:8px 16px;border-radius:10px;border:1.5px solid var(--sage);background:white;color:var(--sage-dark);cursor:pointer;font-size:13px;font-family:inherit">💾 保存图片</button>
</div>
</div>
</div>
</div>
<!-- Safety Cautions Modal -->
<div v-if="showContra" class="modal-overlay" @click.self="showContra = false">
<div ref="contraCardRef" style="position:relative;z-index:1;background:white;border-radius:20px;max-width:420px;width:100%;max-height:88vh;overflow-y:auto;box-shadow:0 16px 56px rgba(0,0,0,0.25)" @click.stop>
<div style="background:linear-gradient(135deg,#e65100,#ff9800);border-radius:20px 20px 0 0;padding:28px 24px;color:white;text-align:center;position:relative">
<button @click="showContra = false" style="position:absolute;top:12px;right:16px;background:rgba(255,255,255,0.2);border:none;color:white;width:30px;height:30px;border-radius:50%;cursor:pointer;font-size:16px">×</button>
<div style="font-size:48px;margin-bottom:8px"></div>
<div style="font-family:'Noto Serif SC',serif;font-size:22px;font-weight:700">精油使用禁忌</div>
<div style="font-size:13px;opacity:0.85;margin-top:4px">安全第一正确使用</div>
</div>
<div style="padding:24px;display:flex;flex-direction:column;gap:12px">
<div style="display:flex;gap:10px;align-items:flex-start">
<span style="font-size:20px;flex-shrink:0">🚫</span>
<div><div style="font-weight:600;color:var(--text-dark)">不得入眼鼻腔</div><div style="font-size:12px;color:var(--text-light);margin-top:2px">精油不可直接接触眼睛耳道和鼻腔内部</div></div>
</div>
<div style="display:flex;gap:10px;align-items:flex-start">
<span style="font-size:20px;flex-shrink:0">🥥</span>
<div><div style="font-weight:600;color:var(--text-dark)">误触或刺激 用椰子油稀释</div><div style="font-size:12px;color:var(--text-light);margin-top:2px">不可用水冲洗水会加剧刺激用椰子油涂抹稀释</div></div>
</div>
<div style="display:flex;gap:10px;align-items:flex-start">
<span style="font-size:20px;flex-shrink:0">🌙</span>
<div><div style="font-weight:600;color:var(--text-dark)">柠檬等光敏性精油仅夜间涂抹</div><div style="font-size:12px;color:var(--text-light);margin-top:2px">涂抹后 12 小时内避免阳光直射</div></div>
</div>
<div style="display:flex;gap:10px;align-items:flex-start">
<span style="font-size:20px;flex-shrink:0">🌡</span>
<div><div style="font-weight:600;color:var(--text-dark)">阴凉避光保存远离儿童</div><div style="font-size:12px;color:var(--text-light);margin-top:2px">避免高温和阳光直射放在儿童够不到的地方</div></div>
</div>
<div style="display:flex;gap:10px;align-items:flex-start">
<span style="font-size:20px;flex-shrink:0">🧴</span>
<div><div style="font-weight:600;color:var(--text-dark)">避免和塑料制品接触</div><div style="font-size:12px;color:var(--text-light);margin-top:2px">精油会腐蚀塑料请使用玻璃或不锈钢容器</div></div>
</div>
<div style="display:flex;gap:10px;align-items:flex-start">
<span style="font-size:20px;flex-shrink:0">💧</span>
<div><div style="font-weight:600;color:var(--text-dark)">少量多次多喝水</div><div style="font-size:12px;color:var(--text-light);margin-top:2px">使用精油后多补充水分帮助身体代谢</div></div>
</div>
<div style="text-align:center;margin-top:12px">
<button @click="saveContraImage" style="padding:8px 16px;border-radius:10px;border:1.5px solid #e65100;background:white;color:#e65100;cursor:pointer;font-size:13px;font-family:inherit">💾 保存图片</button>
</div>
</div>
</div>
</div>
<!-- Search + View Toggle + Add + PDF -->
<div style="display:flex;gap:6px;align-items:center;margin-bottom:12px;flex-wrap:nowrap">
<div class="search-box" style="flex:1;min-width:140px;margin-bottom:0">
<input class="search-input" v-model="searchQuery" placeholder="搜索精油名称…" style="width:100%" />
</div>
<div style="display:flex;border:1.5px solid var(--border);border-radius:10px;overflow:hidden;flex-shrink:0">
<button @click="viewMode = 'bottle'" :style="viewMode === 'bottle' ? 'background:var(--sage);color:white' : 'background:white;color:var(--text-mid)'" style="border:none;border-radius:0;font-size:12px;padding:6px 12px;cursor:pointer">每瓶价</button>
<button @click="viewMode = 'drop'" :style="viewMode === 'drop' ? 'background:var(--sage);color:white' : 'background:white;color:var(--text-mid)'" style="border:none;border-radius:0;font-size:12px;padding:6px 12px;cursor:pointer">每滴价</button>
</div>
<!-- Desktop: text buttons -->
<button v-if="auth.canEdit" class="toolbar-btn-text" @click="showAddForm = !showAddForm">{{ showAddForm ? '收起' : ' 新增' }}</button>
<button v-if="auth.isAdmin" class="toolbar-btn-text" @click="exportPDF">📥 导出PDF</button>
<!-- Mobile: emoji-only buttons -->
<button v-if="auth.canEdit" class="toolbar-btn-icon" @click="showAddForm = !showAddForm" title="新增精油"></button>
<button v-if="auth.isAdmin" class="toolbar-btn-icon" @click="exportPDF" title="导出PDF">📄</button>
</div>
<!-- Add Oil Form (toggleable) -->
<div v-if="showAddForm && auth.canEdit" class="add-oil-form">
<div class="form-row">
<input v-model="newOilName" style="flex:1;min-width:120px" placeholder="精油名称" class="form-input-sm" />
<input v-model="newOilEnName" style="flex:1;min-width:100px" placeholder="英文名" class="form-input-sm" />
<input v-model.number="newBottlePrice" style="width:100px" type="number" step="0.01" min="0" placeholder="会员价 ¥" class="form-input-sm" />
<select v-model="newVolume" class="form-input-sm" style="width:110px">
<option value="">容量</option>
<option value="2.5">2.5ml (46)</option>
<option value="5">5ml (93)</option>
<option value="10">10ml (186)</option>
<option value="15">15ml (280)</option>
<option value="115">115ml (2146)</option>
<option value="custom">自定义滴数</option>
</select>
<input v-if="newVolume === 'custom'" v-model.number="newCustomDrops" style="width:80px" type="number" step="1" min="1" placeholder="滴数" class="form-input-sm" />
<input v-model.number="newRetailPrice" style="width:100px" type="number" step="0.01" min="0" placeholder="零售价 ¥" class="form-input-sm" />
<button class="btn btn-primary btn-sm" @click="addOil" :disabled="!newOilName.trim()"> 添加</button>
</div>
</div>
<!-- Oil Grid -->
<div class="oils-grid">
<div
v-for="name in filteredOilNames"
:key="name + '-' + cardVersion"
class="oil-chip"
:class="{ 'oil-chip--inactive': getMeta(name)?.isActive === false, 'oil-chip--incomplete': auth.isAdmin && isIncomplete(name) }"
:style="chipStyle(name)"
@click="openOilDetail(name)"
>
<div style="flex:1;min-width:0">
<div class="oil-name-line">{{ name }}</div>
<div class="oil-en-line">{{ getEnglishName(name) }}</div>
</div>
<div style="text-align:right;flex-shrink:0">
<template v-if="viewMode === 'bottle'">
<div class="oil-price-line">¥{{ (getMeta(name)?.bottlePrice || 0).toFixed(0) }}<span class="oil-price-unit">/</span></div>
<div v-if="getMeta(name)?.retailPrice" class="oil-retail-line">¥{{ getMeta(name).retailPrice }}/</div>
</template>
<template v-else>
<div class="oil-price-line">¥{{ oils.pricePerDrop(name).toFixed(2) }}<span class="oil-price-unit">{{ name === '植物空胶囊' ? '/颗' : '/滴' }}</span></div>
<div v-if="getMeta(name)?.retailPrice && getMeta(name)?.dropCount" class="oil-retail-line">
¥{{ (getMeta(name).retailPrice / getMeta(name).dropCount).toFixed(2) }}{{ name === '植物空胶囊' ? '/' : '/' }}
</div>
</template>
</div>
<div v-if="auth.canEdit" class="oil-chip-actions" @click.stop>
<button @click="editOil(name)" title="编辑"></button>
<button @click="removeOil(name)" title="删除">🗑</button>
</div>
</div>
<div v-if="filteredOilNames.length === 0" class="empty-hint">未找到精油</div>
</div>
<!-- Oil Knowledge Card Modal -->
<div v-if="activeCard && activeCardName" class="modal-overlay" @click.self="closeOilModal">
<div class="oil-card-modal">
<div class="oil-card-header">
<div class="oil-card-header-content">
<span class="oil-card-emoji">{{ activeCard.emoji }}</span>
<div>
<h2 class="oil-card-title">{{ activeCardName }}</h2>
<div class="oil-card-en">{{ activeCard.en }}</div>
<div class="oil-card-price-info" v-if="getMeta(activeCardName)">
¥ {{ (getMeta(activeCardName).bottlePrice || 0).toFixed(2) }}
<span v-if="oils.pricePerDrop(activeCardName)">
&middot; ¥ {{ oils.pricePerDrop(activeCardName).toFixed(4) }}/
</span>
</div>
</div>
</div>
<button class="btn-close btn-close-light" @click="closeOilModal"></button>
</div>
<!-- Method badges -->
<div class="oil-card-methods">
<span
v-for="badge in parseMethodBadges(activeCard.method)"
:key="badge.label"
class="method-badge"
:class="badge.cls"
>{{ badge.label }}</span>
</div>
<div class="oil-card-body">
<!-- Effects -->
<div class="oil-card-section">
<h4 class="oil-card-section-title"> 主要功效</h4>
<ul class="oil-card-list">
<li v-for="(line, i) in splitLines(activeCard.effects)" :key="i">{{ line }}</li>
</ul>
</div>
<!-- Usage -->
<div class="oil-card-section">
<h4 class="oil-card-section-title">📖 使用方法</h4>
<ul class="oil-card-list">
<li v-for="(line, i) in splitLines(activeCard.usage)" :key="i">{{ line }}</li>
</ul>
</div>
<!-- Caution -->
<div v-if="activeCard.caution" class="oil-card-caution">
<h4 class="oil-card-caution-title"> 注意事项</h4>
<p>{{ activeCard.caution }}</p>
</div>
<div style="text-align:center;padding-top:12px">
<button class="btn btn-outline btn-sm" @click="saveCardImage(activeCardName)">💾 保存图片</button>
</div>
</div>
</div>
</div>
<!-- Simple Oil Detail Panel (for oils without a knowledge card) -->
<div v-if="selectedOilName && !activeCard" class="modal-overlay" @click.self="selectedOilName = null">
<div class="oil-detail-panel">
<div class="detail-header">
<div>
<h3>{{ selectedOilName }}</h3>
<div class="detail-en">{{ getEnglishName(selectedOilName) }}</div>
</div>
<button class="btn-close" @click="selectedOilName = null"></button>
</div>
<div class="detail-body">
<div class="detail-row">
<span class="detail-label">会员价</span>
<span class="detail-value">{{ getMeta(selectedOilName)?.bottlePrice != null ? ('¥ ' + getMeta(selectedOilName).bottlePrice.toFixed(2)) : '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">总滴数</span>
<span class="detail-value">{{ getMeta(selectedOilName)?.dropCount || '--' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">每滴价格</span>
<span class="detail-value">{{ oils.pricePerDrop(selectedOilName) ? ('¥ ' + oils.pricePerDrop(selectedOilName).toFixed(4)) : '--' }}</span>
</div>
<div class="detail-row" v-if="getMeta(selectedOilName)?.retailPrice">
<span class="detail-label">零售价</span>
<span class="detail-value">¥ {{ getMeta(selectedOilName).retailPrice.toFixed(2) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">每ml价格</span>
<span class="detail-value">{{ oils.pricePerDrop(selectedOilName) ? ('¥ ' + (oils.pricePerDrop(selectedOilName) * DROPS_PER_ML).toFixed(2)) : '--' }}</span>
</div>
<h4 style="margin:16px 0 8px">含此精油的配方</h4>
<div v-if="recipesWithOil.length" class="detail-recipes">
<div v-for="r in recipesWithOil" :key="r._id" class="detail-recipe-item">
<span class="dr-name">{{ r.name }}</span>
<span class="dr-drops">{{ getDropsForOil(r, selectedOilName) }}</span>
</div>
</div>
<div v-else class="empty-hint">暂无使用此精油的配方</div>
</div>
</div>
</div>
<!-- Edit Oil Overlay -->
<div v-if="editingOilName" class="modal-overlay" @click.self="editingOilName = null">
<div class="modal-panel">
<div class="modal-header">
<h3>{{ editingOilName }}</h3>
<button class="btn-close" @click="editingOilName = null"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label>精油名称</label>
<input v-model="editOilDisplayName" class="form-input" type="text" placeholder="精油名称" />
</div>
<div class="form-group">
<label>英文名</label>
<input v-model="editOilEnName" class="form-input" type="text" placeholder="English name" />
</div>
<div class="form-group">
<label>容量</label>
<select v-model="editVolume" class="form-select">
<option value="2.5">2.5ml (46)</option>
<option value="5">5ml (93)</option>
<option value="10">10ml (186)</option>
<option value="15">15ml (280)</option>
<option value="115">115ml (2146)</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="form-group" v-if="editVolume === 'custom'">
<label>自定义滴数</label>
<input v-model.number="editDropCount" class="form-input" type="number" />
</div>
<div class="form-group">
<label>会员价 (¥)</label>
<input v-model.number="editBottlePrice" class="form-input" type="number" />
</div>
<div class="form-group">
<label>零售价 (¥)</label>
<input v-model.number="editRetailPrice" class="form-input" type="number" />
</div>
<!-- Knowledge Card Editor -->
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:12px">
<div style="font-size:13px;font-weight:600;color:var(--text-mid);margin-bottom:8px">📖 知识卡片选填填写功效后自动生成</div>
<div class="form-group">
<label>主要功效每行一条</label>
<textarea v-model="editCardEffects" class="form-input" rows="3" placeholder="镇静安神、改善睡眠&#10;舒缓压力、平衡情绪" @input="autoGenerateEmoji"></textarea>
</div>
<div class="form-group">
<label>使用方法每行一条</label>
<textarea v-model="editCardUsage" class="form-input" rows="3" placeholder="夜间香薰助眠&#10;加入护肤品中"></textarea>
</div>
<div class="form-group">
<label>使用方式</label>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button
v-for="m in methodOptions" :key="m.value"
:style="editCardMethodSet.has(m.value)
? 'background:' + m.color + ';color:white;border-color:' + m.color + ';font-weight:600;box-shadow:0 2px 8px rgba(0,0,0,0.15)'
: 'background:white;color:#999;border-color:#ddd'"
style="padding:7px 16px;border-radius:20px;font-size:13px;border:2px solid;cursor:pointer;font-family:inherit;transition:all 0.15s"
@click="toggleMethod(m.value)"
>{{ m.label }}</button>
</div>
</div>
<div class="form-group">
<label>注意事项</label>
<input v-model="editCardCaution" class="form-input" placeholder="如:光敏性,白天避免涂抹" />
</div>
<div class="form-group">
<label>Emoji 图标</label>
<input v-model="editCardEmoji" class="form-input" placeholder="自动生成,也可手动修改" style="width:100px" />
</div>
</div>
<div style="display:flex;gap:10px;justify-content:space-between;margin-top:16px">
<button
:style="getMeta(editingOilName)?.isActive === false
? 'padding:8px 14px;border-radius:8px;font-size:13px;cursor:pointer;font-family:inherit;border:1.5px solid #ccc;background:#f0f0f0;color:#999'
: 'padding:8px 14px;border-radius:8px;font-size:13px;cursor:pointer;font-family:inherit;border:1.5px solid #e8b4b0;background:transparent;color:#c0392b'"
@click="toggleOilActive"
>{{ getMeta(editingOilName)?.isActive === false ? '✓ 已下架 · 点击重新上架' : '下架' }}</button>
<div style="display:flex;gap:10px">
<button class="btn-outline" @click="editingOilName = null">取消</button>
<button class="btn-primary" @click="saveEditOil">保存</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import html2canvas from 'html2canvas'
import { useOilsStore, VOLUME_DROPS, DROPS_PER_ML } from '../stores/oils'
import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
import { useRecipesStore } from '../stores/recipes'
import { oilEn } from '../composables/useOilTranslation'
import { getOilCard, setOilCard } from '../composables/useOilCards'
import { showConfirm } from '../composables/useDialog'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
// Modal states
const showDilution = ref(false)
const showContra = ref(false)
const showAddForm = ref(false)
const dilutionCardRef = ref(null)
const contraCardRef = ref(null)
// Search & view
const searchQuery = ref('')
const cardVersion = ref(0) // bump to force re-render after card changes
const viewMode = ref('bottle')
// Oil detail
const selectedOilName = ref(null)
const activeCardName = ref(null)
const activeCard = ref(null)
// Add oil form
const newOilName = ref('')
const newOilEnName = ref('')
const newBottlePrice = ref(null)
const newVolume = ref('5')
const newCustomDrops = ref(null)
const newRetailPrice = ref(null)
// Edit oil
const editingOilName = ref(null)
const editOilDisplayName = ref('')
const editBottlePrice = ref(0)
const editVolume = ref('5')
const editDropCount = ref(0)
const editRetailPrice = ref(null)
const editOilEnName = ref('')
const editCardEmoji = ref('')
const editCardEffects = ref('')
const editCardUsage = ref('')
const editCardMethod = ref('')
const editCardCaution = ref('')
const editCardMethodSet = ref(new Set())
const methodOptions = [
{ value: 'aroma', label: '🔹 香薰', bg: '#e3f2fd', color: '#1565c0' },
{ value: 'internal', label: '🔸 内用', bg: '#fff3e0', color: '#e65100' },
{ value: 'topical', label: '🔺 涂抹', bg: '#e8f5e9', color: '#2e7d32' },
]
function toggleMethod(value) {
const s = editCardMethodSet.value
if (s.has(value)) s.delete(value)
else s.add(value)
// Rebuild method string
const labels = { aroma: '🔹香薰', internal: '🔸内用', topical: '🔺涂抹' }
editCardMethod.value = [...s].map(k => labels[k]).join(' ')
}
// Emoji keywords map
const EMOJI_MAP = {
'安神': '😴', '睡眠': '😴', '助眠': '😴', '安定': '🌳', '放松': '🌳',
'消化': '🍃', '肠胃': '🍃', '暖胃': '🫚',
'镇痛': '🌿', '酸痛': '🌿', '肌肉': '🌿',
'呼吸': '🌬', '鼻炎': '🌬', '咳嗽': '🌬',
'免疫': '🛡', '杀菌': '🛡', '抗菌': '🌱',
'护肤': '💜', '美白': '💜', '抗衰': '💜',
'提神': '🍊', '情绪': '🍊', '愉悦': '🍊',
'排毒': '🔥', '代谢': '🔥', '净化': '🔥',
'荷尔蒙': '🌸', '经期': '🌸', '女性': '🌸',
'伤口': '👑', '细胞': '👑', '再生': '👑',
}
function autoGenerateEmoji() {
if (editCardEmoji.value && editCardEmoji.value !== '🌿') return // don't override manual
const text = editCardEffects.value
for (const [keyword, emoji] of Object.entries(EMOJI_MAP)) {
if (text.includes(keyword)) {
editCardEmoji.value = emoji
return
}
}
if (text.trim()) editCardEmoji.value = '🌿'
}
// Active chip (for mobile hover)
const activeChip = ref(null)
function toggleChip(name) {
activeChip.value = activeChip.value === name ? null : name
}
// Volume-to-drops mapping
const VOLUME_OPTIONS = {
'2.5': 46,
'5': 93,
'10': 186,
'15': 280,
'115': 2146,
}
// Reverse lookup: drops to volume label
const DROPS_TO_VOLUME = {}
for (const [ml, drops] of Object.entries(VOLUME_OPTIONS)) {
DROPS_TO_VOLUME[drops] = ml + 'ml'
}
function volumeLabel(dropCount, name) {
if (dropCount === 160) return '160颗'
return DROPS_TO_VOLUME[dropCount] || (dropCount + '滴')
}
function chipStyle(name) {
const hasCard = !!getOilCard(name)
if (hasCard) return 'cursor:pointer;border-left:3px solid var(--sage);background:linear-gradient(90deg,var(--sage-mist),white)'
return ''
}
function isIncomplete(name) {
const meta = getMeta(name)
if (!meta) return true
if (meta.isActive === false) return false // 下架的不算不全
// Incomplete: missing English name, retail price, or bottle price
const hasEn = meta.enName || getEnglishName(name)
return !meta.bottlePrice || !meta.retailPrice || !hasEn
}
function getEffectiveDropCount() {
if (newVolume.value === 'custom') return newCustomDrops.value || 0
return VOLUME_OPTIONS[newVolume.value] || 0
}
// When editing, sync volume dropdown from drop count
function dropCountToVolume(dc) {
for (const [ml, drops] of Object.entries(VOLUME_OPTIONS)) {
if (drops === dc) return ml
}
return 'custom'
}
function getEditDropCount() {
if (editVolume.value === 'custom') return editDropCount.value
return VOLUME_OPTIONS[editVolume.value] || 0
}
// Computed
const filteredOilNames = computed(() => {
if (!searchQuery.value.trim()) return oils.oilNames
const q = searchQuery.value.trim().toLowerCase()
return oils.oilNames.filter(n => {
const en = getEnglishName(n).toLowerCase()
return n.toLowerCase().includes(q) || en.includes(q)
})
})
const recipesWithOil = computed(() => {
const name = selectedOilName.value || activeCardName.value
if (!name) return []
return recipeStore.recipes.filter(r =>
r.ingredients.some(i => i.oil === name)
)
})
// Helpers
function getMeta(name) {
return oils.oilsMeta[name]
}
function getEnglishName(name) {
// 1. Oil card has priority
const card = getOilCard(name)
if (card && card.en) return card.en
// 2. Stored en_name in meta
const meta = oils.oilsMeta[name]
if (meta?.enName) return meta.enName
// 3. Static translation map
return oilEn(name)
}
function getDropsForOil(recipe, oilName) {
const ing = recipe.ingredients.find(i => i.oil === oilName)
return ing ? ing.drops : 0
}
function splitLines(text) {
if (!text) return []
return text.split('\n').filter(l => l.trim())
}
function parseMethodBadges(methodStr) {
if (!methodStr) return []
const badges = []
if (methodStr.includes('香薰') || methodStr.includes('熏香')) {
badges.push({ label: '香薰', cls: 'method-aroma' })
}
if (methodStr.includes('内用')) {
badges.push({ label: '内用', cls: 'method-internal' })
}
if (methodStr.includes('涂抹')) {
badges.push({ label: '涂抹', cls: 'method-topical' })
}
return badges
}
// Actions
async function openOilDetail(name) {
const card = getOilCard(name)
if (card) {
activeCardName.value = name
activeCard.value = card
selectedOilName.value = null
// Pre-generate card image for instant save
oilCardImageUrl.value = null
await nextTick()
await new Promise(r => setTimeout(r, 300))
const el = document.querySelector('.oil-card-modal')
if (el) await generateImageFromRef({ value: el }, oilCardImageUrl)
} else {
activeCard.value = null
activeCardName.value = null
selectedOilName.value = name
}
}
function closeOilModal() {
activeCard.value = null
activeCardName.value = null
selectedOilName.value = null
}
async function addOil() {
if (!newOilName.value.trim()) return
const dropCount = getEffectiveDropCount()
try {
await oils.saveOil(
newOilName.value.trim(),
newBottlePrice.value || 0,
dropCount,
newRetailPrice.value || null,
newOilEnName.value.trim() || null
)
ui.showToast(`已添加: ${newOilName.value}`)
newOilName.value = ''
newOilEnName.value = ''
newBottlePrice.value = null
newVolume.value = '5'
newCustomDrops.value = null
newRetailPrice.value = null
} catch (e) {
ui.showToast('添加失败: ' + (e.message || ''))
}
}
function editOil(name) {
editingOilName.value = name
editOilDisplayName.value = name
const meta = oils.oilsMeta[name]
editBottlePrice.value = meta?.bottlePrice || 0
const dc = meta?.dropCount || 0
editVolume.value = dropCountToVolume(dc)
editDropCount.value = dc
editRetailPrice.value = meta?.retailPrice || null
editOilEnName.value = meta?.enName || getEnglishName(name) || ''
// Load knowledge card if exists
const card = getOilCard(name)
editCardEmoji.value = card?.emoji || ''
editCardEffects.value = card?.effects || ''
editCardUsage.value = card?.usage || ''
editCardMethod.value = card?.method || ''
editCardCaution.value = card?.caution || ''
// Parse method string back to set
const ms = new Set()
const methodStr = card?.method || ''
if (methodStr.includes('香薰') || methodStr.includes('熏香')) ms.add('aroma')
if (methodStr.includes('内用')) ms.add('internal')
if (methodStr.includes('涂抹')) ms.add('topical')
editCardMethodSet.value = ms
}
async function saveEditOil() {
const dropCount = getEditDropCount()
const newName = editOilDisplayName.value.trim()
const oldName = editingOilName.value
try {
// If name changed, delete old and create new
if (newName && newName !== oldName) {
await oils.deleteOil(oldName)
}
await oils.saveOil(
newName || oldName,
editBottlePrice.value,
dropCount,
editRetailPrice.value,
editOilEnName.value.trim() || null
)
// Save knowledge card if any content provided
const finalName = newName || oldName
if (editCardEffects.value.trim() || editCardUsage.value.trim()) {
setOilCard(finalName, {
emoji: editCardEmoji.value || '🌿',
en: editOilEnName.value.trim() || '',
effects: editCardEffects.value.trim(),
usage: editCardUsage.value.trim(),
method: editCardMethod.value.trim(),
caution: editCardCaution.value.trim(),
})
}
cardVersion.value++ // trigger re-render for card badges
ui.showToast('已更新')
editingOilName.value = null
} catch (e) {
ui.showToast('更新失败')
}
}
async function toggleOilActive() {
const name = editingOilName.value
if (!name) { ui.showToast('错误: 没有选中精油'); return }
const meta = getMeta(name)
if (!meta) { ui.showToast('错误: 找不到精油数据'); return }
const newActive = meta.isActive === false ? 1 : 0
const payload = {
name,
bottle_price: Number(meta.bottlePrice) || 0,
drop_count: Number(meta.dropCount) || 1,
retail_price: meta.retailPrice ? Number(meta.retailPrice) : null,
en_name: meta.enName || null,
is_active: newActive,
}
try {
const res = await fetch('/api/oils', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + localStorage.getItem('oil_auth_token'),
},
body: JSON.stringify(payload),
})
if (!res.ok) {
const text = await res.text()
ui.showToast('下架失败[' + res.status + ']: ' + text)
return
}
await oils.loadOils()
cardVersion.value++
ui.showToast(newActive ? '已重新上架' : '已下架')
} catch (e) {
ui.showToast('网络错误: ' + e.message)
}
}
async function removeOil(name) {
const ok = await showConfirm(`确定删除精油 "${name}"`)
if (!ok) return
try {
await oils.deleteOil(name)
ui.showToast('已删除')
} catch {
ui.showToast('删除失败')
}
}
// PDF Export
function exportPDF() {
const today = new Date()
const dateStr = today.getFullYear() + String(today.getMonth()+1).padStart(2,'0') + String(today.getDate()).padStart(2,'0')
const title = '精油价目表' + dateStr
const sortedNames = [...oils.oilNames].sort((a, b) => a.localeCompare(b, 'zh'))
let rows = ''
for (const name of sortedNames) {
const meta = getMeta(name)
if (!meta) continue
const en = getEnglishName(name)
const bp = meta.bottlePrice != null ? '¥' + meta.bottlePrice.toFixed(0) : '--'
const rp = meta.retailPrice != null ? '¥' + meta.retailPrice.toFixed(0) : '--'
const vol = volumeLabel(meta.dropCount)
const ppd = oils.pricePerDrop(name) ? '¥' + oils.pricePerDrop(name).toFixed(2) : '--'
rows += `<tr>
<td>${name}</td>
<td>${en}</td>
<td>${bp}</td>
<td>${rp}</td>
<td>${vol}</td>
<td>${ppd}</td>
</tr>`
}
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${title}</title>
<style>
body { font-family: 'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif; padding: 20px; font-size: 11px; color: #333; }
h1 { font-size: 18px; text-align: center; margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; }
th { background: #7a9e7e; color: white; padding: 6px 8px; text-align: center; font-size: 11px; font-weight: 600; }
td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; text-align: center; font-size: 11px; }
td:first-child, th:first-child { text-align: left; font-weight: 500; }
td:nth-child(2), th:nth-child(2) { text-align: left; }
tr:nth-child(even) { background: #f9f9f9; }
tr:hover { background: #e8f5e9; }
@media print { body { padding: 10px; } h1 { font-size: 16px; } }
</style>
</head>
<body>
<h1>doTERRA 精油价目表 ${dateStr}</h1>
<table>
<thead>
<tr><th>精油</th><th>英文名</th><th>会员价</th><th>零售价</th><th>容量</th><th>单价/滴</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
<p style="text-align:center;font-size:10px;color:#aaa;margin-top:12px">共 ${sortedNames.length} 种精油 · doTERRA 配方计算器导出</p>
</body>
</html>`
const w = window.open('', '_blank')
w.document.write(html)
w.document.close()
w.document.title = title
setTimeout(() => w.print(), 500)
}
// ──── Save image logic (identical to RecipeDetailOverlay) ────
// Pre-generated image URLs (same pattern as cardImageUrl in recipe card)
const dilutionImageUrl = ref(null)
const contraImageUrl = ref(null)
const oilCardImageUrl = ref(null)
async function generateImageFromRef(elRef, imageUrlRef) {
const el = elRef.value || elRef
if (!el) return
await nextTick()
await new Promise(r => setTimeout(r, 100))
try {
// Same params as RecipeDetailOverlay.generateCardImage
const canvas = await html2canvas(el, {
backgroundColor: null,
scale: 3,
useCORS: true,
allowTaint: false,
})
imageUrlRef.value = canvas.toDataURL('image/png')
} catch (e) {
console.error('generateImage failed:', e)
}
}
// When modal opens, pre-generate the image (so save button has instant dataUrl)
watch(showDilution, async (v) => {
if (v) {
dilutionImageUrl.value = null
await nextTick()
await new Promise(r => setTimeout(r, 300))
await generateImageFromRef(dilutionCardRef, dilutionImageUrl)
}
})
watch(showContra, async (v) => {
if (v) {
contraImageUrl.value = null
await nextTick()
await new Promise(r => setTimeout(r, 300))
await generateImageFromRef(contraCardRef, contraImageUrl)
}
})
// Save: dataUrl is already cached, navigator.share runs in fresh user gesture
async function saveDilutionImage() {
if (!dilutionImageUrl.value) {
ui.showToast('图片生成中,请稍后再试')
return
}
const { saveImageFromUrl } = await import('../composables/useSaveImage')
await saveImageFromUrl(dilutionImageUrl.value, '精油稀释比例指南')
ui.showToast('已保存图片')
}
async function saveContraImage() {
if (!contraImageUrl.value) {
ui.showToast('图片生成中,请稍后再试')
return
}
const { saveImageFromUrl } = await import('../composables/useSaveImage')
await saveImageFromUrl(contraImageUrl.value, '精油使用禁忌')
ui.showToast('已保存图片')
}
async function saveCardImage(name) {
// Oil card: generate on demand since we don't know which card opens
const el = document.querySelector('.oil-card-modal')
if (!el) { ui.showToast('找不到卡片'); return }
if (!oilCardImageUrl.value) {
await generateImageFromRef({ value: el }, oilCardImageUrl)
}
if (!oilCardImageUrl.value) {
ui.showToast('图片生成失败')
return
}
const { saveImageFromUrl } = await import('../composables/useSaveImage')
await saveImageFromUrl(oilCardImageUrl.value, name + '_精油知识卡')
ui.showToast('已保存图片')
}
</script>
<style scoped>
.oil-reference {
padding: 0 12px 24px;
}
/* ===== Knowledge Cards ===== */
.knowledge-cards {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.kcard {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
background: linear-gradient(135deg, var(--sage-mist, #eef4ee), #f0eeeb);
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.kcard:hover {
border-color: var(--sage, #7a9e7e);
background: linear-gradient(135deg, #e8f5e9, #eef4ee);
box-shadow: var(--shadow, 0 4px 20px rgba(90,60,30,0.08));
}
.kcard-icon {
font-size: 22px;
}
.kcard-title {
font-size: 14px;
font-weight: 600;
color: var(--text-dark, #2c2416);
flex: 1;
font-family: 'Noto Sans SC', sans-serif;
}
.kcard-arrow {
color: var(--text-light, #9a8570);
font-size: 18px;
}
/* ===== Modal Overlay ===== */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.modal-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 520px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.modal-header h3 {
margin: 0;
font-size: 16px;
font-family: 'Noto Serif SC', serif;
color: var(--text-dark, #2c2416);
}
.modal-body {
font-size: 14px;
color: var(--text-dark, #2c2416);
line-height: 1.7;
}
/* ===== Info Table ===== */
.info-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 12px;
}
.info-table th,
.info-table td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid var(--border, #e0d4c0);
font-size: 13px;
}
.info-table th {
font-weight: 600;
color: var(--sage-dark, #5a7d5e);
background: var(--sage-mist, #eef4ee);
}
.info-note {
font-size: 12px;
color: var(--text-light, #9a8570);
margin-top: 8px;
}
.btn-save-img {
display: block;
margin: 16px auto 0;
padding: 8px 20px;
background: var(--sage-mist, #eef4ee);
border: 1.5px solid var(--sage, #7a9e7e);
border-radius: 8px;
color: var(--sage-dark, #5a7d5e);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.btn-save-img:hover {
background: var(--sage, #7a9e7e);
color: #fff;
}
/* ===== Contra sections ===== */
.contra-section {
margin-bottom: 14px;
}
.contra-section h4 {
font-size: 14px;
margin: 0 0 4px;
color: #e65100;
font-family: 'Noto Sans SC', sans-serif;
}
.contra-section p {
font-size: 13px;
color: var(--text-light, #9a8570);
margin: 0;
}
/* ===== Add Oil Form ===== */
.add-oil-form {
margin-bottom: 16px;
padding: 14px;
background: var(--sage-mist, #eef4ee);
border-radius: 12px;
border: 1.5px solid var(--border, #e0d4c0);
}
/* Hide number input spinners in add form */
.add-oil-form input[type="number"]::-webkit-inner-spin-button,
.add-oil-form input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.add-oil-form input[type="number"] {
-moz-appearance: textfield;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--text-dark, #2c2416);
margin: 0 0 10px;
font-family: 'Noto Serif SC', serif;
}
.form-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.form-input {
flex: 1;
width: 100%;
min-width: 100px;
padding: 8px 12px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 8px;
box-sizing: border-box;
font-size: 13px;
font-family: inherit;
outline: none;
background: #fff;
}
.form-input:focus {
border-color: var(--sage, #7a9e7e);
}
.form-input-sm {
width: 80px;
padding: 8px 10px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
text-align: center;
background: #fff;
}
.form-input-sm:focus {
border-color: var(--sage, #7a9e7e);
}
.form-select {
padding: 8px 10px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 8px;
font-size: 13px;
font-family: inherit;
outline: none;
background: #fff;
cursor: pointer;
}
.form-select:focus {
border-color: var(--sage, #7a9e7e);
}
.form-group {
margin-bottom: 12px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text-dark, #2c2416);
margin-bottom: 4px;
}
/* ===== Toolbar ===== */
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 14px;
align-items: center;
}
.search-box {
display: flex;
align-items: center;
background: #fff;
border-radius: 10px;
padding: 2px 8px;
border: 1.5px solid var(--border, #e0d4c0);
flex: 1;
}
.search-input {
flex: 1;
border: none;
background: transparent;
padding: 8px 6px;
font-size: 14px;
outline: none;
font-family: inherit;
}
.search-clear-btn {
border: none;
background: transparent;
cursor: pointer;
color: var(--text-light, #9a8570);
padding: 4px;
}
.view-toggle {
display: flex;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 10px;
overflow: hidden;
}
.toggle-btn {
border: none;
background: #fff;
padding: 8px 14px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
color: var(--text-light, #9a8570);
transition: all 0.15s;
white-space: nowrap;
}
.toggle-btn.active {
background: linear-gradient(135deg, var(--sage, #7a9e7e), var(--sage-dark, #5a7d5e));
color: #fff;
}
.btn-pdf {
border: 1.5px solid var(--border, #e0d4c0);
background: #fff;
border-radius: 10px;
padding: 7px 12px;
font-size: 16px;
cursor: pointer;
transition: all 0.15s;
}
.btn-pdf:hover {
border-color: var(--sage, #7a9e7e);
background: var(--sage-mist, #eef4ee);
}
/* ===== Oil Grid ===== */
.oils-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
.oil-chip {
background: white;
border-radius: 10px;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(90,60,30,0.06);
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
transition: all 0.15s;
position: relative;
}
.oil-chip:hover {
box-shadow: 0 4px 16px rgba(90,60,30,0.12);
}
.oil-chip--inactive {
opacity: 0.7;
background: #f5f5f5 !important;
border: 1px solid #e0e0e0;
}
.oil-chip--incomplete {
background: #fff5f5 !important;
}
.oil-name-line {
font-size: 14px;
font-weight: 500;
color: var(--text-dark);
white-space: nowrap;
}
.oil-en-line {
font-size: 10px;
color: var(--text-light);
white-space: nowrap;
}
.oil-price-line {
font-size: 13px;
color: var(--sage-dark);
font-weight: 600;
white-space: nowrap;
}
.oil-price-unit {
font-size: 10px;
font-weight: 400;
color: var(--text-light);
}
.oil-retail-line {
font-size: 11px;
color: var(--text-light);
text-decoration: line-through;
white-space: nowrap;
}
/* Desktop: show text buttons, hide icon buttons */
.toolbar-btn-text {
padding: 7px 14px;
border-radius: 8px;
font-size: 12px;
cursor: pointer;
font-family: inherit;
border: 1.5px solid var(--sage);
background: white;
color: var(--sage-dark);
white-space: nowrap;
}
.toolbar-btn-text:hover { background: var(--sage-mist); }
.toolbar-btn-icon {
display: none;
background: white;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
cursor: pointer;
padding: 4px 7px;
line-height: 1;
}
.toolbar-btn-icon:hover {
border-color: var(--sage);
background: var(--sage-mist);
}
@media (max-width: 480px) {
.oil-name-line { font-size: 13px; }
.oil-en-line { font-size: 9px; }
.oil-price-line { font-size: 12px; }
.oil-retail-line { font-size: 10px; }
.oils-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; }
.oil-chip { padding: 10px 12px; }
.toolbar-btn-text { display: none; }
.toolbar-btn-icon { display: inline-block; }
}
.oil-chip-actions {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: none;
gap: 2px;
background: white;
border-radius: 6px;
padding: 2px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.oil-chip-actions button {
background: none;
border: none;
cursor: pointer;
font-size: 11px;
padding: 4px 6px;
border-radius: 4px;
color: var(--text-light);
}
.oil-chip-actions button:hover {
background: var(--sage-mist);
}
.oil-chip:hover .oil-chip-actions {
display: flex;
}
.oil-chip-name {
font-weight: 600;
font-size: 14px;
color: var(--text-dark, #2c2416);
font-family: 'Noto Serif SC', serif;
margin-bottom: 1px;
padding-right: 22px;
}
.oil-chip-en {
font-size: 11px;
color: var(--text-light, #9a8570);
margin-bottom: 4px;
font-family: 'Noto Sans SC', sans-serif;
}
.oil-chip-price {
font-size: 14px;
color: var(--sage-dark, #5a7d5e);
font-weight: 500;
}
.oil-unit {
font-size: 11px;
color: var(--text-light, #9a8570);
}
.oil-chip-retail {
font-size: 11px;
color: var(--text-light, #9a8570);
margin-top: 2px;
}
.oil-chip-retail s {
color: #c0a080;
}
.oil-chip-volume {
font-size: 10px;
color: var(--text-light, #9a8570);
margin-top: 3px;
padding: 1px 6px;
background: var(--sage-mist, #eef4ee);
border-radius: 4px;
display: inline-block;
}
.oil-actions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.oil-chip:hover .oil-actions,
/* When badge is present, push actions below */
.btn-icon-sm {
border: none;
background: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-size: 14px;
padding: 4px;
border-radius: 6px;
}
.btn-icon-sm:hover {
background: var(--sage-mist, #eef4ee);
}
/* ===== Oil Knowledge Card Modal ===== */
.oil-card-modal {
background: #fff;
border-radius: 16px;
max-width: 380px;
width: 92%;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.22);
}
.oil-card-header {
background: linear-gradient(135deg, var(--sage, #7a9e7e), var(--sage-dark, #5a7d5e));
padding: 24px 20px;
border-radius: 16px 16px 0 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.oil-card-header-content {
display: flex;
align-items: center;
gap: 14px;
}
.oil-card-emoji {
font-size: 40px;
line-height: 1;
}
.oil-card-title {
margin: 0;
font-size: 20px;
font-family: 'Noto Serif SC', serif;
color: #fff;
font-weight: 700;
}
.oil-card-en {
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
margin-top: 2px;
font-family: 'Noto Sans SC', sans-serif;
}
.oil-card-price-info {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
margin-top: 4px;
}
.btn-close-light {
background: rgba(255, 255, 255, 0.2);
color: #fff;
border: none;
}
.btn-close-light:hover {
background: rgba(255, 255, 255, 0.35);
}
/* Method badges */
.oil-card-methods {
display: flex;
gap: 8px;
padding: 14px 20px;
border-bottom: 1px solid var(--border, #e0d4c0);
}
.method-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
font-family: 'Noto Sans SC', sans-serif;
}
.method-aroma {
background: #e8f5e9;
color: #2e7d32;
}
.method-internal {
background: #fff3e0;
color: #e65100;
}
.method-topical {
background: #e3f2fd;
color: #1565c0;
}
.oil-card-body {
padding: 16px 20px 24px;
}
.oil-card-section {
margin-bottom: 18px;
}
.oil-card-section-title {
font-size: 15px;
font-weight: 600;
color: var(--text-dark, #2c2416);
margin: 0 0 8px;
font-family: 'Noto Serif SC', serif;
}
.oil-card-list {
margin: 0;
padding-left: 18px;
font-size: 13px;
color: var(--text-dark, #2c2416);
line-height: 1.8;
}
.oil-card-list li {
margin-bottom: 2px;
}
/* Caution box */
.oil-card-caution {
background: #fffde7;
border: 1px solid #ffe082;
border-radius: 10px;
padding: 12px 16px;
margin-top: 12px;
}
.oil-card-caution-title {
font-size: 14px;
font-weight: 600;
color: #f57f17;
margin: 0 0 6px;
}
.oil-card-caution p {
font-size: 13px;
color: #795548;
margin: 0;
line-height: 1.6;
}
/* ===== Simple Detail Panel ===== */
.oil-detail-panel {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 440px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.detail-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
}
.detail-header h3 {
margin: 0;
font-size: 18px;
font-family: 'Noto Serif SC', serif;
color: var(--text-dark, #2c2416);
}
.detail-en {
font-size: 12px;
color: var(--text-light, #9a8570);
margin-top: 2px;
}
.detail-body h4 {
font-size: 14px;
color: var(--text-dark, #2c2416);
font-family: 'Noto Serif SC', serif;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--sage-mist, #eef4ee);
font-size: 14px;
}
.detail-label {
color: var(--text-light, #9a8570);
}
.detail-value {
font-weight: 600;
color: var(--text-dark, #2c2416);
}
.detail-recipes {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-recipe-item {
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: var(--sage-mist, #eef4ee);
border-radius: 8px;
font-size: 13px;
}
.dr-name {
color: var(--text-dark, #2c2416);
}
.dr-drops {
color: var(--sage-dark, #5a7d5e);
font-weight: 500;
}
/* ===== Common Buttons ===== */
.btn-close {
border: none;
background: #f0eeeb;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-light, #9a8570);
flex-shrink: 0;
}
.btn-close:hover {
background: #e5e0da;
}
.btn-primary {
background: linear-gradient(135deg, var(--sage, #7a9e7e) 0%, var(--sage-dark, #5a7d5e) 100%);
color: #fff;
border: none;
border-radius: 8px;
padding: 8px 18px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
.btn-outline {
background: #fff;
color: var(--text-light, #9a8570);
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 8px;
padding: 8px 18px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
}
.btn-outline:hover {
border-color: var(--sage, #7a9e7e);
}
.empty-hint {
grid-column: 1 / -1;
text-align: center;
color: var(--text-light, #9a8570);
font-size: 13px;
padding: 24px 0;
}
/* ===== Responsive ===== */
@media (max-width: 600px) {
.oils-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
.form-row {
flex-direction: column;
}
.form-input-sm,
.form-select {
width: 100%;
}
.oil-card-modal {
max-width: 100%;
margin: 8px;
}
}
</style>