- 调整 theme.css 中的 --radius-lg 为 12px 以符合设计标准 - 统一日历页面卡片样式(padding, border-radius, shadow) - 统一统计页面所有卡片组件的样式 - 统一预算页面图表卡片样式,替换硬编码值为 CSS 变量 - 为关键样式添加 fallback 值以防止变量未定义 - 所有卡片现在使用统一的样式变量: - padding: var(--spacing-xl, 16px) - border-radius: var(--radius-lg, 12px) - box-shadow: var(--shadow-sm) - background: var(--bg-secondary)
1018 lines
25 KiB
Vue
1018 lines
25 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">
|
||
<!-- 月度健康度 -->
|
||
{{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
|
||
<van-icon
|
||
name="info-o"
|
||
size="16"
|
||
color="var(--van-primary-color)"
|
||
style="margin-left: auto; cursor: pointer"
|
||
@click="showDescriptionPopup = true; activeDescTab = '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">
|
||
余额
|
||
</div>
|
||
<div
|
||
class="balance-value"
|
||
:style="{
|
||
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">已用</span>
|
||
<span class="value">¥{{ formatMoney(overallStats.month.current) }}</span>
|
||
</div>
|
||
<div class="gauge-item">
|
||
<span class="label">预算</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">
|
||
{{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
|
||
<van-icon
|
||
name="info-o"
|
||
size="16"
|
||
color="var(--van-primary-color)"
|
||
style="margin-left: auto; cursor: pointer"
|
||
@click="showDescriptionPopup = true; activeDescTab = '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">
|
||
余额
|
||
</div>
|
||
<div
|
||
class="balance-value"
|
||
:style="{
|
||
color:
|
||
activeTab === BudgetCategory.Expense &&
|
||
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">已用</span>
|
||
<span class="value">¥{{ formatMoney(overallStats.year.current) }}</span>
|
||
</div>
|
||
<div class="gauge-item">
|
||
<span class="label">预算</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">
|
||
预算剩余消耗趋势
|
||
</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"
|
||
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, onMounted, watch, nextTick, onUnmounted, 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')
|
||
|
||
// Chart.js 相关
|
||
const { getChartOptions, getChartOptionsByType } = 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 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)
|
||
|
||
monthlyData.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
|
||
annualData.sort((a, b) => Math.abs(b.value) - Math.abs(a.value))
|
||
|
||
const sortedData = [...annualData, ...monthlyData]
|
||
|
||
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 getChartOptionsByType('bar', {
|
||
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 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 getChartOptionsByType('line', {
|
||
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 getChartOptionsByType('line', {
|
||
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;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.chart-subtitle {
|
||
font-size: 10px;
|
||
color: var(--van-text-color-2);
|
||
}
|
||
|
||
.chart-body {
|
||
width: 100%;
|
||
}
|
||
|
||
.gauge-chart {
|
||
height: 120px;
|
||
/* 调小高度 */
|
||
}
|
||
|
||
.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>
|