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

103
frontend/src/stores/auth.js Normal file
View File

@@ -0,0 +1,103 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../composables/useApi'
const DEFAULT_USER = {
id: null,
role: 'viewer',
username: 'anonymous',
display_name: '匿名',
has_password: false,
business_verified: false,
}
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('oil_auth_token') || '')
const user = ref({ ...DEFAULT_USER })
// Getters
const isLoggedIn = computed(() => user.value.id !== null)
const isAdmin = computed(() => user.value.role === 'admin')
const canEdit = computed(() =>
['editor', 'senior_editor', 'admin'].includes(user.value.role)
)
const isBusiness = computed(() => user.value.business_verified)
// Actions
async function initToken() {
const params = new URLSearchParams(window.location.search)
const urlToken = params.get('token')
if (urlToken) {
token.value = urlToken
localStorage.setItem('oil_auth_token', urlToken)
// Clean URL
const url = new URL(window.location)
url.searchParams.delete('token')
window.history.replaceState({}, '', url)
}
if (token.value) {
await loadMe()
}
}
async function loadMe() {
try {
const data = await api.get('/api/me')
user.value = {
id: data.id,
role: data.role,
username: data.username,
display_name: data.display_name,
has_password: data.has_password ?? false,
business_verified: data.business_verified ?? false,
}
} catch {
logout()
}
}
async function login(username, password) {
const data = await api.post('/api/login', { username, password })
token.value = data.token
localStorage.setItem('oil_auth_token', data.token)
await loadMe()
}
async function register(username, password, displayName) {
const data = await api.post('/api/register', {
username,
password,
display_name: displayName,
})
token.value = data.token
localStorage.setItem('oil_auth_token', data.token)
await loadMe()
}
function logout() {
token.value = ''
localStorage.removeItem('oil_auth_token')
user.value = { ...DEFAULT_USER }
}
function canEditRecipe(recipe) {
if (isAdmin.value || user.value.role === 'senior_editor') return true
if (user.value.role === 'editor' && recipe._owner_id === user.value.id) return true
return false
}
return {
token,
user,
isLoggedIn,
isAdmin,
canEdit,
isBusiness,
initToken,
loadMe,
login,
register,
logout,
canEditRecipe,
}
})

View File

@@ -0,0 +1,56 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '../composables/useApi'
export const useDiaryStore = defineStore('diary', () => {
const userDiary = ref([])
const currentDiaryId = ref(null)
// Actions
async function loadDiary() {
const data = await api.get('/api/diary')
userDiary.value = data
}
async function createDiary(data) {
const result = await api.post('/api/diary', data)
await loadDiary()
return result
}
async function updateDiary(id, data) {
const result = await api.put(`/api/diary/${id}`, data)
await loadDiary()
return result
}
async function deleteDiary(id) {
await api.delete(`/api/diary/${id}`)
userDiary.value = userDiary.value.filter((d) => (d._id ?? d.id) !== id)
if (currentDiaryId.value === id) {
currentDiaryId.value = null
}
}
async function addEntry(diaryId, content) {
const result = await api.post(`/api/diary/${diaryId}/entries`, content)
await loadDiary()
return result
}
async function deleteEntry(entryId) {
await api.delete(`/api/diary/entries/${entryId}`)
await loadDiary()
}
return {
userDiary,
currentDiaryId,
loadDiary,
createDiary,
updateDiary,
deleteDiary,
addEntry,
deleteEntry,
}
})

106
frontend/src/stores/oils.js Normal file
View File

@@ -0,0 +1,106 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { api } from '../composables/useApi'
export const DROPS_PER_ML = 18.6
export const VOLUME_DROPS = {
'2.5': 46,
'5': 93,
'10': 186,
'15': 280,
'115': 2146,
}
export const useOilsStore = defineStore('oils', () => {
const oils = ref(new Map())
const oilsMeta = ref(new Map())
// Getters
const oilNames = computed(() =>
[...oils.value.keys()].sort((a, b) => a.localeCompare(b, 'zh'))
)
function pricePerDrop(name) {
return oils.value.get(name) || 0
}
function calcCost(ingredients) {
return ingredients.reduce((sum, ing) => {
return sum + pricePerDrop(ing.oil) * ing.drops
}, 0)
}
function calcRetailCost(ingredients) {
return ingredients.reduce((sum, ing) => {
const meta = oilsMeta.value.get(ing.oil)
if (meta && meta.retailPrice && meta.dropCount) {
return sum + (meta.retailPrice / meta.dropCount) * ing.drops
}
return sum + pricePerDrop(ing.oil) * ing.drops
}, 0)
}
function fmtPrice(n) {
return '¥ ' + n.toFixed(2)
}
function fmtCostWithRetail(ingredients) {
const cost = calcCost(ingredients)
const retail = calcRetailCost(ingredients)
const costStr = fmtPrice(cost)
if (retail > cost) {
return { cost: costStr, retail: fmtPrice(retail), hasRetail: true }
}
return { cost: costStr, retail: null, hasRetail: false }
}
// Actions
async function loadOils() {
const data = await api.get('/api/oils')
const newOils = new Map()
const newMeta = new Map()
for (const oil of data) {
const ppd = oil.drop_count ? oil.bottle_price / oil.drop_count : 0
newOils.set(oil.name, ppd)
newMeta.set(oil.name, {
bottlePrice: oil.bottle_price,
dropCount: oil.drop_count,
retailPrice: oil.retail_price ?? null,
isActive: oil.is_active ?? true,
})
}
oils.value = newOils
oilsMeta.value = newMeta
}
async function saveOil(name, bottlePrice, dropCount, retailPrice) {
await api.post('/api/oils', {
name,
bottle_price: bottlePrice,
drop_count: dropCount,
retail_price: retailPrice,
})
await loadOils()
}
async function deleteOil(name) {
await api.delete(`/api/oils/${encodeURIComponent(name)}`)
oils.value.delete(name)
oilsMeta.value.delete(name)
}
return {
oils,
oilsMeta,
oilNames,
pricePerDrop,
calcCost,
calcRetailCost,
fmtPrice,
fmtCostWithRetail,
loadOils,
saveOil,
deleteOil,
}
})

View File

@@ -0,0 +1,105 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '../composables/useApi'
export const useRecipesStore = defineStore('recipes', () => {
const recipes = ref([])
const allTags = ref([])
const userFavorites = ref([])
// Actions
async function loadRecipes() {
const data = await api.get('/api/recipes')
recipes.value = data.map((r) => ({
_id: r._id ?? r.id,
_owner_id: r._owner_id ?? r.owner_id,
_owner_name: r._owner_name ?? r.owner_name ?? '',
_version: r._version ?? r.version ?? 1,
name: r.name,
note: r.note ?? '',
tags: r.tags ?? [],
ingredients: (r.ingredients ?? []).map((ing) => ({
oil: ing.oil ?? ing.name,
drops: ing.drops,
})),
}))
}
async function loadTags() {
const data = await api.get('/api/tags')
const apiTags = data.map((t) => (typeof t === 'string' ? t : t.name))
const recipeTags = recipes.value.flatMap((r) => r.tags)
const tagSet = new Set([...apiTags, ...recipeTags])
allTags.value = [...tagSet].sort((a, b) => a.localeCompare(b, 'zh'))
}
async function loadFavorites() {
try {
const data = await api.get('/api/favorites')
userFavorites.value = data.map((f) => f._id ?? f.id ?? f.recipe_id ?? f)
} catch {
userFavorites.value = []
}
}
async function saveRecipe(recipe) {
if (recipe._id) {
const data = await api.put(`/api/recipes/${recipe._id}`, recipe)
const idx = recipes.value.findIndex((r) => r._id === recipe._id)
if (idx !== -1) {
recipes.value[idx] = { ...recipes.value[idx], ...recipe, _version: data._version ?? data.version ?? recipe._version }
}
return data
} else {
const data = await api.post('/api/recipes', recipe)
await loadRecipes()
return data
}
}
async function deleteRecipe(id) {
await api.delete(`/api/recipes/${id}`)
recipes.value = recipes.value.filter((r) => r._id !== id)
}
async function toggleFavorite(recipeId) {
if (userFavorites.value.includes(recipeId)) {
await api.delete(`/api/favorites/${recipeId}`)
userFavorites.value = userFavorites.value.filter((id) => id !== recipeId)
} else {
await api.post(`/api/favorites/${recipeId}`)
userFavorites.value.push(recipeId)
}
}
function isFavorite(recipe) {
return userFavorites.value.includes(recipe._id)
}
async function createTag(name) {
await api.post('/api/tags', { name })
if (!allTags.value.includes(name)) {
allTags.value = [...allTags.value, name].sort((a, b) => a.localeCompare(b, 'zh'))
}
}
async function deleteTag(name) {
await api.delete(`/api/tags/${encodeURIComponent(name)}`)
allTags.value = allTags.value.filter((t) => t !== name)
}
return {
recipes,
allTags,
userFavorites,
loadRecipes,
loadTags,
loadFavorites,
saveRecipe,
deleteRecipe,
toggleFavorite,
isFavorite,
createTag,
deleteTag,
}
})

40
frontend/src/stores/ui.js Normal file
View File

@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUiStore = defineStore('ui', () => {
const currentSection = ref('search')
const showLoginModal = ref(false)
const toasts = ref([])
let toastId = 0
function showSection(name) {
currentSection.value = name
}
function showToast(msg, duration = 1800) {
const id = ++toastId
toasts.value.push({ id, msg })
setTimeout(() => {
toasts.value = toasts.value.filter((t) => t.id !== id)
}, duration)
}
function openLogin() {
showLoginModal.value = true
}
function closeLogin() {
showLoginModal.value = false
}
return {
currentSection,
showLoginModal,
toasts,
showSection,
showToast,
openLogin,
closeLogin,
}
})