feat: 配方volume字段存储编辑器选择的容量
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 4s
PR Preview / test (pull_request) Successful in 5s
PR Preview / deploy-preview (pull_request) Successful in 12s
Test / e2e-test (push) Successful in 52s

- 后端: recipes表新增volume列,API返回/保存volume
- 前端: 保存时发送formVolume,编辑时优先用stored volume
- 容量显示优先级: stored volume > 椰子油计算 > 产品ml求和
- 修复编辑器容量选择保存后不生效的bug

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 17:31:04 +00:00
parent 13b675e63d
commit 74a8d9aeb6
4 changed files with 54 additions and 22 deletions

View File

@@ -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]

View File

@@ -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)

View File

@@ -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) {

View File

@@ -171,7 +171,7 @@
/>
<div class="row-info" @click="editRecipe(r)">
<span class="row-name">{{ r.name }}</span>
<span v-if="getVolumeLabel(r.ingredients)" class="row-volume">{{ getVolumeLabel(r.ingredients) }}</span>
<span v-if="getVolumeLabel(r.ingredients, r.volume)" class="row-volume">{{ getVolumeLabel(r.ingredients, r.volume) }}</span>
<span class="row-tags">
<span v-for="t in [...(r.tags || [])].sort((a,b)=>a.localeCompare(b,'zh'))" :key="t" class="mini-tag">{{ t }}</span>
</span>
@@ -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) {