Some checks failed
PR Preview / teardown-preview (pull_request) Has been skipped
Test / unit-test (push) Successful in 6s
Test / build-check (push) Successful in 4s
PR Preview / test (pull_request) Successful in 6s
PR Preview / deploy-preview (pull_request) Successful in 15s
Test / e2e-test (push) Failing after 7m5s
saveRecipe payload 漏传 selectedVolume 导致编辑器选择的容量从未写入数据库; 套装方案对比页改用与 RecipeCard 一致的 volumeLabel 计算逻辑。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
489 lines
16 KiB
Vue
489 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-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, 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-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>
|