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
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:
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user