diff --git a/frontend/src/composables/useKitCost.js b/frontend/src/composables/useKitCost.js new file mode 100644 index 0000000..cab8061 --- /dev/null +++ b/frontend/src/composables/useKitCost.js @@ -0,0 +1,136 @@ +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)) + // Sum of bottle prices for all oils in kit + let totalBottlePrice = 0 + const oilBottlePrices = {} + for (const name of resolved) { + const meta = oils.oilsMeta[name] + const bp = meta ? meta.bottlePrice : 0 + oilBottlePrices[name] = bp + totalBottlePrice += bp + } + if (totalBottlePrice === 0) return {} + + // Proportional allocation + const perDrop = {} + for (const name of resolved) { + const meta = oils.oilsMeta[name] + const bp = oilBottlePrices[name] + const kitCostForOil = (bp / totalBottlePrice) * kit.price + const drops = meta ? meta.dropCount : 1 + perDrop[name] = drops > 0 ? kitCostForOil / drops : 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 + const kitAnalysis = computed(() => { + return KITS.map(kit => { + const perDrop = calcKitPerDrop(kit) + const recipes = getKitRecipes(kit) + return { + ...kit, + perDrop, + recipes, + recipeCount: recipes.length, + } + }) + }) + + // Cross-kit comparison: all recipes that at least one kit can make + const crossComparison = computed(() => { + const analysis = kitAnalysis.value + 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) { + // Find recipe info from any kit that has it + let recipe = null + const costs = {} + for (const ka of analysis) { + const found = ka.recipes.find(r => r._id === id) + if (found) { + if (!recipe) recipe = found + costs[ka.id] = found.kitCost + } else { + costs[ka.id] = null // kit can't make this recipe + } + } + rows.push({ + id, + name: recipe.name, + tags: recipe.tags, + ingredients: recipe.ingredients, + originalCost: recipe.originalCost, + costs, // { aroma: 12.3, family: null, home3988: 10.8, full: 9.5 } + }) + } + rows.sort((a, b) => 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..cc55392 --- /dev/null +++ b/frontend/src/config/kits.js @@ -0,0 +1,50 @@ +// doTERRA 套装配置 +// 价格和内容更新频率低,手动维护即可 +export const KITS = [ + { + id: 'aroma', + name: '芳香调理套装', + price: 2950, + oils: [ + '茶树', '野橘', '椒样薄荷', '薰衣草', + '芳香调理', '安定情绪', '保卫', '舒缓', + '椰子油', + ], + }, + { + id: 'family', + name: '家庭医生套装', + price: 2250, + oils: [ + '乳香', '茶树', '薰衣草', '柠檬', '椒样薄荷', '西班牙牛至', + '乐活', '舒缓', '保卫', '顺畅呼吸', + '椰子油', + ], + }, + { + id: 'home3988', + name: '居家呵护套装', + price: 3988, + oils: [ + '乳香', '野橘', '柠檬', '薰衣草', '椒样薄荷', '冬青', '茶树', '生姜', '柠檬草', '西班牙牛至', + '西洋蓍草石榴籽', '乐活', '保卫', '新瑞活力', '舒缓', '安定情绪', '安宁神气', '顺畅呼吸', '柑橘清新', '芳香调理', '元气焕能', + '椰子油', + ], + }, + { + id: 'full', + name: '全精油套装', + price: 17700, + oils: [ + '侧柏', '乳香', '雪松', '芫荽', '芫荽叶', '丝柏', '圆柚', '红橘', '冬青', '没药', '扁柏', '檀香', '姜黄', '玫瑰', + '绿薄荷', '薰衣草', '永久花', '香蜂草', '迷迭香', '麦卢卡', '天竺葵', '蓝艾菊', '小茴香', + '古巴香脂', '依兰依兰', '丁香花蕾', '柠檬尤加利', '广藿香', + '罗勒', '莱姆', '生姜', '柠檬', '茶树', '野橘', '香茅', '枫香', + '芹菜籽', '岩兰草', '苦橙叶', '柠檬草', '山鸡椒', '黑云杉', + '马郁兰', '佛手柑', '黑胡椒', '小豆蔻', '尤加利', '百里香', + '椒样薄荷', '杜松浆果', '加州胡椒', '罗马洋甘菊', '道格拉斯冷杉', '西班牙鼠尾草', + '快乐鼠尾草', '西伯利亚冷杉', + '西班牙牛至', '斯里兰卡肉桂皮', '椰子油', + ], + }, +] 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..aaa7702 --- /dev/null +++ b/frontend/src/views/KitExport.vue @@ -0,0 +1,446 @@ + + + + + diff --git a/frontend/src/views/Projects.vue b/frontend/src/views/Projects.vue index fbef44e..07c9871 100644 --- a/frontend/src/views/Projects.vue +++ b/frontend/src/views/Projects.vue @@ -10,6 +10,7 @@

📊 服务项目成本利润分析

+