发布
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.TotalExpense += amount;
statistics.ExpenseCount++; statistics.ExpenseCount++;
if (amount > statistics.MaxExpense)
{
statistics.MaxExpense = amount;
}
} }
else if (record.Type == TransactionType.Income) else if (record.Type == TransactionType.Income)
{ {
statistics.TotalIncome += amount; statistics.TotalIncome += amount;
statistics.IncomeCount++; statistics.IncomeCount++;
if (amount > statistics.MaxIncome)
{
statistics.MaxIncome = amount;
}
} }
} }
@@ -778,8 +770,6 @@ public class MonthlyStatistics
public int ExpenseCount { get; set; } public int ExpenseCount { get; set; }
public int IncomeCount { get; set; } public int IncomeCount { get; set; }
public int TotalCount { get; set; } public int TotalCount { get; set; }
public decimal MaxExpense { get; set; }
public decimal MaxIncome { get; set; }
} }
/// <summary> /// <summary>

View File

@@ -17,8 +17,6 @@
* @returns {Object} data.expenseCount - 支出笔数 * @returns {Object} data.expenseCount - 支出笔数
* @returns {Object} data.incomeCount - 收入笔数 * @returns {Object} data.incomeCount - 收入笔数
* @returns {Object} data.totalCount - 总笔数 * @returns {Object} data.totalCount - 总笔数
* @returns {Object} data.maxExpense - 最大单笔支出
* @returns {Object} data.maxIncome - 最大单笔收入
*/ */
export const getMonthlyStatistics = (params) => { export const getMonthlyStatistics = (params) => {
return request({ 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. Most variables are replaced by Vant CSS variables.
Keeping only what's necessary or mapping to Vant. Keeping only what's necessary or mapping to Vant.
@@ -17,6 +17,55 @@
--color-text: var(--van-text-color); --color-text: var(--van-text-color);
--section-gap: 160px; --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 */ /* 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"> <div class="grid-row">
<!-- Weekday Labels (Fixed Left) --> <!-- Weekday Labels (Fixed Left) -->
<div class="weekday-col-fixed"> <div class="weekday-col-fixed">
<div class="weekday-label"></div> <div class="weekday-label">
<div class="weekday-label"></div>
<div class="weekday-label"></div> </div>
<div class="weekday-label">
</div>
<div class="weekday-label">
</div>
</div> </div>
<!-- Scrollable Heatmap Area --> <!-- Scrollable Heatmap Area -->
<div ref="scrollContainer" class="heatmap-scroll-container"> <div
ref="scrollContainer"
class="heatmap-scroll-container"
>
<div class="heatmap-content"> <div class="heatmap-content">
<!-- Month Labels --> <!-- Month Labels -->
<div class="month-row"> <div class="month-row">
@@ -25,7 +34,11 @@
<!-- Heatmap Grid --> <!-- Heatmap Grid -->
<div class="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 <div
v-for="(day, dIndex) in week" v-for="(day, dIndex) in week"
:key="dIndex" :key="dIndex"
@@ -42,7 +55,12 @@
</div> </div>
<div class="heatmap-footer"> <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"> <div class="legend">
<span></span> <span></span>
<div class="legend-item level-0" /> <div class="legend-item level-0" />
@@ -110,7 +128,6 @@ const fetchData = async () => {
} }
const avg = last15DaysSum / 15 const avg = last15DaysSum / 15
console.log('avg', avg)
// Step size calculation: ensure at least 1, roughly avg/2 to create spread // Step size calculation: ensure at least 1, roughly avg/2 to create spread
// Level 1: 1 ~ step // Level 1: 1 ~ step
// Level 2: step+1 ~ step*2 // Level 2: step+1 ~ step*2
@@ -127,7 +144,6 @@ const fetchData = async () => {
} }
const generateHeatmapData = (startDate, endDate) => { const generateHeatmapData = (startDate, endDate) => {
const data = []
const current = new Date(startDate) const current = new Date(startDate)
const allDays = [] const allDays = []
@@ -143,7 +159,6 @@ const generateHeatmapData = (startDate, endDate) => {
// Sunday (0) -> 6 days back // Sunday (0) -> 6 days back
// Tuesday (2) -> 1 day back // Tuesday (2) -> 1 day back
const daysToSubtract = (startDay + 6) % 7
// We don't necessarily need to subtract from startDate for data fetching, // 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. // but for grid alignment we want the first column to start on Monday.
@@ -393,62 +408,19 @@ onMounted(() => {
} }
.level-0 { .level-0 {
background-color: var(--van-gray-2); background-color: var(--heatmap-level-0);
} }
/* Default (Light Mode) - Light to Deep Green */
.level-1 { .level-1 {
background-color: #9be9a8; background-color: var(--heatmap-level-1);
} }
.level-2 { .level-2 {
background-color: #40c463; background-color: var(--heatmap-level-2);
} }
.level-3 { .level-3 {
background-color: #30a14e; background-color: var(--heatmap-level-3);
} }
.level-4 { .level-4 {
background-color: #216e39; background-color: var(--heatmap-level-4);
}
/* 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;
}
} }
.heatmap-footer { .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="预算管理" title="预算管理"
placeholder placeholder
> >
<template #title>
<div
class="nav-date-picker"
@click="showDatePicker = true"
>
<span>{{ currentYear }}{{ currentMonth }}</span>
<van-icon name="arrow-down" />
</div>
</template>
<template #right> <template #right>
<van-icon <van-icon
v-if=" v-if="
@@ -26,14 +35,7 @@
@click="showArchiveSummary()" @click="showArchiveSummary()"
/> />
<van-icon <van-icon
v-if="activeTab !== BudgetCategory.Savings" v-if="activeTab === BudgetCategory.Savings"
name="plus"
size="20"
title="添加预算"
@click="budgetEditRef.open({ category: activeTab })"
/>
<van-icon
v-else
name="setting-o" name="setting-o"
size="20" size="20"
title="储蓄分类配置" title="储蓄分类配置"
@@ -52,174 +54,26 @@
title="支出" title="支出"
:name="BudgetCategory.Expense" :name="BudgetCategory.Expense"
> >
<BudgetSummary <div class="scroll-content">
v-if="activeTab !== BudgetCategory.Savings" <BudgetChartAnalysis
v-model:date="selectedDate" :overall-stats="overallStats"
:stats="overallStats" :budgets="expenseBudgets"
:title="activeTabTitle" :active-tab="activeTab"
:get-value-class="getValueClass" />
/> </div>
<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>
</van-tab> </van-tab>
<van-tab <van-tab
title="收入" title="收入"
:name="BudgetCategory.Income" :name="BudgetCategory.Income"
> >
<BudgetSummary <div class="scroll-content">
v-if="activeTab !== BudgetCategory.Savings" <BudgetChartAnalysis
v-model:date="selectedDate" :overall-stats="overallStats"
:stats="overallStats" :budgets="incomeBudgets"
:title="activeTabTitle" :active-tab="activeTab"
:get-value-class="getValueClass" />
/> </div>
<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>
</van-tab> </van-tab>
<van-tab <van-tab
@@ -305,6 +159,16 @@
</van-tab> </van-tab>
</van-tabs> </van-tabs>
<!-- 悬浮编辑按钮 -->
<van-floating-bubble
v-if="activeTab !== BudgetCategory.Savings"
v-model:offset="bubbleOffset"
icon="edit"
axis="xy"
magnetic="x"
@click="showListPopup = true"
/>
<BudgetEditPopup <BudgetEditPopup
ref="budgetEditRef" ref="budgetEditRef"
@success="fetchBudgetList" @success="fetchBudgetList"
@@ -314,6 +178,247 @@
@success="fetchBudgetList" @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 <PopupContainer
v-model="showUncoveredDetails" v-model="showUncoveredDetails"
title="未覆盖预算的分类" title="未覆盖预算的分类"
@@ -373,6 +478,23 @@
/> />
</div> </div>
</PopupContainer> </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> </div>
</template> </template>
@@ -392,16 +514,36 @@ import BudgetCard from '@/components/Budget/BudgetCard.vue'
import BudgetSummary from '@/components/Budget/BudgetSummary.vue' import BudgetSummary from '@/components/Budget/BudgetSummary.vue'
import BudgetEditPopup from '@/components/Budget/BudgetEditPopup.vue' import BudgetEditPopup from '@/components/Budget/BudgetEditPopup.vue'
import SavingsConfigPopup from '@/components/Budget/SavingsConfigPopup.vue' import SavingsConfigPopup from '@/components/Budget/SavingsConfigPopup.vue'
import BudgetChartAnalysis from '@/components/Budget/BudgetChartAnalysis.vue'
import PopupContainer from '@/components/PopupContainer.vue' import PopupContainer from '@/components/PopupContainer.vue'
const activeTab = ref(BudgetCategory.Expense) const activeTab = ref(BudgetCategory.Expense)
const selectedDate = ref(new Date()) 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 budgetEditRef = ref(null)
const savingsConfigRef = ref(null) const savingsConfigRef = ref(null)
const isRefreshing = ref(false) const isRefreshing = ref(false)
const showUncoveredDetails = ref(false) const showUncoveredDetails = ref(false)
const showListPopup = ref(false)
const uncoveredCategories = ref([]) const uncoveredCategories = ref([])
// 初始化悬浮按钮位置,避免遮挡底部导航栏
// 默认位置:右下角,距离底部 100px (避开 Tabbar),距离右侧 24px
const bubbleOffset = ref({
x: window.innerWidth - 48 - 24,
y: window.innerHeight - 48 - 100
})
const showSummaryPopup = ref(false) const showSummaryPopup = ref(false)
const archiveSummary = ref('') const archiveSummary = ref('')
@@ -420,6 +562,19 @@ const activeTabTitle = computed(() => {
return '达成' 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 isArchive = computed(() => {
const now = new Date() const now = new Date()
return ( return (
@@ -437,6 +592,12 @@ watch(selectedDate, async () => {
await Promise.all([fetchBudgetList(), fetchCategoryStats(), fetchUncoveredCategories()]) 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 getValueClass = (rate) => {
const numRate = parseFloat(rate) const numRate = parseFloat(rate)
if (numRate === 0) { if (numRate === 0) {
@@ -844,4 +1005,13 @@ const disabledSavingsNextNav = (budget) => {
:deep(.van-nav-bar) { :deep(.van-nav-bar) {
background: transparent !important; 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> </style>

File diff suppressed because it is too large Load Diff