Files
EmailBill/Web/src/components/Budget/BudgetChartAnalysis.vue
SunCheng 61aa19b3d2 style: unify card styles across calendar, statistics, and budget pages
- 调整 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)
2026-02-18 20:44:58 +08:00

1018 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>