diff --git a/frontend/src/composables/useSmartPaste.js b/frontend/src/composables/useSmartPaste.js index 2b15267..386aed2 100644 --- a/frontend/src/composables/useSmartPaste.js +++ b/frontend/src/composables/useSmartPaste.js @@ -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, } diff --git a/frontend/src/views/RecipeManager.vue b/frontend/src/views/RecipeManager.vue index d55c7e6..34dcaaa 100644 --- a/frontend/src/views/RecipeManager.vue +++ b/frontend/src/views/RecipeManager.vue @@ -201,28 +201,24 @@ - -
-
-
- - -
-
-
- {{ ing.oil }} - - -
-
-
- ⚠️ 未识别: {{ pr.notFound.join('、') }} -
- + +
+
+ 批量识别 ({{ parsedCurrentIndex + 1 }}/{{ parsedRecipes.length }}) + +
-
- - +
+
@@ -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; }