Refactor frontend to Vue 3 + Vite + Pinia + Cypress E2E
- Replace single-file 8441-line HTML with Vue 3 SPA - Pinia stores: auth, oils, recipes, diary, ui - Composables: useApi, useDialog, useSmartPaste, useOilTranslation - 6 shared components: RecipeCard, RecipeDetailOverlay, TagPicker, etc. - 9 page views: RecipeSearch, RecipeManager, Inventory, OilReference, etc. - 14 Cypress E2E test specs (113 tests), all passing - Multi-stage Dockerfile (Node build + Python runtime) - Demo video generation scripts (TTS + subtitles + screen recording) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
262
frontend/src/composables/useSmartPaste.js
Normal file
262
frontend/src/composables/useSmartPaste.js
Normal file
@@ -0,0 +1,262 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user