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">
|
2026-02-16 21:55:38 +08:00
|
|
|
|
<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">
|
2026-02-18 20:44:58 +08:00
|
|
|
|
<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
|
2026-02-18 20:44:58 +08:00
|
|
|
|
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>
|
2026-02-18 20:44:58 +08:00
|
|
|
|
<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>
|
2026-02-18 20:44:58 +08:00
|
|
|
|
<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">
|
2026-02-16 21:55:38 +08:00
|
|
|
|
<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">
|
2026-02-18 20:44:58 +08:00
|
|
|
|
<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
|
2026-02-18 20:44:58 +08:00
|
|
|
|
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-02-18 20:44:58 +08:00
|
|
|
|
}"
|
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>
|
2026-02-18 20:44:58 +08:00
|
|
|
|
<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>
|
2026-02-18 20:44:58 +08:00
|
|
|
|
<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>
|
2026-02-16 21:55:38 +08:00
|
|
|
|
<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"
|
2026-02-18 20:44:58 +08:00
|
|
|
|
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>
|
2026-02-16 21:55:38 +08:00
|
|
|
|
<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"
|
2026-02-18 20:44:58 +08:00
|
|
|
|
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>
|
2026-02-16 21:55:38 +08:00
|
|
|
|
<BaseChart
|
|
|
|
|
|
type="bar"
|
|
|
|
|
|
:data="varianceChartData"
|
|
|
|
|
|
:options="varianceChartOptions"
|
2026-02-19 21:34:55 +08:00
|
|
|
|
:plugins="varianceChartPlugins"
|
2026-01-17 14:38:40 +08:00
|
|
|
|
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
|
2026-02-18 20:44:58 +08:00
|
|
|
|
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'
|
2026-02-16 21:55:38 +08:00
|
|
|
|
import BaseChart from '@/components/Charts/BaseChart.vue'
|
|
|
|
|
|
import { useChartTheme } from '@/composables/useChartTheme'
|
|
|
|
|
|
import { chartjsGaugePlugin } from '@/plugins/chartjs-gauge-plugin'
|
2026-02-18 20:44:58 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
// Chart.js 相关
|
2026-02-18 22:19:25 +08:00
|
|
|
|
const { getChartOptions } = useChartTheme()
|
2026-01-17 14:38:40 +08:00
|
|
|
|
|
2026-01-16 15:56:53 +08:00
|
|
|
|
const formatMoney = (val) => {
|
2026-01-17 14:38:40 +08:00
|
|
|
|
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
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
// 月度仪表盘数据
|
|
|
|
|
|
const monthGaugeData = computed(() => {
|
2026-02-16 22:04:10 +08:00
|
|
|
|
// 防御性检查:如果数据未加载,返回默认结构
|
|
|
|
|
|
if (!props.overallStats || !props.overallStats.month) {
|
|
|
|
|
|
return {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
datasets: [
|
|
|
|
|
|
{
|
|
|
|
|
|
data: [0, 100],
|
|
|
|
|
|
backgroundColor: [getCssVar('--chart-axis'), getCssVar('--chart-axis')],
|
|
|
|
|
|
borderWidth: 0,
|
|
|
|
|
|
circumference: 180,
|
|
|
|
|
|
rotation: 270
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
2026-02-16 22:04:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
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) {
|
2026-02-16 21:55:38 +08:00
|
|
|
|
color = getCssVar('--chart-danger')
|
2026-02-01 10:27:04 +08:00
|
|
|
|
} else if (displayRate <= 30) {
|
2026-02-16 21:55:38 +08:00
|
|
|
|
color = getCssVar('--chart-danger')
|
2026-02-01 10:27:04 +08:00
|
|
|
|
} else if (displayRate <= 65) {
|
2026-02-16 21:55:38 +08:00
|
|
|
|
color = getCssVar('--chart-warning')
|
2026-02-01 10:27:04 +08:00
|
|
|
|
} else {
|
2026-02-16 21:55:38 +08:00
|
|
|
|
color = getCssVar('--chart-success')
|
2026-02-01 10:27:04 +08:00
|
|
|
|
}
|
2026-01-18 13:06:16 +08:00
|
|
|
|
} else {
|
2026-02-16 21:55:38 +08:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
const maxValue = isExpense && rate > 100 ? 50 : 100
|
|
|
|
|
|
const remaining = 100 - displayRate
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
datasets: [
|
2026-01-16 15:56:53 +08:00
|
|
|
|
{
|
2026-02-16 21:55:38 +08:00
|
|
|
|
data: [displayRate, remaining],
|
|
|
|
|
|
backgroundColor: [color, getCssVar('--chart-axis')],
|
|
|
|
|
|
borderWidth: 0,
|
2026-02-18 20:44:58 +08:00
|
|
|
|
circumference: 220,
|
|
|
|
|
|
rotation: 250
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
2026-02-16 21:55:38 +08:00
|
|
|
|
})
|
2026-01-16 15:56:53 +08:00
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
const monthGaugeOptions = computed(() => {
|
|
|
|
|
|
return {
|
|
|
|
|
|
cutout: '75%',
|
|
|
|
|
|
responsive: true,
|
2026-02-18 20:44:58 +08:00
|
|
|
|
maintainAspectRatio: false,
|
2026-02-16 21:55:38 +08:00
|
|
|
|
plugins: {
|
|
|
|
|
|
legend: {
|
|
|
|
|
|
display: false
|
|
|
|
|
|
},
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
enabled: false
|
2026-02-18 20:44:58 +08:00
|
|
|
|
},
|
|
|
|
|
|
datalabels: {
|
|
|
|
|
|
display: false
|
|
|
|
|
|
},
|
|
|
|
|
|
gaugePlugin: {
|
|
|
|
|
|
centerText: false
|
2026-02-16 21:55:38 +08:00
|
|
|
|
}
|
2026-02-18 20:44:58 +08:00
|
|
|
|
},
|
|
|
|
|
|
scales: {
|
|
|
|
|
|
x: { display: false },
|
|
|
|
|
|
y: { display: false }
|
2026-02-16 21:55:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-01-16 15:56:53 +08:00
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
// 年度仪表盘数据
|
|
|
|
|
|
const yearGaugeData = computed(() => {
|
2026-02-16 22:04:10 +08:00
|
|
|
|
// 防御性检查:如果数据未加载,返回默认结构
|
|
|
|
|
|
if (!props.overallStats || !props.overallStats.year) {
|
|
|
|
|
|
return {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
datasets: [
|
|
|
|
|
|
{
|
|
|
|
|
|
data: [0, 100],
|
|
|
|
|
|
backgroundColor: [getCssVar('--chart-axis'), getCssVar('--chart-axis')],
|
|
|
|
|
|
borderWidth: 0,
|
|
|
|
|
|
circumference: 180,
|
|
|
|
|
|
rotation: 270
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
2026-02-16 22:04:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
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
|
|
|
|
|
2026-02-16 21:55:38 +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
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 21:55:38 +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
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
const remaining = 100 - displayRate
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
return {
|
|
|
|
|
|
datasets: [
|
|
|
|
|
|
{
|
|
|
|
|
|
data: [displayRate, remaining],
|
|
|
|
|
|
backgroundColor: [color, getCssVar('--chart-axis')],
|
|
|
|
|
|
borderWidth: 0,
|
2026-02-18 20:44:58 +08:00
|
|
|
|
circumference: 220,
|
|
|
|
|
|
rotation: 250
|
2026-01-21 18:52:31 +08:00
|
|
|
|
}
|
2026-02-16 21:55:38 +08:00
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
const yearGaugeOptions = computed(() => {
|
|
|
|
|
|
return {
|
|
|
|
|
|
cutout: '75%',
|
|
|
|
|
|
responsive: true,
|
2026-02-18 20:44:58 +08:00
|
|
|
|
maintainAspectRatio: false,
|
2026-02-16 21:55:38 +08:00
|
|
|
|
plugins: {
|
|
|
|
|
|
legend: {
|
|
|
|
|
|
display: false
|
|
|
|
|
|
},
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
enabled: false
|
2026-02-18 20:44:58 +08:00
|
|
|
|
},
|
|
|
|
|
|
datalabels: {
|
|
|
|
|
|
display: false
|
|
|
|
|
|
},
|
|
|
|
|
|
gaugePlugin: {
|
|
|
|
|
|
centerText: false
|
2026-01-21 18:52:31 +08:00
|
|
|
|
}
|
2026-02-18 20:44:58 +08:00
|
|
|
|
},
|
|
|
|
|
|
scales: {
|
|
|
|
|
|
x: { display: false },
|
|
|
|
|
|
y: { display: false }
|
2026-02-16 21:55:38 +08:00
|
|
|
|
}
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
2026-02-16 21:55:38 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const calculateChartHeight = (budgets) => {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
if (!budgets) {
|
|
|
|
|
|
return 100
|
|
|
|
|
|
}
|
2026-02-16 21:55:38 +08:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
// 偏差分析图表数据
|
|
|
|
|
|
const varianceChartData = computed(() => {
|
|
|
|
|
|
if (!props.budgets || props.budgets.length === 0) {
|
|
|
|
|
|
return { labels: [], datasets: [] }
|
|
|
|
|
|
}
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
2026-02-18 20:44:58 +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
|
2026-01-17 14:38:40 +08:00
|
|
|
|
const diff = current - limit
|
|
|
|
|
|
return {
|
2026-01-25 13:22:51 +08:00
|
|
|
|
name: b.name + (b.type === BudgetPeriodType.Year ? ' (年)' : ''),
|
2026-01-17 14:38:40 +08:00
|
|
|
|
value: diff,
|
|
|
|
|
|
limit: limit,
|
2026-01-25 13:22:51 +08:00
|
|
|
|
current: current,
|
|
|
|
|
|
type: b.type
|
2026-01-17 14:38:40 +08:00
|
|
|
|
}
|
2026-01-16 17:52:40 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-18 20:44:58 +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
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
return {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
labels: sortedData.map((item) => item.name),
|
2026-02-16 21:55:38 +08:00
|
|
|
|
datasets: [
|
2026-01-17 14:38:40 +08:00
|
|
|
|
{
|
2026-02-16 21:55:38 +08:00
|
|
|
|
label: '偏差',
|
2026-02-18 20:44:58 +08:00
|
|
|
|
data: sortedData.map((item) => item.value),
|
|
|
|
|
|
backgroundColor: sortedData.map((item) => {
|
2026-02-16 21:55:38 +08:00
|
|
|
|
if (props.activeTab === BudgetCategory.Expense) {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
return item.value > 0
|
|
|
|
|
|
? getCssVar('--van-danger-color')
|
|
|
|
|
|
: getCssVar('--van-success-color')
|
2026-01-17 15:03:19 +08:00
|
|
|
|
} else {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
return item.value > 0
|
|
|
|
|
|
? getCssVar('--van-success-color')
|
|
|
|
|
|
: getCssVar('--van-danger-color')
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
2026-01-17 14:38:40 +08:00
|
|
|
|
}),
|
2026-02-16 21:55:38 +08:00
|
|
|
|
borderRadius: 4,
|
|
|
|
|
|
barThickness: 20,
|
|
|
|
|
|
_meta: sortedData
|
2026-01-17 14:38:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
]
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
2026-02-16 21:55:38 +08:00
|
|
|
|
})
|
2026-01-16 15:56:53 +08:00
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
const varianceChartOptions = computed(() => {
|
2026-02-18 22:19:25 +08:00
|
|
|
|
return getChartOptions({
|
2026-02-16 21:55:38 +08:00
|
|
|
|
indexAxis: 'y',
|
|
|
|
|
|
plugins: {
|
|
|
|
|
|
legend: {
|
|
|
|
|
|
display: false
|
|
|
|
|
|
},
|
|
|
|
|
|
tooltip: {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
|
|
|
|
padding: 12,
|
|
|
|
|
|
cornerRadius: 8,
|
2026-02-16 21:55:38 +08:00
|
|
|
|
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
|
2026-02-18 20:44:58 +08:00
|
|
|
|
? `超支: ¥${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))}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
return [
|
|
|
|
|
|
`预算: ¥${formatMoney(item.limit)}`,
|
|
|
|
|
|
`实际: ¥${formatMoney(item.current)}`,
|
|
|
|
|
|
diffText
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
scales: {
|
|
|
|
|
|
x: {
|
|
|
|
|
|
display: false
|
|
|
|
|
|
},
|
|
|
|
|
|
y: {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
grid: {
|
|
|
|
|
|
display: false
|
|
|
|
|
|
},
|
2026-02-16 21:55:38 +08:00
|
|
|
|
ticks: {
|
|
|
|
|
|
autoSkip: false,
|
2026-02-18 20:44:58 +08:00
|
|
|
|
font: {
|
|
|
|
|
|
family: '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif',
|
|
|
|
|
|
size: 11
|
|
|
|
|
|
},
|
2026-02-16 21:55:38 +08:00
|
|
|
|
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
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
// 月度燃尽图数据
|
|
|
|
|
|
const burndownChartData = computed(() => {
|
2026-02-16 22:04:10 +08:00
|
|
|
|
// 防御性检查
|
|
|
|
|
|
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
|
2026-01-17 14:38:40 +08:00
|
|
|
|
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-17 14:38:40 +08:00
|
|
|
|
|
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))
|
|
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
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
|
2026-01-17 14:38:40 +08:00
|
|
|
|
actualBurndown.push(Math.round(actualRemaining))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
actualBurndown.push(null)
|
|
|
|
|
|
}
|
2026-01-16 23:18:04 +08:00
|
|
|
|
} else {
|
2026-01-17 14:38:40 +08:00
|
|
|
|
if (i <= currentDay && totalBudget > 0) {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
const actualRemaining = totalBudget - (currentExpense * i) / currentDay
|
2026-01-17 14:38:40 +08:00
|
|
|
|
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))
|
|
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
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 {
|
2026-01-17 14:38:40 +08:00
|
|
|
|
if (i <= currentDay && totalBudget > 0) {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
const actualAccumulated = Math.min(totalBudget, (currentExpense * i) / currentDay)
|
2026-01-17 14:38:40 +08:00
|
|
|
|
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 ? '实际燃尽' : '实际积累'
|
|
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
return {
|
|
|
|
|
|
labels: dates,
|
|
|
|
|
|
datasets: [
|
2026-01-16 17:52:40 +08:00
|
|
|
|
{
|
2026-02-16 21:55:38 +08:00
|
|
|
|
label: idealSeriesName,
|
2026-01-16 17:52:40 +08:00
|
|
|
|
data: idealBurndown,
|
2026-02-16 21:55:38 +08:00
|
|
|
|
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
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-02-16 21:55:38 +08:00
|
|
|
|
label: actualSeriesName,
|
2026-01-16 17:52:40 +08:00
|
|
|
|
data: actualBurndown,
|
2026-02-16 21:55:38 +08:00
|
|
|
|
borderColor: getCssVar('--chart-primary'),
|
|
|
|
|
|
backgroundColor: getCssVar('--chart-primary'),
|
|
|
|
|
|
borderWidth: 2,
|
|
|
|
|
|
tension: 0,
|
|
|
|
|
|
pointRadius: 0
|
2026-01-16 17:52:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
2026-02-16 21:55:38 +08:00
|
|
|
|
})
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
const burndownChartOptions = computed(() => {
|
2026-02-18 22:19:25 +08:00
|
|
|
|
return getChartOptions({
|
2026-02-16 21:55:38 +08:00
|
|
|
|
plugins: {
|
|
|
|
|
|
legend: {
|
|
|
|
|
|
display: true,
|
2026-02-18 20:44:58 +08:00
|
|
|
|
position: 'top',
|
|
|
|
|
|
labels: {
|
|
|
|
|
|
usePointStyle: true,
|
|
|
|
|
|
pointStyle: 'line',
|
|
|
|
|
|
boxWidth: 20
|
|
|
|
|
|
}
|
2026-02-16 21:55:38 +08:00
|
|
|
|
},
|
|
|
|
|
|
tooltip: {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
|
|
|
|
padding: 12,
|
|
|
|
|
|
cornerRadius: 8,
|
2026-02-16 21:55:38 +08:00
|
|
|
|
callbacks: {
|
|
|
|
|
|
label: (context) => {
|
|
|
|
|
|
const value = context.parsed.y
|
|
|
|
|
|
if (value !== null && value !== undefined) {
|
|
|
|
|
|
return `${context.dataset.label}: ¥${formatMoney(value)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
scales: {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
x: {
|
|
|
|
|
|
grid: {
|
|
|
|
|
|
display: false
|
|
|
|
|
|
},
|
|
|
|
|
|
ticks: {
|
|
|
|
|
|
maxTicksLimit: 10,
|
|
|
|
|
|
font: {
|
|
|
|
|
|
family: '"PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif',
|
|
|
|
|
|
size: 10
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-02-16 21:55:38 +08:00
|
|
|
|
y: {
|
|
|
|
|
|
ticks: {
|
|
|
|
|
|
callback: (value) => {
|
|
|
|
|
|
if (value >= 10000) {
|
|
|
|
|
|
return (value / 10000).toFixed(0) + 'w'
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
2026-02-18 20:44:58 +08:00
|
|
|
|
},
|
|
|
|
|
|
font: {
|
|
|
|
|
|
size: 10
|
2026-02-16 21:55:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2026-01-16 17:52:40 +08:00
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
// 年度燃尽图数据
|
|
|
|
|
|
const yearBurndownChartData = computed(() => {
|
2026-02-16 22:04:10 +08:00
|
|
|
|
// 防御性检查
|
|
|
|
|
|
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
|
2026-01-17 14:38:40 +08:00
|
|
|
|
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))
|
|
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
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
|
2026-01-17 14:38:40 +08:00
|
|
|
|
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) {
|
2026-02-16 21:55:38 +08:00
|
|
|
|
const progress = (i + 1) / 12
|
2026-02-18 20:44:58 +08:00
|
|
|
|
const actualRemaining = totalBudget - currentExpense * progress
|
2026-01-17 14:38:40 +08:00
|
|
|
|
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))
|
|
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
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) {
|
2026-02-16 21:55:38 +08:00
|
|
|
|
const progress = (i + 1) / 12
|
|
|
|
|
|
const actualAccumulated = Math.min(totalBudget, currentExpense * progress)
|
2026-01-17 14:38:40 +08:00
|
|
|
|
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-17 14:38:40 +08:00
|
|
|
|
const idealSeriesName = isExpense ? '理想支出' : '理想收入'
|
|
|
|
|
|
const actualSeriesName = isExpense ? '实际支出' : '实际收入'
|
2026-01-16 23:18:04 +08:00
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
return {
|
|
|
|
|
|
labels: months,
|
|
|
|
|
|
datasets: [
|
2026-01-16 17:52:40 +08:00
|
|
|
|
{
|
2026-02-16 21:55:38 +08:00
|
|
|
|
label: idealSeriesName,
|
2026-01-16 17:52:40 +08:00
|
|
|
|
data: idealBurndown,
|
2026-02-16 21:55:38 +08:00
|
|
|
|
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
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-02-16 21:55:38 +08:00
|
|
|
|
label: actualSeriesName,
|
2026-01-16 17:52:40 +08:00
|
|
|
|
data: actualBurndown,
|
2026-02-16 21:55:38 +08:00
|
|
|
|
borderColor: getCssVar('--chart-primary'),
|
|
|
|
|
|
backgroundColor: getCssVar('--chart-primary'),
|
|
|
|
|
|
borderWidth: 2,
|
|
|
|
|
|
tension: 0,
|
|
|
|
|
|
pointRadius: 0
|
2026-01-16 17:52:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-16 21:55:38 +08:00
|
|
|
|
const yearBurndownChartOptions = computed(() => {
|
2026-02-18 22:19:25 +08:00
|
|
|
|
return getChartOptions({
|
2026-02-16 21:55:38 +08:00
|
|
|
|
plugins: {
|
|
|
|
|
|
legend: {
|
|
|
|
|
|
display: true,
|
2026-02-18 20:44:58 +08:00
|
|
|
|
position: 'top',
|
|
|
|
|
|
labels: {
|
|
|
|
|
|
usePointStyle: true,
|
|
|
|
|
|
pointStyle: 'line',
|
|
|
|
|
|
boxWidth: 20
|
|
|
|
|
|
}
|
2026-02-16 21:55:38 +08:00
|
|
|
|
},
|
|
|
|
|
|
tooltip: {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
|
|
|
|
padding: 12,
|
|
|
|
|
|
cornerRadius: 8,
|
2026-02-16 21:55:38 +08:00
|
|
|
|
callbacks: {
|
|
|
|
|
|
label: (context) => {
|
|
|
|
|
|
const value = context.parsed.y
|
|
|
|
|
|
if (value !== null && value !== undefined) {
|
|
|
|
|
|
return `${context.dataset.label}: ¥${formatMoney(value)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
scales: {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
x: {
|
|
|
|
|
|
grid: {
|
|
|
|
|
|
display: false
|
|
|
|
|
|
},
|
|
|
|
|
|
ticks: {
|
|
|
|
|
|
font: {
|
|
|
|
|
|
size: 10
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-02-16 21:55:38 +08:00
|
|
|
|
y: {
|
|
|
|
|
|
ticks: {
|
|
|
|
|
|
callback: (value) => {
|
|
|
|
|
|
if (value >= 10000) {
|
|
|
|
|
|
return (value / 10000).toFixed(0) + 'w'
|
|
|
|
|
|
}
|
|
|
|
|
|
return value
|
2026-02-18 20:44:58 +08:00
|
|
|
|
},
|
|
|
|
|
|
font: {
|
|
|
|
|
|
size: 10
|
2026-02-16 21:55:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-16 15:56:53 +08:00
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.chart-analysis-container {
|
2026-02-18 20:44:58 +08:00
|
|
|
|
padding: var(--spacing-lg, 12px);
|
2026-01-16 15:56:53 +08:00
|
|
|
|
padding-bottom: 80px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauges-row {
|
|
|
|
|
|
display: flex;
|
2026-02-18 20:44:58 +08:00
|
|
|
|
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);
|
2026-02-18 20:44:58 +08:00
|
|
|
|
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 子项溢出 */
|
2026-02-18 20:44:58 +08:00
|
|
|
|
padding: var(--spacing-lg, 12px);
|
2026-01-16 15:56:53 +08:00
|
|
|
|
/* 减小内边距 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 20:44:58 +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;
|
2026-02-18 20:44:58 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
align-items: center;
|
2026-01-21 16:09:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-text-overlay {
|
|
|
|
|
|
position: absolute;
|
2026-02-18 20:44:58 +08:00
|
|
|
|
top: 50%;
|
2026-01-21 16:09:38 +08:00
|
|
|
|
left: 50%;
|
2026-02-18 20:44:58 +08:00
|
|
|
|
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;
|
2026-02-18 20:44:58 +08:00
|
|
|
|
margin-top: 8px;
|
2026-01-21 16:09:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 20:44:58 +08:00
|
|
|
|
.balance-label {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
color: var(--van-text-color-2);
|
|
|
|
|
|
letter-spacing: 1px;
|
2026-01-21 16:09:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-18 20:44:58 +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;
|
|
|
|
|
|
/* 调小高度 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
.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;
|
2026-02-18 20:44:58 +08:00
|
|
|
|
justify-content: space-between;
|
2026-01-16 15:56:53 +08:00
|
|
|
|
align-items: center;
|
2026-02-18 20:44:58 +08:00
|
|
|
|
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;
|
2026-02-18 20:44:58 +08:00
|
|
|
|
min-width: 0;
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-item .label {
|
|
|
|
|
|
font-size: 10px;
|
|
|
|
|
|
color: var(--van-text-color-2);
|
2026-02-18 20:44:58 +08:00
|
|
|
|
margin-bottom: 4px;
|
2026-01-16 15:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.gauge-item .value {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
font-weight: 600;
|
2026-02-18 20:44:58 +08:00
|
|
|
|
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>
|