Files
oil-formula-calculator/frontend/src/composables/useSmartPaste.js
Hera Zhao 56bc6f2bbb feat: 重写新增配方+共享审核完整流程
新增配方:
- 修复保存失败(oil→oil_name字段转换)
- 智能识别支持多条配方同时解析
- 识别结果逐条预览,可修改/放弃/保存单条/全部保存
- 编辑器加成分表格(单价/滴、小计、总成本)
- 保存到个人配方(diary)

共享审核:
- 新增 /api/recipes/{id}/reject 端点(带原因通知提交者)
- 采纳配方时通知提交者"配方已采纳"
- 拒绝时管理员输入原因
- 贡献统计含被采纳的配方数

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:32:13 +00:00

325 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const DROPS_PER_ML = 18.6
export const OIL_HOMOPHONES = {
'相貌':'香茅','香矛':'香茅','向茅':'香茅','像茅':'香茅',
'如香':'乳香','儒香':'乳香',
'古巴想':'古巴香脂','古巴香':'古巴香脂','古巴相脂':'古巴香脂',
'博荷':'薄荷','薄河':'薄荷',
'尤佳利':'尤加利','优加利':'尤加利',
'依兰':'依兰依兰',
'雪松木':'雪松',
'桧木':'扁柏','桧柏':'扁柏',
'永久化':'永久花','永久华':'永久花',
'罗马洋柑菊':'罗马洋甘菊','洋甘菊':'罗马洋甘菊',
'天竹葵':'天竺葵','天竺癸':'天竺葵',
'没要':'没药','莫药':'没药',
'快乐鼠尾':'快乐鼠尾草',
'椒样博荷':'椒样薄荷','椒样薄和':'椒样薄荷',
'丝柏木':'丝柏',
'柠檬草油':'柠檬草',
'茶树油':'茶树',
'薰衣草油':'薰衣草',
'玫瑰花':'玫瑰',
}
/**
* Levenshtein edit distance between two strings
*/
export function editDistance(a, b) {
const m = a.length, n = b.length
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1]
} else {
dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
}
}
}
return dp[m][n]
}
/**
* Fuzzy match oil name against known list.
* Priority: homophone -> exact -> substring -> missing-char -> edit distance
* Returns matched oil name or null.
*/
export function findOil(input, oilNames) {
if (!input || input.length === 0) return null
const trimmed = input.trim()
if (!trimmed) return null
// 1. Homophone alias check
if (OIL_HOMOPHONES[trimmed]) {
const alias = OIL_HOMOPHONES[trimmed]
if (oilNames.includes(alias)) return alias
}
// 2. Exact match
if (oilNames.includes(trimmed)) return trimmed
// 3. Substring match (input ⊂ name or name ⊂ input), prefer longest
let substringMatches = []
for (const name of oilNames) {
if (name.includes(trimmed) || trimmed.includes(name)) {
substringMatches.push(name)
}
}
if (substringMatches.length > 0) {
substringMatches.sort((a, b) => b.length - a.length)
return substringMatches[0]
}
// 4. "Missing one char" match - input is one char shorter than an oil name
for (const name of oilNames) {
if (Math.abs(name.length - trimmed.length) === 1) {
const longer = name.length > trimmed.length ? name : trimmed
const shorter = name.length > trimmed.length ? trimmed : name
// Check if shorter can be formed by removing one char from longer
for (let i = 0; i < longer.length; i++) {
const candidate = longer.slice(0, i) + longer.slice(i + 1)
if (candidate === shorter) return name
}
}
}
// 5. Edit distance fuzzy match
let bestMatch = null
let bestDist = Infinity
for (const name of oilNames) {
const dist = editDistance(trimmed, name)
const maxLen = Math.max(trimmed.length, name.length)
// Only accept if edit distance is reasonable (less than half the length)
if (dist < bestDist && dist <= Math.floor(maxLen / 2)) {
bestDist = dist
bestMatch = name
}
}
return bestMatch
}
/**
* Greedy longest-match from concatenated string against oil names.
* Returns array of matched oil names in order.
*/
export function greedyMatchOils(text, oilNames) {
const results = []
let i = 0
while (i < text.length) {
let bestMatch = null
let bestLen = 0
// Try all oil names sorted by length (longest first)
const sorted = [...oilNames].sort((a, b) => b.length - a.length)
for (const name of sorted) {
if (text.substring(i, i + name.length) === name) {
bestMatch = name
bestLen = name.length
break
}
}
// Also check homophones
if (!bestMatch) {
for (const [alias, canonical] of Object.entries(OIL_HOMOPHONES)) {
if (text.substring(i, i + alias.length) === alias) {
if (!bestMatch || alias.length > bestLen) {
bestMatch = canonical
bestLen = alias.length
}
}
}
}
if (bestMatch) {
results.push(bestMatch)
i += bestLen
} else {
i++
}
}
return results
}
/**
* Parse text chunk into [{oil, drops}] pairs.
* Handles formats like "芳香调理8永久花10" or "薰衣草 3滴 茶树 2ml"
*/
export function parseOilChunk(text, oilNames) {
const results = []
const regex = /([^\d]+?)(\d+\.?\d*)\s*(ml|毫升|ML|mL|滴)?/g
let match
while ((match = regex.exec(text)) !== null) {
const namePart = match[1].trim()
let amount = parseFloat(match[2])
const unit = match[3] || ''
// Convert ml to drops
if (unit && (unit.toLowerCase() === 'ml' || unit === '毫升')) {
amount = Math.round(amount * 20)
}
// Try greedy match on the name part
const matched = greedyMatchOils(namePart, 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[matched.length - 1], drops: amount })
} else {
// Try findOil as fallback
const found = findOil(namePart, oilNames)
if (found) {
results.push({ oil: found, drops: amount })
} else if (namePart) {
results.push({ oil: namePart, drops: amount, notFound: true })
}
}
}
return results
}
/**
* Split multi-recipe input by blank lines or semicolons.
* Detects recipe boundaries (non-oil text after seeing oils = new recipe).
*/
export function splitRawIntoBlocks(raw, oilNames) {
// First split by semicolons
let parts = raw.split(/[;]/)
// Then split each part by blank lines
let blocks = []
for (const part of parts) {
const subBlocks = part.split(/\n\s*\n/)
blocks.push(...subBlocks)
}
// Filter empty blocks
blocks = blocks.map(b => b.trim()).filter(b => b.length > 0)
return blocks
}
/**
* Parse one recipe block into {name, ingredients, notFound}.
* 1. Split by commas/newlines/etc
* 2. First non-oil, non-number part = recipe name
* 3. Rest parsed through parseOilChunk
* 4. Deduplicate ingredients
*/
export function parseSingleBlock(raw, oilNames) {
// Split by commas, Chinese commas, newlines, spaces
const parts = raw.split(/[,\n\r]+/).map(s => s.trim()).filter(s => s)
let name = ''
let ingredientParts = []
let foundFirstOil = false
for (const part of parts) {
// Check if this part contains oil references
const hasNumber = /\d/.test(part)
const hasOil = oilNames.some(oil => part.includes(oil)) ||
Object.keys(OIL_HOMOPHONES).some(alias => part.includes(alias))
if (!foundFirstOil && !hasOil && !hasNumber && !name) {
// This is the recipe name
name = part
} else {
foundFirstOil = true
ingredientParts.push(part)
}
}
// Parse all ingredient parts
const allIngredients = []
const notFound = []
for (const part of ingredientParts) {
const parsed = parseOilChunk(part, oilNames)
for (const item of parsed) {
if (item.notFound) {
notFound.push(item.oil)
} else {
allIngredients.push(item)
}
}
}
// Deduplicate: merge same oil, sum drops
const deduped = []
const seen = {}
for (const item of allIngredients) {
if (seen[item.oil] !== undefined) {
deduped[seen[item.oil]].drops += item.drops
} else {
seen[item.oil] = deduped.length
deduped.push({ ...item })
}
}
return {
name: name || '未命名配方',
ingredients: deduped,
notFound
}
}
/**
* Parse multi-recipe text. Each time an unrecognized non-number token
* appears after some oils have been found, it starts a new recipe.
*/
export function parseMultiRecipes(raw, oilNames) {
const parts = raw.split(/[,,、\n\r]+/).map(s => s.trim()).filter(s => s)
const recipes = []
let current = { nameParts: [], ingredientParts: [], foundOil: false }
for (const part of parts) {
const hasNumber = /\d/.test(part)
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)
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 {
current.foundOil = true
current.ingredientParts.push(part)
}
}
recipes.push(current)
// Convert each block to parsed recipe
return recipes.filter(r => r.ingredientParts.length > 0 || r.nameParts.length > 0).map(r => {
const allIngs = []
const notFound = []
for (const p of r.ingredientParts) {
const parsed = parseOilChunk(p, oilNames)
for (const item of parsed) {
if (item.notFound) notFound.push(item.oil)
else allIngs.push(item)
}
}
// Deduplicate
const deduped = []
const seen = {}
for (const item of allIngs) {
if (seen[item.oil] !== undefined) {
deduped[seen[item.oil]].drops += item.drops
} else {
seen[item.oil] = deduped.length
deduped.push({ ...item })
}
}
return {
name: r.nameParts.join(' ') || '未命名配方',
ingredients: deduped,
notFound,
}
})
}