feat: 配方编辑器重写容量/椰子油逻辑
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 16s
Test / e2e-test (push) Has been cancelled
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 16s
Test / e2e-test (push) Has been cancelled
编辑器: - 顶部容量选择: 单次/5ml/10ml/15ml/20ml/30ml/自定义 - 椰子油默认在最底行,单次时可编辑滴数(默认10) - 非单次时椰子油自动填满(显示"约Xml") - 实时提示:单次显示滴数+稀释比例,非单次显示总容量+填满+比例 - 新增精油插入到椰子油上方 - 编辑已有配方自动识别容量和稀释比例 其他: - 拼音搜索改前缀匹配 - 输入法enter不触发保存 - 导出Excel移到标签行右侧小图标 - 新增→新增, 标签筛选→标签 - 配方名过长自动缩小 - 已审核标签viewer不可见 - ml显示整数 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -842,6 +842,22 @@ onMounted(() => {
|
||||
})
|
||||
customOilNameEn.value = enMap
|
||||
|
||||
// Calculate current dilution ratio and volume from ingredients
|
||||
const ings = r.ingredients || []
|
||||
const coco = ings.find(i => i.oil === '椰子油')
|
||||
const eoDrops = ings.filter(i => i.oil && i.oil !== '椰子油').reduce((s, i) => s + (i.drops || 0), 0)
|
||||
const cocoDrops = coco ? (coco.drops || 0) : 0
|
||||
const totalDrops = eoDrops + cocoDrops
|
||||
if (eoDrops > 0 && cocoDrops > 0) {
|
||||
dilutionRatio.value = Math.round(cocoDrops / eoDrops)
|
||||
}
|
||||
const ml = totalDrops / DROPS_PER_ML
|
||||
if (ml <= 1.5) selectedVolume.value = 'single'
|
||||
else if (Math.abs(ml - 5) < 1.5) selectedVolume.value = '5'
|
||||
else if (Math.abs(ml - 10) < 3) selectedVolume.value = '10'
|
||||
else if (Math.abs(ml - 30) < 8) selectedVolume.value = '30'
|
||||
else { selectedVolume.value = 'custom'; customVolumeValue.value = Math.round(totalDrops); customVolumeUnit.value = 'drops' }
|
||||
|
||||
loadBrand()
|
||||
nextTick(() => generateCardImage())
|
||||
})
|
||||
|
||||
@@ -14,27 +14,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 1: Search (editor+) + Export (admin only) -->
|
||||
<!-- Row 1: Search (editor+) -->
|
||||
<template v-if="auth.canEdit">
|
||||
<div class="manage-toolbar">
|
||||
<div class="search-box">
|
||||
<input class="search-input" v-model="manageSearch" placeholder="搜索配方..." />
|
||||
<button v-if="manageSearch" class="search-clear-btn" @click="manageSearch = ''">✕</button>
|
||||
</div>
|
||||
<button v-if="auth.isAdmin" class="btn-outline btn-sm" @click="exportExcel">📥 导出Excel</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Row 2: Add + Select + Tags + Batch -->
|
||||
<!-- Row 2: Add + Select + Tags + Batch + Export -->
|
||||
<div class="tag-filter-bar">
|
||||
<button v-if="auth.canEdit" class="btn-outline btn-sm" @click="showAddOverlay = true">+ 添加配方</button>
|
||||
<button v-if="auth.canEdit" class="btn-outline btn-sm" @click="showAddOverlay = true">新增</button>
|
||||
<button
|
||||
class="btn-sm"
|
||||
:class="selectedDiaryIds.size > 0 ? 'btn-select-active' : 'btn-outline'"
|
||||
@click="toggleSelectAllDiary"
|
||||
>全选</button>
|
||||
<button class="tag-toggle-btn" @click="showTagFilter = !showTagFilter">
|
||||
🏷️ 标签筛选 {{ showTagFilter ? '▾' : '▸' }}
|
||||
标签 {{ showTagFilter ? '▾' : '▸' }}
|
||||
</button>
|
||||
<!-- Batch (inline when selected) -->
|
||||
<template v-if="selectedIds.size > 0 || selectedDiaryIds.size > 0">
|
||||
@@ -47,6 +46,7 @@
|
||||
</select>
|
||||
<button class="btn-sm btn-outline" @click="clearSelection">取消</button>
|
||||
</template>
|
||||
<button v-if="auth.isAdmin" class="export-btn" @click="exportExcel" title="导出Excel">📥</button>
|
||||
<div v-if="showTagFilter" class="tag-list">
|
||||
<span
|
||||
v-for="tag in recipeStore.allTags"
|
||||
@@ -197,18 +197,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-tip">
|
||||
💡 推荐按照单次用量(椰子油10~20滴)添加纯精油,系统会根据容量和稀释比例自动计算。
|
||||
<!-- Volume selector -->
|
||||
<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 === '15' }" @click="formVolume = '15'">15ml</button>
|
||||
<button class="volume-btn" :class="{ active: formVolume === '20' }" @click="formVolume = '20'">20ml</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="ml" />
|
||||
<span style="font-size:12px;color:#999">ml</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ingredients table -->
|
||||
<!-- Ingredients table (essential oils only, coconut at bottom) -->
|
||||
<div class="editor-section">
|
||||
<table class="editor-table">
|
||||
<thead>
|
||||
<tr><th>精油</th><th>滴数</th><th>单价/滴</th><th>小计</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(ing, i) in formIngredients" :key="i">
|
||||
<tr v-for="(ing, i) in formEoIngredients" :key="'eo-'+i">
|
||||
<td>
|
||||
<div class="oil-search-wrap">
|
||||
<input
|
||||
@@ -233,40 +247,31 @@
|
||||
<td><input v-model.number="ing.drops" type="number" min="0.5" step="0.5" class="editor-drops" /></td>
|
||||
<td class="ing-ppd">{{ ing.oil ? 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>
|
||||
<td><button class="remove-row-btn" @click="removeEoRow(i)">✕</button></td>
|
||||
</tr>
|
||||
<!-- Coconut oil row -->
|
||||
<tr v-if="formCocoRow" class="coco-row">
|
||||
<td><span class="coco-label">椰子油</span></td>
|
||||
<td>
|
||||
<template v-if="formVolume === 'single'">
|
||||
<input v-model.number="formCocoRow.drops" type="number" min="0" class="editor-drops" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="coco-fill">填满 (约{{ cocoFillMl }}ml)</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="ing-ppd">{{ oils.fmtPrice(oils.pricePerDrop('椰子油')) }}</td>
|
||||
<td class="ing-cost">{{ oils.fmtPrice(oils.pricePerDrop('椰子油') * cocoActualDrops) }}</td>
|
||||
<td><button class="remove-row-btn" @click="formCocoRow = null">✕</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Add ingredient row -->
|
||||
<button class="add-row-btn" @click="formIngredients.push({ oil: '', drops: 1, _search: '', _open: false })">+ 添加精油</button>
|
||||
<button class="add-row-btn" @click="addOilRow">+ 添加精油</button>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Real-time summary -->
|
||||
<div class="recipe-summary">
|
||||
{{ recipeSummaryText }}
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
@@ -387,10 +392,48 @@ const formCustomVolume = ref(100)
|
||||
const formCustomUnit = ref('drops')
|
||||
const formDilution = ref(6)
|
||||
|
||||
const formCocoRow = ref({ oil: '椰子油', drops: 10, _search: '椰子油', _open: false })
|
||||
|
||||
// EO ingredients (everything except coconut)
|
||||
const formEoIngredients = computed(() =>
|
||||
formIngredients.value.filter(i => i.oil !== '椰子油')
|
||||
)
|
||||
|
||||
const eoTotalDrops = computed(() =>
|
||||
formEoIngredients.value.filter(i => i.oil && i.drops > 0).reduce((s, i) => s + i.drops, 0)
|
||||
)
|
||||
|
||||
const targetTotalDrops = computed(() => {
|
||||
if (formVolume.value === 'single') return null
|
||||
if (formVolume.value === 'custom') return Math.round((formCustomVolume.value || 0) * DROPS_PER_ML)
|
||||
return Math.round(Number(formVolume.value) * DROPS_PER_ML)
|
||||
})
|
||||
|
||||
const cocoActualDrops = computed(() => {
|
||||
if (!formCocoRow.value) return 0
|
||||
if (formVolume.value === 'single') return formCocoRow.value.drops || 0
|
||||
if (!targetTotalDrops.value) return 0
|
||||
return Math.max(0, targetTotalDrops.value - eoTotalDrops.value)
|
||||
})
|
||||
|
||||
const cocoFillMl = computed(() => Math.round(cocoActualDrops.value / DROPS_PER_ML))
|
||||
|
||||
const recipeSummaryText = computed(() => {
|
||||
const eo = eoTotalDrops.value
|
||||
const coco = cocoActualDrops.value
|
||||
const ratio = eo > 0 ? Math.round(coco / eo) : 0
|
||||
if (formVolume.value === 'single') {
|
||||
return `该配方为单次用量,纯精油 ${eo} 滴,椰子油 ${coco} 滴,稀释比例 1:${ratio}`
|
||||
}
|
||||
const vol = formVolume.value === 'custom' ? (formCustomVolume.value || 0) : Number(formVolume.value)
|
||||
return `该配方总容量 ${vol}ml,纯精油 ${eo} 滴,剩余用椰子油填满,稀释比例 1:${ratio}`
|
||||
})
|
||||
|
||||
const formTotalCost = computed(() => {
|
||||
const cost = formIngredients.value
|
||||
.filter(i => i.oil && i.drops > 0)
|
||||
let cost = formIngredients.value
|
||||
.filter(i => i.oil && i.oil !== '椰子油' && i.drops > 0)
|
||||
.reduce((sum, i) => sum + oils.pricePerDrop(i.oil) * i.drops, 0)
|
||||
cost += oils.pricePerDrop('椰子油') * cocoActualDrops.value
|
||||
return oils.fmtPrice(cost)
|
||||
})
|
||||
|
||||
@@ -457,10 +500,20 @@ function onBatchSelect() {
|
||||
}
|
||||
|
||||
function toggleSelectAllDiary() {
|
||||
if (selectedDiaryIds.size === myFilteredRecipes.value.length) {
|
||||
const allMySelected = myFilteredRecipes.value.length > 0 && selectedDiaryIds.size === myFilteredRecipes.value.length
|
||||
const allPubSelected = auth.canEdit && publicFilteredRecipes.value.length > 0 && selectedIds.size === publicFilteredRecipes.value.length
|
||||
if (allMySelected && (!auth.canEdit || allPubSelected)) {
|
||||
// All selected → deselect all
|
||||
selectedDiaryIds.clear()
|
||||
selectedIds.clear()
|
||||
} else {
|
||||
// Select all
|
||||
myFilteredRecipes.value.forEach(d => selectedDiaryIds.add(d.id))
|
||||
if (auth.canEdit) {
|
||||
publicFilteredRecipes.value.forEach(r => selectedIds.add(r._id))
|
||||
}
|
||||
showMyRecipes.value = true
|
||||
if (auth.canEdit) showPublicRecipes.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,7 +604,10 @@ function calcDilutionFromIngs(ingredients) {
|
||||
function editRecipe(recipe) {
|
||||
editingRecipe.value = recipe
|
||||
formName.value = recipe.name
|
||||
formIngredients.value = recipe.ingredients.map(i => ({ ...i, _search: i.oil, _open: false }))
|
||||
const ings = recipe.ingredients || []
|
||||
formIngredients.value = ings.filter(i => i.oil !== '椰子油').map(i => ({ ...i, _search: i.oil, _open: false }))
|
||||
const coco = ings.find(i => i.oil === '椰子油')
|
||||
formCocoRow.value = coco ? { ...coco, _search: '椰子油', _open: false } : { oil: '椰子油', drops: 10, _search: '椰子油', _open: false }
|
||||
formNote.value = recipe.note || ''
|
||||
formTags.value = [...(recipe.tags || [])]
|
||||
calcDilutionFromIngs(recipe.ingredients)
|
||||
@@ -567,6 +623,7 @@ function closeOverlay() {
|
||||
function resetForm() {
|
||||
formName.value = ''
|
||||
formIngredients.value = [{ oil: '', drops: 1, _search: '', _open: false }]
|
||||
formCocoRow.value = { oil: '椰子油', drops: 10, _search: '椰子油', _open: false }
|
||||
formNote.value = ''
|
||||
formTags.value = []
|
||||
smartPasteText.value = ''
|
||||
@@ -647,7 +704,7 @@ const formDilutionHint = computed(() => {
|
||||
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}`
|
||||
return `单次用量:纯精油约 ${eoDrops} 滴 + 椰子油约 ${cocoDrops} 滴,共 ${totalDrops} 滴 (${Math.round(totalDrops / DROPS_PER_ML)}ml),稀释 1:${formDilution.value}`
|
||||
}
|
||||
let totalDrops
|
||||
if (formVolume.value === 'custom') {
|
||||
@@ -657,7 +714,7 @@ const formDilutionHint = computed(() => {
|
||||
}
|
||||
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}`
|
||||
return `总容量 ${totalDrops} 滴 (${Math.round(totalDrops / DROPS_PER_ML)}ml),纯精油约 ${targetEo} 滴 + 椰子油约 ${cocoDrops} 滴,稀释 1:${formDilution.value}`
|
||||
})
|
||||
|
||||
function applyVolumeDilution() {
|
||||
@@ -703,6 +760,18 @@ function setFormCoconut(drops) {
|
||||
}
|
||||
}
|
||||
|
||||
function addOilRow() {
|
||||
formIngredients.value.push({ oil: '', drops: 1, _search: '', _open: false })
|
||||
}
|
||||
|
||||
function removeEoRow(index) {
|
||||
// Find the actual index in formIngredients (skip coconut)
|
||||
const eoIngs = formIngredients.value.filter(i => i.oil !== '椰子油')
|
||||
const target = eoIngs[index]
|
||||
const realIdx = formIngredients.value.indexOf(target)
|
||||
if (realIdx >= 0) formIngredients.value.splice(realIdx, 1)
|
||||
}
|
||||
|
||||
function confirmAddIng() {
|
||||
if (!newIngOil.value || !newIngDrops.value) return
|
||||
formIngredients.value.push({ oil: newIngOil.value, drops: newIngDrops.value, _search: newIngOil.value, _open: false })
|
||||
@@ -719,17 +788,21 @@ function toggleFormTag(tag) {
|
||||
}
|
||||
|
||||
async function saveCurrentRecipe() {
|
||||
const validIngs = formIngredients.value.filter(i => i.oil && i.drops > 0)
|
||||
const eoIngs = formIngredients.value.filter(i => i.oil && i.oil !== '椰子油' && i.drops > 0)
|
||||
if (!formName.value.trim()) {
|
||||
ui.showToast('请输入配方名称')
|
||||
return
|
||||
}
|
||||
if (validIngs.length === 0) {
|
||||
ui.showToast('请至少添加一个成分')
|
||||
if (eoIngs.length === 0) {
|
||||
ui.showToast('请至少添加一个精油')
|
||||
return
|
||||
}
|
||||
|
||||
const cleanIngs = validIngs.map(i => ({ oil: i.oil, drops: i.drops }))
|
||||
// Combine EO + coconut
|
||||
const cleanIngs = eoIngs.map(i => ({ oil: i.oil, drops: i.drops }))
|
||||
if (formCocoRow.value && cocoActualDrops.value > 0) {
|
||||
cleanIngs.push({ oil: '椰子油', drops: cocoActualDrops.value })
|
||||
}
|
||||
const diaryPayload = {
|
||||
name: formName.value.trim(),
|
||||
ingredients: cleanIngs,
|
||||
@@ -864,7 +937,10 @@ onMounted(async () => {
|
||||
function editDiaryRecipe(diary) {
|
||||
editingRecipe.value = { _diary_id: diary.id, name: diary.name }
|
||||
formName.value = diary.name
|
||||
formIngredients.value = (diary.ingredients || []).map(i => ({ ...i, _search: i.oil, _open: false }))
|
||||
const ings = diary.ingredients || []
|
||||
formIngredients.value = ings.filter(i => i.oil !== '椰子油').map(i => ({ ...i, _search: i.oil, _open: false }))
|
||||
const coco = ings.find(i => i.oil === '椰子油')
|
||||
formCocoRow.value = coco ? { ...coco, _search: '椰子油', _open: false } : { oil: '椰子油', drops: 10, _search: '椰子油', _open: false }
|
||||
formNote.value = diary.note || ''
|
||||
formTags.value = [...(diary.tags || [])]
|
||||
calcDilutionFromIngs(diary.ingredients)
|
||||
@@ -1419,12 +1495,23 @@ watch(() => recipeStore.recipes, () => {
|
||||
.volume-btn.active { background: #e8f5e9; border-color: #7ec6a4; color: #2e7d5a; font-weight: 600; }
|
||||
.volume-btn:hover { border-color: #7ec6a4; }
|
||||
.custom-volume-row { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; }
|
||||
.dilution-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
|
||||
.dilution-label { font-size: 12px; color: #3e3a44; white-space: nowrap; }
|
||||
.coco-row { background: #f8faf8; }
|
||||
.coco-label { font-weight: 600; color: #4a9d7e; font-size: 13px; }
|
||||
.coco-fill { font-size: 12px; color: #4a9d7e; font-weight: 500; }
|
||||
.recipe-summary {
|
||||
padding: 10px 14px; background: #f0faf5; border-radius: 10px; border-left: 3px solid #7ec6a4;
|
||||
font-size: 13px; color: #2e7d5a; margin-bottom: 12px; line-height: 1.6;
|
||||
}
|
||||
.drops-sm { width: 50px; padding: 4px 6px; border: 1.5px solid #d4cfc7; border-radius: 6px; font-size: 12px; text-align: center; outline: none; font-family: inherit; }
|
||||
.drops-sm:focus { border-color: #7ec6a4; }
|
||||
.select-sm { padding: 4px 6px; border: 1.5px solid #d4cfc7; border-radius: 6px; font-size: 12px; font-family: inherit; background: #fff; width: auto; }
|
||||
.btn-select-active { background: #e8f5e9; color: #2e7d5a; border: 1.5px solid #7ec6a4; border-radius: 10px; padding: 7px 14px; font-size: 13px; cursor: pointer; font-family: inherit; font-weight: 600; }
|
||||
.export-btn {
|
||||
margin-left: auto;
|
||||
border: none; background: none; cursor: pointer; font-size: 16px; padding: 4px 6px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.export-btn:hover { opacity: 1; }
|
||||
.batch-count { font-size: 12px; color: #4a9d7e; font-weight: 600; white-space: nowrap; }
|
||||
.batch-select { padding: 5px 8px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 12px; font-family: inherit; background: #fff; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user