Files
oil-formula-calculator/frontend/src/views/Inventory.vue
Hera Zhao ca3f409827
All checks were successful
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 3s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 13s
Test / e2e-test (push) Successful in 51s
fix: 个人库存页未登录时显示登录引导
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:07:36 +00:00

680 lines
21 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">
<!-- Login prompt -->
<div v-if="!auth.isLoggedIn" class="login-prompt">
<p>登录后可管理个人库存获取购油方案</p>
<button class="btn-primary" @click="ui.openLogin()">登录 / 注册</button>
</div>
<template v-else>
<!-- My Oil Plan -->
<template v-if="auth.isLoggedIn">
<!-- Active plan: show shopping list -->
<div v-if="plansStore.activePlan" class="plan-section">
<div class="plan-header">
<span class="plan-title">📋 {{ plansStore.activePlan.title || '我的购油方案' }}</span>
<span class="plan-teacher">by {{ plansStore.activePlan.teacher_name || '老师' }}</span>
</div>
<div class="plan-recipes-summary">
<span v-for="r in plansStore.activePlan.recipes" :key="r.id" class="plan-recipe-chip">
{{ r.recipe_name }} × {{ r.times_per_month }}/
</span>
</div>
<div v-if="plansStore.shoppingList.length" class="shopping-list">
<div class="shopping-header">🛒 本月购油清单</div>
<div class="shopping-total">
预计月消费¥{{ shoppingTotal }}
</div>
<div v-for="item in plansStore.shoppingList" :key="item.oil_name"
class="shopping-item" :class="{ owned: item.in_inventory }">
<span class="shop-name">{{ item.oil_name }}</span>
<span class="shop-drops">{{ item.monthly_drops }}/</span>
<span class="shop-bottles">{{ item.bottles_needed }}</span>
<span class="shop-cost">¥{{ item.total_cost }}</span>
<button v-if="!item.in_inventory" class="shop-add-btn" @click="addOilFromPlan(item.oil_name)">
加入库存
</button>
<span v-else class="shop-owned-tag">已有</span>
</div>
</div>
</div>
<!-- Pending plan: editable -->
<div v-else-if="plansStore.pendingPlans.length" class="plan-section plan-pending">
<div class="plan-header">
<span class="plan-title"> 老师正在为你定制方案中</span>
</div>
<textarea v-model="pendingDesc" class="form-textarea" rows="3" @blur="updatePendingDesc"></textarea>
<div class="plan-pending-hint">修改后老师会收到通知</div>
</div>
<!-- No plan: request button -->
<div v-else class="plan-request-bar">
<button class="btn-primary" @click="showPlanRequest = true">📋 请求定制购油方案</button>
</div>
<!-- Plan request modal -->
<div v-if="showPlanRequest" class="overlay" @mousedown.self="showPlanRequest = false">
<div class="overlay-panel" style="max-width:400px">
<div class="overlay-header">
<h3>请求定制方案</h3>
<button class="btn-close" @click="showPlanRequest = false"></button>
</div>
<div style="padding:16px">
<label class="form-label">描述你的健康需求</label>
<textarea v-model="planHealthDesc" class="form-textarea" rows="4"
placeholder="例如:家里有老人膝盖痛,小孩经常感冒咳嗽,我自己想改善睡眠..."></textarea>
<label class="form-label" style="margin-top:12px">输入老师的名字</label>
<input v-model="planTeacherName" class="form-input" placeholder="请输入老师告诉你的名字"
@input="matchTeacher" />
<div v-if="matchedTeacher" class="teacher-matched">
已匹配{{ matchedTeacher.display_name }}
</div>
<div v-else-if="planTeacherName.trim() && !matchedTeacher" class="teacher-no-match">
未找到该老师请确认名字是否正确
</div>
<button class="btn-primary" style="width:100%;margin-top:16px"
:disabled="!planHealthDesc.trim() || !matchedTeacher"
@click="submitPlanRequest">
发送请求
</button>
</div>
</div>
</div>
</template>
<!-- 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>
</template>
</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 { usePlansStore } from '../stores/plans'
import { api } from '../composables/useApi'
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const plansStore = usePlansStore()
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()
}
// Plan request
const showPlanRequest = ref(false)
const planHealthDesc = ref('')
const planTeacherName = ref('')
const matchedTeacher = ref(null)
function matchTeacher() {
const name = planTeacherName.value.trim()
if (!name) { matchedTeacher.value = null; return }
matchedTeacher.value = plansStore.teachers.find(t =>
t.display_name === name || t.username === name
) || null
}
const pendingDesc = ref('')
// Sync pendingDesc when plans load
watch(() => plansStore.pendingPlans, (pp) => {
if (pp.length) pendingDesc.value = pp[0].health_desc || ''
}, { immediate: true })
async function updatePendingDesc() {
const plan = plansStore.pendingPlans[0]
if (!plan || pendingDesc.value === plan.health_desc) return
try {
await api(`/api/oil-plans/${plan.id}`, {
method: 'PUT',
body: JSON.stringify({ health_desc: pendingDesc.value }),
})
plan.health_desc = pendingDesc.value
ui.showToast('已更新,老师会收到通知')
} catch {
ui.showToast('更新失败')
}
}
const shoppingTotal = computed(() =>
plansStore.shoppingList.filter(i => !i.in_inventory).reduce((s, i) => s + i.total_cost, 0).toFixed(2)
)
async function submitPlanRequest() {
try {
await plansStore.createPlan(planHealthDesc.value, matchedTeacher.value.id)
showPlanRequest.value = false
planHealthDesc.value = ''
planTeacherName.value = ''
matchedTeacher.value = null
ui.showToast('已发送方案请求')
} catch {
ui.showToast('发送失败')
}
}
async function addOilFromPlan(name) {
if (!ownedOils.value.includes(name)) {
ownedOils.value.push(name)
ownedOils.value.sort((a, b) => a.localeCompare(b, 'zh'))
await saveInventory()
}
// Refresh shopping list
if (plansStore.activePlan) {
await plansStore.loadShoppingList(plansStore.activePlan.id)
}
ui.showToast(`${name} 已加入库存`)
}
async function clearAll() {
ownedOils.value = []
await saveInventory()
ui.showToast('已清空库存')
}
onMounted(async () => {
await loadInventory()
if (auth.isLoggedIn) {
await Promise.all([plansStore.loadPlans(), plansStore.loadTeachers()])
if (plansStore.activePlan) {
await plansStore.loadShoppingList(plansStore.activePlan.id)
}
}
})
</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;
}
/* Plan section */
.plan-section {
background: #f8faf8; border: 1.5px solid #d4e8d4; border-radius: 12px;
padding: 14px; margin-bottom: 16px;
}
.plan-pending { border-color: #e5e4e7; background: #fafafa; }
.plan-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.plan-title { font-weight: 600; font-size: 15px; color: #2e7d5a; }
.plan-teacher { font-size: 12px; color: #b0aab5; }
.plan-desc { font-size: 13px; color: #6b6375; margin: 0; }
.plan-recipes-summary { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.plan-recipe-chip {
font-size: 11px; padding: 3px 10px; border-radius: 8px;
background: #e8f5e9; color: #2e7d5a; white-space: nowrap;
}
.shopping-list { margin-top: 8px; }
.shopping-header { font-weight: 600; font-size: 14px; color: #3e3a44; margin-bottom: 4px; }
.shopping-total { font-size: 12px; color: #5a7d5e; margin-bottom: 8px; font-weight: 500; }
.shopping-item {
display: flex; align-items: center; gap: 8px; padding: 6px 0;
border-bottom: 1px solid #eee; font-size: 13px;
}
.shopping-item.owned { opacity: 0.5; }
.shop-name { flex: 1; font-weight: 500; color: #3e3a44; }
.shop-drops { color: #6b6375; font-size: 12px; white-space: nowrap; }
.shop-bottles { color: #5a7d5e; font-size: 12px; white-space: nowrap; }
.shop-cost { color: #5a7d5e; font-weight: 600; font-size: 12px; white-space: nowrap; }
.shop-add-btn {
padding: 2px 10px; border-radius: 6px; border: 1px solid #7ec6a4;
background: #fff; color: #2e7d5a; font-size: 11px; cursor: pointer; font-family: inherit;
}
.shop-add-btn:hover { background: #e8f5e9; }
.shop-owned-tag { font-size: 11px; color: #b0aab5; }
.plan-request-bar { text-align: center; margin-bottom: 16px; }
.overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.4); z-index: 100; display: flex; align-items: center; justify-content: center; }
.overlay-panel { background: #fff; border-radius: 14px; width: 90%; max-width: 420px; max-height: 80vh; overflow-y: auto; }
.overlay-header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid #eee; }
.overlay-header h3 { margin: 0; font-size: 16px; }
.form-label { display: block; font-size: 13px; color: #6b6375; margin-bottom: 4px; font-weight: 500; }
.form-textarea { width: 100%; border: 1.5px solid #e5e4e7; border-radius: 8px; padding: 8px; font-size: 13px; font-family: inherit; resize: vertical; box-sizing: border-box; }
.login-prompt { text-align: center; padding: 60px 20px; color: #6b6375; }
.login-prompt p { margin-bottom: 16px; font-size: 15px; }
.plan-pending-hint { font-size: 11px; color: #b0aab5; margin-top: 4px; }
.teacher-matched { color: #2e7d5a; font-size: 13px; margin-top: 4px; font-weight: 500; }
.teacher-no-match { color: #c62828; font-size: 12px; margin-top: 4px; }
</style>