feat: 优化月度/年度报表和数据分析页面

- 创建 MonthlyYearlyReport 组件,显示收支对比、盈余状况、储蓄率和日均消费
- 创建 DetailedAnalysisReport 组件,提供详细的分类统计分析
  - 支出/收入分类明细与占比
  - TOP5分类排行榜(金银铜奖牌设计)
  - 可视化进度条和百分比显示
- 在 AnalysisScreen 中新增"报表"视图模式
  - 支持分类、成员、报表三种视图切换
  - 集成月度/年度报表和详细分析报表
- 更新 README:标记月度/年度报表功能为已完成
- 更新 v1.5 版本历史,记录数据分析优化内容

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
yovinchen 2025-07-20 00:17:49 +08:00
parent e651086e6d
commit 74cc6f36a9
5 changed files with 652 additions and 36 deletions

View File

@ -4,7 +4,8 @@
"Bash(./gradlew:*)",
"Bash(git push:*)",
"Bash(git branch:*)",
"Bash(git add:*)"
"Bash(git add:*)",
"Bash(git commit:*)"
],
"deny": []
}

View File

@ -46,7 +46,7 @@
### 2. 图表分析 (已完成 🎉)
- [x] 支出/收入趋势图表
- [x] 分类占比饼图
- [ ] 月度/年度报表
- [x] 月度/年度报表
- [x] 成员消费分析
- [x] 自定义统计周期
@ -82,7 +82,6 @@
### 6. 体验优化 (持续进行 🔄)
- [x] 深色模式支持
- [ ] 手势操作优化
- [ ] 快速记账小组件
- [ ] 多语言支持
- [ ] 自定义主题
@ -131,6 +130,12 @@
- 预算编辑对话框
- 预算状态可视化(进度条、超支提醒)
- 预算导航集成
- 数据分析优化
- 月度/年度报表组件
- 详细分析报表(分类统计明细)
- 收支对比、储蓄率、日均消费分析
- TOP分类排行榜
- 报表视图集成到分析页面
### v1.4
- 数据安全功能

View File

@ -0,0 +1,310 @@
package com.yovinchen.bookkeeping.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.TransactionType
import java.text.NumberFormat
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.*
/**
* 详细分析报表组件
* 显示按分类统计的详细信息
*/
@Composable
fun DetailedAnalysisReport(
records: List<BookkeepingRecord>,
startMonth: YearMonth,
endMonth: YearMonth,
modifier: Modifier = Modifier
) {
val currencyFormatter = NumberFormat.getCurrencyInstance(Locale.CHINA)
// 按类型分组
val incomeRecords = records.filter { it.type == TransactionType.INCOME }
val expenseRecords = records.filter { it.type == TransactionType.EXPENSE }
// 按分类统计
val incomeByCategory = incomeRecords.groupBy { it.category }
.mapValues { it.value.sumOf { record -> record.amount } }
.toList()
.sortedByDescending { it.second }
val expenseByCategory = expenseRecords.groupBy { it.category }
.mapValues { it.value.sumOf { record -> record.amount } }
.toList()
.sortedByDescending { it.second }
// 总收入和总支出
val totalIncome = incomeRecords.sumOf { it.amount }
val totalExpense = expenseRecords.sumOf { it.amount }
LazyColumn(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 时间范围标题
item {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Text(
text = if (startMonth == endMonth) {
"统计期间:${startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}"
} else {
"统计期间:${startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}${endMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}"
},
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(16.dp),
textAlign = TextAlign.Center
)
}
}
// 支出分类详情
if (expenseByCategory.isNotEmpty()) {
item {
CategoryDetailCard(
title = "支出分类明细",
categoryData = expenseByCategory,
total = totalExpense,
color = MaterialTheme.colorScheme.error,
currencyFormatter = currencyFormatter
)
}
}
// 收入分类详情
if (incomeByCategory.isNotEmpty()) {
item {
CategoryDetailCard(
title = "收入分类明细",
categoryData = incomeByCategory,
total = totalIncome,
color = MaterialTheme.colorScheme.primary,
currencyFormatter = currencyFormatter
)
}
}
// 分类占比前5名
if (expenseByCategory.isNotEmpty()) {
item {
TopCategoriesCard(
title = "支出TOP5",
categoryData = expenseByCategory.take(5),
total = totalExpense,
color = MaterialTheme.colorScheme.error,
currencyFormatter = currencyFormatter
)
}
}
}
}
/**
* 分类详情卡片
*/
@Composable
private fun CategoryDetailCard(
title: String,
categoryData: List<Pair<String, Double>>,
total: Double,
color: Color,
currencyFormatter: NumberFormat
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = color,
modifier = Modifier.padding(bottom = 16.dp)
)
// 总计
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "总计",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold
)
Text(
text = currencyFormatter.format(total),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = color
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// 分类列表
categoryData.forEach { (category, amount) ->
val percentage = if (total > 0) (amount / total * 100) else 0.0
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = category,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "${String.format("%.1f", percentage)}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = currencyFormatter.format(amount),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
// 进度条
LinearProgressIndicator(
progress = (percentage / 100).toFloat(),
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(2.dp)),
color = color.copy(alpha = 0.8f),
trackColor = color.copy(alpha = 0.2f)
)
}
}
}
}
/**
* TOP分类卡片
*/
@Composable
private fun TopCategoriesCard(
title: String,
categoryData: List<Pair<String, Double>>,
total: Double,
color: Color,
currencyFormatter: NumberFormat
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp)
)
categoryData.forEachIndexed { index, (category, amount) ->
val percentage = if (total > 0) (amount / total * 100) else 0.0
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 排名
Box(
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(12.dp))
.background(
when (index) {
0 -> Color(0xFFFFD700) // 金色
1 -> Color(0xFFC0C0C0) // 银色
2 -> Color(0xFFCD7F32) // 铜色
else -> MaterialTheme.colorScheme.surfaceVariant
}
),
contentAlignment = Alignment.Center
) {
Text(
text = "${index + 1}",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = if (index < 3) Color.White else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = category,
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (index == 0) FontWeight.Bold else FontWeight.Normal
)
}
Column(
horizontalAlignment = Alignment.End
) {
Text(
text = currencyFormatter.format(amount),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = "${String.format("%.1f", percentage)}%",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}

View File

@ -0,0 +1,258 @@
package com.yovinchen.bookkeeping.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.yovinchen.bookkeeping.model.BookkeepingRecord
import com.yovinchen.bookkeeping.model.TransactionType
import java.text.NumberFormat
import java.time.LocalDateTime
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.*
/**
* 月度/年度报表组件
* 显示收支对比盈余情况等统计信息
*/
@Composable
fun MonthlyYearlyReport(
records: List<BookkeepingRecord>,
period: String, // "月度" 或 "年度"
startMonth: YearMonth,
endMonth: YearMonth,
modifier: Modifier = Modifier
) {
val totalIncome = records
.filter { it.type == TransactionType.INCOME }
.sumOf { it.amount }
val totalExpense = records
.filter { it.type == TransactionType.EXPENSE }
.sumOf { it.amount }
val balance = totalIncome - totalExpense
val savingsRate = if (totalIncome > 0) {
((totalIncome - totalExpense) / totalIncome * 100).coerceAtLeast(0.0)
} else 0.0
Card(
modifier = modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 标题
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "$period 报表",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
text = if (startMonth == endMonth) {
startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))
} else {
"${startMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))} - ${endMonth.format(DateTimeFormatter.ofPattern("yyyy年MM月"))}"
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider()
// 收支对比
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
// 收入
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f)
) {
Text(
text = "总收入",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatCurrency(totalIncome),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
// 分隔线
Box(
modifier = Modifier
.width(1.dp)
.height(40.dp)
.background(MaterialTheme.colorScheme.outlineVariant)
)
// 支出
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f)
) {
Text(
text = "总支出",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatCurrency(totalExpense),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error
)
}
}
// 盈余情况
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (balance >= 0) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
}
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (balance >= 0) "盈余" else "亏损",
style = MaterialTheme.typography.bodyMedium,
color = if (balance >= 0) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
}
)
Text(
text = formatCurrency(kotlin.math.abs(balance)),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = if (balance >= 0) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
}
)
}
}
// 储蓄率
if (totalIncome > 0) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "储蓄率",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Box(
modifier = Modifier.weight(2f)
) {
LinearProgressIndicator(
progress = (savingsRate / 100).toFloat().coerceIn(0f, 1f),
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
color = when {
savingsRate >= 30 -> MaterialTheme.colorScheme.primary
savingsRate >= 10 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
)
}
Text(
text = "${String.format("%.1f", savingsRate)}%",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 8.dp),
color = when {
savingsRate >= 30 -> MaterialTheme.colorScheme.primary
savingsRate >= 10 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
)
}
}
// 日均消费
val dayCount = if (period == "月度") {
// 计算月度天数
java.time.temporal.ChronoUnit.DAYS.between(
startMonth.atDay(1),
endMonth.atEndOfMonth()
) + 1
} else {
// 计算年度天数
java.time.temporal.ChronoUnit.DAYS.between(
startMonth.atDay(1),
endMonth.atEndOfMonth()
) + 1
}
val dailyAverage = if (dayCount > 0) totalExpense / dayCount else 0.0
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "日均消费",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = formatCurrency(dailyAverage),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold
)
}
}
}
}
/**
* 格式化货币
*/
private fun formatCurrency(amount: Double): String {
val format = NumberFormat.getCurrencyInstance(Locale.CHINA)
return format.format(amount)
}

View File

@ -38,12 +38,14 @@ import com.yovinchen.bookkeeping.model.MemberStat
import com.yovinchen.bookkeeping.ui.components.CategoryPieChart
import com.yovinchen.bookkeeping.ui.components.CategoryStatItem
import com.yovinchen.bookkeeping.ui.components.DateRangePicker
import com.yovinchen.bookkeeping.ui.components.DetailedAnalysisReport
import com.yovinchen.bookkeeping.ui.components.MonthlyYearlyReport
import com.yovinchen.bookkeeping.ui.components.TrendLineChart
import com.yovinchen.bookkeeping.viewmodel.AnalysisViewModel
import java.time.YearMonth
enum class ViewMode {
CATEGORY, MEMBER
CATEGORY, MEMBER, REPORT
}
@OptIn(ExperimentalMaterial3Api::class)
@ -93,7 +95,13 @@ fun AnalysisScreen(
Button(
onClick = { showViewModeMenu = true }
) {
Text(if (currentViewMode == ViewMode.CATEGORY) "分类" else "成员")
Text(
when {
currentViewMode == ViewMode.CATEGORY -> "分类"
currentViewMode == ViewMode.MEMBER -> "成员"
else -> "报表"
}
)
Icon(Icons.Default.ArrowDropDown, "切换视图")
}
DropdownMenu(
@ -114,6 +122,13 @@ fun AnalysisScreen(
showViewModeMenu = false
}
)
DropdownMenuItem(
text = { Text("报表") },
onClick = {
currentViewMode = ViewMode.REPORT
showViewModeMenu = false
}
)
}
}
@ -159,41 +174,68 @@ fun AnalysisScreen(
}
}
else -> {
// 饼图视图
item {
CategoryPieChart(
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) },
currentViewMode = currentViewMode == ViewMode.MEMBER,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(bottom = 16.dp),
onCategoryClick = { category ->
if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(category, startMonth, endMonth)
} else {
onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType)
if (currentViewMode == ViewMode.REPORT) {
// 报表视图
item {
MonthlyYearlyReport(
records = records,
period = if (startMonth == endMonth) "月度" else "年度",
startMonth = startMonth,
endMonth = endMonth,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
}
// 详细分析报表
item {
DetailedAnalysisReport(
records = records,
startMonth = startMonth,
endMonth = endMonth,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
)
}
} else {
// 饼图视图
item {
CategoryPieChart(
categoryData = categoryStats.map { Pair(it.category, it.percentage.toFloat()) },
memberData = memberStats.map { Pair(it.member, it.percentage.toFloat()) },
currentViewMode = currentViewMode == ViewMode.MEMBER,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.padding(bottom = 16.dp),
onCategoryClick = { category ->
if (currentViewMode == ViewMode.CATEGORY) {
onNavigateToCategoryDetail(category, startMonth, endMonth)
} else {
onNavigateToMemberDetail(category, startMonth, endMonth, selectedAnalysisType)
}
}
}
)
}
)
}
// 统计列表
items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat ->
val category = if (stat is CategoryStat) stat.category else null
val member = if (stat is MemberStat) stat.member else null
// 统计列表
items(if (currentViewMode == ViewMode.CATEGORY) categoryStats else memberStats) { stat ->
val category = if (stat is CategoryStat) stat.category else null
val member = if (stat is MemberStat) stat.member else null
CategoryStatItem(
stat = stat,
onClick = {
if (currentViewMode == ViewMode.CATEGORY && category != null) {
onNavigateToCategoryDetail(category, startMonth, endMonth)
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType)
CategoryStatItem(
stat = stat,
onClick = {
if (currentViewMode == ViewMode.CATEGORY && category != null) {
onNavigateToCategoryDetail(category, startMonth, endMonth)
} else if (currentViewMode == ViewMode.MEMBER && member != null) {
onNavigateToMemberDetail(member, startMonth, endMonth, selectedAnalysisType)
}
}
}
)
)
}
}
}
}