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

891 lines
23 KiB
Vue
Raw Normal View History

2026-01-25 13:22:51 +08:00
<template>
2026-01-16 15:56:53 +08:00
<div class="chart-analysis-container">
<!-- 仪表盘整体健康度 -->
<div class="gauges-row">
<!-- 月度仪表盘 -->
<div class="chart-card gauge-card">
<div class="chart-header">
<div class="chart-title">
2026-01-16 17:52:40 +08:00
<!-- 月度健康度 -->
2026-01-22 21:03:00 +08:00
{{ 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'"
/>
2026-01-16 15:56:53 +08:00
</div>
</div>
2026-01-21 16:09:38 +08:00
<div class="gauge-wrapper">
<BaseChart
type="doughnut"
:data="monthGaugeData"
:options="monthGaugeOptions"
:plugins="[chartjsGaugePlugin]"
2026-01-21 16:09:38 +08:00
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
/>
<div class="gauge-text-overlay">
<div
class="remaining-label"
>
2026-02-01 10:27:04 +08:00
{{
activeTab === BudgetCategory.Expense
? (
overallStats.month.current > overallStats.month.limit
? '超支'
: '余额'
)
: overallStats.month.current > overallStats.month.limit
? '超额'
: '差额'
}}
2026-01-21 16:09:38 +08:00
</div>
<div
class="remaining-value"
2026-02-01 10:27:04 +08:00
:style="{ color:
overallStats.month.current > overallStats.month.limit
? activeTab === BudgetCategory.Expense ? 'var(--van-danger-color)' : 'var(--van-success-color)'
: ''
}"
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-01-16 15:56:53 +08:00
<div class="gauge-footer compact">
<div class="gauge-item">
2026-01-16 17:52:40 +08:00
<span class="label">
{{ activeTab === BudgetCategory.Expense ? '已用' : '已收' }}
</span>
2026-01-16 15:56:53 +08:00
<span class="value expense">¥{{ formatMoney(overallStats.month.current) }}</span>
</div>
<div class="gauge-item">
2026-01-16 17:52:40 +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-01-22 21:03:00 +08:00
{{ 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'"
/>
2026-01-16 15:56:53 +08:00
</div>
</div>
2026-01-21 16:09:38 +08:00
<div class="gauge-wrapper">
<BaseChart
type="doughnut"
:data="yearGaugeData"
:options="yearGaugeOptions"
:plugins="[chartjsGaugePlugin]"
2026-01-21 16:09:38 +08:00
:style="{ transform: activeTab === BudgetCategory.Expense ? 'scaleX(-1)' : '' }"
/>
<div class="gauge-text-overlay">
<div
class="remaining-label"
>
2026-02-01 10:27:04 +08:00
{{ activeTab === BudgetCategory.Expense ? (overallStats.year.current > overallStats.year.limit ? '超支' : '余额') : '差额' }}
2026-01-21 16:09:38 +08:00
</div>
<div
class="remaining-value"
2026-02-01 10:27:04 +08:00
:style="{ color: activeTab === BudgetCategory.Expense && overallStats.year.current > overallStats.year.limit ? 'var(--van-danger-color)' : '' }"
2026-01-21 16:09:38 +08:00
>
2026-02-01 10:27:04 +08:00
¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }}
2026-01-21 16:09:38 +08:00
</div>
</div>
</div>
2026-01-16 15:56:53 +08:00
<div class="gauge-footer compact">
<div class="gauge-item">
2026-01-16 17:52:40 +08:00
<span class="label">
{{ activeTab === BudgetCategory.Expense ? '已用' : '已收' }}
</span>
2026-01-16 15:56:53 +08:00
<span class="value expense">¥{{ formatMoney(overallStats.year.current) }}</span>
</div>
<div class="gauge-item">
2026-01-16 17:52:40 +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-01-16 17:52:40 +08:00
预算剩余消耗趋势
</div>
</div>
<BaseChart
type="line"
:data="burndownChartData"
:options="burndownChartOptions"
2026-01-16 17:52:40 +08:00
class="chart-body burndown-chart"
/>
2026-01-16 15:56:53 +08:00
</div>
<!-- 年度预算进度 -->
<div
v-if="budgets.length > 0"
class="chart-card"
style="margin-top: 12px"
>
<div class="chart-header">
<div class="chart-title">
预算进度年度
</div>
<div class="chart-subtitle">
本年各预算执行情况
</div>
</div>
<BaseChart
type="line"
:data="yearBurndownChartData"
:options="yearBurndownChartOptions"
2026-01-16 17:52:40 +08:00
class="chart-body burndown-chart"
/>
2026-01-20 19:56:29 +08:00
</div>
<!-- 偏差分析 -->
<div
v-if="budgets.length > 0"
class="chart-card"
style="margin-top: 12px"
>
<div class="chart-header">
<div class="chart-title">
偏差分析
</div>
<div class="chart-subtitle">
预算执行偏差排行
2026-01-16 17:52:40 +08:00
</div>
</div>
<BaseChart
type="bar"
:data="varianceChartData"
:options="varianceChartOptions"
class="chart-body variance-chart"
2026-01-20 19:56:29 +08:00
:style="{ height: calculateChartHeight(budgets) + 'px' }"
2026-01-16 17:52:40 +08:00
/>
</div>
2026-01-16 15:56:53 +08:00
<!-- 空状态占位 -->
<div
2026-01-16 17:52:40 +08:00
v-else-if="budgets.length === 0"
2026-01-16 15:56:53 +08:00
class="chart-card empty-card"
>
<van-empty
description="暂无预算数据"
image="search"
/>
</div>
</div>
2026-01-22 21:03:00 +08:00
<!-- 详细描述弹窗 -->
<PopupContainer
v-model="showDescriptionPopup"
:title="activeDescTab === 'month' ? '预算额度/实际详情(月度)' : '预算额度/实际详情(年度)'"
height="70%"
>
<div
class="rich-html-content"
style="padding: 16px"
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>
2026-01-16 15:56:53 +08:00
</template>
<script setup>
import { ref, onMounted, watch, nextTick, onUnmounted, computed } from 'vue'
2026-01-25 13:22:51 +08:00
import { BudgetCategory, BudgetPeriodType } from '@/constants/enums'
2026-01-16 15:56:53 +08:00
import { getCssVar } from '@/utils/theme'
2026-01-22 21:03:00 +08:00
import PopupContainer from '@/components/PopupContainer.vue'
import BaseChart from '@/components/Charts/BaseChart.vue'
import { useChartTheme } from '@/composables/useChartTheme'
import { chartjsGaugePlugin } from '@/plugins/chartjs-gauge-plugin'
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')
// Chart.js 相关
const { getChartOptions } = useChartTheme()
2026-01-16 15:56:53 +08:00
const formatMoney = (val) => {
if (Math.abs(val) >= 10000) {
2026-01-16 15:56:53 +08:00
return (val / 10000).toFixed(1) + 'w'
}
return parseFloat(val || 0).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
}
// 月度仪表盘数据
const monthGaugeData = computed(() => {
const rate = parseFloat(props.overallStats.month.rate || 0)
const isExpense = props.activeTab === BudgetCategory.Expense
2026-01-16 15:56:53 +08:00
2026-01-18 13:06:16 +08:00
let displayRate
2026-01-16 15:56:53 +08:00
if (isExpense) {
2026-01-18 13:06:16 +08:00
displayRate = Math.max(0, 100 - rate)
2026-02-01 10:27:04 +08:00
if (rate > 100) {
displayRate = rate - 100
}
2026-01-16 15:56:53 +08:00
} else {
2026-02-01 10:27:04 +08:00
displayRate = rate
2026-01-18 13:06:16 +08:00
}
let color
if (isExpense) {
2026-02-01 10:27:04 +08:00
if (rate > 100) {
color = getCssVar('--chart-danger')
2026-02-01 10:27:04 +08:00
} else if (displayRate <= 30) {
color = getCssVar('--chart-danger')
2026-02-01 10:27:04 +08:00
} else if (displayRate <= 65) {
color = getCssVar('--chart-warning')
2026-02-01 10:27:04 +08:00
} else {
color = getCssVar('--chart-success')
2026-02-01 10:27:04 +08:00
}
2026-01-18 13:06:16 +08:00
} else {
if (displayRate <= 30) {
color = getCssVar('--chart-danger')
} else if (displayRate <= 65) {
color = getCssVar('--chart-warning')
} else {
color = getCssVar('--chart-success')
}
2026-01-16 15:56:53 +08:00
}
const maxValue = isExpense && rate > 100 ? 50 : 100
const remaining = 100 - displayRate
return {
datasets: [
2026-01-16 15:56:53 +08:00
{
data: [displayRate, remaining],
backgroundColor: [color, getCssVar('--chart-axis')],
borderWidth: 0,
circumference: 180,
rotation: 270
2026-01-16 15:56:53 +08:00
}
]
}
})
2026-01-16 15:56:53 +08:00
const monthGaugeOptions = computed(() => {
return {
cutout: '75%',
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
}
}
})
2026-01-16 15:56:53 +08:00
// 年度仪表盘数据
const yearGaugeData = computed(() => {
const rate = parseFloat(props.overallStats.year.rate || 0)
2026-01-16 15:56:53 +08:00
const isExpense = props.activeTab === BudgetCategory.Expense
2026-01-21 18:52:31 +08:00
let displayRate
if (isExpense) {
displayRate = Math.max(0, 100 - rate)
if (rate > 100) {
displayRate = rate - 100
}
} else {
displayRate = rate
2026-01-21 18:52:31 +08:00
}
let color
if (isExpense) {
if (rate > 100) {
color = getCssVar('--chart-danger')
} else if (displayRate <= 30) {
color = getCssVar('--chart-danger')
} else if (displayRate <= 65) {
color = getCssVar('--chart-warning')
} else {
color = getCssVar('--chart-success')
}
} else {
if (displayRate <= 30) {
color = getCssVar('--chart-danger')
} else if (displayRate <= 65) {
color = getCssVar('--chart-warning')
} else {
color = getCssVar('--chart-success')
}
2026-01-21 18:52:31 +08:00
}
2026-01-16 15:56:53 +08:00
const remaining = 100 - displayRate
2026-01-16 17:52:40 +08:00
return {
datasets: [
{
data: [displayRate, remaining],
backgroundColor: [color, getCssVar('--chart-axis')],
borderWidth: 0,
circumference: 180,
rotation: 270
2026-01-21 18:52:31 +08:00
}
]
}
})
2026-01-16 17:52:40 +08:00
const yearGaugeOptions = computed(() => {
return {
cutout: '75%',
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
2026-01-21 18:52:31 +08:00
}
}
2026-01-16 15:56:53 +08:00
}
})
const calculateChartHeight = (budgets) => {
if (!budgets) { return 100 }
const dataCount = budgets.length
const minHeight = 100
const heightPerItem = 30
const calculatedHeight = Math.max(minHeight, dataCount * heightPerItem)
return calculatedHeight
2026-01-16 15:56:53 +08:00
}
// 偏差分析图表数据
const varianceChartData = computed(() => {
if (!props.budgets || props.budgets.length === 0) {
return { labels: [], datasets: [] }
}
2026-01-16 17:52:40 +08:00
const data = props.budgets.map(b => {
2026-01-16 17:52:40 +08:00
const limit = b.limit || 0
const current = b.current || 0
const diff = current - limit
return {
2026-01-25 13:22:51 +08:00
name: b.name + (b.type === BudgetPeriodType.Year ? ' (年)' : ''),
value: diff,
limit: limit,
2026-01-25 13:22:51 +08:00
current: current,
type: b.type
}
2026-01-16 17:52:40 +08:00
})
2026-01-25 13:22:51 +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-01-25 13:22:51 +08:00
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]
2026-01-16 15:56:53 +08:00
return {
labels: sortedData.map(item => item.name),
datasets: [
{
label: '偏差',
data: sortedData.map(item => item.value),
backgroundColor: sortedData.map(item => {
if (props.activeTab === BudgetCategory.Expense) {
return item.value > 0 ? getCssVar('--van-danger-color') : getCssVar('--van-success-color')
2026-01-17 15:03:19 +08:00
} else {
return item.value > 0 ? getCssVar('--van-success-color') : getCssVar('--van-danger-color')
2026-01-16 15:56:53 +08:00
}
}),
borderRadius: 4,
barThickness: 20,
_meta: sortedData
}
]
2026-01-16 15:56:53 +08:00
}
})
2026-01-16 15:56:53 +08:00
const varianceChartOptions = computed(() => {
return getChartOptions({
indexAxis: 'y',
plugins: {
legend: {
display: false
},
tooltip: {
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: {
ticks: {
autoSkip: false,
callback: function (value, index) {
const label = this.getLabelForValue(index)
return label.length > 10 ? label.substring(0, 10) + '...' : label
}
}
}
}
})
})
2026-01-16 17:52:40 +08:00
// 月度燃尽图数据
const burndownChartData = computed(() => {
2026-01-21 18:52:31 +08:00
const refDate = props.selectedDate
const year = refDate.getFullYear()
const month = refDate.getMonth()
2026-01-16 17:52:40 +08:00
const daysInMonth = new Date(year, month + 1, 0).getDate()
2026-01-21 18:52:31 +08:00
const now = new Date()
const isCurrentMonth = now.getFullYear() === year && now.getMonth() === month
const isPastMonth =
now.getFullYear() > year || (now.getFullYear() === year && now.getMonth() > month)
const currentDay = isCurrentMonth ? now.getDate() : isPastMonth ? daysInMonth : 0
2026-01-16 23:18:04 +08:00
const isExpense = props.activeTab === BudgetCategory.Expense
2026-01-16 17:52:40 +08:00
const dates = []
const idealBurndown = []
const actualBurndown = []
const totalBudget = props.overallStats.month.limit || 0
const currentExpense = props.overallStats.month.current || 0
const trend = props.overallStats.month.trend || []
2026-01-16 17:52:40 +08:00
for (let i = 1; i <= daysInMonth; i++) {
dates.push(`${i}`)
2026-01-16 23:18:04 +08:00
if (isExpense) {
2026-02-01 10:27:04 +08:00
const idealRemaining = totalBudget * (1 - i / daysInMonth)
2026-01-16 23:18:04 +08:00
idealBurndown.push(Math.round(idealRemaining))
if (trend.length > 0) {
const dayValue = trend[i - 1]
if (dayValue !== undefined && dayValue !== null) {
2026-02-01 10:27:04 +08:00
const actualRemaining = totalBudget - dayValue
actualBurndown.push(Math.round(actualRemaining))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
} else {
if (i <= currentDay && totalBudget > 0) {
2026-02-01 10:27:04 +08:00
const actualRemaining = totalBudget - (currentExpense * i / currentDay)
actualBurndown.push(Math.round(actualRemaining))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
}
2026-01-16 17:52:40 +08:00
} else {
2026-01-16 23:18:04 +08:00
const idealAccumulated = Math.min(totalBudget, totalBudget * (i / daysInMonth))
idealBurndown.push(Math.round(idealAccumulated))
if (trend.length > 0) {
const dayValue = trend[i - 1]
if (dayValue !== undefined && dayValue !== null) {
actualBurndown.push(Math.round(dayValue))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
} else {
if (i <= currentDay && totalBudget > 0) {
const actualAccumulated = Math.min(totalBudget, currentExpense * i / currentDay)
actualBurndown.push(Math.round(actualAccumulated))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
}
2026-01-16 17:52:40 +08:00
}
}
2026-01-16 23:18:04 +08:00
const idealSeriesName = isExpense ? '理想燃尽' : '理想积累'
const actualSeriesName = isExpense ? '实际燃尽' : '实际积累'
return {
labels: dates,
datasets: [
2026-01-16 17:52:40 +08:00
{
label: idealSeriesName,
2026-01-16 17:52:40 +08:00
data: idealBurndown,
borderColor: getCssVar('--chart-warning'),
backgroundColor: getCssVar('--chart-warning'),
borderWidth: 2,
borderDash: [5, 5],
tension: 0,
pointRadius: 0
2026-01-16 17:52:40 +08:00
},
{
label: actualSeriesName,
2026-01-16 17:52:40 +08:00
data: actualBurndown,
borderColor: getCssVar('--chart-primary'),
backgroundColor: getCssVar('--chart-primary'),
borderWidth: 2,
tension: 0,
pointRadius: 0
2026-01-16 17:52:40 +08:00
}
]
}
})
2026-01-16 17:52:40 +08:00
const burndownChartOptions = computed(() => {
return getChartOptions({
plugins: {
legend: {
display: true,
position: 'top'
},
tooltip: {
callbacks: {
label: (context) => {
const value = context.parsed.y
if (value !== null && value !== undefined) {
return `${context.dataset.label}: ¥${formatMoney(value)}`
}
return ''
}
}
}
},
scales: {
y: {
ticks: {
callback: (value) => {
if (value >= 10000) {
return (value / 10000).toFixed(0) + 'w'
}
return value
}
}
}
}
})
})
2026-01-16 17:52:40 +08:00
// 年度燃尽图数据
const yearBurndownChartData = computed(() => {
2026-01-21 18:52:31 +08:00
const refDate = props.selectedDate
const year = refDate.getFullYear()
const now = new Date()
const currentYear = now.getFullYear()
const currentMonth = now.getMonth()
2026-01-16 23:18:04 +08:00
const isExpense = props.activeTab === BudgetCategory.Expense
2026-01-16 17:52:40 +08:00
const months = []
const idealBurndown = []
const actualBurndown = []
const totalBudget = props.overallStats.year.limit || 0
const currentExpense = props.overallStats.year.current || 0
const trend = props.overallStats.year.trend || []
2026-01-16 17:52:40 +08:00
for (let i = 0; i < 12; i++) {
months.push(`${i + 1}`)
2026-01-16 23:18:04 +08:00
if (isExpense) {
2026-02-01 10:27:04 +08:00
const idealRemaining = totalBudget * (1 - (i + 1) / 12)
2026-01-16 23:18:04 +08:00
idealBurndown.push(Math.round(idealRemaining))
if (trend.length > 0) {
const monthValue = trend[i]
if (monthValue !== undefined && monthValue !== null) {
2026-02-01 10:27:04 +08:00
const actualRemaining = totalBudget - monthValue
actualBurndown.push(Math.round(actualRemaining))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
} else {
2026-01-21 18:52:31 +08:00
const isFuture = year > currentYear || (year === currentYear && i > currentMonth)
if (!isFuture && totalBudget > 0) {
const progress = (i + 1) / 12
const actualRemaining = totalBudget - (currentExpense * progress)
actualBurndown.push(Math.round(actualRemaining))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
}
2026-01-16 17:52:40 +08:00
} else {
2026-01-16 23:18:04 +08:00
const idealAccumulated = Math.min(totalBudget, totalBudget * ((i + 1) / 12))
idealBurndown.push(Math.round(idealAccumulated))
if (trend.length > 0) {
const monthValue = trend[i]
if (monthValue !== undefined && monthValue !== null) {
actualBurndown.push(Math.round(monthValue))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
} else {
2026-01-21 18:52:31 +08:00
const isFuture = year > currentYear || (year === currentYear && i > currentMonth)
if (!isFuture && totalBudget > 0) {
const progress = (i + 1) / 12
const actualAccumulated = Math.min(totalBudget, currentExpense * progress)
actualBurndown.push(Math.round(actualAccumulated))
} else {
actualBurndown.push(null)
}
2026-01-16 23:18:04 +08:00
}
2026-01-16 17:52:40 +08:00
}
}
const idealSeriesName = isExpense ? '理想支出' : '理想收入'
const actualSeriesName = isExpense ? '实际支出' : '实际收入'
2026-01-16 23:18:04 +08:00
return {
labels: months,
datasets: [
2026-01-16 17:52:40 +08:00
{
label: idealSeriesName,
2026-01-16 17:52:40 +08:00
data: idealBurndown,
borderColor: getCssVar('--chart-warning'),
backgroundColor: getCssVar('--chart-warning'),
borderWidth: 2,
borderDash: [5, 5],
tension: 0,
pointRadius: 0
2026-01-16 17:52:40 +08:00
},
{
label: actualSeriesName,
2026-01-16 17:52:40 +08:00
data: actualBurndown,
borderColor: getCssVar('--chart-primary'),
backgroundColor: getCssVar('--chart-primary'),
borderWidth: 2,
tension: 0,
pointRadius: 0
2026-01-16 17:52:40 +08:00
}
]
}
})
const yearBurndownChartOptions = computed(() => {
return getChartOptions({
plugins: {
legend: {
display: true,
position: 'top'
},
tooltip: {
callbacks: {
label: (context) => {
const value = context.parsed.y
if (value !== null && value !== undefined) {
return `${context.dataset.label}: ¥${formatMoney(value)}`
}
return ''
}
}
}
},
scales: {
y: {
ticks: {
callback: (value) => {
if (value >= 10000) {
return (value / 10000).toFixed(0) + 'w'
}
return value
}
}
}
}
2026-01-16 15:56:53 +08:00
})
})
</script>
<style scoped>
.chart-analysis-container {
padding: 12px;
padding-bottom: 80px;
}
.gauges-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.chart-card {
background: var(--van-background-2);
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.gauge-card {
flex: 1;
min-width: 0;
/* 防止 flex 子项溢出 */
padding: 12px;
/* 减小内边距 */
}
2026-01-21 16:09:38 +08:00
.gauge-wrapper {
position: relative;
width: 100%;
height: 120px;
}
.gauge-text-overlay {
position: absolute;
bottom: 20%;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 10;
}
.remaining-value {
font-size: 24px;
font-weight: bold;
font-family: DIN Alternate, system-ui;
color: var(--van-text-color);
line-height: 1;
transform-origin: center;
}
.remaining-label {
font-size: 10px;
color: var(--van-text-color-2);
margin-top: 4px;
font-family: system-ui;
transform-origin: center;
}
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;
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 {
2026-01-16 15:56:53 +08:00
min-height: 200px;
}
2026-01-16 17:52:40 +08:00
.burndown-chart {
2026-01-25 13:27:55 +08:00
height: 190px;
2026-01-16 17:52:40 +08:00
}
2026-01-16 15:56:53 +08:00
.gauge-footer {
display: flex;
justify-content: space-around;
/* 分散对齐 */
align-items: center;
margin-top: -20px;
position: relative;
z-index: 10;
}
.gauge-item {
display: flex;
flex-direction: column;
align-items: center;
}
.gauge-item .label {
font-size: 10px;
color: var(--van-text-color-2);
transform: scale(0.9);
/* 视觉上更小 */
}
.gauge-item .value {
font-size: 12px;
font-weight: 600;
font-family: DIN Alternate, system-ui;
color: var(--van-text-color);
}
.gauge-item .value.expense {
color: var(--van-primary-color);
}
2026-01-16 17:52:40 +08:00
2026-01-20 19:56:29 +08:00
/* expand styles removed as they are no longer used */
</style>