发布
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 28s
Docker Build & Deploy / Deploy to Production (push) Successful in 11s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s

This commit is contained in:
孙诚
2026-01-16 15:56:53 +08:00
parent f77cc57cab
commit 0c95b6aa6e
8 changed files with 1150 additions and 906 deletions

View File

@@ -517,19 +517,11 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
{
statistics.TotalExpense += amount;
statistics.ExpenseCount++;
if (amount > statistics.MaxExpense)
{
statistics.MaxExpense = amount;
}
}
else if (record.Type == TransactionType.Income)
{
statistics.TotalIncome += amount;
statistics.IncomeCount++;
if (amount > statistics.MaxIncome)
{
statistics.MaxIncome = amount;
}
}
}
@@ -778,8 +770,6 @@ public class MonthlyStatistics
public int ExpenseCount { get; set; }
public int IncomeCount { get; set; }
public int TotalCount { get; set; }
public decimal MaxExpense { get; set; }
public decimal MaxIncome { get; set; }
}
/// <summary>

View File

@@ -17,8 +17,6 @@
* @returns {Object} data.expenseCount - 支出笔数
* @returns {Object} data.incomeCount - 收入笔数
* @returns {Object} data.totalCount - 总笔数
* @returns {Object} data.maxExpense - 最大单笔支出
* @returns {Object} data.maxIncome - 最大单笔收入
*/
export const getMonthlyStatistics = (params) => {
return request({

View File

@@ -1,4 +1,4 @@
/* color palette from <https://github.com/vuejs/theme> */
/* color palette from <https://github.com/vuejs/theme> */
/*
Most variables are replaced by Vant CSS variables.
Keeping only what's necessary or mapping to Vant.
@@ -17,6 +17,55 @@
--color-text: var(--van-text-color);
--section-gap: 160px;
/* Chart Colors */
--chart-color-1: #FF6B6B;
--chart-color-2: #4ECDC4;
--chart-color-3: #45B7D1;
--chart-color-4: #FFA07A;
--chart-color-5: #98D8C8;
--chart-color-6: #F7DC6F;
--chart-color-7: #BB8FCE;
--chart-color-8: #85C1E2;
--chart-color-9: #F8B88B;
--chart-color-10: #AAB7B8;
--chart-color-11: #FF8ED4;
--chart-color-12: #67E6DC;
--chart-color-13: #FFAB73;
--chart-color-14: #C9B1FF;
--chart-color-15: #7BDFF2;
/* Status Colors for Charts */
--chart-success: #52c41a;
--chart-warning: #faad14;
--chart-danger: #f5222d;
--chart-primary: #1890ff;
--chart-shadow: rgba(0,138,255,0.45);
--chart-axis: #E6EBF8;
--chart-split: #eee;
--chart-text-muted: #999;
/* Heatmap Colors - Light Mode */
--heatmap-level-0: var(--van-gray-2);
--heatmap-level-1: #9be9a8;
--heatmap-level-2: #40c463;
--heatmap-level-3: #30a14e;
--heatmap-level-4: #216e39;
}
@media (prefers-color-scheme: dark) {
:root {
--chart-axis: #333;
--chart-split: #333;
--chart-text-muted: #666;
/* Heatmap Colors - Dark Mode (GitHub Style) */
--heatmap-level-0: #161b22;
--heatmap-level-1: #0e4429;
--heatmap-level-2: #006d32;
--heatmap-level-3: #26a641;
--heatmap-level-4: #39d353;
}
}
/* Removed manual dark mode media query as Vant handles it */

View File

@@ -0,0 +1,514 @@
<template>
<div class="chart-analysis-container">
<!-- 仪表盘整体健康度 -->
<div class="gauges-row">
<!-- 月度仪表盘 -->
<div class="chart-card gauge-card">
<div class="chart-header">
<div class="chart-title">
月度健康度
</div>
<div class="chart-subtitle">
本月预算
</div>
</div>
<div
ref="monthGaugeRef"
class="chart-body gauge-chart"
/>
<div class="gauge-footer compact">
<div class="gauge-item">
<span class="label">已用</span>
<span class="value expense">¥{{ formatMoney(overallStats.month.current) }}</span>
</div>
<div class="gauge-item">
<span class="label">预算</span>
<span class="value">¥{{ formatMoney(overallStats.month.limit) }}</span>
</div>
</div>
</div>
<!-- 年度仪表盘 -->
<div class="chart-card gauge-card">
<div class="chart-header">
<div class="chart-title">
年度健康度
</div>
<div class="chart-subtitle">
本年预算
</div>
</div>
<div
ref="yearGaugeRef"
class="chart-body gauge-chart"
/>
<div class="gauge-footer compact">
<div class="gauge-item">
<span class="label">已用</span>
<span class="value expense">¥{{ formatMoney(overallStats.year.current) }}</span>
</div>
<div class="gauge-item">
<span class="label">预算</span>
<span class="value">¥{{ formatMoney(overallStats.year.limit) }}</span>
</div>
</div>
</div>
</div>
<!-- 月度预算进度 -->
<div
v-if="budgets.length > 0"
class="chart-card"
>
<div class="chart-header">
<div class="chart-title">
预算进度月度
</div>
<div class="chart-subtitle">
本月各预算执行情况
</div>
</div>
<div
ref="monthBarChartRef"
class="chart-body bar-chart"
:style="{ height: calculateChartHeight() + 'px' }"
/>
</div>
<!-- 年度预算进度 -->
<div
v-if="budgets.length > 0"
class="chart-card"
style="margin-top: 12px"
>
<div class="chart-header">
<div class="chart-title">
预算进度年度
</div>
<div class="chart-subtitle">
本年各预算执行情况
</div>
</div>
<div
ref="yearBarChartRef"
class="chart-body bar-chart"
:style="{ height: calculateChartHeight() + 'px' }"
/>
</div>
<!-- 空状态占位 -->
<div
v-else
class="chart-card empty-card"
>
<van-empty
description="暂无预算数据"
image="search"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, onUnmounted } from 'vue'
import * as echarts from 'echarts'
import { BudgetCategory } from '@/constants/enums'
import { getCssVar } from '@/utils/theme'
const props = defineProps({
overallStats: {
type: Object,
required: true
},
budgets: {
type: Array,
default: () => []
},
activeTab: {
type: [Number, String],
default: BudgetCategory.Expense
}
})
const monthGaugeRef = ref(null)
const yearGaugeRef = ref(null)
const monthBarChartRef = ref(null)
const yearBarChartRef = ref(null)
let monthGaugeChart = null
let yearGaugeChart = null
let monthBarChart = null
let yearBarChart = null
const formatMoney = (val) => {
if (val >= 10000) {
return (val / 10000).toFixed(1) + 'w'
}
return parseFloat(val || 0).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
}
const initGaugeChart = (chartInstance, dom, data, isExpense) => {
if (!dom) { return null }
const chart = echarts.init(dom)
updateSingleGauge(chart, data, isExpense)
return chart
}
const updateSingleGauge = (chart, data, isExpense) => {
if (!chart) { return }
const rate = parseFloat(data.rate || 0)
// 颜色逻辑
let color = getCssVar('--chart-success') // 绿色
if (isExpense) {
if (rate >= 100) { color = getCssVar('--chart-danger') } // 红色
else if (rate >= 80) { color = getCssVar('--chart-warning') } // 橙色
} else {
if (rate >= 100) { color = getCssVar('--chart-success') } // 绿色
else if (rate >= 80) { color = getCssVar('--chart-warning') } // 橙色
else { color = getCssVar('--chart-danger') } // 红色
}
const option = {
series: [
{
type: 'gauge',
startAngle: 180,
endAngle: 0,
min: 0,
max: Math.max(100, rate * 1.2),
splitNumber: 5,
radius: '110%', // 放大一点以适应小卡片
center: ['50%', '75%'],
itemStyle: {
color: color,
shadowColor: getCssVar('--chart-shadow'),
shadowBlur: 10,
shadowOffsetX: 2,
shadowOffsetY: 2
},
progress: {
show: true,
roundCap: true,
width: 12 // 变细一点
},
pointer: { show: false },
axisLine: {
roundCap: true,
lineStyle: {
width: 12,
color: [[1, getCssVar('--chart-axis')]]
}
},
axisTick: { show: false },
splitLine: { show: false },
axisLabel: { show: false },
detail: {
valueAnimation: true,
fontSize: 24, // 字体调小
offsetCenter: [0, -5],
color: 'var(--van-text-color)',
formatter: '{value}%',
fontWeight: 'bold',
fontFamily: 'DIN Alternate, system-ui'
},
data: [{ value: rate }]
}
]
}
chart.setOption(option)
}
const updateCharts = () => {
const isExpense = props.activeTab === BudgetCategory.Expense
updateSingleGauge(monthGaugeChart, props.overallStats.month, isExpense)
updateSingleGauge(yearGaugeChart, props.overallStats.year, isExpense)
// 确保 barChart 已初始化,如果还未初始化则先初始化
if (props.budgets.length > 0) {
if (!monthBarChart && monthBarChartRef.value) {
monthBarChart = echarts.init(monthBarChartRef.value)
}
if (!yearBarChart && yearBarChartRef.value) {
yearBarChart = echarts.init(yearBarChartRef.value)
}
updateBarChart()
}
}
const updateBarChart = () => {
if (!monthBarChart || !yearBarChart) { return }
const sortedBudgets = [...props.budgets].sort((a, b) => b.current - a.current).slice(0, 10)
const categories = sortedBudgets.map(b => b.name)
// 月度数据
const monthLimits = sortedBudgets.map(b => b.monthLimit || b.limit)
const monthCurrents = sortedBudgets.map(b => b.monthCurrent || b.current)
// 年度数据
const yearLimits = sortedBudgets.map(b => b.yearLimit || b.limit)
const yearCurrents = sortedBudgets.map(b => b.yearCurrent || b.current)
const getColors = (data, limits) => {
return data.map((current, idx) => {
const limit = limits[idx]
const rate = limit > 0 ? (current / limit) : 0
if (props.activeTab === BudgetCategory.Expense) {
if (rate >= 1) { return getCssVar('--chart-danger') }
if (rate >= 0.8) { return getCssVar('--chart-warning') }
return getCssVar('--chart-primary')
} else {
if (rate >= 1) { return getCssVar('--chart-success') }
return getCssVar('--chart-primary')
}
})
}
const monthColors = getColors(monthCurrents, monthLimits)
const yearColors = getColors(yearCurrents, yearLimits)
// 获取当前主题下的颜色值
const textColor = getCssVar('--van-text-color')
const textColor2 = getCssVar('--van-text-color-2')
const bgColor3 = getCssVar('--van-background-3')
const splitLineColor = getCssVar('--chart-split')
const axisLabelColor = getCssVar('--chart-text-muted')
const createOption = (limits, currents, colors) => {
return {
grid: {
left: '3%',
right: '8%',
bottom: '3%',
top: '3%',
containLabel: true
},
xAxis: {
type: 'value',
splitLine: {
lineStyle: {
type: 'dashed',
color: splitLineColor
}
},
axisLabel: {
color: axisLabelColor,
formatter: (value) => {
if (value >= 10000) { return (value / 10000).toFixed(0) + 'w' }
return value
}
}
},
yAxis: {
type: 'category',
data: categories,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: {
color: textColor,
width: 50,
overflow: 'truncate',
interval: 0
}
},
series: [
{
name: '预算',
type: 'bar',
data: limits,
barWidth: 10,
itemStyle: {
color: bgColor3,
borderRadius: 5
},
z: 1,
label: {
show: true,
position: 'right',
formatter: (params) => {
const val = params.value
return val >= 10000 ? (val / 10000).toFixed(1) + 'w' : val
},
color: textColor2,
fontSize: 10
}
},
{
name: '实际',
type: 'bar',
data: currents,
barGap: '-100%',
barWidth: 10,
itemStyle: {
color: (params) => colors[params.dataIndex],
borderRadius: 5
},
z: 2
}
]
}
}
monthBarChart.setOption(createOption(monthLimits, monthCurrents, monthColors))
yearBarChart.setOption(createOption(yearLimits, yearCurrents, yearColors))
}
const calculateChartHeight = () => {
// 根据数据数量动态计算图表高度
// 每个类别占用 60px最少显示 200px最多 400px
const dataCount = Math.min(props.budgets.length, 10) // 最多显示10条
const minHeight = 150
const maxHeight = 400
const heightPerItem = 60
const calculatedHeight = Math.max(minHeight, dataCount * heightPerItem)
return Math.min(calculatedHeight, maxHeight)
}
watch(() => props.overallStats, () => nextTick(updateCharts), { deep: true })
watch(() => props.budgets, () => {
nextTick(() => {
if (props.budgets.length > 0 && (!monthBarChart || !yearBarChart) && monthBarChartRef.value && yearBarChartRef.value) {
// budgets 从空到有值,需要初始化图表
monthBarChart = echarts.init(monthBarChartRef.value)
yearBarChart = echarts.init(yearBarChartRef.value)
updateBarChart()
} else {
updateCharts()
}
})
}, { deep: true })
watch(() => props.activeTab, () => nextTick(updateCharts))
const handleResize = () => {
monthGaugeChart?.resize()
yearGaugeChart?.resize()
monthBarChart?.resize()
yearBarChart?.resize()
}
onMounted(() => {
nextTick(() => {
const isExpense = props.activeTab === BudgetCategory.Expense
monthGaugeChart = initGaugeChart(monthGaugeChart, monthGaugeRef.value, props.overallStats.month, isExpense)
yearGaugeChart = initGaugeChart(yearGaugeChart, yearGaugeRef.value, props.overallStats.year, isExpense)
// 只在有数据时初始化柱状图
if (props.budgets.length > 0) {
if (monthBarChartRef.value) {
monthBarChart = echarts.init(monthBarChartRef.value)
}
if (yearBarChartRef.value) {
yearBarChart = echarts.init(yearBarChartRef.value)
}
updateBarChart()
}
window.addEventListener('resize', handleResize)
})
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
monthGaugeChart?.dispose()
yearGaugeChart?.dispose()
monthBarChart?.dispose()
yearBarChart?.dispose()
})
</script>
<style scoped>
.chart-analysis-container {
padding: 12px;
padding-bottom: 80px;
}
.gauges-row {
display: flex;
gap: 12px;
margin-bottom: 12px;
}
.chart-card {
background: var(--van-background-2);
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.gauge-card {
flex: 1;
min-width: 0;
/* 防止 flex 子项溢出 */
padding: 12px;
/* 减小内边距 */
}
.chart-header {
margin-bottom: 12px;
}
.chart-title {
font-size: 14px;
/* 调小标题 */
font-weight: 600;
color: var(--van-text-color);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chart-subtitle {
font-size: 10px;
color: var(--van-text-color-2);
}
.chart-body {
width: 100%;
}
.gauge-chart {
height: 120px;
/* 调小高度 */
}
.bar-chart {
min-height: 200px;
max-height: 400px;
}
.gauge-footer {
display: flex;
justify-content: space-around;
/* 分散对齐 */
align-items: center;
margin-top: -20px;
position: relative;
z-index: 10;
}
.gauge-item {
display: flex;
flex-direction: column;
align-items: center;
}
.gauge-item .label {
font-size: 10px;
color: var(--van-text-color-2);
transform: scale(0.9);
/* 视觉上更小 */
}
.gauge-item .value {
font-size: 12px;
font-weight: 600;
font-family: DIN Alternate, system-ui;
color: var(--van-text-color);
}
.gauge-item .value.expense {
color: var(--van-primary-color);
}
</style>

View File

@@ -3,13 +3,22 @@
<div class="grid-row">
<!-- Weekday Labels (Fixed Left) -->
<div class="weekday-col-fixed">
<div class="weekday-label"></div>
<div class="weekday-label"></div>
<div class="weekday-label"></div>
<div class="weekday-label">
</div>
<div class="weekday-label">
</div>
<div class="weekday-label">
</div>
</div>
<!-- Scrollable Heatmap Area -->
<div ref="scrollContainer" class="heatmap-scroll-container">
<div
ref="scrollContainer"
class="heatmap-scroll-container"
>
<div class="heatmap-content">
<!-- Month Labels -->
<div class="month-row">
@@ -25,7 +34,11 @@
<!-- Heatmap Grid -->
<div class="heatmap-grid">
<div v-for="(week, wIndex) in weeks" :key="wIndex" class="heatmap-week">
<div
v-for="(week, wIndex) in weeks"
:key="wIndex"
class="heatmap-week"
>
<div
v-for="(day, dIndex) in week"
:key="dIndex"
@@ -42,7 +55,12 @@
</div>
<div class="heatmap-footer">
<div v-if="totalCount > 0" class="summary-text">过去一年共 {{ totalCount }} 笔交易</div>
<div
v-if="totalCount > 0"
class="summary-text"
>
过去一年共 {{ totalCount }} 笔交易
</div>
<div class="legend">
<span></span>
<div class="legend-item level-0" />
@@ -110,7 +128,6 @@ const fetchData = async () => {
}
const avg = last15DaysSum / 15
console.log('avg', avg)
// Step size calculation: ensure at least 1, roughly avg/2 to create spread
// Level 1: 1 ~ step
// Level 2: step+1 ~ step*2
@@ -127,7 +144,6 @@ const fetchData = async () => {
}
const generateHeatmapData = (startDate, endDate) => {
const data = []
const current = new Date(startDate)
const allDays = []
@@ -143,7 +159,6 @@ const generateHeatmapData = (startDate, endDate) => {
// Sunday (0) -> 6 days back
// Tuesday (2) -> 1 day back
const daysToSubtract = (startDay + 6) % 7
// We don't necessarily need to subtract from startDate for data fetching,
// but for grid alignment we want the first column to start on Monday.
@@ -393,62 +408,19 @@ onMounted(() => {
}
.level-0 {
background-color: var(--van-gray-2);
background-color: var(--heatmap-level-0);
}
/* Default (Light Mode) - Light to Deep Green */
.level-1 {
background-color: #9be9a8;
background-color: var(--heatmap-level-1);
}
.level-2 {
background-color: #40c463;
background-color: var(--heatmap-level-2);
}
.level-3 {
background-color: #30a14e;
background-color: var(--heatmap-level-3);
}
.level-4 {
background-color: #216e39;
}
/* Dark Mode - Dark to Light/Bright Green (GitHub Dark Mode Style) */
/* The user requested "From Light to Deep" (浅至深) which usually means standard heatmap logic (darker = more).
HOWEVER, in dark interfaces, usually "Brighter = More".
If the user explicitly says "colors are wrong, should be from light to deep", and they are referring to the visual gradient:
If they mean visual brightness:
Light (Dim) -> Deep (Bright)
Let's stick to the GitHub Dark Mode palette which is scientifically designed for dark backgrounds:
L1 (Less): Dark Green (#0e4429)
L4 (More): Neon Green (#39d353)
This is visually "Dim to Bright".
If the user meant "Light color to Dark color" literally (like white -> black green), that would look bad on dark mode.
"浅至深" in color context usually implies saturation/intensity.
Let's restore the GitHub Dark Mode colors for dark mode, as my previous change might have inverted them incorrectly or caused confusion.
GitHub Dark Mode:
L0: #161b22
L1: #0e4429
L2: #006d32
L3: #26a641
L4: #39d353
This goes from Dark Green -> Bright Green.
*/
@media (prefers-color-scheme: dark) {
.level-1 {
background-color: #9be9a8;
}
.level-2 {
background-color: #40c463;
}
.level-3 {
background-color: #30a14e;
}
.level-4 {
background-color: #216e39;
}
background-color: var(--heatmap-level-4);
}
.heatmap-footer {

4
Web/src/utils/theme.js Normal file
View File

@@ -0,0 +1,4 @@
export const getCssVar = (name) => {
const val = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return val || '#999' // Default fallback
}

View File

@@ -5,6 +5,15 @@
title="预算管理"
placeholder
>
<template #title>
<div
class="nav-date-picker"
@click="showDatePicker = true"
>
<span>{{ currentYear }}{{ currentMonth }}</span>
<van-icon name="arrow-down" />
</div>
</template>
<template #right>
<van-icon
v-if="
@@ -26,14 +35,7 @@
@click="showArchiveSummary()"
/>
<van-icon
v-if="activeTab !== BudgetCategory.Savings"
name="plus"
size="20"
title="添加预算"
@click="budgetEditRef.open({ category: activeTab })"
/>
<van-icon
v-else
v-if="activeTab === BudgetCategory.Savings"
name="setting-o"
size="20"
title="储蓄分类配置"
@@ -52,174 +54,26 @@
title="支出"
:name="BudgetCategory.Expense"
>
<BudgetSummary
v-if="activeTab !== BudgetCategory.Savings"
v-model:date="selectedDate"
:stats="overallStats"
:title="activeTabTitle"
:get-value-class="getValueClass"
/>
<van-pull-refresh
v-model="isRefreshing"
class="scroll-content"
@refresh="onRefresh"
>
<div class="budget-list">
<template v-if="expenseBudgets?.length > 0">
<van-swipe-cell
v-for="budget in expenseBudgets"
:key="budget.id"
>
<BudgetCard
:budget="budget"
:progress-color="getProgressColor(budget)"
:percent-class="{
warning: budget.current / budget.limit > 0.8
}"
:period-label="getPeriodLabel(budget.type)"
@click="
budgetEditRef.open({
data: budget,
isEditFlag: true,
category: budget.category
})
"
>
<template #amount-info>
<div class="info-item">
<div class="label">
已支出
</div>
<div class="value expense">
¥{{ formatMoney(budget.current) }}
</div>
</div>
<div class="info-item">
<div class="label">
预算
</div>
<div class="value">
¥{{ formatMoney(budget.limit) }}
</div>
</div>
<div class="info-item">
<div class="label">
余额
</div>
<div
class="value"
:class="budget.limit - budget.current >= 0 ? 'income' : 'expense'"
>
¥{{ formatMoney(budget.limit - budget.current) }}
</div>
</div>
</template>
</BudgetCard>
<template #right>
<van-button
square
text="删除"
type="danger"
class="delete-button"
@click="handleDelete(budget)"
/>
</template>
</van-swipe-cell>
</template>
<van-empty
v-else
description="暂无支出预算"
/>
</div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
</van-pull-refresh>
<div class="scroll-content">
<BudgetChartAnalysis
:overall-stats="overallStats"
:budgets="expenseBudgets"
:active-tab="activeTab"
/>
</div>
</van-tab>
<van-tab
title="收入"
:name="BudgetCategory.Income"
>
<BudgetSummary
v-if="activeTab !== BudgetCategory.Savings"
v-model:date="selectedDate"
:stats="overallStats"
:title="activeTabTitle"
:get-value-class="getValueClass"
/>
<van-pull-refresh
v-model="isRefreshing"
class="scroll-content"
@refresh="onRefresh"
>
<div class="budget-list">
<template v-if="incomeBudgets?.length > 0">
<van-swipe-cell
v-for="budget in incomeBudgets"
:key="budget.id"
>
<BudgetCard
:budget="budget"
:progress-color="getProgressColor(budget)"
:percent-class="{
income: budget.current / budget.limit >= 1
}"
:period-label="getPeriodLabel(budget.type)"
@click="
budgetEditRef.open({
data: budget,
isEditFlag: true,
category: budget.category
})
"
>
<template #amount-info>
<div class="info-item">
<div class="label">
已收入
</div>
<div class="value income">
¥{{ formatMoney(budget.current) }}
</div>
</div>
<div class="info-item">
<div class="label">
目标
</div>
<div class="value">
¥{{ formatMoney(budget.limit) }}
</div>
</div>
<div class="info-item">
<div class="label">
差额
</div>
<div
class="value"
:class="budget.current >= budget.limit ? 'income' : 'expense'"
>
¥{{ formatMoney(Math.abs(budget.limit - budget.current)) }}
</div>
</div>
</template>
</BudgetCard>
<template #right>
<van-button
square
text="删除"
type="danger"
class="delete-button"
@click="handleDelete(budget)"
/>
</template>
</van-swipe-cell>
</template>
<van-empty
v-else
description="暂无收入预算"
/>
</div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
</van-pull-refresh>
<div class="scroll-content">
<BudgetChartAnalysis
:overall-stats="overallStats"
:budgets="incomeBudgets"
:active-tab="activeTab"
/>
</div>
</van-tab>
<van-tab
@@ -305,6 +159,16 @@
</van-tab>
</van-tabs>
<!-- 悬浮编辑按钮 -->
<van-floating-bubble
v-if="activeTab !== BudgetCategory.Savings"
v-model:offset="bubbleOffset"
icon="edit"
axis="xy"
magnetic="x"
@click="showListPopup = true"
/>
<BudgetEditPopup
ref="budgetEditRef"
@success="fetchBudgetList"
@@ -314,6 +178,247 @@
@success="fetchBudgetList"
/>
<!-- 预算明细列表弹窗 -->
<PopupContainer
v-model="showListPopup"
:title="popupTitle"
height="80%"
>
<template #header-actions>
<van-icon
v-if="activeTab !== BudgetCategory.Savings"
name="plus"
size="20"
title="添加预算"
@click="budgetEditRef.open({ category: activeTab })"
/>
<van-icon
v-else
name="setting-o"
size="20"
title="储蓄分类配置"
@click="savingsConfigRef.open()"
/>
</template>
<van-pull-refresh
v-model="isRefreshing"
style="min-height: 100%"
@refresh="onRefresh"
>
<div class="budget-list">
<!-- 支出列表 -->
<template v-if="activeTab === BudgetCategory.Expense">
<template v-if="expenseBudgets?.length > 0">
<van-swipe-cell
v-for="budget in expenseBudgets"
:key="budget.id"
>
<BudgetCard
:budget="budget"
:progress-color="getProgressColor(budget)"
:percent-class="{
warning: budget.current / budget.limit > 0.8
}"
:period-label="getPeriodLabel(budget.type)"
@click="
budgetEditRef.open({
data: budget,
isEditFlag: true,
category: budget.category
})
"
>
<template #amount-info>
<div class="info-item">
<div class="label">
已支出
</div>
<div class="value expense">
¥{{ formatMoney(budget.current) }}
</div>
</div>
<div class="info-item">
<div class="label">
预算
</div>
<div class="value">
¥{{ formatMoney(budget.limit) }}
</div>
</div>
<div class="info-item">
<div class="label">
余额
</div>
<div
class="value"
:class="budget.limit - budget.current >= 0 ? 'income' : 'expense'"
>
¥{{ formatMoney(budget.limit - budget.current) }}
</div>
</div>
</template>
</BudgetCard>
<template #right>
<van-button
square
text="删除"
type="danger"
class="delete-button"
@click="handleDelete(budget)"
/>
</template>
</van-swipe-cell>
</template>
<van-empty
v-else
description="暂无支出预算"
/>
</template>
<!-- 收入列表 -->
<template v-if="activeTab === BudgetCategory.Income">
<template v-if="incomeBudgets?.length > 0">
<van-swipe-cell
v-for="budget in incomeBudgets"
:key="budget.id"
>
<BudgetCard
:budget="budget"
:progress-color="getProgressColor(budget)"
:percent-class="{
income: budget.current / budget.limit >= 1
}"
:period-label="getPeriodLabel(budget.type)"
@click="
budgetEditRef.open({
data: budget,
isEditFlag: true,
category: budget.category
})
"
>
<template #amount-info>
<div class="info-item">
<div class="label">
已收入
</div>
<div class="value income">
¥{{ formatMoney(budget.current) }}
</div>
</div>
<div class="info-item">
<div class="label">
目标
</div>
<div class="value">
¥{{ formatMoney(budget.limit) }}
</div>
</div>
<div class="info-item">
<div class="label">
差额
</div>
<div
class="value"
:class="budget.current >= budget.limit ? 'income' : 'expense'"
>
¥{{ formatMoney(Math.abs(budget.limit - budget.current)) }}
</div>
</div>
</template>
</BudgetCard>
<template #right>
<van-button
square
text="删除"
type="danger"
class="delete-button"
@click="handleDelete(budget)"
/>
</template>
</van-swipe-cell>
</template>
<van-empty
v-else
description="暂无收入预算"
/>
</template>
<!-- 存款列表 -->
<template v-if="activeTab === BudgetCategory.Savings">
<template v-if="savingsBudgets?.length > 0">
<BudgetCard
v-for="budget in savingsBudgets"
:key="budget.id"
:budget="budget"
:progress-color="getProgressColor(budget)"
:percent-class="{ income: budget.current / budget.limit >= 1 }"
:period-label="getPeriodLabel(budget.type)"
style="margin: 0 12px 12px"
>
<template #amount-info>
<div class="info-item">
<div class="label">
已存
</div>
<div class="value income">
¥{{ formatMoney(budget.current) }}
</div>
</div>
<div class="info-item">
<div class="label">
目标
</div>
<div class="value">
¥{{ formatMoney(budget.limit) }}
</div>
</div>
<div class="info-item">
<div class="label">
还差
</div>
<div class="value expense">
¥{{ formatMoney(Math.max(0, budget.limit - budget.current)) }}
</div>
</div>
</template>
<template #footer>
<div class="card-footer-actions">
<van-button
size="small"
icon="arrow-left"
plain
type="primary"
@click.stop="handleSavingsNav(budget, -1)"
/>
<span class="current-date-label">
{{ getSavingsDateLabel(budget) }}
</span>
<van-button
size="small"
icon="arrow"
plain
type="primary"
icon-position="right"
:disabled="disabledSavingsNextNav(budget)"
@click.stop="handleSavingsNav(budget, 1)"
/>
</div>
</template>
</BudgetCard>
</template>
<van-empty
v-else
description="暂无存款计划"
/>
</template>
</div>
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))" />
</van-pull-refresh>
</PopupContainer>
<PopupContainer
v-model="showUncoveredDetails"
title="未覆盖预算的分类"
@@ -373,6 +478,23 @@
/>
</div>
</PopupContainer>
<!-- 日期选择弹窗 -->
<van-popup
v-model:show="showDatePicker"
position="bottom"
round
>
<van-date-picker
v-model="pickerDate"
title="选择年月"
:min-date="minDate"
:max-date="maxDate"
:columns-type="['year', 'month']"
@confirm="onConfirmDate"
@cancel="showDatePicker = false"
/>
</van-popup>
</div>
</template>
@@ -392,16 +514,36 @@ import BudgetCard from '@/components/Budget/BudgetCard.vue'
import BudgetSummary from '@/components/Budget/BudgetSummary.vue'
import BudgetEditPopup from '@/components/Budget/BudgetEditPopup.vue'
import SavingsConfigPopup from '@/components/Budget/SavingsConfigPopup.vue'
import BudgetChartAnalysis from '@/components/Budget/BudgetChartAnalysis.vue'
import PopupContainer from '@/components/PopupContainer.vue'
const activeTab = ref(BudgetCategory.Expense)
const selectedDate = ref(new Date())
const showDatePicker = ref(false)
const minDate = new Date(2020, 0, 1)
const maxDate = new Date(2030, 11, 31)
const pickerDate = ref([
selectedDate.value.getFullYear().toString(),
(selectedDate.value.getMonth() + 1).toString().padStart(2, '0')
])
const currentYear = computed(() => selectedDate.value.getFullYear())
const currentMonth = computed(() => selectedDate.value.getMonth() + 1)
const budgetEditRef = ref(null)
const savingsConfigRef = ref(null)
const isRefreshing = ref(false)
const showUncoveredDetails = ref(false)
const showListPopup = ref(false)
const uncoveredCategories = ref([])
// 初始化悬浮按钮位置,避免遮挡底部导航栏
// 默认位置:右下角,距离底部 100px (避开 Tabbar),距离右侧 24px
const bubbleOffset = ref({
x: window.innerWidth - 48 - 24,
y: window.innerHeight - 48 - 100
})
const showSummaryPopup = ref(false)
const archiveSummary = ref('')
@@ -420,6 +562,19 @@ const activeTabTitle = computed(() => {
return '达成'
})
const popupTitle = computed(() => {
switch (activeTab.value) {
case BudgetCategory.Expense:
return '支出预算明细'
case BudgetCategory.Income:
return '收入预算明细'
case BudgetCategory.Savings:
return '存款计划明细'
default:
return '预算明细'
}
})
const isArchive = computed(() => {
const now = new Date()
return (
@@ -437,6 +592,12 @@ watch(selectedDate, async () => {
await Promise.all([fetchBudgetList(), fetchCategoryStats(), fetchUncoveredCategories()])
})
const onConfirmDate = ({ selectedValues }) => {
const [year, month] = selectedValues
selectedDate.value = new Date(parseInt(year), parseInt(month) - 1, 1)
showDatePicker.value = false
}
const getValueClass = (rate) => {
const numRate = parseFloat(rate)
if (numRate === 0) {
@@ -844,4 +1005,13 @@ const disabledSavingsNextNav = (budget) => {
:deep(.van-nav-bar) {
background: transparent !important;
}
.nav-date-picker {
display: flex;
align-items: center;
gap: 4px;
font-size: 16px;
font-weight: 600;
color: var(--van-text-color);
}
</style>

File diff suppressed because it is too large Load Diff