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")
|
c.execute("ALTER TABLE recipes ADD COLUMN updated_by INTEGER")
|
||||||
if "en_name" not in cols:
|
if "en_name" not in cols:
|
||||||
c.execute("ALTER TABLE recipes ADD COLUMN en_name TEXT DEFAULT ''")
|
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
|
# Seed admin user if no users exist
|
||||||
count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ class RecipeUpdate(BaseModel):
|
|||||||
ingredients: Optional[list[IngredientIn]] = None
|
ingredients: Optional[list[IngredientIn]] = None
|
||||||
tags: Optional[list[str]] = None
|
tags: Optional[list[str]] = None
|
||||||
version: Optional[int] = None
|
version: Optional[int] = None
|
||||||
|
volume: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UserIn(BaseModel):
|
class UserIn(BaseModel):
|
||||||
@@ -319,7 +320,7 @@ def symptom_search(body: dict, user=Depends(get_current_user)):
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
# Search in recipe names
|
# Search in recipe names
|
||||||
rows = conn.execute(
|
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()
|
).fetchall()
|
||||||
exact = []
|
exact = []
|
||||||
related = []
|
related = []
|
||||||
@@ -773,6 +774,7 @@ def _recipe_to_dict(conn, row):
|
|||||||
"version": row["version"] if "version" in row.keys() else 1,
|
"version": row["version"] if "version" in row.keys() else 1,
|
||||||
"ingredients": [{"oil_name": i["oil_name"], "drops": i["drops"]} for i in ings],
|
"ingredients": [{"oil_name": i["oil_name"], "drops": i["drops"]} for i in ings],
|
||||||
"tags": [t["tag_name"] for t in tags],
|
"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()
|
conn = get_db()
|
||||||
# Admin sees all; others see admin-owned (adopted) + their own
|
# Admin sees all; others see admin-owned (adopted) + their own
|
||||||
if user["role"] == "admin":
|
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:
|
else:
|
||||||
admin = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
|
admin = conn.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
|
||||||
admin_id = admin["id"] if admin else 1
|
admin_id = admin["id"] if admin else 1
|
||||||
user_id = user.get("id")
|
user_id = user.get("id")
|
||||||
if user_id:
|
if user_id:
|
||||||
rows = conn.execute(
|
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)
|
(admin_id, user_id)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = conn.execute(
|
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,)
|
(admin_id,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
result = [_recipe_to_dict(conn, r) for r in rows]
|
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}")
|
@app.get("/api/recipes/{recipe_id}")
|
||||||
def get_recipe(recipe_id: int):
|
def get_recipe(recipe_id: int):
|
||||||
conn = get_db()
|
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:
|
if not row:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(404, "Recipe not found")
|
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))
|
c.execute("UPDATE recipes SET note = ? WHERE id = ?", (update.note, recipe_id))
|
||||||
if update.en_name is not None:
|
if update.en_name is not None:
|
||||||
c.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (title_case(update.en_name), recipe_id))
|
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:
|
if update.ingredients is not None:
|
||||||
c.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,))
|
c.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,))
|
||||||
for ing in update.ingredients:
|
for ing in update.ingredients:
|
||||||
@@ -933,7 +937,7 @@ def delete_recipe(recipe_id: int, user=Depends(get_current_user)):
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = _check_recipe_permission(conn, recipe_id, user)
|
row = _check_recipe_permission(conn, recipe_id, user)
|
||||||
# Save full snapshot for undo
|
# 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)
|
snapshot = _recipe_to_dict(conn, full)
|
||||||
log_audit(conn, user["id"], "delete_recipe", "recipe", recipe_id, row["name"],
|
log_audit(conn, user["id"], "delete_recipe", "recipe", recipe_id, row["name"],
|
||||||
json.dumps(snapshot, ensure_ascii=False))
|
json.dumps(snapshot, ensure_ascii=False))
|
||||||
@@ -1596,7 +1600,7 @@ def recipes_by_inventory(user=Depends(get_current_user)):
|
|||||||
if not inv:
|
if not inv:
|
||||||
conn.close()
|
conn.close()
|
||||||
return []
|
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 = []
|
result = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
recipe = _recipe_to_dict(conn, r)
|
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 isFav = computed(() => recipesStore.isFavorite(props.recipe))
|
||||||
|
|
||||||
const volumeLabel = computed(() => {
|
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 ings = props.recipe.ingredients || []
|
||||||
const coco = ings.find(i => i.oil === '椰子油')
|
const coco = ings.find(i => i.oil === '椰子油')
|
||||||
if (coco && coco.drops) {
|
if (coco && coco.drops) {
|
||||||
@@ -56,7 +65,7 @@ const volumeLabel = computed(() => {
|
|||||||
if (ml <= 2) return '单次'
|
if (ml <= 2) return '单次'
|
||||||
return `${Math.round(ml)}ml`
|
return `${Math.round(ml)}ml`
|
||||||
}
|
}
|
||||||
// Non-coconut: sum all portion products as ml
|
// Priority 3: sum portion products as ml
|
||||||
let totalMl = 0
|
let totalMl = 0
|
||||||
let hasProduct = false
|
let hasProduct = false
|
||||||
for (const ing of ings) {
|
for (const ing of ings) {
|
||||||
|
|||||||
@@ -171,7 +171,7 @@
|
|||||||
/>
|
/>
|
||||||
<div class="row-info" @click="editRecipe(r)">
|
<div class="row-info" @click="editRecipe(r)">
|
||||||
<span class="row-name">{{ r.name }}</span>
|
<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 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 v-for="t in [...(r.tags || [])].sort((a,b)=>a.localeCompare(b,'zh'))" :key="t" class="mini-tag">{{ t }}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -777,17 +777,25 @@ function editRecipe(recipe) {
|
|||||||
const coco = ings.find(i => i.oil === '椰子油')
|
const coco = ings.find(i => i.oil === '椰子油')
|
||||||
if (coco) {
|
if (coco) {
|
||||||
formCocoRow.value = { ...coco, _search: '椰子油', _open: false }
|
formCocoRow.value = { ...coco, _search: '椰子油', _open: false }
|
||||||
// Guess volume from total drops
|
// Use stored volume if available, otherwise guess from drops
|
||||||
const eoDrops = ings.filter(i => i.oil && i.oil !== '椰子油').reduce((s, i) => s + (i.drops || 0), 0)
|
if (recipe.volume) {
|
||||||
const totalDrops = eoDrops + coco.drops
|
formVolume.value = recipe.volume
|
||||||
const ml = totalDrops / DROPS_PER_ML
|
if (recipe.volume === 'custom') {
|
||||||
if (ml <= 2) formVolume.value = 'single'
|
const totalDrops = ings.reduce((s, i) => s + (i.drops || 0), 0)
|
||||||
else if (Math.abs(ml - 5) < 1.5) formVolume.value = '5'
|
formCustomVolume.value = Math.round(totalDrops / DROPS_PER_ML)
|
||||||
else if (Math.abs(ml - 10) < 2.5) formVolume.value = '10'
|
}
|
||||||
else if (Math.abs(ml - 15) < 2.5) formVolume.value = '15'
|
} else {
|
||||||
else if (Math.abs(ml - 20) < 3) formVolume.value = '20'
|
const eoDrops = ings.filter(i => i.oil && i.oil !== '椰子油').reduce((s, i) => s + (i.drops || 0), 0)
|
||||||
else if (Math.abs(ml - 30) < 6) formVolume.value = '30'
|
const totalDrops = eoDrops + coco.drops
|
||||||
else { formVolume.value = 'custom'; formCustomVolume.value = Math.round(ml) }
|
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
|
// Guess dilution
|
||||||
if (eoDrops > 0 && coco.drops > 0) {
|
if (eoDrops > 0 && coco.drops > 0) {
|
||||||
const ratio = Math.round(coco.drops / eoDrops)
|
const ratio = Math.round(coco.drops / eoDrops)
|
||||||
@@ -1091,6 +1099,7 @@ async function saveCurrentRecipe() {
|
|||||||
ingredients: mappedIngs,
|
ingredients: mappedIngs,
|
||||||
note: formNote.value,
|
note: formNote.value,
|
||||||
tags: formTags.value,
|
tags: formTags.value,
|
||||||
|
volume: formVolume.value || '',
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await recipeStore.saveRecipe(payload)
|
await recipeStore.saveRecipe(payload)
|
||||||
@@ -1455,7 +1464,15 @@ function openRecipeDetail(recipe) {
|
|||||||
if (idx >= 0) previewRecipeIndex.value = idx
|
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 ings = ingredients || []
|
||||||
const coco = ings.find(i => i.oil === '椰子油')
|
const coco = ings.find(i => i.oil === '椰子油')
|
||||||
if (coco && coco.drops) {
|
if (coco && coco.drops) {
|
||||||
@@ -1464,7 +1481,7 @@ function getVolumeLabel(ingredients) {
|
|||||||
if (ml <= 2) return '单次'
|
if (ml <= 2) return '单次'
|
||||||
return `${Math.round(ml)}ml`
|
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 totalMl = 0
|
||||||
let hasProduct = false
|
let hasProduct = false
|
||||||
for (const ing of ings) {
|
for (const ing of ings) {
|
||||||
|
|||||||
Reference in New Issue
Block a user