From 4ce8ed9ff5ceb08ea9782adbe9b1335dc1eac9b5 Mon Sep 17 00:00:00 2001 From: Hera Zhao Date: Fri, 10 Apr 2026 13:00:14 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=85=8D=E6=96=B9=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E9=87=8D=E5=86=99=E5=AE=B9=E9=87=8F/=E6=A4=B0?= =?UTF-8?q?=E5=AD=90=E6=B2=B9=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 编辑器: - 顶部容量选择: 单次/5ml/10ml/15ml/20ml/30ml/自定义 - 椰子油默认在最底行,单次时可编辑滴数(默认10) - 非单次时椰子油自动填满(显示"约Xml") - 实时提示:单次显示滴数+稀释比例,非单次显示总容量+填满+比例 - 新增精油插入到椰子油上方 - 编辑已有配方自动识别容量和稀释比例 其他: - 拼音搜索改前缀匹配 - 输入法enter不触发保存 - 导出Excel移到标签行右侧小图标 - 新增→新增, 标签筛选→标签 - 配方名过长自动缩小 - 已审核标签viewer不可见 - ml显示整数 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/RecipeDetailOverlay.vue | 16 ++ frontend/src/views/RecipeManager.vue | 189 +++++++++++++----- 2 files changed, 154 insertions(+), 51 deletions(-) diff --git a/frontend/src/components/RecipeDetailOverlay.vue b/frontend/src/components/RecipeDetailOverlay.vue index a959d10..56e5599 100644 --- a/frontend/src/components/RecipeDetailOverlay.vue +++ b/frontend/src/components/RecipeDetailOverlay.vue @@ -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()) }) diff --git a/frontend/src/views/RecipeManager.vue b/frontend/src/views/RecipeManager.vue index 0e493db..67444ae 100644 --- a/frontend/src/views/RecipeManager.vue +++ b/frontend/src/views/RecipeManager.vue @@ -14,27 +14,26 @@ - + - +
- + +
-
- 💡 推荐按照单次用量(椰子油10~20滴)添加纯精油,系统会根据容量和稀释比例自动计算。 + +
+ +
+ + + + + + + +
+
+ + ml +
- +
- + - + + + + + + + + +
精油滴数单价/滴小计
{{ ing.oil ? oils.fmtPrice(oils.pricePerDrop(ing.oil)) : '-' }} {{ ing.oil && ing.drops ? oils.fmtPrice(oils.pricePerDrop(ing.oil) * ing.drops) : '-' }}
椰子油 + + + {{ oils.fmtPrice(oils.pricePerDrop('椰子油')) }}{{ oils.fmtPrice(oils.pricePerDrop('椰子油') * cocoActualDrops) }}
- - - +
- -
- -
- - - - - -
-
- - -
-
- 稀释 1: - - -
-
{{ formDilutionHint }}
+ +
+ {{ recipeSummaryText }}
@@ -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; }