feat: 配方卡片编辑器与管理配方编辑器统一
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 15s
Test / e2e-test (push) Failing after 52s
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 15s
Test / e2e-test (push) Failing after 52s
- RecipeDetailOverlay 编辑器改为新版:容量选择+参考比例+椰子油自动填满 - 和 RecipeManager 新增/编辑完全一致的界面和逻辑 - 实时显示配方摘要(用量/容量/稀释比例) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -144,154 +144,72 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tip -->
|
||||
<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: selectedVolume === 'single' }" @click="selectedVolume = 'single'">单次</button>
|
||||
<button class="volume-btn" :class="{ active: selectedVolume === '5' }" @click="selectedVolume = '5'">5ml</button>
|
||||
<button class="volume-btn" :class="{ active: selectedVolume === '10' }" @click="selectedVolume = '10'">10ml</button>
|
||||
<button class="volume-btn" :class="{ active: selectedVolume === '15' }" @click="selectedVolume = '15'">15ml</button>
|
||||
<button class="volume-btn" :class="{ active: selectedVolume === '20' }" @click="selectedVolume = '20'">20ml</button>
|
||||
<button class="volume-btn" :class="{ active: selectedVolume === '30' }" @click="selectedVolume = '30'">30ml</button>
|
||||
<button class="volume-btn" :class="{ active: selectedVolume === 'custom' }" @click="selectedVolume = 'custom'">自定义</button>
|
||||
</div>
|
||||
<div v-if="selectedVolume === 'custom'" class="custom-volume-row">
|
||||
<input v-model.number="customVolumeValue" type="number" min="1" class="editor-drops" placeholder="ml" />
|
||||
<span style="font-size:12px;color:#999">ml</span>
|
||||
</div>
|
||||
<div class="dilution-row">
|
||||
<span class="dilution-label">参考比例 1:</span>
|
||||
<select v-model.number="dilutionRatio" class="editor-select" style="width:60px">
|
||||
<option v-for="n in [3,4,5,6,7,8,9,10,12,15,20]" :key="n" :value="n">{{ n }}</option>
|
||||
</select>
|
||||
<span class="ratio-hint">纯精油约 {{ editorSuggestedEo }} 滴</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ingredients table -->
|
||||
<!-- Ingredients table (EO 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>
|
||||
<tr><th>精油</th><th>滴数</th><th>单价/滴</th><th>小计</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(ing, i) in editIngredients" :key="i">
|
||||
<tr v-for="(ing, i) in editEoIngredients" :key="'eo-'+i">
|
||||
<td>
|
||||
<select v-model="ing.oil" class="editor-select">
|
||||
<option value="">选择精油</option>
|
||||
<option v-for="name in oilsStore.oilNames" :key="name" :value="name">{{ name }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<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 ? oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil)) : '-' }}</td>
|
||||
<td class="ing-cost">{{ ing.oil ? oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * (ing.drops || 0)) : '-' }}</td>
|
||||
<td><button class="remove-row-btn" @click="editIngredients.splice(editIngredients.indexOf(ing), 1)">✕</button></td>
|
||||
</tr>
|
||||
<!-- Coconut oil row -->
|
||||
<tr v-if="editCocoRow" class="coco-row">
|
||||
<td><span class="coco-label">椰子油</span></td>
|
||||
<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 ? oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil)) : '-' }}
|
||||
</td>
|
||||
<td class="ing-cost">
|
||||
{{ ing.oil ? oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * (ing.drops || 0)) : '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<button class="remove-row-btn" @click="removeIngredient(i)">✕</button>
|
||||
<template v-if="selectedVolume === 'single'">
|
||||
<input v-model.number="editCocoRow.drops" type="number" min="0" class="editor-drops" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="coco-fill">填满 (约{{ editorCocoFillMl }}ml)</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="ing-ppd">{{ oilsStore.fmtPrice(oilsStore.pricePerDrop('椰子油')) }}</td>
|
||||
<td class="ing-cost">{{ oilsStore.fmtPrice(oilsStore.pricePerDrop('椰子油') * editorCocoActualDrops) }}</td>
|
||||
<td><button class="remove-row-btn" @click="editCocoRow = null">✕</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Add ingredient row -->
|
||||
<div v-if="showAddRow" class="add-ingredient-row">
|
||||
<div class="oil-autocomplete">
|
||||
<input
|
||||
v-model="oilSearchQuery"
|
||||
@focus="showOilDropdown = true"
|
||||
@blur="closeOilDropdown"
|
||||
@input="newIngOil = ''"
|
||||
class="editor-input oil-search-input"
|
||||
placeholder="搜索精油名称或英文..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div v-if="showOilDropdown && filteredOilsForAdd.length" class="oil-dropdown">
|
||||
<div
|
||||
v-for="name in filteredOilsForAdd"
|
||||
:key="name"
|
||||
class="oil-dropdown-item"
|
||||
:class="{ 'is-selected': newIngOil === name }"
|
||||
@mousedown.prevent="selectNewOil(name)"
|
||||
>
|
||||
<span>{{ name }}</span>
|
||||
<span class="oil-dropdown-en">{{ oilEn(name) }}</span>
|
||||
</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="confirmAddIngredient">确认</button>
|
||||
<button class="action-btn action-btn-sm" @click="cancelAddRow">取消</button>
|
||||
</div>
|
||||
<button v-else class="add-row-btn" @click="showAddRow = true">+ 添加精油</button>
|
||||
<button class="add-row-btn" @click="addEoRow">+ 添加精油</button>
|
||||
</div>
|
||||
|
||||
<!-- Volume & Dilution controls -->
|
||||
<div class="editor-section">
|
||||
<label class="editor-label">容量与稀释</label>
|
||||
<div class="volume-controls">
|
||||
<button
|
||||
class="volume-btn"
|
||||
:class="{ active: selectedVolume === 'single' }"
|
||||
@click="selectedVolume = 'single'"
|
||||
>单次</button>
|
||||
<button
|
||||
class="volume-btn"
|
||||
:class="{ active: selectedVolume === '5' }"
|
||||
@click="selectedVolume = '5'"
|
||||
>5ml</button>
|
||||
<button
|
||||
class="volume-btn"
|
||||
:class="{ active: selectedVolume === '10' }"
|
||||
@click="selectedVolume = '10'"
|
||||
>10ml</button>
|
||||
<button
|
||||
class="volume-btn"
|
||||
:class="{ active: selectedVolume === '30' }"
|
||||
@click="selectedVolume = '30'"
|
||||
>30ml</button>
|
||||
<button
|
||||
class="volume-btn"
|
||||
:class="{ active: selectedVolume === 'custom' }"
|
||||
@click="selectedVolume = 'custom'"
|
||||
>自定义</button>
|
||||
</div>
|
||||
|
||||
<!-- Custom volume input -->
|
||||
<div v-if="selectedVolume === 'custom'" class="custom-volume-row">
|
||||
<input
|
||||
v-model.number="customVolumeValue"
|
||||
type="number"
|
||||
min="1"
|
||||
class="editor-drops"
|
||||
placeholder="数量"
|
||||
/>
|
||||
<select v-model="customVolumeUnit" class="editor-select" style="width:80px">
|
||||
<option value="drops">滴</option>
|
||||
<option value="ml">ml</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Dilution ratio -->
|
||||
<div class="dilution-row">
|
||||
<span class="dilution-label">稀释比例 1:</span>
|
||||
<select v-model.number="dilutionRatio" class="editor-select" style="width:70px">
|
||||
<option v-for="n in 20" :key="n" :value="n">{{ n }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="action-btn action-btn-primary action-btn-sm" @click="applyVolumeDilution" style="margin-top:8px">
|
||||
应用到配方
|
||||
</button>
|
||||
|
||||
<div class="hint" style="margin-top:8px">
|
||||
{{ dilutionHint }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Real-time summary -->
|
||||
<div class="recipe-summary">{{ editorSummaryText }}</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="editor-section">
|
||||
@@ -308,35 +226,18 @@
|
||||
<span class="tag-remove" @click="removeTag(tag)">×</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Candidate tags (from allTags, excluding already selected) -->
|
||||
<div class="candidate-tags" v-if="candidateTags.length">
|
||||
<span
|
||||
v-for="tag in candidateTags"
|
||||
:key="tag"
|
||||
class="candidate-tag"
|
||||
@click="addTag(tag)"
|
||||
>+ {{ tag }}</span>
|
||||
<span v-for="tag in candidateTags" :key="tag" class="candidate-tag" @click="addTag(tag)">+ {{ tag }}</span>
|
||||
</div>
|
||||
<!-- Manual tag input -->
|
||||
<div class="tag-input-row">
|
||||
<input
|
||||
v-model="newTagInput"
|
||||
type="text"
|
||||
class="editor-input"
|
||||
placeholder="添加新标签..."
|
||||
@keydown.enter="addNewTag"
|
||||
style="flex:1"
|
||||
/>
|
||||
<input v-model="newTagInput" type="text" class="editor-input" placeholder="添加新标签..." @keydown.enter="addNewTag" style="flex:1" />
|
||||
<button class="action-btn action-btn-sm" @click="addNewTag" :disabled="!newTagInput.trim()">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total cost -->
|
||||
<div class="editor-total">
|
||||
总计: {{ editPriceInfo.cost }}
|
||||
<span v-if="editPriceInfo.hasRetail" class="editor-retail">
|
||||
零售 {{ editPriceInfo.retail }}
|
||||
</span>
|
||||
总计: {{ editorTotalCost }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -789,7 +690,54 @@ function cancelAddRow() {
|
||||
const selectedVolume = ref('single')
|
||||
const customVolumeValue = ref(100)
|
||||
const customVolumeUnit = ref('drops')
|
||||
const dilutionRatio = ref(3)
|
||||
const dilutionRatio = ref(6)
|
||||
const editCocoRow = ref({ oil: '椰子油', drops: 10 })
|
||||
|
||||
const editEoIngredients = computed(() =>
|
||||
editIngredients.value.filter(i => i.oil !== '椰子油')
|
||||
)
|
||||
const editorEoDrops = computed(() =>
|
||||
editEoIngredients.value.filter(i => i.oil && i.drops > 0).reduce((s, i) => s + i.drops, 0)
|
||||
)
|
||||
const editorTargetDrops = computed(() => {
|
||||
if (selectedVolume.value === 'single') return null
|
||||
if (selectedVolume.value === 'custom') return Math.round((customVolumeValue.value || 0) * DROPS_PER_ML)
|
||||
return Math.round(Number(selectedVolume.value) * DROPS_PER_ML)
|
||||
})
|
||||
const editorCocoActualDrops = computed(() => {
|
||||
if (!editCocoRow.value) return 0
|
||||
if (selectedVolume.value === 'single') return editCocoRow.value.drops || 0
|
||||
if (!editorTargetDrops.value) return 0
|
||||
return Math.max(0, editorTargetDrops.value - editorEoDrops.value)
|
||||
})
|
||||
const editorCocoFillMl = computed(() => Math.round(editorCocoActualDrops.value / DROPS_PER_ML))
|
||||
const editorSuggestedEo = computed(() => {
|
||||
if (selectedVolume.value === 'single') {
|
||||
const coco = editCocoRow.value ? (editCocoRow.value.drops || 10) : 10
|
||||
return Math.round(coco / dilutionRatio.value)
|
||||
}
|
||||
return Math.round((editorTargetDrops.value || 0) / (1 + dilutionRatio.value))
|
||||
})
|
||||
const editorSummaryText = computed(() => {
|
||||
const eo = editorEoDrops.value
|
||||
const coco = editorCocoActualDrops.value
|
||||
const ratio = eo > 0 ? Math.round(coco / eo) : 0
|
||||
if (selectedVolume.value === 'single') {
|
||||
return `该配方为单次用量,纯精油 ${eo} 滴,椰子油 ${coco} 滴,稀释比例 1:${ratio}`
|
||||
}
|
||||
const vol = selectedVolume.value === 'custom' ? (customVolumeValue.value || 0) : Number(selectedVolume.value)
|
||||
return `该配方总容量 ${vol}ml,纯精油 ${eo} 滴,剩余用椰子油填满,稀释比例 1:${ratio}`
|
||||
})
|
||||
const editorTotalCost = computed(() => {
|
||||
let cost = editEoIngredients.value.filter(i => i.oil && i.drops > 0)
|
||||
.reduce((s, i) => s + oilsStore.pricePerDrop(i.oil) * i.drops, 0)
|
||||
cost += oilsStore.pricePerDrop('椰子油') * editorCocoActualDrops.value
|
||||
return oilsStore.fmtPrice(cost)
|
||||
})
|
||||
|
||||
function addEoRow() {
|
||||
editIngredients.value.push({ oil: '', drops: 1 })
|
||||
}
|
||||
|
||||
const editPriceInfo = computed(() =>
|
||||
oilsStore.fmtCostWithRetail(editIngredients.value.filter(i => i.oil))
|
||||
@@ -833,7 +781,10 @@ onMounted(() => {
|
||||
editName.value = r.name
|
||||
editNote.value = r.note || ''
|
||||
editTags.value = [...(r.tags || [])]
|
||||
editIngredients.value = (r.ingredients || []).map(i => ({ oil: i.oil, drops: i.drops }))
|
||||
const allIngs = (r.ingredients || [])
|
||||
editIngredients.value = allIngs.filter(i => i.oil !== '椰子油').map(i => ({ oil: i.oil, drops: i.drops }))
|
||||
const coco = allIngs.find(i => i.oil === '椰子油')
|
||||
editCocoRow.value = coco ? { oil: '椰子油', drops: coco.drops } : { oil: '椰子油', drops: 10 }
|
||||
// Init translation defaults
|
||||
customRecipeNameEn.value = r.en_name || recipeNameEn(r.name)
|
||||
const enMap = {}
|
||||
@@ -843,20 +794,21 @@ 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 cocoIng = allIngs.find(i => i.oil === '椰子油')
|
||||
const eoTotal = allIngs.filter(i => i.oil && i.oil !== '椰子油').reduce((s, i) => s + (i.drops || 0), 0)
|
||||
const cocoTotal = cocoIng ? (cocoIng.drops || 0) : 0
|
||||
const totalDrops = eoTotal + cocoTotal
|
||||
if (eoTotal > 0 && cocoTotal > 0) {
|
||||
dilutionRatio.value = Math.round(cocoTotal / eoTotal)
|
||||
}
|
||||
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 - 15) < 3) selectedVolume.value = '15'
|
||||
else if (Math.abs(ml - 20) < 4) selectedVolume.value = '20'
|
||||
else if (Math.abs(ml - 30) < 8) selectedVolume.value = '30'
|
||||
else { selectedVolume.value = 'custom'; customVolumeValue.value = Math.round(totalDrops); customVolumeUnit.value = 'drops' }
|
||||
else { selectedVolume.value = 'custom'; customVolumeValue.value = Math.round(ml) }
|
||||
|
||||
loadBrand()
|
||||
nextTick(() => generateCardImage())
|
||||
@@ -1018,23 +970,28 @@ function previewFromEditor() {
|
||||
}
|
||||
|
||||
async function saveRecipe() {
|
||||
const ingredients = editIngredients.value.filter(i => i.oil && i.drops > 0)
|
||||
const eoIngs = editIngredients.value.filter(i => i.oil && i.oil !== '椰子油' && i.drops > 0)
|
||||
if (!editName.value.trim()) {
|
||||
ui.showToast('请输入配方名称')
|
||||
return
|
||||
}
|
||||
if (ingredients.length === 0) {
|
||||
if (eoIngs.length === 0) {
|
||||
ui.showToast('请至少添加一种精油')
|
||||
return
|
||||
}
|
||||
|
||||
const allIngs = eoIngs.map(i => ({ oil_name: i.oil, drops: i.drops }))
|
||||
if (editCocoRow.value && editorCocoActualDrops.value > 0) {
|
||||
allIngs.push({ oil_name: '椰子油', drops: editorCocoActualDrops.value })
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...recipe.value,
|
||||
name: editName.value.trim(),
|
||||
note: editNote.value.trim(),
|
||||
tags: editTags.value,
|
||||
ingredients: ingredients.map(i => ({ oil_name: i.oil, drops: i.drops })),
|
||||
ingredients: allIngs,
|
||||
}
|
||||
await recipesStore.saveRecipe(payload)
|
||||
// Reload recipes so the data is fresh when re-opened
|
||||
@@ -1794,6 +1751,15 @@ async function saveRecipe() {
|
||||
color: var(--sage-dark, #5a7d5e);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.ratio-hint { font-size: 12px; color: #4a9d7e; font-weight: 500; white-space: nowrap; }
|
||||
|
||||
/* Volume controls */
|
||||
.volume-controls {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user