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:
@@ -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>
|
||||
</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>
|
||||
<!-- 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-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; }
|
||||
|
||||
Reference in New Issue
Block a user