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

- 新增套装方案对比页面(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:
2026-04-13 22:22:22 +00:00
parent d3e824be5b
commit cbf7294688
6 changed files with 645 additions and 18 deletions

View 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,
}
}

View 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: [
'侧柏', '乳香', '雪松', '芫荽', '芫荽叶', '丝柏', '圆柚', '红橘', '冬青', '没药', '扁柏', '檀香', '姜黄', '玫瑰',
'绿薄荷', '薰衣草', '永久花', '香蜂草', '迷迭香', '麦卢卡', '天竺葵', '蓝艾菊', '小茴香',
'古巴香脂', '依兰依兰', '丁香花蕾', '柠檬尤加利', '广藿香',
'罗勒', '莱姆', '生姜', '柠檬', '茶树', '野橘', '香茅', '枫香',
'芹菜籽', '岩兰草', '苦橙叶', '柠檬草', '山鸡椒', '黑云杉',
'马郁兰', '佛手柑', '黑胡椒', '小豆蔻', '尤加利', '百里香',
'椒样薄荷', '杜松浆果', '加州胡椒', '罗马洋甘菊', '道格拉斯冷杉', '西班牙鼠尾草',
'快乐鼠尾草', '西伯利亚冷杉',
'西班牙牛至', '斯里兰卡肉桂皮', '椰子油',
],
},
]

View File

@@ -29,6 +29,12 @@ const routes = [
component: () => import('../views/Projects.vue'), component: () => import('../views/Projects.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/kit-export',
name: 'KitExport',
component: () => import('../views/KitExport.vue'),
meta: { requiresAuth: true },
},
{ {
path: '/mydiary', path: '/mydiary',
name: 'MyDiary', name: 'MyDiary',

View File

@@ -101,6 +101,7 @@ import { useOilsStore } from '../stores/oils'
import { useRecipesStore } from '../stores/recipes' import { useRecipesStore } from '../stores/recipes'
import { useUiStore } from '../stores/ui' import { useUiStore } from '../stores/ui'
import { api } from '../composables/useApi' import { api } from '../composables/useApi'
import { KITS as KIT_LIST } from '../config/kits'
const auth = useAuthStore() const auth = useAuthStore()
const oils = useOilsStore() const oils = useOilsStore()
@@ -120,30 +121,17 @@ const searchResults = computed(() => {
return oils.oilNames.filter(n => n.toLowerCase().includes(q)).slice(0, 15) return oils.oilNames.filter(n => n.toLowerCase().includes(q)).slice(0, 15)
}) })
// Kit definitions // Kit definitions from shared config
const KITS = { const KITS = Object.fromEntries(KIT_LIST.map(k => [k.id, k.oils]))
family: ['乳香', '茶树', '薰衣草', '柠檬', '椒样薄荷', '保卫', '牛至', '乐活', '顺畅呼吸', '舒缓'],
home3988: ['乳香', '野橘', '柠檬', '薰衣草', '椒样薄荷', '冬青', '茶树', '生姜', '柠檬草', '西班牙牛至',
'西洋蓍草石榴籽', '乐活', '保卫', '新瑞活力', '舒缓', '安定情绪', '安宁神气', '顺畅呼吸', '柑橘清新', '芳香调理', '元气焕能'],
aroma: ['薰衣草', '舒缓', '安定情绪', '芳香调理', '野橘', '椒样薄荷', '保卫', '茶树'],
full: ['侧柏', '乳香', '雪松', '芫荽', '丝柏', '圆柚', '红橘', '冬青', '没药', '扁柏', '檀香', '姜黄', '玫瑰',
'绿薄荷', '薰衣草', '永久花', '香蜂草', '迷迭香', '麦卢卡', '天竺葵', '蓝艾菊', '小茴香',
'古巴香脂', '依兰依兰', '丁香花蕾', '柠檬尤加利', '藿香', '西班牙牛至尾草',
'罗勒', '莱姆', '生姜', '柠檬', '茶树', '野橘', '香茅', '枫香',
'芹菜籽', '岩兰草', '苦橙叶', '柠檬草', '山鸡椒', '黑云杉',
'马郁兰', '佛手柑', '黑胡椒', '小豆蔻', '尤加利', '百里香',
'椒样薄荷', '杜松浆果', '加州白鼠尾草',
'快乐鼠尾草', '西伯利亚冷杉',
'西班牙牛至', '斯里兰卡肉桂']
}
function addKit(kitName) { function addKit(kitName) {
const kit = KITS[kitName] const kit = KITS[kitName]
if (!kit) return if (!kit) return
let added = 0 let added = 0
for (const name of kit) { for (const name of kit) {
// Match existing oil names (fuzzy) // 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.includes(name) || name.includes(n)) const match = oils.oilNames.find(n => n === name)
|| oils.oilNames.find(n => n.endsWith(name) && n !== name)
if (match && !ownedOils.value.includes(match)) { if (match && !ownedOils.value.includes(match)) {
ownedOils.value.push(match) ownedOils.value.push(match)
added++ added++

View 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')">&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-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>

View File

@@ -10,6 +10,7 @@
<div class="toolbar-sticky"> <div class="toolbar-sticky">
<div class="toolbar-inner"> <div class="toolbar-inner">
<h3 class="page-title">📊 服务项目成本利润分析</h3> <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> <button class="btn-primary btn-sm" @click="handleCreateProject">+ 新增项目</button>
</div> </div>
</div> </div>