feat: 英文名自动Title Case + 自动翻译未翻译配方
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 16s
Test / e2e-test (push) Successful in 48s
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 16s
Test / e2e-test (push) Successful in 48s
- 所有 en_name 写入时自动 Title Case(pain relief → Pain Relief) - 新建/采纳配方到公共库时自动生成英文名 - 启动时自动补全未翻译的配方英文名 - 新增 translate.py 关键词翻译字典(100+ 中医/美容/精油词条) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,12 +6,18 @@ import json
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from backend.database import get_db, init_db, seed_defaults, log_audit
|
from backend.database import get_db, init_db, seed_defaults, log_audit
|
||||||
|
from backend.translate import auto_translate
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets as _secrets
|
import secrets as _secrets
|
||||||
|
|
||||||
app = FastAPI(title="Essential Oil Formula Calculator API")
|
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) ─────────
|
# ── Password hashing (PBKDF2-SHA256, stdlib) ─────────
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
salt = _secrets.token_hex(16)
|
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, "
|
"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), "
|
"retail_price=excluded.retail_price, en_name=COALESCE(excluded.en_name, oils.en_name), "
|
||||||
"is_active=COALESCE(excluded.is_active, oils.is_active)",
|
"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,
|
log_audit(conn, user["id"], "upsert_oil", "oil", oil.name, oil.name,
|
||||||
json.dumps({"bottle_price": oil.bottle_price, "drop_count": oil.drop_count}))
|
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()
|
admin = c.execute("SELECT id FROM users WHERE role = 'admin' LIMIT 1").fetchone()
|
||||||
if admin:
|
if admin:
|
||||||
owner_id = admin["id"]
|
owner_id = admin["id"]
|
||||||
c.execute("INSERT INTO recipes (name, note, owner_id) VALUES (?, ?, ?)",
|
en_name = title_case(recipe.en_name) if recipe.en_name else auto_translate(recipe.name)
|
||||||
(recipe.name, recipe.note, owner_id))
|
c.execute("INSERT INTO recipes (name, note, owner_id, en_name) VALUES (?, ?, ?, ?)",
|
||||||
|
(recipe.name, recipe.note, owner_id, en_name))
|
||||||
rid = c.lastrowid
|
rid = c.lastrowid
|
||||||
for ing in recipe.ingredients:
|
for ing in recipe.ingredients:
|
||||||
c.execute(
|
c.execute(
|
||||||
@@ -853,7 +860,7 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current
|
|||||||
if update.note is not None:
|
if update.note is not None:
|
||||||
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 = ?", (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:
|
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:
|
||||||
@@ -916,6 +923,10 @@ def adopt_recipe(recipe_id: int, user=Depends(require_role("admin"))):
|
|||||||
return {"ok": True, "msg": "already owned"}
|
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_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"
|
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))
|
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"],
|
log_audit(conn, user["id"], "adopt_recipe", "recipe", recipe_id, row["name"],
|
||||||
json.dumps({"from_user": old_name}))
|
json.dumps({"from_user": old_name}))
|
||||||
@@ -1811,6 +1822,16 @@ def startup():
|
|||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
seed_defaults(data["oils_meta"], data["recipes"])
|
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):
|
if os.path.isdir(FRONTEND_DIR):
|
||||||
# Serve static assets (js/css/images) directly
|
# Serve static assets (js/css/images) directly
|
||||||
app.mount("/assets", StaticFiles(directory=os.path.join(FRONTEND_DIR, "assets")), name="assets")
|
app.mount("/assets", StaticFiles(directory=os.path.join(FRONTEND_DIR, "assets")), name="assets")
|
||||||
|
|||||||
112
backend/translate.py
Normal file
112
backend/translate.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user