2026-01-16 15:56:53 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="chart-analysis-container">
|
|
|
|
|
|
<!-- 仪表盘:整体健康度 -->
|
|
|
|
|
|
<div class="gauges-row">
|
|
|
|
|
|
<!-- 月度仪表盘 -->
|
|
|
|
|
|
<div class="chart-card gauge-card">
|
|
|
|
|
|
<div class="chart-header">
|
|
|
|
|
|
<div class="chart-title">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
<!-- 月度健康度 -->
|
|
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '使用情况' : '完成情况' }}
|
|
|
|
|
|
(月度)
|
2026-01-16 15:56:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref="monthGaugeRef"
|
|
|
|
|
|
class="chart-body gauge-chart"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="gauge-footer compact">
|
|
|
|
|
|
<div class="gauge-item">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
<span class="label">
|
|
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '已用' : '已收' }}
|
|
|
|
|
|
</span>
|
2026-01-16 15:56:53 +08:00
|
|
|
|
<span class="value expense">¥{{ formatMoney(overallStats.month.current) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="gauge-item">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
<span class="label">
|
|
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}
|
|
|
|
|
|
</span>
|
2026-01-16 15:56:53 +08:00
|
|
|
|
<span class="value">¥{{ formatMoney(overallStats.month.limit) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 年度仪表盘 -->
|
|
|
|
|
|
<div class="chart-card gauge-card">
|
|
|
|
|
|
<div class="chart-header">
|
|
|
|
|
|
<div class="chart-title">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '使用情况' : '完成情况' }}
|
|
|
|
|
|
(年度)
|
2026-01-16 15:56:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref="yearGaugeRef"
|
|
|
|
|
|
class="chart-body gauge-chart"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="gauge-footer compact">
|
|
|
|
|
|
<div class="gauge-item">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
<span class="label">
|
|
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '已用' : '已收' }}
|
|
|
|
|
|
</span>
|
2026-01-16 15:56:53 +08:00
|
|
|
|
<span class="value expense">¥{{ formatMoney(overallStats.year.current) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="gauge-item">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
<span class="label">
|
|
|
|
|
|
{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}
|
|
|
|
|
|
</span>
|
2026-01-16 15:56:53 +08:00
|
|
|
|
<span class="value">¥{{ formatMoney(overallStats.year.limit) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 月度预算进度 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="budgets.length > 0"
|
|
|
|
|
|
class="chart-card"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="chart-header">
|
|
|
|
|
|
<div class="chart-title">
|
|
|
|
|
|
预算进度(月度)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="chart-subtitle">
|
2026-01-16 17:52:40 +08:00
|
|
|
|
预算剩余消耗趋势
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="activeTab === BudgetCategory.Expense"
|
|
|
|
|
|
ref="burndownChartRef"
|
|
|
|
|
|
class="chart-body burndown-chart"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="expand-toggle-row">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="expand-toggle"
|
|
|
|
|
|
@click="monthBarChartExpanded = !monthBarChartExpanded"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="expand-icon"
|
|
|
|
|
|
:class="{ expanded: monthBarChartExpanded }"
|
|
|
|
|
|
>
|
|
|
|
|
|
▼
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="expand-text">{{ monthBarChartExpanded ? '收起详情' : '展开详情' }}</span>
|
2026-01-16 15:56:53 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
2026-01-16 17:52:40 +08:00
|
|
|
|
v-if="monthBarChartExpanded"
|
2026-01-16 15:56:53 +08:00
|
|
|
|
ref="monthBarChartRef"
|
|
|
|
|
|
class="chart-body bar-chart"
|
|
|
|
|
|
:style="{ height: calculateChartHeight() + 'px' }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 年度预算进度 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="budgets.length > 0"
|
|
|
|
|
|
class="chart-card"
|
|
|
|
|
|
style="margin-top: 12px"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="chart-header">
|
|
|
|
|
|
<div class="chart-title">
|
|
|
|
|
|
预算进度(年度)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="chart-subtitle">
|
|
|
|
|
|
本年各预算执行情况
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
2026-01-16 17:52:40 +08:00
|
|
|
|
v-if="activeTab === BudgetCategory.Expense"
|
|
|
|
|
|
ref="yearBurndownChartRef"
|
|
|
|
|
|
class="chart-body burndown-chart"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div class="expand-toggle-row">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="expand-toggle"
|
|
|
|
|
|
@click="yearBarChartExpanded = !yearBarChartExpanded"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
class="expand-icon"
|
|
|
|
|
|
:class="{ expanded: yearBarChartExpanded }"
|
|
|
|
|
|
>
|
|
|
|
|
|
▼
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="expand-text">{{ yearBarChartExpanded ? '收起详情' : '展开详情' }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="yearBarChartExpanded"
|
2026-01-16 15:56:53 +08:00
|
|
|
|
ref="yearBarChartRef"
|
|
|
|
|
|
class="chart-body bar-chart"
|
|
|
|
|
|
:style="{ height: calculateChartHeight() + 'px' }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-16 17:52:40 +08:00
|
|
|
|
<!-- 偏差分析图 -->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="budgets.length > 0"
|
|
|
|
|
|
class="chart-card"
|
|
|
|
|
|
style="margin-top: 12px"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="chart-header">
|
|
|
|
|
|
<div class="chart-title">
|
|
|
|
|
|
预算偏差分析
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="chart-subtitle">
|
|
|
|
|
|
<span style="color: var(--van-danger-color)">红条超支</span> /
|
|
|
|
|
|
<span style="color: var(--van-success-color)">绿条结余</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref="varianceChartRef"
|
|
|
|
|
|
class="chart-body bar-chart"
|
|
|
|
|
|
:style="{ height: calculateChartHeight() + 'px' }"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-01-16 15:56:53 +08:00
|
|
|
|
<!-- 空状态占位 -->
|
|
|
|
|
|
<div
|
2026-01-16 17:52:40 +08:00
|
|
|
|
v-else-if="budgets.length === 0"
|
2026-01-16 15:56:53 +08:00
|
|
|
|
class="chart-card empty-card"
|
|
|
|
|
|
>
|
|
|
|
|
|
<van-empty
|
|
|
|
|
|
description="暂无预算数据"
|
|
|
|
|
|
image="search"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { ref, onMounted, watch, nextTick, onUnmounted } from 'vue'
|
|
|
|
|
|
import * as echarts from 'echarts'
|
|
|
|
|
|
import { BudgetCategory } from '@/constants/enums'
|
|
|
|
|
|
import { getCssVar } from '@/utils/theme'
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps({
|
|
|
|
|
|
overallStats: {
|
|
|
|
|
|
type: Object,
|
|
|
|
|
|
required: true
|
|
|
|
|
|
},
|
|
|
|
|
|
budgets: {
|
|
|
|
|
|
type: Array,
|
|
|
|
|
|
default: () => []
|
|
|
|
|
|
},
|
|
|
|
|
|
activeTab: {
|
|
|
|
|
|
type: [Number, String],
|
|
|
|
|
|
default: BudgetCategory.Expense
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const monthGaugeRef = ref(null)
|
|
|
|
|
|
const yearGaugeRef = ref(null)
|
|
|
|
|
|
const monthBarChartRef = ref(null)
|
|
|
|
|
|
const yearBarChartRef = ref(null)
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const burndownChartRef = ref(null)
|
|
|
|
|
|
const yearBurndownChartRef = ref(null)
|
|
|
|
|
|
const varianceChartRef = ref(null)
|
|
|
|
|
|
const monthBarChartExpanded = ref(false)
|
|
|
|
|
|
const yearBarChartExpanded = ref(false)
|
2026-01-16 15:56:53 +08:00
|
|
|
|
let monthGaugeChart = null
|
|
|
|
|
|
let yearGaugeChart = null
|
|
|
|
|
|
let monthBarChart = null
|
|
|
|
|
|
let yearBarChart = null
|
2026-01-16 17:52:40 +08:00
|
|
|
|
let burndownChart = null
|
|
|
|
|
|
let yearBurndownChart = null
|
|
|
|
|
|
let varianceChart = null
|
2026-01-16 15:56:53 +08:00
|
|
|
|
|
|
|
|
|
|
const formatMoney = (val) => {
|
|
|
|
|
|
if (val >= 10000) {
|
|
|
|
|
|
return (val / 10000).toFixed(1) + 'w'
|
|
|
|
|
|
}
|
|
|
|
|
|
return parseFloat(val || 0).toLocaleString(undefined, {
|
|
|
|
|
|
minimumFractionDigits: 0,
|
|
|
|
|
|
maximumFractionDigits: 0
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const initGaugeChart = (chartInstance, dom, data, isExpense) => {
|
|
|
|
|
|
if (!dom) { return null }
|
|
|
|
|
|
|
|
|
|
|
|
const chart = echarts.init(dom)
|
|
|
|
|
|
updateSingleGauge(chart, data, isExpense)
|
|
|
|
|
|
return chart
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateSingleGauge = (chart, data, isExpense) => {
|
|
|
|
|
|
if (!chart) { return }
|
|
|
|
|
|
|
|
|
|
|
|
const rate = parseFloat(data.rate || 0)
|
|
|
|
|
|
// 颜色逻辑
|
|
|
|
|
|
let color = getCssVar('--chart-success') // 绿色
|
|
|
|
|
|
if (isExpense) {
|
|
|
|
|
|
if (rate >= 100) { color = getCssVar('--chart-danger') } // 红色
|
|
|
|
|
|
else if (rate >= 80) { color = getCssVar('--chart-warning') } // 橙色
|
|
|
|
|
|
} else {
|
|
|
|
|
|
if (rate >= 100) { color = getCssVar('--chart-success') } // 绿色
|
|
|
|
|
|
else if (rate >= 80) { color = getCssVar('--chart-warning') } // 橙色
|
|
|
|
|
|
else { color = getCssVar('--chart-danger') } // 红色
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const option = {
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'gauge',
|
|
|
|
|
|
startAngle: 180,
|
|
|
|
|
|
endAngle: 0,
|
|
|
|
|
|
min: 0,
|
|
|
|
|
|
max: Math.max(100, rate * 1.2),
|
|
|
|
|
|
splitNumber: 5,
|
|
|
|
|
|
radius: '110%', // 放大一点以适应小卡片
|
|
|
|
|
|
center: ['50%', '75%'],
|
|
|
|
|
|
itemStyle: {
|
|
|
|
|
|
color: color,
|
|
|
|
|
|
shadowColor: getCssVar('--chart-shadow'),
|
|
|
|
|
|
shadowBlur: 10,
|
|
|
|
|
|
shadowOffsetX: 2,
|
|
|
|
|
|
shadowOffsetY: 2
|
|
|
|
|
|
},
|
|
|
|
|
|
progress: {
|
|
|
|
|
|
show: true,
|
|
|
|
|
|
roundCap: true,
|
|
|
|
|
|
width: 12 // 变细一点
|
|
|
|
|
|
},
|
|
|
|
|
|
pointer: { show: false },
|
|
|
|
|
|
axisLine: {
|
|
|
|
|
|
roundCap: true,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
width: 12,
|
|
|
|
|
|
color: [[1, getCssVar('--chart-axis')]]
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
axisTick: { show: false },
|
|
|
|
|
|
splitLine: { show: false },
|
|
|
|
|
|
axisLabel: { show: false },
|
|
|
|
|
|
detail: {
|
|
|
|
|
|
valueAnimation: true,
|
|
|
|
|
|
fontSize: 24, // 字体调小
|
|
|
|
|
|
offsetCenter: [0, -5],
|
|
|
|
|
|
color: 'var(--van-text-color)',
|
|
|
|
|
|
formatter: '{value}%',
|
|
|
|
|
|
fontWeight: 'bold',
|
|
|
|
|
|
fontFamily: 'DIN Alternate, system-ui'
|
|
|
|
|
|
},
|
|
|
|
|
|
data: [{ value: rate }]
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
chart.setOption(option)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateCharts = () => {
|
|
|
|
|
|
const isExpense = props.activeTab === BudgetCategory.Expense
|
|
|
|
|
|
updateSingleGauge(monthGaugeChart, props.overallStats.month, isExpense)
|
|
|
|
|
|
updateSingleGauge(yearGaugeChart, props.overallStats.year, isExpense)
|
|
|
|
|
|
|
|
|
|
|
|
// 确保 barChart 已初始化,如果还未初始化则先初始化
|
|
|
|
|
|
if (props.budgets.length > 0) {
|
2026-01-16 17:52:40 +08:00
|
|
|
|
if (!monthBarChart && monthBarChartRef.value && monthBarChartExpanded.value) {
|
2026-01-16 15:56:53 +08:00
|
|
|
|
monthBarChart = echarts.init(monthBarChartRef.value)
|
|
|
|
|
|
}
|
2026-01-16 17:52:40 +08:00
|
|
|
|
if (!yearBarChart && yearBarChartRef.value && yearBarChartExpanded.value) {
|
2026-01-16 15:56:53 +08:00
|
|
|
|
yearBarChart = echarts.init(yearBarChartRef.value)
|
|
|
|
|
|
}
|
2026-01-16 17:52:40 +08:00
|
|
|
|
if (monthBarChart || yearBarChart) {
|
|
|
|
|
|
updateBarChart()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 仅支出时更新燃尽图
|
|
|
|
|
|
if (isExpense) {
|
|
|
|
|
|
if (!burndownChart && burndownChartRef.value) {
|
|
|
|
|
|
burndownChart = echarts.init(burndownChartRef.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
updateBurndownChart()
|
|
|
|
|
|
|
|
|
|
|
|
if (!yearBurndownChart && yearBurndownChartRef.value) {
|
|
|
|
|
|
yearBurndownChart = echarts.init(yearBurndownChartRef.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
updateYearBurndownChart()
|
|
|
|
|
|
|
|
|
|
|
|
if (!varianceChart && varianceChartRef.value) {
|
|
|
|
|
|
varianceChart = echarts.init(varianceChartRef.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
updateVarianceChart()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 非支出时销毁燃尽图实例
|
|
|
|
|
|
if (burndownChart) {
|
|
|
|
|
|
burndownChart.dispose()
|
|
|
|
|
|
burndownChart = null
|
|
|
|
|
|
}
|
|
|
|
|
|
if (yearBurndownChart) {
|
|
|
|
|
|
yearBurndownChart.dispose()
|
|
|
|
|
|
yearBurndownChart = null
|
|
|
|
|
|
}
|
|
|
|
|
|
// 收入/存款也可能需要偏差图,但目前逻辑主要针对支出
|
|
|
|
|
|
// 如果用户想看收入的偏差,也可以保留。我们之前的逻辑已经处理了收入的情况。
|
|
|
|
|
|
// 所以这里不应该销毁 varianceChart,而是应该更新它。
|
|
|
|
|
|
if (!varianceChart && varianceChartRef.value) {
|
|
|
|
|
|
varianceChart = echarts.init(varianceChartRef.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
updateVarianceChart()
|
|
|
|
|
|
}
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateBarChart = () => {
|
2026-01-16 17:52:40 +08:00
|
|
|
|
// 按预算类型分开:月度预算和年度预算
|
|
|
|
|
|
// 1 = Month, 2 = Year
|
|
|
|
|
|
const monthBudgets = props.budgets.filter(b => b.type === 1)
|
|
|
|
|
|
const yearBudgets = props.budgets.filter(b => b.type === 2)
|
|
|
|
|
|
|
|
|
|
|
|
// 为月度预算计算百分比
|
|
|
|
|
|
const budgetsWithMonthPercentage = monthBudgets.map(b => {
|
|
|
|
|
|
const limit = b.limit || 0
|
|
|
|
|
|
const current = b.current || 0
|
|
|
|
|
|
const percentage = limit > 0 ? (current / limit) * 100 : 0
|
|
|
|
|
|
return { ...b, percentage }
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 为年度预算计算百分比
|
|
|
|
|
|
const budgetsWithYearPercentage = yearBudgets.map(b => {
|
|
|
|
|
|
const limit = b.limit || 0
|
|
|
|
|
|
const current = b.current || 0
|
|
|
|
|
|
const percentage = limit > 0 ? (current / limit) * 100 : 0
|
|
|
|
|
|
return { ...b, percentage }
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 分别按百分比从高到低排序,取前10条
|
|
|
|
|
|
const sortedMonthBudgets = budgetsWithMonthPercentage.sort((a, b) => b.percentage - a.percentage).slice(0, 10)
|
|
|
|
|
|
const sortedYearBudgets = budgetsWithYearPercentage.sort((a, b) => b.percentage - a.percentage).slice(0, 10)
|
|
|
|
|
|
|
|
|
|
|
|
const monthCategories = sortedMonthBudgets.map(b => b.name)
|
|
|
|
|
|
const yearCategories = sortedYearBudgets.map(b => b.name)
|
|
|
|
|
|
|
|
|
|
|
|
// 调试输出
|
|
|
|
|
|
console.log('月度预算项数:', monthBudgets.length, '年度预算项数:', yearBudgets.length)
|
|
|
|
|
|
console.log('月度排序后:', sortedMonthBudgets.map(b => ({ name: b.name, percentage: b.percentage })))
|
|
|
|
|
|
console.log('年度排序后:', sortedYearBudgets.map(b => ({ name: b.name, percentage: b.percentage })))
|
|
|
|
|
|
|
|
|
|
|
|
// 计算月度百分比数据
|
|
|
|
|
|
const monthPercentages = sortedMonthBudgets.map(b => {
|
|
|
|
|
|
const limit = b.limit || 0
|
|
|
|
|
|
const current = b.current || 0
|
|
|
|
|
|
if (!limit || limit <= 0) {return 0}
|
|
|
|
|
|
return Math.round((current / limit) * 100)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 计算年度百分比数据
|
|
|
|
|
|
const yearPercentages = sortedYearBudgets.map(b => {
|
|
|
|
|
|
const limit = b.limit || 0
|
|
|
|
|
|
const current = b.current || 0
|
|
|
|
|
|
if (!limit || limit <= 0) {return 0}
|
|
|
|
|
|
return Math.round((current / limit) * 100)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const getColors = (percentages) => {
|
|
|
|
|
|
return percentages.map(percentage => {
|
2026-01-16 15:56:53 +08:00
|
|
|
|
if (props.activeTab === BudgetCategory.Expense) {
|
2026-01-16 17:52:40 +08:00
|
|
|
|
// 支出:百分比越高越不好
|
|
|
|
|
|
if (percentage >= 90) { return getCssVar('--chart-danger') } // 红色
|
|
|
|
|
|
if (percentage >= 60) { return getCssVar('--chart-warning') } // 橙色
|
|
|
|
|
|
return getCssVar('--chart-primary') // 蓝色
|
2026-01-16 15:56:53 +08:00
|
|
|
|
} else {
|
2026-01-16 17:52:40 +08:00
|
|
|
|
// 收入:百分比越高越好(越接近目标越好)
|
|
|
|
|
|
if (percentage >= 90) { return getCssVar('--chart-success') } // 绿色
|
|
|
|
|
|
if (percentage >= 60) { return getCssVar('--chart-primary') } // 蓝色
|
|
|
|
|
|
return getCssVar('--chart-warning') // 橙色
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const monthColors = getColors(monthPercentages)
|
|
|
|
|
|
const yearColors = getColors(yearPercentages)
|
2026-01-16 15:56:53 +08:00
|
|
|
|
|
|
|
|
|
|
// 获取当前主题下的颜色值
|
|
|
|
|
|
const textColor = getCssVar('--van-text-color')
|
|
|
|
|
|
const textColor2 = getCssVar('--van-text-color-2')
|
|
|
|
|
|
const splitLineColor = getCssVar('--chart-split')
|
|
|
|
|
|
const axisLabelColor = getCssVar('--chart-text-muted')
|
|
|
|
|
|
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const createOption = (categories, percentages, colors) => {
|
2026-01-16 15:56:53 +08:00
|
|
|
|
return {
|
|
|
|
|
|
grid: {
|
|
|
|
|
|
left: '3%',
|
|
|
|
|
|
right: '8%',
|
|
|
|
|
|
bottom: '3%',
|
|
|
|
|
|
top: '3%',
|
|
|
|
|
|
containLabel: true
|
|
|
|
|
|
},
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'value',
|
2026-01-16 17:52:40 +08:00
|
|
|
|
max: 100,
|
2026-01-16 15:56:53 +08:00
|
|
|
|
splitLine: {
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
type: 'dashed',
|
|
|
|
|
|
color: splitLineColor
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: axisLabelColor,
|
|
|
|
|
|
formatter: (value) => {
|
2026-01-16 17:52:40 +08:00
|
|
|
|
return value + '%'
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
yAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
data: categories,
|
|
|
|
|
|
axisLine: { show: false },
|
|
|
|
|
|
axisTick: { show: false },
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: textColor,
|
|
|
|
|
|
width: 50,
|
|
|
|
|
|
overflow: 'truncate',
|
|
|
|
|
|
interval: 0
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
2026-01-16 17:52:40 +08:00
|
|
|
|
name: '预算使用率',
|
2026-01-16 15:56:53 +08:00
|
|
|
|
type: 'bar',
|
2026-01-16 17:52:40 +08:00
|
|
|
|
data: percentages,
|
2026-01-16 15:56:53 +08:00
|
|
|
|
barWidth: 10,
|
|
|
|
|
|
itemStyle: {
|
2026-01-16 17:52:40 +08:00
|
|
|
|
color: (params) => colors[params.dataIndex],
|
2026-01-16 15:56:53 +08:00
|
|
|
|
borderRadius: 5
|
|
|
|
|
|
},
|
|
|
|
|
|
label: {
|
|
|
|
|
|
show: true,
|
|
|
|
|
|
position: 'right',
|
|
|
|
|
|
formatter: (params) => {
|
2026-01-16 17:52:40 +08:00
|
|
|
|
return params.value + '%'
|
2026-01-16 15:56:53 +08:00
|
|
|
|
},
|
|
|
|
|
|
color: textColor2,
|
|
|
|
|
|
fontSize: 10
|
|
|
|
|
|
},
|
|
|
|
|
|
z: 2
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 17:52:40 +08:00
|
|
|
|
if (monthBarChart) {
|
|
|
|
|
|
monthBarChart.setOption(createOption(monthCategories, monthPercentages, monthColors))
|
|
|
|
|
|
}
|
|
|
|
|
|
if (yearBarChart) {
|
|
|
|
|
|
yearBarChart.setOption(createOption(yearCategories, yearPercentages, yearColors))
|
|
|
|
|
|
}
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const calculateChartHeight = () => {
|
|
|
|
|
|
// 根据数据数量动态计算图表高度
|
|
|
|
|
|
// 每个类别占用 60px,最少显示 200px,最多 400px
|
|
|
|
|
|
const dataCount = Math.min(props.budgets.length, 10) // 最多显示10条
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const minHeight = 100
|
2026-01-16 15:56:53 +08:00
|
|
|
|
const maxHeight = 400
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const heightPerItem = 40
|
2026-01-16 15:56:53 +08:00
|
|
|
|
const calculatedHeight = Math.max(minHeight, dataCount * heightPerItem)
|
|
|
|
|
|
return Math.min(calculatedHeight, maxHeight)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 17:52:40 +08:00
|
|
|
|
const updateBurndownChart = () => {
|
|
|
|
|
|
if (!burndownChart) { return }
|
|
|
|
|
|
|
|
|
|
|
|
// 获取当前月份的日期
|
|
|
|
|
|
const today = new Date()
|
|
|
|
|
|
const year = today.getFullYear()
|
|
|
|
|
|
const month = today.getMonth()
|
|
|
|
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
|
|
|
|
|
const currentDay = today.getDate()
|
|
|
|
|
|
|
|
|
|
|
|
// 生成日期和理想燃尽线
|
|
|
|
|
|
const dates = []
|
|
|
|
|
|
const idealBurndown = []
|
|
|
|
|
|
const actualBurndown = []
|
|
|
|
|
|
|
|
|
|
|
|
const totalBudget = props.overallStats.month.limit || 0
|
|
|
|
|
|
const currentExpense = props.overallStats.month.current || 0
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 1; i <= daysInMonth; i++) {
|
|
|
|
|
|
dates.push(`${i}日`)
|
|
|
|
|
|
// 理想燃尽:每天均匀消耗
|
|
|
|
|
|
const idealRemaining = Math.max(0, totalBudget * (1 - i / daysInMonth))
|
|
|
|
|
|
idealBurndown.push(Math.round(idealRemaining))
|
|
|
|
|
|
|
|
|
|
|
|
// 实际燃尽:根据当前日期显示
|
|
|
|
|
|
if (i <= currentDay && totalBudget > 0) {
|
|
|
|
|
|
const actualRemaining = Math.max(0, totalBudget - (currentExpense * i / currentDay))
|
|
|
|
|
|
actualBurndown.push(Math.round(actualRemaining))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
actualBurndown.push(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const textColor = getCssVar('--van-text-color')
|
|
|
|
|
|
const textColor2 = getCssVar('--van-text-color-2')
|
|
|
|
|
|
const splitLineColor = getCssVar('--chart-split')
|
|
|
|
|
|
const axisLabelColor = getCssVar('--chart-text-muted')
|
|
|
|
|
|
|
|
|
|
|
|
const option = {
|
|
|
|
|
|
grid: {
|
|
|
|
|
|
left: '3%',
|
|
|
|
|
|
right: '8%',
|
|
|
|
|
|
bottom: '3%',
|
|
|
|
|
|
top: '3%',
|
|
|
|
|
|
containLabel: true
|
|
|
|
|
|
},
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
data: dates,
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: axisLabelColor,
|
|
|
|
|
|
interval: Math.ceil(daysInMonth / 8) - 1,
|
|
|
|
|
|
rotate: 45
|
|
|
|
|
|
},
|
|
|
|
|
|
splitLine: { show: false },
|
|
|
|
|
|
axisLine: {
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: splitLineColor
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
yAxis: {
|
|
|
|
|
|
type: 'value',
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: axisLabelColor,
|
|
|
|
|
|
formatter: (value) => {
|
|
|
|
|
|
if (value >= 10000) { return (value / 10000).toFixed(0) + 'w' }
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
splitLine: {
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
type: 'dashed',
|
|
|
|
|
|
color: splitLineColor
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: (params) => {
|
|
|
|
|
|
let result = params[0].name + '<br/>'
|
|
|
|
|
|
params.forEach(param => {
|
|
|
|
|
|
if (param.value !== null) {
|
|
|
|
|
|
result += param.marker + param.seriesName + ': ¥' + (param.value >= 10000 ? (param.value / 10000).toFixed(1) + 'w' : param.value) + '<br/>'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '理想燃尽',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: idealBurndown,
|
|
|
|
|
|
smooth: false,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-warning'),
|
|
|
|
|
|
width: 2,
|
|
|
|
|
|
type: 'dashed'
|
|
|
|
|
|
},
|
|
|
|
|
|
itemStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-warning')
|
|
|
|
|
|
},
|
|
|
|
|
|
z: 1
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '实际燃尽',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: actualBurndown,
|
|
|
|
|
|
smooth: false,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-primary'),
|
|
|
|
|
|
width: 2
|
|
|
|
|
|
},
|
|
|
|
|
|
itemStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-primary')
|
|
|
|
|
|
},
|
|
|
|
|
|
z: 2
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
burndownChart.setOption(option)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateYearBurndownChart = () => {
|
|
|
|
|
|
if (!yearBurndownChart) { return }
|
|
|
|
|
|
|
|
|
|
|
|
// 获取当前年份的日期
|
|
|
|
|
|
const today = new Date()
|
|
|
|
|
|
const year = today.getFullYear()
|
|
|
|
|
|
const currentMonth = today.getMonth()
|
|
|
|
|
|
const currentDay = today.getDate()
|
|
|
|
|
|
const daysInCurrentMonth = new Date(year, currentMonth + 1, 0).getDate()
|
|
|
|
|
|
|
|
|
|
|
|
// 生成月份和理想燃尽线
|
|
|
|
|
|
const months = []
|
|
|
|
|
|
const idealBurndown = []
|
|
|
|
|
|
const actualBurndown = []
|
|
|
|
|
|
|
|
|
|
|
|
const totalBudget = props.overallStats.year.limit || 0
|
|
|
|
|
|
const currentExpense = props.overallStats.year.current || 0
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
|
|
|
|
months.push(`${i + 1}月`)
|
|
|
|
|
|
|
|
|
|
|
|
// 计算当前时间进度(基于天数)
|
|
|
|
|
|
let daysPassedInYear = 0
|
|
|
|
|
|
let daysInYear = 0
|
|
|
|
|
|
|
|
|
|
|
|
for (let j = 0; j < i; j++) {
|
|
|
|
|
|
daysInYear += new Date(year, j + 1, 0).getDate()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (i < currentMonth) {
|
|
|
|
|
|
// 之前的月份都已完成
|
|
|
|
|
|
daysPassedInYear = daysInYear + new Date(year, i + 1, 0).getDate()
|
|
|
|
|
|
} else if (i === currentMonth) {
|
|
|
|
|
|
// 当前月份
|
|
|
|
|
|
daysPassedInYear = daysInYear + currentDay
|
|
|
|
|
|
daysInYear += daysInCurrentMonth
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 未来的月份
|
|
|
|
|
|
daysInYear += new Date(year, i + 1, 0).getDate()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 全年总天数(365或366)
|
|
|
|
|
|
const daysInYearTotal = new Date(year, 12, 0).getDate() === 29 ? 366 : 365
|
|
|
|
|
|
const yearProgress = i === 11 ? 1 : daysPassedInYear / daysInYearTotal
|
|
|
|
|
|
|
|
|
|
|
|
// 理想燃尽:每月均匀消耗
|
|
|
|
|
|
const idealRemaining = Math.max(0, totalBudget * (1 - (i + 1) / 12))
|
|
|
|
|
|
idealBurndown.push(Math.round(idealRemaining))
|
|
|
|
|
|
|
|
|
|
|
|
// 实际燃尽:根据当前日期显示
|
|
|
|
|
|
if ((i < currentMonth || (i === currentMonth && currentDay > 0)) && totalBudget > 0) {
|
|
|
|
|
|
const actualRemaining = Math.max(0, totalBudget - (currentExpense * yearProgress))
|
|
|
|
|
|
actualBurndown.push(Math.round(actualRemaining))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
actualBurndown.push(null)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const textColor = getCssVar('--van-text-color')
|
|
|
|
|
|
const textColor2 = getCssVar('--van-text-color-2')
|
|
|
|
|
|
const splitLineColor = getCssVar('--chart-split')
|
|
|
|
|
|
const axisLabelColor = getCssVar('--chart-text-muted')
|
|
|
|
|
|
|
|
|
|
|
|
const option = {
|
|
|
|
|
|
grid: {
|
|
|
|
|
|
left: '3%',
|
|
|
|
|
|
right: '8%',
|
|
|
|
|
|
bottom: '3%',
|
|
|
|
|
|
top: '3%',
|
|
|
|
|
|
containLabel: true
|
|
|
|
|
|
},
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
data: months,
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: axisLabelColor
|
|
|
|
|
|
},
|
|
|
|
|
|
splitLine: { show: false },
|
|
|
|
|
|
axisLine: {
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: splitLineColor
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
yAxis: {
|
|
|
|
|
|
type: 'value',
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: axisLabelColor,
|
|
|
|
|
|
formatter: (value) => {
|
|
|
|
|
|
if (value >= 10000) { return (value / 10000).toFixed(0) + 'w' }
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
splitLine: {
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
type: 'dashed',
|
|
|
|
|
|
color: splitLineColor
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: (params) => {
|
|
|
|
|
|
let result = params[0].name + '<br/>'
|
|
|
|
|
|
params.forEach(param => {
|
|
|
|
|
|
if (param.value !== null) {
|
|
|
|
|
|
result += param.marker + param.seriesName + ': ¥' + (param.value >= 10000 ? (param.value / 10000).toFixed(1) + 'w' : param.value) + '<br/>'
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '理想燃尽',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: idealBurndown,
|
|
|
|
|
|
smooth: false,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-warning'),
|
|
|
|
|
|
width: 2,
|
|
|
|
|
|
type: 'dashed'
|
|
|
|
|
|
},
|
|
|
|
|
|
itemStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-warning')
|
|
|
|
|
|
},
|
|
|
|
|
|
z: 1
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '实际燃尽',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
data: actualBurndown,
|
|
|
|
|
|
smooth: false,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-primary'),
|
|
|
|
|
|
width: 2
|
|
|
|
|
|
},
|
|
|
|
|
|
itemStyle: {
|
|
|
|
|
|
color: getCssVar('--chart-primary')
|
|
|
|
|
|
},
|
|
|
|
|
|
z: 2
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
yearBurndownChart.setOption(option)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const updateVarianceChart = () => {
|
|
|
|
|
|
if (!varianceChart) { return }
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 准备数据:计算偏差 (Current - Limit)
|
|
|
|
|
|
// 只关注本年和本月的预算,或者只展示本年?或者全部?
|
|
|
|
|
|
// 按照设计,展示所有预算项的偏差
|
|
|
|
|
|
// 为了避免混淆,我们可以合并月度和年度,或者只展示月度?
|
|
|
|
|
|
// 用户的图表1和2是分开月度和年度的。
|
|
|
|
|
|
// 偏差分析通常用于当前执行周期。如果混合了月度和年度,数值差异会很大(年度偏差可能几千,月度几百)。
|
|
|
|
|
|
// 建议:优先展示月度预算的偏差,因为这是最高频关注点。或者提供选项?
|
|
|
|
|
|
// 简单起见,这里展示“月度预算”的偏差。如果用户切到“收入”,则展示收入预算的偏差。
|
|
|
|
|
|
|
|
|
|
|
|
// 过滤出月度预算
|
|
|
|
|
|
const relevantBudgets = props.budgets // .filter(b => b.type === 1) // 1 = Month
|
|
|
|
|
|
if (relevantBudgets.length === 0) {
|
|
|
|
|
|
// 如果没有月度预算,尝试年度?不,保持一致性,只看月度。
|
|
|
|
|
|
// 或者,我们可以把所有预算都放进去,但在名字上区分?
|
|
|
|
|
|
// 让我们先只做月度,因为这是最直接的反馈。
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算偏差
|
|
|
|
|
|
const data = relevantBudgets.map(b => {
|
|
|
|
|
|
const limit = b.limit || 0
|
|
|
|
|
|
const current = b.current || 0
|
|
|
|
|
|
// 偏差 = 实际 - 预算
|
|
|
|
|
|
// 对于支出:正数 = 超支 (Bad/Red), 负数 = 结余 (Good/Green)
|
|
|
|
|
|
// 对于收入:正数 = 超额 (Good/Green?), 负数 = 差额 (Bad/Red?)
|
|
|
|
|
|
// 但用户明确说:红条超支,绿条结余。这完全是支出视角。
|
|
|
|
|
|
// 如果是收入 Tab,我们保持同样的逻辑:实际 > 目标 (Green), 实际 < 目标 (Red)
|
|
|
|
|
|
// 为了适配用户的“红黑榜”视觉定义:
|
|
|
|
|
|
// 右侧 (正值) -> 红色 (超支/Warning)
|
|
|
|
|
|
// 左侧 (负值) -> 绿色 (结余/Safe)
|
|
|
|
|
|
|
|
|
|
|
|
const diff = current - limit
|
|
|
|
|
|
|
|
|
|
|
|
// 如果是收入,逻辑反转?
|
|
|
|
|
|
// 收入:目标 10000,实际 12000。Diff +2000。这是好事。应该绿。
|
|
|
|
|
|
// 收入:目标 10000,实际 8000。Diff -2000。这是坏事。应该红。
|
|
|
|
|
|
// 所以收入时,我们把 Diff 取反?或者改变颜色映射?
|
|
|
|
|
|
// 让我们先按“支出”逻辑写死,因为这是最常用的。
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
name: b.name,
|
|
|
|
|
|
value: diff,
|
|
|
|
|
|
limit: limit,
|
|
|
|
|
|
current: current
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 排序:按偏差的绝对值降序排列
|
|
|
|
|
|
data.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
|
|
|
|
|
|
|
|
|
|
|
|
const categories = data.map(item => item.name)
|
|
|
|
|
|
const values = data.map(item => item.value)
|
|
|
|
|
|
|
|
|
|
|
|
const textColor = getCssVar('--van-text-color')
|
|
|
|
|
|
const splitLineColor = getCssVar('--chart-split')
|
|
|
|
|
|
const axisLabelColor = getCssVar('--chart-text-muted')
|
|
|
|
|
|
|
|
|
|
|
|
const option = {
|
|
|
|
|
|
grid: {
|
|
|
|
|
|
left: '3%',
|
|
|
|
|
|
right: '8%',
|
|
|
|
|
|
bottom: '3%',
|
|
|
|
|
|
top: '3%',
|
|
|
|
|
|
containLabel: true
|
|
|
|
|
|
},
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: (params) => {
|
|
|
|
|
|
const item = data[params[0].dataIndex]
|
|
|
|
|
|
let html = `${item.name}<br/>`
|
|
|
|
|
|
html += `预算: ¥${formatMoney(item.limit)}<br/>`
|
|
|
|
|
|
html += `实际: ¥${formatMoney(item.current)}<br/>`
|
|
|
|
|
|
const diffText = item.value > 0 ? `超支: ¥${formatMoney(item.value)}` : `结余: ¥${formatMoney(Math.abs(item.value))}`
|
|
|
|
|
|
const color = item.value > 0 ? getCssVar('--van-danger-color') : getCssVar('--van-success-color')
|
|
|
|
|
|
html += `<span style="color:${color};font-weight:bold">${diffText}</span>`
|
|
|
|
|
|
return html
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'value',
|
|
|
|
|
|
position: 'top', // 坐标轴在上方,或者隐藏
|
|
|
|
|
|
axisLine: { show: false },
|
|
|
|
|
|
axisLabel: { show: false }, // 隐藏X轴标签,让图表更干净
|
|
|
|
|
|
splitLine: {
|
|
|
|
|
|
show: true,
|
|
|
|
|
|
lineStyle: {
|
|
|
|
|
|
type: 'dashed',
|
|
|
|
|
|
color: splitLineColor
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
yAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
axisTick: { show: false },
|
|
|
|
|
|
axisLine: { show: false },
|
|
|
|
|
|
data: categories,
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: textColor,
|
|
|
|
|
|
width: 60,
|
|
|
|
|
|
overflow: 'truncate'
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: '偏差',
|
|
|
|
|
|
type: 'bar',
|
|
|
|
|
|
stack: 'Total',
|
|
|
|
|
|
data: values,
|
|
|
|
|
|
label: {
|
|
|
|
|
|
show: true,
|
|
|
|
|
|
position: 'right', // 默认右侧,正负值会自动调整吗?ECharts Bar label position logic:
|
|
|
|
|
|
// For positive value: 'right', 'top', 'inside', etc.
|
|
|
|
|
|
// We need dynamic position based on value sign.
|
|
|
|
|
|
formatter: (params) => {
|
|
|
|
|
|
const val = params.value
|
|
|
|
|
|
return val > 0 ? `+${formatMoney(val)}` : formatMoney(val)
|
|
|
|
|
|
},
|
|
|
|
|
|
color: textColor
|
|
|
|
|
|
},
|
|
|
|
|
|
itemStyle: {
|
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
|
color: (params) => {
|
|
|
|
|
|
const val = params.value
|
|
|
|
|
|
if (props.activeTab === BudgetCategory.Expense) {
|
|
|
|
|
|
// 支出:正数(超支)红,负数(结余)绿
|
|
|
|
|
|
return val > 0 ? getCssVar('--van-danger-color') : getCssVar('--van-success-color')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 收入:正数(超额)绿,负数(差额)红
|
|
|
|
|
|
return val > 0 ? getCssVar('--van-success-color') : getCssVar('--van-danger-color')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 针对 ECharts 的 label 位置优化:正数 label 在右,负数 label 在左
|
|
|
|
|
|
// ECharts 5+ 支持 label position 为 function? 好像不支持。
|
|
|
|
|
|
// 但我们可以通过 rich text 或 multiple series 来实现,或者简单地设为 'outside' (ECharts 没有 outside for bar?)
|
|
|
|
|
|
// 对于 horizontal bar, positive value bar goes right, negative goes left.
|
|
|
|
|
|
// label position 'right' means right of the bar end (for positive) and right of the bar end (for negative, which is inside towards 0).
|
|
|
|
|
|
// ECharts default behavior:
|
|
|
|
|
|
// If value > 0, bar extends right. Position 'right' is outside right.
|
|
|
|
|
|
// If value < 0, bar extends left. Position 'right' is inside (near 0). Position 'left' is outside left.
|
|
|
|
|
|
// Let's keep it simple first. Or use a callback if supported, or two series.
|
|
|
|
|
|
// Actually, let's try just setting it based on logic if possible, but series config is static.
|
|
|
|
|
|
// Better approach: use two series, one for positive, one for negative?
|
|
|
|
|
|
// Or iterate data and set specific label position in data item.
|
|
|
|
|
|
|
|
|
|
|
|
const seriesData = values.map((val, index) => {
|
|
|
|
|
|
return {
|
|
|
|
|
|
value: val,
|
|
|
|
|
|
label: {
|
|
|
|
|
|
position: val >= 0 ? 'right' : 'left'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
option.series[0].data = seriesData
|
|
|
|
|
|
|
|
|
|
|
|
varianceChart.setOption(option)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 15:56:53 +08:00
|
|
|
|
watch(() => props.overallStats, () => nextTick(updateCharts), { deep: true })
|
|
|
|
|
|
watch(() => props.budgets, () => {
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
if (props.budgets.length > 0 && (!monthBarChart || !yearBarChart) && monthBarChartRef.value && yearBarChartRef.value) {
|
|
|
|
|
|
// budgets 从空到有值,需要初始化图表
|
|
|
|
|
|
monthBarChart = echarts.init(monthBarChartRef.value)
|
|
|
|
|
|
yearBarChart = echarts.init(yearBarChartRef.value)
|
|
|
|
|
|
updateBarChart()
|
|
|
|
|
|
} else {
|
|
|
|
|
|
updateCharts()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}, { deep: true })
|
2026-01-16 17:52:40 +08:00
|
|
|
|
watch(() => props.activeTab, () => {
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
updateCharts()
|
|
|
|
|
|
// 切换标签后延迟 resize,确保 DOM 已更新
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
burndownChart?.resize()
|
|
|
|
|
|
yearBurndownChart?.resize()
|
|
|
|
|
|
varianceChart?.resize()
|
|
|
|
|
|
}, 100)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 监听展开/折叠状态
|
|
|
|
|
|
watch(() => monthBarChartExpanded.value, (expanded) => {
|
|
|
|
|
|
if (expanded) {
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
if (monthBarChartRef.value) {
|
|
|
|
|
|
if (!monthBarChart) {
|
|
|
|
|
|
monthBarChart = echarts.init(monthBarChartRef.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
updateBarChart()
|
|
|
|
|
|
monthBarChart.resize()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 收起时销毁图表实例,释放内存
|
|
|
|
|
|
if (monthBarChart) {
|
|
|
|
|
|
monthBarChart.dispose()
|
|
|
|
|
|
monthBarChart = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
watch(() => yearBarChartExpanded.value, (expanded) => {
|
|
|
|
|
|
if (expanded) {
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
if (yearBarChartRef.value) {
|
|
|
|
|
|
if (!yearBarChart) {
|
|
|
|
|
|
yearBarChart = echarts.init(yearBarChartRef.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
updateBarChart()
|
|
|
|
|
|
yearBarChart.resize()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 收起时销毁图表实例,释放内存
|
|
|
|
|
|
if (yearBarChart) {
|
|
|
|
|
|
yearBarChart.dispose()
|
|
|
|
|
|
yearBarChart = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-01-16 15:56:53 +08:00
|
|
|
|
|
|
|
|
|
|
const handleResize = () => {
|
|
|
|
|
|
monthGaugeChart?.resize()
|
|
|
|
|
|
yearGaugeChart?.resize()
|
|
|
|
|
|
monthBarChart?.resize()
|
|
|
|
|
|
yearBarChart?.resize()
|
2026-01-16 17:52:40 +08:00
|
|
|
|
burndownChart?.resize()
|
|
|
|
|
|
yearBurndownChart?.resize()
|
|
|
|
|
|
varianceChart?.resize()
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
nextTick(() => {
|
|
|
|
|
|
const isExpense = props.activeTab === BudgetCategory.Expense
|
|
|
|
|
|
monthGaugeChart = initGaugeChart(monthGaugeChart, monthGaugeRef.value, props.overallStats.month, isExpense)
|
|
|
|
|
|
yearGaugeChart = initGaugeChart(yearGaugeChart, yearGaugeRef.value, props.overallStats.year, isExpense)
|
|
|
|
|
|
// 只在有数据时初始化柱状图
|
|
|
|
|
|
if (props.budgets.length > 0) {
|
|
|
|
|
|
if (monthBarChartRef.value) {
|
|
|
|
|
|
monthBarChart = echarts.init(monthBarChartRef.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (yearBarChartRef.value) {
|
|
|
|
|
|
yearBarChart = echarts.init(yearBarChartRef.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
updateBarChart()
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
|
|
|
|
|
// 仅支出时初始化燃尽图
|
|
|
|
|
|
if (isExpense && burndownChartRef.value) {
|
|
|
|
|
|
burndownChart = echarts.init(burndownChartRef.value)
|
|
|
|
|
|
updateBurndownChart()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (isExpense && yearBurndownChartRef.value) {
|
|
|
|
|
|
yearBurndownChart = echarts.init(yearBurndownChartRef.value)
|
|
|
|
|
|
updateYearBurndownChart()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化偏差图
|
|
|
|
|
|
if (varianceChartRef.value) {
|
|
|
|
|
|
varianceChart = echarts.init(varianceChartRef.value)
|
|
|
|
|
|
updateVarianceChart()
|
|
|
|
|
|
}
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
window.addEventListener('resize', handleResize)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
window.removeEventListener('resize', handleResize)
|
|
|
|
|
|
monthGaugeChart?.dispose()
|
|
|
|
|
|
yearGaugeChart?.dispose()
|
|
|
|
|
|
monthBarChart?.dispose()
|
|
|
|
|
|
yearBarChart?.dispose()
|
2026-01-16 17:52:40 +08:00
|
|
|
|
burndownChart?.dispose()
|
|
|
|
|
|
yearBurndownChart?.dispose()
|
|
|
|
|
|
varianceChart?.dispose()
|
2026-01-16 15:56:53 +08:00
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.chart-analysis-container {
|
|
|
|
|
|
padding: 12px;
|
|
|
|
|
|
padding-bottom: 80px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauges-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chart-card {
|
|
|
|
|
|
background: var(--van-background-2);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
padding: 16px;
|
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-card {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
/* 防止 flex 子项溢出 */
|
|
|
|
|
|
padding: 12px;
|
|
|
|
|
|
/* 减小内边距 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chart-header {
|
|
|
|
|
|
margin-bottom: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chart-title {
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
/* 调小标题 */
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: var(--van-text-color);
|
|
|
|
|
|
margin-bottom: 2px;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chart-subtitle {
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
color: var(--van-text-color-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chart-body {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-chart {
|
|
|
|
|
|
height: 120px;
|
|
|
|
|
|
/* 调小高度 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.bar-chart {
|
|
|
|
|
|
min-height: 200px;
|
|
|
|
|
|
max-height: 400px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 17:52:40 +08:00
|
|
|
|
.burndown-chart {
|
|
|
|
|
|
height: 200px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-16 15:56:53 +08:00
|
|
|
|
.gauge-footer {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-around;
|
|
|
|
|
|
/* 分散对齐 */
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
margin-top: -20px;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-item .label {
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
color: var(--van-text-color-2);
|
|
|
|
|
|
transform: scale(0.9);
|
|
|
|
|
|
/* 视觉上更小 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-item .value {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
font-family: DIN Alternate, system-ui;
|
|
|
|
|
|
color: var(--van-text-color);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-item .value.expense {
|
|
|
|
|
|
color: var(--van-primary-color);
|
|
|
|
|
|
}
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
|
|
|
|
|
.expand-toggle-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.expand-toggle {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
padding: 4px 12px;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
transition: background-color 0.2s ease;
|
|
|
|
|
|
user-select: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.expand-toggle:hover {
|
|
|
|
|
|
background-color: rgba(0, 0, 0, 0.05);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.expand-toggle:active {
|
|
|
|
|
|
background-color: rgba(0, 0, 0, 0.1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.expand-icon {
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--van-text-color-2);
|
|
|
|
|
|
transition: transform 0.2s ease;
|
|
|
|
|
|
transform-origin: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.expand-icon.expanded {
|
|
|
|
|
|
transform: rotate(180deg);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.expand-text {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
color: var(--van-text-color-2);
|
|
|
|
|
|
transition: color 0.2s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.expand-toggle:hover .expand-text {
|
|
|
|
|
|
color: var(--van-text-color);
|
|
|
|
|
|
}
|
2026-01-16 15:56:53 +08:00
|
|
|
|
</style>
|