feat: 智能识别支持无数字精油名(默认1滴)、分号/空行分隔多配方
Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Failing after 5s
Test / e2e-test (push) Has been skipped
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Failing after 5s
PR Preview / deploy-preview (pull_request) Has been skipped

- 精油名后不写数字自动识别为1滴
- 分号分隔多配方(两边都有精油时)
- 空行分隔多配方
- 混合格式支持(部分有数字部分无数字)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 22:13:52 +00:00
parent 9f627bbef9
commit 146ebec588
2 changed files with 54 additions and 3 deletions

View File

@@ -144,12 +144,16 @@ export function greedyMatchOils(text, oilNames) {
/** /**
* Parse text chunk into [{oil, drops}] pairs. * Parse text chunk into [{oil, drops}] pairs.
* Handles formats like "芳香调理8永久花10" or "薰衣草 3滴 茶树 2ml" * Handles formats like "芳香调理8永久花10" or "薰衣草 3滴 茶树 2ml"
* Also handles oil names without numbers, defaulting to 1 drop.
*/ */
export function parseOilChunk(text, oilNames) { export function parseOilChunk(text, oilNames) {
const results = [] const results = []
// Match: name + optional number+unit
const regex = /([^\d]+?)(\d+\.?\d*)\s*(ml|毫升|ML|mL|滴)?/g const regex = /([^\d]+?)(\d+\.?\d*)\s*(ml|毫升|ML|mL|滴)?/g
let match let match
let lastIndex = 0
while ((match = regex.exec(text)) !== null) { while ((match = regex.exec(text)) !== null) {
lastIndex = regex.lastIndex
const namePart = match[1].trim() const namePart = match[1].trim()
let amount = parseFloat(match[2]) let amount = parseFloat(match[2])
const unit = match[3] || '' const unit = match[3] || ''
@@ -164,7 +168,7 @@ export function parseOilChunk(text, oilNames) {
if (matched.length > 0) { if (matched.length > 0) {
// Last matched oil gets the drops // Last matched oil gets the drops
for (let i = 0; i < matched.length - 1; i++) { for (let i = 0; i < matched.length - 1; i++) {
results.push({ oil: matched[i], drops: 0 }) results.push({ oil: matched[i], drops: 1 })
} }
results.push({ oil: matched[matched.length - 1], drops: amount }) results.push({ oil: matched[matched.length - 1], drops: amount })
} else { } else {
@@ -177,9 +181,41 @@ export function parseOilChunk(text, oilNames) {
} }
} }
} }
if (lastIndex === 0) {
// Regex matched nothing — try the whole text as oil names without numbers
_parseNamesOnly(text.trim(), oilNames, results)
} else {
// Handle trailing text after last number match
const trailing = text.substring(lastIndex).trim()
if (trailing) {
_parseNamesOnly(trailing, oilNames, results)
}
}
return results return results
} }
/** Parse text that contains only oil names (no numbers), default 1 drop each. */
function _parseNamesOnly(text, oilNames, results) {
// Try greedy match first
const matched = greedyMatchOils(text, oilNames)
if (matched.length > 0) {
for (const oil of matched) {
results.push({ oil, drops: 1 })
}
return
}
// Fallback: try splitting by common delimiters and fuzzy match
const parts = text.split(/[\s+、,]+/).filter(s => s)
for (const part of parts) {
const found = findOil(part, oilNames)
if (found) {
results.push({ oil: found, drops: 1 })
}
}
}
/** /**
* Split multi-recipe input by blank lines or semicolons. * Split multi-recipe input by blank lines or semicolons.
* Detects recipe boundaries (non-oil text after seeing oils = new recipe). * Detects recipe boundaries (non-oil text after seeing oils = new recipe).
@@ -266,8 +302,23 @@ export function parseSingleBlock(raw, oilNames) {
* appears after some oils have been found, it starts a new recipe. * appears after some oils have been found, it starts a new recipe.
*/ */
export function parseMultiRecipes(raw, oilNames) { export function parseMultiRecipes(raw, oilNames) {
// Split by blank lines into major blocks
const blankLineSplit = raw.split(/\n\s*\n/).map(s => s.trim()).filter(s => s)
if (blankLineSplit.length > 1) {
return blankLineSplit.flatMap(block => parseMultiRecipes(block, oilNames))
}
// Split by semicolons only if both sides contain oil names
const semiParts = raw.split(/[;]/).map(s => s.trim()).filter(s => s)
if (semiParts.length > 1) {
const hasOilInPart = p => oilNames.some(oil => p.includes(oil)) ||
Object.keys(OIL_HOMOPHONES).some(a => p.includes(a))
if (semiParts.every(hasOilInPart)) {
return semiParts.flatMap(block => parseMultiRecipes(block, oilNames))
}
}
// First split by lines/commas, then within each part also try space splitting // First split by lines/commas, then within each part also try space splitting
const roughParts = raw.split(/[,,、\n\r]+/).map(s => s.trim()).filter(s => s) const roughParts = raw.split(/[,,、;\n\r]+/).map(s => s.trim()).filter(s => s)
const parts = [] const parts = []
for (const rp of roughParts) { for (const rp of roughParts) {
// If the part has spaces and contains mixed name+oil, split by spaces too // If the part has spaces and contains mixed name+oil, split by spaces too

View File

@@ -193,7 +193,7 @@
<textarea <textarea
v-model="smartPasteText" v-model="smartPasteText"
class="paste-input" class="paste-input"
placeholder="直接粘贴配方文本,支持多条配方同时识别&#10;例如: 舒缓放松薰衣草3茶树2&#10;提神醒脑柠檬5椒样薄荷3" placeholder="直接粘贴配方文本,支持多条配方同时识别&#10;例如: 舒缓放松薰衣草3茶树2&#10;提神醒脑柠檬5椒样薄荷3&#10;不写数字默认1滴: 薰衣草,茶树,乳香"
rows="4" rows="4"
></textarea> ></textarea>
<button class="btn-primary btn-sm" @click="handleSmartPaste" :disabled="!smartPasteText.trim()"> <button class="btn-primary btn-sm" @click="handleSmartPaste" :disabled="!smartPasteText.trim()">