feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限

This commit is contained in:
Hermes CI Fix
2026-07-12 17:46:08 +08:00
parent cdf00efd69
commit ee25d5fa1d
8871 changed files with 1778433 additions and 0 deletions
@@ -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>