407 lines
8.8 KiB
Vue
407 lines
8.8 KiB
Vue
<template>
|
|
<!-- 支出分类统计 -->
|
|
<div
|
|
class="common-card"
|
|
style="padding-bottom: 10px;"
|
|
>
|
|
<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">
|
|
<div
|
|
ref="pieChartRef"
|
|
style="width: 100%; height: 100%"
|
|
/>
|
|
</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, onBeforeUnmount, nextTick, watch } from 'vue'
|
|
import * as echarts from 'echarts'
|
|
import { getCssVar } from '@/utils/theme'
|
|
import ModernEmpty from '@/components/ModernEmpty.vue'
|
|
|
|
const props = defineProps({
|
|
categories: {
|
|
type: Array,
|
|
default: () => []
|
|
},
|
|
totalExpense: {
|
|
type: Number,
|
|
default: 0
|
|
},
|
|
colors: {
|
|
type: Array,
|
|
default: () => []
|
|
}
|
|
})
|
|
|
|
defineEmits(['category-click'])
|
|
|
|
const pieChartRef = ref(null)
|
|
let pieChartInstance = null
|
|
const showAllExpense = ref(false)
|
|
|
|
// 格式化金额
|
|
const formatMoney = (value) => {
|
|
if (!value && value !== 0) {
|
|
return '0'
|
|
}
|
|
return Number(value)
|
|
.toFixed(0)
|
|
.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
|
}
|
|
|
|
// 计算属性
|
|
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 renderPieChart = () => {
|
|
if (!pieChartRef.value) {
|
|
return
|
|
}
|
|
if (expenseCategoriesView.value.length === 0) {
|
|
return
|
|
}
|
|
|
|
// 尝试获取DOM上的现有实例
|
|
const existingInstance = echarts.getInstanceByDom(pieChartRef.value)
|
|
|
|
if (pieChartInstance && pieChartInstance !== existingInstance) {
|
|
if (!pieChartInstance.isDisposed()) {
|
|
pieChartInstance.dispose()
|
|
}
|
|
pieChartInstance = null
|
|
}
|
|
|
|
if (pieChartInstance && pieChartInstance.getDom() !== pieChartRef.value) {
|
|
pieChartInstance.dispose()
|
|
pieChartInstance = null
|
|
}
|
|
|
|
if (!pieChartInstance && existingInstance) {
|
|
pieChartInstance = existingInstance
|
|
}
|
|
|
|
if (!pieChartInstance) {
|
|
pieChartInstance = echarts.init(pieChartRef.value)
|
|
}
|
|
|
|
// 使用 Top N + Other 的数据逻辑,确保图表不会太拥挤
|
|
const list = [...expenseCategoriesView.value]
|
|
let chartData = []
|
|
|
|
// 按照金额排序
|
|
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)
|
|
|
|
chartData = topList.map((item, index) => ({
|
|
value: item.amount,
|
|
name: item.classify || '未分类',
|
|
itemStyle: { color: props.colors[index % props.colors.length] }
|
|
}))
|
|
|
|
chartData.push({
|
|
value: otherAmount,
|
|
name: '其他',
|
|
itemStyle: { color: getCssVar('--van-gray-6') } // 使用灰色表示其他
|
|
})
|
|
} else {
|
|
chartData = list.map((item, index) => ({
|
|
value: item.amount,
|
|
name: item.classify || '未分类',
|
|
itemStyle: { color: props.colors[index % props.colors.length] }
|
|
}))
|
|
}
|
|
|
|
const option = {
|
|
title: {
|
|
text: '¥' + formatMoney(props.totalExpense),
|
|
subtext: '总支出',
|
|
left: 'center',
|
|
top: 'center',
|
|
textStyle: {
|
|
color: getCssVar('--chart-text-muted'), // 适配深色模式
|
|
fontSize: 20,
|
|
fontWeight: 'bold'
|
|
},
|
|
subtextStyle: {
|
|
color: getCssVar('--chart-text-muted'),
|
|
fontSize: 13
|
|
}
|
|
},
|
|
tooltip: {
|
|
trigger: 'item',
|
|
formatter: (params) => {
|
|
return `${params.name}: ¥${formatMoney(params.value)} (${params.percent.toFixed(1)}%)`
|
|
}
|
|
},
|
|
series: [
|
|
{
|
|
name: '支出分类',
|
|
type: 'pie',
|
|
radius: ['50%', '80%'],
|
|
avoidLabelOverlap: true,
|
|
minAngle: 5, // 最小扇区角度,防止扇区太小看不见
|
|
itemStyle: {
|
|
borderRadius: 5,
|
|
borderColor: getCssVar('--van-background-2'),
|
|
borderWidth: 2
|
|
},
|
|
label: {
|
|
show: false
|
|
},
|
|
labelLine: {
|
|
show: false
|
|
},
|
|
data: chartData
|
|
}
|
|
]
|
|
}
|
|
|
|
pieChartInstance.setOption(option)
|
|
}
|
|
|
|
// 监听数据变化重新渲染图表
|
|
watch(() => [props.categories, props.totalExpense, props.colors], () => {
|
|
nextTick(() => {
|
|
renderPieChart()
|
|
})
|
|
}, { deep: true, immediate: true })
|
|
|
|
// 组件销毁时清理图表实例
|
|
onBeforeUnmount(() => {
|
|
if (pieChartInstance && !pieChartInstance.isDisposed()) {
|
|
pieChartInstance.dispose()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
@import '@/assets/theme.css';
|
|
|
|
// 通用卡片样式
|
|
.common-card {
|
|
background: var(--bg-primary);
|
|
border-radius: var(--radius-lg);
|
|
padding: var(--spacing-xl);
|
|
margin-bottom: var(--spacing-xl);
|
|
box-shadow: var(--shadow-sm);
|
|
}
|
|
|
|
.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: 200px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
/* 分类列表 */
|
|
.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: 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> |