Files
EmailBill/Web/src/views/statisticsV2/modules/ExpenseCategoryCard.vue
SunCheng 3402ffaae2
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 3m13s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
fix
2026-02-19 11:04:05 +08:00

533 lines
13 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="common-card expense-category-card"
style="padding: 12px;"
>
<div class="card-header">
<h3 class="card-title">
支出分类
</h3>
<van-tag
type="primary"
size="medium"
>
{{ expenseCategoriesView.length }}
</van-tag>
</div>
<!-- 环形图区域 -->
<div
v-if="expenseCategoriesView.length > 0"
class="chart-container"
>
<div class="ring-chart">
<BaseChart
type="doughnut"
:data="chartData"
:options="chartOptions"
:plugins="[pieCenterTextPlugin, pieLabelLinePlugin]"
:loading="false"
@chart:render="onChartRender"
/>
</div>
</div>
<!-- 分类列表 -->
<div class="category-list">
<div
v-for="category in expenseCategoriesSimpView"
:key="category.classify"
class="category-item clickable"
@click="$emit('category-click', category.classify, 0)"
>
<div class="category-info">
<div
class="category-color"
:style="{ backgroundColor: category.color }"
/>
<div class="category-name-with-count">
<span class="category-name">{{ category.classify || '未分类' }}</span>
<span class="category-count">{{ category.count }}</span>
</div>
</div>
<div class="category-stats">
<div class="category-amount">
¥{{ formatMoney(category.amount) }}
</div>
<div class="category-percent">
{{ category.percent }}%
</div>
</div>
<van-icon
name="arrow"
class="category-arrow"
/>
</div>
<!-- 展开/收起按钮 -->
<div
v-if="expenseCategoriesView.length > 1"
class="expand-toggle"
@click="showAllExpense = !showAllExpense"
>
<van-icon :name="showAllExpense ? 'arrow-up' : 'arrow-down'" />
</div>
</div>
<ModernEmpty
v-if="!expenseCategoriesView || !expenseCategoriesView.length"
type="chart"
theme="blue"
title="暂无支出"
description="本期还没有支出记录"
size="small"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { getCssVar } from '@/utils/theme'
import ModernEmpty from '@/components/ModernEmpty.vue'
import BaseChart from '@/components/Charts/BaseChart.vue'
import { useChartTheme } from '@/composables/useChartTheme'
import { pieCenterTextPlugin } from '@/plugins/chartjs-pie-center-plugin'
const props = defineProps({
categories: {
type: Array,
default: () => []
},
totalExpense: {
type: Number,
default: 0
},
colors: {
type: Array,
default: () => []
}
})
defineEmits(['category-click'])
const showAllExpense = ref(false)
// Chart.js 相关
const { getChartOptions } = useChartTheme()
let _chartJSInstance = null
// 饼图标签引导线
const pieLabelLinePlugin = {
id: 'pieLabelLine',
afterDraw: (chart) => {
const ctx = chart.ctx
const meta = chart.getDatasetMeta(0)
if (!meta?.data?.length) {return}
const labels = chart.data.labels || []
const centerX = (chart.chartArea.left + chart.chartArea.right) / 2
const lineColor = getCssVar('--van-text-color-2') || '#8a8a8a'
const textColor = getCssVar('--van-text-color') || '#323233'
const strokeColor = getCssVar('--van-background-2') || '#ffffff'
const minSpacing = 12
const labelOffset = 18
const lineOffset = 8
const yPadding = 6
const items = meta.data
.map((arc, index) => {
const label = labels[index]
if (!label) {return null}
const props = arc.getProps(['startAngle', 'endAngle', 'outerRadius', 'x', 'y'], true)
const angle = (props.startAngle + props.endAngle) / 2
const rawX = props.x + Math.cos(angle) * props.outerRadius
const rawY = props.y + Math.sin(angle) * props.outerRadius
const isRight = rawX >= centerX
return {
arc: props,
label,
angle,
isRight,
y: rawY
}
})
.filter(Boolean)
const left = items.filter((item) => !item.isRight).sort((a, b) => a.y - b.y)
const right = items.filter((item) => item.isRight).sort((a, b) => a.y - b.y)
const spread = (list) => {
for (let i = 1; i < list.length; i++) {
if (list[i].y - list[i - 1].y < minSpacing) {
list[i].y = list[i - 1].y + minSpacing
}
}
}
const topLimit = chart.chartArea.top + yPadding
const bottomLimit = chart.chartArea.bottom - yPadding
const clampY = (value) => Math.min(bottomLimit, Math.max(topLimit, value))
spread(left)
spread(right)
left.forEach((item) => { item.y = clampY(item.y) })
right.forEach((item) => { item.y = clampY(item.y) })
ctx.save()
ctx.strokeStyle = lineColor
ctx.lineWidth = 1
ctx.fillStyle = textColor
ctx.textBaseline = 'middle'
ctx.font = 'bold 10px "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif'
const drawItem = (item) => {
const cos = Math.cos(item.angle)
const sin = Math.sin(item.angle)
const startX = item.arc.x + cos * (item.arc.outerRadius + 2)
const startY = item.arc.y + sin * (item.arc.outerRadius + 2)
const midX = item.arc.x + cos * (item.arc.outerRadius + lineOffset)
const midY = item.arc.y + sin * (item.arc.outerRadius + lineOffset)
const endX = item.arc.x + (item.isRight ? 1 : -1) * (item.arc.outerRadius + labelOffset)
const endY = item.y
ctx.strokeStyle = lineColor
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(startX, startY)
ctx.lineTo(midX, midY)
ctx.lineTo(endX, endY)
ctx.stroke()
const textX = endX + (item.isRight ? 6 : -6)
ctx.textAlign = item.isRight ? 'left' : 'right'
ctx.fillStyle = textColor
ctx.fillText(item.label, textX, endY)
}
left.forEach(drawItem)
right.forEach(drawItem)
ctx.restore()
}
}
// 格式化金额
const formatMoney = (value, decimals = 1) => {
if (!value && value !== 0) {
return Number(0).toFixed(decimals)
}
return Number(value)
.toFixed(decimals)
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
.replace(/\.0$/, '')
}
// 计算属性
const expenseCategoriesView = computed(() => {
const list = [...props.categories]
const unclassifiedIndex = list.findIndex((c) => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return list
})
const expenseCategoriesSimpView = computed(() => {
const list = expenseCategoriesView.value
if (showAllExpense.value) {
return list
}
const unclassified = list.filter((c) => c.classify === '未分类' || !c.classify)
if (unclassified.length > 0) {
return [...unclassified]
}
return []
})
// 准备图表数据(通用)
const prepareChartData = () => {
const list = [...expenseCategoriesView.value]
list.sort((a, b) => b.amount - a.amount)
const MAX_SLICES = 8
if (list.length > MAX_SLICES) {
const topList = list.slice(0, MAX_SLICES - 1)
const otherList = list.slice(MAX_SLICES - 1)
const otherAmount = otherList.reduce((sum, item) => sum + item.amount, 0)
const chartData = topList.map((item, index) => ({
label: item.classify || '未分类',
value: item.amount,
color: props.colors[index % props.colors.length]
}))
chartData.push({
label: '其他',
value: otherAmount,
color: getCssVar('--van-gray-6')
})
return chartData
} else {
return list.map((item, index) => ({
label: item.classify || '未分类',
value: item.amount,
color: props.colors[index % props.colors.length]
}))
}
}
// Chart.js 数据
const chartData = computed(() => {
const data = prepareChartData()
return {
labels: data.map((item) => item.label),
datasets: [
{
data: data.map((item) => item.value),
backgroundColor: data.map((item) => item.color),
borderWidth: 2,
borderColor: getCssVar('--van-background-2') || '#fff',
hoverOffset: 8,
borderRadius: 4,
radius: '88%' // 拉大半径,减少上下留白
}
]
}
})
// 计算总金额
const totalAmount = computed(() => {
return props.totalExpense || 0
})
// Chart.js 配置
const chartOptions = computed(() => {
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark'
return getChartOptions({
cutout: '65%',
layout: {
padding: {
top: 0,
bottom: 0,
left: 2,
right: 2
}
},
// 显式禁用笛卡尔坐标系Doughnut 图表不需要)
scales: {
x: { display: false },
y: { display: false }
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: getCssVar('--van-background-2'),
titleColor: getCssVar('--van-text-color'),
bodyColor: getCssVar('--van-text-color'),
borderColor: getCssVar('--van-border-color'),
borderWidth: 1,
padding: 12,
cornerRadius: 8,
callbacks: {
label: (context) => {
const label = context.label || ''
const value = context.parsed || 0
const total = context.dataset.data.reduce((a, b) => a + b, 0)
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0
return `${label}: ¥${formatMoney(value, 0)} (${percentage}%)`
}
}
},
pieCenterText: {
text: `¥${formatMoney(totalAmount.value, 0)}`,
subtext: '总支出',
textColor: isDarkMode ? '#ffffff' : '#323233',
subtextColor: isDarkMode ? '#969799' : '#969799',
fontSize: 24,
subFontSize: 12
},
// 扇区外侧显示分类名称
datalabels: {
display: true
}
},
// 悬停效果增强
hoverOffset: 8
})
})
// Chart.js 渲染完成回调
const onChartRender = (chart) => {
_chartJSInstance = chart
}
</script>
<style scoped lang="scss">
@import '@/assets/theme.css';
.common-card {
background: var(--bg-secondary);
border-radius: var(--radius-lg, 12px);
padding: var(--spacing-xl, 16px);
margin-bottom: var(--spacing-xl, 16px);
box-shadow: var(--shadow-sm);
}
.expense-category-card .card-header {
margin-bottom: 0;
}
.expense-category-card .chart-container {
padding-bottom: 0;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-lg);
}
.card-title {
font-size: var(--font-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0;
}
.chart-container {
padding: 0;
}
.ring-chart {
position: relative;
width: 100%;
height: 190px;
margin: 0px auto 0;
overflow: visible;
}
.ring-chart :deep(.chartjs-size-monitor),
.ring-chart :deep(.chartjs-size-monitor-expand),
.ring-chart :deep(.chartjs-size-monitor-shrink) {
display: none !important;
}
.ring-chart :deep(.base-chart) {
height: 100%;
min-height: 0;
align-items: stretch;
justify-content: flex-start;
}
.ring-chart :deep(canvas) {
height: 100% !important;
width: 100% !important;
}
.category-list {
padding: 0;
}
.category-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--van-border-color);
transition: background-color 0.2s;
gap: 12px;
}
.category-item:last-child {
border-bottom: none;
}
.category-item.clickable {
cursor: pointer;
}
.category-item.clickable:active {
background-color: var(--van-background);
}
.category-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.category-name-with-count {
display: flex;
align-items: center;
gap: 8px;
}
.category-color {
width: 12px;
height: 12px;
border-radius: 50%;
}
.category-name {
font-size: 14px;
color: var(--van-text-color);
}
.category-count {
font-size: 12px;
color: var(--van-text-color-3);
}
.category-stats {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.category-arrow {
margin-left: 8px;
color: var(--van-text-color-3);
font-size: 16px;
flex-shrink: 0;
}
.expand-toggle {
display: flex;
justify-content: center;
align-items: center;
padding-top: 10px;
padding-bottom: 0;
color: var(--van-text-color-3);
font-size: 20px;
cursor: pointer;
}
.expand-toggle:active {
opacity: 0.7;
}
.category-amount {
font-size: 15px;
font-weight: 600;
color: var(--van-text-color);
}
.category-percent {
font-size: 12px;
color: var(--van-text-color-3);
background: var(--van-background);
padding: 2px 8px;
border-radius: 10px;
}
</style>