feat: 智能识别多配方逐条编辑、椰子油单位识别、名称修复
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Failing after 6s
Test / e2e-test (push) Has been skipped
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Failing after 6s
PR Preview / deploy-preview (pull_request) Has been skipped

- 多配方识别后逐条填入完整编辑表单,保存后自动加载下一条
- 队列指示条可切换/删除/全部保存,表单修改实时同步
- 椰子油写滴数→单次模式,写ml→对应容量模式
- 2字以下不做编辑距离模糊匹配,避免"美容"→"宽容"
- 首个非精油带数字的词识别为配方名(如"美容1"→名称"美容")
- 无名配方留空,点击直接输入

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 22:46:19 +00:00
parent 146ebec588
commit 281153eef9
2 changed files with 127 additions and 56 deletions

View File

@@ -86,7 +86,8 @@ export function findOil(input, oilNames) {
}
}
// 5. Edit distance fuzzy match
// 5. Edit distance fuzzy match (only for 3+ char inputs to avoid false positives)
if (trimmed.length < 3) return null
let bestMatch = null
let bestDist = Infinity
for (const name of oilNames) {
@@ -158,9 +159,11 @@ export function parseOilChunk(text, oilNames) {
let amount = parseFloat(match[2])
const unit = match[3] || ''
const isMl = unit && (unit.toLowerCase() === 'ml' || unit === '毫升')
let drops = amount
// Convert ml to drops
if (unit && (unit.toLowerCase() === 'ml' || unit === '毫升')) {
amount = Math.round(amount * 20)
if (isMl) {
drops = Math.round(amount * 20)
}
// Try greedy match on the name part
@@ -170,14 +173,18 @@ export function parseOilChunk(text, oilNames) {
for (let i = 0; i < matched.length - 1; i++) {
results.push({ oil: matched[i], drops: 1 })
}
results.push({ oil: matched[matched.length - 1], drops: amount })
const item = { oil: matched[matched.length - 1], drops }
if (isMl) { item._ml = amount }
results.push(item)
} else {
// Try findOil as fallback
const found = findOil(namePart, oilNames)
if (found) {
results.push({ oil: found, drops: amount })
const item = { oil: found, drops }
if (isMl) { item._ml = amount }
results.push(item)
} else if (namePart) {
results.push({ oil: namePart, drops: amount, notFound: true })
results.push({ oil: namePart, drops, notFound: true })
}
}
}
@@ -291,7 +298,7 @@ export function parseSingleBlock(raw, oilNames) {
}
return {
name: name || '未命名配方',
name: name || '',
ingredients: deduped,
notFound
}
@@ -358,19 +365,23 @@ export function parseMultiRecipes(raw, oilNames) {
for (const part of parts) {
const hasNumber = /\d/.test(part)
const textPart = part.replace(/\d+\.?\d*/g, '').trim()
const hasOil = oilNames.some(oil => part.includes(oil)) ||
Object.keys(OIL_HOMOPHONES).some(alias => part.includes(alias))
// Also check fuzzy: 3+ char parts
const fuzzyOil = !hasOil && part.replace(/\d+\.?\d*/g, '').length >= 2 &&
findOil(part.replace(/\d+\.?\d*/g, '').trim(), oilNames)
const fuzzyOil = !hasOil && textPart.length >= 2 &&
findOil(textPart, oilNames)
// First part only: has number but text is not any oil → likely a name like "美容1"
const isFirstNameWithNumber = !current.foundOil && current.nameParts.length === 0 &&
current.ingredientParts.length === 0 && hasNumber && !hasOil && !fuzzyOil && textPart.length >= 2
if (current.foundOil && !hasOil && !fuzzyOil && !hasNumber && part.length >= 2) {
// New recipe starts
recipes.push(current)
current = { nameParts: [], ingredientParts: [], foundOil: false }
current.nameParts.push(part)
} else if (!current.foundOil && !hasOil && !fuzzyOil && !hasNumber) {
current.nameParts.push(part)
} else if ((!current.foundOil && !hasOil && !fuzzyOil && !hasNumber) || isFirstNameWithNumber) {
current.nameParts.push(isFirstNameWithNumber ? textPart : part)
} else {
current.foundOil = true
current.ingredientParts.push(part)
@@ -401,7 +412,7 @@ export function parseMultiRecipes(raw, oilNames) {
}
}
return {
name: r.nameParts.join(' ') || '未命名配方',
name: r.nameParts.join(' ') || '',
ingredients: deduped,
notFound,
}

View File

@@ -201,28 +201,24 @@
</button>
</div>
<!-- Parsed results preview -->
<div v-if="parsedRecipes.length > 0" class="parsed-results">
<div v-for="(pr, pi) in parsedRecipes" :key="pi" class="parsed-recipe-card">
<div class="parsed-header">
<input v-model="pr.name" class="form-input parsed-name" placeholder="配方名称" />
<button class="btn-icon-sm" @click="parsedRecipes.splice(pi, 1)" title="放弃"></button>
<!-- Multi-recipe queue indicator -->
<div v-if="parsedRecipes.length > 0" class="parsed-queue">
<div class="parsed-queue-header">
<span class="parsed-queue-label">批量识别 ({{ parsedCurrentIndex + 1 }}/{{ parsedRecipes.length }})</span>
<button class="btn-outline btn-sm" @click="saveAllParsed">全部保存</button>
<button class="btn-outline btn-sm" @click="parsedRecipes = []; parsedCurrentIndex = -1">取消全部</button>
</div>
<div class="parsed-ings">
<div v-for="(ing, ii) in pr.ingredients" :key="ii" class="parsed-ing">
<span class="parsed-oil">{{ ing.oil }}</span>
<input v-model.number="ing.drops" type="number" min="0" class="form-input-sm" />
<button class="btn-icon-sm" @click="pr.ingredients.splice(ii, 1)"></button>
</div>
</div>
<div v-if="pr.notFound && pr.notFound.length" class="parsed-warn">
未识别: {{ pr.notFound.join('') }}
</div>
<button class="btn-primary btn-sm" @click="saveParsedRecipe(pi)">💾 保存此条</button>
</div>
<div class="parsed-actions">
<button class="btn-primary btn-sm" @click="saveAllParsed" :disabled="parsedRecipes.length === 0">全部保存 ({{ parsedRecipes.length }})</button>
<button class="btn-outline btn-sm" @click="parsedRecipes = []">取消全部</button>
<div class="parsed-queue-list">
<button
v-for="(pr, pi) in parsedRecipes" :key="pi"
class="parsed-queue-item"
:class="{ active: pi === parsedCurrentIndex }"
@click="loadParsedIntoForm(pi)"
>
<span class="parsed-queue-name">{{ pr.name || '未命名' }}</span>
<span class="parsed-queue-count">{{ pr.ingredients.length }}</span>
<span class="btn-icon-sm" @click.stop="parsedRecipes.splice(pi, 1); if (parsedRecipes.length === 0) parsedCurrentIndex = -1; else if (pi <= parsedCurrentIndex) loadParsedIntoForm(Math.min(parsedCurrentIndex, parsedRecipes.length - 1))"></span>
</button>
</div>
</div>
@@ -435,6 +431,7 @@ const formNote = ref('')
const formTags = ref([])
const smartPasteText = ref('')
const parsedRecipes = ref([])
const parsedCurrentIndex = ref(-1)
const showAddIngRow = ref(false)
const newIngOil = ref('')
const newIngSearch = ref('')
@@ -448,7 +445,7 @@ const formDilution = ref(6)
const formCocoRow = ref(null)
watch(() => formVolume.value, (vol) => {
if (vol && !formCocoRow.value) {
if (vol && !formCocoRow.value && parsedCurrentIndex.value < 0) {
formCocoRow.value = { oil: '椰子油', drops: vol === 'single' ? 10 : 0, _search: '椰子油', _open: false }
}
})
@@ -808,6 +805,7 @@ function resetForm() {
formTags.value = []
smartPasteText.value = ''
parsedRecipes.value = []
parsedCurrentIndex.value = -1
showAddIngRow.value = false
newIngOil.value = ''
newIngSearch.value = ''
@@ -835,9 +833,10 @@ function handleSmartPaste() {
}
parsedRecipes.value = []
} else {
// Multiple recipes: show preview cards
// Multiple recipes: store queue, load first into form
parsedRecipes.value = results
ui.showToast(`识别出 ${results.length} 条配方`)
loadParsedIntoForm(0)
ui.showToast(`识别出 ${results.length} 条配方,逐条编辑保存`)
}
}
@@ -1091,7 +1090,7 @@ async function saveCurrentRecipe() {
}
await recipeStore.saveRecipe(pubPayload)
ui.showToast('已添加到公共配方库')
closeOverlay()
if (!loadNextParsed()) closeOverlay()
} catch (e) {
ui.showToast('保存失败: ' + (e.message || '未知错误'))
}
@@ -1101,7 +1100,7 @@ async function saveCurrentRecipe() {
try {
await diaryStore.createDiary(diaryPayload)
ui.showToast('已添加到我的配方')
closeOverlay()
if (!loadNextParsed()) closeOverlay()
} catch (e) {
ui.showToast('保存失败: ' + (e.message || '未知错误'))
}
@@ -1128,6 +1127,8 @@ async function saveParsedRecipe(index) {
}
async function saveAllParsed() {
// Sync current form edits back first
syncFormToParsed()
let saved = 0
for (let i = parsedRecipes.value.length - 1; i >= 0; i--) {
const r = parsedRecipes.value[i]
@@ -1143,10 +1144,76 @@ async function saveAllParsed() {
} catch {}
}
parsedRecipes.value = []
parsedCurrentIndex.value = -1
ui.showToast(`已保存 ${saved} 条配方到我的配方`)
closeOverlay()
}
/** After saving, mark current as done and load next. Returns true if there's a next one. */
function loadNextParsed() {
if (parsedCurrentIndex.value < 0 || parsedRecipes.value.length === 0) return false
// Remove the just-saved recipe
parsedRecipes.value.splice(parsedCurrentIndex.value, 1)
if (parsedRecipes.value.length === 0) {
parsedCurrentIndex.value = -1
return false
}
// Load next (same index, or last if was at end)
const next = Math.min(parsedCurrentIndex.value, parsedRecipes.value.length - 1)
loadParsedIntoForm(next)
return true
}
/** Sync current form edits back to parsedRecipes before switching */
function syncFormToParsed() {
if (parsedCurrentIndex.value < 0) return
const r = parsedRecipes.value[parsedCurrentIndex.value]
if (!r) return
r.name = formName.value
// Rebuild ingredients from form (EO + coco)
const ings = formIngredients.value.filter(i => i.oil && i.oil !== '椰子油').map(i => ({ oil: i.oil, drops: i.drops }))
if (formCocoRow.value && cocoActualDrops.value > 0) {
ings.push({ oil: '椰子油', drops: cocoActualDrops.value })
}
r.ingredients = ings
}
function loadParsedIntoForm(index) {
// Save current edits before switching
syncFormToParsed()
const r = parsedRecipes.value[index]
if (!r) return
parsedCurrentIndex.value = index
formName.value = r.name
const cocoIng = r.ingredients.find(i => i.oil === '椰子油')
const eoIngs = r.ingredients.filter(i => i.oil !== '椰子油')
formIngredients.value = eoIngs.length > 0
? eoIngs.map(i => ({ ...i, _search: i.oil, _open: false }))
: [{ oil: '', drops: 1, _search: '', _open: false }]
if (cocoIng) {
if (cocoIng._ml) {
// Written as ml — use ml volume mode
const mlStr = String(cocoIng._ml)
const standardMls = ['5', '10', '15', '20', '30']
formCocoRow.value = { oil: '椰子油', drops: 0, _search: '椰子油', _open: false }
formVolume.value = standardMls.includes(mlStr) ? mlStr : 'custom'
if (!standardMls.includes(mlStr)) formCustomVolume.value = cocoIng._ml
} else {
// Written as drops — use single mode
formCocoRow.value = { oil: '椰子油', drops: cocoIng.drops, _search: '椰子油', _open: false }
formVolume.value = 'single'
}
} else {
formCocoRow.value = null
}
formNote.value = ''
formTags.value = []
if (!cocoIng) formVolume.value = ''
if (r.notFound && r.notFound.length > 0) {
ui.showToast(`未识别: ${r.notFound.join('、')}`)
}
}
const sharedCount = ref({ adopted: 0, total: 0, adoptedNames: [], pendingNames: [] })
async function loadContribution() {
@@ -1923,25 +1990,18 @@ watch(() => recipeStore.recipes, () => {
border-color: #7ec6a4;
}
.parsed-results { margin: 12px 0; }
.parsed-recipe-card {
background: #f8faf8;
border: 1.5px solid #d4e8d4;
border-radius: 10px;
padding: 12px;
margin-bottom: 10px;
.parsed-queue { margin: 12px 0; background: #f8faf8; border: 1.5px solid #d4e8d4; border-radius: 10px; padding: 10px 12px; }
.parsed-queue-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.parsed-queue-label { font-size: 13px; font-weight: 600; color: #2e7d5a; flex: 1; }
.parsed-queue-list { display: flex; flex-wrap: wrap; gap: 6px; }
.parsed-queue-item {
display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 8px;
border: 1.5px solid #e5e4e7; background: #fff; font-size: 12px; cursor: pointer; font-family: inherit;
}
.parsed-header { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.parsed-name { flex: 1; font-weight: 600; }
.parsed-ings { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
.parsed-ing {
display: flex; align-items: center; gap: 4px;
background: #fff; border: 1px solid #e5e4e7; border-radius: 8px; padding: 4px 8px; font-size: 13px;
}
.parsed-oil { color: #3e3a44; font-weight: 500; }
.parsed-ing .form-input-sm { width: 50px; padding: 4px 6px; font-size: 12px; }
.parsed-warn { color: #e65100; font-size: 12px; margin-bottom: 6px; }
.parsed-actions { display: flex; gap: 8px; justify-content: center; margin-top: 8px; }
.parsed-queue-item.active { border-color: #7ec6a4; background: #e8f5e9; font-weight: 600; }
.parsed-queue-item:hover { border-color: #d4cfc7; }
.parsed-queue-name { color: #3e3a44; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.parsed-queue-count { color: #b0aab5; font-size: 11px; }
.editor-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.editor-name-input { width: 100%; font-size: 17px; font-weight: 600; border: none; border-bottom: 2px solid #e5e4e7; padding: 6px 0; outline: none; font-family: inherit; background: transparent; color: #3e3a44; }