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:
383
frontend/src/views/Inventory.vue
Normal file
383
frontend/src/views/Inventory.vue
Normal file
@@ -0,0 +1,383 @@
|
||||
<template>
|
||||
<div class="inventory-page">
|
||||
<!-- Search -->
|
||||
<div class="search-box">
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索精油..."
|
||||
/>
|
||||
<button v-if="searchQuery" class="search-clear-btn" @click="searchQuery = ''">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Oil Picker Grid -->
|
||||
<div class="section-label">点击添加到库存</div>
|
||||
<div class="oil-picker-grid">
|
||||
<div
|
||||
v-for="name in filteredOilNames"
|
||||
:key="name"
|
||||
class="oil-pick-chip"
|
||||
:class="{ owned: ownedSet.has(name) }"
|
||||
@click="toggleOil(name)"
|
||||
>
|
||||
<span class="pick-dot" :class="{ active: ownedSet.has(name) }">{{ ownedSet.has(name) ? '✓' : '+' }}</span>
|
||||
<span class="pick-name">{{ name }}</span>
|
||||
</div>
|
||||
<div v-if="filteredOilNames.length === 0" class="empty-hint">未找到精油</div>
|
||||
</div>
|
||||
|
||||
<!-- Owned Oils Section -->
|
||||
<div class="section-header">
|
||||
<span>🧴 已有精油 ({{ ownedOils.length }})</span>
|
||||
<button v-if="ownedOils.length" class="btn-sm btn-outline" @click="clearAll">清空</button>
|
||||
</div>
|
||||
<div v-if="ownedOils.length" class="owned-grid">
|
||||
<div v-for="name in ownedOils" :key="name" class="owned-chip" @click="toggleOil(name)">
|
||||
{{ name }} ✕
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-hint">暂未添加精油,点击上方精油添加到库存</div>
|
||||
|
||||
<!-- Matching Recipes Section -->
|
||||
<div class="section-header" style="margin-top:20px">
|
||||
<span>📋 可做的配方 ({{ matchingRecipes.length }})</span>
|
||||
</div>
|
||||
<div v-if="matchingRecipes.length" class="matching-list">
|
||||
<div v-for="r in matchingRecipes" :key="r._id" class="match-card">
|
||||
<div class="match-name">{{ r.name }}</div>
|
||||
<div class="match-ings">
|
||||
<span
|
||||
v-for="ing in r.ingredients"
|
||||
:key="ing.oil"
|
||||
class="match-ing"
|
||||
:class="{ missing: !ownedSet.has(ing.oil) }"
|
||||
>
|
||||
{{ ing.oil }} {{ ing.drops }}滴
|
||||
</span>
|
||||
</div>
|
||||
<div class="match-meta">
|
||||
<span class="match-coverage">覆盖 {{ coveragePercent(r) }}%</span>
|
||||
<span v-if="missingOils(r).length" class="match-missing">
|
||||
缺少: {{ missingOils(r).join(', ') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-hint">
|
||||
{{ ownedOils.length ? '暂无完全匹配的配方' : '添加精油后自动推荐配方' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useOilsStore } from '../stores/oils'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
import { api } from '../composables/useApi'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const oils = useOilsStore()
|
||||
const recipeStore = useRecipesStore()
|
||||
const ui = useUiStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const ownedOils = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const ownedSet = computed(() => new Set(ownedOils.value))
|
||||
|
||||
const filteredOilNames = computed(() => {
|
||||
if (!searchQuery.value.trim()) return oils.oilNames
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
return oils.oilNames.filter(n => n.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const matchingRecipes = computed(() => {
|
||||
if (ownedOils.value.length === 0) return []
|
||||
return recipeStore.recipes
|
||||
.filter(r => {
|
||||
const needed = r.ingredients.map(i => i.oil)
|
||||
const coverage = needed.filter(o => ownedSet.value.has(o)).length
|
||||
return coverage >= Math.ceil(needed.length * 0.5)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aCov = coverageRatio(a)
|
||||
const bCov = coverageRatio(b)
|
||||
return bCov - aCov
|
||||
})
|
||||
})
|
||||
|
||||
function coverageRatio(recipe) {
|
||||
const needed = recipe.ingredients.map(i => i.oil)
|
||||
if (needed.length === 0) return 0
|
||||
return needed.filter(o => ownedSet.value.has(o)).length / needed.length
|
||||
}
|
||||
|
||||
function coveragePercent(recipe) {
|
||||
return Math.round(coverageRatio(recipe) * 100)
|
||||
}
|
||||
|
||||
function missingOils(recipe) {
|
||||
return recipe.ingredients
|
||||
.map(i => i.oil)
|
||||
.filter(o => !ownedSet.value.has(o))
|
||||
}
|
||||
|
||||
async function loadInventory() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api('/api/inventory')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
ownedOils.value = data.oils || data || []
|
||||
}
|
||||
} catch {
|
||||
// inventory may not exist yet
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function saveInventory() {
|
||||
try {
|
||||
await api('/api/inventory', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ oils: ownedOils.value }),
|
||||
})
|
||||
} catch {
|
||||
// silent save
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleOil(name) {
|
||||
const idx = ownedOils.value.indexOf(name)
|
||||
if (idx >= 0) {
|
||||
ownedOils.value.splice(idx, 1)
|
||||
} else {
|
||||
ownedOils.value.push(name)
|
||||
ownedOils.value.sort((a, b) => a.localeCompare(b, 'zh'))
|
||||
}
|
||||
await saveInventory()
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
ownedOils.value = []
|
||||
await saveInventory()
|
||||
ui.showToast('已清空库存')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadInventory()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inventory-page {
|
||||
padding: 0 12px 24px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f8f7f5;
|
||||
border-radius: 10px;
|
||||
padding: 2px 8px;
|
||||
border: 1.5px solid #e5e4e7;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 8px 6px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 12px;
|
||||
color: #b0aab5;
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.oil-picker-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.oil-pick-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
background: #f8f7f5;
|
||||
border: 1.5px solid #e5e4e7;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.15s;
|
||||
color: #3e3a44;
|
||||
}
|
||||
|
||||
.oil-pick-chip:hover {
|
||||
border-color: #d4cfc7;
|
||||
background: #f0eeeb;
|
||||
}
|
||||
|
||||
.oil-pick-chip.owned {
|
||||
background: #e8f5e9;
|
||||
border-color: #7ec6a4;
|
||||
color: #2e7d5a;
|
||||
}
|
||||
|
||||
.pick-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #e5e4e7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.pick-dot.active {
|
||||
background: #4a9d7e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pick-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #3e3a44;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.owned-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.owned-chip {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
background: #e8f5e9;
|
||||
border: 1.5px solid #7ec6a4;
|
||||
font-size: 13px;
|
||||
color: #2e7d5a;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.owned-chip:hover {
|
||||
background: #ffebee;
|
||||
border-color: #ef9a9a;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.matching-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.match-card {
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1.5px solid #e5e4e7;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.match-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #3e3a44;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.match-ings {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.match-ing {
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: #e8f5e9;
|
||||
font-size: 12px;
|
||||
color: #2e7d5a;
|
||||
}
|
||||
|
||||
.match-ing.missing {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.match-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.match-coverage {
|
||||
color: #4a9d7e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.match-missing {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: #fff;
|
||||
color: #6b6375;
|
||||
border: 1.5px solid #d4cfc7;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: #f8f7f5;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
text-align: center;
|
||||
color: #b0aab5;
|
||||
font-size: 13px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user