feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div :ref="el => containerRef = el" style="width:100%;height:100%;min-height:80px;"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
actual: number
|
||||
target: number
|
||||
label?: string
|
||||
unit?: string
|
||||
}>(), { label: '', unit: '' })
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
function render() {
|
||||
if (!containerRef.value) return
|
||||
if (!chart) chart = echarts.init(containerRef.value)
|
||||
|
||||
const maxVal = Math.max(props.actual, props.target, 1) * 1.3
|
||||
const pct = props.target > 0 ? (props.actual / props.target * 100) : 0
|
||||
|
||||
chart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: () =>
|
||||
`${props.label}<br/>实际: <b>${props.actual?.toLocaleString()}${props.unit}</b><br/>目标: ${props.target?.toLocaleString()}${props.unit}<br/>完成率: <b>${pct.toFixed(1)}%</b>`,
|
||||
},
|
||||
grid: { left: 50, right: 50, top: 10, bottom: 5 },
|
||||
xAxis: { type: 'category', data: [props.label], axisLabel: { show: false }, splitLine: { show: false } },
|
||||
yAxis: { type: 'value', max: maxVal, splitLine: { show: false }, axisLabel: { fontSize: 10, color: '#bbb' } },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: [props.actual],
|
||||
barWidth: 16,
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: pct >= 100 ? '#52c41a' : pct >= 80 ? '#faad14' : '#ff4d4f' },
|
||||
{ offset: 1, color: pct >= 100 ? '#73d13d' : pct >= 80 ? '#ffc53d' : '#ff7875' },
|
||||
]),
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: `${props.actual?.toLocaleString()}${props.unit}`,
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: pct >= 100 ? '#52c41a' : '#ff4d4f',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'bar',
|
||||
data: [props.target],
|
||||
barWidth: 16,
|
||||
barGap: '-100%',
|
||||
itemStyle: { color: 'rgba(0,0,0,0.06)', borderRadius: [4, 4, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'bottom',
|
||||
formatter: `目标: ${props.target?.toLocaleString()}${props.unit}`,
|
||||
fontSize: 10,
|
||||
color: '#999',
|
||||
},
|
||||
},
|
||||
],
|
||||
}, true)
|
||||
}
|
||||
|
||||
watch(() => [props.actual, props.target], render)
|
||||
onMounted(render)
|
||||
onUnmounted(() => { chart?.dispose(); chart = null })
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div :ref="el => containerRef = el" style="width:100%;height:100%;min-height:150px;"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
actual: number
|
||||
target: number
|
||||
label?: string
|
||||
unit?: string
|
||||
}>(), { label: '', unit: '' })
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
function render() {
|
||||
if (!containerRef.value) return
|
||||
if (!chart) chart = echarts.init(containerRef.value)
|
||||
|
||||
const pct = props.target > 0 ? Math.min(props.actual / props.target * 100, 100) : 0
|
||||
const isGood = props.actual >= props.target
|
||||
|
||||
chart.setOption({
|
||||
series: [{
|
||||
type: 'gauge',
|
||||
startAngle: 220,
|
||||
endAngle: -40,
|
||||
min: 0,
|
||||
max: props.target * 1.3 || 1,
|
||||
progress: { show: true, width: 18, itemStyle: { color: isGood ? '#52c41a' : '#ff4d4f' } },
|
||||
axisLine: { lineStyle: { width: 18, color: [[0.3, '#ff4d4f'], [0.7, '#faad14'], [1, '#52c41a']] } },
|
||||
axisTick: { show: false },
|
||||
splitLine: { show: false },
|
||||
axisLabel: { show: false },
|
||||
pointer: { show: false },
|
||||
anchor: { show: false },
|
||||
title: { show: true, offsetCenter: [0, '30%'], fontSize: 12, color: '#999' },
|
||||
detail: {
|
||||
offsetCenter: [0, '-10%'],
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: isGood ? '#52c41a' : '#ff4d4f',
|
||||
formatter: (v: number) => `${props.actual?.toLocaleString()}${props.unit}`,
|
||||
},
|
||||
data: [{ value: props.actual, name: props.label }],
|
||||
}],
|
||||
grid: { top: 5, bottom: 5, left: 5, right: 5 },
|
||||
}, true)
|
||||
}
|
||||
|
||||
watch(() => [props.actual, props.target], render)
|
||||
onMounted(render)
|
||||
onUnmounted(() => { chart?.dispose(); chart = null })
|
||||
</script>
|
||||
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="profit-breakdown">
|
||||
<!-- 第1行:营收 -->
|
||||
<div class="pb-row pb-row-top">
|
||||
<div class="pb-card pb-card-revenue">
|
||||
<div class="pb-label">营收</div>
|
||||
<div class="pb-value">{{ fmt(revenue) }}</div>
|
||||
</div>
|
||||
<div class="pb-arrow">−</div>
|
||||
<div class="pb-card pb-card-expense">
|
||||
<div class="pb-label">成本费用</div>
|
||||
<div class="pb-value pb-neg">{{ fmt(cost) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 等号 + 结果 -->
|
||||
<div class="pb-eq-row">
|
||||
<div class="pb-eq-line"></div>
|
||||
<div class="pb-eq-sign">=</div>
|
||||
<div class="pb-eq-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- 第2行:毛利额 -->
|
||||
<div class="pb-row">
|
||||
<div class="pb-card pb-card-gross">
|
||||
<div class="pb-label">毛利润</div>
|
||||
<div class="pb-value" :class="gross >= 0 ? 'pb-pos' : 'pb-neg'">{{ fmt(gross) }}</div>
|
||||
<div class="pb-sub">毛利率 {{ grossRate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 费用拆解 -->
|
||||
<div v-if="hasDetail" class="pb-detail">
|
||||
<div class="pb-detail-title">费用构成</div>
|
||||
<div class="pb-detail-grid">
|
||||
<div v-for="item in detailItems" :key="item.key" class="pb-detail-item">
|
||||
<span class="pb-dot" :style="{ background: item.color }"></span>
|
||||
<span class="pb-detail-label">{{ item.label }}</span>
|
||||
<span class="pb-detail-val" :class="item.val >= 0 ? 'pb-pos' : 'pb-neg'">{{ fmt(item.val) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分割线 -->
|
||||
<div class="pb-eq-row">
|
||||
<div class="pb-eq-line"></div>
|
||||
<div class="pb-eq-sign">=</div>
|
||||
<div class="pb-eq-line"></div>
|
||||
</div>
|
||||
|
||||
<!-- 最终结果:净利润 -->
|
||||
<div class="pb-row">
|
||||
<div class="pb-card pb-card-net" :class="profit >= 0 ? 'pb-card-pos' : 'pb-card-neg'">
|
||||
<div class="pb-label">净利润</div>
|
||||
<div class="pb-value pb-big" :class="profit >= 0 ? 'pb-pos' : 'pb-neg'">{{ fmt(profit) }}</div>
|
||||
<div class="pb-sub">净利率 {{ netRate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 同期对比 -->
|
||||
<div v-if="prevRevenue" class="pb-compare">
|
||||
<div class="pb-compare-item">
|
||||
<span class="pb-compare-label">营收较上期</span>
|
||||
<span :class="revChg >= 0 ? 'pb-pos' : 'pb-neg'">
|
||||
{{ revChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(revChg) * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="pb-compare-item">
|
||||
<span class="pb-compare-label">净利润较上期</span>
|
||||
<span :class="profitChg >= 0 ? 'pb-pos' : 'pb-neg'">
|
||||
{{ profitChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(profitChg) * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="pb-compare-item">
|
||||
<span class="pb-compare-label">净利率较上期</span>
|
||||
<span :class="rateChg >= 0 ? 'pb-pos' : 'pb-neg'">
|
||||
{{ rateChg >= 0 ? '↑' : '↓' }} {{ (Math.abs(rateChg) * 100).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 小结 -->
|
||||
<div class="pb-summary">
|
||||
<div class="pb-bar-track">
|
||||
<div class="pb-bar-label">每100元营收中</div>
|
||||
<div class="pb-bar-wrap">
|
||||
<div class="pb-bar-seg" :style="barStyle.revenue">营收 100</div>
|
||||
</div>
|
||||
<div class="pb-bar-wrap">
|
||||
<div class="pb-bar-seg pb-bar-cost" :style="barStyle.cost">成本 {{ costRate }}</div>
|
||||
</div>
|
||||
<div class="pb-bar-wrap">
|
||||
<div class="pb-bar-seg" :class="profit >= 0 ? 'pb-bar-profit' : 'pb-bar-loss'" :style="barStyle.profit">
|
||||
利润 {{ netRate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
revenue: number
|
||||
cost: number // 成本费用合计
|
||||
profit: number // 净利润
|
||||
// 可选:费用明细
|
||||
detail?: { key: string; label: string; val: number; color?: string }[]
|
||||
// 可选:上期数据
|
||||
prevRevenue?: number
|
||||
prevProfit?: number
|
||||
}>()
|
||||
|
||||
const hasDetail = computed(() => props.detail && props.detail.length > 0)
|
||||
|
||||
const gross = computed(() => props.revenue - props.cost)
|
||||
const grossRate = computed(() => {
|
||||
if (!props.revenue) return '—'
|
||||
return ((props.revenue - props.cost) / props.revenue * 100).toFixed(1) + '%'
|
||||
})
|
||||
const netRate = computed(() => {
|
||||
if (!props.revenue) return '—'
|
||||
return (props.profit / props.revenue * 100).toFixed(1) + '%'
|
||||
})
|
||||
const costRate = computed(() => {
|
||||
if (!props.revenue) return '—'
|
||||
return (props.cost / props.revenue * 100).toFixed(1) + '%'
|
||||
})
|
||||
|
||||
const revChg = computed(() => {
|
||||
if (!props.prevRevenue) return 0
|
||||
return (props.revenue - props.prevRevenue) / props.prevRevenue
|
||||
})
|
||||
const profitChg = computed(() => {
|
||||
if (!props.prevProfit) return 0
|
||||
return (props.profit - props.prevProfit) / Math.abs(props.prevProfit)
|
||||
})
|
||||
const rateChg = computed(() => {
|
||||
if (!props.prevRevenue || !props.prevProfit) return 0
|
||||
const cur = props.profit / props.revenue
|
||||
const prev = props.prevProfit / props.prevRevenue
|
||||
return (cur - prev) / Math.abs(prev || 0.01)
|
||||
})
|
||||
|
||||
const detailItems = computed(() => {
|
||||
const colors = ['#f56c6c', '#e6a23c', '#909399', '#f8983b', '#d48265']
|
||||
return (props.detail || []).map((d, i) => ({
|
||||
...d,
|
||||
color: d.color || colors[i % colors.length],
|
||||
}))
|
||||
})
|
||||
|
||||
const barStyle = computed(() => {
|
||||
const max = props.revenue || 1
|
||||
const costPct = Math.min(props.cost / max * 100, 100)
|
||||
const profitPct = Math.max(Math.min(props.profit / max * 100, 100), 0)
|
||||
return {
|
||||
revenue: { width: '100%' },
|
||||
cost: { width: costPct + '%' },
|
||||
profit: { width: profitPct + '%' },
|
||||
}
|
||||
})
|
||||
|
||||
function fmt(v: number | undefined | null): string {
|
||||
if (v == null) return '—'
|
||||
return '¥' + Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profit-breakdown {
|
||||
padding: 12px 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* 卡片行(横向) */
|
||||
.pb-row { display: flex; align-items: stretch; gap: 12px; justify-content: center; flex-wrap: wrap; margin: 8px 0; }
|
||||
.pb-row-top { }
|
||||
|
||||
.pb-card {
|
||||
padding: 14px 22px;
|
||||
border-radius: 10px;
|
||||
min-width: 140px;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e8eaed;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.pb-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
|
||||
|
||||
.pb-card-revenue { background: #e6f7ff; border-color: #91d5ff; }
|
||||
.pb-card-expense { background: #fff2f0; border-color: #ffccc7; }
|
||||
.pb-card-gross { background: #f6ffed; border-color: #b7eb8f; min-width: 180px; }
|
||||
.pb-card-net { min-width: 180px; }
|
||||
.pb-card-pos { background: #f0f5ff; border-color: #adc6ff; }
|
||||
.pb-card-neg { background: #fff1f0; border-color: #ffa39e; }
|
||||
|
||||
.pb-arrow { display: flex; align-items: center; font-size: 24px; color: #999; font-weight: 300; }
|
||||
|
||||
.pb-label { font-size: 12px; color: #666; margin-bottom: 4px; }
|
||||
.pb-value { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.pb-value.pb-big { font-size: 22px; }
|
||||
.pb-pos { color: #389e0d; }
|
||||
.pb-neg { color: #cf1322; }
|
||||
.pb-sub { font-size: 11px; color: #999; margin-top: 2px; }
|
||||
|
||||
/* 等号行 */
|
||||
.pb-eq-row { display: flex; align-items: center; gap: 10px; margin: 4px 0; justify-content: center; }
|
||||
.pb-eq-line { flex: 0 1 80px; height: 1px; background: #e0e0e0; }
|
||||
.pb-eq-sign { font-size: 18px; color: #999; font-weight: 300; }
|
||||
|
||||
/* 费用明细 */
|
||||
.pb-detail { margin: 12px auto; max-width: 400px; background: #fafafa; border-radius: 8px; padding: 12px 16px; border: 1px solid #f0f0f0; }
|
||||
.pb-detail-title { font-size: 12px; color: #999; margin-bottom: 8px; }
|
||||
.pb-detail-grid { display: flex; flex-direction: column; gap: 6px; }
|
||||
.pb-detail-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.pb-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.pb-detail-label { flex: 1; color: #555; }
|
||||
.pb-detail-val { font-weight: 600; font-variant-numeric: tabular-nums; min-width: 80px; text-align: right; }
|
||||
|
||||
/* 同期对比 */
|
||||
.pb-compare { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; margin: 16px 0 0; }
|
||||
.pb-compare-item { font-size: 12px; color: #666; background: #fafafa; padding: 6px 14px; border-radius: 20px; border: 1px solid #f0f0f0; }
|
||||
.pb-compare-label { margin-right: 6px; }
|
||||
|
||||
/* 底部 100元拆解条 */
|
||||
.pb-summary { margin: 16px auto 0; max-width: 380px; }
|
||||
.pb-bar-track { }
|
||||
.pb-bar-label { font-size: 11px; color: #999; margin-bottom: 6px; }
|
||||
.pb-bar-wrap { height: 22px; margin-bottom: 3px; border-radius: 4px; overflow: hidden; background: #f5f5f5; }
|
||||
.pb-bar-seg { height: 100%; border-radius: 4px; font-size: 11px; color: #fff; display: flex; align-items: center; padding-left: 8px; font-weight: 600; }
|
||||
.pb-bar-cost { background: #ff7875; }
|
||||
.pb-bar-profit { background: #69b1ff; }
|
||||
.pb-bar-loss { background: #ff4d4f; }
|
||||
|
||||
/* 杜邦拆分树 */</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div :ref="el => containerRef = el" style="width:100%;height:100%;min-height:200px;"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 数据:{ name, value }[] */
|
||||
data: { name: string; value: number }[]
|
||||
totalLabel?: string
|
||||
totalUnit?: string
|
||||
}>(), {
|
||||
totalLabel: '总计',
|
||||
totalUnit: '',
|
||||
})
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
function render() {
|
||||
if (!containerRef.value) return
|
||||
if (!chart) chart = echarts.init(containerRef.value)
|
||||
|
||||
const total = props.data.reduce((s, d) => s + Math.abs(d.value), 0)
|
||||
// 构建瀑布图数据
|
||||
let accumulate = 0
|
||||
const seriesData = props.data.map((d, i) => {
|
||||
const isLast = i === props.data.length - 1
|
||||
const isPos = d.value >= 0
|
||||
const item: any = {
|
||||
value: isPos ? d.value : -d.value,
|
||||
itemStyle: {
|
||||
color: isPos ? '#52c41a' : '#ff4d4f',
|
||||
borderColor: isPos ? '#52c41a' : '#ff4d4f',
|
||||
},
|
||||
}
|
||||
if (!isLast) {
|
||||
if (isPos) {
|
||||
// 正数:从accumulate开始
|
||||
item.offset = accumulate
|
||||
accumulate += d.value
|
||||
} else {
|
||||
// 负数:向下
|
||||
accumulate += d.value
|
||||
item.offset = Math.max(0, accumulate)
|
||||
}
|
||||
} else {
|
||||
// 最后一项是总计
|
||||
item.offset = 0
|
||||
item.value = total
|
||||
item.itemStyle = { color: '#409eff' }
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
// 简化瀑布图:用堆积柱状图模拟
|
||||
const posData = props.data.map(d => d.value > 0 ? d.value : 0)
|
||||
const negData = props.data.map(d => d.value < 0 ? -d.value : 0)
|
||||
|
||||
chart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: (params: any[]) => {
|
||||
const idx = params[0]?.dataIndex
|
||||
const d = props.data[idx]
|
||||
if (!d) return ''
|
||||
return `${d.name}: <b>${d.value?.toLocaleString()}${props.totalUnit}</b>`
|
||||
},
|
||||
},
|
||||
grid: { left: 60, right: 30, top: 20, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: [...props.data.map(d => d.name), props.totalLabel],
|
||||
axisLabel: { rotate: 0, fontSize: 11, color: '#666', interval: 0 },
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: props.totalUnit,
|
||||
splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } },
|
||||
axisLabel: { fontSize: 10, color: '#999' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: posData,
|
||||
barWidth: 24,
|
||||
itemStyle: { color: '#52c41a', borderRadius: [4, 4, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: (p: any) => {
|
||||
const v = props.data[p.dataIndex]?.value
|
||||
return v && v > 0 ? v?.toLocaleString() : ''
|
||||
},
|
||||
fontSize: 10, color: '#52c41a',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: negData,
|
||||
barWidth: 24,
|
||||
itemStyle: { color: '#ff4d4f', borderRadius: [4, 4, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'bottom',
|
||||
formatter: (p: any) => {
|
||||
const v = props.data[p.dataIndex]?.value
|
||||
return v && v < 0 ? v?.toLocaleString() : ''
|
||||
},
|
||||
fontSize: 10, color: '#ff4d4f',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
data: props.data.map(() => null).concat(total),
|
||||
barWidth: 24,
|
||||
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
formatter: total?.toLocaleString(),
|
||||
fontSize: 13, fontWeight: 700, color: '#409eff',
|
||||
},
|
||||
},
|
||||
],
|
||||
}, true)
|
||||
}
|
||||
|
||||
watch(() => props.data, render, { deep: true })
|
||||
onMounted(render)
|
||||
onUnmounted(() => { chart?.dispose(); chart = null })
|
||||
</script>
|
||||
Reference in New Issue
Block a user