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 @@
+
+
+
+
+
+
+
+
{{ ka.name }}
+
¥{{ ka.price }}
+
+ {{ ka.oils.length }} 种精油
+ 可做 {{ ka.recipeCount }} 个配方
+
+
+
+
+
+
+
+
+
+
横向对比 ({{ crossComparison.length }} 个配方)
+
+
+
+
+
+
+
+
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 @@