Files
test/uniapp/pages/budget/index.vue
T
2026-06-11 09:53:11 +08:00

234 lines
7.1 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>
<view class="app-page" :class="themeClass">
<section-card title="月度预算" subtitle="设置总预算与分类预算,控制消费节奏">
<view class="budget-hero surface-strong">
<view>
<text class="tiny-text">本月总预算</text>
<text class="budget-number">{{ formatCurrency(totalBudget) }}</text>
</view>
<view>
<text class="tiny-text">本月已支出</text>
<text class="budget-number negative">{{ formatCurrency(spentTotal) }}</text>
</view>
</view>
<view class="progress-track">
<view class="progress-fill" :style="{ width: usagePercentWidth }"></view>
</view>
<view class="budget-foot">
<text class="tiny-text">使用进度 {{ usagePercentLabel }}</text>
<text class="tiny-text">{{ remainingBudget >= 0 ? `剩余 ${formatCurrency(remainingBudget)}` : `已超支 ${formatCurrency(Math.abs(remainingBudget))}` }}</text>
</view>
<view class="budget-foot secondary-foot">
<text class="tiny-text">{{ totalBudget ? `日均可用 ${formatCurrency(dailyAllowance)}` : '设置预算后可查看剩余额度分配' }}</text>
</view>
<view class="editor-row">
<view class="input-shell"><input v-model="totalBudgetInput" type="digit" placeholder="输入本月总预算" /></view>
<view class="primary-button save-btn" @click="saveTotalBudget">保存</view>
</view>
</section-card>
<section-card v-if="overBudgetRows.length" title="超支提醒" subtitle="当前分类预算已被突破,建议尽快调整">
<view class="alert-list">
<view v-for="item in overBudgetRows" :key="item.id" class="alert-item">
<text class="alert-title">{{ item.name }}</text>
<text class="alert-text">预算 {{ formatCurrency(item.budget) }}已支出 {{ formatCurrency(item.spent) }}</text>
</view>
</view>
</section-card>
<section-card title="分类预算" subtitle="为高频分类分别设置预算,减少超支风险">
<view class="category-list">
<view v-for="item in categoryRows" :key="item.id" class="category-card surface-strong">
<view class="category-head">
<view class="category-title-row">
<view class="category-dot" :style="{ background: item.color }"></view>
<text class="category-title">{{ item.name }}</text>
</view>
<text class="tiny-text">已花 {{ formatCurrency(item.spent) }}</text>
</view>
<view class="progress-track thin-track">
<view class="progress-fill" :style="{ width: item.progressWidth }"></view>
</view>
<view class="budget-foot">
<text class="tiny-text">预算 {{ formatCurrency(item.budget) }}</text>
<text class="tiny-text" :class="item.budget > 0 && item.spent > item.budget ? 'negative' : ''">{{ item.progressLabel }}</text>
</view>
<view class="editor-row">
<view class="input-shell"><input v-model="categoryBudgetDrafts[item.id]" type="digit" placeholder="设置分类预算" /></view>
<view class="ghost-button save-btn" @click="saveCategoryBudget(item.id)">保存</view>
</view>
</view>
</view>
</section-card>
<app-tab-bar current="budget" />
</view>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import SectionCard from '../../components/SectionCard.vue'
import AppTabBar from '../../components/AppTabBar.vue'
import { useAppStore } from '../../utils/store'
import { getDaysLeftInMonth, isSameMonth, toMonthKey } from '../../utils/date'
import { clampPercent, formatCurrency, formatPercent } from '../../utils/money'
const store = useAppStore()
const currentMonth = computed(() => toMonthKey())
const themeClass = computed(() => (store.state.settings.theme === 'dark' ? 'theme-dark' : ''))
const totalBudgetInput = ref(String(store.state.budgets.total || ''))
const categoryBudgetDrafts = reactive({})
const expenseBills = computed(() => store.state.bills.filter((item) => item.type === 'expense' && isSameMonth(item.date, currentMonth.value)))
const spentTotal = computed(() => expenseBills.value.reduce((sum, item) => sum + Number(item.amount), 0))
const totalBudget = computed(() => Number(store.state.budgets.total) || 0)
const remainingBudget = computed(() => totalBudget.value - spentTotal.value)
const dailyAllowance = computed(() => Math.max(remainingBudget.value, 0) / Math.max(1, getDaysLeftInMonth(currentMonth.value)))
const usagePercentLabel = computed(() => (totalBudget.value ? formatPercent(spentTotal.value / Math.max(totalBudget.value, 1)) : '未设置'))
const usagePercentWidth = computed(() => clampPercent(spentTotal.value / Math.max(totalBudget.value || 1, 1)))
const categoryRows = computed(() => store.state.categories.expense.map((category) => {
const spent = expenseBills.value
.filter((item) => item.categoryId === category.id)
.reduce((sum, item) => sum + Number(item.amount), 0)
const budget = Number(store.state.budgets.categoryBudgets[category.id] || 0)
return {
...category,
spent,
budget,
progressLabel: budget ? formatPercent(spent / Math.max(budget, 1)) : '未设置',
progressWidth: clampPercent(spent / Math.max(budget || 1, 1))
}
}))
const overBudgetRows = computed(() => categoryRows.value.filter((item) => item.budget > 0 && item.spent > item.budget))
watch(
() => store.state.budgets.total,
(value) => {
totalBudgetInput.value = String(value || '')
},
{ immediate: true }
)
watch(
categoryRows,
(rows) => {
rows.forEach((item) => {
categoryBudgetDrafts[item.id] = String(item.budget || '')
})
},
{ immediate: true }
)
function saveTotalBudget() {
store.setBudgetTotal(totalBudgetInput.value)
uni.showToast({ title: '总预算已保存', icon: 'none' })
}
function saveCategoryBudget(categoryId) {
store.setCategoryBudget(categoryId, categoryBudgetDrafts[categoryId])
uni.showToast({ title: '分类预算已保存', icon: 'none' })
}
</script>
<style lang="scss" scoped>
.budget-hero,
.category-card {
padding: 24rpx;
border-radius: 24rpx;
}
.budget-hero,
.category-head,
.category-title-row,
.budget-foot,
.editor-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
}
.secondary-foot {
margin-top: 10rpx;
}
.budget-number {
display: block;
margin-top: 10rpx;
font-size: 34rpx;
font-weight: 700;
color: var(--text-primary);
}
.progress-track {
overflow: hidden;
height: 18rpx;
margin: 24rpx 0 16rpx;
border-radius: 999rpx;
background: var(--surface-muted);
}
.thin-track {
height: 14rpx;
margin: 18rpx 0 14rpx;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #5f8df5 0%, #1f6f5f 100%);
}
.editor-row {
margin-top: 20rpx;
}
.editor-row .input-shell {
flex: 1;
}
.save-btn {
flex: 0 0 180rpx;
}
.alert-list,
.category-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.alert-item {
padding: 20rpx 22rpx;
border-radius: 22rpx;
background: var(--danger-soft);
}
.alert-title {
font-size: 28rpx;
font-weight: 600;
color: var(--danger);
}
.alert-text {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: var(--danger);
}
.category-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.category-title {
font-size: 28rpx;
font-weight: 600;
color: var(--text-primary);
}
</style>