feat: 新功能改进 #20

Merged
hera merged 57 commits from feat/next-improvements into main 2026-04-10 20:30:37 +00:00
2 changed files with 154 additions and 51 deletions
Showing only changes of commit 4ce8ed9ff5 - Show all commits

View File

@@ -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())
})

View File

@@ -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; }