Files
oil-formula-calculator/frontend/src/views/Inventory.vue
Hera Zhao ee8ec23dc7 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>
2026-04-06 18:35:00 +00:00

384 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>