Files
EmailBill/Web/src/components/Budget/BudgetChartAnalysis.vue

1132 lines
29 KiB
Vue
Raw Normal View History

2026-01-25 13:22:51 +08:00
<template>
2026-01-16 15:56:53 +08:00
<div class="chart-analysis-container">
<!-- 仪表盘整体健康度 -->
<div class="gauges-row">
<!-- 月度仪表盘 -->
<div class="chart-card gauge-card">
<div class="chart-header">
<div class="chart-title">
2026-02-20 13:56:29 +08:00
<span class="chart-title-text">
{{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
</span>
2026-01-22 21:03:00 +08:00
<van-icon
name="info-o"
size="16"
color="var(--van-primary-color)"
2026-02-20 13:56:29 +08:00
class="info-icon"
@click="handleShowDescription('month')"
2026-01-22 21:03:00 +08:00
/>
2026-01-16 15:56:53 +08:00
</div>
</div>
2026-01-21 16:09:38 +08:00
<div class="gauge-wrapper">
<BaseChart
type="doughnut"
:data="monthGaugeData"
:options="monthGaugeOptions"
:plugins="[chartjsGaugePlugin]"
2026-01-21 16:09:38 +08:00
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
/>
<div class="gauge-text-overlay">
<div class="balance-label">
2026-02-20 13:56:29 +08:00
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
2026-01-21 16:09:38 +08:00
</div>
<div
class="balance-value"
:style="{
color:
2026-02-20 13:56:29 +08:00
activeTab === BudgetCategory.Expense
? (overallStats.month.current > overallStats.month.limit ? 'var(--van-danger-color)' : '')
: (overallStats.month.current < overallStats.month.limit ? 'var(--van-danger-color)' : '')
2026-02-01 10:27:04 +08:00
}"
2026-01-21 16:09:38 +08:00
>
2026-02-01 10:27:04 +08:00
¥{{ formatMoney(Math.abs(overallStats.month.limit - overallStats.month.current)) }}
2026-01-21 16:09:38 +08:00
</div>
</div>
</div>
<div class="gauge-footer">
2026-01-16 15:56:53 +08:00
<div class="gauge-item">
2026-02-20 13:56:29 +08:00
<span class="label">{{ activeTab === BudgetCategory.Expense ? '已用' : '已获得' }}</span>
<span class="value">¥{{ formatMoney(overallStats.month.current) }}</span>
2026-01-16 15:56:53 +08:00
</div>
<div class="gauge-item">
2026-02-20 13:56:29 +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-02-20 13:56:29 +08:00
<span class="chart-title-text">
{{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
</span>
2026-01-22 21:03:00 +08:00
<van-icon
name="info-o"
size="16"
color="var(--van-primary-color)"
2026-02-20 13:56:29 +08:00
class="info-icon"
@click="handleShowDescription('year')"
2026-01-22 21:03:00 +08:00
/>
2026-01-16 15:56:53 +08:00
</div>
</div>
2026-01-21 16:09:38 +08:00
<div class="gauge-wrapper">
<BaseChart
type="doughnut"
:data="yearGaugeData"
:options="yearGaugeOptions"
:plugins="[chartjsGaugePlugin]"
2026-01-21 16:09:38 +08:00
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
/>
<div class="gauge-text-overlay">
<div class="balance-label">
2026-02-20 13:56:29 +08:00
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
2026-01-21 16:09:38 +08:00
</div>
<div
class="balance-value"
:style="{
color:
2026-02-20 13:56:29 +08:00
activeTab === BudgetCategory.Expense
? (overallStats.year.current > overallStats.year.limit ? 'var(--van-danger-color)' : '')
: (overallStats.year.current < overallStats.year.limit ? 'var(--van-danger-color)' : '')
}"
2026-01-21 16:09:38 +08:00
>
2026-02-01 10:27:04 +08:00
¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }}
2026-01-21 16:09:38 +08:00
</div>
</div>
</div>
<div class="gauge-footer">
2026-01-16 15:56:53 +08:00
<div class="gauge-item">
2026-02-20 13:56:29 +08:00
<span class="label">{{ activeTab === BudgetCategory.Expense ? '已用' : '已获得' }}</span>
<span class="value">¥{{ formatMoney(overallStats.year.current) }}</span>
2026-01-16 15:56:53 +08:00
</div>
<div class="gauge-item">
2026-02-20 13:56:29 +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-02-20 13:56:29 +08:00
{{ activeTab === BudgetCategory.Expense ? '预算剩余消耗趋势' : '收入积累趋势' }}
2026-01-16 17:52:40 +08:00
</div>
</div>
<BaseChart
type="line"
:data="burndownChartData"
:options="burndownChartOptions"
2026-01-16 17:52:40 +08:00
class="chart-body burndown-chart"
/>
2026-01-16 15:56:53 +08:00
</div>
<!-- 年度预算进度 -->
<div
v-if="budgets.length > 0"
class="chart-card chart-card-spacing"
2026-01-16 15:56:53 +08:00
>
<div class="chart-header">
<div class="chart-title">
预算进度年度
</div>
<div class="chart-subtitle">
本年各预算执行情况
</div>
</div>
<BaseChart
type="line"
:data="yearBurndownChartData"
:options="yearBurndownChartOptions"
2026-01-16 17:52:40 +08:00
class="chart-body burndown-chart"
/>
2026-01-20 19:56:29 +08:00
</div>
<!-- 偏差分析 -->
<div
v-if="budgets.length > 0"
class="chart-card chart-card-spacing"
2026-01-20 19:56:29 +08:00
>
<div class="chart-header">
<div class="chart-title">
偏差分析
</div>
<div class="chart-subtitle">
预算执行偏差排行
2026-01-16 17:52:40 +08:00
</div>
</div>
<BaseChart
type="bar"
:data="varianceChartData"
:options="varianceChartOptions"
2026-02-19 21:34:55 +08:00
:plugins="varianceChartPlugins"
class="chart-body variance-chart"
2026-01-20 19:56:29 +08:00
:style="{ height: calculateChartHeight(budgets) + 'px' }"
2026-01-16 17:52:40 +08:00
/>
</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>
2026-01-22 21:03:00 +08:00
<!-- 详细描述弹窗 -->
<PopupContainer
v-model="showDescriptionPopup"
:title="activeDescTab === 'month' ? '预算额度/实际详情(月度)' : '预算额度/实际详情(年度)'"
height="70%"
>
<div
class="rich-html-content popup-content-padding"
v-html="
activeDescTab === 'month'
? overallStats.month?.description ||
'<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>'
: overallStats.year?.description ||
'<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>'
"
2026-01-22 21:03:00 +08:00
/>
</PopupContainer>
2026-01-16 15:56:53 +08:00
</template>
<script setup>
2026-02-20 13:56:29 +08:00
import { ref, computed } from 'vue'
2026-01-25 13:22:51 +08:00
import { BudgetCategory, BudgetPeriodType } from '@/constants/enums'
2026-01-16 15:56:53 +08:00
import { getCssVar } from '@/utils/theme'
2026-01-22 21:03:00 +08:00
import PopupContainer from '@/components/PopupContainer.vue'
import BaseChart from '@/components/Charts/BaseChart.vue'
import { useChartTheme } from '@/composables/useChartTheme'
import { chartjsGaugePlugin } from '@/plugins/chartjs-gauge-plugin'
import { Chart as ChartJS } from 'chart.js'
// 注册仪表盘插件
ChartJS.register(chartjsGaugePlugin)
2026-01-16 15:56:53 +08:00
const props = defineProps({
overallStats: {
type: Object,
required: true
},
budgets: {
type: Array,
default: () => []
},
activeTab: {
type: [Number, String],
default: BudgetCategory.Expense
2026-01-21 18:52:31 +08:00
},
selectedDate: {
type: Date,
default: () => new Date()
2026-01-16 15:56:53 +08:00
}
})
2026-01-22 21:03:00 +08:00
// 弹窗状态
const showDescriptionPopup = ref(false)
const activeDescTab = ref('month')
2026-02-20 13:56:29 +08:00
// 显示描述弹窗
const handleShowDescription = (tab) => {
activeDescTab.value = tab
showDescriptionPopup.value = true
}
// Chart.js 相关
const { getChartOptions } = useChartTheme()
2026-01-16 15:56:53 +08:00
const formatMoney = (val) => {
if (Math.abs(val) >= 10000) {
2026-01-16 15:56:53 +08:00
return (val / 10000).toFixed(1) + 'w'
}
return parseFloat(val || 0).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
}
// 月度仪表盘数据
const monthGaugeData = computed(() => {
// 防御性检查:如果数据未加载,返回默认结构
if (!props.overallStats || !props.overallStats.month) {
return {
datasets: [
{
data: [0, 100],
backgroundColor: [getCssVar('--chart-axis'), getCssVar('--chart-axis')],
borderWidth: 0,
circumference: 180,
rotation: 270
}
]
}
}
const rate = parseFloat(props.overallStats.month.rate || 0)
const isExpense = props.activeTab === BudgetCategory.Expense
2026-01-16 15:56:53 +08:00
2026-01-18 13:06:16 +08:00
let displayRate
2026-01-16 15:56:53 +08:00
if (isExpense) {
2026-01-18 13:06:16 +08:00
displayRate = Math.max(0, 100 - rate)
2026-02-01 10:27:04 +08:00
if (rate > 100) {
displayRate = rate - 100
}
2026-01-16 15:56:53 +08:00
} else {
2026-02-01 10:27:04 +08:00
displayRate = rate
2026-01-18 13:06:16 +08:00
}
let color
if (isExpense) {
2026-02-01 10:27:04 +08:00
if (rate > 100) {
color = getCssVar('--chart-danger')
2026-02-01 10:27:04 +08:00
} else if (displayRate <= 30) {
color = getCssVar('--chart-danger')
2026-02-01 10:27:04 +08:00
} else if (displayRate <= 65) {
color = getCssVar('--chart-warning')
2026-02-01 10:27:04 +08:00
} else {
color = getCssVar('--chart-success')
2026-02-01 10:27:04 +08:00
}
2026-01-18 13:06:16 +08:00
} else {
if (displayRate <= 30) {
color = getCssVar('--chart-danger')
} else if (displayRate <= 65) {
color = getCssVar('--chart-warning')
} else {
color = getCssVar('--chart-success')
}
2026-01-16 15:56:53 +08:00
}
const maxValue = isExpense && rate > 100 ? 50 : 100
const remaining = 100 - displayRate
return {
datasets: [
2026-01-16 15:56:53 +08:00
{
data: [displayRate, remaining],
backgroundColor: [color, getCssVar('--chart-axis')],
borderWidth: 0,
circumference: 220,
rotation: 250
2026-01-16 15:56:53 +08:00
}
]
}
})
2026-01-16 15:56:53 +08:00
const monthGaugeOptions = computed(() => {
return {
cutout: '75%',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
},
datalabels: {
display: false
},
gaugePlugin: {
centerText: false
}
},
scales: {
x: { display: false },
y: { display: false }
}
}
})
2026-01-16 15:56:53 +08:00
// 年度仪表盘数据
const yearGaugeData = computed(() => {
// 防御性检查:如果数据未加载,返回默认结构
if (!props.overallStats || !props.overallStats.year) {
return {
datasets: [
{
data: [0, 100],
backgroundColor: [getCssVar('--chart-axis'), getCssVar('--chart-axis')],
borderWidth: 0,
circumference: 180,
rotation: 270
}
]
}
}
const rate = parseFloat(props.overallStats.year.rate || 0)
2026-01-16 15:56:53 +08:00
const isExpense = props.activeTab === BudgetCategory.Expense
2026-01-21 18:52:31 +08:00
let displayRate
if (isExpense) {
displayRate = Math.max(0, 100 - rate)
if (rate > 100) {
displayRate = rate - 100
}
} else {
displayRate = rate
2026-01-21 18:52:31 +08:00
}
let color
if (isExpense) {
if (rate > 100) {
color = getCssVar('--chart-danger')
} else if (displayRate <= 30) {
color = getCssVar('--chart-danger')
} else if (displayRate <= 65) {
color = getCssVar('--chart-warning')
} else {
color = getCssVar('--chart-success')
}
} else {
if (displayRate <= 30) {
color = getCssVar('--chart-danger')
} else if (displayRate <= 65) {
color = getCssVar('--chart-warning')
} else {
color = getCssVar('--chart-success')
}
2026-01-21 18:52:31 +08:00
}
2026-01-16 15:56:53 +08:00
const remaining = 100 - displayRate
2026-01-16 17:52:40 +08:00
return {
datasets: [
{
data: [displayRate, remaining],
backgroundColor: [color, getCssVar('--chart-axis')],
borderWidth: 0,
circumference: 220,
rotation: 250
2026-01-21 18:52:31 +08:00
}
]
}
})
2026-01-16 17:52:40 +08:00
const yearGaugeOptions = computed(() => {
return {
cutout: '75%',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
},
datalabels: {
display: false
},
gaugePlugin: {
centerText: false
2026-01-21 18:52:31 +08:00
}
},
scales: {
x: { display: false },
y: { display: false }
}
2026-01-16 15:56:53 +08:00
}
})
const calculateChartHeight = (budgets) => {
if (!budgets) {
return 100
}
const dataCount = budgets.length
const minHeight = 100
const heightPerItem = 30
const calculatedHeight = Math.max(minHeight, dataCount * heightPerItem)
return calculatedHeight
2026-01-16 15:56:53 +08:00
}
2026-02-19 21:34:55 +08:00
const varianceLabelPlugin = {
id: 'variance-label-plugin',
afterDatasetsDraw: (chart) => {
const dataset = chart.data?.datasets?.[0]
const metaData = dataset?._meta
if (!dataset || !metaData) {
return
}
const meta = chart.getDatasetMeta(0)
if (!meta?.data) {
return
}
const { ctx, chartArea } = chart
const fontFamily = '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif'
ctx.save()
ctx.font = `12px ${fontFamily}`
ctx.textBaseline = 'middle'
meta.data.forEach((bar, index) => {
const item = metaData[index]
if (!item || item.value === 0) {
return
}
const label = formatVarianceLabelValue(item.value)
const textWidth = ctx.measureText(label).width
const position = bar.tooltipPosition ? bar.tooltipPosition() : { x: bar.x, y: bar.y }
const offset = 8
const isPositive = item.value > 0
ctx.fillStyle = getVarianceLabelColor(item.value)
let x = position.x + (isPositive ? offset : -offset)
const y = position.y
if (chartArea) {
const rightLimit = chartArea.right - 4
const leftLimit = chartArea.left + 4
if (isPositive && x + textWidth > rightLimit) {
x = rightLimit - textWidth
}
if (!isPositive && x - textWidth < leftLimit) {
x = leftLimit + textWidth
}
}
ctx.textAlign = isPositive ? 'left' : 'right'
ctx.fillText(label, x, y)
})
ctx.restore()
}
}
const varianceChartPlugins = computed(() => [varianceLabelPlugin])
const formatVarianceLabelValue = (value) => {
const absValue = Math.abs(Math.round(value || 0))
return absValue.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
}
const getVarianceLabelColor = (value) => {
if (props.activeTab === BudgetCategory.Expense) {
return value > 0 ? getCssVar('--van-danger-color') : getCssVar('--van-success-color')
}
return value > 0 ? getCssVar('--van-success-color') : getCssVar('--van-danger-color')
}
// 偏差分析图表数据
const varianceChartData = computed(() => {
if (!props.budgets || props.budgets.length === 0) {
return { labels: [], datasets: [] }
}
2026-01-16 17:52:40 +08:00
const data = props.budgets.map((b) => {
2026-01-16 17:52:40 +08:00
const limit = b.limit || 0
const current = b.current || 0
const diff = current - limit
return {
2026-01-25 13:22:51 +08:00
name: b.name + (b.type === BudgetPeriodType.Year ? ' (年)' : ''),
value: diff,
limit: limit,
2026-01-25 13:22:51 +08:00
current: current,
type: b.type
}
2026-01-16 17:52:40 +08:00
})
const monthlyData = data.filter((item) => item.type === BudgetPeriodType.Month)
const annualData = data.filter((item) => item.type === BudgetPeriodType.Year)
2026-01-16 15:56:53 +08:00
2026-02-19 21:34:55 +08:00
const sortByLimitAndRemaining = (a, b) => {
if (a.limit !== b.limit) {
return a.limit - b.limit
}
const remainingA = a.limit - a.current
const remainingB = b.limit - b.current
return remainingB - remainingA
}
monthlyData.sort(sortByLimitAndRemaining)
annualData.sort(sortByLimitAndRemaining)
2026-01-25 13:22:51 +08:00
2026-02-19 21:34:55 +08:00
const sortedData = [...monthlyData, ...annualData]
2026-01-16 15:56:53 +08:00
return {
labels: sortedData.map((item) => item.name),
datasets: [
{
label: '偏差',
data: sortedData.map((item) => item.value),
backgroundColor: sortedData.map((item) => {
if (props.activeTab === BudgetCategory.Expense) {
return item.value > 0
? getCssVar('--van-danger-color')
: getCssVar('--van-success-color')
2026-01-17 15:03:19 +08:00
} else {
return item.value > 0
? getCssVar('--van-success-color')
: getCssVar('--van-danger-color')
2026-01-16 15:56:53 +08:00
}
}),
borderRadius: 4,
barThickness: 20,
_meta: sortedData
}
]
2026-01-16 15:56:53 +08:00
}
})
2026-01-16 15:56:53 +08:00
const varianceChartOptions = computed(() => {
return getChartOptions({
indexAxis: 'y',
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
callbacks: {
label: (context) => {
const item = context.dataset._meta[context.dataIndex]
2026-02-20 13:56:29 +08:00
const isExpense = props.activeTab === BudgetCategory.Expense
let diffText
if (isExpense) {
diffText = item.value > 0
? `超支: ¥${formatMoney(item.value)}`
: `结余: ¥${formatMoney(Math.abs(item.value))}`
2026-02-20 13:56:29 +08:00
} else {
diffText = item.value > 0
? `超额: ¥${formatMoney(item.value)}`
: `未达标: ¥${formatMoney(Math.abs(item.value))}`
}
return [
`预算: ¥${formatMoney(item.limit)}`,
`实际: ¥${formatMoney(item.current)}`,
diffText
]
}
}
}
},
scales: {
x: {
display: false
},
y: {
grid: {
display: false
},
ticks: {
autoSkip: false,
font: {
family: '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif',
size: 11
},
callback: function (value, index) {
const label = this.getLabelForValue(index)
return label.length > 10 ? label.substring(0, 10) + '...' : label
}
}
}
}
})
})
2026-01-16 17:52:40 +08:00
// 月度燃尽图数据
const burndownChartData = computed(() => {
// 防御性检查
if (!props.overallStats || !props.overallStats.month || !props.selectedDate) {
return {
labels: [],
datasets: []
}
}
2026-01-21 18:52:31 +08:00
const refDate = props.selectedDate
const year = refDate.getFullYear()
const month = refDate.getMonth()
2026-01-16 17:52:40 +08:00
const daysInMonth = new Date(year, month + 1, 0).getDate()
2026-01-21 18:52:31 +08:00
const now = new Date()
const isCurrentMonth = now.getFullYear() === year && now.getMonth() === month
const isPastMonth =
now.getFullYear() > year || (now.getFullYear() === year && now.getMonth() > month)
const currentDay = isCurrentMonth ? now.getDate() : isPastMonth ? daysInMonth : 0
2026-01-16 23:18:04 +08:00
const isExpense = props.activeTab === BudgetCategory.Expense
2026-01-16 17:52:40 +08:00
const dates = []
const idealBurndown = []
const actualBurndown = []
const totalBudget = props.overallStats.month.limit || 0
const currentExpense = props.overallStats.month.current || 0
const trend = props.overallStats.month.trend || []
2026-01-16 17:52:40 +08:00
for (let i = 1; i <= daysInMonth; i++) {
dates.push(`${i}`)
2026-01-16 23:18:04 +08:00
if (isExpense) {
2026-02-01 10:27:04 +08:00
const idealRemaining = totalBudget * (1 - i / daysInMonth)
2026-01-16 23:18:04 +08:00
idealBurndown.push(Math.round(idealRemaining))
if (trend.length > 0) {
const dayValue = trend[i - 1]
if (dayValue !== undefined && dayValue !== null) {
2026-02-01 10:27:04 +08:00
const actualRemaining = totalBudget - dayValue
actualBurndown.push(Math.round(actualRemaining))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
} else {
if (i <= currentDay && totalBudget > 0) {
const actualRemaining = totalBudget - (currentExpense * i) / currentDay
actualBurndown.push(Math.round(actualRemaining))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
}
2026-01-16 17:52:40 +08:00
} else {
2026-01-16 23:18:04 +08:00
const idealAccumulated = Math.min(totalBudget, totalBudget * (i / daysInMonth))
idealBurndown.push(Math.round(idealAccumulated))
if (trend.length > 0) {
const dayValue = trend[i - 1]
if (dayValue !== undefined && dayValue !== null) {
actualBurndown.push(Math.round(dayValue))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
} else {
if (i <= currentDay && totalBudget > 0) {
const actualAccumulated = Math.min(totalBudget, (currentExpense * i) / currentDay)
actualBurndown.push(Math.round(actualAccumulated))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
}
2026-01-16 17:52:40 +08:00
}
}
2026-01-16 23:18:04 +08:00
const idealSeriesName = isExpense ? '理想燃尽' : '理想积累'
const actualSeriesName = isExpense ? '实际燃尽' : '实际积累'
return {
labels: dates,
datasets: [
2026-01-16 17:52:40 +08:00
{
label: idealSeriesName,
2026-01-16 17:52:40 +08:00
data: idealBurndown,
borderColor: getCssVar('--chart-warning'),
backgroundColor: getCssVar('--chart-warning'),
borderWidth: 2,
borderDash: [5, 5],
tension: 0,
pointRadius: 0
2026-01-16 17:52:40 +08:00
},
{
label: actualSeriesName,
2026-01-16 17:52:40 +08:00
data: actualBurndown,
borderColor: getCssVar('--chart-primary'),
backgroundColor: getCssVar('--chart-primary'),
borderWidth: 2,
tension: 0,
pointRadius: 0
2026-01-16 17:52:40 +08:00
}
]
}
})
2026-01-16 17:52:40 +08:00
const burndownChartOptions = computed(() => {
return getChartOptions({
plugins: {
legend: {
display: true,
position: 'top',
labels: {
usePointStyle: true,
pointStyle: 'line',
boxWidth: 20
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
callbacks: {
label: (context) => {
const value = context.parsed.y
if (value !== null && value !== undefined) {
return `${context.dataset.label}: ¥${formatMoney(value)}`
}
return ''
}
}
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
maxTicksLimit: 10,
font: {
family: '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif',
size: 10
}
}
},
y: {
ticks: {
callback: (value) => {
if (value >= 10000) {
return (value / 10000).toFixed(0) + 'w'
}
return value
},
font: {
size: 10
}
}
}
}
})
})
2026-01-16 17:52:40 +08:00
// 年度燃尽图数据
const yearBurndownChartData = computed(() => {
// 防御性检查
if (!props.overallStats || !props.overallStats.year || !props.selectedDate) {
return {
labels: [],
datasets: []
}
}
2026-01-21 18:52:31 +08:00
const refDate = props.selectedDate
const year = refDate.getFullYear()
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth()
2026-01-16 23:18:04 +08:00
const isExpense = props.activeTab === BudgetCategory.Expense
2026-01-16 17:52:40 +08:00
const months = []
const idealBurndown = []
const actualBurndown = []
const totalBudget = props.overallStats.year.limit || 0
const currentExpense = props.overallStats.year.current || 0
const trend = props.overallStats.year.trend || []
2026-01-16 17:52:40 +08:00
for (let i = 0; i < 12; i++) {
months.push(`${i + 1}`)
2026-01-16 23:18:04 +08:00
if (isExpense) {
2026-02-01 10:27:04 +08:00
const idealRemaining = totalBudget * (1 - (i + 1) / 12)
2026-01-16 23:18:04 +08:00
idealBurndown.push(Math.round(idealRemaining))
if (trend.length > 0) {
const monthValue = trend[i]
if (monthValue !== undefined && monthValue !== null) {
2026-02-01 10:27:04 +08:00
const actualRemaining = totalBudget - monthValue
actualBurndown.push(Math.round(actualRemaining))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
} else {
2026-01-21 18:52:31 +08:00
const isFuture = year > currentYear || (year === currentYear && i > currentMonth)
if (!isFuture && totalBudget > 0) {
const progress = (i + 1) / 12
const actualRemaining = totalBudget - currentExpense * progress
actualBurndown.push(Math.round(actualRemaining))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
}
2026-01-16 17:52:40 +08:00
} else {
2026-01-16 23:18:04 +08:00
const idealAccumulated = Math.min(totalBudget, totalBudget * ((i + 1) / 12))
idealBurndown.push(Math.round(idealAccumulated))
if (trend.length > 0) {
const monthValue = trend[i]
if (monthValue !== undefined && monthValue !== null) {
actualBurndown.push(Math.round(monthValue))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
} else {
2026-01-21 18:52:31 +08:00
const isFuture = year > currentYear || (year === currentYear && i > currentMonth)
if (!isFuture && totalBudget > 0) {
const progress = (i + 1) / 12
const actualAccumulated = Math.min(totalBudget, currentExpense * progress)
actualBurndown.push(Math.round(actualAccumulated))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
}
2026-01-16 17:52:40 +08:00
}
}
const idealSeriesName = isExpense ? '理想支出' : '理想收入'
const actualSeriesName = isExpense ? '实际支出' : '实际收入'
2026-01-16 23:18:04 +08:00
return {
labels: months,
datasets: [
2026-01-16 17:52:40 +08:00
{
label: idealSeriesName,
2026-01-16 17:52:40 +08:00
data: idealBurndown,
borderColor: getCssVar('--chart-warning'),
backgroundColor: getCssVar('--chart-warning'),
borderWidth: 2,
borderDash: [5, 5],
tension: 0,
pointRadius: 0
2026-01-16 17:52:40 +08:00
},
{
label: actualSeriesName,
2026-01-16 17:52:40 +08:00
data: actualBurndown,
borderColor: getCssVar('--chart-primary'),
backgroundColor: getCssVar('--chart-primary'),
borderWidth: 2,
tension: 0,
pointRadius: 0
2026-01-16 17:52:40 +08:00
}
]
}
})
const yearBurndownChartOptions = computed(() => {
return getChartOptions({
plugins: {
legend: {
display: true,
position: 'top',
labels: {
usePointStyle: true,
pointStyle: 'line',
boxWidth: 20
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
cornerRadius: 8,
callbacks: {
label: (context) => {
const value = context.parsed.y
if (value !== null && value !== undefined) {
return `${context.dataset.label}: ¥${formatMoney(value)}`
}
return ''
}
}
}
},
scales: {
x: {
grid: {
display: false
},
ticks: {
font: {
size: 10
}
}
},
y: {
ticks: {
callback: (value) => {
if (value >= 10000) {
return (value / 10000).toFixed(0) + 'w'
}
return value
},
font: {
size: 10
}
}
}
}
2026-01-16 15:56:53 +08:00
})
})
</script>
<style scoped>
.chart-analysis-container {
padding: var(--spacing-lg, 12px);
2026-01-16 15:56:53 +08:00
padding-bottom: 80px;
}
.gauges-row {
display: flex;
gap: var(--spacing-xl, 16px);
margin-bottom: var(--spacing-xl, 16px);
2026-01-16 15:56:53 +08:00
}
.chart-card {
background: var(--van-background-2);
border-radius: var(--radius-lg, 12px);
padding: var(--spacing-xl, 16px);
box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.04));
2026-01-16 15:56:53 +08:00
}
.gauge-card {
flex: 1;
min-width: 0;
/* 防止 flex 子项溢出 */
padding: var(--spacing-lg, 12px);
2026-01-16 15:56:53 +08:00
/* 减小内边距 */
}
.chart-card-spacing {
margin-top: var(--spacing-lg, 12px);
}
.popup-content-padding {
padding: var(--spacing-xl, 16px);
}
2026-01-21 16:09:38 +08:00
.gauge-wrapper {
position: relative;
width: 100%;
height: 120px;
display: flex;
justify-content: center;
align-items: center;
2026-01-21 16:09:38 +08:00
}
.gauge-text-overlay {
position: absolute;
top: 50%;
2026-01-21 16:09:38 +08:00
left: 50%;
transform: translate(-50%, -50%);
2026-01-21 16:09:38 +08:00
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 10;
margin-top: 8px;
2026-01-21 16:09:38 +08:00
}
.balance-label {
font-size: 11px;
color: var(--van-text-color-2);
letter-spacing: 1px;
2026-01-21 16:09:38 +08:00
}
.balance-value {
margin-top: 6px;
font-size: 24px;
font-weight: 700;
font-family:
DIN Alternate,
system-ui;
color: var(--van-text-color);
line-height: 1.1;
2026-01-21 16:09:38 +08:00
}
2026-01-16 15:56:53 +08:00
.chart-header {
margin-bottom: 12px;
}
.chart-title {
font-size: 14px;
/* 调小标题 */
font-weight: 600;
color: var(--van-text-color);
margin-bottom: 2px;
2026-02-20 13:56:29 +08:00
display: flex;
align-items: center;
gap: 8px;
}
.chart-title-text {
flex: 1;
2026-01-16 15:56:53 +08:00
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
2026-02-20 13:56:29 +08:00
min-width: 0;
}
.info-icon {
flex-shrink: 0;
cursor: pointer;
padding: 4px;
margin: -4px;
2026-01-16 15:56:53 +08:00
}
.chart-subtitle {
font-size: 10px;
color: var(--van-text-color-2);
}
.chart-body {
width: 100%;
}
.gauge-chart {
height: 120px;
/* 调小高度 */
}
.variance-chart {
2026-01-16 15:56:53 +08:00
min-height: 200px;
}
2026-01-16 17:52:40 +08:00
.burndown-chart {
2026-01-25 13:27:55 +08:00
height: 190px;
2026-01-16 17:52:40 +08:00
}
2026-01-16 15:56:53 +08:00
.gauge-footer {
display: flex;
justify-content: space-between;
2026-01-16 15:56:53 +08:00
align-items: center;
padding: 0 8px 6px;
margin-top: -6px;
2026-01-16 15:56:53 +08:00
}
.gauge-item {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
2026-01-16 15:56:53 +08:00
}
.gauge-item .label {
font-size: 10px;
color: var(--van-text-color-2);
margin-bottom: 4px;
2026-01-16 15:56:53 +08:00
}
.gauge-item .value {
font-size: 12px;
font-weight: 600;
font-family:
DIN Alternate,
system-ui;
2026-01-16 15:56:53 +08:00
color: var(--van-text-color);
}
2026-01-20 19:56:29 +08:00
/* expand styles removed as they are no longer used */
</style>