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:
44
frontend/src/composables/useApi.js
Normal file
44
frontend/src/composables/useApi.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const API_BASE = '' // same origin, uses vite proxy in dev
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem('oil_auth_token') || ''
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
if (token) localStorage.setItem('oil_auth_token', token)
|
||||
else localStorage.removeItem('oil_auth_token')
|
||||
}
|
||||
|
||||
function buildHeaders(extra = {}) {
|
||||
const headers = { 'Content-Type': 'application/json', ...extra }
|
||||
const token = getToken()
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token
|
||||
return headers
|
||||
}
|
||||
|
||||
async function request(path, opts = {}) {
|
||||
const headers = buildHeaders(opts.headers)
|
||||
const res = await fetch(API_BASE + path, { ...opts, headers })
|
||||
return res
|
||||
}
|
||||
|
||||
async function requestJSON(path, opts = {}) {
|
||||
const res = await request(path, opts)
|
||||
if (!res.ok) throw res
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// api is callable as api(path, opts) → raw Response
|
||||
// AND has convenience methods: api.get(), api.post(), api.put(), api.delete()
|
||||
function apiFn(path, opts = {}) {
|
||||
return request(path, opts)
|
||||
}
|
||||
|
||||
apiFn.raw = request
|
||||
apiFn.get = (path) => requestJSON(path)
|
||||
apiFn.post = (path, body) => requestJSON(path, { method: 'POST', body: JSON.stringify(body) })
|
||||
apiFn.put = (path, body) => requestJSON(path, { method: 'PUT', body: JSON.stringify(body) })
|
||||
apiFn.del = (path) => requestJSON(path, { method: 'DELETE' })
|
||||
apiFn.delete = (path) => requestJSON(path, { method: 'DELETE' })
|
||||
|
||||
export const api = apiFn
|
||||
43
frontend/src/composables/useDialog.js
Normal file
43
frontend/src/composables/useDialog.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const dialogState = reactive({
|
||||
visible: false,
|
||||
type: 'alert', // 'alert', 'confirm', 'prompt'
|
||||
message: '',
|
||||
defaultValue: '',
|
||||
resolve: null
|
||||
})
|
||||
|
||||
export function showAlert(msg) {
|
||||
return new Promise(resolve => {
|
||||
dialogState.visible = true
|
||||
dialogState.type = 'alert'
|
||||
dialogState.message = msg
|
||||
dialogState.resolve = resolve
|
||||
})
|
||||
}
|
||||
|
||||
export function showConfirm(msg) {
|
||||
return new Promise(resolve => {
|
||||
dialogState.visible = true
|
||||
dialogState.type = 'confirm'
|
||||
dialogState.message = msg
|
||||
dialogState.resolve = resolve
|
||||
})
|
||||
}
|
||||
|
||||
export function showPrompt(msg, defaultVal = '') {
|
||||
return new Promise(resolve => {
|
||||
dialogState.visible = true
|
||||
dialogState.type = 'prompt'
|
||||
dialogState.message = msg
|
||||
dialogState.defaultValue = defaultVal
|
||||
dialogState.resolve = resolve
|
||||
})
|
||||
}
|
||||
|
||||
export function closeDialog(result) {
|
||||
dialogState.visible = false
|
||||
if (dialogState.resolve) dialogState.resolve(result)
|
||||
dialogState.resolve = null
|
||||
}
|
||||
42
frontend/src/composables/useOilTranslation.js
Normal file
42
frontend/src/composables/useOilTranslation.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Oil English names map
|
||||
const OIL_EN = {
|
||||
'薰衣草': 'Lavender', '茶树': 'Tea Tree', '乳香': 'Frankincense',
|
||||
'柠檬': 'Lemon', '椒样薄荷': 'Peppermint', '丝柏': 'Cypress',
|
||||
'尤加利': 'Eucalyptus', '迷迭香': 'Rosemary', '天竺葵': 'Geranium',
|
||||
'依兰依兰': 'Ylang Ylang', '佛手柑': 'Bergamot', '生姜': 'Ginger',
|
||||
'没药': 'Myrrh', '檀香': 'Sandalwood', '雪松': 'Cedarwood',
|
||||
'罗马洋甘菊': 'Roman Chamomile', '永久花': 'Helichrysum',
|
||||
'快乐鼠尾草': 'Clary Sage', '广藿香': 'Patchouli',
|
||||
'百里香': 'Thyme', '牛至': 'Oregano', '冬青': 'Wintergreen',
|
||||
'肉桂': 'Cinnamon', '丁香': 'Clove', '黑胡椒': 'Black Pepper',
|
||||
'葡萄柚': 'Grapefruit', '橙花': 'Neroli', '玫瑰': 'Rose',
|
||||
'岩兰草': 'Vetiver', '马郁兰': 'Marjoram', '芫荽': 'Coriander',
|
||||
'柠檬草': 'Lemongrass', '杜松浆果': 'Juniper Berry', '甜橙': 'Wild Orange',
|
||||
'香茅': 'Citronella', '薄荷': 'Peppermint', '扁柏': 'Arborvitae',
|
||||
'古巴香脂': 'Copaiba', '椰子油': 'Coconut Oil',
|
||||
'芳香调理': 'AromaTouch', '保卫复方': 'On Guard',
|
||||
'乐活复方': 'Balance', '舒缓复方': 'Past Tense',
|
||||
'净化复方': 'Purify', '呼吸复方': 'Breathe',
|
||||
'舒压复方': 'Adaptiv', '多特瑞': 'doTERRA',
|
||||
}
|
||||
|
||||
export function oilEn(name) {
|
||||
return OIL_EN[name] || ''
|
||||
}
|
||||
|
||||
export function recipeNameEn(name) {
|
||||
// Try to translate known keywords
|
||||
// Simple approach: return original name for now, user can customize
|
||||
return name
|
||||
}
|
||||
|
||||
// Custom translations (can be set by admin)
|
||||
const customTranslations = {}
|
||||
|
||||
export function setCustomTranslation(zhName, enName) {
|
||||
customTranslations[zhName] = enName
|
||||
}
|
||||
|
||||
export function getCustomTranslation(zhName) {
|
||||
return customTranslations[zhName]
|
||||
}
|
||||
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