diff --git a/backend/database.py b/backend/database.py index 137ec68..241c163 100644 --- a/backend/database.py +++ b/backend/database.py @@ -227,6 +227,8 @@ def init_db(): c.execute("ALTER TABLE oils ADD COLUMN is_active INTEGER DEFAULT 1") if "en_name" not in oil_cols: c.execute("ALTER TABLE oils ADD COLUMN en_name TEXT DEFAULT ''") + if "unit" not in oil_cols: + c.execute("ALTER TABLE oils ADD COLUMN unit TEXT DEFAULT 'drop'") # Migration: add new columns to category_modules if missing cat_cols = [row[1] for row in c.execute("PRAGMA table_info(category_modules)").fetchall()] @@ -246,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 55a5729..b521877 100644 --- a/backend/main.py +++ b/backend/main.py @@ -87,6 +87,7 @@ class OilIn(BaseModel): retail_price: Optional[float] = None en_name: Optional[str] = None is_active: Optional[int] = None + unit: Optional[str] = None class IngredientIn(BaseModel): @@ -109,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): @@ -318,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 = [] @@ -715,7 +717,7 @@ def impersonate(body: dict, user=Depends(require_role("admin"))): @app.get("/api/oils") def list_oils(): conn = get_db() - rows = conn.execute("SELECT name, bottle_price, drop_count, retail_price, is_active, en_name FROM oils ORDER BY name").fetchall() + rows = conn.execute("SELECT name, bottle_price, drop_count, retail_price, is_active, en_name, unit FROM oils ORDER BY name").fetchall() conn.close() return [dict(r) for r in rows] @@ -724,11 +726,11 @@ def list_oils(): def upsert_oil(oil: OilIn, user=Depends(require_role("admin", "senior_editor"))): conn = get_db() conn.execute( - "INSERT INTO oils (name, bottle_price, drop_count, retail_price, en_name, is_active) VALUES (?, ?, ?, ?, ?, ?) " + "INSERT INTO oils (name, bottle_price, drop_count, retail_price, en_name, is_active, unit) VALUES (?, ?, ?, ?, ?, ?, ?) " "ON CONFLICT(name) DO UPDATE SET bottle_price=excluded.bottle_price, drop_count=excluded.drop_count, " "retail_price=excluded.retail_price, en_name=COALESCE(excluded.en_name, oils.en_name), " - "is_active=COALESCE(excluded.is_active, oils.is_active)", - (oil.name, oil.bottle_price, oil.drop_count, oil.retail_price, title_case(oil.en_name) if oil.en_name else oil.en_name, oil.is_active), + "is_active=COALESCE(excluded.is_active, oils.is_active), unit=COALESCE(excluded.unit, oils.unit)", + (oil.name, oil.bottle_price, oil.drop_count, oil.retail_price, title_case(oil.en_name) if oil.en_name else oil.en_name, oil.is_active, oil.unit), ) log_audit(conn, user["id"], "upsert_oil", "oil", oil.name, oil.name, json.dumps({"bottle_price": oil.bottle_price, "drop_count": oil.drop_count})) @@ -772,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 "", } @@ -780,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] @@ -803,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") @@ -893,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: @@ -932,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)) @@ -1595,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/__tests__/newFeatures.test.js b/frontend/src/__tests__/newFeatures.test.js index 8b50b8c..804bd81 100644 --- a/frontend/src/__tests__/newFeatures.test.js +++ b/frontend/src/__tests__/newFeatures.test.js @@ -58,15 +58,15 @@ describe('getPinyinInitials', () => { }) describe('matchesPinyinInitials', () => { - it('matches prefix only', () => { + it('matches prefix', () => { expect(matchesPinyinInitials('生姜', 's')).toBe(true) expect(matchesPinyinInitials('生姜', 'sj')).toBe(true) - expect(matchesPinyinInitials('茶树', 's')).toBe(false) // cs doesn't start with s expect(matchesPinyinInitials('茶树', 'cs')).toBe(true) }) - it('does not match substring', () => { - expect(matchesPinyinInitials('茶树', 's')).toBe(false) + it('matches substring and subsequence', () => { + expect(matchesPinyinInitials('茶树', 's')).toBe(true) // substring + expect(matchesPinyinInitials('新瑞活力身体紧致霜', 'js')).toBe(true) // subsequence }) it('matches 忍冬花 with r', () => { diff --git a/frontend/src/__tests__/pr27Features.test.js b/frontend/src/__tests__/pr27Features.test.js index 4aa015e..f2007e3 100644 --- a/frontend/src/__tests__/pr27Features.test.js +++ b/frontend/src/__tests__/pr27Features.test.js @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { recipeNameEn, oilEn } from '../composables/useOilTranslation' -import { matchesPinyinInitials, getPinyinInitials } from '../composables/usePinyinMatch' +import { matchesPinyinInitials, getPinyinInitials, pinyinMatchScore } from '../composables/usePinyinMatch' // --------------------------------------------------------------------------- // EDITOR_ONLY_TAGS includes '已下架' @@ -376,3 +376,71 @@ describe('viewer tag visibility logic', () => { expect([...myTags]).toHaveLength(0) }) }) + +// --------------------------------------------------------------------------- +// PR30: Pinyin subsequence matching + pinyinMatchScore +// --------------------------------------------------------------------------- +describe('pinyin subsequence matching — PR30', () => { + it('js matches 紧致霜 via subsequence', () => { + expect(matchesPinyinInitials('新瑞活力身体紧致霜', 'js')).toBe(true) + }) + + it('prefix match scores 0', () => { + expect(pinyinMatchScore('麦卢卡', 'mlk')).toBe(0) + }) + + it('substring match scores 1', () => { + expect(pinyinMatchScore('椒样薄荷', 'ybh')).toBe(1) + }) + + it('subsequence match scores 2', () => { + expect(pinyinMatchScore('新瑞活力身体紧致霜', 'js')).toBe(2) + }) + + it('no match scores -1', () => { + expect(pinyinMatchScore('薰衣草', 'zz')).toBe(-1) + }) + + it('product names have pinyin', () => { + expect(getPinyinInitials('身体紧致霜')).toBe('stjzs') + expect(getPinyinInitials('深层净肤面膜')).toBe('scjfmm') + expect(getPinyinInitials('青春无龄保湿霜')).toBe('qcwlbss') + }) +}) + +// --------------------------------------------------------------------------- +// PR30: Unit system (drop/ml/g/capsule) +// --------------------------------------------------------------------------- +describe('unit system — PR30', () => { + const UNIT_LABELS = { + drop: { zh: '滴' }, + ml: { zh: 'ml' }, + g: { zh: 'g' }, + capsule: { zh: '颗' }, + } + + it('maps unit to correct label', () => { + expect(UNIT_LABELS['drop'].zh).toBe('滴') + expect(UNIT_LABELS['ml'].zh).toBe('ml') + expect(UNIT_LABELS['g'].zh).toBe('g') + expect(UNIT_LABELS['capsule'].zh).toBe('颗') + }) + + it('volume display priority: stored > calculated > product sum', () => { + // Stored volume takes priority + const recipe1 = { volume: 'single', ingredients: [{ oil: '椰子油', drops: 96 }] } + const vol1 = recipe1.volume === 'single' ? '单次' : '' + expect(vol1).toBe('单次') + + // No stored volume, has coconut oil → calculate + const recipe2 = { volume: '', ingredients: [{ oil: '薰衣草', drops: 3 }, { oil: '椰子油', drops: 90 }] } + const total = recipe2.ingredients.reduce((s, i) => s + i.drops, 0) + const ml = Math.round(total / 18.6) + expect(ml).toBe(5) + + // No coconut oil, has product → show product volume + const recipe3 = { volume: '', ingredients: [{ oil: '薰衣草', drops: 3 }, { oil: '玫瑰护手霜', drops: 30 }] } + const hasProduct = recipe3.ingredients.some(i => i.oil === '玫瑰护手霜') + expect(hasProduct).toBe(true) + }) +}) diff --git a/frontend/src/components/RecipeCard.vue b/frontend/src/components/RecipeCard.vue index 8e33197..8e8c0bf 100644 --- a/frontend/src/components/RecipeCard.vue +++ b/frontend/src/components/RecipeCard.vue @@ -48,13 +48,33 @@ 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) return '' - const totalDrops = ings.reduce((s, i) => s + (i.drops || 0), 0) - const ml = totalDrops / 18.6 - if (ml <= 2) return '单次' - return `${Math.round(ml)}ml` + if (coco && coco.drops) { + const totalDrops = ings.reduce((s, i) => s + (i.drops || 0), 0) + const ml = totalDrops / 18.6 + if (ml <= 2) return '单次' + return `${Math.round(ml)}ml` + } + // Priority 3: sum portion products as ml + let totalMl = 0 + let hasProduct = false + for (const ing of ings) { + if (!oilsStore.isPortionUnit(ing.oil)) continue + hasProduct = true + totalMl += ing.drops || 0 + } + if (hasProduct && totalMl > 0) return `${Math.round(totalMl)}ml` + return '' }) diff --git a/frontend/src/components/RecipeDetailOverlay.vue b/frontend/src/components/RecipeDetailOverlay.vue index e4b6afd..b2a13a2 100644 --- a/frontend/src/components/RecipeDetailOverlay.vue +++ b/frontend/src/components/RecipeDetailOverlay.vue @@ -64,7 +64,7 @@