@@ -7,7 +7,7 @@
< / div >
< div v-if = "showPending && pendingRecipes.length" class="pending-list" >
< div v-for = "r in pendingRecipes" :key="r._id" class="pending-item" >
< span class = "pending-name" > { { r . name } } < / span >
< span class = "pending-name clickable" @click ="openRecipeDetail(r) " > {{ r. name }} < / span >
< span class = "pending-owner" > { { r . _owner _name } } < / span >
< button class = "btn-sm btn-approve" @click ="approveRecipe(r)" > 通过 < / button >
< button class = "btn-sm btn-reject" @click ="rejectRecipe(r)" > 拒绝 < / button >
@@ -31,23 +31,25 @@
< / div >
< / div >
< ! - - Tag Filter Bar - - >
< div class = "tag-filter-bar" >
< button class = "tag-toggle-btn" @click ="showTagFilter = !showTagFilter" >
🏷 ️ 标签筛选 {{ showTagFilter ? ' ▾ ' : ' ▸ ' }}
< / button >
< div v-if = "showTagFilter" class="tag-list" >
< span
v-for = "tag in recipeStore.allTags"
:key = "tag"
class = "tag-chip"
: class = "{ active: selectedTags.includes(tag) }"
@click ="toggleTag(tag)"
> { { tag } } < / span >
< / div >
< / div >
< / template >
< ! - - Tag Filter & Select All ( visible to all ) - - >
< div class = "tag-filter-bar" >
< button class = "tag-toggle-btn" @click ="showTagFilter = !showTagFilter" >
🏷 ️ 标签筛选 {{ showTagFilter ? ' ▾ ' : ' ▸ ' }}
< / button >
< button class = "btn-sm btn-outline" @click ="toggleSelectAllDiary" > 全选 / 取消 < / button >
< div v-if = "showTagFilter" class="tag-list" >
< span
v-for = "tag in recipeStore.allTags"
:key = "tag"
class = "tag-chip"
: class = "{ active: selectedTags.includes(tag) }"
@click ="toggleTag(tag)"
> { { tag } } < / span >
< / div >
< / div >
<!-- Batch Operations -- >
< div v-if = "selectedIds.size > 0 || selectedDiaryIds.size > 0" class="batch-bar" >
< span > 已选 { { selectedIds . size + selectedDiaryIds . size } } 项 < / span >
@@ -60,11 +62,12 @@
< ! - - My Recipes Section ( from diary ) - - >
< div class = "recipe-section" >
< h3 class = "section-title" >
< h3 class = "section-title clickable" @click ="showMyRecipes = !showMyRecipes " >
< span > 📖 我的配方 ( { { myRecipes . length } } ) < / span >
< span v-if = "!auth.isAdmin && sharedCount > 0 " class="contrib-tag" > 已贡献 {{ sharedCount }} 条 < / span >
< butto n class = "btn-sm btn-outline" @click ="toggleSelectAllDiary" > 全选 / 取消 < / butto n>
< span v-if = "!auth.isAdmin" class="contrib-tag" > 已贡献 {{ sharedCount.adopted }} / {{ sharedCount.total }} 条 < / span >
< spa n class = "toggle-icon" > { { showMyRecipes ? '▾' : '▸' } } < / spa n>
< / h3 >
< template v-if = "showMyRecipes" >
< div class = "recipe-list" >
< div
v-for = "d in myFilteredRecipes"
@@ -96,12 +99,16 @@
< / div >
< div v-if = "myFilteredRecipes.length === 0" class="empty-hint" > 暂无个人配方 < / div >
< / div >
< / template >
< / div >
< ! - - Public Recipes Section ( editor + ) - - >
< div v-if = "auth.canEdit" class="recipe-section" >
< h3 class = "section-title" > 🌿 公共配方库 ( { { publicRecipes . length } } ) < / h3 >
< div class = "recipe-list" >
< h3 class = "section-title clickable" @click ="showPublicRecipes = !showPublicRecipes" >
< span > 🌿 公共配方库 ( { { publicRecipes . length } } ) < / span >
< span class = "toggle-icon" > { { showPublicRecipes ? '▾' : '▸' } } < / span >
< / h3 >
< div v-if = "showPublicRecipes" class="recipe-list" >
< div
v-for = "r in publicFilteredRecipes"
:key = "r._id"
@@ -181,14 +188,23 @@
< div class = "divider-text" > 或手动输入 < / div >
< / template >
<!-- Manual Form ( same a s RecipeDetailOverlay editor ) -- >
< div class = "form-group " >
< label > 配方名称 < / label >
< input v-model = "formName" class="form -input" placeholder="配方名称" / >
<!-- Manual Form ( matche s RecipeDetailOverlay editor ) -- >
< div class = "editor-header " >
< div style = "flex:1;min-width:0" >
< input v-model = "formName" type="text" class="editor-name -input" placeholder="配方名称" / >
< / div >
< div class = "editor-header-actions" >
< button class = "action-btn action-btn-primary action-btn-sm" @click ="saveCurrentRecipe" > 💾 保存 < / button >
< button class = "action-btn action-btn-sm" @click ="closeOverlay" > ✕ 取消 < / button >
< / div >
< / div >
< div class = "form-grou p" >
< label > 成分 < / label >
< div class = "editor-ti p" >
💡 推荐按照单次用量 ( 椰子油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 >
@@ -216,42 +232,121 @@
< / div >
< / div >
< / td >
< td > < input v -model .number = " ing.drops " type = "number" min = "0" step = "0.5" class = "form-input-sm " / > < / td >
< td class = "cell-mute d" > { { ing . oil ? oils . fmtPrice ( oils . pricePerDrop ( ing . oil ) ) : '-' } } < / td >
< td class = "cell -cost" > { { ing . oil && ing . drops ? oils . fmtPrice ( oils . pricePerDrop ( ing . oil ) * ing . drops ) : '-' } } < / td >
< td > < button class = "btn-icon-sm " @click ="formIngredients.splice(i, 1)" > ✕ < / button > < / td >
< td > < input v -model .number = " ing.drops " type = "number" min = "0.5 " step = "0.5" class = "editor-drops " / > < / td >
< td class = "ing-pp d" > { { ing . oil ? oils . fmtPrice ( oils . pricePerDrop ( ing . oil ) ) : '-' } } < / td >
< td class = "ing -cost" > { { ing . oil && ing . drops ? oils . fmtPrice ( oils . pricePerDrop ( ing . oil ) * ing . drops ) : '-' } } < / td >
< td > < button class = "remove-row-btn " @click ="formIngredients.splice(i, 1)" > ✕ < / button > < / td >
< / tr >
< / tbody >
< / table >
< button class = "btn-outline btn-sm" @click ="formIngredients.push({ oil: '', drops: 1, _search: '', _open: false })" > + 添加精油 < / button >
<div class = "form-total" > 总成本 : { { formTotalCost } } < / div >
<! - - Add ingredient row - - >
< div v-if = "showAddIngRow" class="add-ingredient-row" >
< div class = "oil-search-wrap" style = "flex:1" >
< input
v-model = "newIngSearch"
class = "editor-input"
placeholder = "搜索精油名称..."
@focus ="newIngDropdownOpen = true"
@input ="newIngDropdownOpen = true"
@blur ="setTimeout(() => newIngDropdownOpen = false, 150)"
/ >
< div v-if = "newIngDropdownOpen && filteredOilNames(newIngSearch).length" class="oil-dropdown" >
< div
v-for = "name in filteredOilNames(newIngSearch)"
:key = "name"
class = "oil-option"
@mousedown.prevent ="newIngOil = name; newIngSearch = name; newIngDropdownOpen = false"
> { { name } } < / 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 ="confirmAddIng" > 确认 < / button >
< button class = "action-btn action-btn-sm" @click ="showAddIngRow = false" > 取消 < / button >
< / div >
< button v-else class = "add-row-btn" @click ="showAddIngRow = true" > + 添加精油 < / button >
< / div >
<div class = "form-group" >
< label > 备注 < / label >
< textarea v-model = "formNote" class="form-input" rows="2" placeholder="配方备注" > < / textarea >
<! - - Volume & Dilution - - >
< div class = "editor-section" >
< label class = "editor-label" > 容量与稀释 < / label >
< div class = "volume-controls" >
< button class = "volume-btn" : class = "{ active: formVolume === 'single' }" @click ="formVolume = 'single'" > 单次 < / button >
< button class = "volume-btn" : class = "{ active: formVolume === '5' }" @click ="formVolume = '5'" > 5ml < / button >
< button class = "volume-btn" : class = "{ active: formVolume === '10' }" @click ="formVolume = '10'" > 10ml < / button >
< button class = "volume-btn" : class = "{ active: formVolume === '30' }" @click ="formVolume = '30'" > 30ml < / button >
< button class = "volume-btn" : class = "{ active: formVolume === 'custom' }" @click ="formVolume = 'custom'" > 自定义 < / button >
< / div >
< div v-if = "formVolume === 'custom'" class="custom-volume-row" >
< input v -model .number = " formCustomVolume " type = "number" min = "1" class = "drops-sm" placeholder = "数量" / >
< select v-model = "formCustomUnit" class="select-sm" >
< option value = "drops" > 滴 < / option >
< option value = "ml" > ml < / option >
< / select >
< / div >
< div class = "dilution-row" >
< span class = "dilution-label" > 稀释 1 : < / span >
< select v -model .number = " formDilution " class = "select-sm" >
< option v-for = "n in 20" :key="n" :value="n" > {{ n }} < / option >
< / select >
< button class = "action-btn action-btn-primary action-btn-sm" @click ="applyVolumeDilution" > 应用 < / button >
< / div >
< div class = "hint" style = "margin-top:6px;font-size:11px;color:#999" > { { formDilutionHint } } < / div >
< / div >
< div class = "form-group" >
< label > 标签 < / label >
< div class = "tag-list" >
< span
v-for = "tag in recipeStore.allTags"
:key = "tag"
class = "tag-chip"
: class = "{ active: formTags.includes(tag) }"
@click ="toggleFormTag(tag)"
> { { tag } } < / span >
<!-- Notes -- >
< div class = "editor-section" >
< label class = "editor-label" > 备注 < / label >
< textarea v-model = "formNote" 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 formTags" :key="tag" class="editor-tag" >
{{ tag }}
< span class = "tag-remove" @click ="toggleFormTag(tag)" > × < / span >
< / span >
< / div >
< div class = "candidate-tags" v-if = "formCandidateTags.length" >
< span v-for = "tag in formCandidateTags" :key="tag" class="candidate-tag" @click="toggleFormTag(tag)" > + {{ tag }} < / span >
< / div >
< / div >
<div class = "overlay-footer" >
< button class = "btn-outline" @click ="closeOverlay" > 取消 < / button >
< button class = "btn-primary" @click ="saveCurrentRecipe" > 保存 < / button >
<! - - Total cost - - >
< div class = "editor-total" >
总计 : { { formTotalCost } }
< / div >
< / div >
< / div >
<!-- Review History ( admin only ) -- >
< div v-if = "auth.isAdmin" class="recipe-section" >
< h3 class = "section-title clickable" @click ="showReviewHistory = !showReviewHistory" >
< span > 📋 审核记录 < / span >
< span class = "toggle-icon" > { { showReviewHistory ? '▾' : '▸' } } < / span >
< / h3 >
< div v-if = "showReviewHistory" class="review-history" >
< div v-for = "r in reviewHistory" :key="r.id" class="review-log-item" >
< span : class = "r.action === 'adopt_recipe' ? 'log-approve' : 'log-reject'" >
{ { r . action === 'adopt_recipe' ? '✅ 采纳' : '❌ 拒绝' } }
< / span >
< span class = "log-recipe" > { { r . target _name } } < / span >
< span class = "log-from" v-if = "r.detail" > {{ parseReviewDetail ( r.detail ) }} < / span >
< span class = "log-time" > { { formatDate ( r . created _at ) } } < / span >
< / div >
< div v-if = "reviewHistory.length === 0" class="empty-hint" > 暂无审核记录 < / div >
< / div >
< / div >
< ! - - Recipe Detail Overlay - - >
< RecipeDetailOverlay
v-if = "previewRecipeIndex !== null"
:recipeIndex = "previewRecipeIndex"
@close ="previewRecipeIndex = null"
/ >
<!-- Tag Picker Overlay -- >
< TagPicker
v-if = "showTagPicker"
@@ -277,6 +372,7 @@ import { parseSingleBlock, parseMultiRecipes } from '../composables/useSmartPast
import { matchesPinyinInitials } from '../composables/usePinyinMatch'
import RecipeCard from '../components/RecipeCard.vue'
import TagPicker from '../components/TagPicker.vue'
import RecipeDetailOverlay from '../components/RecipeDetailOverlay.vue'
const auth = useAuthStore ( )
const oils = useOilsStore ( )
@@ -302,6 +398,15 @@ const formNote = ref('')
const formTags = ref ( [ ] )
const smartPasteText = ref ( '' )
const parsedRecipes = ref ( [ ] )
const showAddIngRow = ref ( false )
const newIngOil = ref ( '' )
const newIngSearch = ref ( '' )
const newIngDrops = ref ( 1 )
const newIngDropdownOpen = ref ( false )
const formVolume = ref ( 'single' )
const formCustomVolume = ref ( 100 )
const formCustomUnit = ref ( 'drops' )
const formDilution = ref ( 3 )
const formTotalCost = computed ( ( ) => {
const cost = formIngredients . value
@@ -458,6 +563,11 @@ function resetForm() {
formNote . value = ''
formTags . value = [ ]
smartPasteText . value = ''
parsedRecipes . value = [ ]
showAddIngRow . value = false
newIngOil . value = ''
newIngSearch . value = ''
newIngDrops . value = 1
}
function handleSmartPaste ( ) {
@@ -506,6 +616,83 @@ function onOilBlur(ing) {
} , 150 )
}
const formCandidateTags = computed ( ( ) =>
recipeStore . allTags . filter ( t => ! formTags . value . includes ( t ) )
)
const DROPS _PER _ML = 18.6
const formDilutionHint = computed ( ( ) => {
const eoIngs = formIngredients . value . filter ( i => i . oil && i . oil !== '椰子油' && i . drops > 0 )
const eoDrops = eoIngs . reduce ( ( s , i ) => s + i . drops , 0 )
if ( formVolume . value === 'single' ) {
const cocoDrops = Math . round ( eoDrops * formDilution . value )
const totalDrops = eoDrops + cocoDrops
return ` 单次用量:纯精油约 ${ eoDrops } 滴 + 椰子油约 ${ cocoDrops } 滴,共 ${ totalDrops } 滴 ( ${ ( totalDrops / DROPS _PER _ML ) . toFixed ( 1 ) } ml),稀释 1: ${ formDilution . value } `
}
let totalDrops
if ( formVolume . value === 'custom' ) {
totalDrops = formCustomUnit . value === 'ml' ? Math . round ( formCustomVolume . value * DROPS _PER _ML ) : formCustomVolume . value
} else {
totalDrops = Math . round ( Number ( formVolume . value ) * DROPS _PER _ML )
}
const targetEo = Math . round ( totalDrops / ( 1 + formDilution . value ) )
const cocoDrops = totalDrops - targetEo
return ` 总容量 ${ totalDrops } 滴 ( ${ ( totalDrops / DROPS _PER _ML ) . toFixed ( 1 ) } ml),纯精油约 ${ targetEo } 滴 + 椰子油约 ${ cocoDrops } 滴,稀释 1: ${ formDilution . value } `
} )
function applyVolumeDilution ( ) {
const eoIngs = formIngredients . value . filter ( i => i . oil && i . oil !== '椰子油' )
if ( eoIngs . length === 0 ) { ui . showToast ( '请先添加精油' ) ; return }
let targetTotalDrops
if ( formVolume . value === 'single' ) {
const targetEoDrops = 10
const currentEoTotal = eoIngs . reduce ( ( s , i ) => s + ( i . drops || 0 ) , 0 )
if ( currentEoTotal <= 0 ) return
const scale = targetEoDrops / currentEoTotal
eoIngs . forEach ( i => { i . drops = Math . max ( 0.5 , Math . round ( i . drops * scale * 2 ) / 2 ) } )
const actualEo = eoIngs . reduce ( ( s , i ) => s + i . drops , 0 )
setFormCoconut ( actualEo * formDilution . value )
ui . showToast ( '已应用单次用量' )
return
}
if ( formVolume . value === 'custom' ) {
targetTotalDrops = formCustomUnit . value === 'ml' ? Math . round ( formCustomVolume . value * DROPS _PER _ML ) : formCustomVolume . value
} else {
targetTotalDrops = Math . round ( Number ( formVolume . value ) * DROPS _PER _ML )
}
const targetEoDrops = Math . round ( targetTotalDrops / ( 1 + formDilution . value ) )
const currentEoTotal = eoIngs . reduce ( ( s , i ) => s + ( i . drops || 0 ) , 0 )
if ( currentEoTotal <= 0 ) return
const scale = targetEoDrops / currentEoTotal
eoIngs . forEach ( i => { i . drops = Math . max ( 0.5 , Math . round ( i . drops * scale * 2 ) / 2 ) } )
const actualEo = eoIngs . reduce ( ( s , i ) => s + i . drops , 0 )
setFormCoconut ( targetTotalDrops - actualEo )
ui . showToast ( '已应用容量设置' )
}
function setFormCoconut ( drops ) {
drops = Math . max ( 0 , Math . round ( drops ) )
const idx = formIngredients . value . findIndex ( i => i . oil === '椰子油' )
if ( idx >= 0 ) {
formIngredients . value [ idx ] . drops = drops
} else if ( drops > 0 ) {
formIngredients . value . push ( { oil : '椰子油' , drops , _search : '椰子油' , _open : false } )
}
}
function confirmAddIng ( ) {
if ( ! newIngOil . value || ! newIngDrops . value ) return
formIngredients . value . push ( { oil : newIngOil . value , drops : newIngDrops . value , _search : newIngOil . value , _open : false } )
newIngOil . value = ''
newIngSearch . value = ''
newIngDrops . value = 1
showAddIngRow . value = false
}
function toggleFormTag ( tag ) {
const idx = formTags . value . indexOf ( tag )
if ( idx >= 0 ) formTags . value . splice ( idx , 1 )
@@ -613,7 +800,26 @@ async function saveAllParsed() {
closeOverlay ( )
}
const sharedCount = ref ( 0 )
const sharedCount = ref ( { adopted : 0 , total : 0 } )
const previewRecipeIndex = ref ( null )
const showMyRecipes = ref ( true )
const showPublicRecipes = ref ( false )
const showReviewHistory = ref ( false )
const reviewHistory = ref ( [ ] )
function parseReviewDetail ( detail ) {
try {
const d = JSON . parse ( detail )
if ( d . from _user ) return ` 来自: ${ d . from _user } `
if ( d . reason ) return ` 原因: ${ d . reason } `
} catch { }
return ''
}
function formatDate ( d ) {
if ( ! d ) return ''
return new Date ( d + 'Z' ) . toLocaleString ( 'zh-CN' , { month : 'short' , day : 'numeric' , hour : '2-digit' , minute : '2-digit' } )
}
// Load diary on mount
onMounted ( async ( ) => {
@@ -623,10 +829,16 @@ onMounted(async () => {
const res = await api ( '/api/me/contribution' )
if ( res . ok ) {
const data = await res . json ( )
sharedCount . value = data . shared _count || 0
sharedCount . value = { adopted : data . adopted _count || 0 , total : data . shared _count || 0 }
}
} catch { }
}
if ( auth . isAdmin ) {
try {
const res = await api ( '/api/recipe-reviews' )
if ( res . ok ) reviewHistory . value = await res . json ( )
} catch { }
}
} )
function editDiaryRecipe ( diary ) {
@@ -638,6 +850,11 @@ function editDiaryRecipe(diary) {
showAddOverlay . value = true
}
function openRecipeDetail ( recipe ) {
const idx = recipeStore . recipes . findIndex ( r => r . _id === recipe . _id )
if ( idx >= 0 ) previewRecipeIndex . value = idx
}
function getDiaryShareStatus ( d ) {
// Check if a public recipe with same name exists, owned by current user or adopted by admin
const pub = recipeStore . recipes . find ( r => r . name === d . name )
@@ -792,6 +1009,14 @@ watch(() => recipeStore.recipes, () => {
font - weight : 600 ;
flex : 1 ;
}
. pending - name . clickable {
cursor : pointer ;
color : # 4 a9d7e ;
text - decoration : underline ;
}
. pending - name . clickable : hover {
color : # 2 e7d5a ;
}
. pending - owner {
color : # 999 ;
@@ -836,6 +1061,10 @@ watch(() => recipeStore.recipes, () => {
. tag - filter - bar {
margin - bottom : 12 px ;
display : flex ;
gap : 8 px ;
align - items : center ;
flex - wrap : wrap ;
}
. tag - toggle - btn {
@@ -985,6 +1214,19 @@ watch(() => recipeStore.recipes, () => {
. share - tag . shared { background : # e8f5e9 ; color : # 2 e7d32 ; }
. share - tag . pending { background : # fff3e0 ; color : # e65100 ; }
. review - history { max - height : 300 px ; overflow - y : auto ; }
. review - log - item {
display : flex ; align - items : center ; gap : 8 px ; padding : 8 px 12 px ;
border - bottom : 1 px solid # f5f5f5 ; font - size : 13 px ; flex - wrap : wrap ;
}
. log - approve { color : # 2 e7d32 ; font - weight : 600 ; white - space : nowrap ; }
. log - reject { color : # c62828 ; font - weight : 600 ; white - space : nowrap ; }
. log - recipe { font - weight : 500 ; color : # 3 e3a44 ; }
. log - from { color : # 999 ; font - size : 12 px ; }
. log - time { color : # bbb ; font - size : 11 px ; margin - left : auto ; white - space : nowrap ; }
. section - title . clickable { cursor : pointer ; display : flex ; justify - content : space - between ; align - items : center ; }
. toggle - icon { font - size : 12 px ; color : # 999 ; }
. contrib - tag {
font - size : 11 px ;
color : # 4 a9d7e ;
@@ -1113,12 +1355,52 @@ watch(() => recipeStore.recipes, () => {
. parsed - warn { color : # e65100 ; font - size : 12 px ; margin - bottom : 6 px ; }
. parsed - actions { display : flex ; gap : 8 px ; justify - content : center ; margin - top : 8 px ; }
. editor - header { display : flex ; align - items : center ; gap : 10 px ; margin - bottom : 12 px ; }
. editor - name - input { width : 100 % ; font - size : 17 px ; font - weight : 600 ; border : none ; border - bottom : 2 px solid # e5e4e7 ; padding : 6 px 0 ; outline : none ; font - family : inherit ; background : transparent ; }
. editor - name - input : focus { border - bottom - color : # 7 ec6a4 ; }
. editor - header - actions { display : flex ; gap : 6 px ; flex - shrink : 0 ; }
. editor - tip { font - size : 12 px ; color : # 999 ; background : # f8f7f5 ; padding : 8 px 12 px ; border - radius : 8 px ; margin - bottom : 12 px ; }
. editor - section { margin - bottom : 16 px ; }
. editor - label { font - size : 13 px ; font - weight : 600 ; color : # 3 e3a44 ; margin - bottom : 6 px ; display : block ; }
. editor - table { width : 100 % ; border - collapse : collapse ; font - size : 13 px ; margin - bottom : 8 px ; }
. editor - table th { text - align : left ; padding : 6 px 4 px ; color : # 999 ; font - weight : 500 ; font - size : 12 px ; border - bottom : 1 px solid # eee ; }
. editor - table td { padding : 6 px 4 px ; border - bottom : 1 px solid # f5f5f5 ; }
. cell - muted { color : # b0aab5 ; font - size : 12 px ; }
. cell - cost { color : # 4 a9d7e ; font - weight : 500 ; font - size : 13 px ; }
. f orm - total { text - align : right ; font - size : 14 px ; font - weight : 600 ; color : # 4 a9d7e ; margin - top : 8 p x; }
. editor - drops { width : 65 px ; padding : 6 px 8 px ; border : 1.5 px solid # d4cfc7 ; border - radius : 8 px ; font - size : 13 px ; text - align : center ; outline : none ; font - family : inherit ; }
. editor - drops : focus { border - color : # 7 ec6a4 ; }
. edit or- input { padding : 8 px 10 px ; border : 1.5 px solid # d4cfc7 ; border - radius : 8 px ; font - size : 13 px ; outline : none ; font - family : inherit ; width : 100 % ; box - sizing : border - bo x; }
. editor - input : focus { border - color : # 7 ec6a4 ; }
. editor - textarea { width : 100 % ; padding : 8 px 10 px ; border : 1.5 px solid # d4cfc7 ; border - radius : 8 px ; font - size : 13 px ; font - family : inherit ; outline : none ; resize : vertical ; box - sizing : border - box ; }
. editor - textarea : focus { border - color : # 7 ec6a4 ; }
. ing - ppd { color : # b0aab5 ; font - size : 12 px ; }
. ing - cost { color : # 4 a9d7e ; font - weight : 500 ; font - size : 13 px ; }
. remove - row - btn { border : none ; background : none ; color : # ccc ; cursor : pointer ; font - size : 16 px ; padding : 2 px 4 px ; }
. remove - row - btn : hover { color : # c0392b ; }
. add - row - btn { border : 1.5 px dashed # d4cfc7 ; background : none ; color : # 999 ; padding : 8 px 16 px ; border - radius : 8 px ; cursor : pointer ; font - size : 13 px ; font - family : inherit ; width : 100 % ; }
. add - row - btn : hover { border - color : # 7 ec6a4 ; color : # 4 a9d7e ; }
. add - ingredient - row { display : flex ; gap : 6 px ; align - items : center ; margin - bottom : 8 px ; }
. editor - tags { display : flex ; flex - wrap : wrap ; gap : 6 px ; margin - bottom : 8 px ; }
. editor - tag { background : # e8f5e9 ; color : # 2 e7d5a ; padding : 4 px 10 px ; border - radius : 12 px ; font - size : 12 px ; display : flex ; align - items : center ; gap : 4 px ; }
. tag - remove { cursor : pointer ; font - size : 14 px ; color : # 999 ; }
. tag - remove : hover { color : # c0392b ; }
. candidate - tags { display : flex ; flex - wrap : wrap ; gap : 6 px ; margin - bottom : 8 px ; }
. candidate - tag { background : # f0eeeb ; color : # 6 b6375 ; padding : 4 px 10 px ; border - radius : 12 px ; font - size : 12 px ; cursor : pointer ; }
. candidate - tag : hover { background : # e8f5e9 ; color : # 2 e7d5a ; }
. editor - total { text - align : right ; font - size : 15 px ; font - weight : 600 ; color : # 4 a9d7e ; padding : 10 px 0 ; border - top : 1 px solid # eee ; }
. action - btn { border : 1.5 px solid # d4cfc7 ; background : # fff ; color : # 6 b6375 ; border - radius : 8 px ; padding : 6 px 14 px ; font - size : 13 px ; cursor : pointer ; font - family : inherit ; white - space : nowrap ; }
. action - btn : hover { background : # f8f7f5 ; }
. action - btn - primary { background : linear - gradient ( 135 deg , # 7 ec6a4 , # 4 a9d7e ) ; color : # fff ; border - color : transparent ; }
. action - btn - primary : hover { opacity : 0.9 ; }
. action - btn - sm { padding : 5 px 12 px ; font - size : 12 px ; }
. volume - controls { display : flex ; gap : 6 px ; flex - wrap : wrap ; margin - bottom : 8 px ; }
. volume - btn { padding : 6 px 14 px ; border : 1.5 px solid # d4cfc7 ; border - radius : 8 px ; background : # fff ; font - size : 13 px ; cursor : pointer ; font - family : inherit ; color : # 6 b6375 ; }
. volume - btn . active { background : # e8f5e9 ; border - color : # 7 ec6a4 ; color : # 2 e7d5a ; font - weight : 600 ; }
. volume - btn : hover { border - color : # 7 ec6a4 ; }
. custom - volume - row { display : flex ; gap : 6 px ; align - items : center ; margin - bottom : 6 px ; }
. dilution - row { display : flex ; align - items : center ; gap : 6 px ; margin - top : 6 px ; }
. dilution - label { font - size : 12 px ; color : # 3 e3a44 ; white - space : nowrap ; }
. drops - sm { width : 50 px ; padding : 4 px 6 px ; border : 1.5 px solid # d4cfc7 ; border - radius : 6 px ; font - size : 12 px ; text - align : center ; outline : none ; font - family : inherit ; }
. drops - sm : focus { border - color : # 7 ec6a4 ; }
. select - sm { padding : 4 px 6 px ; border : 1.5 px solid # d4cfc7 ; border - radius : 6 px ; font - size : 12 px ; font - family : inherit ; background : # fff ; width : auto ; }
. divider - text {
text - align : center ;