Files
oil-formula-calculator/frontend/src/views/Inventory.vue
Hera Zhao c4005f229e
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 16s
Test / e2e-test (push) Successful in 49s
feat: 消耗分析+认证修复+套装更新+认证简化
商业核算:
- 芳香调理技术作为体验项目,使用真实配方数据
- 新增消耗分析:每种精油可做次数、哪个最先消耗完、最多可做几次

个人库存:
- 芳香调理套装改为正确精油列表

商业认证:
- 简化为商户名+证明图片
- 通过后内容保持显示
- 用户管理页面根据用户实际认证状态显示(修复拒绝后又通过仍显示已拒绝)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:06:40 +00:00

477 lines
12 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 + direct add -->
<div class="search-box">
<input
class="search-input"
v-model="searchQuery"
placeholder="搜索精油名称,回车添加..."
@keydown.enter="addFromSearch"
/>
<button v-if="searchQuery" class="search-clear-btn" @click="searchQuery = ''"></button>
</div>
<!-- Search results for direct add -->
<div v-if="searchQuery && searchResults.length" class="search-results">
<div v-for="name in searchResults" :key="name" class="search-result-item" @click="addOil(name)">
<span class="pick-dot" :class="{ active: ownedSet.has(name) }">{{ ownedSet.has(name) ? '✓' : '+' }}</span>
{{ name }}
</div>
</div>
<!-- Quick add kits -->
<div class="kit-bar">
<button class="kit-btn" @click="addKit('family')">家庭医生</button>
<button class="kit-btn" @click="addKit('home3988')">居家呵护(3988)</button>
<button class="kit-btn" @click="addKit('aroma')">芳香调理</button>
<button class="kit-btn" @click="addKit('full')">全精油</button>
</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>
<!-- Oil Picker Grid (collapsed by default) -->
<div class="section-header clickable" @click="showPicker = !showPicker">
<span>📦 全部精油</span>
<span class="toggle-icon">{{ showPicker ? '▾' : '▸' }}</span>
</div>
<div v-if="showPicker" class="oil-picker-grid">
<div
v-for="name in oils.oilNames"
: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>
<!-- 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 showPicker = ref(false)
const ownedSet = computed(() => new Set(ownedOils.value))
const searchResults = computed(() => {
if (!searchQuery.value.trim()) return []
const q = searchQuery.value.trim().toLowerCase()
return oils.oilNames.filter(n => n.toLowerCase().includes(q)).slice(0, 15)
})
// Kit definitions
const KITS = {
family: ['乳香', '茶树', '薰衣草', '柠檬', '椒样薄荷', '保卫', '牛至', '乐活', '顺畅呼吸', '舒缓'],
home3988: ['乳香', '野橘', '柠檬', '薰衣草', '椒样薄荷', '冬青', '茶树', '生姜', '柠檬草', '西班牙牛至',
'西洋蓍草石榴籽', '乐活', '保卫', '新瑞活力', '舒缓', '安定情绪', '安宁神气', '顺畅呼吸', '柑橘清新', '芳香调理', '元气焕能'],
aroma: ['薰衣草', '舒缓', '安定情绪', '芳香调理', '野橘', '椒样薄荷', '保卫', '茶树'],
full: ['侧柏', '乳香', '雪松', '芫荽', '丝柏', '圆柚', '红橘', '冬青', '没药', '扁柏', '檀香', '姜黄', '玫瑰',
'绿薄荷', '薰衣草', '永久花', '香蜂草', '迷迭香', '麦卢卡', '天竺葵', '蓝艾菊', '小茴香',
'古巴香脂', '依兰依兰', '丁香花蕾', '柠檬尤加利', '藿香', '西班牙牛至尾草',
'罗勒', '莱姆', '生姜', '柠檬', '茶树', '野橘', '香茅', '枫香',
'芹菜籽', '岩兰草', '苦橙叶', '柠檬草', '山鸡椒', '黑云杉',
'马郁兰', '佛手柑', '黑胡椒', '小豆蔻', '尤加利', '百里香',
'椒样薄荷', '杜松浆果', '加州白鼠尾草',
'快乐鼠尾草', '西伯利亚冷杉',
'西班牙牛至', '斯里兰卡肉桂']
}
function addKit(kitName) {
const kit = KITS[kitName]
if (!kit) return
let added = 0
for (const name of kit) {
// Match existing oil names (fuzzy)
const match = oils.oilNames.find(n => n === name) || oils.oilNames.find(n => n.includes(name) || name.includes(n))
if (match && !ownedOils.value.includes(match)) {
ownedOils.value.push(match)
added++
}
}
ownedOils.value.sort((a, b) => a.localeCompare(b, 'zh'))
saveInventory()
ui.showToast(`已添加 ${added} 种精油`)
}
function addFromSearch() {
if (searchResults.value.length > 0) {
addOil(searchResults.value[0])
}
}
function addOil(name) {
if (!ownedOils.value.includes(name)) {
ownedOils.value.push(name)
ownedOils.value.sort((a, b) => a.localeCompare(b, 'zh'))
saveInventory()
}
searchQuery.value = ''
}
const matchingRecipes = computed(() => {
if (ownedOils.value.length === 0) return []
return recipeStore.recipes
.filter(r => {
// Exclude coconut oil from matching
const needed = r.ingredients.filter(i => i.oil !== '椰子油').map(i => i.oil)
if (needed.length === 0) return false
const coverage = needed.filter(o => ownedSet.value.has(o)).length
// Show if at least 1 oil matches
return coverage >= 1
})
.sort((a, b) => {
const aCov = coverageRatio(a)
const bCov = coverageRatio(b)
if (bCov !== aCov) return bCov - aCov
return a.name.localeCompare(b.name, 'zh')
})
})
function coverageRatio(recipe) {
const needed = recipe.ingredients.filter(i => i.oil !== '椰子油').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;
}
.search-results {
margin-bottom: 10px; max-height: 200px; overflow-y: auto;
border: 1.5px solid #e5e4e7; border-radius: 10px; background: #fff;
}
.search-result-item {
padding: 8px 12px; cursor: pointer; font-size: 13px; display: flex; align-items: center; gap: 6px;
border-bottom: 1px solid #f5f5f5;
}
.search-result-item:hover { background: #f0faf5; }
.search-result-item:last-child { border-bottom: none; }
.kit-bar { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.kit-btn {
padding: 5px 12px; border: 1.5px solid #e5e4e7; border-radius: 20px; background: #fff;
font-size: 12px; cursor: pointer; font-family: inherit; color: #6b6375;
}
.kit-btn:hover { border-color: #7ec6a4; color: #4a9d7e; }
.clickable { cursor: pointer; }
.toggle-icon { font-size: 12px; color: #999; margin-left: auto; }
.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>