Compare commits

...

6 Commits

Author SHA1 Message Date
e8af6e2565 fix: Excel 导出英文名也直接取 DB meta.enName
All checks were successful
PR Preview / test (pull_request) Has been skipped
Deploy Production / test (push) Successful in 7s
PR Preview / teardown-preview (pull_request) Successful in 14s
PR Preview / deploy-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
Deploy Production / deploy (push) Successful in 6s
Test / e2e-test (push) Successful in 3m2s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:01:19 +00:00
c04bb53ddd fix: 精油编辑统一保存到 DB + 导出 Excel 增加功效列
Some checks failed
Test / unit-test (push) Successful in 7s
Test / build-check (push) Successful in 4s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Failing after 1m12s
PR Preview / test (pull_request) Successful in 7s
PR Preview / deploy-preview (pull_request) Successful in 19s
- 新增 oil_cards 表,持久化知识卡片(功效/用法/方法/注意/emoji)
- POST /api/oils 扩展接受 card_* 字段,在同一事务里 upsert oil_cards
- GET /api/oil-cards 返回全部卡片
- 前端 getOilCard 优先查 DB,再 fallback 静态表
- saveEditOil 统一走 saveOil,不再分两套保存
- 精油价目 Excel 导出增加「功效」列
- 首次部署自动从静态 OIL_CARDS 播种到 oil_cards 表

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 10:56:30 +00:00
fam
8f004a02cd Merge branch 'main' into feat/search-recipes-by-oil
All checks were successful
PR Preview / test (pull_request) Has been skipped
Deploy Production / test (push) Successful in 7s
PR Preview / teardown-preview (pull_request) Successful in 13s
PR Preview / deploy-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
Deploy Production / deploy (push) Successful in 6s
Test / e2e-test (push) Successful in 3m2s
2026-04-15 21:08:19 +00:00
50751ed9be ci: retrigger after backend crash flake
Some checks failed
PR Preview / test (pull_request) Has been skipped
Deploy Production / test (push) Successful in 7s
PR Preview / teardown-preview (pull_request) Successful in 13s
PR Preview / deploy-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
Deploy Production / deploy (push) Successful in 5s
Test / e2e-test (push) Failing after 6m1s
2026-04-15 21:03:25 +00:00
f34dd49dcb feat: 配方查询支持按精油名搜索
All checks were successful
Test / unit-test (push) Successful in 6s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / build-check (push) Successful in 9s
Test / e2e-test (push) Successful in 3m1s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 20s
输入精油中文名/英文名会返回含该精油的所有配方。
中文查询 ≥2 字才匹配精油,避免「草」这样的单字噪音。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:47:59 +00:00
ed8d49d9a0 fix: 多项修复 — 滴数框/零售价显示/精油英文名保存/再次审核通知
Some checks failed
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Failing after 25s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 15s
1. 编辑配方的滴数输入框从 42px 加宽到 58px,确保 50.5 不被 spinner 遮挡
2. 配方卡片在零售价==会员价时也显示零售价(之前因 retail>cost 过滤掉)
3. 精油价目英文名保存后被静态 OIL_CARDS 覆盖,把 getEnglishName 的优先级改
   为先用 DB meta.enName,解决温柔呵护/仕女呵护等显示不更新的问题
4. 再次审核 tag 的配方被非管理员修改时,给管理员发通知,内容含前后 diff
5. 对应 vitest + cypress 测试各一组

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:39:19 +00:00
12 changed files with 443 additions and 31 deletions

View File

@@ -123,6 +123,15 @@ def init_db():
tag_name TEXT NOT NULL,
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS oil_cards (
name TEXT PRIMARY KEY REFERENCES oils(name) ON DELETE CASCADE,
emoji TEXT DEFAULT '',
en TEXT DEFAULT '',
effects TEXT DEFAULT '',
usage TEXT DEFAULT '',
method TEXT DEFAULT '',
caution TEXT DEFAULT ''
);
""")
# Migration: add password and brand fields to users if missing
@@ -341,7 +350,7 @@ def log_audit(conn, user_id, action, target_type=None, target_id=None, target_na
)
def seed_defaults(default_oils_meta: dict, default_recipes: list):
def seed_defaults(default_oils_meta: dict, default_recipes: list, default_oil_cards: dict = None):
"""Seed DB with defaults if empty."""
conn = get_db()
c = conn.cursor()
@@ -379,5 +388,18 @@ def seed_defaults(default_oils_meta: dict, default_recipes: list):
(rid, tag),
)
# Seed oil_cards from static data if table is empty
if default_oil_cards:
card_count = c.execute("SELECT COUNT(*) FROM oil_cards").fetchone()[0]
if card_count == 0:
for name, card in default_oil_cards.items():
c.execute(
"INSERT OR IGNORE INTO oil_cards (name, emoji, en, effects, usage, method, caution) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(name, card.get("emoji", ""), card.get("en", ""),
card.get("effects", ""), card.get("usage", ""),
card.get("method", ""), card.get("caution", "")),
)
conn.commit()
conn.close()

View File

@@ -12,6 +12,32 @@ import secrets as _secrets
app = FastAPI(title="Essential Oil Formula Calculator API")
# Default oil knowledge cards for DB seeding (mirrors frontend OIL_CARDS)
DEFAULT_OIL_CARDS = {
"野橘": {"emoji": "🍊", "en": "Wild Orange", "effects": "安抚镇静、驱散负能量、紧张情绪和压力\n抗氧化、消炎、抗病毒、增强免疫\n提振食欲,刺激胆汁分泌,促进消化\n促进循环", "usage": "日常香薰,提振情绪、舒压,营造愉悦氛围\n饮水加入 1至2 滴,护肝抗病毒\n泡澡时滴 3至4 滴,消除疲劳,放松身心\n扫地、拖地时加入 3至5 滴,清新空气\n涂抹腹部促进消化\n涂抹肝区帮助肝脏排毒\n口腔溃疡时加入水中漱口", "method": "🔹香薰 🔸内用 🔺涂抹", "caution": "轻微光敏,白天涂抹注意防晒"},
"冬青": {"emoji": "🌿", "en": "Wintergreen", "effects": "强效镇痛(肌肉、关节)\n抗炎、促进循环\n舒缓紧绷肌肉,抗痉挛", "usage": "牙疼时加 1 滴到水中漱口\n扭伤、落枕、酸痛(如肩颈酸痛)处稀释涂抹\n运动前后按摩", "method": "🔹香薰 |🔺涂抹(需 6 倍稀释)", "caution": "不可内用、孕期慎用、避免儿童误食"},
"生姜": {"emoji": "🫚", "en": "Ginger", "effects": "促进消化、暖胃\n活血、改善循环、祛湿\n抗炎、抗氧化、强健免疫\n缓解恶心、晕车\n促进骨骼、肌肉和关节的健康", "usage": "胀气、腹冷时,稀释涂抹腹部或喝 1 滴\n手脚冰凉时稀释涂抹脚底或将1滴加入热饮中\n晕车时,吸闻或滴在手心嗅吸\n祛除风寒可将 2 滴加入热水中泡脚\n痛经时,稀释涂抹于小腹并按摩\n做菜时可加入 1 滴帮助增添风味", "method": "🔹香薰 🔸内用 🔺涂抹(需稀释)", "caution": ""},
"柠檬草": {"emoji": "🍃", "en": "Lemongrass", "effects": "强效抗菌、抗炎\n驱虫、净化空气\n扩张血管,促进循环,缓解肌肉疼痛", "usage": "筋膜紧绷、腿麻或肌肉酸痛时稀释涂抹\n肩周炎时6 倍稀释后涂抹于肩颈部位并按摩\n做菜时加入 1 滴,增加泰式风味\n加入椰子油中制成家居喷雾,涂抹在裸露肌肤上驱蚊虫\n洗衣时加 3至5 滴祛味杀菌\n日常香薰平衡情绪", "method": "🔹香薰 🔸内用 🔺涂抹(需 6 倍稀释)", "caution": ""},
"柑橘清新": {"emoji": "🍬", "en": "Citrus Bliss", "effects": "提振精神,改善负面情绪\n净化空间\n降低压力", "usage": "日常香薰提升愉悦感,提振精神,净化空间\n拖地时加几滴清新空气\n加入到护手霜中,滋润手部肌肤,享受清新香气", "method": "🔹香薰 🔺涂抹", "caution": "含柑橘类,光敏注意白天涂抹"},
"芳香调理": {"emoji": "🤲", "en": "AromaTouch", "effects": "放松紧绷肌肉,放松关节\n促进血液循环\n促进淋巴排毒\n提升免疫\n舒缓放松,减少紧张", "usage": "稀释涂抹于太阳穴,缓解头痛,改善紧张情绪\n稀释涂抹于僵硬的身体部位如肩颈处并按摩,促进肌肉放松\n日常香薰或加入热水中泡澡,释放压力", "method": "🔹香薰 🔺涂抹", "caution": ""},
"西洋蓍草": {"emoji": "🔵", "en": "Yarrow | Pom", "effects": "改善肌肤老化症状\n美白肌肤,改善瑕疵\n呵护敏感肌肤,对抗炎症\n提升整体免疫", "usage": "早晚护肤时涂抹3至4滴于面部改善皱纹和细纹美白肌肤\n每天早晚舌下含服1滴促进细胞健康提升免疫", "method": "🔸内用 🔺涂抹", "caution": ""},
"新瑞活力": {"emoji": "🌿", "en": "MetaPWR", "effects": "促进新陈代谢,减肥\n抑制食欲,减少对甜食的渴望\n稳定血糖波动\n提振情绪,激励身心", "usage": "饭前喝1至2滴控制食欲稳定血糖提升代谢\n日常香薰可以帮助恢复能量,消除疲乏感\n稀释涂抹与身体需紧致的部位,帮助紧致塑形\n加入饮品中,帮助增添风味", "method": "🔹香薰 🔸内用 🔺涂抹(需稀释)", "caution": ""},
"安定情绪": {"emoji": "🌳", "en": "Balance", "effects": "促进全身的放松\n减轻焦虑,缓解紧张情绪\n带来宁静和安定感", "usage": "日常香薰稳定情绪,放松\n夜间香薰促进睡眠\n涂抹脚底或脊椎放松情绪,放松肌肉\n冥想、瑜伽前涂抹", "method": "🔹香薰 🔺涂抹", "caution": ""},
"安宁神气": {"emoji": "😴", "en": "Serenity", "effects": "促进深度睡眠\n放松身体,缓解焦虑\n平衡情绪\n平衡自律神经系统", "usage": "夜间香薰或稀释涂抹脚底促进深度睡眠,释放压力\n稀释涂抹太阳穴或脚底舒缓压力\n吸闻缓解焦虑和紧张情绪", "method": "🔹香薰 🔺涂抹", "caution": ""},
"元气焕能": {"emoji": "🔥", "en": "Zendocrine", "effects": "帮助身体净化,排毒\n维持肝脏和肾脏健康\n平衡情绪", "usage": "饭前内用1至2滴帮助代谢\n稀释涂抹肝区或内服3滴帮助养护肝脏\n稀释涂抹后腰脊椎出帮助养护肾脏,排除毒素\n日常香薰消除压力", "method": "🔹香薰 🔸内用 🔺涂抹", "caution": ""},
"温柔呵护": {"emoji": "🌸", "en": "Soft Talk", "effects": "平衡荷尔蒙\n抚平情绪波动\n调理经期不适\n舒缓压力\n提升女性魅力", "usage": "稀释涂抹下腹部帮助平衡荷尔蒙,或进行经期调理\n手心嗅吸帮助舒缓压力,平衡情绪\n2滴直接涂抹于脖颈后侧或手腕动脉处提升女性魅力", "method": "🔹香薰 🔺涂抹", "caution": ""},
"柠檬": {"emoji": "🍋", "en": "Lemon", "effects": "清洁身体与环境\n强健免疫系统\n帮助肝脏代谢、排毒\n抗氧化\n净化空气、去异味\n蔬果清洗、保鲜\n促进循环、提振精神", "usage": "添加至护肤品中晚上使用\n添加至牙膏里美白牙齿\n滴入口中或水里喝下一天三次每次3至5滴净化身体\n洗水果和蔬菜时添加 1至2 滴浸泡\n嗓子疼或感冒初期时含服柠檬1至2滴\n日常香薰提振情绪,护肝", "method": "🔹香薰 🔸内用 🔺涂抹(夜间)", "caution": "光敏性,白天避免涂抹"},
"薰衣草": {"emoji": "💜", "en": "Lavender", "effects": "镇静安神、改善睡眠、缓解头痛\n舒缓压力、平衡情绪、抗抑郁\n烧烫伤修复、疤痕、痘印\n促进伤口修复、止血\n促进细胞再生,修复结缔组织\n抗炎、抗过敏、止痛\n皮肤舒缓止痒,如蚊虫叮咬", "usage": "烧伤、烫伤、割伤及任何伤口处涂抹,止血防疤\n夜间香薰助眠,白天香薰舒缓情绪\n鱼刺卡嗓子时滴入口中\n加入护肤品中平衡油脂、改善痘痘、去疤痕", "method": "🔹香薰 🔸内用 🔺涂抹(儿童/敏感肌需稀释)", "caution": ""},
"椒样薄荷": {"emoji": "🌿", "en": "Peppermint", "effects": "促进健康的呼吸系统\n祛痰、抗粘膜发炎、打开呼吸道\n强肝利胆,促进消化\n退热、缓解中暑\n清凉止痒\n提神醒脑、提升专注、缓解头痛", "usage": "白天香薰提神醒脑,清新空气\n按摩头部缓解头疼、提神醒脑\n蚊虫叮咬后,涂抹止痒\n混入水中进行漱口,清新口气\n发烧时涂抹额头腋下帮助降温\n打嗝、咳嗽、鼻塞时吸闻\n消化不良时稀释涂抹于腹部或内用 2 滴", "method": "🔹香薰 🔸内用 🔺涂抹(儿童/敏感肌需稀释)", "caution": "孕期/高血压慎用,晚上少用"},
"茶树": {"emoji": "🌱", "en": "Tea Tree", "effects": "抗菌、抗病毒、抗真菌\n提升免疫力\n头皮屑护理\n预防化脓\n居家杀菌净化", "usage": "各种痤疮处点涂\n加入护肤品中,清洁皮肤\n洗头时加 1 滴到洗头膏,去头皮屑\n洗衣服时加入 3至5 滴,杀菌祛味\n脚气时用茶树泡脚\n感冒时涂抹,杀菌抗病毒", "method": "🔹香薰 🔸内用 🔺涂抹(儿童/敏感肌需稀释)", "caution": ""},
"西班牙牛至": {"emoji": "🔥", "en": "Oregano", "effects": "强抗菌、抗病毒、抗顽固性真菌\n成人炎症辅助\n促进消化\n强抗氧化、抗衰老\n免疫力提升", "usage": "洗衣服或拖地时加入 3至5 滴,消炎杀菌\n吃坏肚子时灌于胶囊中内用\n灰指甲时稀释涂抹于患处\n流感季节时香薰,杀灭空气中微生物", "method": "🔹香薰 🔸内用(胶囊) 🔺涂抹(需高倍稀释)", "caution": ""},
"保卫": {"emoji": "🛡", "en": "On Guard", "effects": "强化免疫力\n抗氧化\n天然杀菌、净化空气\n维护口腔健康", "usage": "日常香熏净化空气,强化免疫力\n流感季节或换季时香薰\n混入水中漱口,保持口气清新\n日常稀释涂抹于脊椎或脚底,强化免疫力\n感冒时涂抹,抗菌抗病毒", "method": "🔹香薰 🔸内用 🔺涂抹(儿童/敏感肌需稀释)", "caution": "含肉桂丁香,不宜频繁涂抹"},
"顺畅呼吸": {"emoji": "🌬", "en": "Breathe", "effects": "帮助缓解鼻炎、感冒等呼吸道不适\n促进呼吸系统健康\n净化空气", "usage": "日常香薰,强健呼吸系统,净化空气\n咳嗽、鼻塞时香薰、吸闻、涂抹于鼻翼、喉咙或肺部\n打鼾、哮喘、鼻炎可日常吸闻\n运动前吸闻,扩张呼吸道", "method": "🔹香薰 🔺涂抹(儿童/敏感肌需稀释)", "caution": ""},
"乐活": {"emoji": "🍃", "en": "DigestZen", "effects": "促进消化\n缓解胀气、消化不良、便秘等胃肠不适", "usage": "便秘时,稀释涂抹肚脐周围并顺时针揉腹\n喝酒前后各喝2滴解酒护肝\n晕车时吸闻或稀释涂抹肚脐周围\n拉肚子时逆时针揉腹", "method": "🔹熏香 🔸内用 🔺涂抹(儿童/敏感肌需稀释)", "caution": ""},
"舒缓": {"emoji": "🌿", "en": "Deep Blue", "effects": "缓解肌肉酸痛\n抗痉挛,抗炎", "usage": "肌肉酸痛、扭伤、挫伤、肩颈紧绷、落枕、关节疼痛时稀释涂抹于患处", "method": "🔺涂抹(需稀释)", "caution": ""},
"乳香": {"emoji": "👑", "en": "Frankincense", "effects": "促进伤口愈合,促进细胞健康\n抗氧化、抗发炎、抗衰老、淡纹\n活血行气\n疏通血管\n滋养大脑神经", "usage": "加入护肤品中,淡斑,抗衰\n稀释后涂抹大眼眶,改善视力\n早晚舌下含服 2 滴,提高血氧含量\n夜间香薰,滋养大脑,安眠\n任何情况下,想不起来用什么就用乳香", "method": "🔹香薰 🔸内用 🔺涂抹", "caution": ""},
}
def title_case(s: str) -> str:
"""Convert to title case: 'pain relief''Pain Relief'"""
@@ -88,6 +114,12 @@ class OilIn(BaseModel):
en_name: Optional[str] = None
is_active: Optional[int] = None
unit: Optional[str] = None
# Oil card fields (optional, saved to oil_cards table)
card_emoji: Optional[str] = None
card_effects: Optional[str] = None
card_usage: Optional[str] = None
card_method: Optional[str] = None
card_caution: Optional[str] = None
class IngredientIn(BaseModel):
@@ -732,6 +764,22 @@ def upsert_oil(oil: OilIn, user=Depends(require_role("admin", "senior_editor")))
"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),
)
# Upsert oil_cards if any card field provided
has_card = any(v is not None for v in [oil.card_emoji, oil.card_effects, oil.card_usage, oil.card_method, oil.card_caution])
if has_card:
conn.execute(
"INSERT INTO oil_cards (name, emoji, en, effects, usage, method, caution) "
"VALUES (?, ?, COALESCE(?, ''), ?, ?, ?, ?) "
"ON CONFLICT(name) DO UPDATE SET "
"emoji=COALESCE(excluded.emoji, oil_cards.emoji), "
"en=COALESCE(excluded.en, oil_cards.en), "
"effects=COALESCE(excluded.effects, oil_cards.effects), "
"usage=COALESCE(excluded.usage, oil_cards.usage), "
"method=COALESCE(excluded.method, oil_cards.method), "
"caution=COALESCE(excluded.caution, oil_cards.caution)",
(oil.name, oil.card_emoji or '', title_case(oil.en_name) if oil.en_name else '',
oil.card_effects or '', oil.card_usage or '', oil.card_method or '', oil.card_caution or ''),
)
log_audit(conn, user["id"], "upsert_oil", "oil", oil.name, oil.name,
json.dumps({"bottle_price": oil.bottle_price, "drop_count": oil.drop_count}))
conn.commit()
@@ -752,6 +800,25 @@ def delete_oil(name: str, user=Depends(require_role("admin", "senior_editor"))):
return {"ok": True}
# ── Oil Cards ──────────────────────────────────────────
@app.get("/api/oil-cards")
def list_oil_cards():
conn = get_db()
rows = conn.execute("SELECT name, emoji, en, effects, usage, method, caution FROM oil_cards ORDER BY name").fetchall()
conn.close()
return [dict(r) for r in rows]
@app.get("/api/oil-cards/{name}")
def get_oil_card(name: str):
conn = get_db()
row = conn.execute("SELECT name, emoji, en, effects, usage, method, caution FROM oil_cards WHERE name = ?", (name,)).fetchone()
conn.close()
if not row:
raise HTTPException(404, "Oil card not found")
return dict(row)
# ── Recipes ─────────────────────────────────────────────
def _recipe_to_dict(conn, row):
rid = row["id"]
@@ -887,6 +954,11 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current
conn.close()
raise HTTPException(409, "此配方已被其他人修改,请刷新后重试")
# Snapshot before state for re-review diff notification
before_row = c.execute("SELECT name, note, en_name, volume FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
before_ings = [dict(r) for r in c.execute("SELECT oil_name, drops FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,)).fetchall()]
before_tags = set(r["tag_name"] for r in c.execute("SELECT tag_name FROM recipe_tags WHERE recipe_id = ?", (recipe_id,)).fetchall())
if update.name is not None:
c.execute("UPDATE recipes SET name = ? WHERE id = ?", (update.name, recipe_id))
# Re-translate en_name if name changed and no explicit en_name provided
@@ -925,6 +997,35 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current
log_audit(conn, user["id"], "update_recipe", "recipe", recipe_id,
rname["name"] if rname else update.name,
json.dumps({"changed": "".join(changed)}, ensure_ascii=False) if changed else None)
# Notify admin when non-admin user edits a recipe tagged 再次审核
after_tags = before_tags if update.tags is None else set(update.tags)
needs_review = "再次审核" in (before_tags | after_tags)
if user.get("role") != "admin" and needs_review and changed:
after_row = c.execute("SELECT name, note, en_name, volume FROM recipes WHERE id = ?", (recipe_id,)).fetchone()
after_ings = [dict(r) for r in c.execute("SELECT oil_name, drops FROM recipe_ingredients WHERE recipe_id = ?", (recipe_id,)).fetchall()]
diff_lines = []
def _fmt_ings(ings):
return "".join(f"{i['oil_name']} {i['drops']}" for i in ings) or "(空)"
if update.name is not None and before_row["name"] != after_row["name"]:
diff_lines.append(f"名称:{before_row['name']}{after_row['name']}")
if update.ingredients is not None and before_ings != after_ings:
diff_lines.append(f"成分:{_fmt_ings(before_ings)}{_fmt_ings(after_ings)}")
if update.tags is not None and before_tags != after_tags:
diff_lines.append(f"标签:{''.join(sorted(before_tags)) or '(空)'}{''.join(sorted(after_tags)) or '(空)'}")
if update.note is not None and (before_row["note"] or "") != (after_row["note"] or ""):
diff_lines.append(f"备注:{before_row['note'] or '(空)'}{after_row['note'] or '(空)'}")
if update.en_name is not None and (before_row["en_name"] or "") != (after_row["en_name"] or ""):
diff_lines.append(f"英文名:{before_row['en_name'] or '(空)'}{after_row['en_name'] or '(空)'}")
if diff_lines:
editor = user.get("display_name") or user.get("username") or f"user#{user['id']}"
title = f"📝 再次审核配方被修改:{after_row['name']}"
body = f"{editor} 修改了配方「{after_row['name']}」:\n\n" + "\n".join(diff_lines)
conn.execute(
"INSERT INTO notifications (target_role, title, body) VALUES (?, ?, ?)",
("admin", title, body),
)
conn.commit()
conn.close()
return {"ok": True}
@@ -1897,7 +1998,7 @@ def startup():
if os.path.exists(defaults_path):
with open(defaults_path) as f:
data = json.load(f)
seed_defaults(data["oils_meta"], data["recipes"])
seed_defaults(data["oils_meta"], data["recipes"], DEFAULT_OIL_CARDS)
# One-time migration: sync display_name = username, notify about username change
conn = get_db()

View File

@@ -0,0 +1,92 @@
// Verifies that when a non-admin edits a recipe tagged 再次审核,
// an admin-targeted notification is created containing a before/after diff.
describe('Re-review notification on non-admin edit', () => {
let adminToken
let viewerToken
let recipeId
before(() => {
cy.getAdminToken().then(t => {
adminToken = t
const uname = 'editor_' + Date.now()
cy.request({
method: 'POST', url: '/api/register',
body: { username: uname, password: 'pw12345678' }
}).then(res => {
viewerToken = res.body.token
// Look up user id via admin /api/users, then promote to editor
cy.request({ url: '/api/users', headers: { Authorization: `Bearer ${adminToken}` } })
.then(r => {
const u = r.body.find(x => x.username === uname)
cy.request({
method: 'PUT', url: `/api/users/${u.id}`,
headers: { Authorization: `Bearer ${adminToken}` },
body: { role: 'editor' },
})
})
})
})
})
it('creates admin notification with diff lines', () => {
// Editor creates their own recipe tagged 再次审核
cy.request({
method: 'POST', url: '/api/recipes',
headers: { Authorization: `Bearer ${viewerToken}` },
body: {
name: 're-review-fixture-' + Date.now(),
ingredients: [{ oil_name: '薰衣草', drops: 3 }],
tags: ['再次审核'],
},
}).then(res => {
recipeId = res.body.id
expect(recipeId).to.be.a('number')
})
// Mark notifications read so we can detect the new one
cy.request({
method: 'POST', url: '/api/notifications/read-all',
headers: { Authorization: `Bearer ${adminToken}` }, body: {},
})
// Non-admin edits the recipe
cy.then(() => {
cy.request({
method: 'PUT', url: `/api/recipes/${recipeId}`,
headers: { Authorization: `Bearer ${viewerToken}` },
body: {
ingredients: [{ oil_name: '薰衣草', drops: 5 }, { oil_name: '柠檬', drops: 2 }],
note: '新备注',
},
}).then(r => expect(r.status).to.eq(200))
})
// Admin sees a new unread notification mentioning the recipe and diff
cy.then(() => {
cy.request({ url: '/api/notifications', headers: { Authorization: `Bearer ${adminToken}` } })
.then(res => {
const unread = res.body.filter(n => !n.is_read && n.title && n.title.includes('再次审核配方被修改'))
expect(unread.length).to.be.greaterThan(0)
expect(unread[0].body).to.match(/成分|备注/)
expect(unread[0].body).to.contain('→')
})
})
})
it('admin edits do NOT create re-review notification', () => {
cy.request({
method: 'POST', url: '/api/notifications/read-all',
headers: { Authorization: `Bearer ${adminToken}` }, body: {},
})
cy.request({
method: 'PUT', url: `/api/recipes/${recipeId}`,
headers: { Authorization: `Bearer ${adminToken}` },
body: { note: '管理员备注' },
})
cy.request({ url: '/api/notifications', headers: { Authorization: `Bearer ${adminToken}` } })
.then(res => {
const unread = res.body.filter(n => !n.is_read && n.title && n.title.includes('再次审核配方被修改'))
expect(unread.length).to.eq(0)
})
})
})

View File

@@ -26,6 +26,14 @@ describe('Recipe Search', () => {
})
})
it('searching by oil name returns recipes containing that oil', () => {
cy.get('input[placeholder*="搜索"]').type('薰衣草')
cy.wait(500)
cy.get('.search-results-section, .recipe-card', { timeout: 5000 }).should('exist')
// At least one result card should exist (any recipe using 薰衣草)
cy.get('.recipe-card').should('have.length.gte', 1)
})
it('clears search and restores all recipes', () => {
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
cy.get('input[placeholder*="搜索"]').type('薰衣草')

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest'
// Replicates the fixed fmtCostWithRetail logic: retail shown whenever any ingredient
// has a retail price stored (even when it equals the member price).
function fmtCostWithRetail(ingredients, oilsMeta) {
const cost = ingredients.reduce((s, i) => {
const m = oilsMeta[i.oil]
return s + (m ? (m.bottlePrice / m.dropCount) * i.drops : 0)
}, 0)
const retail = ingredients.reduce((s, i) => {
const m = oilsMeta[i.oil]
if (m && m.retailPrice && m.dropCount) return s + (m.retailPrice / m.dropCount) * i.drops
return s + (m ? (m.bottlePrice / m.dropCount) * i.drops : 0)
}, 0)
const anyRetail = ingredients.some(i => {
const m = oilsMeta[i.oil]
return m && m.retailPrice && m.dropCount
})
if (anyRetail && retail > 0) {
return { cost: '¥ ' + cost.toFixed(2), retail: '¥ ' + retail.toFixed(2), hasRetail: true }
}
return { cost: '¥ ' + cost.toFixed(2), retail: null, hasRetail: false }
}
describe('fmtCostWithRetail — retail price display', () => {
it('shows retail when retail > member', () => {
const meta = { '玫瑰': { bottlePrice: 100, retailPrice: 150, dropCount: 10 } }
const r = fmtCostWithRetail([{ oil: '玫瑰', drops: 5 }], meta)
expect(r.hasRetail).toBe(true)
expect(r.retail).toBe('¥ 75.00')
})
it('still shows retail when retail === member (regression: 带玫瑰护手霜 case)', () => {
const meta = { '玫瑰护手霜': { bottlePrice: 300, retailPrice: 300, dropCount: 50 } }
const r = fmtCostWithRetail([{ oil: '玫瑰护手霜', drops: 5 }], meta)
expect(r.hasRetail).toBe(true)
expect(r.cost).toBe('¥ 30.00')
expect(r.retail).toBe('¥ 30.00')
})
it('no retail when ingredient has no retail price', () => {
const meta = { '薰衣草': { bottlePrice: 100, retailPrice: null, dropCount: 10 } }
const r = fmtCostWithRetail([{ oil: '薰衣草', drops: 5 }], meta)
expect(r.hasRetail).toBe(false)
expect(r.retail).toBeNull()
})
})
// getEnglishName priority fix — DB en_name must beat static card override.
function getEnglishName(name, oilsMeta, cards, aliases, oilEnFn) {
const meta = oilsMeta[name]
if (meta?.enName) return meta.enName
if (cards[name]?.en) return cards[name].en
if (aliases[name] && cards[aliases[name]]?.en) return cards[aliases[name]].en
const base = name.replace(/呵护$/, '')
if (base !== name && cards[base]?.en) return cards[base].en
return oilEnFn ? oilEnFn(name) : ''
}
describe('getEnglishName — DB wins over static card', () => {
const cards = {
'温柔呵护': { en: 'Soft Talk' },
'椒样薄荷': { en: 'Peppermint' },
'西班牙牛至': { en: 'Oregano' },
}
const aliases = { '仕女呵护': '温柔呵护', '薄荷呵护': '椒样薄荷', '牛至呵护': '西班牙牛至' }
it('uses DB en_name over static card en (温柔呵护 regression)', () => {
const meta = { '温柔呵护': { enName: 'Clary Calm' } }
expect(getEnglishName('温柔呵护', meta, cards, aliases)).toBe('Clary Calm')
})
it('uses DB en_name over aliased card en (仕女呵护 regression)', () => {
const meta = { '仕女呵护': { enName: 'Soft Talk Touch' } }
expect(getEnglishName('仕女呵护', meta, cards, aliases)).toBe('Soft Talk Touch')
})
it('falls back to static card when DB en_name is empty', () => {
const meta = { '温柔呵护': { enName: '' } }
expect(getEnglishName('温柔呵护', meta, cards, aliases)).toBe('Soft Talk')
})
it('alias still works as fallback', () => {
const meta = { '牛至呵护': {} }
expect(getEnglishName('牛至呵护', meta, cards, aliases)).toBe('Oregano')
})
})

View File

@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
// Mirrors the exactResults matching rule in RecipeSearch.vue
function matches(recipes, q, oilEn = (s) => '') {
const query = (q || '').trim().toLowerCase()
if (!query) return []
const isEn = /^[a-zA-Z\s]+$/.test(query)
return recipes.filter(r => {
if (r.tags && r.tags.includes('已下架')) return false
const nameMatch = r.name.toLowerCase().includes(query)
const enNameMatch = isEn && (r.en_name || '').toLowerCase().includes(query)
const oilEnMatch = isEn && (r.ingredients || []).some(ing => (oilEn(ing.oil) || '').toLowerCase().includes(query))
const oilZhMatch = query.length >= 2 && (r.ingredients || []).some(ing => ing.oil.toLowerCase().includes(query))
const tagMatch = (r.tags || []).some(t => t.toLowerCase().includes(query))
return nameMatch || enNameMatch || oilEnMatch || oilZhMatch || tagMatch
})
}
const recipes = [
{ name: '助眠晚安', tags: [], ingredients: [{ oil: '薰衣草', drops: 3 }, { oil: '乳香', drops: 2 }] },
{ name: '提神醒脑', tags: [], ingredients: [{ oil: '椒样薄荷', drops: 2 }, { oil: '柠檬', drops: 3 }] },
{ name: '肩颈舒缓', tags: ['舒缓'], ingredients: [{ oil: '西班牙牛至', drops: 1 }, { oil: '椰子油', drops: 10 }] },
{ name: '感冒护理', tags: [], ingredients: [{ oil: '牛至呵护', drops: 2 }] },
{ name: '下架配方', tags: ['已下架'], ingredients: [{ oil: '薰衣草', drops: 1 }] },
]
describe('Recipe search by oil name', () => {
it('finds recipes containing the oil (Chinese exact)', () => {
const r = matches(recipes, '薰衣草')
expect(r.map(x => x.name)).toEqual(['助眠晚安'])
})
it('finds multiple recipes for a common oil', () => {
expect(matches(recipes, '牛至').map(x => x.name).sort()).toEqual(['感冒护理', '肩颈舒缓'])
})
it('excludes 已下架 recipes', () => {
const r = matches(recipes, '薰衣草')
expect(r.some(x => x.name === '下架配方')).toBe(false)
})
it('single-char query does not match oil names (avoids noise)', () => {
const r = matches(recipes, '草')
expect(r).toEqual([])
})
it('still matches recipe name for short queries', () => {
const r = matches(recipes, '晚')
expect(r.map(x => x.name)).toEqual(['助眠晚安'])
})
it('matches english oil name when query is english', () => {
const oilEn = (o) => ({ '薰衣草': 'Lavender', '乳香': 'Frankincense' }[o] || '')
const r = matches(recipes, 'Lavender', oilEn)
expect(r.map(x => x.name)).toEqual(['助眠晚安'])
})
})

View File

@@ -66,7 +66,7 @@
<span class="ec-oil-name">{{ getCardOilName(ing.oil) }}</span>
<span class="ec-drops">{{ ing.drops }} {{ oilsStore.unitLabelPlural(ing.oil, ing.drops, cardLang) }}</span>
<span class="ec-cost">{{ oilsStore.fmtPrice(oilsStore.pricePerDrop(ing.oil) * ing.drops) }}</span>
<span v-if="cardHasAnyRetail" class="ec-retail">{{ hasRetailForOil(ing.oil) && retailPerDrop(ing.oil) > oilsStore.pricePerDrop(ing.oil) ? oilsStore.fmtPrice(retailPerDrop(ing.oil) * ing.drops) : '' }}</span>
<span v-if="cardHasAnyRetail" class="ec-retail">{{ hasRetailForOil(ing.oil) ? oilsStore.fmtPrice(retailPerDrop(ing.oil) * ing.drops) : '' }}</span>
</li>
</ul>
@@ -591,7 +591,7 @@ function getCardRecipeName() {
}
const cardHasAnyRetail = computed(() =>
cardIngredients.value.some(ing => hasRetailForOil(ing.oil) && retailPerDrop(ing.oil) > oilsStore.pricePerDrop(ing.oil))
cardIngredients.value.some(ing => hasRetailForOil(ing.oil))
)
const cardTitleSize = computed(() => {
@@ -1699,8 +1699,8 @@ async function saveRecipe() {
}
.editor-drops {
width: 42px;
padding: 5px 2px;
width: 58px;
padding: 5px 4px 5px 6px;
border: 1.5px solid var(--border, #e0d4c0);
border-radius: 8px;
font-size: 13px;

View File

@@ -1,5 +1,6 @@
// Oil knowledge cards - usage guides for common essential oils
// Ported from original vanilla JS implementation
import { useOilsStore } from '../stores/oils'
export const OIL_CARDS = {
'野橘': { emoji: '🍊', en: 'Wild Orange', effects: '安抚镇静、驱散负能量、紧张情绪和压力\n抗氧化、消炎、抗病毒、增强免疫\n提振食欲刺激胆汁分泌促进消化\n促进循环', usage: '日常香薰,提振情绪、舒压,营造愉悦氛围\n饮水加入 1至2 滴,护肝抗病毒\n泡澡时滴 3至4 滴,消除疲劳,放松身心\n扫地、拖地时加入 3至5 滴,清新空气\n涂抹腹部促进消化\n涂抹肝区帮助肝脏排毒\n口腔溃疡时加入水中漱口', method: '🔹香薰 🔸内用 🔺涂抹', caution: '轻微光敏,白天涂抹注意防晒' },
@@ -33,6 +34,17 @@ export const OIL_CARD_ALIAS = {
}
export function getOilCard(name) {
// Check DB-persisted cards first (via store)
try {
const store = useOilsStore()
if (store.oilCards[name]) return store.oilCards[name]
// Check alias in store too
const aliased = OIL_CARD_ALIAS[name]
if (aliased && store.oilCards[aliased]) return store.oilCards[aliased]
} catch {
// Store may not be initialized yet, fall through to static data
}
// Fall back to static OIL_CARDS
if (OIL_CARDS[name]) return OIL_CARDS[name]
if (OIL_CARD_ALIAS[name] && OIL_CARDS[OIL_CARD_ALIAS[name]]) return OIL_CARDS[OIL_CARD_ALIAS[name]]
const base = name.replace(/呵护$/, '')

View File

@@ -16,6 +16,7 @@ export const VOLUME_DROPS = {
export const useOilsStore = defineStore('oils', () => {
const oils = ref({})
const oilsMeta = ref({})
const oilCards = ref({})
// Getters
const oilNames = computed(() =>
@@ -50,7 +51,11 @@ export const useOilsStore = defineStore('oils', () => {
const cost = calcCost(ingredients)
const retail = calcRetailCost(ingredients)
const costStr = fmtPrice(cost)
if (retail > cost) {
const anyRetail = ingredients.some(i => {
const m = oilsMeta.value[i.oil]
return m && m.retailPrice && m.dropCount
})
if (anyRetail && retail > 0) {
return { cost: costStr, retail: fmtPrice(retail), hasRetail: true }
}
return { cost: costStr, retail: null, hasRetail: false }
@@ -75,9 +80,28 @@ export const useOilsStore = defineStore('oils', () => {
}
oils.value = newOils
oilsMeta.value = newMeta
// Also fetch oil cards from DB
try {
const cards = await api.get('/api/oil-cards')
const newCards = {}
for (const card of cards) {
newCards[card.name] = {
emoji: card.emoji || '',
en: card.en || '',
effects: card.effects || '',
usage: card.usage || '',
method: card.method || '',
caution: card.caution || '',
}
}
oilCards.value = newCards
} catch {
// oil_cards table may not exist yet on older backends
}
}
async function saveOil(name, bottlePrice, dropCount, retailPrice, enName = null, unit = null) {
async function saveOil(name, bottlePrice, dropCount, retailPrice, enName = null, unit = null, card = null) {
const payload = {
name,
bottle_price: bottlePrice,
@@ -86,6 +110,13 @@ export const useOilsStore = defineStore('oils', () => {
en_name: enName,
}
if (unit) payload.unit = unit
if (card) {
payload.card_emoji = card.emoji ?? null
payload.card_effects = card.effects ?? null
payload.card_usage = card.usage ?? null
payload.card_method = card.method ?? null
payload.card_caution = card.caution ?? null
}
await api.post('/api/oils', payload)
await loadOils()
}
@@ -134,6 +165,7 @@ export const useOilsStore = defineStore('oils', () => {
return {
oils,
oilsMeta,
oilCards,
oilNames,
pricePerDrop,
calcCost,

View File

@@ -442,7 +442,7 @@ import { useAuthStore } from '../stores/auth'
import { useUiStore } from '../stores/ui'
import { useRecipesStore } from '../stores/recipes'
import { oilEn } from '../composables/useOilTranslation'
import { getOilCard, setOilCard } from '../composables/useOilCards'
import { getOilCard } from '../composables/useOilCards'
import { showConfirm } from '../composables/useDialog'
import { api } from '../composables/useApi'
import { parseOilProductPaste } from '../composables/useOilProductPaste'
@@ -670,12 +670,12 @@ function getMeta(name) {
}
function getEnglishName(name) {
// 1. Oil card has priority
const card = getOilCard(name)
if (card && card.en) return card.en
// 2. Stored en_name in meta
// 1. User-edited en_name in DB wins — prevents saves being masked by static cards
const meta = oils.oilsMeta[name]
if (meta?.enName) return meta.enName
// 2. Oil card fallback
const card = getOilCard(name)
if (card && card.en) return card.en
// 3. Static translation map
return oilEn(name)
}
@@ -816,26 +816,24 @@ async function saveEditOil() {
}
const finalDropCount = editUnit.value !== 'drop' ? editProductAmount.value : dropCount
const finalUnit = editUnit.value !== 'drop' ? editProductUnit.value : null
// Build card payload if any card content provided
const hasCard = editCardEffects.value.trim() || editCardUsage.value.trim()
const cardPayload = hasCard ? {
emoji: editCardEmoji.value || '🌿',
effects: editCardEffects.value.trim(),
usage: editCardUsage.value.trim(),
method: editCardMethod.value.trim(),
caution: editCardCaution.value.trim(),
} : null
await oils.saveOil(
newName || oldName,
editBottlePrice.value,
finalDropCount,
editRetailPrice.value,
editOilEnName.value.trim() || null,
finalUnit
finalUnit,
cardPayload
)
// Save knowledge card if any content provided
const finalName = newName || oldName
if (editCardEffects.value.trim() || editCardUsage.value.trim()) {
setOilCard(finalName, {
emoji: editCardEmoji.value || '🌿',
en: editOilEnName.value.trim() || '',
effects: editCardEffects.value.trim(),
usage: editCardUsage.value.trim(),
method: editCardMethod.value.trim(),
caution: editCardCaution.value.trim(),
})
}
cardVersion.value++ // trigger re-render for card badges
ui.showToast('已更新')
editingOilName.value = null
@@ -902,10 +900,11 @@ async function exportExcel() {
for (const name of sortedNames) {
const meta = getMeta(name)
if (!meta) continue
const en = getEnglishName(name)
const en = meta.enName || getEnglishName(name)
const vol = volumeLabel(meta.dropCount, name)
const unit = oilPriceUnit(name)
const ppdNum = oils.pricePerDrop(name)
const card = getOilCard(name)
rows.push({
'精油': name,
'英文名': en,
@@ -913,12 +912,13 @@ async function exportExcel() {
'零售价': meta.retailPrice != null ? Number(meta.retailPrice.toFixed(2)) : '',
'容量': vol,
'单价': ppdNum ? `¥${ppdNum.toFixed(2)}/${unit}` : '',
'功效': card?.effects || '',
'状态': meta.isActive === false ? '下架' : '在售',
})
}
const ws = XLSX.utils.json_to_sheet(rows)
ws['!cols'] = [{ wch: 16 }, { wch: 28 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 16 }, { wch: 8 }]
ws['!cols'] = [{ wch: 16 }, { wch: 28 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 16 }, { wch: 40 }, { wch: 8 }]
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, '精油价目表')
XLSX.writeFile(wb, `精油价目表${dateStr}.xlsx`)

View File

@@ -2114,7 +2114,7 @@ watch(() => recipeStore.recipes, () => {
.editor-table th { text-align: center; padding: 6px 4px; color: #999; font-weight: 500; font-size: 12px; border-bottom: 1px solid #eee; }
.editor-table th:first-child { text-align: left; }
.editor-table td { padding: 6px 4px; border-bottom: 1px solid #f5f5f5; }
.editor-drops { width: 42px; padding: 5px 2px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; text-align: center; outline: none; font-family: inherit; }
.editor-drops { width: 58px; padding: 5px 4px 5px 6px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; text-align: center; outline: none; font-family: inherit; }
.editor-drops:focus { border-color: #7ec6a4; }
.drops-with-unit { display: flex; align-items: center; gap: 2px; }
.unit-hint { font-size: 11px; color: #b0aab5; white-space: nowrap; }

View File

@@ -312,7 +312,7 @@ function expandQuery(q) {
return terms
}
// Search results: exact matches (query in recipe name or tags, NOT oil names to avoid noise like 西班牙牛至)
// Search results: matches in recipe name, tags, oil names (zh + en)
const exactResults = computed(() => {
if (!searchQuery.value.trim()) return []
const q = searchQuery.value.trim().toLowerCase()
@@ -322,9 +322,10 @@ const exactResults = computed(() => {
const nameMatch = r.name.toLowerCase().includes(q)
const enNameMatch = isEn && (r.en_name || '').toLowerCase().includes(q)
const oilEnMatch = isEn && r.ingredients.some(ing => (oilEn(ing.oil) || '').toLowerCase().includes(q))
const oilZhMatch = q.length >= 2 && r.ingredients.some(ing => ing.oil.toLowerCase().includes(q))
const visibleTags = auth.canEdit ? (r.tags || []) : (r.tags || []).filter(t => !EDITOR_ONLY_TAGS.includes(t))
const tagMatch = visibleTags.some(t => t.toLowerCase().includes(q))
return nameMatch || enNameMatch || oilEnMatch || tagMatch
return nameMatch || enNameMatch || oilEnMatch || oilZhMatch || tagMatch
}).sort((a, b) => a.name.localeCompare(b.name, 'zh'))
})