feat: 更新预算摘要组件以支持日期选择;在预算视图中添加日期绑定,优化数据获取逻辑
All checks were successful
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
Docker Build & Deploy / Build Docker Image (push) Successful in 17s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s

This commit is contained in:
2026-01-11 12:41:52 +08:00
parent d2b2158b30
commit 49023237e7
2 changed files with 193 additions and 25 deletions

View File

@@ -1,23 +1,50 @@
<template> <template>
<div v-if="stats && (stats.month || stats.year)" class="summary-card common-card"> <div class="summary-container">
<template v-for="(config, key) in periodConfigs" :key="key"> <transition :name="transitionName" mode="out-in">
<div class="summary-item"> <div v-if="stats && (stats.month || stats.year)" :key="dateKey" class="summary-card common-card">
<div class="label">{{ config.label }}{{ title }}</div> <!-- 左切换按钮 -->
<div class="value" :class="getValueClass(stats[key]?.rate || '0.0')"> <div class="nav-arrow left" @click.stop="changeMonth(-1)">
{{ stats[key]?.rate || '0.0' }}<span class="unit">%</span> <van-icon name="arrow-left" />
</div> </div>
<div class="sub-info">
<span class="amount">¥{{ formatMoney(stats[key]?.current || 0) }}</span> <div class="summary-content">
<span class="separator">/</span> <template v-for="(config, key) in periodConfigs" :key="key">
<span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span> <div class="summary-item">
<div class="label">{{ config.label }}{{ title }}</div>
<div class="value" :class="getValueClass(stats[key]?.rate || '0.0')">
{{ stats[key]?.rate || '0.0' }}<span class="unit">%</span>
</div>
<div class="sub-info">
<span class="amount">¥{{ formatMoney(stats[key]?.current || 0) }}</span>
<span class="separator">/</span>
<span class="amount">¥{{ formatMoney(stats[key]?.limit || 0) }}</span>
</div>
</div>
<div v-if="config.showDivider" class="divider"></div>
</template>
</div>
<!-- 右切换按钮 -->
<div
class="nav-arrow right"
:class="{ disabled: isCurrentMonth }"
@click.stop="!isCurrentMonth && changeMonth(1)"
>
<van-icon name="arrow" />
</div>
<!-- 非本月时显示的日期标识 -->
<div v-if="!isCurrentMonth" class="date-tag">
{{ props.date.getFullYear() }}{{ props.date.getMonth() + 1 }}
</div> </div>
</div> </div>
<div v-if="config.showDivider" class="divider"></div> </transition>
</template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue'
const props = defineProps({ const props = defineProps({
stats: { stats: {
type: Object, type: Object,
@@ -30,12 +57,40 @@ const props = defineProps({
getValueClass: { getValueClass: {
type: Function, type: Function,
required: true required: true
},
date: {
type: Date,
default: () => new Date()
} }
}) })
const periodConfigs = { const emit = defineEmits(['update:date'])
month: { label: '本月', showDivider: true },
year: { label: '年度', showDivider: false } const transitionName = ref('slide-right')
const dateKey = computed(() => props.date.getFullYear() + '-' + props.date.getMonth())
const isCurrentMonth = computed(() => {
const now = new Date()
return props.date.getFullYear() === now.getFullYear() &&
props.date.getMonth() === now.getMonth()
})
const periodConfigs = computed(() => ({
month: {
label: isCurrentMonth.value ? '本月' : `${props.date.getMonth() + 1}`,
showDivider: true
},
year: {
label: isCurrentMonth.value ? '年度' : `${props.date.getFullYear()}`,
showDivider: false
}
}))
const changeMonth = (delta) => {
transitionName.value = delta > 0 ? 'slide-left' : 'slide-right'
const newDate = new Date(props.date)
newDate.setMonth(newDate.getMonth() + delta)
emit('update:date', newDate)
} }
const formatMoney = (val) => { const formatMoney = (val) => {
@@ -44,14 +99,99 @@ const formatMoney = (val) => {
</script> </script>
<style scoped> <style scoped>
.summary-container {
margin-top: 12px;
position: relative;
}
.summary-card { .summary-card {
position: relative;
display: flex;
align-items: center;
padding: 16px 36px;
margin: 0 12px 8px;
min-height: 80px;
}
.summary-content {
flex: 1;
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
align-items: center; align-items: center;
text-align: center; text-align: center;
padding: 12px 16px; }
margin-top: 12px;
margin-bottom: 4px; .nav-arrow {
position: absolute;
top: 0;
bottom: 0;
width: 40px;
display: flex;
justify-content: center;
align-items: center;
font-size: 18px;
color: #c8c9cc;
cursor: pointer;
transition: all 0.2s;
z-index: 1;
}
.nav-arrow:active {
color: var(--van-primary-color);
background-color: rgba(0, 0, 0, 0.02);
}
.nav-arrow.left {
left: 0;
}
.nav-arrow.right {
right: 0;
}
.nav-arrow.disabled {
color: #f2f3f5;
cursor: not-allowed;
}
.date-tag {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: var(--van-primary-color);
background-color: var(--van-primary-color-light);
padding: 1px 8px;
border-radius: 0 0 8px 8px;
font-weight: 500;
opacity: 0.8;
}
/* 动画效果 */
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-left-enter-from {
opacity: 0;
transform: translateX(30px);
}
.slide-left-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.slide-right-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(30px);
} }
.summary-item { .summary-item {
@@ -113,6 +253,12 @@ const formatMoney = (val) => {
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.nav-arrow:active {
background-color: rgba(255, 255, 255, 0.05);
}
.nav-arrow.disabled {
color: #323233;
}
.summary-item .value { .summary-item .value {
color: #f5f5f5; color: #f5f5f5;
} }

View File

@@ -29,6 +29,7 @@
<van-tab title="支出" :name="BudgetCategory.Expense"> <van-tab title="支出" :name="BudgetCategory.Expense">
<BudgetSummary <BudgetSummary
v-if="activeTab !== BudgetCategory.Savings" v-if="activeTab !== BudgetCategory.Savings"
v-model:date="selectedDate"
:stats="overallStats" :stats="overallStats"
:title="activeTabTitle" :title="activeTabTitle"
:get-value-class="getValueClass" :get-value-class="getValueClass"
@@ -80,6 +81,7 @@
<van-tab title="收入" :name="BudgetCategory.Income"> <van-tab title="收入" :name="BudgetCategory.Income">
<BudgetSummary <BudgetSummary
v-if="activeTab !== BudgetCategory.Savings" v-if="activeTab !== BudgetCategory.Savings"
v-model:date="selectedDate"
:stats="overallStats" :stats="overallStats"
:title="activeTabTitle" :title="activeTabTitle"
:get-value-class="getValueClass" :get-value-class="getValueClass"
@@ -217,6 +219,7 @@ import SavingsConfigPopup from '@/components/Budget/SavingsConfigPopup.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 budgetEditRef = ref(null) const budgetEditRef = ref(null)
const savingsConfigRef = ref(null) const savingsConfigRef = ref(null)
const isRefreshing = ref(false) const isRefreshing = ref(false)
@@ -240,6 +243,14 @@ watch(activeTab, async () => {
await Promise.all([fetchCategoryStats(), fetchUncoveredCategories()]) await Promise.all([fetchCategoryStats(), fetchUncoveredCategories()])
}) })
watch(selectedDate, async () => {
await Promise.all([
fetchBudgetList(),
fetchCategoryStats(),
fetchUncoveredCategories()
])
})
const getValueClass = (rate) => { const getValueClass = (rate) => {
const numRate = parseFloat(rate) const numRate = parseFloat(rate)
if (numRate === 0) return '' if (numRate === 0) return ''
@@ -256,7 +267,7 @@ const getValueClass = (rate) => {
const fetchBudgetList = async () => { const fetchBudgetList = async () => {
try { try {
const res = await getBudgetList() const res = await getBudgetList(selectedDate.value.toISOString())
if (res.success) { if (res.success) {
const data = res.data || [] const data = res.data || []
expenseBudgets.value = data.filter(b => b.category === BudgetCategory.Expense) expenseBudgets.value = data.filter(b => b.category === BudgetCategory.Expense)
@@ -280,7 +291,7 @@ const onRefresh = async () => {
const fetchCategoryStats = async () => { const fetchCategoryStats = async () => {
try { try {
const res = await getCategoryStats(activeTab.value) const res = await getCategoryStats(activeTab.value, selectedDate.value.toISOString())
if (res.success) { if (res.success) {
// 转换后端返回的数据格式为前端需要的格式 // 转换后端返回的数据格式为前端需要的格式
const data = res.data const data = res.data
@@ -310,7 +321,7 @@ const fetchUncoveredCategories = async () => {
return return
} }
try { try {
const res = await getUncoveredCategories(activeTab.value) const res = await getUncoveredCategories(activeTab.value, selectedDate.value.toISOString())
if (res.success) { if (res.success) {
uncoveredCategories.value = res.data || [] uncoveredCategories.value = res.data || []
} }
@@ -336,11 +347,22 @@ const formatMoney = (val) => {
} }
const getPeriodLabel = (type) => { const getPeriodLabel = (type) => {
const map = { const isCurrent = (date) => {
[BudgetPeriodType.Month]: '本月', const now = new Date()
[BudgetPeriodType.Year]: '本年' return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth()
} }
return map[type] || '周期' const isCurrentYear = (date) => {
const now = new Date()
return date.getFullYear() === now.getFullYear()
}
if (type === BudgetPeriodType.Month) {
return isCurrent(selectedDate.value) ? '本月' : `${selectedDate.value.getMonth() + 1}月`
}
if (type === BudgetPeriodType.Year) {
return isCurrentYear(selectedDate.value) ? '本年' : `${selectedDate.value.getFullYear()}年`
}
return '周期'
} }
const getProgressColor = (budget) => { const getProgressColor = (budget) => {