diff --git a/frontend/src/composables/useSmartPaste.js b/frontend/src/composables/useSmartPaste.js
index a609767..2b15267 100644
--- a/frontend/src/composables/useSmartPaste.js
+++ b/frontend/src/composables/useSmartPaste.js
@@ -144,12 +144,16 @@ export function greedyMatchOils(text, oilNames) {
/**
* Parse text chunk into [{oil, drops}] pairs.
* Handles formats like "芳香调理8永久花10" or "薰衣草 3滴 茶树 2ml"
+ * Also handles oil names without numbers, defaulting to 1 drop.
*/
export function parseOilChunk(text, oilNames) {
const results = []
+ // Match: name + optional number+unit
const regex = /([^\d]+?)(\d+\.?\d*)\s*(ml|毫升|ML|mL|滴)?/g
let match
+ let lastIndex = 0
while ((match = regex.exec(text)) !== null) {
+ lastIndex = regex.lastIndex
const namePart = match[1].trim()
let amount = parseFloat(match[2])
const unit = match[3] || ''
@@ -164,7 +168,7 @@ export function parseOilChunk(text, oilNames) {
if (matched.length > 0) {
// Last matched oil gets the drops
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 })
} 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
}
+/** 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.
* 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.
*/
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
- 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 = []
for (const rp of roughParts) {
// If the part has spaces and contains mixed name+oil, split by spaces too
diff --git a/frontend/src/views/RecipeManager.vue b/frontend/src/views/RecipeManager.vue
index 16e0da0..d55c7e6 100644
--- a/frontend/src/views/RecipeManager.vue
+++ b/frontend/src/views/RecipeManager.vue
@@ -193,7 +193,7 @@