Files
oil-formula-calculator/frontend/src/views/OilReference.vue
Hera Zhao 6563a6f7d2
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
PR Preview / deploy-preview (pull_request) Successful in 20s
Test / e2e-test (push) Failing after 1m2s
Grant senior_editor oil editing, PDF export, and public recipe management
Add canManage computed (senior_editor + admin) to auth store and use it
for oil edit/delete buttons, PDF export, and public recipe section
visibility. Backend already allowed these operations for senior_editor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:59:49 +00:00

1308 lines
32 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 class="knowledge-cards">
<div class="kcard" @click="showDilution = true">
<span class="kcard-icon">💧</span>
<span class="kcard-title">稀释比例</span>
<span class="kcard-arrow"></span>
</div>
<div class="kcard" @click="showContra = true">
<span class="kcard-icon"></span>
<span class="kcard-title">使用禁忌</span>
<span class="kcard-arrow"></span>
</div>
</div>
<!-- Dilution Ratio Modal -->
<div v-if="showDilution" class="modal-overlay" @click.self="showDilution = false">
<div class="modal-panel">
<div class="modal-header">
<h3>💧 稀释比例参考</h3>
<button class="btn-close" @click="showDilution = false"></button>
</div>
<div class="modal-body">
<table class="info-table">
<thead>
<tr><th>用途</th><th>比例</th><th>每10ml基底油</th></tr>
</thead>
<tbody>
<tr><td>面部护肤</td><td>1%</td><td>2滴精油</td></tr>
<tr><td>身体按摩</td><td>2-3%</td><td>4-6滴精油</td></tr>
<tr><td>局部疼痛</td><td>3-5%</td><td>6-10滴精油</td></tr>
<tr><td>急救用途</td><td>5-10%</td><td>10-20滴精油</td></tr>
<tr><td>儿童(2-6)</td><td>0.5-1%</td><td>1-2滴精油</td></tr>
<tr><td>婴儿(&lt;2)</td><td>0.25%</td><td>0.5滴精油</td></tr>
</tbody>
</table>
<p class="info-note">* 1ml 约等于 {{ DROPS_PER_ML }} </p>
<button class="btn-save-img" @click="saveDilutionImage">💾 保存图片</button>
</div>
</div>
</div>
<!-- Safety Cautions Modal -->
<div v-if="showContra" class="modal-overlay" @click.self="showContra = false">
<div class="modal-panel">
<div class="modal-header">
<h3> 使用禁忌</h3>
<button class="btn-close" @click="showContra = false"></button>
</div>
<div class="modal-body">
<div class="contra-section">
<h4>光敏性精油涂抹后12小时内避免阳光直射</h4>
<p>柠檬佛手柑葡萄柚莱姆甜橙野橘</p>
</div>
<div class="contra-section">
<h4>孕妇慎用</h4>
<p>快乐鼠尾草迷迭香肉桂丁香百里香牛至冬青</p>
</div>
<div class="contra-section">
<h4>儿童慎用</h4>
<p>椒样薄荷6岁以下避免尤加利10岁以下慎用冬青肉桂</p>
</div>
<div class="contra-section">
<h4>宠物禁用</h4>
<p>茶树尤加利肉桂丁香百里香冬青对猫有毒</p>
</div>
<button class="btn-save-img" @click="saveContraImage">💾 保存图片</button>
</div>
</div>
</div>
<!-- Add Oil Form (admin/senior_editor only) -->
<div v-if="auth.canEdit" class="add-oil-form">
<h3 class="section-title">添加精油</h3>
<div class="form-row">
<input v-model="newOilName" class="form-input" placeholder="精油名称" />
<input v-model.number="newBottlePrice" class="form-input-sm" type="number" placeholder="瓶价 ¥" />
<select v-model="newVolume" 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>
<input
v-if="newVolume === 'custom'"
v-model.number="newCustomDrops"
class="form-input-sm"
type="number"
placeholder="滴数"
/>
<input v-model.number="newRetailPrice" class="form-input-sm" type="number" placeholder="零售价 ¥" />
<button class="btn-primary" @click="addOil" :disabled="!newOilName.trim()">添加</button>
</div>
</div>
<!-- Search + View Toggle + PDF -->
<div class="toolbar">
<div class="search-box">
<input
class="search-input"
v-model="searchQuery"
placeholder="搜索精油..."
/>
<button v-if="searchQuery" class="search-clear-btn" @click="searchQuery = ''"></button>
</div>
<div class="view-toggle">
<button
class="toggle-btn"
:class="{ active: viewMode === 'bottle' }"
@click="viewMode = 'bottle'"
>瓶价</button>
<button
class="toggle-btn"
:class="{ active: viewMode === 'drop' }"
@click="viewMode = 'drop'"
>滴价</button>
</div>
<button v-if="auth.canManage" class="btn-pdf" @click="exportPDF" title="导出PDF">
📄
</button>
</div>
<!-- Oil Grid -->
<div class="oil-grid">
<div
v-for="name in filteredOilNames"
:key="name"
class="oil-chip"
:class="{ 'oil-chip--inactive': getMeta(name)?.isActive === false }"
@click="openOilDetail(name)"
>
<span v-if="getOilCard(name)" class="oil-badge" title="有知识卡片">📖</span>
<div class="oil-chip-name">{{ name }}</div>
<div class="oil-chip-en">{{ getEnglishName(name) }}</div>
<div class="oil-chip-price" v-if="viewMode === 'bottle'">
<template v-if="getMeta(name)?.bottlePrice != null">
¥ {{ getMeta(name).bottlePrice.toFixed(2) }}
</template>
<template v-else>--</template>
</div>
<div class="oil-chip-price" v-else>
<template v-if="oils.pricePerDrop(name)">
¥ {{ oils.pricePerDrop(name).toFixed(4) }}<span class="oil-unit">/</span>
</template>
<template v-else>--</template>
</div>
<div
v-if="getMeta(name)?.retailPrice && getMeta(name).retailPrice !== getMeta(name).bottlePrice"
class="oil-chip-retail"
>
<s>零售 ¥ {{ getMeta(name).retailPrice.toFixed(2) }}</s>
</div>
<div v-else-if="getMeta(name)?.retailPrice" class="oil-chip-retail">
零售 ¥ {{ getMeta(name).retailPrice.toFixed(2) }}
</div>
<div class="oil-chip-volume" v-if="getMeta(name)?.dropCount">
{{ volumeLabel(getMeta(name).dropCount) }}
</div>
<div class="oil-actions" v-if="auth.canManage" @click.stop>
<button class="btn-icon-sm" @click="editOil(name)" title="编辑"></button>
<button class="btn-icon-sm" @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>
</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" style="max-width:400px">
<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.number="editBottlePrice" class="form-input" type="number" />
</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="editRetailPrice" class="form-input" type="number" />
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:16px">
<button class="btn-outline" @click="editingOilName = null">取消</button>
<button class="btn-primary" @click="saveEditOil">保存</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
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 } 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)
// Search & view
const searchQuery = ref('')
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 newBottlePrice = ref(null)
const newVolume = ref('5')
const newCustomDrops = ref(null)
const newRetailPrice = ref(null)
// Edit oil
const editingOilName = ref(null)
const editBottlePrice = ref(0)
const editVolume = ref('5')
const editDropCount = ref(0)
const editRetailPrice = ref(null)
// 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) {
return DROPS_TO_VOLUME[dropCount] || (dropCount + '滴')
}
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) {
// First check the oil card for English name
const card = getOilCard(name)
if (card && card.en) return card.en
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
function openOilDetail(name) {
const card = getOilCard(name)
if (card) {
activeCardName.value = name
activeCard.value = card
selectedOilName.value = null
} 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
)
ui.showToast(`已添加: ${newOilName.value}`)
newOilName.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
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
}
async function saveEditOil() {
const dropCount = getEditDropCount()
try {
await oils.saveOil(
editingOilName.value,
editBottlePrice.value,
dropCount,
editRetailPrice.value
)
ui.showToast('已更新')
editingOilName.value = null
} catch (e) {
ui.showToast('更新失败')
}
}
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 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 bp = meta.bottlePrice != null ? '¥ ' + meta.bottlePrice.toFixed(2) : '--'
const rp = meta.retailPrice != null ? '¥ ' + meta.retailPrice.toFixed(2) : '--'
const vol = volumeLabel(meta.dropCount)
const dc = meta.dropCount || '--'
const ppd = oils.pricePerDrop(name) ? '¥ ' + oils.pricePerDrop(name).toFixed(4) : '--'
rows += `<tr>
<td>${name}</td>
<td>${bp}</td>
<td>${rp}</td>
<td>${vol}</td>
<td>${dc}</td>
<td>${ppd}</td>
</tr>`
}
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>精油价格表</title>
<style>
body { font-family: 'Noto Sans SC', sans-serif; padding: 20px; }
h1 { font-family: 'Noto Serif SC', serif; font-size: 20px; color: #2c2416; margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #e0d4c0; }
th { background: #eef4ee; color: #5a7d5e; font-weight: 600; }
tr:hover { background: #f9f7f4; }
@media print { body { padding: 0; } }
</style>
</head>
<body>
<h1>精油价格表</h1>
<table>
<thead>
<tr><th>精油</th><th>每瓶价格</th><th>零售价</th><th>容量</th><th>滴数</th><th>单价/滴</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
</body>
</html>`
const w = window.open('', '_blank')
w.document.write(html)
w.document.close()
w.onload = () => w.print()
}
// Placeholder save image functions
function saveDilutionImage() {
ui.showToast('保存图片功能开发中')
}
function saveContraImage() {
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);
}
.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;
min-width: 100px;
padding: 8px 12px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 8px;
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 ===== */
.oil-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 8px;
}
.oil-chip {
padding: 12px;
background: #fff;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
position: relative;
}
.oil-chip:hover {
border-color: var(--sage, #7a9e7e);
box-shadow: var(--shadow, 0 4px 20px rgba(90,60,30,0.08));
}
.oil-chip--inactive {
opacity: 0.45;
filter: grayscale(0.6);
}
.oil-badge {
position: absolute;
top: 8px;
right: 8px;
font-size: 14px;
line-height: 1;
}
.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 {
opacity: 1;
}
/* When badge is present, push actions below */
.oil-chip:hover .oil-badge {
display: none;
}
.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: 480px;
width: 100%;
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) {
.oil-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>