Files
kt-financial-system/apps/web-antd/src/views/finance/statistics/index.vue
woshiqp465 1def26f74f feat: 更新财务系统功能和界面优化
- 优化财务仪表板数据展示
- 增强账户管理功能
- 改进预算和分类管理
- 完善报表和统计分析
- 优化交易管理界面
- 更新Workspace工作区

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 15:10:06 +08:00

1300 lines
37 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.

<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue';
import {
Alert,
Badge,
Button,
Card,
DatePicker,
notification,
Progress,
Radio,
RangePicker,
Select,
Table,
TabPane,
Tabs,
} from 'ant-design-vue';
import dayjs, { Dayjs } from 'dayjs';
import quarterOfYear from 'dayjs/plugin/quarterOfYear';
import * as echarts from 'echarts';
import * as XLSX from 'xlsx';
import { useFinanceStore } from '#/store/finance';
dayjs.extend(quarterOfYear);
const financeStore = useFinanceStore();
// 时间维度
const timeDimension = ref<'custom' | 'monthly' | 'quarterly' | 'yearly'>(
'monthly',
);
const selectedMonth = ref<Dayjs>(dayjs());
const selectedQuarter = ref<string>(`${dayjs().year()}-Q${dayjs().quarter()}`);
const selectedYear = ref<number>(dayjs().year());
const customRange = ref<[Dayjs, Dayjs]>([dayjs().subtract(30, 'day'), dayjs()]);
// 对比类型
const comparisonType = ref<'mom' | 'yoy'>('mom');
// ECharts实例
const cashFlowChart = ref<HTMLElement | null>(null);
const expenseTreeChart = ref<HTMLElement | null>(null);
let cashFlowChartInstance: echarts.ECharts | null = null;
let expenseTreeChartInstance: echarts.ECharts | null = null;
// 计算可用的年份和季度
const availableYears = computed(() => {
const years = new Set<number>();
financeStore.transactions.forEach((t) => {
const year = dayjs(t.transactionDate).year();
years.add(year);
});
return [...years].sort((a, b) => b - a);
});
const availableQuarters = computed(() => {
const quarters = new Set<string>();
financeStore.transactions.forEach((t) => {
const date = dayjs(t.transactionDate);
quarters.add(`${date.year()}-Q${date.quarter()}`);
});
return [...quarters]
.sort((a, b) => b.localeCompare(a))
.map((q) => ({
value: q,
label: `${q.replace('-Q', '年第')}季度`,
}));
});
// 获取当前期间的交易数据
const getCurrentPeriodTransactions = () => {
let endDate: Dayjs, startDate: Dayjs;
switch (timeDimension.value) {
case 'custom': {
startDate = customRange.value[0];
endDate = customRange.value[1];
break;
}
case 'monthly': {
startDate = selectedMonth.value.startOf('month');
endDate = selectedMonth.value.endOf('month');
break;
}
case 'quarterly': {
const [year, quarter] = selectedQuarter.value.split('-Q');
startDate = dayjs(
`${year}-${(Number.parseInt(quarter) - 1) * 3 + 1}-01`,
).startOf('month');
endDate = startDate.add(2, 'month').endOf('month');
break;
}
case 'yearly': {
startDate = dayjs(`${selectedYear.value}-01-01`);
endDate = dayjs(`${selectedYear.value}-12-31`);
break;
}
}
return financeStore.transactions.filter((t) => {
const date = dayjs(t.transactionDate);
return (
date.isAfter(startDate.subtract(1, 'day')) &&
date.isBefore(endDate.add(1, 'day'))
);
});
};
// 当前期间数据
const currentPeriodData = computed(() => {
const transactions = getCurrentPeriodTransactions();
const income = transactions
.filter((t) => t.type === 'income')
.reduce((sum, t) => sum + t.amountInBase, 0);
const expense = transactions
.filter((t) => t.type === 'expense')
.reduce((sum, t) => sum + t.amountInBase, 0);
const expenseTransactions = transactions.filter((t) => t.type === 'expense');
const maxExpenseTransaction =
expenseTransactions.length > 0
? expenseTransactions.reduce((max, t) =>
t.amountInBase > max.amountInBase ? t : max,
)
: null;
return {
totalIncome: income,
totalExpense: expense,
netIncome: income - expense,
transactionCount: transactions.length,
expenseCount: expenseTransactions.length,
avgAmount:
expenseTransactions.length > 0 ? expense / expenseTransactions.length : 0,
maxExpense: maxExpenseTransaction?.amountInBase || 0,
maxExpenseCategory: maxExpenseTransaction
? financeStore.getCategoryById(maxExpenseTransaction.categoryId)?.name
: null,
};
});
// 对比数据
const comparisonData = computed(() => {
// 简化版本,后续可以添加更复杂的对比逻辑
return {
momExpense: 0,
momIncome: 0,
momCount: 0,
yoyExpense: 0,
yoyIncome: 0,
yoyCount: 0,
incomeTrend: null,
expenseTrend: null,
incomeCompareText: '',
expenseCompareText: '',
};
});
// 智能洞察
const smartInsights = computed(() => {
const insights = [];
// 洞察1: 支出趋势
insights.push({
type: 'expense_trend',
icon: '📉',
title: '支出趋势',
description: `本期总支出 ¥${currentPeriodData.value.totalExpense.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
value: `${currentPeriodData.value.expenseCount}`,
trend: null,
valueClass: 'text-red-600',
trendClass: '',
alertLevel: 'bg-red-50 border-red-200',
isAlert: false,
});
// 洞察2: 高频分类
const categoryStats = new Map<number, { amount: number; count: number }>();
getCurrentPeriodTransactions()
.filter((t) => t.type === 'expense' && t.categoryId)
.forEach((t) => {
const stat = categoryStats.get(t.categoryId!) || { count: 0, amount: 0 };
stat.count++;
stat.amount += t.amountInBase;
categoryStats.set(t.categoryId!, stat);
});
if (categoryStats.size > 0) {
const topCategory = [...categoryStats.entries()].sort(
(a, b) => b[1].count - a[1].count,
)[0];
const category = financeStore.getCategoryById(topCategory[0]);
insights.push({
type: 'top_category',
icon: category?.icon || '📊',
title: '高频分类',
description: `${category?.name || '未知'} 是本期最常用的支出分类`,
value: `${topCategory[1].count}`,
trend: null,
valueClass: 'text-blue-600',
trendClass: '',
alertLevel: 'bg-blue-50 border-blue-200',
isAlert: false,
});
}
// 洞察3: 平均单笔
insights.push({
type: 'avg_amount',
icon: '💎',
title: '平均单笔',
description: '本期平均每笔支出金额',
value: `¥${currentPeriodData.value.avgAmount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`,
trend: null,
valueClass: 'text-purple-600',
trendClass: '',
alertLevel: 'bg-purple-50 border-purple-200',
isAlert: false,
});
return insights;
});
// 分类健康度评分
const categoryHealthScores = computed(() => {
const categoryStats = new Map<number, { amount: number; count: number }>();
const transactions = getCurrentPeriodTransactions().filter(
(t) => t.type === 'expense' && t.categoryId,
);
const totalExpense = currentPeriodData.value.totalExpense;
transactions.forEach((t) => {
const stat = categoryStats.get(t.categoryId!) || { count: 0, amount: 0 };
stat.count++;
stat.amount += t.amountInBase;
categoryStats.set(t.categoryId!, stat);
});
return [...categoryStats.entries()]
.map(([categoryId, stat]) => {
const category = financeStore.getCategoryById(categoryId);
const percentage =
totalExpense > 0 ? (stat.amount / totalExpense) * 100 : 0;
let status: 'exception' | 'normal' | 'success' = 'normal';
let healthLabel = '正常';
let badgeColor = 'green';
let color = '#52c41a';
if (percentage > 40) {
status = 'exception';
healthLabel = '偏高';
badgeColor = 'red';
color = '#ff4d4f';
} else if (percentage > 25) {
status = 'normal';
healthLabel = '关注';
badgeColor = 'orange';
color = '#faad14';
}
return {
categoryId,
categoryName: category?.name || '未知',
icon: category?.icon || '📝',
amount: stat.amount,
count: stat.count,
percentage: Math.round(percentage),
status,
healthLabel,
badgeColor,
color,
};
})
.sort((a, b) => b.amount - a.amount);
});
// 异常交易检测
const anomalies = computed(() => {
const transactions = getCurrentPeriodTransactions().filter(
(t) => t.type === 'expense',
);
const avgAmount = currentPeriodData.value.avgAmount;
// 检测: 金额超过平均值3倍的交易
return transactions
.filter((t) => t.amountInBase > avgAmount * 3 && avgAmount > 0)
.map((t) => ({
id: t.id,
description: t.description || t.project || '无描述',
date: dayjs(t.transactionDate).format('YYYY-MM-DD'),
category: financeStore.getCategoryById(t.categoryId)?.name || '未分类',
amount: t.amountInBase,
reason: `金额是平均值的 ${(t.amountInBase / avgAmount).toFixed(1)}`,
}))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5);
});
// 前后切换
const canGoPrevious = ref(true);
const canGoNext = ref(true);
const previousPeriod = () => {
switch (timeDimension.value) {
case 'monthly': {
selectedMonth.value = selectedMonth.value.subtract(1, 'month');
break;
}
case 'quarterly': {
const [year, quarter] = selectedQuarter.value.split('-Q');
const prevQ = Number.parseInt(quarter) - 1;
selectedQuarter.value =
prevQ < 1 ? `${Number.parseInt(year) - 1}-Q4` : `${year}-Q${prevQ}`;
break;
}
case 'yearly': {
selectedYear.value--;
break;
}
}
};
const nextPeriod = () => {
switch (timeDimension.value) {
case 'monthly': {
selectedMonth.value = selectedMonth.value.add(1, 'month');
break;
}
case 'quarterly': {
const [year, quarter] = selectedQuarter.value.split('-Q');
const nextQ = Number.parseInt(quarter) + 1;
selectedQuarter.value =
nextQ > 4 ? `${Number.parseInt(year) + 1}-Q1` : `${year}-Q${nextQ}`;
break;
}
case 'yearly': {
selectedYear.value++;
break;
}
}
};
const handleDimensionChange = () => {
nextTick(() => {
initCharts();
});
};
const handleMonthChange = () => {
nextTick(() => {
initCharts();
});
};
const handleQuarterChange = () => {
nextTick(() => {
initCharts();
});
};
const handleYearChange = () => {
nextTick(() => {
initCharts();
});
};
const handleCustomRangeChange = () => {
nextTick(() => {
initCharts();
});
};
// 初始化图表
const initCharts = () => {
initCashFlowChart();
initExpenseTreeChart();
};
// 现金流趋势图
const initCashFlowChart = () => {
if (!cashFlowChart.value) return;
if (cashFlowChartInstance) {
cashFlowChartInstance.dispose();
}
cashFlowChartInstance = echarts.init(cashFlowChart.value);
const transactions = getCurrentPeriodTransactions();
const dateMap = new Map<string, { expense: number; income: number }>();
transactions.forEach((t) => {
const date = dayjs(t.transactionDate).format('YYYY-MM-DD');
const stat = dateMap.get(date) || { income: 0, expense: 0 };
if (t.type === 'income') {
stat.income += t.amountInBase;
} else if (t.type === 'expense') {
stat.expense += t.amountInBase;
}
dateMap.set(date, stat);
});
const dates = [...dateMap.keys()].sort();
const incomeData = dates.map((d) => dateMap.get(d)!.income);
const expenseData = dates.map((d) => dateMap.get(d)!.expense);
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
},
},
legend: {
data: ['收入', '支出', '净收入'],
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dates.map((d) => dayjs(d).format('MM-DD')),
},
yAxis: {
type: 'value',
axisLabel: {
formatter: '¥{value}',
},
},
series: [
{
name: '收入',
type: 'line',
data: incomeData,
smooth: true,
itemStyle: { color: '#52c41a' },
areaStyle: { opacity: 0.3 },
},
{
name: '支出',
type: 'line',
data: expenseData,
smooth: true,
itemStyle: { color: '#ff4d4f' },
areaStyle: { opacity: 0.3 },
},
{
name: '净收入',
type: 'line',
data: dates.map((d, i) => incomeData[i] - expenseData[i]),
smooth: true,
itemStyle: { color: '#1890ff' },
},
],
};
cashFlowChartInstance.setOption(option);
};
// 支出结构树图
const initExpenseTreeChart = () => {
if (!expenseTreeChart.value) return;
if (expenseTreeChartInstance) {
expenseTreeChartInstance.dispose();
}
expenseTreeChartInstance = echarts.init(expenseTreeChart.value);
const categoryStats = new Map<number, number>();
getCurrentPeriodTransactions()
.filter((t) => t.type === 'expense' && t.categoryId)
.forEach((t) => {
const amount = categoryStats.get(t.categoryId!) || 0;
categoryStats.set(t.categoryId!, amount + t.amountInBase);
});
const data = [...categoryStats.entries()]
.map(([categoryId, amount]) => {
const category = financeStore.getCategoryById(categoryId);
return {
name: `${category?.icon || ''} ${category?.name || '未知'}`,
value: amount,
};
})
.sort((a, b) => b.value - a.value);
const option = {
tooltip: {
trigger: 'item',
formatter: '{b}: ¥{c} ({d}%)',
},
series: [
{
type: 'treemap',
data,
leafDepth: 1,
label: {
show: true,
formatter: '{b}\n¥{c}',
},
upperLabel: {
show: true,
height: 30,
},
itemStyle: {
borderColor: '#fff',
borderWidth: 2,
},
levels: [
{
itemStyle: {
borderColor: '#777',
borderWidth: 0,
gapWidth: 1,
},
upperLabel: {
show: false,
},
},
{
itemStyle: {
borderColor: '#555',
borderWidth: 5,
gapWidth: 1,
},
emphasis: {
itemStyle: {
borderColor: '#ddd',
},
},
},
],
},
],
};
expenseTreeChartInstance.setOption(option);
};
// 表格视图相关状态
const tableViewYear = ref<number>(dayjs().year());
const tableViewMode = ref<'category' | 'summary'>('summary');
// 汇总表格列定义
const summaryTableColumns = [
{
title: '月份',
dataIndex: 'month',
key: 'month',
width: 100,
fixed: 'left',
},
{
title: '收入',
dataIndex: 'income',
key: 'income',
width: 150,
align: 'right',
},
{
title: '支出',
dataIndex: 'expense',
key: 'expense',
width: 150,
align: 'right',
},
{ title: '净收入', dataIndex: 'net', key: 'net', width: 150, align: 'right' },
{
title: '交易笔数',
dataIndex: 'count',
key: 'count',
width: 100,
align: 'center',
},
{
title: '储蓄率',
dataIndex: 'savingsRate',
key: 'savingsRate',
width: 100,
align: 'center',
},
];
// 分类表格列定义
const categoryTableColumns = computed(() => {
const expenseCategories = financeStore.expenseCategories || [];
const columns: any[] = [
{
title: '月份',
dataIndex: 'month',
key: 'month',
width: 100,
fixed: 'left',
},
];
// 添加每个分类的列
expenseCategories.forEach((cat) => {
columns.push({
title: `${cat.icon} ${cat.name}`,
dataIndex: `cat_${cat.id}`,
key: `cat_${cat.id}`,
width: 150,
align: 'right',
});
});
columns.push({
title: '总计',
dataIndex: 'total',
key: 'total',
width: 150,
align: 'right',
fixed: 'right',
});
return columns;
});
// 月度汇总数据
const monthlyTableData = computed(() => {
const year = tableViewYear.value;
const data: any[] = [];
for (let month = 1; month <= 12; month++) {
const monthStr = `${year}-${String(month).padStart(2, '0')}`;
const monthTransactions = financeStore.transactions.filter((t) => {
return dayjs(t.transactionDate).format('YYYY-MM') === monthStr;
});
const income = monthTransactions
.filter((t) => t.type === 'income')
.reduce((sum, t) => sum + t.amountInBase, 0);
const expense = monthTransactions
.filter((t) => t.type === 'expense')
.reduce((sum, t) => sum + t.amountInBase, 0);
const net = income - expense;
const savingsRate = income > 0 ? Math.round((net / income) * 100) : 0;
data.push({
month: `${month}`,
income,
expense,
net,
count: monthTransactions.length,
savingsRate,
});
}
// 添加合计行
const totalIncome = data.reduce((sum, row) => sum + row.income, 0);
const totalExpense = data.reduce((sum, row) => sum + row.expense, 0);
const totalCount = data.reduce((sum, row) => sum + row.count, 0);
const avgSavingsRate =
totalIncome > 0
? Math.round(((totalIncome - totalExpense) / totalIncome) * 100)
: 0;
data.push({
month: '合计',
income: totalIncome,
expense: totalExpense,
net: totalIncome - totalExpense,
count: totalCount,
savingsRate: avgSavingsRate,
});
return data;
});
// 月度分类明细数据
const monthlyCategoryData = computed(() => {
const year = tableViewYear.value;
const data: any[] = [];
const expenseCategories = financeStore.expenseCategories || [];
for (let month = 1; month <= 12; month++) {
const monthStr = `${year}-${String(month).padStart(2, '0')}`;
const monthTransactions = financeStore.transactions.filter((t) => {
return (
dayjs(t.transactionDate).format('YYYY-MM') === monthStr &&
t.type === 'expense'
);
});
const row: any = {
month: `${month}`,
total: 0,
};
// 计算每个分类的支出
expenseCategories.forEach((cat) => {
const catExpense = monthTransactions
.filter((t) => t.categoryId === cat.id)
.reduce((sum, t) => sum + t.amountInBase, 0);
if (catExpense > 0) {
const total = monthTransactions.reduce(
(sum, t) => sum + t.amountInBase,
0,
);
const percentage =
total > 0 ? Math.round((catExpense / total) * 100) : 0;
row[`cat_${cat.id}`] = {
amount: catExpense,
percentage,
};
row.total += catExpense;
}
});
data.push(row);
}
// 添加合计行
const totalRow: any = {
month: '合计',
total: 0,
};
expenseCategories.forEach((cat) => {
const catTotal = data.reduce((sum, row) => {
return sum + (row[`cat_${cat.id}`]?.amount || 0);
}, 0);
if (catTotal > 0) {
const grandTotal = data.reduce((sum, row) => sum + row.total, 0);
const percentage =
grandTotal > 0 ? Math.round((catTotal / grandTotal) * 100) : 0;
totalRow[`cat_${cat.id}`] = {
amount: catTotal,
percentage,
};
totalRow.total += catTotal;
}
});
data.push(totalRow);
return data;
});
// 处理年份变化
const handleTableYearChange = () => {
// 数据会自动重新计算
};
// 导出Excel
const exportMonthlyTable = () => {
try {
const data =
tableViewMode.value === 'summary'
? monthlyTableData.value
: monthlyCategoryData.value;
const ws = XLSX.utils.json_to_sheet(data);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, `${tableViewYear.value}年数据`);
XLSX.writeFile(
wb,
`财务统计_${tableViewYear.value}年_${tableViewMode.value === 'summary' ? '汇总' : '分类'}.xlsx`,
);
notification.success({
message: '导出成功',
description: '数据已成功导出为Excel文件',
});
} catch {
notification.error({
message: '导出失败',
description: '导出Excel文件时出错请重试',
});
}
};
// 加载数据
onMounted(async () => {
await financeStore.fetchTransactions();
await financeStore.fetchCategories();
nextTick(() => {
initCharts();
});
});
// 监听窗口大小变化
window.addEventListener('resize', () => {
cashFlowChartInstance?.resize();
expenseTreeChartInstance?.resize();
});
</script>
<template>
<div class="p-6">
<div class="mb-6">
<h1 class="mb-2 text-3xl font-bold text-gray-900">📊 财务统计分析</h1>
<p class="text-gray-600">多维度智能分析您的财务数据</p>
</div>
<!-- 时间维度选择器 -->
<Card class="mb-6">
<Tabs v-model:active-key="timeDimension" @change="handleDimensionChange">
<TabPane key="monthly" tab="📅 月度分析">
<div class="flex items-center space-x-4 py-2">
<Button @click="previousPeriod" :disabled="!canGoPrevious">
</Button>
<DatePicker
v-model:value="selectedMonth"
picker="month"
format="YYYY年MM月"
@change="handleMonthChange"
style="width: 200px"
/>
<Button @click="nextPeriod" :disabled="!canGoNext"> </Button>
<span class="text-gray-500"> {{ currentPeriodData.transactionCount }} 笔交易</span>
</div>
</TabPane>
<TabPane key="quarterly" tab="📊 季度分析">
<div class="flex items-center space-x-4 py-2">
<Button @click="previousPeriod" :disabled="!canGoPrevious">
</Button>
<Select
v-model:value="selectedQuarter"
style="width: 200px"
@change="handleQuarterChange"
>
<Select.Option
v-for="quarter in availableQuarters"
:key="quarter.value"
:value="quarter.value"
>
{{ quarter.label }}
</Select.Option>
</Select>
<Button @click="nextPeriod" :disabled="!canGoNext"> </Button>
<span class="text-gray-500"> {{ currentPeriodData.transactionCount }} 笔交易</span>
</div>
</TabPane>
<TabPane key="yearly" tab="📈 年度分析">
<div class="flex items-center space-x-4 py-2">
<Button @click="previousPeriod" :disabled="!canGoPrevious">
</Button>
<Select
v-model:value="selectedYear"
style="width: 200px"
@change="handleYearChange"
>
<Select.Option
v-for="year in availableYears"
:key="year"
:value="year"
>
{{ year }}
</Select.Option>
</Select>
<Button @click="nextPeriod" :disabled="!canGoNext"> </Button>
<span class="text-gray-500"> {{ currentPeriodData.transactionCount }} 笔交易</span>
</div>
</TabPane>
<TabPane key="custom" tab="🎯 自定义">
<div class="flex items-center space-x-4 py-2">
<RangePicker
v-model:value="customRange"
format="YYYY-MM-DD"
@change="handleCustomRangeChange"
/>
<span class="text-gray-500"> {{ currentPeriodData.transactionCount }} 笔交易</span>
</div>
</TabPane>
</Tabs>
</Card>
<!-- 智能洞察卡片 -->
<Card class="mb-6" title="💡 智能洞察">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div
v-for="insight in smartInsights"
:key="insight.type"
class="rounded-lg border p-4"
:class="insight.alertLevel"
>
<div class="mb-2 flex items-start justify-between">
<span class="text-2xl">{{ insight.icon }}</span>
<Badge v-if="insight.isAlert" status="error" text="异常" />
</div>
<h3 class="mb-2 font-semibold">{{ insight.title }}</h3>
<p class="mb-3 text-sm text-gray-600">{{ insight.description }}</p>
<div class="flex items-center justify-between">
<span class="text-xl font-bold" :class="insight.valueClass">{{
insight.value
}}</span>
<span
v-if="insight.trend"
class="text-sm"
:class="insight.trendClass"
>
<template v-if="insight.trend > 0"> +{{ insight.trend }}%</template>
<template v-else> {{ insight.trend }}%</template>
</span>
</div>
</div>
</div>
</Card>
<!-- 关键指标卡片 -->
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
<Card class="text-center transition-shadow hover:shadow-lg">
<div class="space-y-2">
<div class="text-3xl">💰</div>
<p class="text-sm text-gray-500">期间总收入</p>
<p class="text-2xl font-bold text-green-600">
¥{{
currentPeriodData.totalIncome.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
})
}}
</p>
<p
v-if="comparisonData.incomeTrend !== null"
class="text-xs"
:class="
comparisonData.incomeTrend >= 0
? 'text-green-600'
: 'text-red-600'
"
>
{{ comparisonData.incomeCompareText }}
</p>
</div>
</Card>
<Card class="text-center transition-shadow hover:shadow-lg">
<div class="space-y-2">
<div class="text-3xl">💸</div>
<p class="text-sm text-gray-500">期间总支出</p>
<p class="text-2xl font-bold text-red-600">
¥{{
currentPeriodData.totalExpense.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
})
}}
</p>
<p
v-if="comparisonData.expenseTrend !== null"
class="text-xs"
:class="
comparisonData.expenseTrend <= 0
? 'text-green-600'
: 'text-red-600'
"
>
{{ comparisonData.expenseCompareText }}
</p>
</div>
</Card>
<Card class="text-center transition-shadow hover:shadow-lg">
<div class="space-y-2">
<div class="text-3xl">📊</div>
<p class="text-sm text-gray-500">平均单笔金额</p>
<p class="text-2xl font-bold text-blue-600">
¥{{
currentPeriodData.avgAmount.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
})
}}
</p>
<p class="text-xs text-gray-500">
支出笔数: {{ currentPeriodData.expenseCount }}
</p>
</div>
</Card>
<Card class="text-center transition-shadow hover:shadow-lg">
<div class="space-y-2">
<div class="text-3xl">🏆</div>
<p class="text-sm text-gray-500">最大单笔支出</p>
<p class="text-2xl font-bold text-orange-600">
¥{{
currentPeriodData.maxExpense.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
})
}}
</p>
<p
v-if="currentPeriodData.maxExpenseCategory"
class="text-xs text-gray-500"
>
{{ currentPeriodData.maxExpenseCategory }}
</p>
</div>
</Card>
</div>
<!-- 对比分析卡片 -->
<Card class="mb-6" title="📈 对比分析">
<Tabs v-model:active-key="comparisonType">
<TabPane key="mom" tab="环比分析">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-lg bg-blue-50 p-4">
<p class="mb-2 text-sm text-gray-600">支出环比</p>
<p
class="text-2xl font-bold"
:class="
comparisonData.momExpense < 0
? 'text-green-600'
: 'text-red-600'
"
>
{{ comparisonData.momExpense >= 0 ? '+' : ''
}}{{ comparisonData.momExpense }}%
</p>
</div>
<div class="rounded-lg bg-green-50 p-4">
<p class="mb-2 text-sm text-gray-600">收入环比</p>
<p
class="text-2xl font-bold"
:class="
comparisonData.momIncome >= 0
? 'text-green-600'
: 'text-red-600'
"
>
{{ comparisonData.momIncome >= 0 ? '+' : ''
}}{{ comparisonData.momIncome }}%
</p>
</div>
<div class="rounded-lg bg-purple-50 p-4">
<p class="mb-2 text-sm text-gray-600">交易笔数环比</p>
<p class="text-2xl font-bold text-purple-600">
{{ comparisonData.momCount >= 0 ? '+' : ''
}}{{ comparisonData.momCount }}%
</p>
</div>
</div>
</TabPane>
<TabPane key="yoy" tab="同比分析">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-lg bg-blue-50 p-4">
<p class="mb-2 text-sm text-gray-600">支出同比</p>
<p
class="text-2xl font-bold"
:class="
comparisonData.yoyExpense < 0
? 'text-green-600'
: 'text-red-600'
"
>
{{ comparisonData.yoyExpense >= 0 ? '+' : ''
}}{{ comparisonData.yoyExpense }}%
</p>
</div>
<div class="rounded-lg bg-green-50 p-4">
<p class="mb-2 text-sm text-gray-600">收入同比</p>
<p
class="text-2xl font-bold"
:class="
comparisonData.yoyIncome >= 0
? 'text-green-600'
: 'text-red-600'
"
>
{{ comparisonData.yoyIncome >= 0 ? '+' : ''
}}{{ comparisonData.yoyIncome }}%
</p>
</div>
<div class="rounded-lg bg-purple-50 p-4">
<p class="mb-2 text-sm text-gray-600">交易笔数同比</p>
<p class="text-2xl font-bold text-purple-600">
{{ comparisonData.yoyCount >= 0 ? '+' : ''
}}{{ comparisonData.yoyCount }}%
</p>
</div>
</div>
</TabPane>
</Tabs>
</Card>
<!-- 现金流趋势图 -->
<Card class="mb-6" title="💹 现金流趋势">
<div ref="cashFlowChart" style="width: 100%; height: 400px"></div>
</Card>
<!-- 支出结构树图 -->
<Card class="mb-6" title="🌳 支出结构分析">
<div ref="expenseTreeChart" style="width: 100%; height: 500px"></div>
</Card>
<!-- 分类健康度评分 -->
<Card class="mb-6" title="💪 分类健康度评分">
<div class="space-y-3">
<div
v-for="health in categoryHealthScores"
:key="health.categoryId"
class="flex items-center justify-between rounded-lg bg-gray-50 p-3"
>
<div class="flex items-center space-x-3">
<span class="text-2xl">{{ health.icon }}</span>
<div>
<p class="font-semibold">{{ health.categoryName }}</p>
<p class="text-xs text-gray-500">
¥{{
health.amount.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
})
}}
| {{ health.count }}
</p>
</div>
</div>
<div class="flex items-center space-x-4">
<div class="w-32">
<Progress
:percent="health.percentage"
:status="health.status"
:stroke-color="health.color"
/>
</div>
<Badge :color="health.badgeColor" :text="health.healthLabel" />
</div>
</div>
</div>
</Card>
<!-- 异常检测 -->
<Card v-if="anomalies.length > 0" title="⚠️ 异常交易检测" class="mb-6">
<Alert
message="发现异常交易"
:description="`检测到 ${anomalies.length} 笔可能需要关注的交易`"
type="warning"
show-icon
class="mb-4"
/>
<div class="space-y-2">
<div
v-for="anomaly in anomalies"
:key="anomaly.id"
class="rounded border-l-4 border-orange-500 bg-orange-50 p-3"
>
<div class="flex items-center justify-between">
<div>
<p class="font-semibold">{{ anomaly.description }}</p>
<p class="text-sm text-gray-600">
{{ anomaly.date }} | {{ anomaly.category }}
</p>
</div>
<div class="text-right">
<p class="text-lg font-bold text-red-600">
-¥{{
anomaly.amount.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
})
}}
</p>
<p class="text-xs text-orange-600">{{ anomaly.reason }}</p>
</div>
</div>
</div>
</div>
</Card>
<!-- 月度表格视图 -->
<Card title="📋 月度数据表格" class="mb-6">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center space-x-4">
<Select
v-model:value="tableViewYear"
style="width: 120px"
@change="handleTableYearChange"
>
<Select.Option
v-for="year in availableYears"
:key="year"
:value="year"
>
{{ year }}
</Select.Option>
</Select>
<Radio.Group v-model:value="tableViewMode" button-style="solid">
<Radio.Button value="summary">收支汇总</Radio.Button>
<Radio.Button value="category">分类明细</Radio.Button>
</Radio.Group>
</div>
<Button type="primary" @click="exportMonthlyTable">
📥 导出Excel
</Button>
</div>
<!-- 汇总视图 -->
<Table
v-if="tableViewMode === 'summary'"
:columns="summaryTableColumns"
:data-source="monthlyTableData"
:pagination="false"
:scroll="{ x: 800 }"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'income'">
<span class="font-semibold text-green-600">
¥{{
record.income.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
})
}}
</span>
</template>
<template v-else-if="column.key === 'expense'">
<span class="font-semibold text-red-600">
¥{{
record.expense.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
})
}}
</span>
</template>
<template v-else-if="column.key === 'net'">
<span
:class="record.net >= 0 ? 'text-green-600' : 'text-red-600'"
class="font-bold"
>
{{ record.net >= 0 ? '+' : '' }}¥{{
record.net.toLocaleString('zh-CN', { minimumFractionDigits: 2 })
}}
</span>
</template>
<template v-else-if="column.key === 'savingsRate'">
<span
:class="
record.savingsRate >= 30
? 'text-green-600'
: record.savingsRate >= 10
? 'text-orange-600'
: 'text-red-600'
"
>
{{ record.savingsRate }}%
</span>
</template>
</template>
</Table>
<!-- 分类明细视图 -->
<Table
v-else
:columns="categoryTableColumns"
:data-source="monthlyCategoryData"
:pagination="false"
:scroll="{ x: 1200 }"
bordered
>
<template #bodyCell="{ column, record }">
<template
v-if="column.dataIndex !== 'month' && column.dataIndex !== 'total'"
>
<div v-if="record[column.dataIndex]">
<div class="font-semibold">
¥{{
record[column.dataIndex].amount.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
})
}}
</div>
<div class="text-xs text-gray-500">
{{ record[column.dataIndex].percentage }}%
</div>
</div>
<div v-else class="text-gray-400">-</div>
</template>
<template v-else-if="column.key === 'total'">
<span class="font-bold text-red-600">
¥{{
record.total.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
})
}}
</span>
</template>
</template>
</Table>
</Card>
</div>
</template>
<style scoped>
.ant-card {
border-radius: 8px;
}
</style>