feat: 购油方案 — 会员请求+老师定制+购油清单
Some checks failed
Test / unit-test (push) Successful in 5s
Test / build-check (push) Failing after 4s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Failing after 3m5s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Failing after 10s
Some checks failed
Test / unit-test (push) Successful in 5s
Test / build-check (push) Failing after 4s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Failing after 3m5s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Failing after 10s
后端: - oil_plans/oil_plan_recipes 表 - 7个API: teachers列表、方案CRUD、配方增删、购油清单计算 - 购油清单自动算月消耗、瓶数、费用,交叉比对库存 前端: - 会员端(库存页): 请求方案→选老师→描述需求;查看购油清单 - 老师端(用户管理): 接收请求→选配方+频率→激活方案 - plans store 管理状态 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,78 @@
|
||||
<template>
|
||||
<div class="inventory-page">
|
||||
<!-- 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 -->
|
||||
<div v-else-if="plansStore.pendingPlans.length" class="plan-section plan-pending">
|
||||
<div class="plan-header">
|
||||
<span class="plan-title">⏳ 方案审核中</span>
|
||||
</div>
|
||||
<p class="plan-desc">{{ plansStore.pendingPlans[0].health_desc }}</p>
|
||||
</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>
|
||||
<select v-model="planTeacherId" class="form-select">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="t in plansStore.teachers" :key="t.id" :value="t.id">
|
||||
{{ t.display_name }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="btn-primary" style="width:100%;margin-top:16px"
|
||||
:disabled="!planHealthDesc.trim() || !planTeacherId"
|
||||
@click="submitPlanRequest">
|
||||
发送请求
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Search + direct add -->
|
||||
<div class="search-box">
|
||||
<input
|
||||
@@ -93,12 +166,14 @@ 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([])
|
||||
@@ -233,14 +308,54 @@ async function toggleOil(name) {
|
||||
await saveInventory()
|
||||
}
|
||||
|
||||
// Plan request
|
||||
const showPlanRequest = ref(false)
|
||||
const planHealthDesc = ref('')
|
||||
const planTeacherId = ref('')
|
||||
|
||||
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, planTeacherId.value)
|
||||
showPlanRequest.value = false
|
||||
planHealthDesc.value = ''
|
||||
planTeacherId.value = ''
|
||||
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(() => {
|
||||
loadInventory()
|
||||
onMounted(async () => {
|
||||
await loadInventory()
|
||||
if (auth.isLoggedIn) {
|
||||
await Promise.all([plansStore.loadPlans(), plansStore.loadTeachers()])
|
||||
if (plansStore.activePlan) {
|
||||
await plansStore.loadShoppingList(plansStore.activePlan.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -473,4 +588,46 @@ onMounted(() => {
|
||||
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; }
|
||||
.form-select { width: 100%; border: 1.5px solid #e5e4e7; border-radius: 8px; padding: 8px; font-size: 13px; font-family: inherit; box-sizing: border-box; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user