Compare commits
35 Commits
feat/kit-c
...
fix/oil-ca
| Author | SHA1 | Date | |
|---|---|---|---|
| e8af6e2565 | |||
| c04bb53ddd | |||
| 8f004a02cd | |||
| 50751ed9be | |||
| f34dd49dcb | |||
| ed8d49d9a0 | |||
| bf29551a31 | |||
| a8c9c2252f | |||
| 6baecfc2bf | |||
| a0de8fa7f3 | |||
| 0ce14352f1 | |||
| 2dca4d13b9 | |||
| a28ba1ef57 | |||
| fef28330f0 | |||
| 27418695a5 | |||
| 1053cf9140 | |||
| 1613b54bc6 | |||
| 9fc89cdb74 | |||
| cca7dd4471 | |||
| 7fbf5586b5 | |||
| f8d368a03a | |||
| 317ea3a2b6 | |||
| 18a74df083 | |||
| 2330ce1f2c | |||
| c9af05219b | |||
| e30891d3d2 | |||
| 9e11270fbf | |||
| cccf0091ba | |||
| 5ebffb8da4 | |||
| 3953218e41 | |||
| c7d86b909a | |||
| 06b29e6446 | |||
| 3043d4d6c4 | |||
| 9ba0f6e9b5 | |||
| 8a49938929 |
@@ -12,7 +12,7 @@ jobs:
|
|||||||
e2e-test:
|
e2e-test:
|
||||||
runs-on: test
|
runs-on: test
|
||||||
needs: unit-test
|
needs: unit-test
|
||||||
timeout-minutes: 8
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -62,23 +62,37 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run all specs except demo-walkthrough (too slow for CI)
|
# Run all specs in 3 batches to avoid Electron memory crashes
|
||||||
cd frontend
|
cd frontend
|
||||||
timeout 420 npx cypress run \
|
CYPRESS_CFG="video=false,defaultCommandTimeout=5000,pageLoadTimeout=10000,requestTimeout=5000,responseTimeout=10000,baseUrl=http://localhost:$FE_PORT,experimentalMemoryManagement=true,numTestsKeptInMemory=0"
|
||||||
--spec "cypress/e2e/!(demo-walkthrough).cy.js" \
|
|
||||||
--config "video=false,defaultCommandTimeout=5000,pageLoadTimeout=10000,requestTimeout=5000,responseTimeout=10000,baseUrl=http://localhost:$FE_PORT,experimentalMemoryManagement=true,numTestsKeptInMemory=0" \
|
echo "=== Batch 1: API & data tests ==="
|
||||||
--env "ADMIN_TOKEN=$ADMIN_TOKEN"
|
timeout 300 npx cypress run \
|
||||||
EXIT_CODE=$?
|
--spec "cypress/e2e/api-crud.cy.js,cypress/e2e/api-health.cy.js,cypress/e2e/oil-data-integrity.cy.js,cypress/e2e/recipe-cost-parity.cy.js,cypress/e2e/endpoint-parity.cy.js,cypress/e2e/registration-flow.cy.js,cypress/e2e/pr27-features.cy.js,cypress/e2e/kit-export.cy.js" \
|
||||||
|
--config "$CYPRESS_CFG" --env "ADMIN_TOKEN=$ADMIN_TOKEN"
|
||||||
|
B1=$?
|
||||||
|
|
||||||
|
echo "=== Batch 2: UI flow tests ==="
|
||||||
|
timeout 300 npx cypress run \
|
||||||
|
--spec "cypress/e2e/auth-flow.cy.js,cypress/e2e/admin-flow.cy.js,cypress/e2e/navigation.cy.js,cypress/e2e/recipe-detail.cy.js,cypress/e2e/recipe-search.cy.js,cypress/e2e/manage-recipes.cy.js,cypress/e2e/diary-flow.cy.js,cypress/e2e/favorites.cy.js,cypress/e2e/inventory-flow.cy.js,cypress/e2e/demo-walkthrough.cy.js" \
|
||||||
|
--config "$CYPRESS_CFG" --env "ADMIN_TOKEN=$ADMIN_TOKEN"
|
||||||
|
B2=$?
|
||||||
|
|
||||||
|
echo "=== Batch 3: Remaining tests ==="
|
||||||
|
timeout 300 npx cypress run \
|
||||||
|
--spec "cypress/e2e/app-load.cy.js,cypress/e2e/account-settings.cy.js,cypress/e2e/audit-log-advanced.cy.js,cypress/e2e/batch-operations.cy.js,cypress/e2e/bug-tracker-flow.cy.js,cypress/e2e/category-modules.cy.js,cypress/e2e/notification-flow.cy.js,cypress/e2e/oil-reference.cy.js,cypress/e2e/oil-smart-paste.cy.js,cypress/e2e/performance.cy.js,cypress/e2e/price-display.cy.js,cypress/e2e/projects-flow.cy.js,cypress/e2e/responsive.cy.js,cypress/e2e/search-advanced.cy.js,cypress/e2e/user-management-flow.cy.js,cypress/e2e/visual-check.cy.js" \
|
||||||
|
--config "$CYPRESS_CFG" --env "ADMIN_TOKEN=$ADMIN_TOKEN"
|
||||||
|
B3=$?
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
kill $BE_PID $FE_PID 2>/dev/null
|
kill $BE_PID $FE_PID 2>/dev/null
|
||||||
pkill -f "Cypress" 2>/dev/null || true
|
pkill -f "Cypress" 2>/dev/null || true
|
||||||
rm -f "$DB_FILE"
|
rm -f "$DB_FILE"
|
||||||
if [ $EXIT_CODE -eq 124 ]; then
|
|
||||||
echo "ERROR: Cypress timed out after 7 minutes"
|
echo "Results: Batch1=$B1 Batch2=$B2 Batch3=$B3"
|
||||||
|
if [ $B1 -ne 0 ] || [ $B2 -ne 0 ] || [ $B3 -ne 0 ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
exit $EXIT_CODE
|
|
||||||
|
|
||||||
build-check:
|
build-check:
|
||||||
runs-on: test
|
runs-on: test
|
||||||
|
|||||||
@@ -123,6 +123,15 @@ def init_db():
|
|||||||
tag_name TEXT NOT NULL,
|
tag_name TEXT NOT NULL,
|
||||||
sort_order INTEGER DEFAULT 0
|
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
|
# 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."""
|
"""Seed DB with defaults if empty."""
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
c = conn.cursor()
|
c = conn.cursor()
|
||||||
@@ -379,5 +388,18 @@ def seed_defaults(default_oils_meta: dict, default_recipes: list):
|
|||||||
(rid, tag),
|
(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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
103
backend/main.py
103
backend/main.py
@@ -12,6 +12,32 @@ import secrets as _secrets
|
|||||||
|
|
||||||
app = FastAPI(title="Essential Oil Formula Calculator API")
|
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:
|
def title_case(s: str) -> str:
|
||||||
"""Convert to title case: 'pain relief' → 'Pain Relief'"""
|
"""Convert to title case: 'pain relief' → 'Pain Relief'"""
|
||||||
@@ -88,6 +114,12 @@ class OilIn(BaseModel):
|
|||||||
en_name: Optional[str] = None
|
en_name: Optional[str] = None
|
||||||
is_active: Optional[int] = None
|
is_active: Optional[int] = None
|
||||||
unit: Optional[str] = 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):
|
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)",
|
"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),
|
(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,
|
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}))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -752,6 +800,25 @@ def delete_oil(name: str, user=Depends(require_role("admin", "senior_editor"))):
|
|||||||
return {"ok": True}
|
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 ─────────────────────────────────────────────
|
# ── Recipes ─────────────────────────────────────────────
|
||||||
def _recipe_to_dict(conn, row):
|
def _recipe_to_dict(conn, row):
|
||||||
rid = row["id"]
|
rid = row["id"]
|
||||||
@@ -887,6 +954,11 @@ def update_recipe(recipe_id: int, update: RecipeUpdate, user=Depends(get_current
|
|||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(409, "此配方已被其他人修改,请刷新后重试")
|
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:
|
if update.name is not None:
|
||||||
c.execute("UPDATE recipes SET name = ? WHERE id = ?", (update.name, recipe_id))
|
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
|
# 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,
|
log_audit(conn, user["id"], "update_recipe", "recipe", recipe_id,
|
||||||
rname["name"] if rname else update.name,
|
rname["name"] if rname else update.name,
|
||||||
json.dumps({"changed": "、".join(changed)}, ensure_ascii=False) if changed else None)
|
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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -1897,7 +1998,7 @@ def startup():
|
|||||||
if os.path.exists(defaults_path):
|
if os.path.exists(defaults_path):
|
||||||
with open(defaults_path) as f:
|
with open(defaults_path) as f:
|
||||||
data = json.load(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
|
# One-time migration: sync display_name = username, notify about username change
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ spec:
|
|||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
|
set -e
|
||||||
BACKUP_DIR=/data/backups
|
BACKUP_DIR=/data/backups
|
||||||
mkdir -p $BACKUP_DIR
|
mkdir -p $BACKUP_DIR
|
||||||
DATE=$(date +%Y%m%d_%H%M%S)
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
# Backup SQLite database using .backup for consistency
|
DST="$BACKUP_DIR/oil_calculator_${DATE}.db"
|
||||||
sqlite3 /data/oil_calculator.db ".backup '$BACKUP_DIR/oil_calculator_${DATE}.db'"
|
# Consistent snapshot via Python's sqlite3 .backup API (sqlite3 CLI not in image)
|
||||||
echo "Backup done: $BACKUP_DIR/oil_calculator_${DATE}.db ($(du -h $BACKUP_DIR/oil_calculator_${DATE}.db | cut -f1))"
|
python3 -c "import sqlite3; s=sqlite3.connect('/data/oil_calculator.db'); d=sqlite3.connect('$DST'); s.backup(d); d.close(); s.close()"
|
||||||
|
echo "Backup done: $DST ($(du -h $DST | cut -f1))"
|
||||||
# Keep last 48 backups (2 days of hourly)
|
# Keep last 48 backups (2 days of hourly)
|
||||||
ls -t $BACKUP_DIR/oil_calculator_*.db | tail -n +49 | xargs rm -f 2>/dev/null
|
ls -t $BACKUP_DIR/oil_calculator_*.db | tail -n +49 | xargs rm -f 2>/dev/null
|
||||||
echo "Backups retained: $(ls $BACKUP_DIR/oil_calculator_*.db | wc -l)"
|
echo "Backups retained: $(ls $BACKUP_DIR/oil_calculator_*.db | wc -l)"
|
||||||
|
|||||||
@@ -5,82 +5,26 @@ describe('doTERRA 精油配方计算器 - 功能演示', () => {
|
|||||||
cy.getAdminToken().then(token => { adminToken = token })
|
cy.getAdminToken().then(token => { adminToken = token })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('完整功能演示', { defaultCommandTimeout: 20000 }, () => {
|
it('首页和搜索', { defaultCommandTimeout: 10000 }, () => {
|
||||||
// ===== 开场:首页加载 =====
|
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
||||||
cy.visit('/', {
|
|
||||||
onBeforeLoad(win) {
|
|
||||||
win.localStorage.setItem('oil_auth_token', adminToken)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
cy.get('.app-header').should('be.visible')
|
|
||||||
cy.wait(1000)
|
|
||||||
|
|
||||||
// ===== 配方卡片列表 =====
|
|
||||||
cy.get('.recipe-card', { timeout: 15000 }).should('have.length.gte', 1)
|
|
||||||
cy.wait(500)
|
|
||||||
|
|
||||||
// ===== 搜索框输入 =====
|
|
||||||
cy.get('input[placeholder*="搜索"]').should('be.visible').click()
|
|
||||||
cy.get('input[placeholder*="搜索"]').type('薰衣草', { delay: 100 })
|
|
||||||
cy.wait(500)
|
|
||||||
cy.get('input[placeholder*="搜索"]').clear()
|
|
||||||
cy.wait(500)
|
|
||||||
|
|
||||||
// ===== 点击配方卡片 =====
|
|
||||||
cy.get('.recipe-card', { timeout: 10000 }).first().click()
|
|
||||||
cy.wait(1000)
|
|
||||||
|
|
||||||
// ===== 查看详情 =====
|
|
||||||
cy.get('[class*="overlay"], [class*="detail"]', { timeout: 10000 }).should('be.visible')
|
|
||||||
cy.get('.detail-close-btn').first().click({ force: true })
|
|
||||||
cy.wait(500)
|
|
||||||
|
|
||||||
// ===== 切换精油价目 =====
|
|
||||||
cy.get('.nav-tab').contains('精油价目').click()
|
|
||||||
cy.wait(1000)
|
|
||||||
|
|
||||||
// ===== 精油页面 =====
|
|
||||||
cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1)
|
|
||||||
|
|
||||||
// ===== 管理配方 =====
|
|
||||||
cy.get('.nav-tab').contains('管理配方').click()
|
|
||||||
cy.wait(1000)
|
|
||||||
|
|
||||||
// ===== 个人库存 =====
|
|
||||||
cy.get('.nav-tab').contains('个人库存').click()
|
|
||||||
cy.wait(1000)
|
|
||||||
|
|
||||||
// ===== Admin pages via direct URL =====
|
|
||||||
cy.visit('/audit', {
|
|
||||||
onBeforeLoad(win) {
|
|
||||||
win.localStorage.setItem('oil_auth_token', adminToken)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
cy.contains('操作日志', { timeout: 10000 }).should('be.visible')
|
|
||||||
cy.wait(500)
|
|
||||||
|
|
||||||
cy.visit('/bugs', {
|
|
||||||
onBeforeLoad(win) {
|
|
||||||
win.localStorage.setItem('oil_auth_token', adminToken)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
cy.contains('Bug', { timeout: 10000 }).should('be.visible')
|
|
||||||
cy.wait(500)
|
|
||||||
|
|
||||||
cy.visit('/users', {
|
|
||||||
onBeforeLoad(win) {
|
|
||||||
win.localStorage.setItem('oil_auth_token', adminToken)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
cy.contains('用户管理', { timeout: 10000 }).should('be.visible')
|
|
||||||
cy.wait(500)
|
|
||||||
|
|
||||||
// ===== 回到首页 =====
|
|
||||||
cy.visit('/', {
|
|
||||||
onBeforeLoad(win) {
|
|
||||||
win.localStorage.setItem('oil_auth_token', adminToken)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
||||||
|
cy.get('input[placeholder*="搜索"]').type('薰衣草')
|
||||||
|
cy.get('input[placeholder*="搜索"]').clear()
|
||||||
|
cy.get('.recipe-card').should('have.length.gte', 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('页面导航', { defaultCommandTimeout: 10000 }, () => {
|
||||||
|
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
||||||
|
cy.get('.nav-tab').contains('精油价目').click()
|
||||||
|
cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1)
|
||||||
|
cy.get('.nav-tab').contains('管理配方').click()
|
||||||
|
cy.get('.nav-tab').contains('个人库存').click()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('管理页面可访问', { defaultCommandTimeout: 10000 }, () => {
|
||||||
|
cy.visit('/audit', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
||||||
|
cy.contains('操作日志', { timeout: 10000 }).should('be.visible')
|
||||||
|
cy.visit('/users', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
||||||
|
cy.contains('用户管理', { timeout: 10000 }).should('be.visible')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
28
frontend/cypress/e2e/oil-price-export.cy.js
Normal file
28
frontend/cypress/e2e/oil-price-export.cy.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
describe('Oil Reference Excel Export', () => {
|
||||||
|
let adminToken
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
cy.getAdminToken().then(token => { adminToken = token })
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/oils', {
|
||||||
|
onBeforeLoad(win) {
|
||||||
|
win.localStorage.setItem('oil_auth_token', adminToken)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cy.get('.oil-chip, .oils-grid', { timeout: 10000 }).should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the Excel export button for admins', () => {
|
||||||
|
cy.contains('button', '📥 导出Excel').should('be.visible')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking Excel export shows success toast', () => {
|
||||||
|
cy.window().then(win => {
|
||||||
|
cy.stub(win.HTMLAnchorElement.prototype, 'click').returns(undefined)
|
||||||
|
})
|
||||||
|
cy.contains('button', '📥 导出Excel').click()
|
||||||
|
cy.get('.toast', { timeout: 10000 }).should('contain', '导出成功')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,12 +16,22 @@ describe('Oil Reference Page', () => {
|
|||||||
it('filters oils by search', () => {
|
it('filters oils by search', () => {
|
||||||
cy.get('.oil-chip').then($chips => {
|
cy.get('.oil-chip').then($chips => {
|
||||||
const initial = $chips.length
|
const initial = $chips.length
|
||||||
cy.get('input[placeholder*="搜索精油"]').type('薰衣草')
|
cy.get('input[placeholder*="搜索"]').type('薰衣草')
|
||||||
cy.wait(300)
|
cy.wait(300)
|
||||||
cy.get('.oil-chip').should('have.length.lt', initial)
|
cy.get('.oil-chip').should('have.length.lt', initial)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('filters oils by english name', () => {
|
||||||
|
cy.get('.oil-chip').then($chips => {
|
||||||
|
const initial = $chips.length
|
||||||
|
cy.get('input[placeholder*="搜索"]').type('Lavender')
|
||||||
|
cy.wait(300)
|
||||||
|
cy.get('.oil-chip').should('have.length.lt', initial)
|
||||||
|
cy.get('.oil-chip').should('exist')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('toggles between bottle and drop price view', () => {
|
it('toggles between bottle and drop price view', () => {
|
||||||
cy.get('.oil-chip').first().invoke('text').then(textBefore => {
|
cy.get('.oil-chip').first().invoke('text').then(textBefore => {
|
||||||
cy.contains('滴价').click()
|
cy.contains('滴价').click()
|
||||||
|
|||||||
51
frontend/cypress/e2e/oil-smart-paste.cy.js
Normal file
51
frontend/cypress/e2e/oil-smart-paste.cy.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
describe('Oil Reference Smart Paste', () => {
|
||||||
|
let adminToken
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
cy.getAdminToken().then(token => { adminToken = token })
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/oils', {
|
||||||
|
onBeforeLoad(win) {
|
||||||
|
win.localStorage.setItem('oil_auth_token', adminToken)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
cy.get('.oil-chip, .oils-grid', { timeout: 10000 }).should('exist')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('smart paste fills product form fields', () => {
|
||||||
|
cy.contains('button', '+ 新增').click()
|
||||||
|
cy.contains('button', '🪄 智能识别').click()
|
||||||
|
|
||||||
|
const sample = [
|
||||||
|
'优惠顾客价:¥310PT:41',
|
||||||
|
'',
|
||||||
|
'零售价:¥465',
|
||||||
|
'',
|
||||||
|
'点数:37 规格:100毫升',
|
||||||
|
'',
|
||||||
|
'花样年华焕颜精华水 Salubelle Rejuvenating Essence',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
cy.get('textarea').type(sample, { parseSpecialCharSequences: false, delay: 0 })
|
||||||
|
cy.contains('button', '识别并填入').click()
|
||||||
|
|
||||||
|
cy.get('.add-type-tab.active').should('contain', '其他')
|
||||||
|
cy.get('input[placeholder="产品名称"]').should('have.value', '花样年华焕颜精华水')
|
||||||
|
cy.get('input[placeholder="英文名"]').should('have.value', 'Salubelle Rejuvenating Essence')
|
||||||
|
cy.get('input[placeholder="会员价 ¥"]').should('have.value', '310')
|
||||||
|
cy.get('input[placeholder="零售价 ¥"]').should('have.value', '465')
|
||||||
|
cy.get('input[placeholder="容量"]').should('have.value', '100')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('smart paste detects standard ml volume as essential oil', () => {
|
||||||
|
cy.contains('button', '+ 新增').click()
|
||||||
|
cy.contains('button', '🪄 智能识别').click()
|
||||||
|
const sample = '会员价:¥200\n零售价:¥267\n规格:15毫升\n薰衣草测试 LavenderTest'
|
||||||
|
cy.get('textarea').type(sample, { parseSpecialCharSequences: false, delay: 0 })
|
||||||
|
cy.contains('button', '识别并填入').click()
|
||||||
|
cy.get('.add-type-tab.active').should('contain', '精油')
|
||||||
|
cy.get('input[placeholder="精油名称"]').should('have.value', '薰衣草测试')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -29,16 +29,14 @@ describe('Price Display Regression', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('recipe detail shows non-zero total cost', () => {
|
it('recipe cards show price in correct format', () => {
|
||||||
cy.visit('/')
|
cy.visit('/')
|
||||||
cy.get('.recipe-card', { timeout: 10000 }).first().click()
|
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
||||||
cy.wait(1000)
|
// Verify multiple cards have prices
|
||||||
|
cy.get('.recipe-card-price').should('have.length.gte', 1)
|
||||||
// Look for any ¥ amount > 0 in the detail overlay
|
cy.get('.recipe-card-price').each($el => {
|
||||||
cy.get('[class*="overlay"], [class*="detail"]').invoke('text').then(text => {
|
const text = $el.text()
|
||||||
const prices = [...text.matchAll(/¥\s*(\d+\.?\d*)/g)].map(m => parseFloat(m[1]))
|
expect(text).to.match(/¥|💰/)
|
||||||
const nonZero = prices.filter(p => p > 0)
|
|
||||||
expect(nonZero.length, 'Detail should show at least one non-zero price').to.be.gte(1)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
92
frontend/cypress/e2e/re-review-notify.cy.js
Normal file
92
frontend/cypress/e2e/re-review-notify.cy.js
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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', () => {
|
it('clears search and restores all recipes', () => {
|
||||||
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
||||||
cy.get('input[placeholder*="搜索"]').type('薰衣草')
|
cy.get('input[placeholder*="搜索"]').type('薰衣草')
|
||||||
|
|||||||
@@ -1,57 +1,44 @@
|
|||||||
describe('Visual Check - Screenshots', () => {
|
describe('Visual Check', () => {
|
||||||
let adminToken
|
let adminToken
|
||||||
|
|
||||||
before(() => {
|
before(() => {
|
||||||
cy.getAdminToken().then(token => { adminToken = token })
|
cy.getAdminToken().then(token => { adminToken = token })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('homepage with recipes', () => {
|
it('homepage loads with recipes', () => {
|
||||||
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
||||||
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
||||||
cy.wait(1000)
|
|
||||||
cy.screenshot('01-homepage')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('recipe detail overlay', () => {
|
it('oil reference loads with chips', () => {
|
||||||
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
|
||||||
cy.get('.recipe-card', { timeout: 10000 }).first().click()
|
|
||||||
cy.wait(1000)
|
|
||||||
cy.screenshot('02-recipe-detail')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('oil reference page', () => {
|
|
||||||
cy.visit('/oils', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
cy.visit('/oils', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
||||||
cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1)
|
cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1)
|
||||||
cy.wait(500)
|
|
||||||
cy.screenshot('03-oil-reference')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('manage recipes page', () => {
|
it('manage recipes page loads', () => {
|
||||||
cy.visit('/manage', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
cy.visit('/manage', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
||||||
cy.wait(2000)
|
cy.get('.recipe-manager', { timeout: 10000 }).should('exist')
|
||||||
cy.screenshot('04-manage-recipes')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('inventory page', () => {
|
it('inventory page loads', () => {
|
||||||
cy.visit('/inventory', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
cy.visit('/inventory', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
||||||
cy.wait(1500)
|
cy.get('.inventory-page', { timeout: 10000 }).should('exist')
|
||||||
cy.screenshot('05-inventory')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('check if recipe cards show price > 0', () => {
|
it('recipe cards show price > 0', () => {
|
||||||
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
||||||
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
|
||||||
// Check if any card shows a non-zero price
|
|
||||||
cy.get('.recipe-card').first().invoke('text').then(text => {
|
cy.get('.recipe-card').first().invoke('text').then(text => {
|
||||||
cy.log('First card text: ' + text)
|
|
||||||
const priceMatch = text.match(/¥\s*(\d+\.?\d*)/)
|
const priceMatch = text.match(/¥\s*(\d+\.?\d*)/)
|
||||||
if (priceMatch) {
|
if (priceMatch) {
|
||||||
cy.log('Price found: ¥' + priceMatch[1])
|
expect(parseFloat(priceMatch[1])).to.be.gt(0)
|
||||||
const price = parseFloat(priceMatch[1])
|
|
||||||
expect(price, 'Recipe card should show price > 0').to.be.gt(0)
|
|
||||||
} else {
|
|
||||||
cy.log('WARNING: No price found on recipe card')
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('recipe detail overlay opens', () => {
|
||||||
|
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } })
|
||||||
|
cy.get('.recipe-card', { timeout: 10000 }).first().click()
|
||||||
|
cy.get('.detail-overlay', { timeout: 10000 }).should('exist')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
87
frontend/src/__tests__/multiFixIssues.test.js
Normal file
87
frontend/src/__tests__/multiFixIssues.test.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
73
frontend/src/__tests__/oilProductPaste.test.js
Normal file
73
frontend/src/__tests__/oilProductPaste.test.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseOilProductPaste } from '../composables/useOilProductPaste'
|
||||||
|
|
||||||
|
describe('parseOilProductPaste', () => {
|
||||||
|
it('returns empty shape for empty input', () => {
|
||||||
|
const r = parseOilProductPaste('')
|
||||||
|
expect(r.cn).toBe('')
|
||||||
|
expect(r.en).toBe('')
|
||||||
|
expect(r.memberPrice).toBeNull()
|
||||||
|
expect(r.retailPrice).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses the 花样年华 sample as product with 100ml', () => {
|
||||||
|
const sample = `优惠顾客价:¥310PT:41
|
||||||
|
|
||||||
|
零售价:¥465
|
||||||
|
|
||||||
|
点数:37 规格:100毫升
|
||||||
|
|
||||||
|
花样年华焕颜精华水 Salubelle Rejuvenating Essence`
|
||||||
|
const r = parseOilProductPaste(sample)
|
||||||
|
expect(r.type).toBe('product')
|
||||||
|
expect(r.memberPrice).toBe(310)
|
||||||
|
expect(r.retailPrice).toBe(465)
|
||||||
|
expect(r.productAmount).toBe(100)
|
||||||
|
expect(r.productUnit).toBe('ml')
|
||||||
|
expect(r.cn).toBe('花样年华焕颜精华水')
|
||||||
|
expect(r.en).toBe('Salubelle Rejuvenating Essence')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects essential oil when volume is standard ml', () => {
|
||||||
|
const sample = `会员价:¥200\n零售价:¥267\n规格:15毫升\n薰衣草 Lavender`
|
||||||
|
const r = parseOilProductPaste(sample)
|
||||||
|
expect(r.type).toBe('oil')
|
||||||
|
expect(r.volume).toBe('15')
|
||||||
|
expect(r.cn).toBe('薰衣草')
|
||||||
|
expect(r.en).toBe('Lavender')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles half-width colon and dollar variant', () => {
|
||||||
|
const r = parseOilProductPaste('优惠顾客价: ¥99\n零售价: ¥150\n规格: 5ml\n柠檬 Lemon')
|
||||||
|
expect(r.memberPrice).toBe(99)
|
||||||
|
expect(r.retailPrice).toBe(150)
|
||||||
|
expect(r.type).toBe('oil')
|
||||||
|
expect(r.volume).toBe('5')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses capsule spec as product', () => {
|
||||||
|
const r = parseOilProductPaste('优惠顾客价:¥200\n规格:60粒\n深海鱼油 Omega')
|
||||||
|
expect(r.type).toBe('product')
|
||||||
|
expect(r.productAmount).toBe(60)
|
||||||
|
expect(r.productUnit).toBe('capsule')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses gram spec as product', () => {
|
||||||
|
const r = parseOilProductPaste('优惠顾客价:¥80\n规格:120克\n洁面乳 Face Wash')
|
||||||
|
expect(r.productUnit).toBe('g')
|
||||||
|
expect(r.productAmount).toBe(120)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-standard ml volume falls to product', () => {
|
||||||
|
const r = parseOilProductPaste('优惠顾客价:¥310\n规格:100毫升\n精华 Essence')
|
||||||
|
expect(r.type).toBe('product')
|
||||||
|
expect(r.productAmount).toBe(100)
|
||||||
|
expect(r.productUnit).toBe('ml')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('name without english part keeps cn only', () => {
|
||||||
|
const r = parseOilProductPaste('优惠顾客价:¥50\n规格:5毫升\n某国产品')
|
||||||
|
expect(r.cn).toBe('某国产品')
|
||||||
|
expect(r.en).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -499,3 +499,149 @@ describe('volume field in recipe mapping — PR31', () => {
|
|||||||
expect(labels['']).toBe('')
|
expect(labels['']).toBe('')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PR33: Oil card branding logic
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('oil card branding — PR33', () => {
|
||||||
|
it('brand data determines card display elements', () => {
|
||||||
|
const brand = { qr_code: 'data:image/png;base64,abc', brand_bg: 'data:image/png;base64,bg', brand_logo: null, brand_name: '测试品牌', brand_align: 'center' }
|
||||||
|
expect(!!brand.qr_code).toBe(true)
|
||||||
|
expect(!!brand.brand_bg).toBe(true)
|
||||||
|
expect(!!brand.brand_logo).toBe(false)
|
||||||
|
expect(!!brand.brand_name).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('empty brand shows plain card', () => {
|
||||||
|
const brand = {}
|
||||||
|
expect(!!brand.qr_code).toBe(false)
|
||||||
|
expect(!!brand.brand_bg).toBe(false)
|
||||||
|
expect(!!brand.brand_logo).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('volumeLabel with name parameter works for drops and ml', () => {
|
||||||
|
// Simulates the fix: volumeLabel(dropCount, name) needs both params
|
||||||
|
const DROPS_TO_VOLUME = { 93: '5ml', 280: '15ml' }
|
||||||
|
function volumeLabel(dropCount, name) {
|
||||||
|
if (name === '无香乳液') return dropCount + 'ml' // ml unit
|
||||||
|
return DROPS_TO_VOLUME[dropCount] || (dropCount + '滴')
|
||||||
|
}
|
||||||
|
expect(volumeLabel(280, '薰衣草')).toBe('15ml')
|
||||||
|
expect(volumeLabel(200, '无香乳液')).toBe('200ml')
|
||||||
|
expect(volumeLabel(93, '茶树')).toBe('5ml')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PDF export price unit adapts to product type', () => {
|
||||||
|
function oilPriceUnit(name) {
|
||||||
|
if (name === '无香乳液') return 'ml'
|
||||||
|
if (name === '植物空胶囊') return '颗'
|
||||||
|
return '滴'
|
||||||
|
}
|
||||||
|
expect(oilPriceUnit('薰衣草')).toBe('滴')
|
||||||
|
expect(oilPriceUnit('无香乳液')).toBe('ml')
|
||||||
|
expect(oilPriceUnit('植物空胶囊')).toBe('颗')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PR34: Product edit UI — unit-based form switching
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('product edit UI logic — PR34', () => {
|
||||||
|
it('drop unit shows standard volume selector', () => {
|
||||||
|
const unit = 'drop'
|
||||||
|
expect(unit === 'drop').toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-drop unit shows amount + unit selector', () => {
|
||||||
|
for (const u of ['ml', 'g', 'capsule']) {
|
||||||
|
expect(u !== 'drop').toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edit form initializes correct unit from meta', () => {
|
||||||
|
const meta = { unit: 'g', dropCount: 80 }
|
||||||
|
const editUnit = meta.unit || 'drop'
|
||||||
|
const editProductAmount = editUnit !== 'drop' ? meta.dropCount : null
|
||||||
|
const editProductUnit = editUnit !== 'drop' ? editUnit : 'ml'
|
||||||
|
expect(editUnit).toBe('g')
|
||||||
|
expect(editProductAmount).toBe(80)
|
||||||
|
expect(editProductUnit).toBe('g')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edit form defaults to drop for oils', () => {
|
||||||
|
const meta = { unit: 'drop', dropCount: 280 }
|
||||||
|
const editUnit = meta.unit || 'drop'
|
||||||
|
expect(editUnit).toBe('drop')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edit form defaults to drop when unit is undefined', () => {
|
||||||
|
const meta = { dropCount: 280 }
|
||||||
|
const editUnit = meta.unit || 'drop'
|
||||||
|
expect(editUnit).toBe('drop')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('save uses product amount and unit for non-drop', () => {
|
||||||
|
const editUnit = 'ml'
|
||||||
|
const editProductAmount = 200
|
||||||
|
const editProductUnit = 'ml'
|
||||||
|
const dropCount = 280 // from standard volume selector
|
||||||
|
const finalDropCount = editUnit !== 'drop' ? editProductAmount : dropCount
|
||||||
|
const finalUnit = editUnit !== 'drop' ? editProductUnit : null
|
||||||
|
expect(finalDropCount).toBe(200)
|
||||||
|
expect(finalUnit).toBe('ml')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('save uses standard drop count for oils', () => {
|
||||||
|
const editUnit = 'drop'
|
||||||
|
const editProductAmount = null
|
||||||
|
const dropCount = 280
|
||||||
|
const finalDropCount = editUnit !== 'drop' ? editProductAmount : dropCount
|
||||||
|
const finalUnit = editUnit !== 'drop' ? 'ml' : null
|
||||||
|
expect(finalDropCount).toBe(280)
|
||||||
|
expect(finalUnit).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('label adapts: 精油名称 for oils, 产品名称 for products', () => {
|
||||||
|
const labelForDrop = 'drop' === 'drop' ? '精油名称' : '产品名称'
|
||||||
|
const labelForMl = 'ml' === 'drop' ? '精油名称' : '产品名称'
|
||||||
|
expect(labelForDrop).toBe('精油名称')
|
||||||
|
expect(labelForMl).toBe('产品名称')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PR34: Share text and consumption analysis use dynamic unit
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('share text and consumption use dynamic unit — PR34', () => {
|
||||||
|
const UNIT_MAP = { drop: '滴', ml: 'ml', g: 'g', capsule: '颗' }
|
||||||
|
function unitLabel(name, unitMap) { return UNIT_MAP[unitMap[name] || 'drop'] }
|
||||||
|
|
||||||
|
it('share text uses unitLabel for each ingredient', () => {
|
||||||
|
const units = { '薰衣草': 'drop', '无香乳液': 'ml', '植物空胶囊': 'capsule' }
|
||||||
|
const ings = [
|
||||||
|
{ oil: '薰衣草', drops: 3 },
|
||||||
|
{ oil: '无香乳液', drops: 30 },
|
||||||
|
{ oil: '植物空胶囊', drops: 2 },
|
||||||
|
]
|
||||||
|
const lines = ings.map(i => `${i.oil} ${i.drops}${unitLabel(i.oil, units)}`)
|
||||||
|
expect(lines[0]).toBe('薰衣草 3滴')
|
||||||
|
expect(lines[1]).toBe('无香乳液 30ml')
|
||||||
|
expect(lines[2]).toBe('植物空胶囊 2颗')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('consumption analysis uses unitLabel per oil', () => {
|
||||||
|
const units = { '薰衣草': 'drop', '活力磨砂膏': 'g' }
|
||||||
|
const data = [
|
||||||
|
{ oil: '薰衣草', drops: 15, bottleDrops: 280 },
|
||||||
|
{ oil: '活力磨砂膏', drops: 30, bottleDrops: 70 },
|
||||||
|
]
|
||||||
|
const display = data.map(c => ({
|
||||||
|
usage: `${c.drops}${unitLabel(c.oil, units)}`,
|
||||||
|
capacity: `${c.bottleDrops}${unitLabel(c.oil, units)}`,
|
||||||
|
}))
|
||||||
|
expect(display[0].usage).toBe('15滴')
|
||||||
|
expect(display[0].capacity).toBe('280滴')
|
||||||
|
expect(display[1].usage).toBe('30g')
|
||||||
|
expect(display[1].capacity).toBe('70g')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
57
frontend/src/__tests__/recipeSearchByOil.test.js
Normal file
57
frontend/src/__tests__/recipeSearchByOil.test.js
Normal 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(['助眠晚安'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
<span class="ec-oil-name">{{ getCardOilName(ing.oil) }}</span>
|
<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-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 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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -483,7 +483,7 @@ function copyText() {
|
|||||||
const ings = cardIngredients.value
|
const ings = cardIngredients.value
|
||||||
const lines = ings.map(ing => {
|
const lines = ings.map(ing => {
|
||||||
const cost = oilsStore.pricePerDrop(ing.oil) * ing.drops
|
const cost = oilsStore.pricePerDrop(ing.oil) * ing.drops
|
||||||
return `${ing.oil} ${ing.drops}滴 ${oilsStore.fmtPrice(cost)}`
|
return `${ing.oil} ${ing.drops}${oilsStore.unitLabel(ing.oil)} ${oilsStore.fmtPrice(cost)}`
|
||||||
})
|
})
|
||||||
const total = priceInfo.value.cost
|
const total = priceInfo.value.cost
|
||||||
const text = [
|
const text = [
|
||||||
@@ -591,7 +591,7 @@ function getCardRecipeName() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cardHasAnyRetail = computed(() =>
|
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(() => {
|
const cardTitleSize = computed(() => {
|
||||||
@@ -829,7 +829,7 @@ onMounted(() => {
|
|||||||
else { selectedVolume.value = 'custom'; customVolumeValue.value = Math.round(ml) }
|
else { selectedVolume.value = 'custom'; customVolumeValue.value = Math.round(ml) }
|
||||||
|
|
||||||
loadBrand()
|
loadBrand()
|
||||||
nextTick(() => generateCardImage())
|
// Don't auto-generate card image on mount — generate on demand when saving
|
||||||
})
|
})
|
||||||
|
|
||||||
function addIngredient() {
|
function addIngredient() {
|
||||||
@@ -1699,8 +1699,8 @@ async function saveRecipe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-drops {
|
.editor-drops {
|
||||||
width: 42px;
|
width: 58px;
|
||||||
padding: 5px 2px;
|
padding: 5px 4px 5px 6px;
|
||||||
border: 1.5px solid var(--border, #e0d4c0);
|
border: 1.5px solid var(--border, #e0d4c0);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Oil knowledge cards - usage guides for common essential oils
|
// Oil knowledge cards - usage guides for common essential oils
|
||||||
// Ported from original vanilla JS implementation
|
// Ported from original vanilla JS implementation
|
||||||
|
import { useOilsStore } from '../stores/oils'
|
||||||
|
|
||||||
export const OIL_CARDS = {
|
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: '轻微光敏,白天涂抹注意防晒' },
|
'野橘': { 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) {
|
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_CARDS[name]) return OIL_CARDS[name]
|
||||||
if (OIL_CARD_ALIAS[name] && OIL_CARDS[OIL_CARD_ALIAS[name]]) return OIL_CARDS[OIL_CARD_ALIAS[name]]
|
if (OIL_CARD_ALIAS[name] && OIL_CARDS[OIL_CARD_ALIAS[name]]) return OIL_CARDS[OIL_CARD_ALIAS[name]]
|
||||||
const base = name.replace(/呵护$/, '')
|
const base = name.replace(/呵护$/, '')
|
||||||
|
|||||||
52
frontend/src/composables/useOilProductPaste.js
Normal file
52
frontend/src/composables/useOilProductPaste.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const OIL_VOLUMES = new Set(['2.5', '5', '10', '15', '115'])
|
||||||
|
|
||||||
|
export function parseOilProductPaste(raw) {
|
||||||
|
const result = {
|
||||||
|
type: 'product',
|
||||||
|
cn: '',
|
||||||
|
en: '',
|
||||||
|
memberPrice: null,
|
||||||
|
retailPrice: null,
|
||||||
|
volume: null,
|
||||||
|
customDrops: null,
|
||||||
|
productAmount: null,
|
||||||
|
productUnit: null,
|
||||||
|
}
|
||||||
|
if (!raw || !raw.trim()) return result
|
||||||
|
|
||||||
|
const text = raw.replace(/[::]/g, ':').replace(/[¥¥]/g, '')
|
||||||
|
|
||||||
|
const memberMatch = text.match(/(?:优惠顾客价|会员价|批发价)\s*:?\s*(\d+(?:\.\d+)?)/)
|
||||||
|
const retailMatch = text.match(/零售价\s*:?\s*(\d+(?:\.\d+)?)/)
|
||||||
|
const specMatch = text.match(/规格\s*:?\s*(\d+(?:\.\d+)?)\s*(毫升|ml|ML|克|g|G|颗|粒|片)/)
|
||||||
|
|
||||||
|
if (memberMatch) result.memberPrice = Number(memberMatch[1])
|
||||||
|
if (retailMatch) result.retailPrice = Number(retailMatch[1])
|
||||||
|
|
||||||
|
for (const line of raw.split(/\r?\n/)) {
|
||||||
|
const s = line.trim()
|
||||||
|
if (!s) continue
|
||||||
|
if (/优惠顾客价|会员价|零售价|点数|规格|PT\s*:|批发价/i.test(s)) continue
|
||||||
|
const m = s.match(/^([^A-Za-z]+?)\s+([A-Za-z].*)$/)
|
||||||
|
if (m) { result.cn = m[1].trim(); result.en = m[2].trim() } else { result.cn = s }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (specMatch) {
|
||||||
|
const amount = specMatch[1]
|
||||||
|
const unitRaw = specMatch[2].toLowerCase()
|
||||||
|
const isMl = unitRaw === '毫升' || unitRaw === 'ml'
|
||||||
|
if (isMl && OIL_VOLUMES.has(String(Number(amount)))) {
|
||||||
|
result.type = 'oil'
|
||||||
|
result.volume = String(Number(amount))
|
||||||
|
} else {
|
||||||
|
result.type = 'product'
|
||||||
|
result.productAmount = Number(amount)
|
||||||
|
result.productUnit = (unitRaw === '克' || unitRaw === 'g') ? 'g'
|
||||||
|
: (unitRaw === '颗' || unitRaw === '粒' || unitRaw === '片') ? 'capsule'
|
||||||
|
: 'ml'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export const VOLUME_DROPS = {
|
|||||||
export const useOilsStore = defineStore('oils', () => {
|
export const useOilsStore = defineStore('oils', () => {
|
||||||
const oils = ref({})
|
const oils = ref({})
|
||||||
const oilsMeta = ref({})
|
const oilsMeta = ref({})
|
||||||
|
const oilCards = ref({})
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
const oilNames = computed(() =>
|
const oilNames = computed(() =>
|
||||||
@@ -50,7 +51,11 @@ export const useOilsStore = defineStore('oils', () => {
|
|||||||
const cost = calcCost(ingredients)
|
const cost = calcCost(ingredients)
|
||||||
const retail = calcRetailCost(ingredients)
|
const retail = calcRetailCost(ingredients)
|
||||||
const costStr = fmtPrice(cost)
|
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: fmtPrice(retail), hasRetail: true }
|
||||||
}
|
}
|
||||||
return { cost: costStr, retail: null, hasRetail: false }
|
return { cost: costStr, retail: null, hasRetail: false }
|
||||||
@@ -75,9 +80,28 @@ export const useOilsStore = defineStore('oils', () => {
|
|||||||
}
|
}
|
||||||
oils.value = newOils
|
oils.value = newOils
|
||||||
oilsMeta.value = newMeta
|
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 = {
|
const payload = {
|
||||||
name,
|
name,
|
||||||
bottle_price: bottlePrice,
|
bottle_price: bottlePrice,
|
||||||
@@ -86,6 +110,13 @@ export const useOilsStore = defineStore('oils', () => {
|
|||||||
en_name: enName,
|
en_name: enName,
|
||||||
}
|
}
|
||||||
if (unit) payload.unit = unit
|
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 api.post('/api/oils', payload)
|
||||||
await loadOils()
|
await loadOils()
|
||||||
}
|
}
|
||||||
@@ -134,6 +165,7 @@ export const useOilsStore = defineStore('oils', () => {
|
|||||||
return {
|
return {
|
||||||
oils,
|
oils,
|
||||||
oilsMeta,
|
oilsMeta,
|
||||||
|
oilCards,
|
||||||
oilNames,
|
oilNames,
|
||||||
pricePerDrop,
|
pricePerDrop,
|
||||||
calcCost,
|
calcCost,
|
||||||
|
|||||||
@@ -267,8 +267,9 @@ async function exportExcel(mode) {
|
|||||||
const unit = oils.unitLabel(i.oil)
|
const unit = oils.unitLabel(i.oil)
|
||||||
return `${i.oil} ${i.drops}${unit}`
|
return `${i.oil} ${i.drops}${unit}`
|
||||||
}).join('、')
|
}).join('、')
|
||||||
|
const vol = volumeLabel(r)
|
||||||
ws.addRow([
|
ws.addRow([
|
||||||
r.name,
|
vol ? `${r.name}(${vol})` : r.name,
|
||||||
(r.tags || []).join('/'),
|
(r.tags || []).join('/'),
|
||||||
ingredientStr,
|
ingredientStr,
|
||||||
calcMaxTimes(r),
|
calcMaxTimes(r),
|
||||||
@@ -287,8 +288,9 @@ async function exportExcel(mode) {
|
|||||||
for (const r of ka.recipes) {
|
for (const r of ka.recipes) {
|
||||||
const price = getSellingPrice(r._id)
|
const price = getSellingPrice(r._id)
|
||||||
const margin = calcMargin(r.kitCost, price)
|
const margin = calcMargin(r.kitCost, price)
|
||||||
|
const vol = volumeLabel(r)
|
||||||
ws.addRow([
|
ws.addRow([
|
||||||
r.name,
|
vol ? `${r.name}(${vol})` : r.name,
|
||||||
calcMaxTimes(r),
|
calcMaxTimes(r),
|
||||||
Number(r.kitCost.toFixed(2)),
|
Number(r.kitCost.toFixed(2)),
|
||||||
Number(r.originalCost.toFixed(2)),
|
Number(r.originalCost.toFixed(2)),
|
||||||
@@ -326,7 +328,8 @@ async function exportExcel(mode) {
|
|||||||
|
|
||||||
for (const row of crossComparison.value) {
|
for (const row of crossComparison.value) {
|
||||||
const price = getSellingPrice(row.id)
|
const price = getSellingPrice(row.id)
|
||||||
const vals = [row.name]
|
const vol = volumeLabel(row)
|
||||||
|
const vals = [vol ? `${row.name}(${vol})` : row.name]
|
||||||
if (mode === 'full') vals.push((row.tags || []).join('/'))
|
if (mode === 'full') vals.push((row.tags || []).join('/'))
|
||||||
for (const ka of kitAnalysis.value) {
|
for (const ka of kitAnalysis.value) {
|
||||||
const cost = row.costs[ka.id]
|
const cost = row.costs[ka.id]
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
<!-- Search + View Toggle + Add + PDF -->
|
<!-- Search + View Toggle + Add + PDF -->
|
||||||
<div style="display:flex;gap:6px;align-items:center;margin-bottom:12px;flex-wrap:nowrap">
|
<div style="display:flex;gap:6px;align-items:center;margin-bottom:12px;flex-wrap:nowrap">
|
||||||
<div class="search-box" style="flex:1;min-width:140px;margin-bottom:0">
|
<div class="search-box" style="flex:1;min-width:140px;margin-bottom:0">
|
||||||
<input class="search-input" v-model="searchQuery" placeholder="搜索精油名称…" style="width:100%" />
|
<input class="search-input" v-model="searchQuery" placeholder="搜索中文或英文名…" style="width:100%" />
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;border:1.5px solid var(--border);border-radius:10px;overflow:hidden;flex-shrink:0">
|
<div style="display:flex;border:1.5px solid var(--border);border-radius:10px;overflow:hidden;flex-shrink:0">
|
||||||
<button @click="viewMode = 'bottle'" :style="viewMode === 'bottle' ? 'background:var(--sage);color:white' : 'background:white;color:var(--text-mid)'" style="border:none;border-radius:0;font-size:12px;padding:6px 12px;cursor:pointer">每瓶价</button>
|
<button @click="viewMode = 'bottle'" :style="viewMode === 'bottle' ? 'background:var(--sage);color:white' : 'background:white;color:var(--text-mid)'" style="border:none;border-radius:0;font-size:12px;padding:6px 12px;cursor:pointer">每瓶价</button>
|
||||||
@@ -99,10 +99,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Desktop: text buttons -->
|
<!-- Desktop: text buttons -->
|
||||||
<button v-if="auth.canManage" class="toolbar-btn-text" @click="showAddForm = !showAddForm">{{ showAddForm ? '收起' : '+ 新增' }}</button>
|
<button v-if="auth.canManage" class="toolbar-btn-text" @click="showAddForm = !showAddForm">{{ showAddForm ? '收起' : '+ 新增' }}</button>
|
||||||
<button v-if="auth.isAdmin" class="toolbar-btn-text" @click="exportPDF">📥 导出PDF</button>
|
<button v-if="auth.isAdmin" class="toolbar-btn-text" @click="exportExcel">📥 导出Excel</button>
|
||||||
<!-- Mobile: emoji-only buttons -->
|
<!-- Mobile: emoji-only buttons -->
|
||||||
<button v-if="auth.canManage" class="toolbar-btn-icon" @click="showAddForm = !showAddForm" title="新增精油">➕</button>
|
<button v-if="auth.canManage" class="toolbar-btn-icon" @click="showAddForm = !showAddForm" title="新增精油">➕</button>
|
||||||
<button v-if="auth.isAdmin" class="toolbar-btn-icon" @click="exportPDF" title="导出PDF">📄</button>
|
<button v-if="auth.isAdmin" class="toolbar-btn-icon" @click="exportExcel" title="导出Excel">📄</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Oil Form (toggleable) -->
|
<!-- Add Oil Form (toggleable) -->
|
||||||
@@ -110,6 +110,14 @@
|
|||||||
<div class="add-type-tabs">
|
<div class="add-type-tabs">
|
||||||
<button class="add-type-tab" :class="{ active: addType === 'oil' }" @click="addType = 'oil'">精油</button>
|
<button class="add-type-tab" :class="{ active: addType === 'oil' }" @click="addType = 'oil'">精油</button>
|
||||||
<button class="add-type-tab" :class="{ active: addType === 'product' }" @click="addType = 'product'">其他</button>
|
<button class="add-type-tab" :class="{ active: addType === 'product' }" @click="addType = 'product'">其他</button>
|
||||||
|
<button class="add-type-tab" :class="{ active: showSmartPaste }" @click="showSmartPaste = !showSmartPaste" style="margin-left:auto">🪄 智能识别</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="showSmartPaste" class="form-row" style="flex-direction:column;align-items:stretch;gap:6px">
|
||||||
|
<textarea v-model="smartPasteText" rows="4" class="form-input-sm" placeholder="粘贴产品信息,例如: 优惠顾客价:¥310 零售价:¥465 规格:100毫升 花样年华焕颜精华水 Salubelle Rejuvenating Essence" style="width:100%;resize:vertical;font-family:inherit"></textarea>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button class="btn btn-primary btn-sm" @click="runSmartPaste" :disabled="!smartPasteText.trim()">识别并填入</button>
|
||||||
|
<button class="btn btn-sm" @click="smartPasteText = ''">清空</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 新增精油 -->
|
<!-- 新增精油 -->
|
||||||
<div v-if="addType === 'oil'" class="form-row">
|
<div v-if="addType === 'oil'" class="form-row">
|
||||||
@@ -181,9 +189,16 @@
|
|||||||
|
|
||||||
<!-- Oil Knowledge Card Modal -->
|
<!-- Oil Knowledge Card Modal -->
|
||||||
<div v-if="activeCard && activeCardName" class="modal-overlay" @mousedown.self="closeOilModal">
|
<div v-if="activeCard && activeCardName" class="modal-overlay" @mousedown.self="closeOilModal">
|
||||||
<div class="oil-card-modal">
|
<div class="oil-card-modal" style="position:relative;overflow:hidden">
|
||||||
<div class="oil-card-header">
|
<!-- Brand background -->
|
||||||
<div class="oil-card-header-content">
|
<div v-if="brand.brand_bg" style="position:absolute;inset:0;width:100%;height:100%;background-size:cover;background-position:center;opacity:0.08;pointer-events:none;z-index:0" :style="{ backgroundImage: `url('${brand.brand_bg}')` }"></div>
|
||||||
|
<!-- QR code -->
|
||||||
|
<div v-if="brand.qr_code" style="position:absolute;top:16px;right:16px;display:flex;flex-direction:column;gap:3px;z-index:3" :style="{ alignItems: (brand.brand_align === 'left' ? 'flex-start' : brand.brand_align === 'right' ? 'flex-end' : 'center') }">
|
||||||
|
<img :src="brand.qr_code" crossorigin="anonymous" style="width:48px;height:48px;object-fit:cover;border-radius:6px;box-shadow:0 2px 6px rgba(0,0,0,0.1)" />
|
||||||
|
<div v-if="brand.brand_name" style="font-size:7px;color:rgba(255,255,255,0.8);line-height:1.3;max-width:60px;white-space:pre-line;text-align:center">{{ brand.brand_name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="oil-card-header" style="position:relative;z-index:1">
|
||||||
|
<div class="oil-card-header-content" :style="brand.qr_code ? 'padding-right:70px' : ''">
|
||||||
<span class="oil-card-emoji">{{ activeCard.emoji }}</span>
|
<span class="oil-card-emoji">{{ activeCard.emoji }}</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 class="oil-card-title">{{ activeCardName }}</h2>
|
<h2 class="oil-card-title">{{ activeCardName }}</h2>
|
||||||
@@ -196,7 +211,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-close btn-close-light" @click="closeOilModal">✕</button>
|
<button class="btn-close btn-close-light" @click="closeOilModal" style="z-index:4">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Method badges -->
|
<!-- Method badges -->
|
||||||
<div class="oil-card-methods">
|
<div class="oil-card-methods">
|
||||||
@@ -227,6 +242,10 @@
|
|||||||
<h4 class="oil-card-caution-title">⚠️ 注意事项</h4>
|
<h4 class="oil-card-caution-title">⚠️ 注意事项</h4>
|
||||||
<p>{{ activeCard.caution }}</p>
|
<p>{{ activeCard.caution }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Logo -->
|
||||||
|
<div v-if="brand.brand_logo" style="padding-top:8px">
|
||||||
|
<img :src="brand.brand_logo" crossorigin="anonymous" style="height:24px;opacity:0.7" />
|
||||||
|
</div>
|
||||||
<div style="text-align:center;padding-top:12px">
|
<div style="text-align:center;padding-top:12px">
|
||||||
<button class="btn btn-outline btn-sm" @click="saveCardImage(activeCardName)">💾 保存图片</button>
|
<button class="btn btn-outline btn-sm" @click="saveCardImage(activeCardName)">💾 保存图片</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,13 +335,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>精油名称</label>
|
<label>{{ editUnit === 'drop' ? '精油名称' : '产品名称' }}</label>
|
||||||
<input v-model="editOilDisplayName" class="form-input" type="text" placeholder="精油名称" />
|
<input v-model="editOilDisplayName" class="form-input" type="text" :placeholder="editUnit === 'drop' ? '精油名称' : '产品名称'" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>英文名</label>
|
<label>英文名</label>
|
||||||
<input v-model="editOilEnName" class="form-input" type="text" placeholder="English name" />
|
<input v-model="editOilEnName" class="form-input" type="text" placeholder="English name" />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 精油容量 -->
|
||||||
|
<template v-if="editUnit === 'drop'">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>容量</label>
|
<label>容量</label>
|
||||||
<select v-model="editVolume" class="form-select">
|
<select v-model="editVolume" class="form-select">
|
||||||
@@ -338,6 +359,21 @@
|
|||||||
<label>自定义滴数</label>
|
<label>自定义滴数</label>
|
||||||
<input v-model.number="editDropCount" class="form-input" type="number" />
|
<input v-model.number="editDropCount" class="form-input" type="number" />
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- 其他产品容量 -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>容量</label>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<input v-model.number="editProductAmount" class="form-input" type="number" min="1" style="flex:1" />
|
||||||
|
<select v-model="editProductUnit" class="form-select" style="width:70px">
|
||||||
|
<option value="ml">ml</option>
|
||||||
|
<option value="g">g</option>
|
||||||
|
<option value="capsule">颗</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>会员价 (¥)</label>
|
<label>会员价 (¥)</label>
|
||||||
<input v-model.number="editBottlePrice" class="form-input" type="number" />
|
<input v-model.number="editBottlePrice" class="form-input" type="number" />
|
||||||
@@ -406,14 +442,22 @@ import { useAuthStore } from '../stores/auth'
|
|||||||
import { useUiStore } from '../stores/ui'
|
import { useUiStore } from '../stores/ui'
|
||||||
import { useRecipesStore } from '../stores/recipes'
|
import { useRecipesStore } from '../stores/recipes'
|
||||||
import { oilEn } from '../composables/useOilTranslation'
|
import { oilEn } from '../composables/useOilTranslation'
|
||||||
import { getOilCard, setOilCard } from '../composables/useOilCards'
|
import { getOilCard } from '../composables/useOilCards'
|
||||||
import { showConfirm } from '../composables/useDialog'
|
import { showConfirm } from '../composables/useDialog'
|
||||||
|
import { api } from '../composables/useApi'
|
||||||
|
import { parseOilProductPaste } from '../composables/useOilProductPaste'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const oils = useOilsStore()
|
const oils = useOilsStore()
|
||||||
const recipeStore = useRecipesStore()
|
const recipeStore = useRecipesStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
|
|
||||||
|
// Brand data for card
|
||||||
|
const brand = ref({})
|
||||||
|
async function loadBrand() {
|
||||||
|
try { brand.value = await api.get('/api/brand') } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// Modal states
|
// Modal states
|
||||||
const showDilution = ref(false)
|
const showDilution = ref(false)
|
||||||
const showContra = ref(false)
|
const showContra = ref(false)
|
||||||
@@ -433,6 +477,28 @@ const activeCard = ref(null)
|
|||||||
|
|
||||||
// Add oil form
|
// Add oil form
|
||||||
const addType = ref('oil')
|
const addType = ref('oil')
|
||||||
|
const showSmartPaste = ref(false)
|
||||||
|
const smartPasteText = ref('')
|
||||||
|
|
||||||
|
function runSmartPaste() {
|
||||||
|
const raw = smartPasteText.value || ''
|
||||||
|
if (!raw.trim()) return
|
||||||
|
const parsed = parseOilProductPaste(raw)
|
||||||
|
if (parsed.memberPrice != null) newBottlePrice.value = parsed.memberPrice
|
||||||
|
if (parsed.retailPrice != null) newRetailPrice.value = parsed.retailPrice
|
||||||
|
if (parsed.cn) newOilName.value = parsed.cn
|
||||||
|
if (parsed.en) newOilEnName.value = parsed.en
|
||||||
|
addType.value = parsed.type
|
||||||
|
if (parsed.type === 'oil') {
|
||||||
|
if (parsed.volume) newVolume.value = parsed.volume
|
||||||
|
newCustomDrops.value = null
|
||||||
|
} else {
|
||||||
|
if (parsed.productAmount != null) newProductAmount.value = parsed.productAmount
|
||||||
|
if (parsed.productUnit) newProductUnit.value = parsed.productUnit
|
||||||
|
}
|
||||||
|
ui.showToast('已识别并填入,请检查后点添加')
|
||||||
|
}
|
||||||
|
|
||||||
const newOilName = ref('')
|
const newOilName = ref('')
|
||||||
const newOilEnName = ref('')
|
const newOilEnName = ref('')
|
||||||
const newBottlePrice = ref(null)
|
const newBottlePrice = ref(null)
|
||||||
@@ -450,6 +516,9 @@ const editVolume = ref('5')
|
|||||||
const editDropCount = ref(0)
|
const editDropCount = ref(0)
|
||||||
const editRetailPrice = ref(null)
|
const editRetailPrice = ref(null)
|
||||||
const editOilEnName = ref('')
|
const editOilEnName = ref('')
|
||||||
|
const editUnit = ref('drop')
|
||||||
|
const editProductAmount = ref(null)
|
||||||
|
const editProductUnit = ref('ml')
|
||||||
const editCardEmoji = ref('')
|
const editCardEmoji = ref('')
|
||||||
const editCardEffects = ref('')
|
const editCardEffects = ref('')
|
||||||
const editCardUsage = ref('')
|
const editCardUsage = ref('')
|
||||||
@@ -576,8 +645,14 @@ const filteredOilNames = computed(() => {
|
|||||||
if (!searchQuery.value.trim()) return oils.oilNames
|
if (!searchQuery.value.trim()) return oils.oilNames
|
||||||
const q = searchQuery.value.trim().toLowerCase()
|
const q = searchQuery.value.trim().toLowerCase()
|
||||||
return oils.oilNames.filter(n => {
|
return oils.oilNames.filter(n => {
|
||||||
const en = getEnglishName(n).toLowerCase()
|
if (n.toLowerCase().includes(q)) return true
|
||||||
return n.toLowerCase().includes(q) || en.includes(q)
|
const card = getOilCard(n)
|
||||||
|
if (card?.en && card.en.toLowerCase().includes(q)) return true
|
||||||
|
const meta = oils.oilsMeta[n]
|
||||||
|
if (meta?.enName && meta.enName.toLowerCase().includes(q)) return true
|
||||||
|
const fallback = oilEn(n)
|
||||||
|
if (fallback && fallback.toLowerCase().includes(q)) return true
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -595,12 +670,12 @@ function getMeta(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEnglishName(name) {
|
function getEnglishName(name) {
|
||||||
// 1. Oil card has priority
|
// 1. User-edited en_name in DB wins — prevents saves being masked by static cards
|
||||||
const card = getOilCard(name)
|
|
||||||
if (card && card.en) return card.en
|
|
||||||
// 2. Stored en_name in meta
|
|
||||||
const meta = oils.oilsMeta[name]
|
const meta = oils.oilsMeta[name]
|
||||||
if (meta?.enName) return meta.enName
|
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
|
// 3. Static translation map
|
||||||
return oilEn(name)
|
return oilEn(name)
|
||||||
}
|
}
|
||||||
@@ -637,12 +712,9 @@ async function openOilDetail(name) {
|
|||||||
activeCardName.value = name
|
activeCardName.value = name
|
||||||
activeCard.value = card
|
activeCard.value = card
|
||||||
selectedOilName.value = null
|
selectedOilName.value = null
|
||||||
// Pre-generate card image for instant save
|
loadBrand()
|
||||||
|
// Generate image on demand when saving, not on open
|
||||||
oilCardImageUrl.value = null
|
oilCardImageUrl.value = null
|
||||||
await nextTick()
|
|
||||||
await new Promise(r => setTimeout(r, 300))
|
|
||||||
const el = document.querySelector('.oil-card-modal')
|
|
||||||
if (el) await generateImageFromRef({ value: el }, oilCardImageUrl)
|
|
||||||
} else {
|
} else {
|
||||||
activeCard.value = null
|
activeCard.value = null
|
||||||
activeCardName.value = null
|
activeCardName.value = null
|
||||||
@@ -712,6 +784,11 @@ function editOil(name) {
|
|||||||
editDropCount.value = dc
|
editDropCount.value = dc
|
||||||
editRetailPrice.value = meta?.retailPrice || null
|
editRetailPrice.value = meta?.retailPrice || null
|
||||||
editOilEnName.value = meta?.enName || getEnglishName(name) || ''
|
editOilEnName.value = meta?.enName || getEnglishName(name) || ''
|
||||||
|
editUnit.value = meta?.unit || 'drop'
|
||||||
|
if (editUnit.value !== 'drop') {
|
||||||
|
editProductAmount.value = dc
|
||||||
|
editProductUnit.value = editUnit.value
|
||||||
|
}
|
||||||
// Load knowledge card if exists
|
// Load knowledge card if exists
|
||||||
const card = getOilCard(name)
|
const card = getOilCard(name)
|
||||||
editCardEmoji.value = card?.emoji || ''
|
editCardEmoji.value = card?.emoji || ''
|
||||||
@@ -737,25 +814,26 @@ async function saveEditOil() {
|
|||||||
if (newName && newName !== oldName) {
|
if (newName && newName !== oldName) {
|
||||||
await oils.deleteOil(oldName)
|
await oils.deleteOil(oldName)
|
||||||
}
|
}
|
||||||
await oils.saveOil(
|
const finalDropCount = editUnit.value !== 'drop' ? editProductAmount.value : dropCount
|
||||||
newName || oldName,
|
const finalUnit = editUnit.value !== 'drop' ? editProductUnit.value : null
|
||||||
editBottlePrice.value,
|
// Build card payload if any card content provided
|
||||||
dropCount,
|
const hasCard = editCardEffects.value.trim() || editCardUsage.value.trim()
|
||||||
editRetailPrice.value,
|
const cardPayload = hasCard ? {
|
||||||
editOilEnName.value.trim() || null
|
|
||||||
)
|
|
||||||
// Save knowledge card if any content provided
|
|
||||||
const finalName = newName || oldName
|
|
||||||
if (editCardEffects.value.trim() || editCardUsage.value.trim()) {
|
|
||||||
setOilCard(finalName, {
|
|
||||||
emoji: editCardEmoji.value || '🌿',
|
emoji: editCardEmoji.value || '🌿',
|
||||||
en: editOilEnName.value.trim() || '',
|
|
||||||
effects: editCardEffects.value.trim(),
|
effects: editCardEffects.value.trim(),
|
||||||
usage: editCardUsage.value.trim(),
|
usage: editCardUsage.value.trim(),
|
||||||
method: editCardMethod.value.trim(),
|
method: editCardMethod.value.trim(),
|
||||||
caution: editCardCaution.value.trim(),
|
caution: editCardCaution.value.trim(),
|
||||||
})
|
} : null
|
||||||
}
|
await oils.saveOil(
|
||||||
|
newName || oldName,
|
||||||
|
editBottlePrice.value,
|
||||||
|
finalDropCount,
|
||||||
|
editRetailPrice.value,
|
||||||
|
editOilEnName.value.trim() || null,
|
||||||
|
finalUnit,
|
||||||
|
cardPayload
|
||||||
|
)
|
||||||
cardVersion.value++ // trigger re-render for card badges
|
cardVersion.value++ // trigger re-render for card badges
|
||||||
ui.showToast('已更新')
|
ui.showToast('已更新')
|
||||||
editingOilName.value = null
|
editingOilName.value = null
|
||||||
@@ -811,65 +889,40 @@ async function removeOil(name) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PDF Export
|
// Excel Export
|
||||||
function exportPDF() {
|
async function exportExcel() {
|
||||||
|
const XLSX = (await import('xlsx')).default || await import('xlsx')
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const dateStr = today.getFullYear() + String(today.getMonth()+1).padStart(2,'0') + String(today.getDate()).padStart(2,'0')
|
const dateStr = today.getFullYear() + '-' + String(today.getMonth()+1).padStart(2,'0') + '-' + String(today.getDate()).padStart(2,'0')
|
||||||
const title = '精油价目表' + dateStr
|
|
||||||
|
|
||||||
const sortedNames = [...oils.oilNames].sort((a, b) => a.localeCompare(b, 'zh'))
|
const sortedNames = [...oils.oilNames].sort((a, b) => a.localeCompare(b, 'zh'))
|
||||||
let rows = ''
|
const rows = []
|
||||||
for (const name of sortedNames) {
|
for (const name of sortedNames) {
|
||||||
const meta = getMeta(name)
|
const meta = getMeta(name)
|
||||||
if (!meta) continue
|
if (!meta) continue
|
||||||
const en = getEnglishName(name)
|
const en = meta.enName || getEnglishName(name)
|
||||||
const bp = meta.bottlePrice != null ? '¥' + meta.bottlePrice.toFixed(0) : '--'
|
const vol = volumeLabel(meta.dropCount, name)
|
||||||
const rp = meta.retailPrice != null ? '¥' + meta.retailPrice.toFixed(0) : '--'
|
const unit = oilPriceUnit(name)
|
||||||
const vol = volumeLabel(meta.dropCount)
|
const ppdNum = oils.pricePerDrop(name)
|
||||||
const ppd = oils.pricePerDrop(name) ? '¥' + oils.pricePerDrop(name).toFixed(2) : '--'
|
const card = getOilCard(name)
|
||||||
rows += `<tr>
|
rows.push({
|
||||||
<td>${name}</td>
|
'精油': name,
|
||||||
<td>${en}</td>
|
'英文名': en,
|
||||||
<td>${bp}</td>
|
'会员价': meta.bottlePrice != null ? Number(meta.bottlePrice.toFixed(2)) : '',
|
||||||
<td>${rp}</td>
|
'零售价': meta.retailPrice != null ? Number(meta.retailPrice.toFixed(2)) : '',
|
||||||
<td>${vol}</td>
|
'容量': vol,
|
||||||
<td>${ppd}</td>
|
'单价': ppdNum ? `¥${ppdNum.toFixed(2)}/${unit}` : '',
|
||||||
</tr>`
|
'功效': card?.effects || '',
|
||||||
|
'状态': meta.isActive === false ? '下架' : '在售',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const html = `<!DOCTYPE html>
|
|
||||||
<html>
|
const ws = XLSX.utils.json_to_sheet(rows)
|
||||||
<head>
|
ws['!cols'] = [{ wch: 16 }, { wch: 28 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 16 }, { wch: 40 }, { wch: 8 }]
|
||||||
<meta charset="utf-8">
|
const wb = XLSX.utils.book_new()
|
||||||
<title>${title}</title>
|
XLSX.utils.book_append_sheet(wb, ws, '精油价目表')
|
||||||
<style>
|
XLSX.writeFile(wb, `精油价目表${dateStr}.xlsx`)
|
||||||
body { font-family: 'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif; padding: 20px; font-size: 11px; color: #333; }
|
ui.showToast('导出成功')
|
||||||
h1 { font-size: 18px; text-align: center; margin-bottom: 16px; }
|
|
||||||
table { width: 100%; border-collapse: collapse; }
|
|
||||||
th { background: #7a9e7e; color: white; padding: 6px 8px; text-align: center; font-size: 11px; font-weight: 600; }
|
|
||||||
td { padding: 5px 8px; border-bottom: 1px solid #e0e0e0; text-align: center; font-size: 11px; }
|
|
||||||
td:first-child, th:first-child { text-align: left; font-weight: 500; }
|
|
||||||
td:nth-child(2), th:nth-child(2) { text-align: left; }
|
|
||||||
tr:nth-child(even) { background: #f9f9f9; }
|
|
||||||
tr:hover { background: #e8f5e9; }
|
|
||||||
@media print { body { padding: 10px; } h1 { font-size: 16px; } }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>doTERRA 精油价目表 ${dateStr}</h1>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>精油</th><th>英文名</th><th>会员价</th><th>零售价</th><th>容量</th><th>单价/滴</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
<p style="text-align:center;font-size:10px;color:#aaa;margin-top:12px">共 ${sortedNames.length} 种精油 · doTERRA 配方计算器导出</p>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
const w = window.open('', '_blank')
|
|
||||||
w.document.write(html)
|
|
||||||
w.document.close()
|
|
||||||
w.document.title = title
|
|
||||||
setTimeout(() => w.print(), 500)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──── Save image logic (identical to RecipeDetailOverlay) ────
|
// ──── Save image logic (identical to RecipeDetailOverlay) ────
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>精油</th>
|
<th>精油</th>
|
||||||
<th>用量</th>
|
<th>用量</th>
|
||||||
<th>每滴</th>
|
<th>单价</th>
|
||||||
<th>小计</th>
|
<th>小计</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -133,8 +133,8 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="c in consumptionData" :key="c.oil" :class="{ 'limit-oil': c.isLimit }">
|
<tr v-for="c in consumptionData" :key="c.oil" :class="{ 'limit-oil': c.isLimit }">
|
||||||
<td>{{ c.oil }}</td>
|
<td>{{ c.oil }}</td>
|
||||||
<td>{{ c.drops }}滴</td>
|
<td>{{ c.drops }}{{ oils.unitLabel(c.oil) }}</td>
|
||||||
<td>{{ c.bottleDrops }}滴</td>
|
<td>{{ c.bottleDrops }}{{ oils.unitLabel(c.oil) }}</td>
|
||||||
<td>{{ c.sessions }}次</td>
|
<td>{{ c.sessions }}次</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<div class="action-bar">
|
<div class="action-bar">
|
||||||
<button class="action-chip" @click="showAddOverlay = true">新增</button>
|
<button class="action-chip" @click="oils.loadOils(); showAddOverlay = true">新增</button>
|
||||||
<button class="action-chip" :class="{ active: isAllSelected }" @click="toggleSelectAll">
|
<button class="action-chip" :class="{ active: isAllSelected }" @click="toggleSelectAll">
|
||||||
全选<span v-if="totalSelected > 0" class="chip-count">{{ totalSelected }}</span>
|
全选<span v-if="totalSelected > 0" class="chip-count">{{ totalSelected }}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -808,6 +808,7 @@ function editRecipe(recipe) {
|
|||||||
}
|
}
|
||||||
formNote.value = recipe.note || ''
|
formNote.value = recipe.note || ''
|
||||||
formTags.value = [...(recipe.tags || [])]
|
formTags.value = [...(recipe.tags || [])]
|
||||||
|
oils.loadOils()
|
||||||
showAddOverlay.value = true
|
showAddOverlay.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1698,7 +1699,7 @@ async function exportExcel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
XLSX.writeFile(wb, `精油配方${today}.xlsx`)
|
XLSX.writeFile(wb, `精油配方备份${today}.xlsx`)
|
||||||
ui.showToast('导出成功')
|
ui.showToast('导出成功')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2113,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 { 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 th:first-child { text-align: left; }
|
||||||
.editor-table td { padding: 6px 4px; border-bottom: 1px solid #f5f5f5; }
|
.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; }
|
.editor-drops:focus { border-color: #7ec6a4; }
|
||||||
.drops-with-unit { display: flex; align-items: center; gap: 2px; }
|
.drops-with-unit { display: flex; align-items: center; gap: 2px; }
|
||||||
.unit-hint { font-size: 11px; color: #b0aab5; white-space: nowrap; }
|
.unit-hint { font-size: 11px; color: #b0aab5; white-space: nowrap; }
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ function expandQuery(q) {
|
|||||||
return terms
|
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(() => {
|
const exactResults = computed(() => {
|
||||||
if (!searchQuery.value.trim()) return []
|
if (!searchQuery.value.trim()) return []
|
||||||
const q = searchQuery.value.trim().toLowerCase()
|
const q = searchQuery.value.trim().toLowerCase()
|
||||||
@@ -322,9 +322,10 @@ const exactResults = computed(() => {
|
|||||||
const nameMatch = r.name.toLowerCase().includes(q)
|
const nameMatch = r.name.toLowerCase().includes(q)
|
||||||
const enNameMatch = isEn && (r.en_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 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 visibleTags = auth.canEdit ? (r.tags || []) : (r.tags || []).filter(t => !EDITOR_ONLY_TAGS.includes(t))
|
||||||
const tagMatch = visibleTags.some(t => t.toLowerCase().includes(q))
|
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'))
|
}).sort((a, b) => a.name.localeCompare(b.name, 'zh'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user