feat: 套装方案对比与导出功能
All checks were successful
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Successful in 2m58s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 14s
All checks were successful
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / teardown-preview (pull_request) Has been skipped
Test / e2e-test (push) Successful in 2m58s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 14s
- 新增套装方案对比页面(KitExport.vue),支持4个套装(芳香调理/家庭医生/居家呵护/全精油)的配方匹配和成本对比 - 按各精油原瓶价占比分摊套装总价,计算每滴套装成本 - 支持设置配方售价,自动计算利润率 - Excel导出完整版(含成分明细)和简版,含横向对比sheet - 抽取套装配置到共享config/kits.js,Inventory页复用 - 修复库存页模糊匹配bug(牛至错误匹配到牛至呵护) - 修正全精油套装列表(补芫荽叶/加州胡椒/罗马洋甘菊/道格拉斯冷杉/西班牙鼠尾草,修正广藿香/斯里兰卡肉桂皮名称) - 所有套装加入椰子油 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
136
frontend/src/composables/useKitCost.js
Normal file
136
frontend/src/composables/useKitCost.js
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
50
frontend/src/config/kits.js
Normal file
50
frontend/src/config/kits.js
Normal file
@@ -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: [
|
||||
'侧柏', '乳香', '雪松', '芫荽', '芫荽叶', '丝柏', '圆柚', '红橘', '冬青', '没药', '扁柏', '檀香', '姜黄', '玫瑰',
|
||||
'绿薄荷', '薰衣草', '永久花', '香蜂草', '迷迭香', '麦卢卡', '天竺葵', '蓝艾菊', '小茴香',
|
||||
'古巴香脂', '依兰依兰', '丁香花蕾', '柠檬尤加利', '广藿香',
|
||||
'罗勒', '莱姆', '生姜', '柠檬', '茶树', '野橘', '香茅', '枫香',
|
||||
'芹菜籽', '岩兰草', '苦橙叶', '柠檬草', '山鸡椒', '黑云杉',
|
||||
'马郁兰', '佛手柑', '黑胡椒', '小豆蔻', '尤加利', '百里香',
|
||||
'椒样薄荷', '杜松浆果', '加州胡椒', '罗马洋甘菊', '道格拉斯冷杉', '西班牙鼠尾草',
|
||||
'快乐鼠尾草', '西伯利亚冷杉',
|
||||
'西班牙牛至', '斯里兰卡肉桂皮', '椰子油',
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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++
|
||||
|
||||
446
frontend/src/views/KitExport.vue
Normal file
446
frontend/src/views/KitExport.vue
Normal file
@@ -0,0 +1,446 @@
|
||||
<template>
|
||||
<div class="kit-export-page">
|
||||
<div class="toolbar-sticky">
|
||||
<div class="toolbar-inner">
|
||||
<button class="btn-back" @click="$router.push('/projects')">← 返回</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-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-tags">标签</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 }}</td>
|
||||
<td class="td-tags">{{ (r.tags || []).join('/') }}</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-price">售价</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in crossComparison" :key="row.id">
|
||||
<td class="td-name">{{ row.name }}</td>
|
||||
<td v-for="ka in kitAnalysis" :key="ka.id" class="td-kit-cost">
|
||||
<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-price">
|
||||
<div class="price-input-wrap">
|
||||
<span>¥</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="getSellingPrice(row.id)"
|
||||
@change="setSellingPrice(row.id, $event.target.value)"
|
||||
class="selling-input"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useOilsStore } from '../stores/oils'
|
||||
import { useRecipesStore } from '../stores/recipes'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
import { useKitCost } from '../composables/useKitCost'
|
||||
|
||||
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))
|
||||
|
||||
onMounted(async () => {
|
||||
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))
|
||||
}
|
||||
|
||||
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, minWidth = 10) {
|
||||
ws.columns.forEach(col => {
|
||||
let max = minWidth
|
||||
col.eachCell({ includeEmpty: true }, cell => {
|
||||
const len = cell.value ? String(cell.value).length * 1.5 : 0
|
||||
if (len > max) max = len
|
||||
})
|
||||
col.width = Math.min(max + 2, 40)
|
||||
})
|
||||
}
|
||||
|
||||
// Per-kit sheets
|
||||
for (const ka of kitAnalysis.value) {
|
||||
const ws = wb.addWorksheet(ka.name)
|
||||
|
||||
// Kit info header
|
||||
ws.mergeCells('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 detail, 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('、')
|
||||
ws.addRow([
|
||||
r.name,
|
||||
(r.tags || []).join('/'),
|
||||
ingredientStr,
|
||||
Number(r.kitCost.toFixed(2)),
|
||||
Number(r.originalCost.toFixed(2)),
|
||||
price || '',
|
||||
price ? `${margin.toFixed(1)}%` : '',
|
||||
])
|
||||
}
|
||||
} else {
|
||||
// Simple version: recipe name, kit 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)
|
||||
ws.addRow([
|
||||
r.name,
|
||||
Number(r.kitCost.toFixed(2)),
|
||||
price || '',
|
||||
price ? `${margin.toFixed(1)}%` : '',
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
autoCols(ws)
|
||||
|
||||
// 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 vals = [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' }
|
||||
})
|
||||
}
|
||||
|
||||
autoCols(csWs)
|
||||
|
||||
// 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: 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-kit-cost { font-weight: 500; }
|
||||
.na { color: #ccc; }
|
||||
|
||||
.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>
|
||||
@@ -10,6 +10,7 @@
|
||||
<div class="toolbar-sticky">
|
||||
<div class="toolbar-inner">
|
||||
<h3 class="page-title">📊 服务项目成本利润分析</h3>
|
||||
<button class="btn-outline btn-sm" @click="$router.push('/kit-export')">套装方案对比</button>
|
||||
<button class="btn-primary btn-sm" @click="handleCreateProject">+ 新增项目</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user