Files
X-Financial/web/src/components/charts/BudgetTrendChart.vue
caoxiaozhu 678f64d772 feat: 统一后端分页查询与前端服务层适配
后端新增通用分页模块,为报销单、员工、预算、agent 资产等
端点统一接入分页参数和游标查询,优化 repository 层分页实
现,前端服务层适配分页响应结构,完善预算图表和全局样式,
优化侧边栏和企业选择器组件,引入 Element Plus 插件注册。
2026-05-29 14:11:06 +08:00

196 lines
5.5 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 ref="chartElement" class="budget-trend-chart" role="img" :aria-label="ariaLabel"></div>
</template>
<script setup>
import { computed, shallowRef } from 'vue'
import { BarChart as EChartsBarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
use([GridComponent, TooltipComponent, EChartsBarChart, CanvasRenderer])
const props = defineProps({
labels: { type: Array, required: true },
budget: { type: Array, required: true },
used: { type: Array, required: true },
occupied: { type: Array, default: () => [] },
available: { type: Array, default: () => [] }
})
const chartElement = shallowRef(null)
const themeColors = useThemeColors()
const prefersReducedMotion = () =>
typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
const currency = (value) =>
Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
const percent = (value, total) => {
const denominator = Number(total || 0)
if (!denominator) return 0
return Number(((Number(value || 0) / denominator) * 100).toFixed(2))
}
const percentSeries = (series) =>
props.budget.map((total, index) => percent(series[index], total))
const usedPercent = computed(() => percentSeries(props.used))
const occupiedPercent = computed(() => percentSeries(props.occupied))
const availableAmountSeries = computed(() =>
props.budget.map((total, index) => {
const explicitValue = Number(props.available[index])
if (Number.isFinite(explicitValue) && explicitValue > 0) {
return explicitValue
}
const usedValue = Number(props.used[index] || 0)
const occupiedValue = Number(props.occupied[index] || 0)
return Math.max(Number(total || 0) - usedValue - occupiedValue, 0)
})
)
const availablePercent = computed(() =>
props.budget.map((total, index) => percent(availableAmountSeries.value[index], total))
)
const yAxisMax = computed(() => {
const maxUsage = Math.max(
100,
...usedPercent.value.map((value, index) => value + Number(occupiedPercent.value[index] || 0))
)
return Math.ceil(maxUsage / 20) * 20
})
const ariaLabel = computed(() =>
props.labels.map((label, index) => (
`${label}预算${currency(props.budget[index])},已使用${usedPercent.value[index] || 0}%,已占用${occupiedPercent.value[index] || 0}%`
)).join('')
)
function buildSeriesData(percentValues, amountValues) {
return percentValues.map((value, index) => ({
value,
amount: Number(amountValues[index] || 0)
}))
}
const chartOptions = computed(() => ({
backgroundColor: 'transparent',
animation: {
duration: prefersReducedMotion() ? 0 : 760,
easing: 'easeOutQuart'
},
grid: {
top: 12,
right: 16,
bottom: 24,
left: 34,
containLabel: true
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: true,
axisPointer: { type: 'shadow' },
backgroundColor: '#ffffff',
borderColor: '#e2e8f0',
borderWidth: 1,
padding: [10, 12],
textStyle: {
color: '#475569',
fontSize: 12,
fontWeight: 700
},
extraCssText: 'border-radius:4px;box-shadow:0 12px 28px rgba(15,23,42,.12);',
formatter(params = []) {
const items = Array.isArray(params) ? params : [params]
const index = Number(items[0]?.dataIndex || 0)
const lines = items.map((item) => {
const percentValue = Number(item?.value || 0).toFixed(2)
const amount = currency(item?.data?.amount || 0)
return `${item.marker}${item.seriesName}: ${percentValue}%(¥${amount}`
})
return [`${items[0]?.axisValue || ''}`, ...lines, `预算总额: ¥${currency(props.budget[index])}`].join('<br/>')
}
},
xAxis: {
type: 'category',
data: props.labels,
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#64748b',
fontSize: 12,
fontWeight: 700
}
},
yAxis: {
type: 'value',
min: 0,
max: yAxisMax.value,
splitNumber: Math.max(1, Math.ceil(yAxisMax.value / 20)),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: '#64748b',
fontSize: 12,
fontWeight: 700,
formatter: (value) => `${Number(value)}%`
},
splitLine: { lineStyle: { color: '#edf2f7' } }
},
series: [
{
name: '已使用',
type: 'bar',
stack: 'budgetUsage',
data: buildSeriesData(usedPercent.value, props.used),
barWidth: 16,
itemStyle: {
color: themeColors.value.chartPrimary,
borderRadius: [4, 4, 0, 0]
}
},
{
name: '已占用',
type: 'bar',
stack: 'budgetUsage',
data: buildSeriesData(occupiedPercent.value, props.occupied),
barWidth: 16,
itemStyle: {
color: themeColors.value.warning,
borderRadius: [4, 4, 0, 0]
}
},
{
name: '剩余可用',
type: 'bar',
stack: 'budgetUsage',
data: buildSeriesData(availablePercent.value, availableAmountSeries.value),
barWidth: 16,
itemStyle: {
color: '#e5edf3',
borderRadius: [4, 4, 0, 0]
}
}
]
}))
useEcharts(chartElement, chartOptions)
</script>
<style scoped>
.budget-trend-chart {
position: relative;
width: 100%;
height: 220px;
}
</style>