Files
oil-formula-calculator/frontend/src/views/KitExport.vue
Hera Zhao a002ba7ef6
All checks were successful
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 5s
Test / e2e-test (push) Successful in 2m56s
feat: 套装方案对比导出时配方名后附容量
形如「某配方(100ml)」。full/simple/横向对比三个 sheet 都带上。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 22:48:09 +00:00

508 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>