形如「某配方(100ml)」。full/simple/横向对比三个 sheet 都带上。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
508 lines
16 KiB
Vue
508 lines
16 KiB
Vue
<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-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>
|