From 1af9e02e9269a63ad22b6cdb1ad8d1ea2d89d5d3 Mon Sep 17 00:00:00 2001 From: Hera Zhao Date: Sun, 12 Apr 2026 09:50:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=8B=B1=E6=96=87=E5=90=8D=E8=87=AA?= =?UTF-8?q?=E5=8A=A8Title=20Case=20+=20=E8=87=AA=E5=8A=A8=E7=BF=BB?= =?UTF-8?q?=E8=AF=91=E6=9C=AA=E7=BF=BB=E8=AF=91=E9=85=8D=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 所有 en_name 写入时自动 Title Case(pain relief → Pain Relief) - 新建/采纳配方到公共库时自动生成英文名 - 启动时自动补全未翻译的配方英文名 - 新增 translate.py 关键词翻译字典(100+ 中医/美容/精油词条) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/main.py | 29 +++++++++-- backend/translate.py | 112 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 backend/translate.py diff --git a/backend/main.py b/backend/main.py index 69d872e..1ed768d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,12 +6,18 @@ import json import os from backend.database import get_db, init_db, seed_defaults, log_audit +from backend.translate import auto_translate import hashlib import secrets as _secrets app = FastAPI(title="Essential Oil Formula Calculator API") +def title_case(s: str) -> str: + """Convert to title case: 'pain relief' → 'Pain Relief'""" + return s.strip().title() if s else s + + # ── Password hashing (PBKDF2-SHA256, stdlib) ───────── def hash_password(password: str) -> str: salt = _secrets.token_hex(16) @@ -693,7 +699,7 @@ def upsert_oil(oil: OilIn, user=Depends(require_role("admin", "senior_editor"))) "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, oil.en_name, oil.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), ) log_audit(conn, user["id"], "upsert_oil", "oil", oil.name, oil.name, json.dumps({"bottle_price": oil.bottle_price, "drop_count": oil.drop_count})) @@ -789,8 +795,9 @@ def create_recipe(recipe: RecipeIn, user=Depends(get_current_user)): admin = c.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone() if admin: owner_id = admin["id"] - c.execute("INSERT INTO recipes (name, note, owner_id) VALUES (?, ?, ?)", - (recipe.name, recipe.note, owner_id)) + en_name = title_case(recipe.en_name) if recipe.en_name else auto_translate(recipe.name) + c.execute("INSERT INTO recipes (name, note, owner_id, en_name) VALUES (?, ?, ?, ?)", + (recipe.name, recipe.note, owner_id, en_name)) rid = c.lastrowid for ing in recipe.ingredients: c.execute( @@ -853,7 +860,7 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current if update.note is not None: 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 = ?", (update.en_name, recipe_id)) + c.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (title_case(update.en_name), recipe_id)) if update.ingredients is not None: c.execute("DELETE FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,)) for ing in update.ingredients: @@ -916,6 +923,10 @@ def adopt_recipe(recipe_id: int, user=Depends(require_role("admin"))): return {"ok": True, "msg": "already owned"} old_owner = conn.execute("SELECT id, role, display_name, username FROM users WHERE id = ?", (row["owner_id"],)).fetchone() old_name = (old_owner["display_name"] or old_owner["username"]) if old_owner else "unknown" + # Auto-fill en_name if missing + existing_en = conn.execute("SELECT en_name FROM recipes WHERE id = ?", (recipe_id,)).fetchone() + if not existing_en["en_name"]: + conn.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (auto_translate(row["name"]), recipe_id)) conn.execute("UPDATE recipes SET owner_id = ?, updated_by = ? WHERE id = ?", (user["id"], user["id"], recipe_id)) log_audit(conn, user["id"], "adopt_recipe", "recipe", recipe_id, row["name"], json.dumps({"from_user": old_name})) @@ -1811,6 +1822,16 @@ def startup(): data = json.load(f) seed_defaults(data["oils_meta"], data["recipes"]) + # Auto-fill missing en_name for existing recipes + conn = get_db() + missing = conn.execute("SELECT id, name FROM recipes WHERE en_name IS NULL OR en_name = ''").fetchall() + for row in missing: + conn.execute("UPDATE recipes SET en_name = ? WHERE id = ?", (auto_translate(row["name"]), row["id"])) + if missing: + conn.commit() + print(f"[INIT] Auto-translated {len(missing)} recipe names to English") + conn.close() + if os.path.isdir(FRONTEND_DIR): # Serve static assets (js/css/images) directly app.mount("/assets", StaticFiles(directory=os.path.join(FRONTEND_DIR, "assets")), name="assets") diff --git a/backend/translate.py b/backend/translate.py new file mode 100644 index 0000000..a0fe5ed --- /dev/null +++ b/backend/translate.py @@ -0,0 +1,112 @@ +"""Auto-translate Chinese recipe names to English using keyword dictionary.""" + +# Common keywords in essential oil recipe names +_KEYWORDS = { + # Body parts + '头': 'Head', '头疗': 'Scalp Therapy', '头皮': 'Scalp', '头发': 'Hair', + '脸': 'Face', '面部': 'Face', '眼': 'Eye', '眼部': 'Eye', + '鼻': 'Nose', '鼻腔': 'Nasal', '耳': 'Ear', + '颈': 'Neck', '颈椎': 'Cervical', '肩': 'Shoulder', '肩颈': 'Neck & Shoulder', + '背': 'Back', '腰': 'Lower Back', '腰椎': 'Lumbar', + '胸': 'Chest', '腹': 'Abdomen', '腹部': 'Abdominal', + '手': 'Hand', '脚': 'Foot', '足': 'Foot', '膝': 'Knee', '关节': 'Joint', + '皮肤': 'Skin', '肌肤': 'Skin', '毛孔': 'Pore', + '乳腺': 'Breast', '子宫': 'Uterine', '私密': 'Intimate', + '淋巴': 'Lymph', '肝': 'Liver', '肾': 'Kidney', '脾': 'Spleen', '胃': 'Stomach', + '肺': 'Lung', '心': 'Heart', '肠': 'Intestinal', + '带脉': 'Belt Meridian', '经络': 'Meridian', + + # Symptoms & conditions + '酸痛': 'Pain Relief', '疼痛': 'Pain Relief', '止痛': 'Pain Relief', + '感冒': 'Cold', '发烧': 'Fever', '咳嗽': 'Cough', '咽喉': 'Throat', + '过敏': 'Allergy', '鼻炎': 'Rhinitis', '哮喘': 'Asthma', + '湿疹': 'Eczema', '痘痘': 'Acne', '粉刺': 'Acne', + '炎症': 'Anti-Inflammatory', '消炎': 'Anti-Inflammatory', + '便秘': 'Constipation', '腹泻': 'Diarrhea', '消化': 'Digestion', + '失眠': 'Insomnia', '助眠': 'Sleep Aid', '好眠': 'Sleep Well', '安眠': 'Sleep', + '焦虑': 'Anxiety', '抑郁': 'Depression', '情绪': 'Emotional', + '压力': 'Stress', '放松': 'Relaxation', '舒缓': 'Soothing', + '头痛': 'Headache', '偏头痛': 'Migraine', + '水肿': 'Edema', '浮肿': 'Swelling', + '痛经': 'Menstrual Pain', '月经': 'Menstrual', '经期': 'Menstrual', + '更年期': 'Menopause', '荷尔蒙': 'Hormone', + '结节': 'Nodule', '囊肿': 'Cyst', + '灰指甲': 'Nail Fungus', '脚气': 'Athlete\'s Foot', + '白发': 'Gray Hair', '脱发': 'Hair Loss', '生发': 'Hair Growth', + '瘦身': 'Slimming', '减肥': 'Weight Loss', '纤体': 'Body Sculpting', + '紫外线': 'UV', '晒伤': 'Sunburn', '防晒': 'Sun Protection', + '抗衰': 'Anti-Aging', '抗皱': 'Anti-Wrinkle', '美白': 'Whitening', + '补水': 'Hydrating', '保湿': 'Moisturizing', + '排毒': 'Detox', '清洁': 'Cleansing', '净化': 'Purifying', + '驱蚊': 'Mosquito Repellent', '驱虫': 'Insect Repellent', + + # Actions & methods + '护理': 'Care', '调理': 'Therapy', '修复': 'Repair', '养护': 'Nourish', + '按摩': 'Massage', '刮痧': 'Gua Sha', '拔罐': 'Cupping', '艾灸': 'Moxibustion', + '泡脚': 'Foot Soak', '泡澡': 'Bath', '精油浴': 'Oil Bath', + '热敷': 'Hot Compress', '冷敷': 'Cold Compress', '敷面': 'Face Mask', + '喷雾': 'Spray', '滚珠': 'Roll-On', '扩香': 'Diffuser', + '涂抹': 'Topical', '吸嗅': 'Inhalation', + '疏通': 'Unblock', '提升': 'Boost', '增强': 'Enhance', '促进': 'Promote', + '预防': 'Prevention', '改善': 'Improve', + '祛湿': 'Dampness Relief', '驱寒': 'Warming', + '化痰': 'Phlegm Relief', '健脾': 'Spleen Wellness', + '化湿': 'Dampness Clear', + + # Beauty + '美容': 'Beauty', '美发': 'Hair Care', '美体': 'Body Care', + '面膜': 'Face Mask', '发膜': 'Hair Mask', '眼霜': 'Eye Cream', + '精华': 'Serum', '乳液': 'Lotion', '洗发': 'Shampoo', + + # General + '配方': 'Blend', '方': 'Blend', '包': 'Blend', + '增强版': 'Enhanced', '高配版': 'Premium', '基础版': 'Basic', + '男士': 'Men\'s', '女士': 'Women\'s', '儿童': 'Children\'s', '宝宝': 'Baby', + '日常': 'Daily', '夜间': 'Night', '早晨': 'Morning', + '呼吸': 'Respiratory', '呼吸系统': 'Respiratory System', + '免疫': 'Immunity', '免疫力': 'Immunity', + '细胞': 'Cellular', '律动': 'Rhythm', +} + +# Longer keys first for greedy matching +_SORTED_KEYS = sorted(_KEYWORDS.keys(), key=len, reverse=True) + + +def auto_translate(name: str) -> str: + """Translate a Chinese recipe name to English using keyword matching.""" + if not name: + return '' + remaining = name.strip() + parts = [] + i = 0 + while i < len(remaining): + matched = False + for key in _SORTED_KEYS: + if remaining[i:i+len(key)] == key: + en = _KEYWORDS[key] + if en not in parts: # avoid duplicates + parts.append(en) + i += len(key) + matched = True + break + if not matched: + # Skip numbers, punctuation, and unrecognized chars + ch = remaining[i] + if ch.isascii() and ch.isalpha(): + # Collect consecutive ASCII chars + j = i + while j < len(remaining) and remaining[j].isascii() and remaining[j].isalpha(): + j += 1 + word = remaining[i:j] + if word not in parts: + parts.append(word) + i = j + else: + i += 1 + + if parts: + result = ' '.join(parts) + # Title case each word but preserve apostrophes (Men's not Men'S) + return ' '.join(w[0].upper() + w[1:] if w else w for w in result.split()) + # Fallback: return original name + return name