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
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:
@@ -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 bestMatch = null
|
||||||
let bestDist = Infinity
|
let bestDist = Infinity
|
||||||
for (const name of oilNames) {
|
for (const name of oilNames) {
|
||||||
@@ -158,9 +159,11 @@ export function parseOilChunk(text, oilNames) {
|
|||||||
let amount = parseFloat(match[2])
|
let amount = parseFloat(match[2])
|
||||||
const unit = match[3] || ''
|
const unit = match[3] || ''
|
||||||
|
|
||||||
|
const isMl = unit && (unit.toLowerCase() === 'ml' || unit === '毫升')
|
||||||
|
let drops = amount
|
||||||
// Convert ml to drops
|
// Convert ml to drops
|
||||||
if (unit && (unit.toLowerCase() === 'ml' || unit === '毫升')) {
|
if (isMl) {
|
||||||
amount = Math.round(amount * 20)
|
drops = Math.round(amount * 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try greedy match on the name part
|
// 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++) {
|
for (let i = 0; i < matched.length - 1; i++) {
|
||||||
results.push({ oil: matched[i], drops: 1 })
|
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 {
|
} else {
|
||||||
// Try findOil as fallback
|
// Try findOil as fallback
|
||||||
const found = findOil(namePart, oilNames)
|
const found = findOil(namePart, oilNames)
|
||||||
if (found) {
|
if (found) {
|
||||||
results.push({ oil: found, drops: amount })
|
const item = { oil: found, drops }
|
||||||
|
if (isMl) { item._ml = amount }
|
||||||
|
results.push(item)
|
||||||
} else if (namePart) {
|
} 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 {
|
return {
|
||||||
name: name || '未命名配方',
|
name: name || '',
|
||||||
ingredients: deduped,
|
ingredients: deduped,
|
||||||
notFound
|
notFound
|
||||||
}
|
}
|
||||||
@@ -358,19 +365,23 @@ export function parseMultiRecipes(raw, oilNames) {
|
|||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
const hasNumber = /\d/.test(part)
|
const hasNumber = /\d/.test(part)
|
||||||
|
const textPart = part.replace(/\d+\.?\d*/g, '').trim()
|
||||||
const hasOil = oilNames.some(oil => part.includes(oil)) ||
|
const hasOil = oilNames.some(oil => part.includes(oil)) ||
|
||||||
Object.keys(OIL_HOMOPHONES).some(alias => part.includes(alias))
|
Object.keys(OIL_HOMOPHONES).some(alias => part.includes(alias))
|
||||||
// Also check fuzzy: 3+ char parts
|
// Also check fuzzy: 3+ char parts
|
||||||
const fuzzyOil = !hasOil && part.replace(/\d+\.?\d*/g, '').length >= 2 &&
|
const fuzzyOil = !hasOil && textPart.length >= 2 &&
|
||||||
findOil(part.replace(/\d+\.?\d*/g, '').trim(), oilNames)
|
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) {
|
if (current.foundOil && !hasOil && !fuzzyOil && !hasNumber && part.length >= 2) {
|
||||||
// New recipe starts
|
// New recipe starts
|
||||||
recipes.push(current)
|
recipes.push(current)
|
||||||
current = { nameParts: [], ingredientParts: [], foundOil: false }
|
current = { nameParts: [], ingredientParts: [], foundOil: false }
|
||||||
current.nameParts.push(part)
|
current.nameParts.push(part)
|
||||||
} else if (!current.foundOil && !hasOil && !fuzzyOil && !hasNumber) {
|
} else if ((!current.foundOil && !hasOil && !fuzzyOil && !hasNumber) || isFirstNameWithNumber) {
|
||||||
current.nameParts.push(part)
|
current.nameParts.push(isFirstNameWithNumber ? textPart : part)
|
||||||
} else {
|
} else {
|
||||||
current.foundOil = true
|
current.foundOil = true
|
||||||
current.ingredientParts.push(part)
|
current.ingredientParts.push(part)
|
||||||
@@ -401,7 +412,7 @@ export function parseMultiRecipes(raw, oilNames) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name: r.nameParts.join(' ') || '未命名配方',
|
name: r.nameParts.join(' ') || '',
|
||||||
ingredients: deduped,
|
ingredients: deduped,
|
||||||
notFound,
|
notFound,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,28 +201,24 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Parsed results preview -->
|
<!-- Multi-recipe queue indicator -->
|
||||||
<div v-if="parsedRecipes.length > 0" class="parsed-results">
|
<div v-if="parsedRecipes.length > 0" class="parsed-queue">
|
||||||
<div v-for="(pr, pi) in parsedRecipes" :key="pi" class="parsed-recipe-card">
|
<div class="parsed-queue-header">
|
||||||
<div class="parsed-header">
|
<span class="parsed-queue-label">批量识别 ({{ parsedCurrentIndex + 1 }}/{{ parsedRecipes.length }})</span>
|
||||||
<input v-model="pr.name" class="form-input parsed-name" placeholder="配方名称" />
|
<button class="btn-outline btn-sm" @click="saveAllParsed">全部保存</button>
|
||||||
<button class="btn-icon-sm" @click="parsedRecipes.splice(pi, 1)" title="放弃">✕</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>
|
||||||
<div class="parsed-actions">
|
<div class="parsed-queue-list">
|
||||||
<button class="btn-primary btn-sm" @click="saveAllParsed" :disabled="parsedRecipes.length === 0">全部保存 ({{ parsedRecipes.length }})</button>
|
<button
|
||||||
<button class="btn-outline btn-sm" @click="parsedRecipes = []">取消全部</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -435,6 +431,7 @@ const formNote = ref('')
|
|||||||
const formTags = ref([])
|
const formTags = ref([])
|
||||||
const smartPasteText = ref('')
|
const smartPasteText = ref('')
|
||||||
const parsedRecipes = ref([])
|
const parsedRecipes = ref([])
|
||||||
|
const parsedCurrentIndex = ref(-1)
|
||||||
const showAddIngRow = ref(false)
|
const showAddIngRow = ref(false)
|
||||||
const newIngOil = ref('')
|
const newIngOil = ref('')
|
||||||
const newIngSearch = ref('')
|
const newIngSearch = ref('')
|
||||||
@@ -448,7 +445,7 @@ const formDilution = ref(6)
|
|||||||
const formCocoRow = ref(null)
|
const formCocoRow = ref(null)
|
||||||
|
|
||||||
watch(() => formVolume.value, (vol) => {
|
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 }
|
formCocoRow.value = { oil: '椰子油', drops: vol === 'single' ? 10 : 0, _search: '椰子油', _open: false }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -808,6 +805,7 @@ function resetForm() {
|
|||||||
formTags.value = []
|
formTags.value = []
|
||||||
smartPasteText.value = ''
|
smartPasteText.value = ''
|
||||||
parsedRecipes.value = []
|
parsedRecipes.value = []
|
||||||
|
parsedCurrentIndex.value = -1
|
||||||
showAddIngRow.value = false
|
showAddIngRow.value = false
|
||||||
newIngOil.value = ''
|
newIngOil.value = ''
|
||||||
newIngSearch.value = ''
|
newIngSearch.value = ''
|
||||||
@@ -835,9 +833,10 @@ function handleSmartPaste() {
|
|||||||
}
|
}
|
||||||
parsedRecipes.value = []
|
parsedRecipes.value = []
|
||||||
} else {
|
} else {
|
||||||
// Multiple recipes: show preview cards
|
// Multiple recipes: store queue, load first into form
|
||||||
parsedRecipes.value = results
|
parsedRecipes.value = results
|
||||||
ui.showToast(`识别出 ${results.length} 条配方`)
|
loadParsedIntoForm(0)
|
||||||
|
ui.showToast(`识别出 ${results.length} 条配方,逐条编辑保存`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1091,7 +1090,7 @@ async function saveCurrentRecipe() {
|
|||||||
}
|
}
|
||||||
await recipeStore.saveRecipe(pubPayload)
|
await recipeStore.saveRecipe(pubPayload)
|
||||||
ui.showToast('已添加到公共配方库')
|
ui.showToast('已添加到公共配方库')
|
||||||
closeOverlay()
|
if (!loadNextParsed()) closeOverlay()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.showToast('保存失败: ' + (e.message || '未知错误'))
|
ui.showToast('保存失败: ' + (e.message || '未知错误'))
|
||||||
}
|
}
|
||||||
@@ -1101,7 +1100,7 @@ async function saveCurrentRecipe() {
|
|||||||
try {
|
try {
|
||||||
await diaryStore.createDiary(diaryPayload)
|
await diaryStore.createDiary(diaryPayload)
|
||||||
ui.showToast('已添加到我的配方')
|
ui.showToast('已添加到我的配方')
|
||||||
closeOverlay()
|
if (!loadNextParsed()) closeOverlay()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
ui.showToast('保存失败: ' + (e.message || '未知错误'))
|
ui.showToast('保存失败: ' + (e.message || '未知错误'))
|
||||||
}
|
}
|
||||||
@@ -1128,6 +1127,8 @@ async function saveParsedRecipe(index) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveAllParsed() {
|
async function saveAllParsed() {
|
||||||
|
// Sync current form edits back first
|
||||||
|
syncFormToParsed()
|
||||||
let saved = 0
|
let saved = 0
|
||||||
for (let i = parsedRecipes.value.length - 1; i >= 0; i--) {
|
for (let i = parsedRecipes.value.length - 1; i >= 0; i--) {
|
||||||
const r = parsedRecipes.value[i]
|
const r = parsedRecipes.value[i]
|
||||||
@@ -1143,10 +1144,76 @@ async function saveAllParsed() {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
parsedRecipes.value = []
|
parsedRecipes.value = []
|
||||||
|
parsedCurrentIndex.value = -1
|
||||||
ui.showToast(`已保存 ${saved} 条配方到我的配方`)
|
ui.showToast(`已保存 ${saved} 条配方到我的配方`)
|
||||||
closeOverlay()
|
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: [] })
|
const sharedCount = ref({ adopted: 0, total: 0, adoptedNames: [], pendingNames: [] })
|
||||||
|
|
||||||
async function loadContribution() {
|
async function loadContribution() {
|
||||||
@@ -1923,25 +1990,18 @@ watch(() => recipeStore.recipes, () => {
|
|||||||
border-color: #7ec6a4;
|
border-color: #7ec6a4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.parsed-results { margin: 12px 0; }
|
.parsed-queue { margin: 12px 0; background: #f8faf8; border: 1.5px solid #d4e8d4; border-radius: 10px; padding: 10px 12px; }
|
||||||
.parsed-recipe-card {
|
.parsed-queue-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||||
background: #f8faf8;
|
.parsed-queue-label { font-size: 13px; font-weight: 600; color: #2e7d5a; flex: 1; }
|
||||||
border: 1.5px solid #d4e8d4;
|
.parsed-queue-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
border-radius: 10px;
|
.parsed-queue-item {
|
||||||
padding: 12px;
|
display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 8px;
|
||||||
margin-bottom: 10px;
|
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-queue-item.active { border-color: #7ec6a4; background: #e8f5e9; font-weight: 600; }
|
||||||
.parsed-name { flex: 1; font-weight: 600; }
|
.parsed-queue-item:hover { border-color: #d4cfc7; }
|
||||||
.parsed-ings { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; }
|
.parsed-queue-name { color: #3e3a44; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.parsed-ing {
|
.parsed-queue-count { color: #b0aab5; font-size: 11px; }
|
||||||
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; }
|
|
||||||
|
|
||||||
.editor-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
.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; }
|
.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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user