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:
2026-04-06 18:35:00 +00:00
parent 0368e85abe
commit ee8ec23dc7
62 changed files with 15035 additions and 8448 deletions

View 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

View 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
}

View 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]
}

View 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
}
}