Files
EmailBill/Web/src/views/StatisticsView.vue
孙诚 09393f8ee5
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 20s
Docker Build & Deploy / Deploy to Production (push) Successful in 5s
新增:统计功能
2025-12-26 17:13:57 +08:00

834 lines
20 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="statistics-container">
<!-- 顶部导航栏 -->
<van-nav-bar title="账单统计" placeholder>
<template #right>
<van-icon name="chat-o" size="20" @click="goToAnalysis" />
</template>
</van-nav-bar>
<!-- 下拉刷新 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<!-- 加载中 -->
<van-loading v-if="loading" vertical style="padding: 100px 0">
加载统计数据中...
</van-loading>
<!-- 统计内容 -->
<div v-else class="statistics-content">
<!-- 月份选择器 -->
<div class="month-selector">
<van-button
icon="arrow-left"
plain
size="small"
@click="changeMonth(-1)"
/>
<div class="month-text" @click="showMonthPicker = true">
{{ currentYear }}{{ currentMonth }}
<van-icon name="arrow-down" />
</div>
<van-button
icon="arrow"
plain
size="small"
@click="changeMonth(1)"
:disabled="isCurrentMonth"
/>
</div>
<!-- 月度概览卡片 -->
<div class="overview-card">
<div class="overview-item">
<div class="label">总支出</div>
<div class="value expense">¥{{ formatMoney(monthlyData.totalExpense) }}</div>
<div class="sub-text">{{ monthlyData.expenseCount }}</div>
</div>
<div class="divider"></div>
<div class="overview-item">
<div class="label">总收入</div>
<div class="value income">¥{{ formatMoney(monthlyData.totalIncome) }}</div>
<div class="sub-text">{{ monthlyData.incomeCount }}</div>
</div>
<div class="divider"></div>
<div class="overview-item">
<div class="label">结余</div>
<div class="value" :class="monthlyData.balance >= 0 ? 'income' : 'expense'">
{{ monthlyData.balance >= 0 ? '' : '-' }}¥{{ formatMoney(Math.abs(monthlyData.balance)) }}
</div>
<div class="sub-text">{{ monthlyData.totalCount }}笔交易</div>
</div>
</div>
<!-- 分类统计 -->
<div class="stat-card">
<div class="stat-header">
<h3 class="stat-title">支出分类统计</h3>
<van-tag type="primary" size="medium">{{ expenseCategories.length }}</van-tag>
</div>
<!-- 环形图区域 -->
<div class="chart-container" v-if="expenseCategories.length > 0">
<div class="ring-chart">
<svg viewBox="0 0 200 200" class="ring-svg">
<circle
v-for="(segment, index) in chartSegments"
:key="index"
cx="100"
cy="100"
r="70"
fill="none"
:stroke="segment.color"
:stroke-width="35"
:stroke-dasharray="`${segment.length} ${circumference - segment.length}`"
:stroke-dashoffset="-segment.offset"
transform="rotate(-90 100 100)"
class="ring-segment"
/>
</svg>
<div class="ring-center">
<div class="center-value">¥{{ formatMoney(monthlyData.totalExpense) }}</div>
<div class="center-label">总支出</div>
</div>
</div>
</div>
<!-- 分类列表 -->
<div class="category-list" v-if="expenseCategories.length > 0">
<div
v-for="(category) in expenseCategories"
:key="category.classify"
class="category-item"
>
<div class="category-info">
<div class="category-color" :style="{ backgroundColor: category.color }"></div>
<span class="category-name">{{ category.classify || '未分类' }}</span>
</div>
<div class="category-stats">
<div class="category-amount">¥{{ formatMoney(category.amount) }}</div>
<div class="category-percent">{{ category.percent }}%</div>
</div>
</div>
</div>
<van-empty
v-else
description="本月暂无支出记录"
image="search"
/>
</div>
<!-- 收入分类统计 -->
<div class="stat-card" v-if="incomeCategories.length > 0">
<div class="stat-header">
<h3 class="stat-title">收入分类统计</h3>
<van-tag type="success" size="medium">{{ incomeCategories.length }}</van-tag>
</div>
<div class="category-list">
<div
v-for="category in incomeCategories"
:key="category.classify"
class="category-item"
>
<div class="category-info">
<div class="category-color income-color"></div>
<span class="category-name">{{ category.classify || '未分类' }}</span>
</div>
<div class="category-stats">
<div class="category-amount income-text">¥{{ formatMoney(category.amount) }}</div>
<div class="category-percent">{{ category.percent }}%</div>
</div>
</div>
</div>
</div>
<!-- 趋势统计 -->
<div class="stat-card">
<div class="stat-header">
<h3 class="stat-title">近6个月趋势</h3>
</div>
<div class="trend-chart">
<div class="trend-bars">
<div
v-for="item in trendData"
:key="item.month"
class="trend-bar-group"
>
<div class="bar-container">
<div
class="bar expense-bar"
:style="{ height: getBarHeight(item.expense, maxTrendValue) }"
>
<div class="bar-value" v-if="item.expense > 0">
{{ formatShortMoney(item.expense) }}
</div>
</div>
<div
class="bar income-bar"
:style="{ height: getBarHeight(item.income, maxTrendValue) }"
>
<div class="bar-value" v-if="item.income > 0">
{{ formatShortMoney(item.income) }}
</div>
</div>
</div>
<div class="bar-label">{{ item.label }}</div>
</div>
</div>
<div class="trend-legend">
<div class="legend-item">
<div class="legend-color expense-color"></div>
<span>支出</span>
</div>
<div class="legend-item">
<div class="legend-color income-color"></div>
<span>收入</span>
</div>
</div>
</div>
</div>
<!-- 其他统计 -->
<div class="stat-card">
<div class="stat-header">
<h3 class="stat-title">其他统计</h3>
</div>
<div class="other-stats">
<div class="stat-item">
<div class="stat-label">日均支出</div>
<div class="stat-value">¥{{ formatMoney(dailyAverage.expense) }}</div>
</div>
<div class="stat-item">
<div class="stat-label">日均收入</div>
<div class="stat-value income-text">¥{{ formatMoney(dailyAverage.income) }}</div>
</div>
<div class="stat-item">
<div class="stat-label">最大单笔支出</div>
<div class="stat-value">¥{{ formatMoney(monthlyData.maxExpense) }}</div>
</div>
<div class="stat-item">
<div class="stat-label">最大单笔收入</div>
<div class="stat-value income-text">¥{{ formatMoney(monthlyData.maxIncome) }}</div>
</div>
</div>
</div>
<!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</div>
</van-pull-refresh>
<!-- 月份选择器 -->
<van-popup v-model:show="showMonthPicker" position="bottom" round>
<van-date-picker
v-model="selectedDate"
title="选择月份"
:min-date="minDate"
:max-date="maxDate"
:columns-type="['year', 'month']"
@confirm="onMonthConfirm"
@cancel="showMonthPicker = false"
/>
</van-popup>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { showToast } from 'vant'
import { useRouter } from 'vue-router'
import { getMonthlyStatistics, getCategoryStatistics, getTrendStatistics } from '@/api/statistics'
const router = useRouter()
// 响应式数据
const loading = ref(true)
const refreshing = ref(false)
const showMonthPicker = ref(false)
const currentYear = ref(new Date().getFullYear())
const currentMonth = ref(new Date().getMonth() + 1)
const selectedDate = ref([new Date().getFullYear().toString(), (new Date().getMonth() + 1).toString().padStart(2, '0')])
// 月度数据
const monthlyData = ref({
totalExpense: 0,
totalIncome: 0,
balance: 0,
expenseCount: 0,
incomeCount: 0,
totalCount: 0,
maxExpense: 0,
maxIncome: 0
})
// 分类数据
const expenseCategories = ref([])
const incomeCategories = ref([])
// 趋势数据
const trendData = ref([])
// 日期范围
const minDate = new Date(2020, 0, 1)
const maxDate = new Date()
// 颜色配置
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8',
'#F7DC6F', '#BB8FCE', '#85C1E2', '#F8B88B', '#AAB7B8',
'#FF8ED4', '#67E6DC', '#FFAB73', '#C9B1FF', '#7BDFF2'
]
// 计算环形图数据
const circumference = computed(() => 2 * Math.PI * 70)
const chartSegments = computed(() => {
let offset = 0
return expenseCategories.value.map((category) => {
const percent = category.percent / 100
const length = circumference.value * percent
const segment = {
color: category.color,
length,
offset
}
offset += length
return segment
})
})
// 日均统计
const dailyAverage = computed(() => {
const daysInMonth = new Date(currentYear.value, currentMonth.value, 0).getDate()
return {
expense: monthlyData.value.totalExpense / daysInMonth,
income: monthlyData.value.totalIncome / daysInMonth
}
})
// 趋势图最大值
const maxTrendValue = computed(() => {
const allValues = trendData.value.flatMap(item => [item.expense, item.income])
return Math.max(...allValues, 1)
})
// 是否是当前月
const isCurrentMonth = computed(() => {
const now = new Date()
return currentYear.value === now.getFullYear() && currentMonth.value === now.getMonth() + 1
})
// 格式化金额
const formatMoney = (value) => {
if (!value && value !== 0) return '0.00'
return Number(value).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
// 格式化短金额k为单位
const formatShortMoney = (value) => {
if (!value) return '0'
if (value >= 10000) {
return (value / 10000).toFixed(1) + 'w'
}
if (value >= 1000) {
return (value / 1000).toFixed(1) + 'k'
}
return value.toFixed(0)
}
// 获取柱状图高度
const getBarHeight = (value, maxValue) => {
if (!value || !maxValue) return '0%'
const percent = (value / maxValue) * 100
return Math.max(percent, 5) + '%' // 最小5%以便显示
}
// 切换月份
const changeMonth = (offset) => {
let newMonth = currentMonth.value + offset
let newYear = currentYear.value
if (newMonth > 12) {
newMonth = 1
newYear++
} else if (newMonth < 1) {
newMonth = 12
newYear--
}
// 不能超过当前月份
const now = new Date()
const targetDate = new Date(newYear, newMonth - 1)
if (targetDate > now) {
return
}
currentYear.value = newYear
currentMonth.value = newMonth
fetchStatistics()
}
// 确认月份选择
const onMonthConfirm = ({ selectedValues }) => {
currentYear.value = parseInt(selectedValues[0])
currentMonth.value = parseInt(selectedValues[1])
showMonthPicker.value = false
fetchStatistics()
}
// 下拉刷新
const onRefresh = async () => {
await fetchStatistics()
refreshing.value = false
}
// 获取统计数据
const fetchStatistics = async () => {
loading.value = true
try {
await Promise.all([
fetchMonthlyData(),
fetchCategoryData(),
fetchTrendData()
])
} catch (error) {
console.error('获取统计数据失败:', error)
showToast('获取统计数据失败')
} finally {
loading.value = false
}
}
// 获取月度数据
const fetchMonthlyData = async () => {
try {
const response = await getMonthlyStatistics({
year: currentYear.value,
month: currentMonth.value
})
if (response.success && response.data) {
monthlyData.value = response.data
}
} catch (error) {
console.error('获取月度数据失败:', error)
showToast('获取月度数据失败')
}
}
// 获取分类数据
const fetchCategoryData = async () => {
try {
// 获取支出分类
const expenseResponse = await getCategoryStatistics({
year: currentYear.value,
month: currentMonth.value,
type: 0 // 支出
})
if (expenseResponse.success && expenseResponse.data) {
expenseCategories.value = expenseResponse.data.map((item, index) => ({
classify: item.classify,
amount: item.amount,
percent: item.percent,
color: colors[index % colors.length]
}))
}
// 获取收入分类
const incomeResponse = await getCategoryStatistics({
year: currentYear.value,
month: currentMonth.value,
type: 1 // 收入
})
if (incomeResponse.success && incomeResponse.data) {
incomeCategories.value = incomeResponse.data.map(item => ({
classify: item.classify,
amount: item.amount,
percent: item.percent
}))
}
} catch (error) {
console.error('获取分类数据失败:', error)
showToast('获取分类数据失败')
}
}
// 获取趋势数据
const fetchTrendData = async () => {
try {
// 计算开始年月当前月往前推5个月
let startYear = currentYear.value
let startMonth = currentMonth.value - 5
if (startMonth <= 0) {
startMonth += 12
startYear--
}
const response = await getTrendStatistics({
startYear,
startMonth,
monthCount: 6
})
if (response.success && response.data) {
trendData.value = response.data.map(item => ({
year: item.year,
month: item.month,
label: `${item.month}`,
expense: item.expense,
income: item.income
}))
}
} catch (error) {
console.error('获取趋势数据失败:', error)
showToast('获取趋势数据失败')
}
}
// 跳转到智能分析页面
const goToAnalysis = () => {
router.push('/bill-analysis')
}
// 初始化
onMounted(() => {
fetchStatistics()
})
</script>
<style scoped>
.statistics-container {
min-height: 100vh;
background: var(--van-background-2);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.statistics-content {
padding: 16px 0 0 0;
}
/* 月份选择器 */
.month-selector {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
margin: 0 12px 16px;
background: var(--van-background);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.month-text {
font-size: 16px;
font-weight: 600;
color: var(--van-text-color);
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
user-select: none;
}
/* 月度概览卡片 */
.overview-card {
display: flex;
justify-content: space-around;
align-items: center;
background: var(--van-background);
margin: 0 12px 16px;
padding: 24px 12px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border: 1px solid var(--van-border-color);
}
.overview-item {
flex: 1;
text-align: center;
}
.overview-item .label {
font-size: 13px;
color: var(--van-text-color-2);
margin-bottom: 8px;
}
.overview-item .value {
font-size: 20px;
font-weight: 700;
margin-bottom: 4px;
}
.overview-item .sub-text {
font-size: 12px;
color: var(--van-text-color-3);
}
.divider {
width: 1px;
height: 40px;
background: var(--van-border-color);
}
.expense {
color: #ff6b6b;
}
.income {
color: #51cf66;
}
/* 统计卡片 */
.stat-card {
background: var(--van-background);
margin: 0 12px 16px;
padding: 16px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
border: 1px solid var(--van-border-color);
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.stat-title {
font-size: 16px;
font-weight: 600;
color: var(--van-text-color);
margin: 0;
}
/* 环形图 */
.chart-container {
padding: 20px;
}
.ring-chart {
position: relative;
width: 200px;
height: 200px;
margin: 0 auto;
}
.ring-svg {
width: 100%;
height: 100%;
transform: scale(1);
}
.ring-segment {
transition: all 0.3s ease;
}
.ring-center {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
}
.center-value {
font-size: 20px;
font-weight: 700;
color: var(--van-text-color);
margin-bottom: 4px;
}
.center-label {
font-size: 13px;
color: var(--van-text-color-2);
}
/* 分类列表 */
.category-list {
padding: 12px 16px;
}
.category-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--van-border-color);
}
.category-item:last-child {
border-bottom: none;
}
.category-info {
display: flex;
align-items: center;
gap: 12px;
}
.category-color {
width: 12px;
height: 12px;
border-radius: 50%;
}
.category-name {
font-size: 14px;
color: var(--van-text-color);
}
.category-stats {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.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-2);
padding: 2px 8px;
border-radius: 10px;
}
.income-color {
background-color: #51cf66;
}
.income-text {
color: #51cf66;
}
.expense-color {
background-color: #ff6b6b;
}
/* 趋势图 */
.trend-chart {
padding: 20px 16px;
}
.trend-bars {
display: flex;
justify-content: space-between;
align-items: flex-end;
height: 180px;
margin-bottom: 16px;
padding: 0 4px;
}
.trend-bar-group {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.bar-container {
width: 100%;
height: 150px;
display: flex;
justify-content: center;
align-items: flex-end;
gap: 4px;
padding: 0 2px;
}
.bar {
flex: 1;
max-width: 20px;
min-height: 4px;
border-radius: 4px 4px 0 0;
position: relative;
transition: all 0.3s ease;
display: flex;
align-items: flex-start;
justify-content: center;
}
.expense-bar {
background: linear-gradient(180deg, #ff6b6b 0%, #ff8787 100%);
}
.income-bar {
background: linear-gradient(180deg, #51cf66 0%, #69db7c 100%);
}
.bar-value {
font-size: 10px;
color: var(--van-text-color-2);
white-space: nowrap;
margin-top: -18px;
}
.bar-label {
font-size: 11px;
color: var(--van-text-color-3);
}
.trend-legend {
display: flex;
justify-content: center;
gap: 24px;
padding-top: 12px;
border-top: 1px solid var(--van-border-color);
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--van-text-color-2);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
}
/* 其他统计 */
.other-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 16px;
}
.stat-item {
background: var(--van-background-2);
padding: 16px;
border-radius: 12px;
text-align: center;
}
.stat-label {
font-size: 13px;
color: var(--van-text-color-2);
margin-bottom: 8px;
}
.stat-value {
font-size: 18px;
font-weight: 700;
color: var(--van-text-color);
}
</style>