diff --git a/backend/database.py b/backend/database.py index bda3ef6..bedec5a 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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: diff --git a/backend/main.py b/backend/main.py index e727656..8308576 100644 --- a/backend/main.py +++ b/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)): diff --git a/frontend/src/stores/plans.js b/frontend/src/stores/plans.js new file mode 100644 index 0000000..5d0f46b --- /dev/null +++ b/frontend/src/stores/plans.js @@ -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, + } +}) diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue index 58047c8..4122023 100644 --- a/frontend/src/views/Inventory.vue +++ b/frontend/src/views/Inventory.vue @@ -1,5 +1,78 @@