多个样式调整
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 21s
Docker Build & Deploy / Deploy to Production (push) Successful in 8s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s

This commit is contained in:
2026-01-11 16:33:55 +08:00
parent 49023237e7
commit ec460376c6
4 changed files with 300 additions and 80 deletions

View File

@@ -22,6 +22,11 @@ public class BudgetArchive : BaseEntity
/// </summary>
public decimal RealizedAmount { get; set; }
/// <summary>
/// 详细描述
/// </summary>
public string? Description { get; set; }
/// <summary>
/// 归档目标年份
/// </summary>

View File

@@ -96,7 +96,11 @@ public class BudgetService(
if (archive != null) // 存在归档 直接读取归档数据
{
budget.Limit = archive.BudgetedAmount;
return BudgetResult.FromEntity(budget, archive.RealizedAmount, referenceDate);
return BudgetResult.FromEntity(
budget,
archive.RealizedAmount,
referenceDate,
archive.Description ?? string.Empty);
}
}
@@ -271,6 +275,7 @@ public class BudgetService(
{
archive.RealizedAmount = budget.Current;
archive.ArchiveDate = DateTime.Now;
archive.Description = budget.Description;
updateArchives.Add(archive);
}
else
@@ -283,6 +288,7 @@ public class BudgetService(
Month = month,
BudgetedAmount = budget.Limit,
RealizedAmount = budget.Current,
Description = budget.Description,
ArchiveDate = DateTime.Now
};

View File

@@ -291,7 +291,7 @@ const timePercentage = computed(() => {
margin-left: 0;
margin-right: 0;
margin-bottom: 0;
padding-bottom: 8px;
padding: 8px 12px;
overflow: hidden;
position: relative;
cursor: pointer;

View File

@@ -7,62 +7,69 @@
</template>
</van-nav-bar>
<!-- 月份选择器 固定区域 -->
<div class="sticky-header">
<van-button
icon="arrow-left"
plain
size="small"
@click="changeMonth(-1)"
/>
<div class="sticky-header-text" @click="showMonthPicker = true">
{{ currentYear }}{{ currentMonth }}
<van-icon name="arrow-down" />
</div>
<van-button
icon="arrow"
plain
size="small"
:disabled="isCurrentMonth"
@click="changeMonth(1)"
/>
</div>
<!-- 下拉刷新 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<!-- 加载中 -->
<van-loading v-if="loading" vertical style="padding: 100px 0">
<!-- 初始加载中 -->
<van-loading v-if="loading && firstLoading" vertical style="padding: 100px 0">
加载统计数据中...
</van-loading>
<!-- 统计内容 -->
<div v-else class="statistics-content">
<!-- 固定概览部分置顶不滚动 -->
<div v-if="!firstLoading" class="overview-fixed-wrapper">
<transition :name="transitionName" mode="out-in">
<div :key="dateKey">
<!-- 月度概览卡片 -->
<div class="overview-card">
<!-- 左切换按钮 -->
<div class="nav-arrow left" @click.stop="changeMonth(-1)">
<van-icon name="arrow-left" />
</div>
<!-- 月度概览卡片 -->
<div class="overview-card">
<div class="overview-item clickable" @click="goToTypeOverviewBills(0)">
<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 clickable" @click="goToTypeOverviewBills(1)">
<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 clickable" @click="goToTypeOverviewBills(null)">
<div class="label">结余</div>
<div class="value" :class="monthlyData.balance >= 0 ? 'income' : 'expense'">
{{ monthlyData.balance >= 0 ? '' : '-' }}¥{{ formatMoney(Math.abs(monthlyData.balance)) }}
<div class="overview-item clickable" @click="goToTypeOverviewBills(0)">
<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 clickable" @click="goToTypeOverviewBills(1)">
<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 clickable" @click="goToTypeOverviewBills(null)">
<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
class="nav-arrow right"
:class="{ disabled: isCurrentMonth }"
@click.stop="!isCurrentMonth && changeMonth(1)"
>
<van-icon name="arrow" />
</div>
<!-- 月份日期标识 -->
<div class="date-tag" @click="showMonthPicker = true">
{{ dateTagLabel }}
<van-icon name="arrow-down" />
</div>
</div>
<div class="sub-text">{{ monthlyData.totalCount }}笔交易</div>
</div>
</div>
</transition>
</div>
<!-- 分类统计 -->
<div class="common-card">
<!-- 统计内容可滚动部分 -->
<div v-if="!firstLoading" class="statistics-content">
<transition :name="transitionName" mode="out-in">
<div :key="dateKey">
<!-- 分类统计 -->
<div class="common-card">
<div class="card-header">
<h3 class="card-title">支出分类统计</h3>
<van-tag type="primary" size="medium">{{ expenseCategoriesView.length }}</van-tag>
@@ -98,9 +105,9 @@
<div v-if="expenseCategoriesView.length > 0" class="category-list">
<div
v-for="(category) in expenseCategoriesView"
:key="category.classify"
:key="category.isOther ? 'other' : category.classify"
class="category-item clickable"
@click="goToCategoryBills(category.classify, 0)"
@click="category.isOther ? (showAllExpense = true) : goToCategoryBills(category.classify, 0)"
>
<div class="category-info">
<div class="category-color" :style="{ backgroundColor: category.color }"></div>
@@ -113,7 +120,7 @@
<div class="category-amount">¥{{ formatMoney(category.amount) }}</div>
<div class="category-percent">{{ category.percent }}%</div>
</div>
<van-icon name="arrow" class="category-arrow" />
<van-icon :name="category.isOther ? 'arrow-down' : 'arrow'" class="category-arrow" />
</div>
</div>
@@ -134,9 +141,9 @@
<div class="category-list">
<div
v-for="category in incomeCategoriesView"
:key="category.classify"
:key="category.isOther ? 'other' : category.classify"
class="category-item clickable"
@click="goToCategoryBills(category.classify, 1)"
@click="category.isOther ? (showAllIncome = true) : goToCategoryBills(category.classify, 1)"
>
<div class="category-info">
<div class="category-color income-color"></div>
@@ -149,7 +156,7 @@
<div class="category-amount income-text">¥{{ formatMoney(category.amount) }}</div>
<div class="category-percent">{{ category.percent }}%</div>
</div>
<van-icon name="arrow" class="category-arrow" />
<van-icon :name="category.isOther ? 'arrow-down' : 'arrow'" class="category-arrow" />
</div>
</div>
</div>
@@ -164,9 +171,9 @@
<div class="category-list">
<div
v-for="category in noneCategoriesView"
:key="category.classify"
:key="category.isOther ? 'other' : category.classify"
class="category-item clickable"
@click="goToCategoryBills(category.classify, 2)"
@click="category.isOther ? (showAllNone = true) : goToCategoryBills(category.classify, 2)"
>
<div class="category-info">
<div class="category-color none-color"></div>
@@ -179,7 +186,7 @@
<div class="category-amount none-text">¥{{ formatMoney(category.amount) }}</div>
<div class="category-percent">{{ category.percent }}%</div>
</div>
<van-icon name="arrow" class="category-arrow" />
<van-icon :name="category.isOther ? 'arrow-down' : 'arrow'" class="category-arrow" />
</div>
</div>
</div>
@@ -260,6 +267,8 @@
<!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</div>
</transition>
</div>
</van-pull-refresh>
@@ -331,12 +340,19 @@ const router = useRouter()
// 响应式数据
const loading = ref(true)
const firstLoading = ref(true)
const refreshing = ref(false)
const showMonthPicker = ref(false)
const showAllExpense = ref(false)
const showAllIncome = ref(false)
const showAllNone = 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 transitionName = ref('slide-right')
const dateKey = computed(() => `${currentYear.value}-${currentMonth.value}`)
// 账单列表相关
const billListVisible = ref(false)
const billListLoading = ref(false)
@@ -371,33 +387,70 @@ const incomeCategories = ref([])
const noneCategories = ref([])
const expenseCategoriesView = computed(() => {
const sorted = [...expenseCategories.value]
const unclassifiedIndex = sorted.findIndex(c => !c.classify)
const list = [...expenseCategories.value]
const unclassifiedIndex = list.findIndex(c => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = sorted.splice(unclassifiedIndex, 1)
sorted.unshift(unclassified)
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return sorted
if (showAllExpense.value || list.length <= 7) return list
const top = list.slice(0, 6)
const rest = list.slice(6)
top.push({
classify: '其他',
amount: rest.reduce((s, c) => s + c.amount, 0),
count: rest.reduce((s, c) => s + c.count, 0),
percent: parseFloat(rest.reduce((s, c) => s + c.percent, 0).toFixed(1)),
color: '#AAB7B8',
isOther: true
})
return top
})
const incomeCategoriesView = computed(() => {
const sorted = [...incomeCategories.value]
const unclassifiedIndex = sorted.findIndex(c => !c.classify)
const list = [...incomeCategories.value]
const unclassifiedIndex = list.findIndex(c => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = sorted.splice(unclassifiedIndex, 1)
sorted.unshift(unclassified)
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return sorted
if (showAllIncome.value || list.length <= 7) return list
const top = list.slice(0, 6)
const rest = list.slice(6)
top.push({
classify: '其他',
amount: rest.reduce((s, c) => s + c.amount, 0),
count: rest.reduce((s, c) => s + c.count, 0),
percent: parseFloat(rest.reduce((s, c) => s + c.percent, 0).toFixed(1)),
isOther: true
})
return top
})
const noneCategoriesView = computed(() => {
const sorted = [...noneCategories.value]
const unclassifiedIndex = sorted.findIndex(c => !c.classify)
const list = [...noneCategories.value]
const unclassifiedIndex = list.findIndex(c => !c.classify)
if (unclassifiedIndex !== -1) {
const [unclassified] = sorted.splice(unclassifiedIndex, 1)
sorted.unshift(unclassified)
const [unclassified] = list.splice(unclassifiedIndex, 1)
list.unshift(unclassified)
}
return sorted
if (showAllNone.value || list.length <= 7) return list
const top = list.slice(0, 6)
const rest = list.slice(6)
top.push({
classify: '其他',
amount: rest.reduce((s, c) => s + c.amount, 0),
count: rest.reduce((s, c) => s + c.count, 0),
percent: parseFloat(rest.reduce((s, c) => s + c.percent, 0).toFixed(1)),
isOther: true
})
return top
})
// 趋势数据
@@ -418,7 +471,7 @@ const colors = [
const circumference = computed(() => 2 * Math.PI * 70)
const chartSegments = computed(() => {
let offset = 0
return expenseCategories.value.map((category) => {
return expenseCategoriesView.value.map((category) => {
const percent = category.percent / 100
const length = circumference.value * percent
const segment = {
@@ -452,6 +505,31 @@ const isCurrentMonth = computed(() => {
return currentYear.value === now.getFullYear() && currentMonth.value === now.getMonth() + 1
})
// 日期标签展示文字
const dateTagLabel = computed(() => {
const now = new Date()
const todayYear = now.getFullYear()
const todayMonth = now.getMonth() + 1
if (currentYear.value === todayYear && currentMonth.value === todayMonth) {
return '本月'
}
// 计算上个月
let lastYear = todayYear
let lastMonth = todayMonth - 1
if (lastMonth === 0) {
lastMonth = 12
lastYear--
}
if (currentYear.value === lastYear && currentMonth.value === lastMonth) {
return `上月`
}
return `${currentYear.value}${currentMonth.value}`
})
// 是否为未分类账单
const isUnclassified = computed(() => {
return selectedClassify.value === '未分类' || selectedClassify.value === ''
@@ -484,6 +562,7 @@ const getBarHeight = (value, maxValue) => {
// 切换月份
const changeMonth = (offset) => {
transitionName.value = offset > 0 ? 'slide-left' : 'slide-right'
let newMonth = currentMonth.value + offset
let newYear = currentYear.value
@@ -504,26 +583,51 @@ const changeMonth = (offset) => {
currentYear.value = newYear
currentMonth.value = newMonth
// 重置展开状态
showAllExpense.value = false
showAllIncome.value = false
showAllNone.value = false
fetchStatistics()
}
// 确认月份选择
const onMonthConfirm = ({ selectedValues }) => {
currentYear.value = parseInt(selectedValues[0])
currentMonth.value = parseInt(selectedValues[1])
const newYear = parseInt(selectedValues[0])
const newMonth = parseInt(selectedValues[1])
// 判断方向以应用动画
if (newYear > currentYear.value || (newYear === currentYear.value && newMonth > currentMonth.value)) {
transitionName.value = 'slide-left'
} else {
transitionName.value = 'slide-right'
}
currentYear.value = newYear
currentMonth.value = newMonth
showMonthPicker.value = false
// 重置展开状态
showAllExpense.value = false
showAllIncome.value = false
showAllNone.value = false
fetchStatistics()
}
// 下拉刷新
const onRefresh = async () => {
await fetchStatistics()
await fetchStatistics(false)
refreshing.value = false
}
// 获取统计数据
const fetchStatistics = async () => {
loading.value = true
const fetchStatistics = async (showLoading = true) => {
if (showLoading && firstLoading.value) {
loading.value = true
}
try {
await Promise.all([
fetchMonthlyData(),
@@ -535,6 +639,7 @@ const fetchStatistics = async () => {
showToast('获取统计数据失败')
} finally {
loading.value = false
firstLoading.value = false
}
}
@@ -863,8 +968,24 @@ onBeforeUnmount(() => {
</script>
<style scoped>
.overview-fixed-wrapper {
position: sticky;
top: 0;
z-index: 10;
background: #f7f8fa;
padding: 16px 0 1px 0;
overflow-x: hidden;
}
@media (prefers-color-scheme: dark) {
.overview-fixed-wrapper {
background: #121212;
}
}
.statistics-content {
padding: 16px 0 0 0;
padding: 0;
overflow-x: hidden; /* 防止滑动动画出现横向滚动条 */
}
:deep(.van-pull-refresh) {
@@ -875,6 +996,7 @@ onBeforeUnmount(() => {
/* 月度概览卡片 */
.overview-card {
position: relative;
display: flex;
justify-content: space-around;
align-items: center;
@@ -893,6 +1015,93 @@ onBeforeUnmount(() => {
}
}
.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;
border-radius: 16px 0 0 16px;
}
.nav-arrow.right {
right: 0;
border-radius: 0 16px 16px 0;
}
.nav-arrow.disabled {
color: #f2f3f5;
cursor: not-allowed;
opacity: 0.3;
}
.date-tag {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
font-size: 11px;
color: var(--van-primary-color);
background-color: var(--van-primary-color-light);
padding: 2px 10px;
border-radius: 0 0 10px 10px;
font-weight: 500;
opacity: 0.9;
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
z-index: 2;
}
@media (prefers-color-scheme: dark) {
.date-tag {
background-color: rgba(var(--van-primary-color-rgb), 0.2);
}
}
/* 动画效果 */
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.35s 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);
}
.overview-item {
flex: 1;
text-align: center;