From 74a8d9aeb66e1b9f160f14988b9b84e6d19f81bf Mon Sep 17 00:00:00 2001 From: Hera Zhao Date: Mon, 13 Apr 2026 17:31:04 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=85=8D=E6=96=B9volume=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=AD=98=E5=82=A8=E7=BC=96=E8=BE=91=E5=99=A8=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E7=9A=84=E5=AE=B9=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: recipes表新增volume列,API返回/保存volume - 前端: 保存时发送formVolume,编辑时优先用stored volume - 容量显示优先级: stored volume > 椰子油计算 > 产品ml求和 - 修复编辑器容量选择保存后不生效的bug Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/database.py | 2 ++ backend/main.py | 18 +++++++---- frontend/src/components/RecipeCard.vue | 11 ++++++- frontend/src/views/RecipeManager.vue | 45 ++++++++++++++++++-------- 4 files changed, 54 insertions(+), 22 deletions(-) diff --git a/backend/database.py b/backend/database.py index 52eef67..241c163 100644 --- a/backend/database.py +++ b/backend/database.py @@ -248,6 +248,8 @@ def init_db(): c.execute("ALTER TABLE recipes ADD COLUMN updated_by INTEGER") if "en_name" not in cols: c.execute("ALTER TABLE recipes ADD COLUMN en_name TEXT DEFAULT ''") + if "volume" not in cols: + c.execute("ALTER TABLE recipes ADD COLUMN volume TEXT DEFAULT ''") # Seed admin user if no users exist count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0] diff --git a/backend/main.py b/backend/main.py index 213ba2b..b521877 100644 --- a/backend/main.py +++ b/backend/main.py @@ -110,6 +110,7 @@ class RecipeUpdate(BaseModel): ingredients: Optional[list[IngredientIn]] = None tags: Optional[list[str]] = None version: Optional[int] = None + volume: Optional[str] = None class UserIn(BaseModel): @@ -319,7 +320,7 @@ def symptom_search(body: dict, user=Depends(get_current_user)): conn = get_db() # Search in recipe names rows = conn.execute( - "SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id" + "SELECT id, name, note, owner_id, version, en_name, volume FROM recipes ORDER BY id" ).fetchall() exact = [] related = [] @@ -773,6 +774,7 @@ def _recipe_to_dict(conn, row): "version": row["version"] if "version" in row.keys() else 1, "ingredients": [{"oil_name": i["oil_name"], "drops": i["drops"]} for i in ings], "tags": [t["tag_name"] for t in tags], + "volume": row["volume"] if "volume" in row.keys() else "", } @@ -781,19 +783,19 @@ def list_recipes(user=Depends(get_current_user)): conn = get_db() # Admin sees all; others see admin-owned (adopted) + their own if user["role"] == "admin": - rows = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id").fetchall() + rows = conn.execute("SELECT id, name, note, owner_id, version, en_name, volume FROM recipes ORDER BY id").fetchall() else: admin = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone() admin_id = admin["id"] if admin else 1 user_id = user.get("id") if user_id: rows = conn.execute( - "SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE owner_id = ? OR owner_id = ? ORDER BY id", + "SELECT id, name, note, owner_id, version, en_name, volume FROM recipes WHERE owner_id = ? OR owner_id = ? ORDER BY id", (admin_id, user_id) ).fetchall() else: rows = conn.execute( - "SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE owner_id = ? ORDER BY id", + "SELECT id, name, note, owner_id, version, en_name, volume FROM recipes WHERE owner_id = ? ORDER BY id", (admin_id,) ).fetchall() result = [_recipe_to_dict(conn, r) for r in rows] @@ -804,7 +806,7 @@ def list_recipes(user=Depends(get_current_user)): @app.get("/api/recipes/{recipe_id}") def get_recipe(recipe_id: int): conn = get_db() - row = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + row = conn.execute("SELECT id, name, note, owner_id, version, en_name, volume FROM recipes WHERE id = ?", (recipe_id,)).fetchone() if not row: conn.close() raise HTTPException(404, "Recipe not found") @@ -894,6 +896,8 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current c.execute("UPDATE recipes SET note = ? WHERE id = ?", (update.note, recipe_id)) if update.en_name is not None: c.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (title_case(update.en_name), recipe_id)) + if update.volume is not None: + c.execute("UPDATE recipes SET volume = ? WHERE id = ?", (update.volume, recipe_id)) if update.ingredients is not None: c.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,)) for ing in update.ingredients: @@ -933,7 +937,7 @@ def delete_recipe(recipe_id: int, user=Depends(get_current_user)): conn = get_db() row = _check_recipe_permission(conn, recipe_id, user) # Save full snapshot for undo - full = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + full = conn.execute("SELECT id, name, note, owner_id, version, en_name, volume FROM recipes WHERE id = ?", (recipe_id,)).fetchone() snapshot = _recipe_to_dict(conn, full) log_audit(conn, user["id"], "delete_recipe", "recipe", recipe_id, row["name"], json.dumps(snapshot, ensure_ascii=False)) @@ -1596,7 +1600,7 @@ def recipes_by_inventory(user=Depends(get_current_user)): if not inv: conn.close() return [] - rows = conn.execute("SELECT id, name, note, owner_id, version, en_name FROM recipes ORDER BY id").fetchall() + rows = conn.execute("SELECT id, name, note, owner_id, version, en_name, volume FROM recipes ORDER BY id").fetchall() result = [] for r in rows: recipe = _recipe_to_dict(conn, r) diff --git a/frontend/src/components/RecipeCard.vue b/frontend/src/components/RecipeCard.vue index 07b6965..8e8c0bf 100644 --- a/frontend/src/components/RecipeCard.vue +++ b/frontend/src/components/RecipeCard.vue @@ -48,6 +48,15 @@ const priceInfo = computed(() => oilsStore.fmtCostWithRetail(props.recipe.ingred const isFav = computed(() => recipesStore.isFavorite(props.recipe)) const volumeLabel = computed(() => { + // Priority 1: stored volume from editor selection + const vol = props.recipe.volume + if (vol) { + if (vol === 'single') return '单次' + if (vol === 'custom') return '' + if (/^\d+$/.test(vol)) return `${vol}ml` + return vol + } + // Priority 2: calculate from ingredients const ings = props.recipe.ingredients || [] const coco = ings.find(i => i.oil === '椰子油') if (coco && coco.drops) { @@ -56,7 +65,7 @@ const volumeLabel = computed(() => { if (ml <= 2) return '单次' return `${Math.round(ml)}ml` } - // Non-coconut: sum all portion products as ml + // Priority 3: sum portion products as ml let totalMl = 0 let hasProduct = false for (const ing of ings) { diff --git a/frontend/src/views/RecipeManager.vue b/frontend/src/views/RecipeManager.vue index ed8f15e..26549ca 100644 --- a/frontend/src/views/RecipeManager.vue +++ b/frontend/src/views/RecipeManager.vue @@ -171,7 +171,7 @@ />
{{ r.name }} - {{ getVolumeLabel(r.ingredients) }} + {{ getVolumeLabel(r.ingredients, r.volume) }} {{ t }} @@ -777,17 +777,25 @@ function editRecipe(recipe) { const coco = ings.find(i => i.oil === '椰子油') if (coco) { formCocoRow.value = { ...coco, _search: '椰子油', _open: false } - // Guess volume from total drops - const eoDrops = ings.filter(i => i.oil && i.oil !== '椰子油').reduce((s, i) => s + (i.drops || 0), 0) - const totalDrops = eoDrops + coco.drops - const ml = totalDrops / DROPS_PER_ML - if (ml <= 2) formVolume.value = 'single' - else if (Math.abs(ml - 5) < 1.5) formVolume.value = '5' - else if (Math.abs(ml - 10) < 2.5) formVolume.value = '10' - else if (Math.abs(ml - 15) < 2.5) formVolume.value = '15' - else if (Math.abs(ml - 20) < 3) formVolume.value = '20' - else if (Math.abs(ml - 30) < 6) formVolume.value = '30' - else { formVolume.value = 'custom'; formCustomVolume.value = Math.round(ml) } + // Use stored volume if available, otherwise guess from drops + if (recipe.volume) { + formVolume.value = recipe.volume + if (recipe.volume === 'custom') { + const totalDrops = ings.reduce((s, i) => s + (i.drops || 0), 0) + formCustomVolume.value = Math.round(totalDrops / DROPS_PER_ML) + } + } else { + const eoDrops = ings.filter(i => i.oil && i.oil !== '椰子油').reduce((s, i) => s + (i.drops || 0), 0) + const totalDrops = eoDrops + coco.drops + const ml = totalDrops / DROPS_PER_ML + if (ml <= 2) formVolume.value = 'single' + else if (Math.abs(ml - 5) < 1.5) formVolume.value = '5' + else if (Math.abs(ml - 10) < 2.5) formVolume.value = '10' + else if (Math.abs(ml - 15) < 2.5) formVolume.value = '15' + else if (Math.abs(ml - 20) < 3) formVolume.value = '20' + else if (Math.abs(ml - 30) < 6) formVolume.value = '30' + else { formVolume.value = 'custom'; formCustomVolume.value = Math.round(ml) } + } // Guess dilution if (eoDrops > 0 && coco.drops > 0) { const ratio = Math.round(coco.drops / eoDrops) @@ -1091,6 +1099,7 @@ async function saveCurrentRecipe() { ingredients: mappedIngs, note: formNote.value, tags: formTags.value, + volume: formVolume.value || '', } try { await recipeStore.saveRecipe(payload) @@ -1455,7 +1464,15 @@ function openRecipeDetail(recipe) { if (idx >= 0) previewRecipeIndex.value = idx } -function getVolumeLabel(ingredients) { +function getVolumeLabel(ingredients, volume) { + // Priority 1: stored volume + if (volume) { + if (volume === 'single') return '单次' + if (volume === 'custom') return '' + if (/^\d+$/.test(volume)) return `${volume}ml` + return volume + } + // Priority 2: calculate from ingredients const ings = ingredients || [] const coco = ings.find(i => i.oil === '椰子油') if (coco && coco.drops) { @@ -1464,7 +1481,7 @@ function getVolumeLabel(ingredients) { if (ml <= 2) return '单次' return `${Math.round(ml)}ml` } - // Non-coconut: sum portion products, mixed units all convert to ml + // Priority 3: sum portion products as ml let totalMl = 0 let hasProduct = false for (const ing of ings) {