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:
@@ -245,6 +245,24 @@ def init_db():
|
||||
if "en_name" not in cols:
|
||||
c.execute("ALTER TABLE recipes ADD COLUMN en_name TEXT DEFAULT ''")
|
||||
|
||||
# Oil plans
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS oil_plans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
teacher_id INTEGER,
|
||||
title TEXT DEFAULT '',
|
||||
health_desc TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)""")
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS oil_plan_recipes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
plan_id INTEGER NOT NULL REFERENCES oil_plans(id) ON DELETE CASCADE,
|
||||
recipe_name TEXT NOT NULL,
|
||||
ingredients TEXT NOT NULL DEFAULT '[]',
|
||||
times_per_month INTEGER NOT NULL DEFAULT 1
|
||||
)""")
|
||||
|
||||
# Seed admin user if no users exist
|
||||
count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
if count == 0:
|
||||
|
||||
185
backend/main.py
185
backend/main.py
@@ -1583,6 +1583,191 @@ def recipes_by_inventory(user=Depends(get_current_user)):
|
||||
return result
|
||||
|
||||
|
||||
# ── Oil Plans (purchase plans) ─────────────────────────
|
||||
@app.get("/api/teachers")
|
||||
def list_teachers(user=Depends(get_current_user)):
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT id, display_name, username FROM users WHERE role IN ('senior_editor', 'admin') ORDER BY id"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [{"id": r["id"], "display_name": r["display_name"] or r["username"], "username": r["username"]} for r in rows]
|
||||
|
||||
|
||||
@app.get("/api/oil-plans")
|
||||
def list_oil_plans(user=Depends(get_current_user)):
|
||||
if not user.get("id"):
|
||||
return []
|
||||
conn = get_db()
|
||||
if user["role"] in ("senior_editor", "admin"):
|
||||
plans = conn.execute(
|
||||
"SELECT p.*, u.display_name as user_name, u.username "
|
||||
"FROM oil_plans p LEFT JOIN users u ON p.user_id = u.id "
|
||||
"WHERE p.teacher_id = ? OR p.user_id = ? ORDER BY p.created_at DESC",
|
||||
(user["id"], user["id"])
|
||||
).fetchall()
|
||||
else:
|
||||
plans = conn.execute(
|
||||
"SELECT p.*, u.display_name as teacher_name "
|
||||
"FROM oil_plans p LEFT JOIN users u ON p.teacher_id = u.id "
|
||||
"WHERE p.user_id = ? ORDER BY p.created_at DESC", (user["id"],)
|
||||
).fetchall()
|
||||
result = []
|
||||
for p in plans:
|
||||
d = dict(p)
|
||||
recipes = conn.execute(
|
||||
"SELECT id, recipe_name, ingredients, times_per_month FROM oil_plan_recipes WHERE plan_id = ?",
|
||||
(p["id"],)
|
||||
).fetchall()
|
||||
d["recipes"] = [{"id": r["id"], "recipe_name": r["recipe_name"],
|
||||
"ingredients": json.loads(r["ingredients"]), "times_per_month": r["times_per_month"]} for r in recipes]
|
||||
result.append(d)
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/oil-plans", status_code=201)
|
||||
def create_oil_plan(body: dict, user=Depends(get_current_user)):
|
||||
if not user.get("id"):
|
||||
raise HTTPException(403, "请先登录")
|
||||
health_desc = body.get("health_desc", "").strip()
|
||||
teacher_id = body.get("teacher_id")
|
||||
# Teacher/admin can create plan for a user directly
|
||||
target_user_id = body.get("user_id") if user["role"] in ("senior_editor", "admin") else None
|
||||
if not teacher_id:
|
||||
raise HTTPException(400, "请选择老师")
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
plan_user_id = target_user_id or user["id"]
|
||||
c.execute("INSERT INTO oil_plans (user_id, teacher_id, health_desc) VALUES (?, ?, ?)",
|
||||
(plan_user_id, teacher_id, health_desc))
|
||||
plan_id = c.lastrowid
|
||||
who = user.get("display_name") or user["username"]
|
||||
conn.execute(
|
||||
"INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)",
|
||||
("admin", "📋 新方案请求", f"{who}:{health_desc[:50]}", teacher_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"id": plan_id}
|
||||
|
||||
|
||||
@app.put("/api/oil-plans/{plan_id}")
|
||||
def update_oil_plan(plan_id: int, body: dict, user=Depends(get_current_user)):
|
||||
conn = get_db()
|
||||
plan = conn.execute("SELECT * FROM oil_plans WHERE id = ?", (plan_id,)).fetchone()
|
||||
if not plan:
|
||||
conn.close()
|
||||
raise HTTPException(404, "方案不存在")
|
||||
if plan["teacher_id"] != user["id"] and user["role"] != "admin":
|
||||
conn.close()
|
||||
raise HTTPException(403, "无权操作")
|
||||
if "title" in body:
|
||||
conn.execute("UPDATE oil_plans SET title = ? WHERE id = ?", (body["title"], plan_id))
|
||||
if "status" in body:
|
||||
old_status = plan["status"]
|
||||
conn.execute("UPDATE oil_plans SET status = ? WHERE id = ?", (body["status"], plan_id))
|
||||
if body["status"] == "active" and old_status != "active":
|
||||
teacher_name = user.get("display_name") or user["username"]
|
||||
conn.execute(
|
||||
"INSERT INTO notifications (target_role, title, body, target_user_id) VALUES (?, ?, ?, ?)",
|
||||
("viewer", "🎉 你的购油方案已就绪", f"{teacher_name}老师已为你定制好方案,去库存页查看!", plan["user_id"])
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/oil-plans/{plan_id}/recipes", status_code=201)
|
||||
def add_plan_recipe(plan_id: int, body: dict, user=Depends(get_current_user)):
|
||||
conn = get_db()
|
||||
plan = conn.execute("SELECT * FROM oil_plans WHERE id = ?", (plan_id,)).fetchone()
|
||||
if not plan:
|
||||
conn.close()
|
||||
raise HTTPException(404, "方案不存在")
|
||||
if plan["teacher_id"] != user["id"] and user["role"] != "admin":
|
||||
conn.close()
|
||||
raise HTTPException(403, "无权操作")
|
||||
recipe_name = body.get("recipe_name", "").strip()
|
||||
ingredients = body.get("ingredients", [])
|
||||
times = body.get("times_per_month", 1)
|
||||
if not recipe_name or not ingredients:
|
||||
conn.close()
|
||||
raise HTTPException(400, "配方名和成分不能为空")
|
||||
c = conn.cursor()
|
||||
c.execute("INSERT INTO oil_plan_recipes (plan_id, recipe_name, ingredients, times_per_month) VALUES (?, ?, ?, ?)",
|
||||
(plan_id, recipe_name, json.dumps(ingredients, ensure_ascii=False), times))
|
||||
conn.commit()
|
||||
rid = c.lastrowid
|
||||
conn.close()
|
||||
return {"id": rid}
|
||||
|
||||
|
||||
@app.delete("/api/oil-plans/{plan_id}/recipes/{recipe_id}")
|
||||
def remove_plan_recipe(plan_id: int, recipe_id: int, user=Depends(get_current_user)):
|
||||
conn = get_db()
|
||||
plan = conn.execute("SELECT * FROM oil_plans WHERE id = ?", (plan_id,)).fetchone()
|
||||
if not plan:
|
||||
conn.close()
|
||||
raise HTTPException(404)
|
||||
if plan["teacher_id"] != user["id"] and user["role"] != "admin":
|
||||
conn.close()
|
||||
raise HTTPException(403)
|
||||
conn.execute("DELETE FROM oil_plan_recipes WHERE id = ? AND plan_id = ?", (recipe_id, plan_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/oil-plans/{plan_id}/shopping-list")
|
||||
def plan_shopping_list(plan_id: int, user=Depends(get_current_user)):
|
||||
if not user.get("id"):
|
||||
raise HTTPException(403, "请先登录")
|
||||
conn = get_db()
|
||||
plan = conn.execute("SELECT * FROM oil_plans WHERE id = ?", (plan_id,)).fetchone()
|
||||
if not plan:
|
||||
conn.close()
|
||||
raise HTTPException(404)
|
||||
if plan["user_id"] != user["id"] and plan["teacher_id"] != user["id"] and user["role"] != "admin":
|
||||
conn.close()
|
||||
raise HTTPException(403)
|
||||
recipes = conn.execute("SELECT * FROM oil_plan_recipes WHERE plan_id = ?", (plan_id,)).fetchall()
|
||||
# Calculate monthly drops per oil
|
||||
oil_drops = {}
|
||||
for r in recipes:
|
||||
ings = json.loads(r["ingredients"])
|
||||
for ing in ings:
|
||||
name = ing.get("oil_name") or ing.get("oil", "")
|
||||
drops = ing.get("drops", 0)
|
||||
if name and name != "椰子油":
|
||||
oil_drops[name] = oil_drops.get(name, 0) + drops * r["times_per_month"]
|
||||
# Get oil metadata
|
||||
oils_meta = {}
|
||||
for row in conn.execute("SELECT name, bottle_price, drop_count FROM oils").fetchall():
|
||||
oils_meta[row["name"]] = {"bottle_price": row["bottle_price"], "drop_count": row["drop_count"]}
|
||||
# Get user inventory
|
||||
inv = set(r["oil_name"] for r in conn.execute(
|
||||
"SELECT oil_name FROM user_inventory WHERE user_id = ?", (plan["user_id"],)).fetchall())
|
||||
conn.close()
|
||||
import math
|
||||
result = []
|
||||
for oil, drops in sorted(oil_drops.items()):
|
||||
meta = oils_meta.get(oil, {})
|
||||
drop_count = meta.get("drop_count", 250)
|
||||
bottle_price = meta.get("bottle_price", 0)
|
||||
bottles = math.ceil(drops / drop_count) if drop_count else 1
|
||||
result.append({
|
||||
"oil_name": oil,
|
||||
"monthly_drops": round(drops, 1),
|
||||
"bottles_needed": bottles,
|
||||
"bottle_price": bottle_price,
|
||||
"total_cost": round(bottles * bottle_price, 2),
|
||||
"in_inventory": oil in inv,
|
||||
})
|
||||
result.sort(key=lambda x: (x["in_inventory"], x["oil_name"]))
|
||||
return result
|
||||
|
||||
|
||||
# ── Search Logging ─────────────────────────────────────
|
||||
@app.post("/api/search-log")
|
||||
def log_search(body: dict, user=Depends(get_current_user)):
|
||||
|
||||
62
frontend/src/stores/plans.js
Normal file
62
frontend/src/stores/plans.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { requestJSON, api } from '../composables/useApi'
|
||||
|
||||
export const usePlansStore = defineStore('plans', () => {
|
||||
const plans = ref([])
|
||||
const teachers = ref([])
|
||||
const shoppingList = ref([])
|
||||
|
||||
const activePlan = computed(() => plans.value.find(p => p.status === 'active'))
|
||||
const pendingPlans = computed(() => plans.value.filter(p => p.status === 'pending'))
|
||||
|
||||
async function loadPlans() {
|
||||
plans.value = await requestJSON('/api/oil-plans')
|
||||
}
|
||||
|
||||
async function loadTeachers() {
|
||||
teachers.value = await requestJSON('/api/teachers')
|
||||
}
|
||||
|
||||
async function createPlan(healthDesc, teacherId) {
|
||||
const res = await api('/api/oil-plans', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ health_desc: healthDesc, teacher_id: teacherId }),
|
||||
})
|
||||
if (!res.ok) throw new Error('创建失败')
|
||||
await loadPlans()
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function updatePlan(planId, data) {
|
||||
await api(`/api/oil-plans/${planId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
await loadPlans()
|
||||
}
|
||||
|
||||
async function addRecipe(planId, recipeName, ingredients, timesPerMonth) {
|
||||
await api(`/api/oil-plans/${planId}/recipes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ recipe_name: recipeName, ingredients, times_per_month: timesPerMonth }),
|
||||
})
|
||||
await loadPlans()
|
||||
}
|
||||
|
||||
async function removeRecipe(planId, recipeId) {
|
||||
await api(`/api/oil-plans/${planId}/recipes/${recipeId}`, { method: 'DELETE' })
|
||||
await loadPlans()
|
||||
}
|
||||
|
||||
async function loadShoppingList(planId) {
|
||||
shoppingList.value = await requestJSON(`/api/oil-plans/${planId}/shopping-list`)
|
||||
}
|
||||
|
||||
return {
|
||||
plans, teachers, shoppingList,
|
||||
activePlan, pendingPlans,
|
||||
loadPlans, loadTeachers, createPlan, updatePlan,
|
||||
addRecipe, removeRecipe, loadShoppingList,
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
</select>
|
||||
<button v-if="!u.business_verified" class="btn-sm btn-outline" @click="grantBusiness(u)" title="开通商业认证">💼</button>
|
||||
<button v-else class="btn-sm btn-outline" @click="revokeBusiness(u)" title="撤销商业认证" style="opacity:0.5">💼✕</button>
|
||||
<button class="btn-sm btn-outline" @click="openPlanEditor(u)" title="定制方案">📋</button>
|
||||
<button class="btn-sm btn-delete" @click="removeUser(u)" title="删除用户">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,6 +114,63 @@
|
||||
|
||||
<div class="user-count">共 {{ users.length }} 个用户</div>
|
||||
|
||||
<!-- Pending Plan Requests -->
|
||||
<div v-if="pendingPlanRequests.length" class="review-section">
|
||||
<h4 class="section-title">📋 方案请求</h4>
|
||||
<div class="review-list">
|
||||
<div v-for="p in pendingPlanRequests" :key="p.id" class="review-item">
|
||||
<div class="review-info">
|
||||
<span class="review-name">{{ p.user_name || p.username || '用户' }}</span>
|
||||
<span class="review-reason">{{ p.health_desc }}</span>
|
||||
</div>
|
||||
<div class="review-actions">
|
||||
<button class="btn-sm btn-approve" @click="openPlanEditorForRequest(p)">制定方案</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan Editor Modal -->
|
||||
<div v-if="planEditing" class="overlay" @mousedown.self="planEditing = null">
|
||||
<div class="overlay-panel" style="max-width:500px">
|
||||
<div class="overlay-header">
|
||||
<h3>{{ planEditing.user_name || '用户' }} 的方案</h3>
|
||||
<button class="btn-close" @click="planEditing = null">✕</button>
|
||||
</div>
|
||||
<div style="padding:16px">
|
||||
<div v-if="planEditing.health_desc" class="plan-health-desc">
|
||||
<strong>健康需求:</strong>{{ planEditing.health_desc }}
|
||||
</div>
|
||||
<label class="form-label">方案标题</label>
|
||||
<input v-model="planTitle" class="form-input" placeholder="例如:家庭日常保健方案" />
|
||||
|
||||
<label class="form-label" style="margin-top:12px">配方列表</label>
|
||||
<div v-for="(pr, i) in planRecipes" :key="i" class="plan-recipe-row">
|
||||
<span class="plan-recipe-name">{{ pr.recipe_name }}</span>
|
||||
<input v-model.number="pr.times_per_month" type="number" min="1" class="plan-freq-input" />
|
||||
<span class="plan-freq-label">次/月</span>
|
||||
<button class="btn-icon-sm" @click="planRecipes.splice(i, 1)">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Add recipe from public library -->
|
||||
<div class="plan-add-recipe">
|
||||
<input v-model="planRecipeSearch" class="form-input" placeholder="搜索配方添加..." />
|
||||
<div v-if="planRecipeSearch && planSearchResults.length" class="plan-search-results">
|
||||
<div v-for="r in planSearchResults" :key="r._id" class="plan-search-item" @click="addRecipeToPlan(r)">
|
||||
{{ r.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;margin-top:16px">
|
||||
<button class="btn-primary" style="flex:1" @click="savePlan" :disabled="planRecipes.length === 0">
|
||||
{{ planEditing.id ? '保存并激活' : '创建并激活' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full-size document preview -->
|
||||
<div v-if="showDocFull" class="doc-overlay" @click="showDocFull = null">
|
||||
<img :src="showDocFull" class="doc-full-img" />
|
||||
@@ -124,11 +182,15 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { usePlansStore } from '../stores/plans'
|
||||
import { api } from '../composables/useApi'
|
||||
import { showConfirm, showPrompt } from '../composables/useDialog'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const ui = useUiStore()
|
||||
const recipeStore = useRecipesStore()
|
||||
const plansStore = usePlansStore()
|
||||
|
||||
const users = ref([])
|
||||
const searchQuery = ref('')
|
||||
@@ -343,10 +405,109 @@ async function rejectBusiness(app) {
|
||||
}
|
||||
}
|
||||
|
||||
// Plan management
|
||||
const planEditing = ref(null)
|
||||
const planTitle = ref('')
|
||||
const planRecipes = ref([])
|
||||
const planRecipeSearch = ref('')
|
||||
|
||||
const pendingPlanRequests = computed(() =>
|
||||
plansStore.plans.filter(p => p.status === 'pending')
|
||||
)
|
||||
|
||||
const planSearchResults = computed(() => {
|
||||
if (!planRecipeSearch.value.trim()) return []
|
||||
const q = planRecipeSearch.value.trim().toLowerCase()
|
||||
const existing = new Set(planRecipes.value.map(r => r.recipe_name))
|
||||
return recipeStore.recipes
|
||||
.filter(r => r.name.toLowerCase().includes(q) && !existing.has(r.name))
|
||||
.slice(0, 10)
|
||||
})
|
||||
|
||||
function openPlanEditor(user) {
|
||||
const existing = plansStore.plans.find(p => p.user_id === (user._id || user.id) && p.status !== 'archived')
|
||||
if (existing) {
|
||||
planEditing.value = { ...existing, user_name: user.display_name || user.username }
|
||||
planTitle.value = existing.title || ''
|
||||
planRecipes.value = (existing.recipes || []).map(r => ({ ...r }))
|
||||
} else {
|
||||
planEditing.value = { user_id: user._id || user.id, user_name: user.display_name || user.username, health_desc: '' }
|
||||
planTitle.value = ''
|
||||
planRecipes.value = []
|
||||
}
|
||||
planRecipeSearch.value = ''
|
||||
}
|
||||
|
||||
function openPlanEditorForRequest(plan) {
|
||||
planEditing.value = { ...plan }
|
||||
planTitle.value = plan.title || ''
|
||||
planRecipes.value = (plan.recipes || []).map(r => ({ ...r }))
|
||||
planRecipeSearch.value = ''
|
||||
}
|
||||
|
||||
function addRecipeToPlan(recipe) {
|
||||
planRecipes.value.push({
|
||||
recipe_name: recipe.name,
|
||||
ingredients: recipe.ingredients.map(i => ({ oil_name: i.oil, drops: i.drops })),
|
||||
times_per_month: 30,
|
||||
})
|
||||
planRecipeSearch.value = ''
|
||||
}
|
||||
|
||||
async function savePlan() {
|
||||
try {
|
||||
let planId = planEditing.value.id
|
||||
if (!planId) {
|
||||
// Create new plan
|
||||
const res = await api('/api/oil-plans', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
health_desc: planEditing.value.health_desc || '',
|
||||
teacher_id: auth.user.id,
|
||||
user_id: planEditing.value.user_id,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
planId = data.id
|
||||
}
|
||||
// Update title
|
||||
await api(`/api/oil-plans/${planId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ title: planTitle.value }),
|
||||
})
|
||||
// Sync recipes: remove old, add new
|
||||
const existing = planEditing.value.recipes || []
|
||||
for (const r of existing) {
|
||||
await api(`/api/oil-plans/${planId}/recipes/${r.id}`, { method: 'DELETE' })
|
||||
}
|
||||
for (const r of planRecipes.value) {
|
||||
await api(`/api/oil-plans/${planId}/recipes`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
recipe_name: r.recipe_name,
|
||||
ingredients: r.ingredients,
|
||||
times_per_month: r.times_per_month,
|
||||
}),
|
||||
})
|
||||
}
|
||||
// Activate
|
||||
await api(`/api/oil-plans/${planId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'active' }),
|
||||
})
|
||||
planEditing.value = null
|
||||
await plansStore.loadPlans()
|
||||
ui.showToast('方案已激活')
|
||||
} catch {
|
||||
ui.showToast('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers()
|
||||
loadTranslations()
|
||||
loadBusinessApps()
|
||||
plansStore.loadPlans()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -757,4 +918,27 @@ onMounted(() => {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
/* Plan editor */
|
||||
.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-height: 85vh; 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; }
|
||||
.plan-health-desc { background: #f8f7f5; border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; font-size: 13px; color: #6b6375; }
|
||||
.form-label { display: block; font-size: 13px; color: #6b6375; margin-bottom: 4px; font-weight: 500; }
|
||||
.form-input { width: 100%; border: 1.5px solid #e5e4e7; border-radius: 8px; padding: 8px; font-size: 13px; font-family: inherit; box-sizing: border-box; }
|
||||
.plan-recipe-row {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.plan-recipe-name { flex: 1; font-size: 13px; font-weight: 500; }
|
||||
.plan-freq-input { width: 50px; padding: 4px 6px; border: 1px solid #e5e4e7; border-radius: 6px; font-size: 12px; text-align: center; }
|
||||
.plan-freq-label { font-size: 11px; color: #b0aab5; }
|
||||
.plan-add-recipe { position: relative; margin-top: 8px; }
|
||||
.plan-search-results {
|
||||
position: absolute; top: 100%; left: 0; right: 0; background: #fff;
|
||||
border: 1px solid #e5e4e7; border-radius: 8px; max-height: 200px; overflow-y: auto; z-index: 10;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.plan-search-item { padding: 8px 12px; cursor: pointer; font-size: 13px; }
|
||||
.plan-search-item:hover { background: #f8faf8; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user