diff --git a/backend/database.py b/backend/database.py index 241c163..e355710 100644 --- a/backend/database.py +++ b/backend/database.py @@ -251,6 +251,71 @@ def init_db(): if "volume" not in cols: 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 count = c.execute("SELECT COUNT(*) FROM users").fetchone()[0] if count == 0: diff --git a/backend/defaults.json b/backend/defaults.json index a8227f6..1f96d80 100644 --- a/backend/defaults.json +++ b/backend/defaults.json @@ -332,7 +332,7 @@ "bottlePrice": 450, "dropCount": 280 }, - "元气": { + "元气焕能": { "bottlePrice": 230, "dropCount": 280 }, @@ -1615,7 +1615,7 @@ "drops": 20 }, { - "oil": "元气", + "oil": "元气焕能", "drops": 20 }, { @@ -2072,7 +2072,7 @@ "drops": 5 }, { - "oil": "元气", + "oil": "元气焕能", "drops": 5 }, { @@ -2216,7 +2216,7 @@ "drops": 5 }, { - "oil": "元气", + "oil": "元气焕能", "drops": 5 } ] @@ -2328,7 +2328,7 @@ "drops": 5 }, { - "oil": "元气", + "oil": "元气焕能", "drops": 8 }, { @@ -2491,7 +2491,7 @@ "drops": 10 }, { - "oil": "元气", + "oil": "元气焕能", "drops": 10 } ] @@ -2666,7 +2666,7 @@ "drops": 5 }, { - "oil": "元气", + "oil": "元气焕能", "drops": 5 }, { @@ -2816,7 +2816,7 @@ "tags": [], "ingredients": [ { - "oil": "元气", + "oil": "元气焕能", "drops": 15 }, { @@ -2901,7 +2901,7 @@ "tags": [], "ingredients": [ { - "oil": "元气", + "oil": "元气焕能", "drops": 5 }, { @@ -3111,7 +3111,7 @@ "drops": 10 }, { - "oil": "元气", + "oil": "元气焕能", "drops": 5 }, { @@ -6583,7 +6583,7 @@ "drops": 4 }, { - "oil": "元气", + "oil": "元气焕能", "drops": 4 }, { @@ -6662,7 +6662,7 @@ "drops": 4 }, { - "oil": "元气", + "oil": "元气焕能", "drops": 4 }, { @@ -6729,7 +6729,7 @@ "drops": 4 }, { - "oil": "元气", + "oil": "元气焕能", "drops": 4 }, { @@ -7819,7 +7819,7 @@ "drops": 2 }, { - "oil": "元气", + "oil": "元气焕能", "drops": 3 }, { diff --git a/backend/main.py b/backend/main.py index b521877..f70e706 100644 --- a/backend/main.py +++ b/backend/main.py @@ -682,7 +682,7 @@ def revoke_business(user_id: int, body: dict = None, user=Depends(require_role(" conn = get_db() conn.execute("UPDATE users SET business_verified = 0 WHERE id = ?", (user_id,)) reason = (body or {}).get("reason", "").strip() - target = conn.execute("SELECT role FROM users WHERE id = ?", (user_id,)).fetchone() + target = conn.execute("SELECT role, display_name, username FROM users WHERE id = ?", (user_id,)).fetchone() if target: msg = "你的商业用户资格已被取消。" if reason: diff --git a/frontend/cypress/e2e/kit-export.cy.js b/frontend/cypress/e2e/kit-export.cy.js new file mode 100644 index 0000000..6b3e6fc --- /dev/null +++ b/frontend/cypress/e2e/kit-export.cy.js @@ -0,0 +1,137 @@ +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') + }) + }) +}) diff --git a/frontend/src/__tests__/kitCost.test.js b/frontend/src/__tests__/kitCost.test.js new file mode 100644 index 0000000..f04acfe --- /dev/null +++ b/frontend/src/__tests__/kitCost.test.js @@ -0,0 +1,330 @@ +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) + } + } + }) +}) diff --git a/frontend/src/components/RecipeDetailOverlay.vue b/frontend/src/components/RecipeDetailOverlay.vue index b20c769..2532a54 100644 --- a/frontend/src/components/RecipeDetailOverlay.vue +++ b/frontend/src/components/RecipeDetailOverlay.vue @@ -1010,6 +1010,7 @@ async function saveRecipe() { note: editNote.value.trim(), tags: editTags.value, ingredients: allIngs, + volume: selectedVolume.value || '', } await recipesStore.saveRecipe(payload) // Reload recipes so the data is fresh when re-opened diff --git a/frontend/src/composables/useKitCost.js b/frontend/src/composables/useKitCost.js new file mode 100644 index 0000000..efbdd12 --- /dev/null +++ b/frontend/src/composables/useKitCost.js @@ -0,0 +1,173 @@ +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, + } +} diff --git a/frontend/src/config/kits.js b/frontend/src/config/kits.js new file mode 100644 index 0000000..6fd73ee --- /dev/null +++ b/frontend/src/config/kits.js @@ -0,0 +1,64 @@ +// 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 }, + }, +] diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 68a69ff..01e2279 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -29,6 +29,12 @@ const routes = [ component: () => import('../views/Projects.vue'), meta: { requiresAuth: true }, }, + { + path: '/kit-export', + name: 'KitExport', + component: () => import('../views/KitExport.vue'), + meta: { requiresAuth: true }, + }, { path: '/mydiary', name: 'MyDiary', diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue index 2d71d29..7eaa0f8 100644 --- a/frontend/src/views/Inventory.vue +++ b/frontend/src/views/Inventory.vue @@ -101,6 +101,7 @@ import { useOilsStore } from '../stores/oils' import { useRecipesStore } from '../stores/recipes' import { useUiStore } from '../stores/ui' import { api } from '../composables/useApi' +import { KITS as KIT_LIST } from '../config/kits' const auth = useAuthStore() const oils = useOilsStore() @@ -120,30 +121,17 @@ const searchResults = computed(() => { return oils.oilNames.filter(n => n.toLowerCase().includes(q)).slice(0, 15) }) -// Kit definitions -const KITS = { - family: ['乳香', '茶树', '薰衣草', '柠檬', '椒样薄荷', '保卫', '牛至', '乐活', '顺畅呼吸', '舒缓'], - home3988: ['乳香', '野橘', '柠檬', '薰衣草', '椒样薄荷', '冬青', '茶树', '生姜', '柠檬草', '西班牙牛至', - '西洋蓍草石榴籽', '乐活', '保卫', '新瑞活力', '舒缓', '安定情绪', '安宁神气', '顺畅呼吸', '柑橘清新', '芳香调理', '元气焕能'], - aroma: ['薰衣草', '舒缓', '安定情绪', '芳香调理', '野橘', '椒样薄荷', '保卫', '茶树'], - full: ['侧柏', '乳香', '雪松', '芫荽', '丝柏', '圆柚', '红橘', '冬青', '没药', '扁柏', '檀香', '姜黄', '玫瑰', - '绿薄荷', '薰衣草', '永久花', '香蜂草', '迷迭香', '麦卢卡', '天竺葵', '蓝艾菊', '小茴香', - '古巴香脂', '依兰依兰', '丁香花蕾', '柠檬尤加利', '藿香', '西班牙牛至尾草', - '罗勒', '莱姆', '生姜', '柠檬', '茶树', '野橘', '香茅', '枫香', - '芹菜籽', '岩兰草', '苦橙叶', '柠檬草', '山鸡椒', '黑云杉', - '马郁兰', '佛手柑', '黑胡椒', '小豆蔻', '尤加利', '百里香', - '椒样薄荷', '杜松浆果', '加州白鼠尾草', - '快乐鼠尾草', '西伯利亚冷杉', - '西班牙牛至', '斯里兰卡肉桂'] -} +// Kit definitions from shared config +const KITS = Object.fromEntries(KIT_LIST.map(k => [k.id, k.oils])) function addKit(kitName) { const kit = KITS[kitName] if (!kit) return let added = 0 for (const name of kit) { - // Match existing oil names (fuzzy) - const match = oils.oilNames.find(n => n === name) || oils.oilNames.find(n => n.includes(name) || name.includes(n)) + // Match existing oil names: exact first, then oil name ending with kit name (西班牙牛至 matches 牛至, but 牛至呵护 does not) + const match = oils.oilNames.find(n => n === name) + || oils.oilNames.find(n => n.endsWith(name) && n !== name) if (match && !ownedOils.value.includes(match)) { ownedOils.value.push(match) added++ diff --git a/frontend/src/views/KitExport.vue b/frontend/src/views/KitExport.vue new file mode 100644 index 0000000..d069c1b --- /dev/null +++ b/frontend/src/views/KitExport.vue @@ -0,0 +1,504 @@ + + + + + diff --git a/frontend/src/views/Projects.vue b/frontend/src/views/Projects.vue index fbef44e..1c11546 100644 --- a/frontend/src/views/Projects.vue +++ b/frontend/src/views/Projects.vue @@ -1,9 +1,17 @@ @@ -296,6 +305,14 @@ function selectDemoProject() { } } +function handleKitExport() { + if (!auth.isBusiness && !auth.isAdmin) { + showCertPrompt() + return + } + router.push('/kit-export') +} + function handleCreateProject() { if (!auth.isBusiness && !auth.isAdmin) { showCertPrompt() @@ -866,6 +883,25 @@ function formatDate(d) { 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 */ .btn-primary { background: linear-gradient(135deg, #7ec6a4 0%, #4a9d7e 100%); @@ -923,6 +959,14 @@ function formatDate(d) { 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 { text-align: center; color: #b0aab5;