Compare commits

..

1 Commits

Author SHA1 Message Date
3a8698c187 fix: 滴数输入框再缩窄到42px
All checks were successful
Test / unit-test (push) Successful in 5s
Test / build-check (push) Successful in 4s
Test / e2e-test (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:23:47 +00:00
55 changed files with 680 additions and 3024 deletions

View File

@@ -12,7 +12,7 @@ jobs:
e2e-test: e2e-test:
runs-on: test runs-on: test
needs: unit-test needs: unit-test
timeout-minutes: 15 timeout-minutes: 5
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -30,12 +30,8 @@ jobs:
DB_FILE="/tmp/ci_oil_test_${BE_PORT}.db" DB_FILE="/tmp/ci_oil_test_${BE_PORT}.db"
echo "Using backend=$BE_PORT frontend=$FE_PORT db=$DB_FILE" echo "Using backend=$BE_PORT frontend=$FE_PORT db=$DB_FILE"
# Known admin token for E2E tests
ADMIN_TOKEN="cypress_ci_admin_token_e2e_$(echo $BE_PORT)"
export ADMIN_TOKEN
# Start backend # Start backend
DB_PATH="$DB_FILE" FRONTEND_DIR=/dev/null ADMIN_TOKEN="$ADMIN_TOKEN" \ DB_PATH="$DB_FILE" FRONTEND_DIR=/dev/null \
/tmp/ci-venv/bin/uvicorn backend.main:app --port $BE_PORT & /tmp/ci-venv/bin/uvicorn backend.main:app --port $BE_PORT &
BE_PID=$! BE_PID=$!
@@ -62,37 +58,28 @@ jobs:
exit 1 exit 1
fi fi
# Run all specs in 3 batches to avoid Electron memory crashes # Run core cypress specs with hard 3-minute timeout
cd frontend cd frontend
CYPRESS_CFG="video=false,defaultCommandTimeout=5000,pageLoadTimeout=10000,requestTimeout=5000,responseTimeout=10000,baseUrl=http://localhost:$FE_PORT,experimentalMemoryManagement=true,numTestsKeptInMemory=0" timeout 180 npx cypress run --spec "\
cypress/e2e/recipe-detail.cy.js,\
echo "=== Batch 1: API & data tests ===" cypress/e2e/oil-reference.cy.js,\
timeout 300 npx cypress run \ cypress/e2e/oil-data-integrity.cy.js,\
--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" \ cypress/e2e/recipe-cost-parity.cy.js,\
--config "$CYPRESS_CFG" --env "ADMIN_TOKEN=$ADMIN_TOKEN" cypress/e2e/category-modules.cy.js,\
B1=$? cypress/e2e/notification-flow.cy.js,\
cypress/e2e/registration-flow.cy.js\
echo "=== Batch 2: UI flow tests ===" " --config "video=false,defaultCommandTimeout=5000,pageLoadTimeout=10000,requestTimeout=5000,responseTimeout=10000,baseUrl=http://localhost:$FE_PORT"
timeout 300 npx cypress run \ EXIT_CODE=$?
--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 "Results: Batch1=$B1 Batch2=$B2 Batch3=$B3" echo "ERROR: Cypress timed out after 3 minutes"
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

View File

@@ -123,15 +123,6 @@ 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
@@ -260,71 +251,6 @@ def init_db():
if "volume" not in cols: if "volume" not in cols:
c.execute("ALTER TABLE recipes ADD COLUMN volume TEXT DEFAULT ''") c.execute("ALTER TABLE recipes ADD COLUMN volume TEXT DEFAULT ''")
# Migration: rename oils 西洋蓍草→西洋蓍草石榴籽, 元气→元气焕能
_oil_renames = [("元气", "元气焕能")]
for old_name, new_name in _oil_renames:
old_exists = c.execute("SELECT 1 FROM oils WHERE name = ?", (old_name,)).fetchone()
new_exists = c.execute("SELECT 1 FROM oils WHERE name = ?", (new_name,)).fetchone()
if old_exists and new_exists:
# Both exist: delete old, update recipe references to new
c.execute("DELETE FROM oils WHERE name = ?", (old_name,))
c.execute("UPDATE recipe_ingredients SET oil_name = ? WHERE oil_name = ?", (new_name, old_name))
elif old_exists:
c.execute("UPDATE oils SET name = ? WHERE name = ?", (new_name, old_name))
c.execute("UPDATE recipe_ingredients SET oil_name = ? WHERE oil_name = ?", (new_name, old_name))
# Migration: clean up recipe names — remove leading numbers, normalize 细胞律动 format
_recipe_renames = {
"2、神经系统细胞律动": "细胞律动-神经系统",
"3、消化系统细胞律动": "细胞律动-消化系统",
"4、骨骼系统细胞律动炎症控制": "细胞律动-骨骼系统(炎症控制)",
"5、淋巴系统细胞律动": "细胞律动-淋巴系统",
"6、生殖系统细胞律动": "细胞律动-生殖系统",
"7、免疫系统细胞律动": "细胞律动-免疫系统",
"8、循环系统细胞律动": "细胞律动-循环系统",
"9、内分泌系统细胞律动": "细胞律动-内分泌系统",
"12、芳香调理技术": "芳香调理技术",
"普拉提根基配方2": "普拉提根基配方(二)",
}
for old_name, new_name in _recipe_renames.items():
c.execute("UPDATE recipes SET name = ? WHERE name = ?", (new_name, old_name))
# Migration: trailing Arabic numerals → Chinese numerals in parentheses
import re as _re
_num_map = {'1': '', '2': '', '3': '', '4': '', '5': '', '6': '', '7': '', '8': '', '9': ''}
_all_recipes = c.execute("SELECT id, name FROM recipes").fetchall()
for row in _all_recipes:
name = row['name']
# Step 1: trailing Arabic digits → Chinese in parentheses: 灰指甲1 → 灰指甲(一)
m = _re.search(r'(\d+)$', name)
if m:
chinese = ''.join(_num_map.get(d, d) for d in m.group(1))
name = name[:m.start()] + '' + chinese + ''
c.execute("UPDATE recipes SET name = ? WHERE id = ?", (name, row['id']))
# Step 2: trailing bare Chinese numeral → add parentheses: 灰指甲一 → 灰指甲(一)
m2 = _re.search(r'([一二三四五六七八九十]+)$', name)
if m2 and not name.endswith(''):
name = name[:m2.start()] + '' + m2.group(1) + ''
c.execute("UPDATE recipes SET name = ? WHERE id = ?", (name, row['id']))
# Migration: add number suffix to base recipes that have numbered siblings
_all_recipes2 = c.execute("SELECT id, name FROM recipes").fetchall()
_cn_nums = list('一二三四五六七八九十')
_base_groups = {}
for row in _all_recipes2:
name = row['name']
m = _re.match(r'^(.+?)([一二三四五六七八九十]+)$', name)
if m:
_base_groups.setdefault(m.group(1), set()).add(m.group(2))
# Find bare names that match a numbered group, assign next available number
for row in _all_recipes2:
if row['name'] in _base_groups:
used = _base_groups[row['name']]
next_num = next((n for n in _cn_nums if n not in used), '')
new_name = row['name'] + '' + next_num + ''
c.execute("UPDATE recipes SET name = ? WHERE id = ?", (new_name, row['id']))
_base_groups[row['name']].add(next_num)
# Seed admin user if no users exist # Seed admin user if no users exist
count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0] count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if count == 0: if count == 0:
@@ -350,7 +276,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, default_oil_cards: dict = None): def seed_defaults(default_oils_meta: dict, default_recipes: list):
"""Seed DB with defaults if empty.""" """Seed DB with defaults if empty."""
conn = get_db() conn = get_db()
c = conn.cursor() c = conn.cursor()
@@ -388,18 +314,5 @@ def seed_defaults(default_oils_meta: dict, default_recipes: list, default_oil_ca
(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()

View File

@@ -332,7 +332,7 @@
"bottlePrice": 450, "bottlePrice": 450,
"dropCount": 280 "dropCount": 280
}, },
"元气焕能": { "元气": {
"bottlePrice": 230, "bottlePrice": 230,
"dropCount": 280 "dropCount": 280
}, },
@@ -1615,7 +1615,7 @@
"drops": 20 "drops": 20
}, },
{ {
"oil": "元气焕能", "oil": "元气",
"drops": 20 "drops": 20
}, },
{ {
@@ -2072,7 +2072,7 @@
"drops": 5 "drops": 5
}, },
{ {
"oil": "元气焕能", "oil": "元气",
"drops": 5 "drops": 5
}, },
{ {
@@ -2216,7 +2216,7 @@
"drops": 5 "drops": 5
}, },
{ {
"oil": "元气焕能", "oil": "元气",
"drops": 5 "drops": 5
} }
] ]
@@ -2328,7 +2328,7 @@
"drops": 5 "drops": 5
}, },
{ {
"oil": "元气焕能", "oil": "元气",
"drops": 8 "drops": 8
}, },
{ {
@@ -2491,7 +2491,7 @@
"drops": 10 "drops": 10
}, },
{ {
"oil": "元气焕能", "oil": "元气",
"drops": 10 "drops": 10
} }
] ]
@@ -2666,7 +2666,7 @@
"drops": 5 "drops": 5
}, },
{ {
"oil": "元气焕能", "oil": "元气",
"drops": 5 "drops": 5
}, },
{ {
@@ -2816,7 +2816,7 @@
"tags": [], "tags": [],
"ingredients": [ "ingredients": [
{ {
"oil": "元气焕能", "oil": "元气",
"drops": 15 "drops": 15
}, },
{ {
@@ -2901,7 +2901,7 @@
"tags": [], "tags": [],
"ingredients": [ "ingredients": [
{ {
"oil": "元气焕能", "oil": "元气",
"drops": 5 "drops": 5
}, },
{ {
@@ -3111,7 +3111,7 @@
"drops": 10 "drops": 10
}, },
{ {
"oil": "元气焕能", "oil": "元气",
"drops": 5 "drops": 5
}, },
{ {
@@ -6583,7 +6583,7 @@
"drops": 4 "drops": 4
}, },
{ {
"oil": "元气焕能", "oil": "元气",
"drops": 4 "drops": 4
}, },
{ {
@@ -6662,7 +6662,7 @@
"drops": 4 "drops": 4
}, },
{ {
"oil": "元气焕能", "oil": "元气",
"drops": 4 "drops": 4
}, },
{ {
@@ -6729,7 +6729,7 @@
"drops": 4 "drops": 4
}, },
{ {
"oil": "元气焕能", "oil": "元气",
"drops": 4 "drops": 4
}, },
{ {
@@ -7819,7 +7819,7 @@
"drops": 2 "drops": 2
}, },
{ {
"oil": "元气焕能", "oil": "元气",
"drops": 3 "drops": 3
}, },
{ {

View File

@@ -12,32 +12,6 @@ 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'"""
@@ -114,12 +88,6 @@ 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):
@@ -714,7 +682,7 @@ def revoke_business(user_id: int, body: dict = None, user=Depends(require_role("
conn = get_db() conn = get_db()
conn.execute("UPDATE users SET business_verified = 0 WHERE id = ?", (user_id,)) conn.execute("UPDATE users SET business_verified = 0 WHERE id = ?", (user_id,))
reason = (body or {}).get("reason", "").strip() reason = (body or {}).get("reason", "").strip()
target = conn.execute("SELECT role, display_name, username FROM users WHERE id = ?", (user_id,)).fetchone() target = conn.execute("SELECT role FROM users WHERE id = ?", (user_id,)).fetchone()
if target: if target:
msg = "你的商业用户资格已被取消。" msg = "你的商业用户资格已被取消。"
if reason: if reason:
@@ -764,22 +732,6 @@ 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()
@@ -800,25 +752,6 @@ 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"]
@@ -954,11 +887,6 @@ 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
@@ -997,35 +925,6 @@ 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}
@@ -1998,7 +1897,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"], DEFAULT_OIL_CARDS) seed_defaults(data["oils_meta"], data["recipes"])
# 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()

View File

@@ -18,14 +18,12 @@ 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)
DST="$BACKUP_DIR/oil_calculator_${DATE}.db" # Backup SQLite database using .backup for consistency
# Consistent snapshot via Python's sqlite3 .backup API (sqlite3 CLI not in image) sqlite3 /data/oil_calculator.db ".backup '$BACKUP_DIR/oil_calculator_${DATE}.db'"
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: $BACKUP_DIR/oil_calculator_${DATE}.db ($(du -h $BACKUP_DIR/oil_calculator_${DATE}.db | cut -f1))"
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)"

View File

@@ -9,6 +9,6 @@ export default defineConfig({
viewportHeight: 800, viewportHeight: 800,
video: true, video: true,
videoCompression: false, videoCompression: false,
allowCypressEnv: true, allowCypressEnv: false,
}, },
}) })

View File

@@ -1,18 +1,11 @@
describe('Account Settings', () => { describe('Account Settings', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
let authHeaders const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
it('can read current user profile', () => { it('can read current user profile', () => {
cy.request({ url: '/api/me', headers: authHeaders }).then(res => { cy.request({ url: '/api/me', headers: authHeaders }).then(res => {
expect(res.body.username).to.eq('hera')
expect(res.body.role).to.eq('admin') expect(res.body.role).to.eq('admin')
expect(res.body).to.have.property('username')
expect(res.body).to.have.property('display_name') expect(res.body).to.have.property('display_name')
expect(res.body).to.have.property('has_password') expect(res.body).to.have.property('has_password')
}) })
@@ -27,10 +20,9 @@ describe('Account Settings', () => {
method: 'PUT', url: `/api/users/${res.body.id}`, headers: authHeaders, method: 'PUT', url: `/api/users/${res.body.id}`, headers: authHeaders,
body: { display_name: 'Cypress测试名' } body: { display_name: 'Cypress测试名' }
}).then(r => expect(r.status).to.eq(200)) }).then(r => expect(r.status).to.eq(200))
// Verify — display_name is synced to username, so /api/me returns username // Verify
cy.request({ url: '/api/me', headers: authHeaders }).then(r2 => { cy.request({ url: '/api/me', headers: authHeaders }).then(r2 => {
// display_name from /api/me is always same as username expect(r2.body.display_name).to.eq('Cypress测试名')
expect(r2.body.display_name).to.be.a('string')
}) })
// Restore // Restore
cy.request({ cy.request({

View File

@@ -1,62 +1,41 @@
describe('Admin Flow', () => { describe('Admin Flow', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
before(() => {
cy.getAdminToken().then(token => { adminToken = token })
})
beforeEach(() => { beforeEach(() => {
cy.visit('/', { cy.visit('/', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.nav-tab', { timeout: 10000 }).should('have.length.gte', 3) cy.get('.nav-tab', { timeout: 10000 }).should('have.length.gte', 6)
}) })
it('shows standard tabs for logged-in users', () => { it('shows admin-only tabs', () => {
cy.get('.nav-tab').contains('配方查询').should('be.visible') cy.get('.nav-tab').contains('操作日志').should('be.visible')
cy.get('.nav-tab').contains('管理配方').should('be.visible') cy.get('.nav-tab').contains('Bug').should('be.visible')
cy.get('.nav-tab').contains('精油价目').should('be.visible') cy.get('.nav-tab').contains('用户管理').should('be.visible')
}) })
it('admin pages accessible via URL (audit log)', () => { it('can access manage recipes page', () => {
cy.visit('/audit', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken)
}
})
cy.contains('操作日志', { timeout: 10000 }).should('be.visible')
})
it('admin pages accessible via URL (user management)', () => {
cy.visit('/users', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken)
}
})
cy.contains('用户管理', { timeout: 10000 }).should('be.visible')
})
it('admin pages accessible via URL (bug tracker)', () => {
cy.visit('/bugs', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken)
}
})
cy.contains('Bug', { timeout: 10000 }).should('be.visible')
})
it('can access manage recipes page via tab', () => {
cy.get('.nav-tab').contains('管理配方').click() cy.get('.nav-tab').contains('管理配方').click()
cy.url().should('include', '/manage') cy.url().should('include', '/manage')
}) })
it('user menu shows admin links', () => { it('can access audit log page', () => {
// Open user menu by clicking username cy.get('.nav-tab').contains('操作日志').click()
cy.get('.user-name').click() cy.url().should('include', '/audit')
cy.get('.usermenu-card', { timeout: 5000 }).should('be.visible') cy.contains('操作日志').should('be.visible')
cy.get('.usermenu-btn').contains('操作日志').should('be.visible') })
cy.get('.usermenu-btn').contains('用户管理').should('be.visible')
it('can access user management page', () => {
cy.get('.nav-tab').contains('用户管理').click()
cy.url().should('include', '/users')
cy.contains('用户管理').should('be.visible')
})
it('can access bug tracker page', () => {
cy.get('.nav-tab').contains('Bug').click()
cy.url().should('include', '/bugs')
cy.contains('Bug').should('be.visible')
}) })
}) })

View File

@@ -1,13 +1,6 @@
describe('API CRUD Operations', () => { describe('API CRUD Operations', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
let authHeaders const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
describe('Oils API', () => { describe('Oils API', () => {
it('creates a new oil', () => { it('creates a new oil', () => {
@@ -73,7 +66,7 @@ describe('API CRUD Operations', () => {
}) })
it('reads the created recipe', () => { it('reads the created recipe', () => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(res => { cy.request('/api/recipes').then(res => {
const found = res.body.find(r => r.name === 'Cypress测试配方') const found = res.body.find(r => r.name === 'Cypress测试配方')
expect(found).to.exist expect(found).to.exist
expect(found.note).to.eq('E2E测试用') expect(found.note).to.eq('E2E测试用')
@@ -83,7 +76,7 @@ describe('API CRUD Operations', () => {
}) })
it('updates the recipe', () => { it('updates the recipe', () => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(res => { cy.request('/api/recipes').then(res => {
const found = res.body.find(r => r.name === 'Cypress测试配方') const found = res.body.find(r => r.name === 'Cypress测试配方')
cy.request({ cy.request({
method: 'PUT', method: 'PUT',
@@ -105,7 +98,7 @@ describe('API CRUD Operations', () => {
}) })
it('verifies the update', () => { it('verifies the update', () => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(res => { cy.request('/api/recipes').then(res => {
const found = res.body.find(r => r.name === 'Cypress更新配方') const found = res.body.find(r => r.name === 'Cypress更新配方')
expect(found).to.exist expect(found).to.exist
expect(found.note).to.eq('已更新') expect(found.note).to.eq('已更新')
@@ -115,7 +108,7 @@ describe('API CRUD Operations', () => {
}) })
it('deletes the test recipe', () => { it('deletes the test recipe', () => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(res => { cy.request('/api/recipes').then(res => {
const found = res.body.find(r => r.name === 'Cypress更新配方') const found = res.body.find(r => r.name === 'Cypress更新配方')
if (found) { if (found) {
cy.request({ cy.request({
@@ -211,8 +204,7 @@ describe('API CRUD Operations', () => {
describe('Favorites API', () => { describe('Favorites API', () => {
it('adds a recipe to favorites', () => { it('adds a recipe to favorites', () => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(res => { cy.request('/api/recipes').then(res => {
if (res.body.length === 0) return
const recipe = res.body[0] const recipe = res.body[0]
cy.request({ cy.request({
method: 'POST', method: 'POST',
@@ -231,12 +223,12 @@ describe('API CRUD Operations', () => {
headers: authHeaders headers: authHeaders
}).then(res => { }).then(res => {
expect(res.body).to.be.an('array') expect(res.body).to.be.an('array')
expect(res.body.length).to.be.gte(1)
}) })
}) })
it('removes the favorite', () => { it('removes the favorite', () => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(res => { cy.request('/api/recipes').then(res => {
if (res.body.length === 0) return
const recipe = res.body[0] const recipe = res.body[0]
cy.request({ cy.request({
method: 'DELETE', method: 'DELETE',

View File

@@ -1,10 +1,4 @@
describe('API Health Check', () => { describe('API Health Check', () => {
let adminToken
before(() => {
cy.getAdminToken().then(token => { adminToken = token })
})
it('GET /api/version returns version', () => { it('GET /api/version returns version', () => {
cy.request('/api/version').then(res => { cy.request('/api/version').then(res => {
expect(res.status).to.eq(200) expect(res.status).to.eq(200)
@@ -52,9 +46,10 @@ describe('API Health Check', () => {
}) })
it('GET /api/me returns authenticated user with valid token', () => { it('GET /api/me returns authenticated user with valid token', () => {
const token = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
cy.request({ cy.request({
url: '/api/me', url: '/api/me',
headers: { Authorization: `Bearer ${adminToken}` } headers: { Authorization: `Bearer ${token}` }
}).then(res => { }).then(res => {
expect(res.status).to.eq(200) expect(res.status).to.eq(200)
expect(res.body.id).to.not.be.null expect(res.body.id).to.not.be.null

View File

@@ -1,13 +1,6 @@
describe('Audit Log Advanced', () => { describe('Audit Log Advanced', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
let authHeaders const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
it('fetches audit logs with pagination', () => { it('fetches audit logs with pagination', () => {
cy.request({ url: '/api/audit-log?limit=10&offset=0', headers: authHeaders }).then(res => { cy.request({ url: '/api/audit-log?limit=10&offset=0', headers: authHeaders }).then(res => {
@@ -46,7 +39,7 @@ describe('Audit Log Advanced', () => {
body: { name: 'Cypress审计测试', note: '', ingredients: [{ oil_name: '薰衣草', drops: 1 }], tags: [] } body: { name: 'Cypress审计测试', note: '', ingredients: [{ oil_name: '薰衣草', drops: 1 }], tags: [] }
}).then(createRes => { }).then(createRes => {
const recipeId = createRes.body.id const recipeId = createRes.body.id
// Check audit log — admin creates recipes with action 'share_recipe' // Check audit log
cy.request({ url: '/api/audit-log?limit=5&offset=0', headers: authHeaders }).then(res => { cy.request({ url: '/api/audit-log?limit=5&offset=0', headers: authHeaders }).then(res => {
const entry = res.body.find(e => e.action === 'share_recipe' && e.target_name === 'Cypress审计测试') const entry = res.body.find(e => e.action === 'share_recipe' && e.target_name === 'Cypress审计测试')
expect(entry).to.exist expect(entry).to.exist

View File

@@ -1,19 +1,4 @@
describe('Authentication Flow', () => { describe('Authentication Flow', () => {
let adminToken
let adminUsername
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
cy.request({
url: '/api/me',
headers: { Authorization: `Bearer ${token}` }
}).then(res => {
adminUsername = res.body.username
})
})
})
it('shows login button when not authenticated', () => { it('shows login button when not authenticated', () => {
cy.visit('/') cy.visit('/')
cy.contains('登录').should('be.visible') cy.contains('登录').should('be.visible')
@@ -35,46 +20,60 @@ describe('Authentication Flow', () => {
it('shows error for invalid login', () => { it('shows error for invalid login', () => {
cy.visit('/') cy.visit('/')
cy.contains('登录').click() cy.contains('登录').click()
// Try submitting with invalid credentials
cy.get('input[placeholder*="用户名"], input[type="text"]').first().type('nonexistent_user_xyz') cy.get('input[placeholder*="用户名"], input[type="text"]').first().type('nonexistent_user_xyz')
cy.get('input[type="password"]').first().type('wrongpassword') cy.get('input[type="password"]').first().type('wrongpassword')
cy.contains('button', /登录|确定|提交/).click() cy.contains('button', /登录|确定|提交/).click()
// Should show error (alert, toast, or inline message)
cy.wait(1000) cy.wait(1000)
// The modal should still be visible (login failed) // The modal should still be visible (login failed)
cy.get('[class*="overlay"], [class*="modal"], [class*="login"]').should('exist') cy.get('[class*="overlay"], [class*="modal"], [class*="login"]').should('exist')
}) })
it('authenticated user sees their name in header', () => { it('authenticated user sees their name in header', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
cy.visit('/', { cy.visit('/', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.app-header', { timeout: 8000 }).should('be.visible') cy.get('.app-header', { timeout: 8000 }).should('be.visible')
cy.get('.user-name', { timeout: 8000 }).should('be.visible') cy.contains('Hera').should('be.visible')
}) })
it('logout clears auth and shows login button', () => { it('logout clears auth and shows login button', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
cy.visit('/', { cy.visit('/', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.user-name', { timeout: 8000 }).should('be.visible') cy.contains('Hera', { timeout: 8000 }).should('be.visible')
// Click user name to open menu // Click user name to open menu
cy.get('.user-name').click() cy.contains('Hera').click()
// Click logout // Click logout
cy.contains(/退出|登出|logout/i).click() cy.contains(/退出|登出|logout/i).click()
// Should show login button again // Should show login button again
cy.contains('登录', { timeout: 5000 }).should('be.visible') cy.contains('登录', { timeout: 5000 }).should('be.visible')
}) })
it('token from URL param authenticates user', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
cy.visit('/?token=' + ADMIN_TOKEN)
// Should authenticate and show user name
cy.contains('Hera', { timeout: 8000 }).should('be.visible')
// Token should be removed from URL
cy.url().should('not.include', 'token=')
})
it('protected tabs become accessible after login', () => { it('protected tabs become accessible after login', () => {
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
cy.visit('/', { cy.visit('/', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.nav-tab', { timeout: 10000 }).should('have.length.gte', 3) cy.get('.nav-tab', { timeout: 10000 }).should('have.length.gte', 6)
cy.get('.nav-tab').contains('管理配方').click() cy.get('.nav-tab').contains('管理配方').click()
// Should navigate to manage page, not show login modal // Should navigate to manage page, not show login modal
cy.url().should('include', '/manage') cy.url().should('include', '/manage')

View File

@@ -1,30 +1,20 @@
describe('Batch Operations', () => { describe('Batch Operations', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
let authHeaders const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
describe('Batch tag operations via API', () => { describe('Batch tag operations via API', () => {
let testRecipeIds = [] let testRecipeIds = []
before(() => { before(() => {
cy.getAdminToken().then(token => {
const headers = { Authorization: `Bearer ${token}` }
// Create 3 test recipes // Create 3 test recipes
const recipes = ['Cypress批量1', 'Cypress批量2', 'Cypress批量3'] const recipes = ['Cypress批量1', 'Cypress批量2', 'Cypress批量3']
recipes.forEach(name => { recipes.forEach(name => {
cy.request({ cy.request({
method: 'POST', url: '/api/recipes', headers, method: 'POST', url: '/api/recipes', headers: authHeaders,
body: { name, note: 'batch test', ingredients: [{ oil_name: '薰衣草', drops: 5 }], tags: [] } body: { name, note: 'batch test', ingredients: [{ oil_name: '薰衣草', drops: 5 }], tags: [] }
}).then(res => testRecipeIds.push(res.body.id)) }).then(res => testRecipeIds.push(res.body.id))
}) })
}) })
})
it('created 3 test recipes', () => { it('created 3 test recipes', () => {
expect(testRecipeIds).to.have.length(3) expect(testRecipeIds).to.have.length(3)
@@ -40,7 +30,7 @@ describe('Batch Operations', () => {
}) })
it('verifies tags were applied', () => { it('verifies tags were applied', () => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(res => { cy.request('/api/recipes').then(res => {
const tagged = res.body.filter(r => (r.tags || []).includes('cypress-batch-tag')) const tagged = res.body.filter(r => (r.tags || []).includes('cypress-batch-tag'))
expect(tagged.length).to.be.gte(3) expect(tagged.length).to.be.gte(3)
}) })
@@ -55,7 +45,7 @@ describe('Batch Operations', () => {
}) })
it('verifies recipes are deleted', () => { it('verifies recipes are deleted', () => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(res => { cy.request('/api/recipes').then(res => {
const found = res.body.filter(r => r.name && r.name.startsWith('Cypress批量')) const found = res.body.filter(r => r.name && r.name.startsWith('Cypress批量'))
expect(found).to.have.length(0) expect(found).to.have.length(0)
}) })
@@ -65,7 +55,7 @@ describe('Batch Operations', () => {
// Cleanup tag // Cleanup tag
cy.request({ method: 'DELETE', url: '/api/tags/cypress-batch-tag', headers: authHeaders, failOnStatusCode: false }) cy.request({ method: 'DELETE', url: '/api/tags/cypress-batch-tag', headers: authHeaders, failOnStatusCode: false })
// Cleanup any remaining test recipes // Cleanup any remaining test recipes
cy.request({ url: '/api/recipes', headers: authHeaders }).then(res => { cy.request('/api/recipes').then(res => {
res.body.filter(r => r.name && r.name.startsWith('Cypress批量')).forEach(r => { res.body.filter(r => r.name && r.name.startsWith('Cypress批量')).forEach(r => {
cy.request({ method: 'DELETE', url: `/api/recipes/${r.id}`, headers: authHeaders, failOnStatusCode: false }) cy.request({ method: 'DELETE', url: `/api/recipes/${r.id}`, headers: authHeaders, failOnStatusCode: false })
}) })
@@ -74,11 +64,10 @@ describe('Batch Operations', () => {
}) })
describe('Recipe adopt workflow (admin)', () => { describe('Recipe adopt workflow (admin)', () => {
// Test the adopt/review workflow that admin uses to approve user-submitted recipes
it('lists recipes and checks for owner_id field', () => { it('lists recipes and checks for owner_id field', () => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(res => { cy.request('/api/recipes').then(res => {
if (res.body.length > 0) {
expect(res.body[0]).to.have.property('owner_id') expect(res.body[0]).to.have.property('owner_id')
}
}) })
}) })
}) })

View File

@@ -1,16 +1,9 @@
describe('Bug Tracker Flow', () => { describe('Bug Tracker Flow', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
let authHeaders const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
const TEST_CONTENT = 'Cypress_E2E_Bug_测试缺陷_' + Date.now() const TEST_CONTENT = 'Cypress_E2E_Bug_测试缺陷_' + Date.now()
let testBugId = null let testBugId = null
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
describe('API: bug lifecycle', () => { describe('API: bug lifecycle', () => {
it('submits a new bug via API', () => { it('submits a new bug via API', () => {
cy.request({ cy.request({
@@ -52,6 +45,9 @@ describe('Bug Tracker Flow', () => {
}) })
}) })
// NOTE: POST /api/bug-reports/{id}/comment has a backend bug — the decorator
// is stacked on delete_bug function, so POST to /comment actually deletes the bug.
// Skipping comment tests until backend is fixed.
it('bug has auto-generated creation comment', () => { it('bug has auto-generated creation comment', () => {
cy.request({ url: '/api/bug-reports', headers: authHeaders }).then(res => { cy.request({ url: '/api/bug-reports', headers: authHeaders }).then(res => {
const found = res.body.find(b => b.content && b.content.includes('Cypress_E2E_Bug')) const found = res.body.find(b => b.content && b.content.includes('Cypress_E2E_Bug'))
@@ -85,7 +81,7 @@ describe('Bug Tracker Flow', () => {
describe('UI: bugs page', () => { describe('UI: bugs page', () => {
it('visits /bugs and page renders', () => { it('visits /bugs and page renders', () => {
cy.visit('/bugs', { cy.visit('/bugs', {
onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) }
}) })
cy.contains('Bug', { timeout: 10000 }).should('be.visible') cy.contains('Bug', { timeout: 10000 }).should('be.visible')
}) })

View File

@@ -1,30 +1,106 @@
// Demo walkthrough for video recording
// Timeline paced to match 90s TTS narration
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
describe('doTERRA 精油配方计算器 - 功能演示', () => { describe('doTERRA 精油配方计算器 - 功能演示', () => {
let adminToken it('完整功能演示', { defaultCommandTimeout: 15000 }, () => {
// ===== 0:00-0:05 开场:首页加载 =====
before(() => { cy.visit('/', {
cy.getAdminToken().then(token => { adminToken = token }) onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
}) })
cy.get('.app-header').should('be.visible')
cy.wait(4500)
it('首页和搜索', { defaultCommandTimeout: 10000 }, () => { // ===== 0:05-0:09 配方卡片列表 =====
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.wait(3500)
// ===== 0:09-0:12 滚动浏览 =====
cy.scrollTo(0, 500, { duration: 1200 })
cy.wait(1500)
cy.scrollTo('top', { duration: 800 })
cy.wait(1000)
// ===== 0:12-0:16 搜索框输入 =====
cy.get('input[placeholder*="搜索"]').click()
cy.wait(800)
cy.get('input[placeholder*="搜索"]').type('薰衣草', { delay: 200 })
cy.wait(2500)
// ===== 0:16-0:20 搜索结果 =====
cy.wait(2000)
cy.get('input[placeholder*="搜索"]').clear() cy.get('input[placeholder*="搜索"]').clear()
cy.get('.recipe-card').should('have.length.gte', 1) cy.wait(1500)
})
it('页面导航', { defaultCommandTimeout: 10000 }, () => { // ===== 0:20-0:24 点击配方卡片 =====
cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) cy.get('.recipe-card').first().click()
cy.wait(4000)
// ===== 0:24-0:30 查看详情 =====
cy.get('[class*="overlay"], [class*="detail"]').should('be.visible')
cy.wait(4500)
cy.get('button').contains(/✕|关闭|←/).first().click()
cy.wait(1500)
// ===== 0:30-0:34 切换精油价目 =====
cy.get('.nav-tab').contains('精油价目').click() cy.get('.nav-tab').contains('精油价目').click()
cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1) cy.wait(4000)
cy.get('.nav-tab').contains('管理配方').click()
cy.get('.nav-tab').contains('个人库存').click()
})
it('管理页面可访问', { defaultCommandTimeout: 10000 }, () => { // ===== 0:34-0:38 搜索精油 =====
cy.visit('/audit', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) cy.get('.oil-card', { timeout: 10000 }).should('have.length.gte', 1)
cy.contains('操作日志', { timeout: 10000 }).should('be.visible') cy.get('input[placeholder*="搜索精油"]').type('薰衣草', { delay: 200 })
cy.visit('/users', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) cy.wait(2500)
cy.contains('用户管理', { timeout: 10000 }).should('be.visible') cy.get('input[placeholder*="搜索精油"]').clear()
cy.wait(1000)
// ===== 0:38-0:42 切换瓶价/滴价 =====
cy.contains('滴价').click()
cy.wait(2000)
cy.contains('瓶价').click()
cy.wait(1500)
// ===== 0:42-0:47 管理配方 =====
cy.get('.nav-tab').contains('管理配方').click()
cy.wait(4500)
// ===== 0:47-0:52 管理页面浏览 =====
cy.scrollTo(0, 300, { duration: 1000 })
cy.wait(2000)
cy.scrollTo('top', { duration: 600 })
cy.wait(2000)
// ===== 0:52-0:56 个人库存 =====
cy.get('.nav-tab').contains('个人库存').click()
cy.wait(4500)
// ===== 0:56-1:00 库存推荐 =====
cy.scrollTo(0, 200, { duration: 600 })
cy.wait(2000)
cy.scrollTo('top', { duration: 400 })
cy.wait(1500)
// ===== 1:00-1:06 操作日志 =====
cy.get('.nav-tab').contains('操作日志').click()
cy.wait(3000)
cy.scrollTo(0, 200, { duration: 600 })
cy.wait(2500)
// ===== 1:06-1:12 Bug 追踪 =====
cy.get('.nav-tab').contains('Bug').click()
cy.wait(5500)
// ===== 1:12-1:18 用户管理 =====
cy.get('.nav-tab').contains('用户管理').click()
cy.wait(5500)
// ===== 1:18-1:22 回到首页 =====
cy.get('.nav-tab').contains('配方查询').click()
cy.wait(3500)
// ===== 1:22-1:30 结束 =====
cy.wait(5000)
}) })
}) })

View File

@@ -1,15 +1,8 @@
describe('Diary Flow', () => { describe('Diary Flow', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
let authHeaders const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
let testDiaryId = null let testDiaryId = null
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
describe('API: full diary lifecycle', () => { describe('API: full diary lifecycle', () => {
it('creates a diary entry via API', () => { it('creates a diary entry via API', () => {
cy.request({ cy.request({
@@ -175,26 +168,26 @@ describe('Diary Flow', () => {
it('visits /mydiary and verifies page renders', () => { it('visits /mydiary and verifies page renders', () => {
cy.visit('/mydiary', { cy.visit('/mydiary', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.my-diary', { timeout: 10000 }).should('exist') cy.get('.my-diary', { timeout: 10000 }).should('exist')
// Should show sub-tabs (品牌 and 账户) // Should show diary sub-tabs
cy.get('.sub-tab').should('have.length.gte', 2) cy.get('.sub-tab').should('have.length', 3)
cy.contains('我的品牌').should('be.visible') cy.contains('配方日记').should('be.visible')
cy.contains('我的账户').should('be.visible') cy.contains('Brand').should('be.visible')
cy.contains('Account').should('be.visible')
}) })
it('brand tab content is visible by default', () => { it('diary grid is visible on diary tab', () => {
cy.visit('/mydiary', { cy.visit('/mydiary', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.my-diary', { timeout: 10000 }).should('exist') cy.get('.my-diary', { timeout: 10000 }).should('exist')
// Default tab is brand; section card with upload areas should be present // Diary grid or empty hint should be present
cy.get('.section-card, .sub-tab.active', { timeout: 10000 }).should('exist') cy.get('.diary-grid, .empty-hint').should('exist')
cy.get('.sub-tab.active').should('contain', '我的品牌')
}) })
}) })

View File

@@ -1,16 +1,13 @@
// Verify that Vue frontend pages call the correct backend API endpoints. // Verify that Vue frontend pages call the correct backend API endpoints.
// This test catches mismatched endpoint names (e.g. /api/bugs vs /api/bug-reports).
const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
describe('API Endpoint Parity', () => { describe('API Endpoint Parity', () => {
let adminToken
before(() => {
cy.getAdminToken().then(token => { adminToken = token })
})
function visitAsAdmin(path) { function visitAsAdmin(path) {
cy.visit(path, { cy.visit(path, {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
} }

View File

@@ -1,33 +1,20 @@
describe('Favorites System', () => { describe('Favorites System', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
let authHeaders
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
describe('API Level', () => { describe('API Level', () => {
let firstRecipeId let firstRecipeId
before(() => { before(() => {
cy.getAdminToken().then(token => { cy.request('/api/recipes').then(res => {
cy.request({ url: '/api/recipes', headers: { Authorization: `Bearer ${token}` } }).then(res => {
if (res.body.length > 0) {
firstRecipeId = res.body[0].id firstRecipeId = res.body[0].id
}
})
}) })
}) })
it('can add a favorite via API', () => { it('can add a favorite via API', () => {
if (!firstRecipeId) return // skip if no recipes
cy.request({ cy.request({
method: 'POST', method: 'POST',
url: `/api/favorites/${firstRecipeId}`, url: `/api/favorites/${firstRecipeId}`,
headers: authHeaders, headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
body: {} body: {}
}).then(res => { }).then(res => {
expect(res.status).to.be.oneOf([200, 201]) expect(res.status).to.be.oneOf([200, 201])
@@ -35,31 +22,28 @@ describe('Favorites System', () => {
}) })
it('lists the favorite', () => { it('lists the favorite', () => {
if (!firstRecipeId) return
cy.request({ cy.request({
url: '/api/favorites', url: '/api/favorites',
headers: authHeaders headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }
}).then(res => { }).then(res => {
expect(res.body).to.include(firstRecipeId) expect(res.body).to.include(firstRecipeId)
}) })
}) })
it('can remove the favorite via API', () => { it('can remove the favorite via API', () => {
if (!firstRecipeId) return
cy.request({ cy.request({
method: 'DELETE', method: 'DELETE',
url: `/api/favorites/${firstRecipeId}`, url: `/api/favorites/${firstRecipeId}`,
headers: authHeaders headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }
}).then(res => { }).then(res => {
expect(res.status).to.eq(200) expect(res.status).to.eq(200)
}) })
}) })
it('favorite is removed from list', () => { it('favorite is removed from list', () => {
if (!firstRecipeId) return
cy.request({ cy.request({
url: '/api/favorites', url: '/api/favorites',
headers: authHeaders headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }
}).then(res => { }).then(res => {
expect(res.body).to.not.include(firstRecipeId) expect(res.body).to.not.include(firstRecipeId)
}) })
@@ -67,15 +51,37 @@ describe('Favorites System', () => {
}) })
describe('UI Level', () => { describe('UI Level', () => {
it('recipe cards have favorite buttons for logged-in users', () => { it('recipe cards have star buttons for logged-in users', () => {
cy.visit('/', { cy.visit('/', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1) cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
// Fav button should be present on cards // Stars should be present on cards
cy.get('.fav-btn').first().should('exist') cy.get('.recipe-card').first().within(() => {
cy.contains(/★|☆/).should('exist')
})
})
it('clicking star toggles favorite state', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.recipe-card', { timeout: 10000 }).first().within(() => {
cy.contains(/★|☆/).then($star => {
const wasFav = $star.text().includes('★')
$star.trigger('click')
// Star text should have toggled
cy.wait(500)
cy.contains(/★|☆/).invoke('text').should(text => {
if (wasFav) expect(text).to.include('☆')
else expect(text).to.include('★')
})
})
})
}) })
}) })
}) })

View File

@@ -1,15 +1,8 @@
describe('Inventory Flow', () => { describe('Inventory Flow', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
let authHeaders const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
const TEST_OIL = '薰衣草' const TEST_OIL = '薰衣草'
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
describe('API: inventory CRUD', () => { describe('API: inventory CRUD', () => {
it('adds an oil to inventory', () => { it('adds an oil to inventory', () => {
cy.request({ cy.request({
@@ -56,7 +49,7 @@ describe('Inventory Flow', () => {
describe('UI: inventory page', () => { describe('UI: inventory page', () => {
it('page loads with oil picker', () => { it('page loads with oil picker', () => {
cy.visit('/inventory', { cy.visit('/inventory', {
onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) }
}) })
cy.contains('库存', { timeout: 10000 }).should('be.visible') cy.contains('库存', { timeout: 10000 }).should('be.visible')
}) })

View File

@@ -1,137 +0,0 @@
describe('Kit Export Feature', () => {
let adminToken
let authHeaders
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
describe('API: recipes and oils available', () => {
it('loads oils list', () => {
cy.request({ url: '/api/oils', headers: authHeaders }).then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
expect(res.body.length).to.be.greaterThan(50)
})
})
it('loads recipes list', () => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(res => {
expect(res.status).to.eq(200)
expect(res.body).to.be.an('array')
expect(res.body.length).to.be.greaterThan(10)
})
})
})
describe('UI: Projects page access', () => {
it('shows login prompt when not logged in', () => {
cy.visit('/projects')
cy.contains('登录', { timeout: 10000 }).should('be.visible')
})
it('shows kit compare button when logged in as admin', () => {
cy.visit('/projects', {
onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) }
})
cy.contains('套装方案对比', { timeout: 10000 }).should('be.visible')
})
})
describe('UI: Kit Export page', () => {
beforeEach(() => {
cy.visit('/kit-export', {
onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) }
})
})
it('loads with 4 kit cards', () => {
cy.get('.kit-card', { timeout: 10000 }).should('have.length', 4)
})
it('shows kit names and prices', () => {
cy.contains('芳香调理套装').should('be.visible')
cy.contains('家庭医生套装').should('be.visible')
cy.contains('居家呵护套装').should('be.visible')
cy.contains('全精油套装').should('be.visible')
cy.contains('¥1575').should('be.visible')
cy.contains('¥2250').should('be.visible')
cy.contains('¥3988').should('be.visible')
cy.contains('¥17700').should('be.visible')
})
it('shows recipe count for each kit', () => {
cy.get('.kit-recipe-count').should('have.length', 4)
cy.get('.kit-recipe-count').each($el => {
expect($el.text()).to.match(/可做 \d+ 个配方/)
})
})
it('clicking a kit card shows its recipes', () => {
cy.get('.kit-card').eq(1).click()
cy.get('.kit-detail', { timeout: 5000 }).should('be.visible')
cy.get('.recipe-table').should('exist')
})
it('recipe table has cost and profit columns', () => {
cy.get('.kit-card').first().click()
cy.get('.recipe-table', { timeout: 5000 }).within(() => {
cy.contains('th', '套装成本').should('exist')
cy.contains('th', '单买成本').should('exist')
cy.contains('th', '售价').should('exist')
cy.contains('th', '利润率').should('exist')
cy.contains('th', '可做次数').should('exist')
})
})
it('shows cross comparison section', () => {
cy.get('.cross-section', { timeout: 10000 }).should('be.visible')
cy.get('.cross-table').should('exist')
})
it('cross comparison has single-buy column', () => {
cy.get('.cross-table', { timeout: 10000 }).within(() => {
cy.contains('th', '单买').should('exist')
})
})
it('cross comparison shows staircase pattern (available cells have green bg)', () => {
cy.get('.td-kit-available', { timeout: 10000 }).should('have.length.greaterThan', 0)
cy.get('.td-kit-na').should('have.length.greaterThan', 0)
})
it('kit cost is always <= original cost in recipe table', () => {
cy.get('.kit-card').first().click()
cy.get('.recipe-table tbody tr', { timeout: 5000 }).each($row => {
const cells = $row.find('td')
// td[1] = 可做次数, td[2] = 套装成本, td[3] = 单买成本
const kitCostText = cells.eq(2).text().replace(/[¥,\s]/g, '')
const origCostText = cells.eq(3).text().replace(/[¥,\s]/g, '')
const kitCost = parseFloat(kitCostText)
const origCost = parseFloat(origCostText)
if (!isNaN(kitCost) && !isNaN(origCost)) {
expect(kitCost).to.be.at.most(origCost + 0.01)
}
})
})
it('export buttons exist', () => {
cy.contains('button', '导出完整版').should('be.visible')
cy.contains('button', '导出简版').should('be.visible')
})
it('shows volume info next to recipe name', () => {
cy.get('.td-volume', { timeout: 10000 }).should('have.length.greaterThan', 0)
})
})
describe('UI: Kit Export access control', () => {
it('redirects to /projects when not logged in', () => {
cy.visit('/kit-export')
cy.url({ timeout: 10000 }).should('include', '/projects')
})
})
})

View File

@@ -1,21 +1,14 @@
describe('Manage Recipes Page', () => { describe('Manage Recipes Page', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
before(() => {
cy.getAdminToken().then(token => { adminToken = token })
})
beforeEach(() => { beforeEach(() => {
cy.visit('/manage', { cy.visit('/manage', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
// Wait for the recipe manager to load // Wait for the recipe manager to load
cy.get('.recipe-manager', { timeout: 10000 }).should('exist') cy.get('.recipe-manager', { timeout: 10000 }).should('exist')
// Expand the public recipes section by clicking its title
cy.contains('公共配方库', { timeout: 10000 }).should('be.visible').click()
cy.get('.recipe-row', { timeout: 10000 }).should('have.length.gte', 1)
}) })
it('loads and shows recipe lists', () => { it('loads and shows recipe lists', () => {
@@ -28,7 +21,7 @@ describe('Manage Recipes Page', () => {
cy.get('.recipe-row').then($rows => { cy.get('.recipe-row').then($rows => {
const initialCount = $rows.length const initialCount = $rows.length
// Type a search term // Type a search term
cy.get('.search-input').type('薰衣草') cy.get('.manage-toolbar .search-input').type('薰衣草')
cy.wait(500) cy.wait(500)
// Filtered count should be different (fewer or equal) // Filtered count should be different (fewer or equal)
cy.get('.recipe-row').should('have.length.lte', initialCount) cy.get('.recipe-row').should('have.length.lte', initialCount)
@@ -36,11 +29,11 @@ describe('Manage Recipes Page', () => {
}) })
it('clearing search restores all recipes', () => { it('clearing search restores all recipes', () => {
cy.get('.search-input').type('薰衣草') cy.get('.manage-toolbar .search-input').type('薰衣草')
cy.wait(500) cy.wait(500)
cy.get('.recipe-row').then($filtered => { cy.get('.recipe-row').then($filtered => {
const filteredCount = $filtered.length const filteredCount = $filtered.length
cy.get('.search-input').clear() cy.get('.manage-toolbar .search-input').clear()
cy.wait(500) cy.wait(500)
cy.get('.recipe-row').should('have.length.gte', filteredCount) cy.get('.recipe-row').should('have.length.gte', filteredCount)
}) })
@@ -52,17 +45,17 @@ describe('Manage Recipes Page', () => {
// Editor overlay should appear // Editor overlay should appear
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible') cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
cy.contains('编辑配方').should('be.visible') cy.contains('编辑配方').should('be.visible')
// Should have editor name input // Should have form fields
cy.get('.editor-name-input').should('exist') cy.get('.form-group').should('have.length.gte', 1)
}) })
it('editor shows ingredients table with oil inputs', () => { it('editor shows ingredients table with oil selects', () => {
cy.get('.recipe-row .row-info').first().click() cy.get('.recipe-row .row-info').first().click()
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible') cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
// Ingredients section should have rows with oil search inputs // Ingredients section should have rows with select dropdowns
cy.get('.overlay-panel .editor-table').should('exist') cy.get('.overlay-panel .ing-row').should('have.length.gte', 1)
cy.get('.overlay-panel .form-select').should('have.length.gte', 1) cy.get('.overlay-panel .form-select').should('have.length.gte', 1)
cy.get('.overlay-panel .editor-drops').should('have.length.gte', 1) cy.get('.overlay-panel .form-input-sm').should('have.length.gte', 1)
}) })
it('can close the editor overlay', () => { it('can close the editor overlay', () => {
@@ -73,11 +66,10 @@ describe('Manage Recipes Page', () => {
cy.get('.overlay-panel').should('not.exist') cy.get('.overlay-panel').should('not.exist')
}) })
it('can close the editor with close button again', () => { it('can close the editor with cancel button', () => {
cy.get('.recipe-row .row-info').first().click() cy.get('.recipe-row .row-info').first().click()
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible') cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
// Close via the X button cy.get('.overlay-panel').contains('取消').click()
cy.get('.overlay-panel .btn-close').click()
cy.get('.overlay-panel').should('not.exist') cy.get('.overlay-panel').should('not.exist')
}) })
@@ -100,7 +92,7 @@ describe('Manage Recipes Page', () => {
}) })
it('has add recipe button that opens overlay', () => { it('has add recipe button that opens overlay', () => {
cy.get('.action-chip').contains('新增').click() cy.get('.manage-toolbar').contains('添加配方').click()
cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible') cy.get('.overlay-panel', { timeout: 5000 }).should('be.visible')
cy.contains('添加配方').should('be.visible') cy.contains('添加配方').should('be.visible')
// Close it // Close it

View File

@@ -1,9 +1,5 @@
describe('Navigation & Routing', () => { describe('Navigation & Routing', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
before(() => {
cy.getAdminToken().then(token => { adminToken = token })
})
it('direct URL /oils loads oil reference page', () => { it('direct URL /oils loads oil reference page', () => {
cy.visit('/oils') cy.visit('/oils')
@@ -36,50 +32,38 @@ describe('Navigation & Routing', () => {
cy.get('.nav-tab').contains('配方查询').should('not.have.class', 'active') cy.get('.nav-tab').contains('配方查询').should('not.have.class', 'active')
}) })
it('admin-only pages not accessible as tabs for anonymous users', () => { it('admin tabs only visible when authenticated', () => {
cy.visit('/') cy.visit('/')
// The nav tabs should only show public tabs cy.get('.nav-tab').contains('操作日志').should('not.exist')
cy.get('.nav-tab').should('have.length.lte', 5) cy.get('.nav-tab').contains('用户管理').should('not.exist')
// No admin menu links visible
cy.get('.usermenu-card').should('not.exist')
}) })
it('admin pages accessible via direct URL when logged in', () => { it('admin tabs appear after login', () => {
cy.visit('/audit', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken)
}
})
cy.contains('操作日志', { timeout: 10000 }).should('be.visible')
})
it('all tab pages are navigable', () => {
cy.visit('/', { cy.visit('/', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
}
})
cy.get('.nav-tab', { timeout: 10000 }).contains('操作日志').should('be.visible')
cy.get('.nav-tab').contains('用户管理').should('be.visible')
})
it('all admin pages are navigable', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
const pages = [ const pages = [
{ tab: '管理配方', url: '/manage' }, { tab: '管理配方', url: '/manage' },
{ tab: '个人库存', url: '/inventory' }, { tab: '个人库存', url: '/inventory' },
{ tab: '精油价目', url: '/oils' }, { tab: '精油价目', url: '/oils' },
{ tab: '操作日志', url: '/audit' },
{ tab: '用户管理', url: '/users' },
] ]
pages.forEach(({ tab, url }) => { pages.forEach(({ tab, url }) => {
cy.get('.nav-tab').contains(tab).click() cy.get('.nav-tab').contains(tab).click()
cy.url().should('include', url) cy.url().should('include', url)
}) })
}) })
it('admin pages accessible via user menu', () => {
cy.visit('/', {
onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken)
}
})
// Open user menu
cy.get('.user-name', { timeout: 10000 }).click()
cy.get('.usermenu-card').should('be.visible')
cy.get('.usermenu-btn').contains('操作日志').click()
cy.url().should('include', '/audit')
})
}) })

View File

@@ -1,13 +1,6 @@
describe('Notification Flow', () => { describe('Notification Flow', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
let authHeaders const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
it('fetches notifications', () => { it('fetches notifications', () => {
cy.request({ url: '/api/notifications', headers: authHeaders }).then(res => { cy.request({ url: '/api/notifications', headers: authHeaders }).then(res => {

View File

@@ -1,28 +0,0 @@
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', '导出成功')
})
})

View File

@@ -16,22 +16,12 @@ 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()

View File

@@ -1,51 +0,0 @@
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', '薰衣草测试')
})
})

View File

@@ -25,12 +25,11 @@ describe('Performance', () => {
it('search filtering is near-instant', () => { it('search filtering is near-instant', () => {
cy.visit('/') cy.visit('/')
cy.get('.recipe-card, .empty-hint', { timeout: 10000 }).should('exist')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1) cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1)
const start = Date.now() const start = Date.now()
cy.get('input[placeholder*="搜索"]').type('薰衣草') cy.get('input[placeholder*="搜索"]').type('薰衣草')
cy.wait(300) cy.wait(300)
cy.get('.recipe-card, .empty-hint').should('exist') cy.get('.recipe-card').should('exist')
cy.then(() => { cy.then(() => {
expect(Date.now() - start).to.be.lt(2000) expect(Date.now() - start).to.be.lt(2000)
}) })
@@ -45,13 +44,12 @@ describe('Performance', () => {
}) })
}) })
it('handles many recipes without crashing', () => { it('handles 250+ recipes without crashing', () => {
cy.request('/api/recipes').then(res => { cy.request('/api/recipes').then(res => {
// In CI with fresh DB, may have fewer than 250 recipes expect(res.body.length).to.be.gte(200)
expect(res.body.length).to.be.gte(1)
}) })
cy.visit('/') cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1) cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 10)
// Scroll to trigger lazy loading if any // Scroll to trigger lazy loading if any
cy.scrollTo('bottom') cy.scrollTo('bottom')
cy.wait(500) cy.wait(500)

View File

@@ -1,15 +1,8 @@
describe('PR27 Feature Tests', () => { describe('PR27 Feature Tests', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
let authHeaders const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
const TEST_USERNAME = 'cypress_pr27_user' const TEST_USERNAME = 'cypress_pr27_user'
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// API: en_name auto title case on recipe create // API: en_name auto title case on recipe create
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -17,6 +10,7 @@ describe('PR27 Feature Tests', () => {
let recipeId let recipeId
after(() => { after(() => {
// Cleanup
if (recipeId) { if (recipeId) {
cy.request({ cy.request({
method: 'DELETE', method: 'DELETE',
@@ -45,7 +39,7 @@ describe('PR27 Feature Tests', () => {
}) })
it('verifies en_name is title-cased', () => { it('verifies en_name is title-cased', () => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(res => { cy.request('/api/recipes').then(res => {
const found = res.body.find(r => r.name === 'PR27标题测试') const found = res.body.find(r => r.name === 'PR27标题测试')
expect(found).to.exist expect(found).to.exist
expect(found.en_name).to.eq('Pain Relief Blend') expect(found.en_name).to.eq('Pain Relief Blend')
@@ -67,12 +61,13 @@ describe('PR27 Feature Tests', () => {
expect(res.status).to.be.oneOf([200, 201]) expect(res.status).to.be.oneOf([200, 201])
const autoId = res.body.id const autoId = res.body.id
cy.request({ url: '/api/recipes', headers: authHeaders }).then(listRes => { cy.request('/api/recipes').then(listRes => {
const found = listRes.body.find(r => r.id === autoId) const found = listRes.body.find(r => r.id === autoId)
expect(found).to.exist expect(found).to.exist
// auto_translate('助眠配方') should produce English // auto_translate('助眠配方') should produce English with "Sleep" and "Blend"
expect(found.en_name).to.be.a('string') expect(found.en_name).to.be.a('string')
expect(found.en_name.length).to.be.greaterThan(0) expect(found.en_name.length).to.be.greaterThan(0)
expect(found.en_name).to.include('Sleep')
// Cleanup // Cleanup
cy.request({ cy.request({
@@ -93,22 +88,23 @@ describe('PR27 Feature Tests', () => {
let testUserId let testUserId
let testUserToken let testUserToken
// Cleanup leftover test user
before(() => { before(() => {
cy.getAdminToken().then(token => { cy.request({
const headers = { Authorization: `Bearer ${token}` } url: '/api/users',
cy.request({ url: '/api/users', headers }).then(res => { headers: authHeaders
}).then(res => {
const leftover = res.body.find(u => u.username === TEST_USERNAME) const leftover = res.body.find(u => u.username === TEST_USERNAME)
if (leftover) { if (leftover) {
cy.request({ cy.request({
method: 'DELETE', method: 'DELETE',
url: `/api/users/${leftover.id || leftover._id}`, url: `/api/users/${leftover.id || leftover._id}`,
headers, headers: authHeaders,
failOnStatusCode: false failOnStatusCode: false
}) })
} }
}) })
}) })
})
it('creates a test user', () => { it('creates a test user', () => {
cy.request({ cy.request({
@@ -122,12 +118,9 @@ describe('PR27 Feature Tests', () => {
} }
}).then(res => { }).then(res => {
expect(res.status).to.be.oneOf([200, 201]) expect(res.status).to.be.oneOf([200, 201])
testUserId = res.body.id || res.body._id
testUserToken = res.body.token testUserToken = res.body.token
// Get user id from user list expect(testUserId).to.be.a('number')
cy.request({ url: '/api/users', headers: authHeaders }).then(listRes => {
const u = listRes.body.find(x => x.username === TEST_USERNAME)
testUserId = u.id || u._id
})
}) })
}) })
@@ -165,11 +158,11 @@ describe('PR27 Feature Tests', () => {
}).then(res => { }).then(res => {
expect(res.body).to.be.an('array') expect(res.body).to.be.an('array')
// Transferred diary should have user's name appended // Transferred diary should have user's name appended
const transferred = res.body.find(d => d.name && d.name.includes('PR27用户日记')) const transferred = res.body.find(d => d.name && d.name.includes('PR27用户日记') && d.name.includes('PR27 Test User'))
expect(transferred).to.exist expect(transferred).to.exist
expect(transferred.note).to.eq('转移测试') expect(transferred.note).to.eq('转移测试')
// Cleanup // Cleanup: delete the transferred diary
if (transferred) { if (transferred) {
cy.request({ cy.request({
method: 'DELETE', method: 'DELETE',
@@ -200,22 +193,20 @@ describe('PR27 Feature Tests', () => {
body: { name: '头痛', ingredients: [{ oil_name: '薰衣草', drops: 3 }], tags: [] } body: { name: '头痛', ingredients: [{ oil_name: '薰衣草', drops: 3 }], tags: [] }
}).then(res => { }).then(res => {
recipeId = res.body.id recipeId = res.body.id
// Verify initial en_name exists // Verify initial en_name
cy.request({ url: '/api/recipes', headers: authHeaders }).then(list => { cy.request('/api/recipes').then(list => {
const r = list.body.find(x => x.id === recipeId) const r = list.body.find(x => x.id === recipeId)
expect(r.en_name).to.be.a('string') expect(r.en_name).to.include('Headache')
expect(r.en_name.length).to.be.greaterThan(0)
}) })
// Rename to 肩颈按摩 // Rename to 肩颈按摩
cy.request({ cy.request({
method: 'PUT', url: `/api/recipes/${recipeId}`, headers: authHeaders, method: 'PUT', url: `/api/recipes/${recipeId}`, headers: authHeaders,
body: { name: '肩颈按摩' } body: { name: '肩颈按摩' }
}).then(() => { }).then(() => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(list => { cy.request('/api/recipes').then(list => {
const r = list.body.find(x => x.id === recipeId) const r = list.body.find(x => x.id === recipeId)
// en_name should be updated (retranslated) expect(r.en_name).to.include('Neck')
expect(r.en_name).to.be.a('string') expect(r.en_name).to.include('Massage')
expect(r.en_name.length).to.be.greaterThan(0)
}) })
}) })
}) })
@@ -238,15 +229,9 @@ describe('PR27 Feature Tests', () => {
const userToken = res.body.token const userToken = res.body.token
const userAuth = { Authorization: `Bearer ${userToken}` } const userAuth = { Authorization: `Bearer ${userToken}` }
// Get user id from users list if not returned directly
cy.request({ url: '/api/users', headers: authHeaders }).then(listRes => {
const u = listRes.body.find(x => x.username === DUP_USER)
const actualUserId = u.id || u._id
// Get a public recipe's ingredients to create a duplicate // Get a public recipe's ingredients to create a duplicate
cy.request({ url: '/api/recipes', headers: authHeaders }).then(recListRes => { cy.request({ url: '/api/recipes', headers: authHeaders }).then(listRes => {
if (recListRes.body.length === 0) return const pub = listRes.body[0]
const pub = recListRes.body[0]
const dupIngs = pub.ingredients.map(i => ({ oil: i.oil_name, drops: i.drops })) const dupIngs = pub.ingredients.map(i => ({ oil: i.oil_name, drops: i.drops }))
// Add diary with same ingredients as public recipe (different name) // Add diary with same ingredients as public recipe (different name)
@@ -255,7 +240,7 @@ describe('PR27 Feature Tests', () => {
body: { name: '我的重复方', ingredients: dupIngs, note: '' } body: { name: '我的重复方', ingredients: dupIngs, note: '' }
}).then(() => { }).then(() => {
// Delete user // Delete user
cy.request({ method: 'DELETE', url: `/api/users/${actualUserId}`, headers: authHeaders }).then(delRes => { cy.request({ method: 'DELETE', url: `/api/users/${userId}`, headers: authHeaders }).then(delRes => {
expect(delRes.body.ok).to.eq(true) expect(delRes.body.ok).to.eq(true)
// Verify duplicate was NOT transferred // Verify duplicate was NOT transferred
cy.request({ url: '/api/diary', headers: authHeaders }).then(diaryRes => { cy.request({ url: '/api/diary', headers: authHeaders }).then(diaryRes => {
@@ -268,16 +253,16 @@ describe('PR27 Feature Tests', () => {
}) })
}) })
}) })
})
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// UI: 管理配方 login prompt when not logged in // UI: 管理配方 login prompt when not logged in
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
describe('UI: RecipeManager login prompt', () => { describe('UI: RecipeManager login prompt', () => {
it('shows login prompt when not logged in', () => { it('shows login prompt when not logged in', () => {
// Clear any stored auth
cy.clearLocalStorage() cy.clearLocalStorage()
cy.visit('/manage') cy.visit('/#/manage')
cy.contains('登录后可管理配方', { timeout: 10000 }).should('be.visible') cy.contains('登录后可管理配方').should('be.visible')
cy.contains('登录 / 注册').should('be.visible') cy.contains('登录 / 注册').should('be.visible')
}) })
}) })
@@ -289,10 +274,9 @@ describe('PR27 Feature Tests', () => {
const CASE_USER = 'CypressCaseTest' const CASE_USER = 'CypressCaseTest'
const CASE_PASS = 'test1234' const CASE_PASS = 'test1234'
// Cleanup before test
before(() => { before(() => {
cy.getAdminToken().then(token => { cy.request({ url: '/api/users', headers: authHeaders }).then(res => {
const headers = { Authorization: `Bearer ${token}` }
cy.request({ url: '/api/users', headers }).then(res => {
const leftover = res.body.find(u => const leftover = res.body.find(u =>
u.username.toLowerCase() === CASE_USER.toLowerCase() u.username.toLowerCase() === CASE_USER.toLowerCase()
) )
@@ -300,15 +284,15 @@ describe('PR27 Feature Tests', () => {
cy.request({ cy.request({
method: 'DELETE', method: 'DELETE',
url: `/api/users/${leftover.id || leftover._id}`, url: `/api/users/${leftover.id || leftover._id}`,
headers, headers: authHeaders,
failOnStatusCode: false, failOnStatusCode: false,
}) })
} }
}) })
}) })
})
after(() => { after(() => {
// Cleanup registered user
cy.request({ url: '/api/users', headers: authHeaders }).then(res => { cy.request({ url: '/api/users', headers: authHeaders }).then(res => {
const user = res.body.find(u => const user = res.body.find(u =>
u.username.toLowerCase() === CASE_USER.toLowerCase() u.username.toLowerCase() === CASE_USER.toLowerCase()
@@ -381,25 +365,24 @@ describe('PR27 Feature Tests', () => {
let renameUserId let renameUserId
before(() => { before(() => {
cy.getAdminToken().then(token => { // Cleanup leftovers
const headers = { Authorization: `Bearer ${token}` } cy.request({ url: '/api/users', headers: authHeaders }).then(res => {
cy.request({ url: '/api/users', headers }).then(res => {
for (const name of [RENAME_USER, 'cypress_renamed']) { for (const name of [RENAME_USER, 'cypress_renamed']) {
const leftover = res.body.find(u => u.username.toLowerCase() === name) const leftover = res.body.find(u => u.username.toLowerCase() === name)
if (leftover) { if (leftover) {
cy.request({ cy.request({
method: 'DELETE', method: 'DELETE',
url: `/api/users/${leftover.id || leftover._id}`, url: `/api/users/${leftover.id || leftover._id}`,
headers, headers: authHeaders,
failOnStatusCode: false, failOnStatusCode: false,
}) })
} }
} }
}) })
}) })
})
after(() => { after(() => {
// Cleanup
if (renameUserId) { if (renameUserId) {
cy.request({ cy.request({
method: 'DELETE', method: 'DELETE',
@@ -503,11 +486,14 @@ describe('PR27 Feature Tests', () => {
expect(res.status).to.be.oneOf([200, 201]) expect(res.status).to.be.oneOf([200, 201])
recipeId = res.body.id recipeId = res.body.id
cy.request({ url: '/api/recipes', headers: authHeaders }).then(listRes => { cy.request('/api/recipes').then(listRes => {
const found = listRes.body.find(r => r.id === recipeId) const found = listRes.body.find(r => r.id === recipeId)
expect(found).to.exist expect(found).to.exist
expect(found.en_name).to.be.a('string') expect(found.en_name).to.be.a('string')
expect(found.en_name.length).to.be.greaterThan(0) expect(found.en_name.length).to.be.greaterThan(0)
// auto_translate('排毒按摩') should produce 'Detox Massage'
expect(found.en_name).to.include('Detox')
expect(found.en_name).to.include('Massage')
}) })
}) })
}) })
@@ -543,12 +529,10 @@ describe('PR27 Feature Tests', () => {
}).then(res => { }).then(res => {
recipeId = res.body.id recipeId = res.body.id
// Verify initial auto-translation exists // Verify initial auto-translation
cy.request({ url: '/api/recipes', headers: authHeaders }).then(listRes => { cy.request('/api/recipes').then(listRes => {
const r = listRes.body.find(x => x.id === recipeId) const r = listRes.body.find(x => x.id === recipeId)
expect(r.en_name).to.be.a('string') expect(r.en_name).to.include('Sleep')
expect(r.en_name.length).to.be.greaterThan(0)
const initialEn = r.en_name
// Rename to completely different name // Rename to completely different name
cy.request({ cy.request({
@@ -557,13 +541,13 @@ describe('PR27 Feature Tests', () => {
headers: authHeaders, headers: authHeaders,
body: { name: '肩颈按摩' }, body: { name: '肩颈按摩' },
}).then(() => { }).then(() => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(list2 => { cy.request('/api/recipes').then(list2 => {
const r2 = list2.body.find(x => x.id === recipeId) const r2 = list2.body.find(x => x.id === recipeId)
// Should now be retranslated // Should now be retranslated to Neck & Shoulder Massage
expect(r2.en_name).to.be.a('string') expect(r2.en_name).to.include('Neck')
expect(r2.en_name.length).to.be.greaterThan(0) expect(r2.en_name).to.include('Massage')
// Should be different from original // Should NOT contain Sleep anymore
expect(r2.en_name).to.not.eq(initialEn) expect(r2.en_name).to.not.include('Sleep')
}) })
}) })
}) })
@@ -577,7 +561,7 @@ describe('PR27 Feature Tests', () => {
headers: authHeaders, headers: authHeaders,
body: { name: '免疫配方', en_name: 'my custom name' }, body: { name: '免疫配方', en_name: 'my custom name' },
}).then(() => { }).then(() => {
cy.request({ url: '/api/recipes', headers: authHeaders }).then(listRes => { cy.request('/api/recipes').then(listRes => {
const r = listRes.body.find(x => x.id === recipeId) const r = listRes.body.find(x => x.id === recipeId)
expect(r.en_name).to.eq('My Custom Name') // title-cased expect(r.en_name).to.eq('My Custom Name') // title-cased
}) })
@@ -595,21 +579,19 @@ describe('PR27 Feature Tests', () => {
let xferToken let xferToken
before(() => { before(() => {
cy.getAdminToken().then(token => { // Cleanup leftovers
const headers = { Authorization: `Bearer ${token}` } cy.request({ url: '/api/users', headers: authHeaders }).then(res => {
cy.request({ url: '/api/users', headers }).then(res => {
const leftover = res.body.find(u => u.username === XFER_USER) const leftover = res.body.find(u => u.username === XFER_USER)
if (leftover) { if (leftover) {
cy.request({ cy.request({
method: 'DELETE', method: 'DELETE',
url: `/api/users/${leftover.id || leftover._id}`, url: `/api/users/${leftover.id || leftover._id}`,
headers, headers: authHeaders,
failOnStatusCode: false, failOnStatusCode: false,
}) })
} }
}) })
}) })
})
it('registers user, adds diary, deletes user, verifies transfer', () => { it('registers user, adds diary, deletes user, verifies transfer', () => {
// Register // Register

View File

@@ -14,29 +14,26 @@ describe('Price Display Regression', () => {
it('oil reference page shows non-zero prices', () => { it('oil reference page shows non-zero prices', () => {
cy.visit('/oils') cy.visit('/oils')
cy.get('.oil-chip', { timeout: 10000 }).should('have.length.gte', 1) cy.get('.oil-card', { timeout: 10000 }).should('have.length.gte', 1)
cy.wait(500) cy.wait(500)
// Check that oil chips contain price info cy.get('.oil-card').first().invoke('text').then(text => {
cy.get('.oil-chip').first().invoke('text').then(text => {
// Oil chips show price somewhere in their text
const match = text.match(/¥\s*(\d+\.?\d*)/) const match = text.match(/¥\s*(\d+\.?\d*)/)
if (match) { expect(match, 'Oil card should contain a price').to.not.be.null
expect(parseFloat(match[1])).to.be.gt(0) expect(parseFloat(match[1])).to.be.gt(0)
}
// Even without ¥, just verify the chip renders
expect(text.length).to.be.gt(0)
}) })
}) })
it('recipe cards show price in correct format', () => { it('recipe detail shows non-zero total cost', () => {
cy.visit('/') cy.visit('/')
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1) cy.get('.recipe-card', { timeout: 10000 }).first().click()
// Verify multiple cards have prices cy.wait(1000)
cy.get('.recipe-card-price').should('have.length.gte', 1)
cy.get('.recipe-card-price').each($el => { // Look for any ¥ amount > 0 in the detail overlay
const text = $el.text() cy.get('[class*="overlay"], [class*="detail"]').invoke('text').then(text => {
expect(text).to.match(|💰/) const prices = [...text.matchAll(\s*(\d+\.?\d*)/g)].map(m => parseFloat(m[1]))
const nonZero = prices.filter(p => p > 0)
expect(nonZero.length, 'Detail should show at least one non-zero price').to.be.gte(1)
}) })
}) })
}) })

View File

@@ -1,22 +1,15 @@
describe('Projects Flow', () => { describe('Projects Flow', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
let authHeaders const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
let testProjectId = null let testProjectId = null
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
it('creates a project', () => { it('creates a project', () => {
cy.request({ cy.request({
method: 'POST', url: '/api/projects', headers: authHeaders, method: 'POST', url: '/api/projects', headers: authHeaders,
body: { body: {
name: 'Cypress测试项目', name: 'Cypress测试项目',
ingredients: [{ oil: '薰衣草', drops: 5 }, { oil: '乳香', drops: 3 }], ingredients: JSON.stringify([{ oil: '薰衣草', drops: 5 }, { oil: '乳香', drops: 3 }]),
selling_price: 100, pricing: 100,
note: 'E2E test project' note: 'E2E test project'
} }
}).then(res => { }).then(res => {
@@ -34,13 +27,13 @@ describe('Projects Flow', () => {
}) })
}) })
it('updates the project', () => { it('updates the project pricing', () => {
cy.request({ url: '/api/projects', headers: authHeaders }).then(res => { cy.request({ url: '/api/projects', headers: authHeaders }).then(res => {
const found = res.body.find(p => p.name === 'Cypress测试项目') const found = res.body.find(p => p.name === 'Cypress测试项目')
testProjectId = found.id testProjectId = found.id
cy.request({ cy.request({
method: 'PUT', url: `/api/projects/${testProjectId}`, headers: authHeaders, method: 'PUT', url: `/api/projects/${testProjectId}`, headers: authHeaders,
body: { selling_price: 200, note: 'updated pricing' } body: { pricing: 200, note: 'updated pricing' }
}).then(r => expect(r.status).to.eq(200)) }).then(r => expect(r.status).to.eq(200))
}) })
}) })
@@ -48,9 +41,24 @@ describe('Projects Flow', () => {
it('verifies update', () => { it('verifies update', () => {
cy.request({ url: '/api/projects', headers: authHeaders }).then(res => { cy.request({ url: '/api/projects', headers: authHeaders }).then(res => {
const found = res.body.find(p => p.name === 'Cypress测试项目') const found = res.body.find(p => p.name === 'Cypress测试项目')
expect(found).to.exist expect(found.pricing).to.eq(200)
expect(found.note).to.eq('updated pricing') })
expect(found.selling_price).to.eq(200) })
it('project profit calculation is correct', () => {
// Fetch oils to calculate expected cost
cy.request('/api/oils').then(oilRes => {
const oilMap = {}
oilRes.body.forEach(o => { oilMap[o.name] = o.bottle_price / o.drop_count })
cy.request({ url: '/api/projects', headers: authHeaders }).then(res => {
const proj = res.body.find(p => p.name === 'Cypress测试项目')
const ings = JSON.parse(proj.ingredients)
const cost = ings.reduce((s, i) => s + (oilMap[i.oil] || 0) * i.drops, 0)
const profit = proj.pricing - cost
expect(profit).to.be.gt(0) // pricing(200) > cost
expect(cost).to.be.gt(0)
})
}) })
}) })

View File

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

View File

@@ -20,11 +20,15 @@ describe('Recipe Cost Parity Test', () => {
}) })
}) })
it('oil data has correct structure (100+ oils)', () => { it('oil data has correct structure (137+ oils)', () => {
expect(Object.keys(oilsMap).length).to.be.gte(100) expect(Object.keys(oilsMap).length).to.be.gte(100)
const lav = oilsMap['薰衣草']
expect(lav).to.exist
expect(lav.bottle_price).to.be.gt(0)
expect(lav.drop_count).to.be.gt(0)
}) })
it('price-per-drop matches formula for available oils', () => { it('price-per-drop matches formula for common oils', () => {
const checks = ['薰衣草', '乳香', '茶树', '柠檬', '椒样薄荷'] const checks = ['薰衣草', '乳香', '茶树', '柠檬', '椒样薄荷']
checks.forEach(name => { checks.forEach(name => {
const oil = oilsMap[name] const oil = oilsMap[name]
@@ -55,7 +59,6 @@ describe('Recipe Cost Parity Test', () => {
}) })
it('no recipe has all-zero cost', () => { it('no recipe has all-zero cost', () => {
if (testRecipes.length === 0) return
let zeroCostCount = 0 let zeroCostCount = 0
testRecipes.forEach(recipe => { testRecipes.forEach(recipe => {
let cost = 0 let cost = 0

View File

@@ -2,7 +2,7 @@ describe('Recipe Search', () => {
beforeEach(() => { beforeEach(() => {
cy.visit('/') cy.visit('/')
// Wait for recipes to load // Wait for recipes to load
cy.get('.recipe-card, .empty-hint', { timeout: 10000 }).should('exist') cy.get('.recipe-card, .empty-state', { timeout: 10000 }).should('exist')
}) })
it('displays recipe cards in the grid', () => { it('displays recipe cards in the grid', () => {
@@ -26,22 +26,15 @@ 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('input[placeholder*="搜索"]').type('薰衣草') cy.get('input[placeholder*="搜索"]').type('薰衣草')
cy.wait(500) cy.wait(500)
cy.get('.recipe-card, .empty-hint', { timeout: 10000 }).should('exist') cy.get('.recipe-card').then($filtered => {
const filteredCount = $filtered.length
cy.get('input[placeholder*="搜索"]').clear() cy.get('input[placeholder*="搜索"]').clear()
cy.wait(500) cy.wait(500)
cy.get('.recipe-card', { timeout: 10000 }).should('have.length.gte', 1) cy.get('.recipe-card').should('have.length.gte', filteredCount)
})
}) })
it('opens recipe detail when clicking a card', () => { it('opens recipe detail when clicking a card', () => {

View File

@@ -1,21 +1,15 @@
describe('Registration Flow', () => { describe('Registration Flow', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
let authHeaders const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
const TEST_USER = 'cypress_test_register_' + Date.now() const TEST_USER = 'cypress_test_register_' + Date.now()
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
it('can register a new user via API', () => { it('can register a new user via API', () => {
cy.request({ cy.request({
method: 'POST', url: '/api/register', method: 'POST', url: '/api/register',
body: { username: TEST_USER, password: 'test1234' }, body: { username: TEST_USER, password: 'test1234', display_name: 'Cypress注册测试' },
failOnStatusCode: false failOnStatusCode: false
}).then(res => { }).then(res => {
// Registration may or may not be implemented
if (res.status === 200 || res.status === 201) { if (res.status === 200 || res.status === 201) {
expect(res.body).to.have.property('token') expect(res.body).to.have.property('token')
} }
@@ -38,10 +32,11 @@ describe('Registration Flow', () => {
it('rejects duplicate username', () => { it('rejects duplicate username', () => {
cy.request({ cy.request({
method: 'POST', url: '/api/register', method: 'POST', url: '/api/register',
body: { username: TEST_USER, password: 'another123' }, body: { username: TEST_USER, password: 'another123', display_name: 'Duplicate' },
failOnStatusCode: false failOnStatusCode: false
}).then(res => { }).then(res => {
if (res.status !== 404) { // Should fail with 400 or 409
if (res.status !== 404) { // 404 means register endpoint doesn't exist
expect(res.status).to.be.oneOf([400, 409, 422]) expect(res.status).to.be.oneOf([400, 409, 422])
} }
}) })

View File

@@ -1,35 +1,28 @@
describe('User Management Flow', () => { describe('User Management Flow', () => {
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
let authHeaders const authHeaders = { Authorization: `Bearer ${ADMIN_TOKEN}` }
const TEST_USERNAME = 'cypress_test_user_e2e' const TEST_USERNAME = 'cypress_test_user_e2e'
const TEST_DISPLAY_NAME = 'Cypress E2E Test User' const TEST_DISPLAY_NAME = 'Cypress E2E Test User'
let testUserId = null let testUserId = null
before(() => {
cy.getAdminToken().then(token => {
adminToken = token
authHeaders = { Authorization: `Bearer ${token}` }
})
})
describe('API: user lifecycle', () => { describe('API: user lifecycle', () => {
// Cleanup any leftover test user first // Cleanup any leftover test user first
before(() => { before(() => {
cy.getAdminToken().then(token => { cy.request({
const headers = { Authorization: `Bearer ${token}` } url: '/api/users',
cy.request({ url: '/api/users', headers }).then(res => { headers: authHeaders
}).then(res => {
const leftover = res.body.find(u => u.username === TEST_USERNAME) const leftover = res.body.find(u => u.username === TEST_USERNAME)
if (leftover) { if (leftover) {
cy.request({ cy.request({
method: 'DELETE', method: 'DELETE',
url: `/api/users/${leftover.id || leftover._id}`, url: `/api/users/${leftover.id || leftover._id}`,
headers, headers: authHeaders,
failOnStatusCode: false failOnStatusCode: false
}) })
} }
}) })
}) })
})
it('creates a new test user via API', () => { it('creates a new test user via API', () => {
cy.request({ cy.request({
@@ -127,7 +120,7 @@ describe('User Management Flow', () => {
it('visits /users and verifies page structure', () => { it('visits /users and verifies page structure', () => {
cy.visit('/users', { cy.visit('/users', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.user-management', { timeout: 10000 }).should('exist') cy.get('.user-management', { timeout: 10000 }).should('exist')
@@ -137,7 +130,7 @@ describe('User Management Flow', () => {
it('shows search input and role filter buttons', () => { it('shows search input and role filter buttons', () => {
cy.visit('/users', { cy.visit('/users', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.user-management', { timeout: 10000 }).should('exist') cy.get('.user-management', { timeout: 10000 }).should('exist')
@@ -145,13 +138,15 @@ describe('User Management Flow', () => {
cy.get('.search-input').should('exist') cy.get('.search-input').should('exist')
// Role filter buttons // Role filter buttons
cy.get('.filter-btn').should('have.length.gte', 1) cy.get('.filter-btn').should('have.length.gte', 1)
cy.get('.filter-btn').contains('管理员').should('exist')
cy.get('.filter-btn').contains('编辑').should('exist') cy.get('.filter-btn').contains('编辑').should('exist')
cy.get('.filter-btn').contains('查看者').should('exist')
}) })
it('displays user list with user cards', () => { it('displays user list with user cards', () => {
cy.visit('/users', { cy.visit('/users', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.user-management', { timeout: 10000 }).should('exist') cy.get('.user-management', { timeout: 10000 }).should('exist')
@@ -166,7 +161,7 @@ describe('User Management Flow', () => {
it('search filters users', () => { it('search filters users', () => {
cy.visit('/users', { cy.visit('/users', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.user-management', { timeout: 10000 }).should('exist') cy.get('.user-management', { timeout: 10000 }).should('exist')
@@ -182,18 +177,18 @@ describe('User Management Flow', () => {
it('role filter narrows user list', () => { it('role filter narrows user list', () => {
cy.visit('/users', { cy.visit('/users', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.user-management', { timeout: 10000 }).should('exist') cy.get('.user-management', { timeout: 10000 }).should('exist')
cy.get('.user-card').then($cards => { cy.get('.user-card').then($cards => {
const total = $cards.length const total = $cards.length
// Click a role filter // Click a role filter
cy.get('.filter-btn').contains('查看者').click() cy.get('.filter-btn').contains('管理员').click()
cy.wait(300) cy.wait(300)
cy.get('.user-card').should('have.length.lte', total) cy.get('.user-card').should('have.length.lte', total)
// Clicking again deactivates the filter // Clicking again deactivates the filter
cy.get('.filter-btn').contains('查看者').click() cy.get('.filter-btn').contains('管理员').click()
cy.wait(300) cy.wait(300)
cy.get('.user-card').should('have.length', total) cy.get('.user-card').should('have.length', total)
}) })
@@ -202,22 +197,22 @@ describe('User Management Flow', () => {
it('shows user count', () => { it('shows user count', () => {
cy.visit('/users', { cy.visit('/users', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.user-management', { timeout: 10000 }).should('exist') cy.get('.user-management', { timeout: 10000 }).should('exist')
cy.get('.user-count').should('contain', '个用户') cy.get('.user-count').should('contain', '个用户')
}) })
it('page has user management container', () => { it('has create user section', () => {
cy.visit('/users', { cy.visit('/users', {
onBeforeLoad(win) { onBeforeLoad(win) {
win.localStorage.setItem('oil_auth_token', adminToken) win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN)
} }
}) })
cy.get('.user-management', { timeout: 10000 }).should('exist') cy.get('.user-management', { timeout: 10000 }).should('exist')
// Verify the page loaded with user data cy.get('.create-section').should('exist')
cy.get('.user-card').should('have.length.gte', 1) cy.contains('创建新用户').should('be.visible')
}) })
}) })

View File

@@ -1,44 +1,55 @@
describe('Visual Check', () => { // Quick visual screenshots for manual review before deploy
let adminToken const ADMIN_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
before(() => { describe('Visual Check - Screenshots', () => {
cy.getAdminToken().then(token => { adminToken = token }) it('homepage with recipes', () => {
}) cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) } })
it('homepage loads with recipes', () => {
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('oil reference loads with chips', () => { it('recipe detail overlay', () => {
cy.visit('/oils', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) cy.visit('/', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) } })
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', ADMIN_TOKEN) } })
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 loads', () => { it('manage recipes page', () => {
cy.visit('/manage', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) cy.visit('/manage', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) } })
cy.get('.recipe-manager', { timeout: 10000 }).should('exist') cy.wait(2000)
cy.screenshot('04-manage-recipes')
}) })
it('inventory page loads', () => { it('inventory page', () => {
cy.visit('/inventory', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', adminToken) } }) cy.visit('/inventory', { onBeforeLoad(win) { win.localStorage.setItem('oil_auth_token', ADMIN_TOKEN) } })
cy.get('.inventory-page', { timeout: 10000 }).should('exist') cy.wait(1500)
cy.screenshot('05-inventory')
}) })
it('recipe cards show price > 0', () => { it('check if 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', ADMIN_TOKEN) } })
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)
// Check if it contains a price like ¥ X.XX where X > 0
const priceMatch = text.match(/¥\s*(\d+\.?\d*)/) const priceMatch = text.match(/¥\s*(\d+\.?\d*)/)
if (priceMatch) { if (priceMatch) {
expect(parseFloat(priceMatch[1])).to.be.gt(0) cy.log('Price found: ¥' + priceMatch[1])
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')
})
}) })

View File

@@ -3,94 +3,17 @@
// These are tracked separately; E2E tests focus on user-visible behavior. // These are tracked separately; E2E tests focus on user-visible behavior.
Cypress.on('uncaught:exception', () => false) Cypress.on('uncaught:exception', () => false)
// ── Admin token management ──────────────────────────────
// In CI, the backend is started with ADMIN_TOKEN env var set to a known value.
// Locally, the admin token may be the hardcoded dev value.
// This helper tries multiple strategies to obtain a working admin token.
let _cachedAdminToken = null
/**
* Get a working admin token. Tries:
* 1. Cached token from previous call
* 2. CYPRESS_ADMIN_TOKEN env var (set via CI or cypress.env.json)
* 3. Hardcoded local dev token
* 4. Register a user and use its token (viewer-level fallback)
*
* Returns the token via cy.wrap() so it can be used in chains.
*/
Cypress.Commands.add('getAdminToken', () => {
if (_cachedAdminToken) {
return cy.wrap(_cachedAdminToken)
}
// Strategy 1: Try the CI token (passed via CYPRESS_ADMIN_TOKEN env or set in config)
const envToken = Cypress.env('ADMIN_TOKEN')
if (envToken) {
return cy.request({ url: '/api/me', headers: { Authorization: `Bearer ${envToken}` } }).then(res => {
if (res.body && res.body.role === 'admin') {
_cachedAdminToken = envToken
return cy.wrap(envToken)
}
// Token didn't work as admin, fall through
return _tryLocalToken()
})
}
return _tryLocalToken()
})
function _tryLocalToken() {
// Strategy 2: Try the hardcoded local dev token
const LOCAL_TOKEN = 'c86ae7afbe10fabe3c1d5e1a7fee74feaadfd5dc7be2ab62'
return cy.request({
url: '/api/me',
headers: { Authorization: `Bearer ${LOCAL_TOKEN}` },
failOnStatusCode: false
}).then(res => {
if (res.status === 200 && res.body && res.body.role === 'admin') {
_cachedAdminToken = LOCAL_TOKEN
return cy.wrap(LOCAL_TOKEN)
}
// Strategy 3: Register a test user — will be viewer but some tests just need any auth
// For admin-requiring tests, the CI must set ADMIN_TOKEN properly
return cy.request({
method: 'POST',
url: '/api/register',
body: { username: 'cypress_admin_fallback', password: 'cypresstest1234' },
failOnStatusCode: false
}).then(regRes => {
if (regRes.status === 201 || regRes.status === 200) {
_cachedAdminToken = regRes.body.token
return cy.wrap(regRes.body.token)
}
// Maybe already registered, try login
return cy.request({
method: 'POST',
url: '/api/login',
body: { username: 'cypress_admin_fallback', password: 'cypresstest1234' },
failOnStatusCode: false
}).then(loginRes => {
if (loginRes.status === 200) {
_cachedAdminToken = loginRes.body.token
return cy.wrap(loginRes.body.token)
}
// Last resort: return local token anyway
_cachedAdminToken = LOCAL_TOKEN
return cy.wrap(LOCAL_TOKEN)
})
})
})
}
// Custom commands for the oil calculator app // Custom commands for the oil calculator app
// Login as admin via token injection — uses dynamic token // Login as admin via token injection
Cypress.Commands.add('loginAsAdmin', () => { Cypress.Commands.add('loginAsAdmin', () => {
cy.getAdminToken().then(token => { cy.request('GET', '/api/users').then((res) => {
const admin = res.body.find(u => u.role === 'admin')
if (admin) {
cy.window().then(win => { cy.window().then(win => {
win.localStorage.setItem('oil_auth_token', token) win.localStorage.setItem('oil_auth_token', admin.token)
}) })
}
}) })
}) })
@@ -101,13 +24,6 @@ Cypress.Commands.add('loginWithToken', (token) => {
}) })
}) })
// Get auth headers for API requests
Cypress.Commands.add('adminHeaders', () => {
return cy.getAdminToken().then(token => {
return { Authorization: `Bearer ${token}` }
})
})
// Verify toast message appears // Verify toast message appears
Cypress.Commands.add('expectToast', (text) => { Cypress.Commands.add('expectToast', (text) => {
cy.get('.toast').should('contain', text) cy.get('.toast').should('contain', text)

View File

@@ -1,330 +0,0 @@
import { describe, it, expect } from 'vitest'
import prodData from './fixtures/production-data.json'
import { KITS } from '../config/kits'
const oils = prodData.oils
// ---------------------------------------------------------------------------
// Replicate kit cost calculation logic from useKitCost.js (pure functions)
// ---------------------------------------------------------------------------
function resolveOilName(kitOilName) {
if (oils[kitOilName]) return kitOilName
const match = Object.keys(oils).find(n => n.endsWith(kitOilName) && n !== kitOilName)
return match || kitOilName
}
function calcKitPerDrop(kit) {
const resolved = kit.oils.map(resolveOilName)
const bc = kit.bottleCount || {}
let totalBottlePrice = 0
const oilBottlePrices = {}
for (const name of resolved) {
const meta = oils[name]
const count = bc[name] || 1
const bp = meta ? meta.bottlePrice * count : 0
oilBottlePrices[name] = bp
totalBottlePrice += bp
}
if (totalBottlePrice === 0) return {}
const totalValue = totalBottlePrice + (kit.accessoryValue || 0)
const discountRate = Math.min(kit.price / totalValue, 1)
const perDrop = {}
for (const name of resolved) {
const meta = oils[name]
const count = bc[name] || 1
const bp = oilBottlePrices[name]
const kitCostForOil = bp * discountRate
const totalDrops = meta ? meta.dropCount * count : 1
perDrop[name] = totalDrops > 0 ? kitCostForOil / totalDrops : 0
}
return perDrop
}
function canMakeRecipe(kit, recipe) {
const resolvedSet = new Set(kit.oils.map(resolveOilName))
return recipe.ingredients.every(ing => resolvedSet.has(ing.oil))
}
function calcRecipeCostWithKit(kitPerDrop, recipe) {
return recipe.ingredients.reduce((sum, ing) => {
return sum + (kitPerDrop[ing.oil] || 0) * ing.drops
}, 0)
}
function calcOriginalCost(recipe) {
return recipe.ingredients.reduce((sum, ing) => {
const meta = oils[ing.oil]
if (!meta || !meta.dropCount) return sum
return sum + (meta.bottlePrice / meta.dropCount) * ing.drops
}, 0)
}
// ---------------------------------------------------------------------------
// Kit Configuration
// ---------------------------------------------------------------------------
describe('Kit Configuration', () => {
it('has 4 kits defined', () => {
expect(KITS).toHaveLength(4)
})
it('each kit has required fields', () => {
for (const kit of KITS) {
expect(kit).toHaveProperty('id')
expect(kit).toHaveProperty('name')
expect(kit).toHaveProperty('price')
expect(kit).toHaveProperty('oils')
expect(kit.price).toBeGreaterThan(0)
expect(kit.oils.length).toBeGreaterThan(0)
}
})
it('all kits include coconut oil', () => {
for (const kit of KITS) {
expect(kit.oils).toContain('椰子油')
}
})
it('kit ids are unique', () => {
const ids = KITS.map(k => k.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('芳香调理 has 9 oils at ¥1575', () => {
const kit = KITS.find(k => k.id === 'aroma')
expect(kit.price).toBe(1575)
expect(kit.oils).toHaveLength(9)
})
it('家庭医生 has 11 oils at ¥2250', () => {
const kit = KITS.find(k => k.id === 'family')
expect(kit.price).toBe(2250)
expect(kit.oils).toHaveLength(11)
})
it('居家呵护 has 23 oils at ¥3988', () => {
const kit = KITS.find(k => k.id === 'home3988')
expect(kit.price).toBe(3988)
expect(kit.oils).toHaveLength(23)
})
it('全精油 has 80 oils at ¥17700 with bottleCount for coconut oil', () => {
const kit = KITS.find(k => k.id === 'full')
expect(kit.price).toBe(17700)
expect(kit.oils.length).toBe(80)
expect(kit.bottleCount).toBeDefined()
expect(kit.bottleCount['椰子油']).toBeCloseTo(2.57, 2)
})
})
// ---------------------------------------------------------------------------
// Oil Name Resolution
// ---------------------------------------------------------------------------
describe('Oil Name Resolution', () => {
it('resolves exact match', () => {
expect(resolveOilName('薰衣草')).toBe('薰衣草')
expect(resolveOilName('乳香')).toBe('乳香')
})
it('resolves 牛至 to 西班牙牛至 via endsWith', () => {
expect(resolveOilName('牛至')).toBe('西班牙牛至')
})
it('does NOT resolve 牛至 to 牛至呵护', () => {
expect(resolveOilName('牛至')).not.toBe('牛至呵护')
})
it('returns original name for unknown oil', () => {
expect(resolveOilName('不存在的油')).toBe('不存在的油')
})
})
// ---------------------------------------------------------------------------
// Kit Cost Calculation
// ---------------------------------------------------------------------------
describe('Kit Cost Calculation', () => {
it('discount rate is always <= 1 (kit never more expensive than buying individually)', () => {
for (const kit of KITS) {
const perDrop = calcKitPerDrop(kit)
for (const [name, ppd] of Object.entries(perDrop)) {
const meta = oils[name]
if (!meta || !meta.dropCount) continue
const originalPpd = meta.bottlePrice / meta.dropCount
expect(ppd).toBeLessThanOrEqual(originalPpd + 0.001) // tiny float tolerance
}
}
})
it('家庭医生 discount is ~32-33%', () => {
const kit = KITS.find(k => k.id === 'family')
const bc = kit.bottleCount || {}
let totalBp = 0
for (const name of kit.oils) {
const resolved = resolveOilName(name)
const meta = oils[resolved]
totalBp += meta ? meta.bottlePrice * (bc[resolved] || 1) : 0
}
const totalValue = totalBp + (kit.accessoryValue || 0)
const discount = 1 - kit.price / totalValue
expect(discount).toBeGreaterThan(0.30)
expect(discount).toBeLessThan(0.40)
})
it('kit cost for a recipe is less than original cost', () => {
const kit = KITS.find(k => k.id === 'family')
const perDrop = calcKitPerDrop(kit)
// 灰指甲: 西班牙牛至 + 椰子油
const recipe = prodData.recipes.find(r => r.name === '灰指甲')
if (recipe && canMakeRecipe(kit, recipe)) {
const kitCost = calcRecipeCostWithKit(perDrop, recipe)
const origCost = calcOriginalCost(recipe)
expect(kitCost).toBeLessThan(origCost)
expect(kitCost).toBeGreaterThan(0)
}
})
it('全精油 bottleCount 2.57 makes coconut oil cheaper per drop than without multiplier', () => {
const fullKit = KITS.find(k => k.id === 'full')
const perDrop = calcKitPerDrop(fullKit)
// With 2.57 bottles, per-drop cost should be roughly 1/2.57 of single-bottle kit cost
// at the same discount rate. Just verify it's significantly less than original per-drop.
const origPpd = oils['椰子油'].bottlePrice / oils['椰子油'].dropCount
expect(perDrop['椰子油']).toBeLessThan(origPpd)
expect(perDrop['椰子油']).toBeGreaterThan(0)
})
it('accessoryValue reduces effective oil cost', () => {
const kit = KITS.find(k => k.id === 'family')
// Without accessory: rate = price / totalBp
// With accessory: rate = price / (totalBp + accessoryValue) < previous rate
expect(kit.accessoryValue).toBeGreaterThan(0)
const bc = kit.bottleCount || {}
let totalBp = 0
for (const name of kit.oils) {
const resolved = resolveOilName(name)
const meta = oils[resolved]
totalBp += meta ? meta.bottlePrice * (bc[resolved] || 1) : 0
}
const rateWithAcc = kit.price / (totalBp + kit.accessoryValue)
const rateWithoutAcc = kit.price / totalBp
expect(rateWithAcc).toBeLessThan(rateWithoutAcc)
})
})
// ---------------------------------------------------------------------------
// Recipe Matching
// ---------------------------------------------------------------------------
describe('Recipe Matching', () => {
it('larger kits can make at least as many recipes as smaller ones', () => {
const counts = KITS.map(kit => {
const matched = prodData.recipes.filter(r => canMakeRecipe(kit, r))
return { id: kit.id, count: matched.length, oilCount: kit.oils.length }
}).sort((a, b) => a.oilCount - b.oilCount)
for (let i = 1; i < counts.length; i++) {
expect(counts[i].count).toBeGreaterThanOrEqual(counts[i - 1].count)
}
})
it('全精油 can make the most recipes', () => {
const fullKit = KITS.find(k => k.id === 'full')
const fullCount = prodData.recipes.filter(r => canMakeRecipe(fullKit, r)).length
for (const kit of KITS) {
if (kit.id === 'full') continue
const count = prodData.recipes.filter(r => canMakeRecipe(kit, r)).length
expect(fullCount).toBeGreaterThanOrEqual(count)
}
})
it('灰指甲 (牛至+椰子油) can be made by 家庭医生', () => {
const kit = KITS.find(k => k.id === 'family')
const recipe = prodData.recipes.find(r => r.name === '灰指甲')
expect(canMakeRecipe(kit, recipe)).toBe(true)
})
it('recipe requiring 永久花 cannot be made by 芳香调理', () => {
const kit = KITS.find(k => k.id === 'aroma')
const recipe = prodData.recipes.find(r => r.name === '小v脸') // has 永久花
expect(canMakeRecipe(kit, recipe)).toBe(false)
})
})
// ---------------------------------------------------------------------------
// Accessory Values — PR32
// ---------------------------------------------------------------------------
describe('Accessory Values', () => {
it('芳香调理 has no accessories', () => {
const kit = KITS.find(k => k.id === 'aroma')
expect(kit.accessoryValue).toBeUndefined()
})
it('家庭医生 accessories = 475 (香薰机375 + 木盒100)', () => {
const kit = KITS.find(k => k.id === 'family')
expect(kit.accessoryValue).toBe(475)
})
it('居家呵护 accessories = 585 (香薰机375 + 竹木架210)', () => {
const kit = KITS.find(k => k.id === 'home3988')
expect(kit.accessoryValue).toBe(585)
})
it('全精油 accessories = 795 (香薰机375 + 竹木架210×2)', () => {
const kit = KITS.find(k => k.id === 'full')
expect(kit.accessoryValue).toBe(795)
})
})
// ---------------------------------------------------------------------------
// Discount Rate Calculation — PR32
// ---------------------------------------------------------------------------
describe('Discount Rate', () => {
function calcDiscountRate(kit) {
const resolved = kit.oils.map(resolveOilName)
const bc = kit.bottleCount || {}
let totalBP = 0
for (const name of resolved) {
const meta = oils[name]
totalBP += meta ? meta.bottlePrice * (bc[name] || 1) : 0
}
const totalValue = totalBP + (kit.accessoryValue || 0)
return totalValue > 0 ? Math.min(kit.price / totalValue, 1) : 1
}
it('all kits have discount rate < 1 (套装比单买便宜)', () => {
for (const kit of KITS) {
const rate = calcDiscountRate(kit)
expect(rate).toBeLessThan(1)
expect(rate).toBeGreaterThan(0)
}
})
it('全精油 discount rate ≈ 0.69', () => {
const kit = KITS.find(k => k.id === 'full')
const rate = calcDiscountRate(kit)
expect(rate).toBeGreaterThan(0.65)
expect(rate).toBeLessThan(0.75)
})
it('larger kits have better discounts', () => {
const rates = KITS.map(k => ({ id: k.id, rate: calcDiscountRate(k), count: k.oils.length }))
rates.sort((a, b) => a.count - b.count)
// Generally larger kits should have lower discount rate (better deal)
// At minimum, the largest kit should have a lower rate than the smallest
expect(rates[rates.length - 1].rate).toBeLessThanOrEqual(rates[0].rate)
})
it('kit cost per recipe should be less than original cost', () => {
for (const kit of KITS) {
const perDrop = calcKitPerDrop(kit)
const recipes = prodData.recipes.filter(r => canMakeRecipe(kit, r))
for (const r of recipes.slice(0, 5)) {
const kitCost = calcRecipeCostWithKit(perDrop, r)
const originalCost = calcOriginalCost(r)
expect(kitCost).toBeLessThanOrEqual(originalCost + 0.01)
}
}
})
})

View File

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

View File

@@ -1,73 +0,0 @@
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('')
})
})

View File

@@ -444,204 +444,3 @@ describe('unit system — PR30', () => {
expect(hasProduct).toBe(true) expect(hasProduct).toBe(true)
}) })
}) })
// ---------------------------------------------------------------------------
// PR31: Retail price column alignment logic
// ---------------------------------------------------------------------------
describe('retail price column alignment — PR31', () => {
function hasAnyRetail(ingredients, retailMap) {
return ingredients.some(ing => retailMap[ing.oil] && retailMap[ing.oil] > 0)
}
it('shows retail column when at least one ingredient has retail price', () => {
const ings = [{ oil: '薰衣草', drops: 3 }, { oil: '无香乳液', drops: 30 }]
const retailMap = { '薰衣草': 0.94, '无香乳液': 0 }
expect(hasAnyRetail(ings, retailMap)).toBe(true)
})
it('hides retail column when no ingredient has retail price', () => {
const ings = [{ oil: '无香乳液', drops: 30 }, { oil: '玫瑰护手霜', drops: 20 }]
const retailMap = { '无香乳液': 0, '玫瑰护手霜': 0 }
expect(hasAnyRetail(ings, retailMap)).toBe(false)
})
it('all rows render when column is shown (empty string for missing retail)', () => {
const ings = [{ oil: '薰衣草', drops: 3 }, { oil: '无香乳液', drops: 30 }]
const retailMap = { '薰衣草': 0.94, '无香乳液': 0 }
const showColumn = hasAnyRetail(ings, retailMap)
expect(showColumn).toBe(true)
const values = ings.map(i => retailMap[i.oil] > 0 ? `¥${(retailMap[i.oil] * i.drops).toFixed(2)}` : '')
expect(values[0]).toBe('¥2.82')
expect(values[1]).toBe('')
})
})
// ---------------------------------------------------------------------------
// PR31: Volume field in recipe store mapping
// ---------------------------------------------------------------------------
describe('volume field in recipe mapping — PR31', () => {
it('maps volume from API response', () => {
const apiRecipe = { id: 1, name: 'test', volume: 'single', ingredients: [], tags: [] }
const mapped = { volume: apiRecipe.volume || '' }
expect(mapped.volume).toBe('single')
})
it('defaults to empty string when volume is null', () => {
const apiRecipe = { id: 1, name: 'test', volume: null, ingredients: [], tags: [] }
const mapped = { volume: apiRecipe.volume || '' }
expect(mapped.volume).toBe('')
})
it('volume values map to correct display labels', () => {
const labels = { 'single': '单次', '5': '5ml', '10': '10ml', '15': '15ml', '': '' }
expect(labels['single']).toBe('单次')
expect(labels['5']).toBe('5ml')
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')
})
})

View File

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

View File

@@ -66,7 +66,7 @@
<span class="ec-oil-name">{{ getCardOilName(ing.oil) }}</span> <span class="ec-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) ? oilsStore.fmtPrice(retailPerDrop(ing.oil) * ing.drops) : '' }}</span> <span v-if="hasRetailForOil(ing.oil) && retailPerDrop(ing.oil) > oilsStore.pricePerDrop(ing.oil)" class="ec-retail">{{ 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.unitLabel(ing.oil)} ${oilsStore.fmtPrice(cost)}` return `${ing.oil} ${ing.drops} ${oilsStore.fmtPrice(cost)}`
}) })
const total = priceInfo.value.cost const total = priceInfo.value.cost
const text = [ const text = [
@@ -590,10 +590,6 @@ function getCardRecipeName() {
return displayRecipe.value.name return displayRecipe.value.name
} }
const cardHasAnyRetail = computed(() =>
cardIngredients.value.some(ing => hasRetailForOil(ing.oil))
)
const cardTitleSize = computed(() => { const cardTitleSize = computed(() => {
const name = getCardRecipeName() const name = getCardRecipeName()
const len = name.length const len = name.length
@@ -829,7 +825,7 @@ onMounted(() => {
else { selectedVolume.value = 'custom'; customVolumeValue.value = Math.round(ml) } else { selectedVolume.value = 'custom'; customVolumeValue.value = Math.round(ml) }
loadBrand() loadBrand()
// Don't auto-generate card image on mount — generate on demand when saving nextTick(() => generateCardImage())
}) })
function addIngredient() { function addIngredient() {
@@ -1010,7 +1006,6 @@ async function saveRecipe() {
note: editNote.value.trim(), note: editNote.value.trim(),
tags: editTags.value, tags: editTags.value,
ingredients: allIngs, ingredients: allIngs,
volume: selectedVolume.value || '',
} }
await recipesStore.saveRecipe(payload) await recipesStore.saveRecipe(payload)
// Reload recipes so the data is fresh when re-opened // Reload recipes so the data is fresh when re-opened
@@ -1699,8 +1694,8 @@ async function saveRecipe() {
} }
.editor-drops { .editor-drops {
width: 58px; width: 42px;
padding: 5px 4px 5px 6px; padding: 5px 2px;
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;

View File

@@ -1,173 +0,0 @@
import { computed } from 'vue'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { KITS } from '../config/kits'
/**
* 套装成本分摊与配方匹配
*
* 分摊逻辑:按各精油原瓶价占比分摊套装总价
* 某精油套装内成本 = (该油原瓶价 / 套装内所有油原瓶价之和) × 套装总价
* 套装内每滴成本 = 套装内成本 / 该油滴数
*/
export function useKitCost() {
const oils = useOilsStore()
const recipeStore = useRecipesStore()
// Resolve kit oil name to system oil name (handles 牛至→西班牙牛至 etc.)
function resolveOilName(kitOilName) {
if (oils.oilsMeta[kitOilName]) return kitOilName
// Try finding system oil that ends with kit name
return oils.oilNames.find(n => n.endsWith(kitOilName) && n !== kitOilName) || kitOilName
}
// Calculate per-drop costs for a kit
function calcKitPerDrop(kit) {
const resolved = kit.oils.map(name => resolveOilName(name))
const bc = kit.bottleCount || {} // e.g. { '椰子油': 2.57 }
// Sum of bottle prices for all oils in kit (accounting for multiple bottles)
let totalBottlePrice = 0
const oilBottlePrices = {}
for (const name of resolved) {
const meta = oils.oilsMeta[name]
const count = bc[name] || 1
const bp = meta ? meta.bottlePrice * count : 0
oilBottlePrices[name] = bp
totalBottlePrice += bp
}
if (totalBottlePrice === 0) return {}
// Uniform discount: kit price covers oils + accessories at the same discount rate
// discount_rate = kit_price / (oil_total + accessory_value)
// each oil's kit cost = bottle_price × discount_rate
const totalValue = totalBottlePrice + (kit.accessoryValue || 0)
const discountRate = Math.min(kit.price / totalValue, 1) // cap at 1 (no markup)
const perDrop = {}
for (const name of resolved) {
const meta = oils.oilsMeta[name]
const count = bc[name] || 1
const bp = oilBottlePrices[name]
const kitCostForOil = bp * discountRate
const totalDrops = meta ? meta.dropCount * count : 1
perDrop[name] = totalDrops > 0 ? kitCostForOil / totalDrops : 0
}
return perDrop
}
// Check if a recipe can be made with a kit
function canMakeRecipe(kit, recipe) {
const resolvedSet = new Set(kit.oils.map(name => resolveOilName(name)))
return recipe.ingredients.every(ing => resolvedSet.has(ing.oil))
}
// Calculate recipe cost using kit pricing
function calcRecipeCostWithKit(kitPerDrop, recipe) {
return recipe.ingredients.reduce((sum, ing) => {
const ppd = kitPerDrop[ing.oil] || 0
return sum + ppd * ing.drops
}, 0)
}
// Get all matching recipes for a kit
function getKitRecipes(kit) {
const perDrop = calcKitPerDrop(kit)
return recipeStore.recipes
.filter(r => canMakeRecipe(kit, r))
.map(r => ({
...r,
kitCost: calcRecipeCostWithKit(perDrop, r),
originalCost: oils.calcCost(r.ingredients),
}))
.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
}
// Build full analysis for all kits, sorted by recipe count ascending (fewest recipes first)
const kitAnalysis = computed(() => {
return KITS.map(kit => {
const perDrop = calcKitPerDrop(kit)
const recipes = getKitRecipes(kit)
// Calculate discount rate for display
const resolved = kit.oils.map(name => resolveOilName(name))
const bc = kit.bottleCount || {}
let totalBP = 0
for (const name of resolved) {
const meta = oils.oilsMeta[name]
totalBP += meta ? meta.bottlePrice * (bc[name] || 1) : 0
}
const totalValue = totalBP + (kit.accessoryValue || 0)
const discountRate = totalValue > 0 ? Math.min(kit.price / totalValue, 1) : 1
return {
...kit,
perDrop,
recipes,
recipeCount: recipes.length,
discountRate,
}
}).sort((a, b) => a.recipeCount - b.recipeCount)
})
// Cross-kit comparison: membership-tier style
// Columns: kits ordered by recipe count (fewest→most, from kitAnalysis)
// Rows: recipes available to most kits at top, exclusive recipes at bottom (staircase pattern)
const crossComparison = computed(() => {
const analysis = kitAnalysis.value
// Kit order for staircase: index in sorted analysis (0 = smallest kit)
const kitOrder = analysis.map(ka => ka.id)
const allRecipeIds = new Set()
for (const ka of analysis) {
for (const r of ka.recipes) allRecipeIds.add(r._id)
}
const rows = []
for (const id of allRecipeIds) {
let recipe = null
const costs = {}
let availCount = 0
// Track which kit columns have this recipe (by index in sorted order)
let smallestKitIdx = kitOrder.length
for (let i = 0; i < analysis.length; i++) {
const ka = analysis[i]
const found = ka.recipes.find(r => r._id === id)
if (found) {
if (!recipe) recipe = found
costs[ka.id] = found.kitCost
availCount++
if (i < smallestKitIdx) smallestKitIdx = i
} else {
costs[ka.id] = null
}
}
rows.push({
id,
name: recipe.name,
tags: recipe.tags,
volume: recipe.volume,
ingredients: recipe.ingredients,
originalCost: recipe.originalCost,
costs,
availCount,
smallestKitIdx,
})
}
// Staircase sort: most available first, then by smallest kit that has it, then by name
rows.sort((a, b) => {
if (a.availCount !== b.availCount) return b.availCount - a.availCount
if (a.smallestKitIdx !== b.smallestKitIdx) return a.smallestKitIdx - b.smallestKitIdx
return a.name.localeCompare(b.name, 'zh')
})
return rows
})
return {
KITS,
resolveOilName,
calcKitPerDrop,
canMakeRecipe,
calcRecipeCostWithKit,
getKitRecipes,
kitAnalysis,
crossComparison,
}
}

View File

@@ -1,6 +1,5 @@
// 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: '轻微光敏,白天涂抹注意防晒' },
@@ -34,17 +33,6 @@ 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(/呵护$/, '')

View File

@@ -1,52 +0,0 @@
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
}

View File

@@ -1,64 +0,0 @@
// doTERRA 套装配置
// 价格和内容更新频率低,手动维护即可
// bottleCount: 某种油在套装中的瓶数用于成本分摊和可做次数计算默认1
// accessoryValue: 配件价值(香薰机、木盒等),从套装价中扣除后再分摊到精油
export const KITS = [
{
id: 'aroma',
name: '芳香调理套装',
price: 1575,
oils: [
'茶树', '野橘', '椒样薄荷', '薰衣草',
'芳香调理', '安定情绪', '保卫', '舒缓',
'椰子油',
],
},
{
id: 'family',
name: '家庭医生套装',
price: 2250,
accessoryValue: 475, // 香薰机375 + 木盒100
oils: [
'乳香', '茶树', '薰衣草', '柠檬', '椒样薄荷', '西班牙牛至',
'乐活', '舒缓', '保卫', '顺畅呼吸',
'椰子油',
],
},
{
id: 'home3988',
name: '居家呵护套装',
price: 3988,
accessoryValue: 585, // 香薰机375 + 旋转竹木精油架210
oils: [
'乳香', '野橘', '柠檬', '薰衣草', '椒样薄荷', '冬青', '茶树', '生姜', '柠檬草', '西班牙牛至',
'西洋蓍草', '乐活', '保卫', '新瑞活力', '舒缓', '安定情绪', '安宁神气', '顺畅呼吸', '柑橘清新', '芳香调理', '元气焕能',
'仕女呵护',
'椰子油',
],
},
{
id: 'full',
name: '全精油套装',
price: 17700,
oils: [
'侧柏', '乳香', '雪松', '芫荽', '芫荽叶', '丝柏', '圆柚', '红橘', '冬青', '没药', '扁柏', '檀香', '姜黄', '玫瑰',
'绿薄荷', '薰衣草', '永久花', '香蜂草', '迷迭香', '麦卢卡', '天竺葵', '蓝艾菊', '小茴香',
'古巴香脂', '依兰依兰', '丁香花蕾', '柠檬尤加利', '广藿香',
'罗勒', '莱姆', '生姜', '柠檬', '茶树', '野橘', '香茅', '枫香',
'芹菜籽', '岩兰草', '苦橙叶', '柠檬草', '山鸡椒', '黑云杉',
'马郁兰', '佛手柑', '黑胡椒', '小豆蔻', '尤加利', '百里香',
'椒样薄荷', '杜松浆果', '加州胡椒', '罗马洋甘菊', '道格拉斯冷杉', '西班牙鼠尾草',
'快乐鼠尾草', '西伯利亚冷杉',
'西班牙牛至', '斯里兰卡肉桂皮',
// 复配精油
'完美修护', '西洋蓍草', '花样年华焕肤油', '元气焕能', '舒缓',
'保卫', '乐释', '乐活', '愈创木', '椰风香草', '清醇薄荷',
'柑橘绚烂', '新瑞活力', '安宁神气', '芳香调理', '安定情绪',
'柑橘清新', '顺畅呼吸', '净化清新', '赋活呼吸', '天然防护',
'椰子油',
],
accessoryValue: 795, // 香薰机375 + 旋转竹木精油架210×2
// 115mL×1 + 30mL×6 = 295mL, 标准瓶115mL, 约2.57瓶
bottleCount: { '椰子油': 2.57 },
},
]

View File

@@ -29,12 +29,6 @@ const routes = [
component: () => import('../views/Projects.vue'), component: () => import('../views/Projects.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/kit-export',
name: 'KitExport',
component: () => import('../views/KitExport.vue'),
meta: { requiresAuth: true },
},
{ {
path: '/mydiary', path: '/mydiary',
name: 'MyDiary', name: 'MyDiary',

View File

@@ -16,7 +16,6 @@ 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(() =>
@@ -51,11 +50,7 @@ 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)
const anyRetail = ingredients.some(i => { if (retail > cost) {
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 }
@@ -80,28 +75,9 @@ 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, card = null) { async function saveOil(name, bottlePrice, dropCount, retailPrice, enName = null, unit = null) {
const payload = { const payload = {
name, name,
bottle_price: bottlePrice, bottle_price: bottlePrice,
@@ -110,13 +86,6 @@ 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()
} }
@@ -165,7 +134,6 @@ export const useOilsStore = defineStore('oils', () => {
return { return {
oils, oils,
oilsMeta, oilsMeta,
oilCards,
oilNames, oilNames,
pricePerDrop, pricePerDrop,
calcCost, calcCost,

View File

@@ -101,7 +101,6 @@ import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes' import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui' import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi' import { api } from '../composables/useApi'
import { KITS as KIT_LIST } from '../config/kits'
const auth = useAuthStore() const auth = useAuthStore()
const oils = useOilsStore() const oils = useOilsStore()
@@ -121,17 +120,30 @@ const searchResults = computed(() => {
return oils.oilNames.filter(n => n.toLowerCase().includes(q)).slice(0, 15) return oils.oilNames.filter(n => n.toLowerCase().includes(q)).slice(0, 15)
}) })
// Kit definitions from shared config // Kit definitions
const KITS = Object.fromEntries(KIT_LIST.map(k => [k.id, k.oils])) const KITS = {
family: ['乳香', '茶树', '薰衣草', '柠檬', '椒样薄荷', '保卫', '牛至', '乐活', '顺畅呼吸', '舒缓'],
home3988: ['乳香', '野橘', '柠檬', '薰衣草', '椒样薄荷', '冬青', '茶树', '生姜', '柠檬草', '西班牙牛至',
'西洋蓍草石榴籽', '乐活', '保卫', '新瑞活力', '舒缓', '安定情绪', '安宁神气', '顺畅呼吸', '柑橘清新', '芳香调理', '元气焕能'],
aroma: ['薰衣草', '舒缓', '安定情绪', '芳香调理', '野橘', '椒样薄荷', '保卫', '茶树'],
full: ['侧柏', '乳香', '雪松', '芫荽', '丝柏', '圆柚', '红橘', '冬青', '没药', '扁柏', '檀香', '姜黄', '玫瑰',
'绿薄荷', '薰衣草', '永久花', '香蜂草', '迷迭香', '麦卢卡', '天竺葵', '蓝艾菊', '小茴香',
'古巴香脂', '依兰依兰', '丁香花蕾', '柠檬尤加利', '藿香', '西班牙牛至尾草',
'罗勒', '莱姆', '生姜', '柠檬', '茶树', '野橘', '香茅', '枫香',
'芹菜籽', '岩兰草', '苦橙叶', '柠檬草', '山鸡椒', '黑云杉',
'马郁兰', '佛手柑', '黑胡椒', '小豆蔻', '尤加利', '百里香',
'椒样薄荷', '杜松浆果', '加州白鼠尾草',
'快乐鼠尾草', '西伯利亚冷杉',
'西班牙牛至', '斯里兰卡肉桂']
}
function addKit(kitName) { function addKit(kitName) {
const kit = KITS[kitName] const kit = KITS[kitName]
if (!kit) return if (!kit) return
let added = 0 let added = 0
for (const name of kit) { for (const name of kit) {
// Match existing oil names: exact first, then oil name ending with kit name (西班牙牛至 matches 牛至, but 牛至呵护 does not) // Match existing oil names (fuzzy)
const match = oils.oilNames.find(n => n === name) const match = oils.oilNames.find(n => n === name) || oils.oilNames.find(n => n.includes(name) || name.includes(n))
|| oils.oilNames.find(n => n.endsWith(name) && n !== name)
if (match && !ownedOils.value.includes(match)) { if (match && !ownedOils.value.includes(match)) {
ownedOils.value.push(match) ownedOils.value.push(match)
added++ added++

View File

@@ -1,507 +0,0 @@
<template>
<div class="kit-export-page">
<div class="toolbar-sticky">
<div class="toolbar-inner">
<button class="btn-back" @click="$router.push('/projects')">&larr; 返回</button>
<h3 class="page-title">套装方案对比</h3>
<div class="toolbar-actions">
<button class="btn-outline btn-sm" @click="exportExcel('full')">导出完整版</button>
<button class="btn-outline btn-sm" @click="exportExcel('simple')">导出简版</button>
</div>
</div>
</div>
<!-- Kit Summary Cards -->
<div class="kit-cards">
<div
v-for="ka in kitAnalysis"
:key="ka.id"
class="kit-card"
:class="{ active: activeKit === ka.id }"
@click="activeKit = ka.id"
>
<div class="kit-name">{{ ka.name }}</div>
<div class="kit-price">¥{{ ka.price }}</div>
<div class="kit-discount">会员价后再{{ (ka.discountRate * 10).toFixed(1) }}</div>
<div class="kit-stats">
<span>{{ ka.oils.length }} 种精油</span>
<span class="kit-recipe-count">可做 {{ ka.recipeCount }} 个配方</span>
</div>
</div>
</div>
<!-- Active Kit Detail -->
<div v-if="activeKitData" class="kit-detail">
<div class="detail-header">
<h4>{{ activeKitData.name }} 可做配方 ({{ activeKitData.recipeCount }})</h4>
</div>
<div v-if="activeKitData.recipes.length === 0" class="empty-hint">该套装暂无完全匹配的配方</div>
<table v-else class="recipe-table">
<thead>
<tr>
<th class="th-name">配方名</th>
<th class="th-times">可做次数</th>
<th class="th-cost">套装成本</th>
<th class="th-cost">单买成本</th>
<th class="th-price">售价</th>
<th class="th-profit">利润率</th>
</tr>
</thead>
<tbody>
<tr v-for="r in activeKitData.recipes" :key="r._id">
<td class="td-name">{{ r.name }} <span v-if="volumeLabel(r)" class="td-volume">{{ volumeLabel(r) }}</span></td>
<td class="td-times">{{ calcMaxTimes(r) }}</td>
<td class="td-cost">{{ fmtPrice(r.kitCost) }}</td>
<td class="td-cost original">{{ fmtPrice(r.originalCost) }}</td>
<td class="td-price">
<div class="price-input-wrap">
<span>¥</span>
<input
type="number"
:value="getSellingPrice(r._id)"
@change="setSellingPrice(r._id, $event.target.value)"
class="selling-input"
/>
</div>
</td>
<td class="td-profit" :class="{ negative: calcMargin(r.kitCost, getSellingPrice(r._id)) < 0 }">
{{ calcMargin(r.kitCost, getSellingPrice(r._id)).toFixed(1) }}%
</td>
</tr>
</tbody>
</table>
</div>
<!-- Cross Comparison -->
<div class="cross-section">
<h4>横向对比 ({{ crossComparison.length }} 个配方)</h4>
<div class="cross-scroll">
<table class="cross-table">
<thead>
<tr>
<th class="th-name">配方名</th>
<th v-for="ka in kitAnalysis" :key="ka.id" class="th-kit">{{ ka.name }}</th>
<th class="th-kit">单买</th>
</tr>
</thead>
<tbody>
<tr v-for="row in crossComparison" :key="row.id">
<td class="td-name">{{ row.name }} <span v-if="volumeLabel(row)" class="td-volume">{{ volumeLabel(row) }}</span></td>
<td v-for="ka in kitAnalysis" :key="ka.id" :class="row.costs[ka.id] != null ? 'td-kit-available' : 'td-kit-na'">
<template v-if="row.costs[ka.id] != null">{{ fmtPrice(row.costs[ka.id]) }}</template>
<template v-else><span class="na"></span></template>
</td>
<td class="td-cost original">{{ fmtPrice(row.originalCost) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui'
import { useKitCost } from '../composables/useKitCost'
const router = useRouter()
const auth = useAuthStore()
const oils = useOilsStore()
const recipeStore = useRecipesStore()
const ui = useUiStore()
const { KITS, kitAnalysis, crossComparison } = useKitCost()
const activeKit = ref(KITS[0].id)
const sellingPrices = ref({})
const activeKitData = computed(() => kitAnalysis.value.find(k => k.id === activeKit.value))
function volumeLabel(recipe) {
const vol = recipe.volume
if (vol) {
if (vol === 'single') return '单次'
if (vol === 'custom') return ''
if (/^\d+$/.test(vol)) return `${vol}ml`
return vol
}
const ings = recipe.ingredients || []
const coco = ings.find(i => i.oil === '椰子油')
if (coco && coco.drops) {
const totalDrops = ings.reduce((s, i) => s + (i.drops || 0), 0)
const ml = totalDrops / 18.6
if (ml <= 2) return '单次'
return `${Math.round(ml)}ml`
}
let totalMl = 0
let hasProduct = false
for (const ing of ings) {
if (!oils.isPortionUnit(ing.oil)) continue
hasProduct = true
totalMl += ing.drops || 0
}
if (hasProduct && totalMl > 0) return `${Math.round(totalMl)}ml`
return ''
}
onMounted(async () => {
if (!auth.isBusiness && !auth.isAdmin) {
router.replace('/projects')
return
}
if (!oils.oilNames.length) await oils.loadOils()
if (!recipeStore.recipes.length) await recipeStore.loadRecipes()
loadSellingPrices()
})
// Persist selling prices to localStorage
const STORAGE_KEY = 'kit-export-selling-prices'
function loadSellingPrices() {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) sellingPrices.value = JSON.parse(stored)
} catch { /* ignore */ }
}
function saveSellingPrices() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(sellingPrices.value))
}
// Calculate how many times a recipe can be made with the kit (limited by the oil that runs out first)
function calcMaxTimes(recipe) {
const ings = (recipe.ingredients || []).filter(i => i.oil && i.oil !== '椰子油' && i.drops > 0)
if (!ings.length) return '—'
let minTimes = Infinity
for (const ing of ings) {
const meta = oils.oilsMeta[ing.oil]
if (!meta || !meta.dropCount) return '—'
const times = Math.floor(meta.dropCount / ing.drops)
if (times < minTimes) minTimes = times
}
return minTimes === Infinity ? '—' : minTimes + '次'
}
function getSellingPrice(recipeId) {
return sellingPrices.value[recipeId] ?? 0
}
function setSellingPrice(recipeId, val) {
sellingPrices.value[recipeId] = Number(val) || 0
saveSellingPrices()
}
function fmtPrice(n) {
return '¥' + (n || 0).toFixed(2)
}
function calcMargin(cost, price) {
if (!price || price <= 0) return 0
return ((price - cost) / price) * 100
}
// Excel export
async function exportExcel(mode) {
const ExcelJS = (await import('exceljs')).default || await import('exceljs')
const wb = new ExcelJS.Workbook()
const headerFill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4A9D7E' } }
const headerFont = { bold: true, color: { argb: 'FFFFFFFF' }, size: 11 }
const priceFont = { color: { argb: 'FF4A9D7E' } }
const naFont = { color: { argb: 'FFBBBBBB' } }
function applyHeaderStyle(row) {
row.eachCell(cell => {
cell.fill = headerFill
cell.font = headerFont
cell.alignment = { horizontal: 'center', vertical: 'middle' }
})
row.height = 24
}
function autoCols(ws, ingredientCol = -1) {
ws.columns.forEach((col, i) => {
if (i === 0) {
// First column (配方名): fit longest content
let max = 8
col.eachCell({ includeEmpty: true }, cell => {
const len = cell.value ? String(cell.value).length * 1.8 : 0
if (len > max) max = len
})
col.width = Math.min(max + 2, 30)
} else if (i === ingredientCol) {
// Ingredient column: wider
col.width = 35
} else {
// All other columns: uniform narrow width
col.width = 10
}
})
}
// Per-kit sheets
for (const ka of kitAnalysis.value) {
const ws = wb.addWorksheet(ka.name)
// Kit info header
ws.mergeCells(mode === 'full' ? 'A1:H1' : 'A1:F1')
const titleCell = ws.getCell('A1')
titleCell.value = `${ka.name} — ¥${ka.price}${ka.oils.length}种精油 — 可做${ka.recipeCount}个配方`
titleCell.font = { bold: true, size: 13 }
titleCell.alignment = { horizontal: 'center' }
if (mode === 'full') {
// Full version: recipe name, tags, ingredients, times, kit cost, original cost, selling price, margin
const headers = ['配方名', '标签', '精油成分', '可做次数', '套装成本', '单买成本', '售价', '利润率']
const headerRow = ws.addRow(headers)
applyHeaderStyle(headerRow)
for (const r of ka.recipes) {
const price = getSellingPrice(r._id)
const margin = calcMargin(r.kitCost, price)
const ingredientStr = r.ingredients.map(i => {
const unit = oils.unitLabel(i.oil)
return `${i.oil} ${i.drops}${unit}`
}).join('、')
const vol = volumeLabel(r)
ws.addRow([
vol ? `${r.name}${vol}` : r.name,
(r.tags || []).join('/'),
ingredientStr,
calcMaxTimes(r),
Number(r.kitCost.toFixed(2)),
Number(r.originalCost.toFixed(2)),
price || '',
price ? `${margin.toFixed(1)}%` : '',
])
}
} else {
// Simple version: recipe name, times, kit cost, original cost, selling price, margin
const headers = ['配方名', '可做次数', '套装成本', '单买成本', '售价', '利润率']
const headerRow = ws.addRow(headers)
applyHeaderStyle(headerRow)
for (const r of ka.recipes) {
const price = getSellingPrice(r._id)
const margin = calcMargin(r.kitCost, price)
const vol = volumeLabel(r)
ws.addRow([
vol ? `${r.name}${vol}` : r.name,
calcMaxTimes(r),
Number(r.kitCost.toFixed(2)),
Number(r.originalCost.toFixed(2)),
price || '',
price ? `${margin.toFixed(1)}%` : '',
])
}
}
autoCols(ws, mode === 'full' ? 2 : -1)
// Style cost columns
ws.eachRow((row, rowNum) => {
if (rowNum <= 2) return
row.eachCell((cell, colNum) => {
cell.alignment = { horizontal: 'center', vertical: 'middle' }
// ingredient column left-aligned
if (mode === 'full' && colNum === 3) {
cell.alignment = { horizontal: 'left', vertical: 'middle', wrapText: true }
}
})
})
}
// Cross comparison sheet
const csWs = wb.addWorksheet('横向对比')
const csHeaders = ['配方名']
if (mode === 'full') csHeaders.push('标签')
for (const ka of kitAnalysis.value) csHeaders.push(ka.name)
csHeaders.push('售价')
if (mode === 'full') csHeaders.push('精油成分')
const csHeaderRow = csWs.addRow(csHeaders)
applyHeaderStyle(csHeaderRow)
for (const row of crossComparison.value) {
const price = getSellingPrice(row.id)
const vol = volumeLabel(row)
const vals = [vol ? `${row.name}${vol}` : row.name]
if (mode === 'full') vals.push((row.tags || []).join('/'))
for (const ka of kitAnalysis.value) {
const cost = row.costs[ka.id]
vals.push(cost != null ? Number(cost.toFixed(2)) : '—')
}
vals.push(price || '')
if (mode === 'full') {
vals.push(row.ingredients.map(i => `${i.oil} ${i.drops}${oils.unitLabel(i.oil)}`).join('、'))
}
const dataRow = csWs.addRow(vals)
// Grey out "—" cells
dataRow.eachCell((cell) => {
if (cell.value === '—') cell.font = naFont
cell.alignment = { horizontal: 'center', vertical: 'middle' }
})
}
// Cross sheet: ingredient is last column in full mode
const csIngCol = mode === 'full' ? csHeaders.length - 1 : -1
autoCols(csWs, csIngCol)
// Download
const buf = await wb.xlsx.writeBuffer()
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
const today = new Date().toISOString().slice(0, 10)
a.href = url
a.download = `套装方案对比_${mode === 'full' ? '完整版' : '简版'}_${today}.xlsx`
a.click()
URL.revokeObjectURL(url)
ui.showToast('导出成功')
}
</script>
<style scoped>
.kit-export-page {
padding: 0 12px 24px;
}
.toolbar-sticky {
position: sticky;
top: 0;
z-index: 20;
background: linear-gradient(135deg, #f0faf5 0%, #e8f0e8 100%);
margin: 0 -12px;
padding: 0 12px;
border-bottom: 1.5px solid #d4e8d4;
}
.toolbar-inner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 0;
}
.toolbar-actions {
margin-left: auto;
display: flex;
gap: 6px;
}
.page-title {
margin: 0;
font-size: 16px;
color: #3e3a44;
}
/* Kit Cards */
.kit-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin: 16px 0;
}
.kit-card {
padding: 14px;
background: #fff;
border: 1.5px solid #e5e4e7;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s;
text-align: center;
}
.kit-card:hover { border-color: #7ec6a4; }
.kit-card.active {
border-color: #4a9d7e;
background: #f0faf5;
box-shadow: 0 2px 8px rgba(74, 157, 126, 0.15);
}
.kit-name { font-weight: 600; font-size: 14px; color: #3e3a44; margin-bottom: 4px; }
.kit-price { font-size: 18px; font-weight: 700; color: #4a9d7e; margin-bottom: 2px; }
.kit-discount { font-size: 11px; color: #e65100; margin-bottom: 6px; }
.kit-stats { font-size: 12px; color: #6b6375; display: flex; flex-direction: column; gap: 2px; }
.kit-recipe-count { color: #4a9d7e; font-weight: 500; }
/* Detail Table */
.kit-detail {
margin-bottom: 20px;
padding: 14px;
background: #f8f7f5;
border-radius: 12px;
border: 1.5px solid #e5e4e7;
}
.detail-header {
margin-bottom: 12px;
}
.detail-header h4 { margin: 0; font-size: 14px; color: #3e3a44; }
.recipe-table, .cross-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.recipe-table th, .cross-table th {
text-align: center; padding: 8px 6px; font-size: 12px; font-weight: 600;
color: #999; border-bottom: 2px solid #e5e4e7; white-space: nowrap;
}
.recipe-table td, .cross-table td {
padding: 8px 6px; border-bottom: 1px solid #f0f0f0; text-align: center;
}
.th-name { text-align: left !important; }
.td-name { text-align: left !important; font-weight: 500; color: #3e3a44; }
.td-tags { font-size: 11px; color: #b0aab5; }
.td-cost { color: #4a9d7e; font-weight: 500; }
.td-cost.original { color: #999; font-weight: 400; }
.td-profit { font-weight: 600; color: #4a9d7e; }
.td-profit.negative { color: #ef5350; }
.td-volume { font-size: 10px; color: #b0aab5; margin-left: 4px; }
.td-times { color: #6b6375; font-size: 12px; }
.td-kit-available { font-weight: 500; color: #4a9d7e; background: #f0faf5; }
.td-kit-na { background: #fafafa; }
.na { color: #ddd; }
.price-input-wrap {
display: inline-flex; align-items: center; gap: 2px; font-size: 13px; color: #3e3a44;
}
.selling-input {
width: 55px; text-align: right; padding: 3px 4px; border: 1px solid #d4cfc7;
border-radius: 6px; font-size: 12px; font-family: inherit; outline: none;
}
.selling-input:focus { border-color: #7ec6a4; }
/* Cross Comparison */
.cross-section {
margin-bottom: 20px;
padding: 14px;
background: #f8f7f5;
border-radius: 12px;
border: 1.5px solid #e5e4e7;
}
.cross-section h4 { margin: 0 0 12px; font-size: 14px; color: #3e3a44; }
.cross-scroll { overflow-x: auto; }
/* Buttons */
.btn-back {
border: none; background: #f0eeeb; padding: 8px 14px; border-radius: 8px;
cursor: pointer; font-family: inherit; font-size: 13px; color: #6b6375;
}
.btn-back:hover { background: #e5e4e7; }
.btn-outline {
background: #fff; color: #6b6375; border: 1.5px solid #d4cfc7; border-radius: 10px;
padding: 9px 20px; font-size: 13px; cursor: pointer; font-family: inherit;
}
.btn-outline:hover { background: #f8f7f5; }
.btn-sm { padding: 6px 14px; font-size: 12px; border-radius: 8px; }
.empty-hint {
text-align: center; color: #b0aab5; font-size: 13px; padding: 24px 0;
}
@media (max-width: 600px) {
.kit-cards { grid-template-columns: repeat(2, 1fr); }
.toolbar-inner { flex-wrap: wrap; }
.toolbar-actions { width: 100%; justify-content: flex-end; }
}
</style>

View File

@@ -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="exportExcel">📥 导出Excel</button> <button v-if="auth.isAdmin" class="toolbar-btn-text" @click="exportPDF">📥 导出PDF</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="exportExcel" title="导出Excel">📄</button> <button v-if="auth.isAdmin" class="toolbar-btn-icon" @click="exportPDF" title="导出PDF">📄</button>
</div> </div>
<!-- Add Oil Form (toggleable) --> <!-- Add Oil Form (toggleable) -->
@@ -110,14 +110,6 @@
<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="粘贴产品信息,例如:&#10;优惠顾客价:¥310&#10;零售价:¥465&#10;规格:100毫升&#10;花样年华焕颜精华水 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">
@@ -189,16 +181,9 @@
<!-- 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" style="position:relative;overflow:hidden"> <div class="oil-card-modal">
<!-- Brand background --> <div class="oil-card-header">
<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> <div class="oil-card-header-content">
<!-- 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>
@@ -211,7 +196,7 @@
</div> </div>
</div> </div>
</div> </div>
<button class="btn-close btn-close-light" @click="closeOilModal" style="z-index:4"></button> <button class="btn-close btn-close-light" @click="closeOilModal"></button>
</div> </div>
<!-- Method badges --> <!-- Method badges -->
<div class="oil-card-methods"> <div class="oil-card-methods">
@@ -242,10 +227,6 @@
<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>
@@ -335,15 +316,13 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group"> <div class="form-group">
<label>{{ editUnit === 'drop' ? '精油名称' : '产品名称' }}</label> <label>精油名称</label>
<input v-model="editOilDisplayName" class="form-input" type="text" :placeholder="editUnit === 'drop' ? '精油名称' : '产品名称'" /> <input v-model="editOilDisplayName" class="form-input" type="text" placeholder="精油名称" />
</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">
@@ -359,21 +338,6 @@
<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" />
@@ -442,22 +406,14 @@ 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 } from '../composables/useOilCards' import { getOilCard, setOilCard } 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)
@@ -477,28 +433,6 @@ 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)
@@ -516,9 +450,6 @@ 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('')
@@ -645,14 +576,8 @@ 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 => {
if (n.toLowerCase().includes(q)) return true const en = getEnglishName(n).toLowerCase()
const card = getOilCard(n) return n.toLowerCase().includes(q) || en.includes(q)
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
}) })
}) })
@@ -670,12 +595,12 @@ function getMeta(name) {
} }
function getEnglishName(name) { function getEnglishName(name) {
// 1. User-edited en_name in DB wins — prevents saves being masked by static cards // 1. Oil card has priority
const meta = oils.oilsMeta[name]
if (meta?.enName) return meta.enName
// 2. Oil card fallback
const card = getOilCard(name) const card = getOilCard(name)
if (card && card.en) return card.en if (card && card.en) return card.en
// 2. Stored en_name in meta
const meta = oils.oilsMeta[name]
if (meta?.enName) return meta.enName
// 3. Static translation map // 3. Static translation map
return oilEn(name) return oilEn(name)
} }
@@ -712,9 +637,12 @@ async function openOilDetail(name) {
activeCardName.value = name activeCardName.value = name
activeCard.value = card activeCard.value = card
selectedOilName.value = null selectedOilName.value = null
loadBrand() // Pre-generate card image for instant save
// 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
@@ -784,11 +712,6 @@ 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 || ''
@@ -814,26 +737,25 @@ async function saveEditOil() {
if (newName && newName !== oldName) { if (newName && newName !== oldName) {
await oils.deleteOil(oldName) await oils.deleteOil(oldName)
} }
const finalDropCount = editUnit.value !== 'drop' ? editProductAmount.value : dropCount await oils.saveOil(
const finalUnit = editUnit.value !== 'drop' ? editProductUnit.value : null newName || oldName,
// Build card payload if any card content provided editBottlePrice.value,
const hasCard = editCardEffects.value.trim() || editCardUsage.value.trim() dropCount,
const cardPayload = hasCard ? { editRetailPrice.value,
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
@@ -889,43 +811,65 @@ async function removeOil(name) {
} }
} }
// Excel Export // PDF Export
async function exportExcel() { function exportPDF() {
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'))
const rows = [] let 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 = meta.enName || getEnglishName(name) const en = getEnglishName(name)
const vol = volumeLabel(meta.dropCount, name) const bp = meta.bottlePrice != null ? '¥' + meta.bottlePrice.toFixed(0) : '--'
const unit = oilPriceUnit(name) const rp = meta.retailPrice != null ? '¥' + meta.retailPrice.toFixed(0) : '--'
const ppdNum = oils.pricePerDrop(name) const vol = volumeLabel(meta.dropCount)
const card = getOilCard(name) const ppd = oils.pricePerDrop(name) ? '¥' + oils.pricePerDrop(name).toFixed(2) : '--'
rows.push({ rows += `<tr>
'精油': name, <td>${name}</td>
'英文名': en, <td>${en}</td>
'会员价': meta.bottlePrice != null ? Number(meta.bottlePrice.toFixed(2)) : '', <td>${bp}</td>
'零售价': meta.retailPrice != null ? Number(meta.retailPrice.toFixed(2)) : '', <td>${rp}</td>
'容量': vol, <td>${vol}</td>
'单价': ppdNum ? `¥${ppdNum.toFixed(2)}/${unit}` : '', <td>${ppd}</td>
'功效': card?.effects || '', </tr>`
'使用方法': card?.usage || '',
'使用方式': card?.method || '',
'注意事项': card?.caution || '',
'状态': meta.isActive === false ? '下架' : '在售',
})
} }
const html = `<!DOCTYPE html>
const ws = XLSX.utils.json_to_sheet(rows) <html>
ws['!cols'] = [{ wch: 16 }, { wch: 28 }, { wch: 10 }, { wch: 10 }, { wch: 12 }, { wch: 16 }, { wch: 40 }, { wch: 40 }, { wch: 20 }, { wch: 24 }, { wch: 8 }] <head>
const wb = XLSX.utils.book_new() <meta charset="utf-8">
XLSX.utils.book_append_sheet(wb, ws, '精油价目表') <title>${title}</title>
XLSX.writeFile(wb, `精油价目表${dateStr}.xlsx`) <style>
ui.showToast('导出成功') body { font-family: 'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif; padding: 20px; font-size: 11px; color: #333; }
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) ────

View File

@@ -1,17 +1,9 @@
<template> <template>
<div class="projects-page"> <div class="projects-page">
<!-- Login prompt -->
<div v-if="!auth.isLoggedIn" class="login-prompt">
<div class="commercial-icon">💼</div>
<p>登录后可使用商业核算功能</p>
<button class="btn-primary" @click="ui.openLogin()">登录 / 注册</button>
</div>
<template v-else>
<!-- Header --> <!-- Header -->
<div class="commercial-header"> <div class="commercial-header">
<div class="commercial-icon">💼</div> <div class="commercial-icon">💼</div>
<div class="commercial-desc">商业用户专属功能包含项目核算成本分析等工具</div> <div class="commercial-desc">商业用户专属功能包含项目核算成本分析等工具</div>
<button class="btn-kit-compare" @click="handleKitExport">📦 套装方案对比</button>
</div> </div>
<!-- Project List --> <!-- Project List -->
@@ -81,7 +73,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 +125,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 }}{{ oils.unitLabel(c.oil) }}</td> <td>{{ c.drops }}</td>
<td>{{ c.bottleDrops }}{{ oils.unitLabel(c.oil) }}</td> <td>{{ c.bottleDrops }}</td>
<td>{{ c.sessions }}</td> <td>{{ c.sessions }}</td>
<td></td> <td></td>
</tr> </tr>
@@ -235,7 +227,6 @@
</div> </div>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>
@@ -305,14 +296,6 @@ function selectDemoProject() {
} }
} }
function handleKitExport() {
if (!auth.isBusiness && !auth.isAdmin) {
showCertPrompt()
return
}
router.push('/kit-export')
}
function handleCreateProject() { function handleCreateProject() {
if (!auth.isBusiness && !auth.isAdmin) { if (!auth.isBusiness && !auth.isAdmin) {
showCertPrompt() showCertPrompt()
@@ -883,25 +866,6 @@ function formatDate(d) {
font-weight: 500; font-weight: 500;
} }
.btn-kit-compare {
display: inline-block;
margin-top: 12px;
background: linear-gradient(135deg, #f0e6d3 0%, #e8d5b8 100%);
color: #7a6540;
border: 1.5px solid #d4c4a0;
border-radius: 10px;
padding: 8px 20px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
font-weight: 500;
transition: all 0.15s;
}
.btn-kit-compare:hover {
background: linear-gradient(135deg, #e8d5b8 0%, #d4c4a0 100%);
box-shadow: 0 2px 6px rgba(122, 101, 64, 0.15);
}
/* Buttons */ /* Buttons */
.btn-primary { .btn-primary {
background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%); background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%);
@@ -959,14 +923,6 @@ function formatDate(d) {
color: #6b6375; color: #6b6375;
} }
.login-prompt {
text-align: center;
padding: 60px 20px;
color: #6b6375;
}
.login-prompt .commercial-icon { font-size: 48px; margin-bottom: 12px; }
.login-prompt p { margin-bottom: 16px; font-size: 15px; }
.empty-hint { .empty-hint {
text-align: center; text-align: center;
color: #b0aab5; color: #b0aab5;

View File

@@ -44,7 +44,7 @@
<!-- Action buttons --> <!-- Action buttons -->
<div class="action-bar"> <div class="action-bar">
<button class="action-chip" @click="oils.loadOils(); showAddOverlay = true">新增</button> <button class="action-chip" @click="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,7 +808,6 @@ 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
} }
@@ -1699,7 +1698,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('导出成功')
} }
@@ -2111,10 +2110,9 @@ watch(() => recipeStore.recipes, () => {
.editor-section { margin-bottom: 16px; } .editor-section { margin-bottom: 16px; }
.editor-label { font-size: 13px; font-weight: 600; color: #3e3a44; margin-bottom: 6px; display: block; } .editor-label { font-size: 13px; font-weight: 600; color: #3e3a44; margin-bottom: 6px; display: block; }
.editor-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 8px; } .editor-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 8px; }
.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: left; padding: 6px 4px; color: #999; font-weight: 500; font-size: 12px; border-bottom: 1px solid #eee; }
.editor-table th:first-child { text-align: left; }
.editor-table td { padding: 6px 4px; border-bottom: 1px solid #f5f5f5; } .editor-table td { padding: 6px 4px; border-bottom: 1px solid #f5f5f5; }
.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 { 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: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; }
@@ -2122,8 +2120,8 @@ watch(() => recipeStore.recipes, () => {
.editor-input:focus { border-color: #7ec6a4; } .editor-input:focus { border-color: #7ec6a4; }
.editor-textarea { width: 100%; padding: 8px 10px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; font-family: inherit; outline: none; resize: vertical; box-sizing: border-box; } .editor-textarea { width: 100%; padding: 8px 10px; border: 1.5px solid #d4cfc7; border-radius: 8px; font-size: 13px; font-family: inherit; outline: none; resize: vertical; box-sizing: border-box; }
.editor-textarea:focus { border-color: #7ec6a4; } .editor-textarea:focus { border-color: #7ec6a4; }
.ing-ppd { color: #b0aab5; font-size: 12px; text-align: center; } .ing-ppd { color: #b0aab5; font-size: 12px; }
.ing-cost { color: #4a9d7e; font-weight: 500; font-size: 13px; text-align: center; } .ing-cost { color: #4a9d7e; font-weight: 500; font-size: 13px; }
.remove-row-btn { border: none; background: none; color: #ccc; cursor: pointer; font-size: 16px; padding: 2px 4px; } .remove-row-btn { border: none; background: none; color: #ccc; cursor: pointer; font-size: 16px; padding: 2px 4px; }
.remove-row-btn:hover { color: #c0392b; } .remove-row-btn:hover { color: #c0392b; }
.add-row-btn { border: 1.5px dashed #d4cfc7; background: none; color: #999; padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; width: 100%; } .add-row-btn { border: 1.5px dashed #d4cfc7; background: none; color: #999; padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 13px; font-family: inherit; width: 100%; }

View File

@@ -312,7 +312,7 @@ function expandQuery(q) {
return terms return terms
} }
// Search results: matches in recipe name, tags, oil names (zh + en) // Search results: exact matches (query in recipe name or tags, NOT oil names to avoid noise like 西班牙牛至)
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,10 +322,9 @@ 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 || oilZhMatch || tagMatch return nameMatch || enNameMatch || oilEnMatch || tagMatch
}).sort((a, b) => a.name.localeCompare(b.name, 'zh')) }).sort((a, b) => a.name.localeCompare(b.name, 'zh'))
}) })