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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
680 lines
21 KiB
Vue
680 lines
21 KiB
Vue
<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>
|