All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 18s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 0s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
1132 lines
29 KiB
Vue
1132 lines
29 KiB
Vue
<template>
|
||
<div class="chart-analysis-container">
|
||
<!-- 仪表盘:整体健康度 -->
|
||
<div class="gauges-row">
|
||
<!-- 月度仪表盘 -->
|
||
<div class="chart-card gauge-card">
|
||
<div class="chart-header">
|
||
<div class="chart-title">
|
||
<span class="chart-title-text">
|
||
{{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
|
||
</span>
|
||
<van-icon
|
||
name="info-o"
|
||
size="16"
|
||
color="var(--van-primary-color)"
|
||
class="info-icon"
|
||
@click="handleShowDescription('month')"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="gauge-wrapper">
|
||
<BaseChart
|
||
type="doughnut"
|
||
:data="monthGaugeData"
|
||
:options="monthGaugeOptions"
|
||
:plugins="[chartjsGaugePlugin]"
|
||
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
|
||
/>
|
||
<div class="gauge-text-overlay">
|
||
<div class="balance-label">
|
||
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
|
||
</div>
|
||
<div
|
||
class="balance-value"
|
||
:style="{
|
||
color:
|
||
activeTab === BudgetCategory.Expense
|
||
? (overallStats.month.current > overallStats.month.limit ? 'var(--van-danger-color)' : '')
|
||
: (overallStats.month.current < overallStats.month.limit ? 'var(--van-danger-color)' : '')
|
||
}"
|
||
>
|
||
¥{{ formatMoney(Math.abs(overallStats.month.limit - overallStats.month.current)) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="gauge-footer">
|
||
<div class="gauge-item">
|
||
<span class="label">{{ activeTab === BudgetCategory.Expense ? '已用' : '已获得' }}</span>
|
||
<span class="value">¥{{ formatMoney(overallStats.month.current) }}</span>
|
||
</div>
|
||
<div class="gauge-item">
|
||
<span class="label">{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}</span>
|
||
<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">
|
||
<span class="chart-title-text">
|
||
{{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
|
||
</span>
|
||
<van-icon
|
||
name="info-o"
|
||
size="16"
|
||
color="var(--van-primary-color)"
|
||
class="info-icon"
|
||
@click="handleShowDescription('year')"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="gauge-wrapper">
|
||
<BaseChart
|
||
type="doughnut"
|
||
:data="yearGaugeData"
|
||
:options="yearGaugeOptions"
|
||
:plugins="[chartjsGaugePlugin]"
|
||
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
|
||
/>
|
||
<div class="gauge-text-overlay">
|
||
<div class="balance-label">
|
||
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
|
||
</div>
|
||
<div
|
||
class="balance-value"
|
||
:style="{
|
||
color:
|
||
activeTab === BudgetCategory.Expense
|
||
? (overallStats.year.current > overallStats.year.limit ? 'var(--van-danger-color)' : '')
|
||
: (overallStats.year.current < overallStats.year.limit ? 'var(--van-danger-color)' : '')
|
||
}"
|
||
>
|
||
¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="gauge-footer">
|
||
<div class="gauge-item">
|
||
<span class="label">{{ activeTab === BudgetCategory.Expense ? '已用' : '已获得' }}</span>
|
||
<span class="value">¥{{ formatMoney(overallStats.year.current) }}</span>
|
||
</div>
|
||
<div class="gauge-item">
|
||
<span class="label">{{ activeTab === BudgetCategory.Expense ? '预算' : '目标' }}</span>
|
||
<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">
|
||
{{ activeTab === BudgetCategory.Expense ? '预算剩余消耗趋势' : '收入积累趋势' }}
|
||
</div>
|
||
</div>
|
||
<BaseChart
|
||
type="line"
|
||
:data="burndownChartData"
|
||
:options="burndownChartOptions"
|
||
class="chart-body burndown-chart"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 年度预算进度 -->
|
||
<div
|
||
v-if="budgets.length > 0"
|
||
class="chart-card chart-card-spacing"
|
||
>
|
||
<div class="chart-header">
|
||
<div class="chart-title">
|
||
预算进度(年度)
|
||
</div>
|
||
<div class="chart-subtitle">
|
||
本年各预算执行情况
|
||
</div>
|
||
</div>
|
||
<BaseChart
|
||
type="line"
|
||
:data="yearBurndownChartData"
|
||
:options="yearBurndownChartOptions"
|
||
class="chart-body burndown-chart"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 偏差分析 -->
|
||
<div
|
||
v-if="budgets.length > 0"
|
||
class="chart-card chart-card-spacing"
|
||
>
|
||
<div class="chart-header">
|
||
<div class="chart-title">
|
||
偏差分析
|
||
</div>
|
||
<div class="chart-subtitle">
|
||
预算执行偏差排行
|
||
</div>
|
||
</div>
|
||
<BaseChart
|
||
type="bar"
|
||
:data="varianceChartData"
|
||
:options="varianceChartOptions"
|
||
:plugins="varianceChartPlugins"
|
||
class="chart-body variance-chart"
|
||
:style="{ height: calculateChartHeight(budgets) + 'px' }"
|
||
/>
|
||
</div>
|
||
|
||
<!-- 空状态占位 -->
|
||
<div
|
||
v-else-if="budgets.length === 0"
|
||
class="chart-card empty-card"
|
||
>
|
||
<van-empty
|
||
description="暂无预算数据"
|
||
image="search"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 详细描述弹窗 -->
|
||
<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>'
|
||
"
|
||
/>
|
||
</PopupContainer>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed } from 'vue'
|
||
import { BudgetCategory, BudgetPeriodType } from '@/constants/enums'
|
||
import { getCssVar } from '@/utils/theme'
|
||
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)
|
||
|
||
const props = defineProps({
|
||
overallStats: {
|
||
type: Object,
|
||
required: true
|
||
},
|
||
budgets: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
activeTab: {
|
||
type: [Number, String],
|
||
default: BudgetCategory.Expense
|
||
},
|
||
selectedDate: {
|
||
type: Date,
|
||
default: () => new Date()
|
||
}
|
||
})
|
||
|
||
// 弹窗状态
|
||
const showDescriptionPopup = ref(false)
|
||
const activeDescTab = ref('month')
|
||
|
||
// 显示描述弹窗
|
||
const handleShowDescription = (tab) => {
|
||
activeDescTab.value = tab
|
||
showDescriptionPopup.value = true
|
||
}
|
||
|
||
// Chart.js 相关
|
||
const { getChartOptions } = useChartTheme()
|
||
|
||
const formatMoney = (val) => {
|
||
if (Math.abs(val) >= 10000) {
|
||
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
|
||
|
||
let displayRate
|
||
if (isExpense) {
|
||
displayRate = Math.max(0, 100 - rate)
|
||
if (rate > 100) {
|
||
displayRate = rate - 100
|
||
}
|
||
} else {
|
||
displayRate = rate
|
||
}
|
||
|
||
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')
|
||
}
|
||
}
|
||
|
||
const maxValue = isExpense && rate > 100 ? 50 : 100
|
||
const remaining = 100 - displayRate
|
||
|
||
return {
|
||
datasets: [
|
||
{
|
||
data: [displayRate, remaining],
|
||
backgroundColor: [color, getCssVar('--chart-axis')],
|
||
borderWidth: 0,
|
||
circumference: 220,
|
||
rotation: 250
|
||
}
|
||
]
|
||
}
|
||
})
|
||
|
||
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 }
|
||
}
|
||
}
|
||
})
|
||
|
||
// 年度仪表盘数据
|
||
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)
|
||
const isExpense = props.activeTab === BudgetCategory.Expense
|
||
|
||
let displayRate
|
||
if (isExpense) {
|
||
displayRate = Math.max(0, 100 - rate)
|
||
if (rate > 100) {
|
||
displayRate = rate - 100
|
||
}
|
||
} else {
|
||
displayRate = rate
|
||
}
|
||
|
||
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')
|
||
}
|
||
}
|
||
|
||
const remaining = 100 - displayRate
|
||
|
||
return {
|
||
datasets: [
|
||
{
|
||
data: [displayRate, remaining],
|
||
backgroundColor: [color, getCssVar('--chart-axis')],
|
||
borderWidth: 0,
|
||
circumference: 220,
|
||
rotation: 250
|
||
}
|
||
]
|
||
}
|
||
})
|
||
|
||
const yearGaugeOptions = 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 }
|
||
}
|
||
}
|
||
})
|
||
|
||
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
|
||
}
|
||
|
||
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: [] }
|
||
}
|
||
|
||
const data = props.budgets.map((b) => {
|
||
const limit = b.limit || 0
|
||
const current = b.current || 0
|
||
const diff = current - limit
|
||
return {
|
||
name: b.name + (b.type === BudgetPeriodType.Year ? ' (年)' : ''),
|
||
value: diff,
|
||
limit: limit,
|
||
current: current,
|
||
type: b.type
|
||
}
|
||
})
|
||
|
||
const monthlyData = data.filter((item) => item.type === BudgetPeriodType.Month)
|
||
const annualData = data.filter((item) => item.type === BudgetPeriodType.Year)
|
||
|
||
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)
|
||
|
||
const sortedData = [...monthlyData, ...annualData]
|
||
|
||
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')
|
||
} else {
|
||
return item.value > 0
|
||
? getCssVar('--van-success-color')
|
||
: getCssVar('--van-danger-color')
|
||
}
|
||
}),
|
||
borderRadius: 4,
|
||
barThickness: 20,
|
||
_meta: sortedData
|
||
}
|
||
]
|
||
}
|
||
})
|
||
|
||
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]
|
||
const isExpense = props.activeTab === BudgetCategory.Expense
|
||
|
||
let diffText
|
||
if (isExpense) {
|
||
diffText = item.value > 0
|
||
? `超支: ¥${formatMoney(item.value)}`
|
||
: `结余: ¥${formatMoney(Math.abs(item.value))}`
|
||
} 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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
// 月度燃尽图数据
|
||
const burndownChartData = computed(() => {
|
||
// 防御性检查
|
||
if (!props.overallStats || !props.overallStats.month || !props.selectedDate) {
|
||
return {
|
||
labels: [],
|
||
datasets: []
|
||
}
|
||
}
|
||
|
||
const refDate = props.selectedDate
|
||
const year = refDate.getFullYear()
|
||
const month = refDate.getMonth()
|
||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||
|
||
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
|
||
const isExpense = props.activeTab === BudgetCategory.Expense
|
||
|
||
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 || []
|
||
|
||
for (let i = 1; i <= daysInMonth; i++) {
|
||
dates.push(`${i}日`)
|
||
|
||
if (isExpense) {
|
||
const idealRemaining = totalBudget * (1 - i / daysInMonth)
|
||
idealBurndown.push(Math.round(idealRemaining))
|
||
|
||
if (trend.length > 0) {
|
||
const dayValue = trend[i - 1]
|
||
if (dayValue !== undefined && dayValue !== null) {
|
||
const actualRemaining = totalBudget - dayValue
|
||
actualBurndown.push(Math.round(actualRemaining))
|
||
} else {
|
||
actualBurndown.push(null)
|
||
}
|
||
} else {
|
||
if (i <= currentDay && totalBudget > 0) {
|
||
const actualRemaining = totalBudget - (currentExpense * i) / currentDay
|
||
actualBurndown.push(Math.round(actualRemaining))
|
||
} else {
|
||
actualBurndown.push(null)
|
||
}
|
||
}
|
||
} else {
|
||
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)
|
||
}
|
||
} else {
|
||
if (i <= currentDay && totalBudget > 0) {
|
||
const actualAccumulated = Math.min(totalBudget, (currentExpense * i) / currentDay)
|
||
actualBurndown.push(Math.round(actualAccumulated))
|
||
} else {
|
||
actualBurndown.push(null)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const idealSeriesName = isExpense ? '理想燃尽' : '理想积累'
|
||
const actualSeriesName = isExpense ? '实际燃尽' : '实际积累'
|
||
|
||
return {
|
||
labels: dates,
|
||
datasets: [
|
||
{
|
||
label: idealSeriesName,
|
||
data: idealBurndown,
|
||
borderColor: getCssVar('--chart-warning'),
|
||
backgroundColor: getCssVar('--chart-warning'),
|
||
borderWidth: 2,
|
||
borderDash: [5, 5],
|
||
tension: 0,
|
||
pointRadius: 0
|
||
},
|
||
{
|
||
label: actualSeriesName,
|
||
data: actualBurndown,
|
||
borderColor: getCssVar('--chart-primary'),
|
||
backgroundColor: getCssVar('--chart-primary'),
|
||
borderWidth: 2,
|
||
tension: 0,
|
||
pointRadius: 0
|
||
}
|
||
]
|
||
}
|
||
})
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
// 年度燃尽图数据
|
||
const yearBurndownChartData = computed(() => {
|
||
// 防御性检查
|
||
if (!props.overallStats || !props.overallStats.year || !props.selectedDate) {
|
||
return {
|
||
labels: [],
|
||
datasets: []
|
||
}
|
||
}
|
||
|
||
const refDate = props.selectedDate
|
||
const year = refDate.getFullYear()
|
||
|
||
const now = new Date()
|
||
const currentYear = now.getFullYear()
|
||
const currentMonth = now.getMonth()
|
||
const isExpense = props.activeTab === BudgetCategory.Expense
|
||
|
||
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 || []
|
||
|
||
for (let i = 0; i < 12; i++) {
|
||
months.push(`${i + 1}月`)
|
||
|
||
if (isExpense) {
|
||
const idealRemaining = totalBudget * (1 - (i + 1) / 12)
|
||
idealBurndown.push(Math.round(idealRemaining))
|
||
|
||
if (trend.length > 0) {
|
||
const monthValue = trend[i]
|
||
if (monthValue !== undefined && monthValue !== null) {
|
||
const actualRemaining = totalBudget - monthValue
|
||
actualBurndown.push(Math.round(actualRemaining))
|
||
} else {
|
||
actualBurndown.push(null)
|
||
}
|
||
} else {
|
||
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)
|
||
}
|
||
}
|
||
} else {
|
||
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)
|
||
}
|
||
} else {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const idealSeriesName = isExpense ? '理想支出' : '理想收入'
|
||
const actualSeriesName = isExpense ? '实际支出' : '实际收入'
|
||
|
||
return {
|
||
labels: months,
|
||
datasets: [
|
||
{
|
||
label: idealSeriesName,
|
||
data: idealBurndown,
|
||
borderColor: getCssVar('--chart-warning'),
|
||
backgroundColor: getCssVar('--chart-warning'),
|
||
borderWidth: 2,
|
||
borderDash: [5, 5],
|
||
tension: 0,
|
||
pointRadius: 0
|
||
},
|
||
{
|
||
label: actualSeriesName,
|
||
data: actualBurndown,
|
||
borderColor: getCssVar('--chart-primary'),
|
||
backgroundColor: getCssVar('--chart-primary'),
|
||
borderWidth: 2,
|
||
tension: 0,
|
||
pointRadius: 0
|
||
}
|
||
]
|
||
}
|
||
})
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
})
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.chart-analysis-container {
|
||
padding: var(--spacing-lg, 12px);
|
||
padding-bottom: 80px;
|
||
}
|
||
|
||
.gauges-row {
|
||
display: flex;
|
||
gap: var(--spacing-xl, 16px);
|
||
margin-bottom: var(--spacing-xl, 16px);
|
||
}
|
||
|
||
.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));
|
||
}
|
||
|
||
.gauge-card {
|
||
flex: 1;
|
||
min-width: 0;
|
||
/* 防止 flex 子项溢出 */
|
||
padding: var(--spacing-lg, 12px);
|
||
/* 减小内边距 */
|
||
}
|
||
|
||
.chart-card-spacing {
|
||
margin-top: var(--spacing-lg, 12px);
|
||
}
|
||
|
||
.popup-content-padding {
|
||
padding: var(--spacing-xl, 16px);
|
||
}
|
||
|
||
.gauge-wrapper {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 120px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.gauge-text-overlay {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
pointer-events: none;
|
||
z-index: 10;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.balance-label {
|
||
font-size: 11px;
|
||
color: var(--van-text-color-2);
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.chart-header {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.chart-title {
|
||
font-size: 14px;
|
||
/* 调小标题 */
|
||
font-weight: 600;
|
||
color: var(--van-text-color);
|
||
margin-bottom: 2px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.chart-title-text {
|
||
flex: 1;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
min-width: 0;
|
||
}
|
||
|
||
.info-icon {
|
||
flex-shrink: 0;
|
||
cursor: pointer;
|
||
padding: 4px;
|
||
margin: -4px;
|
||
}
|
||
|
||
.chart-subtitle {
|
||
font-size: 10px;
|
||
color: var(--van-text-color-2);
|
||
}
|
||
|
||
.chart-body {
|
||
width: 100%;
|
||
}
|
||
|
||
.gauge-chart {
|
||
height: 120px;
|
||
/* 调小高度 */
|
||
}
|
||
|
||
.variance-chart {
|
||
min-height: 200px;
|
||
}
|
||
|
||
.burndown-chart {
|
||
height: 190px;
|
||
}
|
||
|
||
.gauge-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 0 8px 6px;
|
||
margin-top: -6px;
|
||
}
|
||
|
||
.gauge-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
min-width: 0;
|
||
}
|
||
|
||
.gauge-item .label {
|
||
font-size: 10px;
|
||
color: var(--van-text-color-2);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.gauge-item .value {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
font-family:
|
||
DIN Alternate,
|
||
system-ui;
|
||
color: var(--van-text-color);
|
||
}
|
||
|
||
/* expand styles removed as they are no longer used */
|
||
</style>
|