feat: 添加报告屏幕及分类饼图、月度柱状图和趋势折线图组件。

This commit is contained in:
2026-01-28 01:08:54 +08:00
parent 72571b2b9d
commit e552e0f4a9
6 changed files with 514 additions and 4 deletions

View File

@@ -0,0 +1,155 @@
import React from 'react';
import { View, StyleSheet, Text, Dimensions } from 'react-native';
import { PieChart } from 'react-native-gifted-charts';
import { useTheme } from '../../contexts';
import { CategorySummaryItem } from '../../services/reportService';
import { spacing, borderRadius, typography } from '../../theme';
import { formatCurrency } from '../../utils';
const { width: screenWidth } = Dimensions.get('window');
interface CategoryPieChartProps {
data: CategorySummaryItem[];
type: 'income' | 'expense';
}
export const CategoryPieChart: React.FC<CategoryPieChartProps> = ({ data, type }) => {
const { colors, isDark } = useTheme();
if (!data || data.length === 0) {
return null; // Handle empty state in parent or just hide
}
// Default palette if category doesn't have specific color (could be enhanced)
const PASTE_PALETTE = [
'#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40',
'#E7E9ED', '#76A346', '#8E44AD', '#3498DB'
];
const chartData = data.map((item, index) => ({
value: item.total_amount,
text: `${Math.round(item.percentage)}%`,
color: PASTE_PALETTE[index % PASTE_PALETTE.length],
shiftTextX: 5,
shiftTextY: 5,
// Custom properties for legend
categoryName: item.category_name,
amount: item.total_amount,
}));
// Find the item with maximum value to focus/explode slightly? Maybe not for now.
const renderLegend = () => {
return (
<View style={styles.legendContainer}>
{chartData.slice(0, 5).map((item, index) => (
<View key={index} style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: item.color }]} />
<View style={styles.legendTextContainer}>
<Text style={[styles.legendName, { color: colors.text }]} numberOfLines={1}>
{item.categoryName}
</Text>
<Text style={[styles.legendAmount, { color: colors.textSecondary }]}>
{formatCurrency(item.amount)}
</Text>
</View>
</View>
))}
</View>
);
};
return (
<View style={[styles.container, { backgroundColor: isDark ? colors.surfaceLight : colors.surface }]}>
<Text style={[styles.title, { color: colors.text }]}>
{type === 'expense' ? '支出构成' : '收入构成'}
</Text>
<View style={styles.content}>
<View style={styles.chartWrapper}>
<PieChart
data={chartData}
donut
showGradient
sectionAutoFocus
radius={70}
innerRadius={50}
innerCircleColor={isDark ? colors.surfaceLight : colors.surface}
centerLabelComponent={() => {
return (
<View style={styles.centerLabel}>
<Text style={[styles.centerText, { color: colors.textSecondary }]}></Text>
<Text style={[styles.centerAmount, { color: colors.text }]}>
{formatCurrency(data.reduce((acc, cur) => acc + cur.total_amount, 0))}
</Text>
</View>
);
}}
/>
</View>
{renderLegend()}
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
borderRadius: borderRadius.lg,
padding: spacing.md,
marginBottom: spacing.lg,
},
title: {
...typography.h4,
marginBottom: spacing.lg,
},
content: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
chartWrapper: {
alignItems: 'center',
justifyContent: 'center',
width: '50%',
},
centerLabel: {
justifyContent: 'center',
alignItems: 'center',
},
centerText: {
...typography.caption,
fontSize: 10,
},
centerAmount: {
...typography.bodySmall,
fontWeight: 'bold',
fontSize: 12,
},
legendContainer: {
width: '50%',
paddingLeft: spacing.sm,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.xs,
},
legendDot: {
width: 10,
height: 10,
borderRadius: 5,
marginRight: 8,
},
legendTextContainer: {
flex: 1,
},
legendName: {
...typography.caption,
fontWeight: '500',
},
legendAmount: {
fontSize: 10,
},
});

View File

@@ -0,0 +1,169 @@
import React, { useEffect, useState } from 'react';
import { View, StyleSheet, ActivityIndicator, Text, Dimensions } from 'react-native';
import { BarChart } from 'react-native-gifted-charts';
import { useTheme } from '../../contexts';
import { reportService, TrendDataPoint } from '../../services/reportService';
import { spacing, borderRadius, typography } from '../../theme';
import { formatCurrency } from '../../utils';
const { width: screenWidth } = Dimensions.get('window');
export const MonthlyBarChart: React.FC = () => {
const { colors, isDark } = useTheme();
const [loading, setLoading] = useState(true);
const [data, setData] = useState<TrendDataPoint[]>([]);
useEffect(() => {
loadMonthlyData();
}, []);
const loadMonthlyData = async () => {
try {
// Get last 6 months
const end = new Date();
const start = new Date();
start.setMonth(start.getMonth() - 5);
start.setDate(1); // First day of 6 months ago
// End date should be end of current month
const endDate = new Date(end.getFullYear(), end.getMonth() + 1, 0);
const response = await reportService.getTrendData({
start_date: start.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0],
period: 'month',
});
setData(response.data_points);
} catch (error) {
console.error('Failed to load monthly data', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary[500]} />
</View>
);
}
if (data.length === 0) {
return null;
}
// Prepare data for BarChart
// We want grouped bars: Income vs Expense
const barData: any[] = [];
data.forEach(item => {
const label = `${new Date(item.Date).getMonth() + 1}`;
// Income Bar
barData.push({
value: item.TotalIncome,
label: label,
spacing: 2,
labelWidth: 30,
labelTextStyle: { color: colors.textSecondary, fontSize: 10 },
frontColor: colors.semantic.income,
topLabelComponent: () => (
<Text style={{ color: colors.semantic.income, fontSize: 9, marginBottom: 2 }}>
{item.TotalIncome > 0 ? formatCurrency(item.TotalIncome).replace('¥', '') : ''}
</Text>
),
});
// Expense Bar
barData.push({
value: item.TotalExpense,
frontColor: colors.semantic.expense,
topLabelComponent: () => (
<Text style={{ color: colors.semantic.expense, fontSize: 9, marginBottom: 2 }}>
{item.TotalExpense > 0 ? formatCurrency(item.TotalExpense).replace('¥', '') : ''}
</Text>
),
});
});
const maxVal = Math.max(
...data.map(d => d.TotalIncome),
...data.map(d => d.TotalExpense)
);
const yAxisMax = maxVal > 0 ? maxVal * 1.2 : 1000;
return (
<View style={[styles.container, { backgroundColor: isDark ? colors.surfaceLight : colors.surface }]}>
<Text style={[styles.title, { color: colors.text }]}></Text>
<View style={styles.chartContainer}>
<BarChart
data={barData}
barWidth={16}
spacing={24}
roundedTop
roundedBottom
hideRules
xAxisThickness={0}
yAxisThickness={0}
yAxisTextStyle={{ color: colors.textSecondary, fontSize: 10 }}
noOfSections={4}
maxValue={yAxisMax}
height={200}
width={screenWidth - 60}
isAnimated
/>
</View>
<View style={styles.legendContainer}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: colors.semantic.income }]} />
<Text style={[styles.legendText, { color: colors.textSecondary }]}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: colors.semantic.expense }]} />
<Text style={[styles.legendText, { color: colors.textSecondary }]}></Text>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
borderRadius: borderRadius.lg,
padding: spacing.md,
marginBottom: spacing.lg,
},
loadingContainer: {
height: 250,
justifyContent: 'center',
alignItems: 'center',
},
title: {
...typography.h4,
marginBottom: spacing.md,
},
chartContainer: {
marginTop: spacing.sm,
marginLeft: -10,
},
legendContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: spacing.md,
gap: spacing.lg,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
},
legendDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 6,
},
legendText: {
...typography.caption,
},
});

View File

@@ -0,0 +1,173 @@
import React, { useEffect, useState } from 'react';
import { View, StyleSheet, ActivityIndicator, Text, Dimensions } from 'react-native';
import { LineChart } from 'react-native-gifted-charts';
import { useTheme } from '../../contexts';
import { reportService, TrendDataPoint } from '../../services/reportService';
import { spacing, borderRadius, typography } from '../../theme';
import { formatCurrency } from '../../utils';
const { width: screenWidth } = Dimensions.get('window');
interface TrendLineChartProps {
startDate: string;
endDate: string;
}
export const TrendLineChart: React.FC<TrendLineChartProps> = ({ startDate, endDate }) => {
const { colors, isDark } = useTheme();
const [loading, setLoading] = useState(true);
const [data, setData] = useState<TrendDataPoint[]>([]);
useEffect(() => {
loadTrendData();
}, [startDate, endDate]);
const loadTrendData = async () => {
try {
const response = await reportService.getTrendData({
start_date: startDate,
end_date: endDate,
period: 'day',
// default currency
});
setData(response.data_points);
} catch (error) {
console.error('Failed to load trend data', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary[500]} />
</View>
);
}
if (data.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={[styles.emptyText, { color: colors.textSecondary }]}></Text>
</View>
);
}
// Prepare data for the chart
const incomeData = data.map(point => ({
value: point.TotalIncome,
label: point.Date.slice(5), // MM-DD
dataPointText: point.TotalIncome > 0 ? formatCurrency(point.TotalIncome) : '',
textColor: colors.semantic.income,
}));
const expenseData = data.map(point => ({
value: point.TotalExpense,
label: point.Date.slice(5),
dataPointText: point.TotalExpense > 0 ? formatCurrency(point.TotalExpense) : '',
textColor: colors.semantic.expense,
}));
// Calculate max value for y-axis
const maxVal = Math.max(
...data.map(d => d.TotalIncome),
...data.map(d => d.TotalExpense)
);
const yAxisMax = maxVal > 0 ? maxVal * 1.2 : 1000;
return (
<View style={[styles.container, { backgroundColor: isDark ? colors.surfaceLight : colors.surface }]}>
<Text style={[styles.title, { color: colors.text }]}></Text>
<View style={styles.chartContainer}>
<LineChart
data={incomeData}
data2={expenseData}
color1={colors.semantic.income}
color2={colors.semantic.expense}
height={220}
width={screenWidth - 60} // Adjust based on padding
initialSpacing={10}
endSpacing={10}
thickness1={3}
thickness2={3}
hideRules
hideYAxisText={false}
yAxisTextStyle={{ color: colors.textSecondary, fontSize: 10 }}
xAxisLabelTextStyle={{ color: colors.textSecondary, fontSize: 10 }}
maxValue={yAxisMax}
noOfSections={4}
curved
isAnimated
animationDuration={1000}
dataPointsColor1={colors.semantic.income}
dataPointsColor2={colors.semantic.expense}
startFillColor1={colors.semantic.income}
startFillColor2={colors.semantic.expense}
startOpacity={0.1}
endOpacity={0.0}
areaChart
/>
</View>
<View style={styles.legendContainer}>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: colors.semantic.income }]} />
<Text style={[styles.legendText, { color: colors.textSecondary }]}></Text>
</View>
<View style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: colors.semantic.expense }]} />
<Text style={[styles.legendText, { color: colors.textSecondary }]}></Text>
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
borderRadius: borderRadius.lg,
padding: spacing.md,
marginBottom: spacing.lg,
},
loadingContainer: {
height: 250,
justifyContent: 'center',
alignItems: 'center',
},
emptyContainer: {
height: 100,
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
...typography.bodySmall,
},
title: {
...typography.h4,
marginBottom: spacing.md,
},
chartContainer: {
marginVertical: spacing.sm,
marginLeft: -10, // Cancel out left padding slightly for axis
},
legendContainer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: spacing.sm,
gap: spacing.lg,
},
legendItem: {
flexDirection: 'row',
alignItems: 'center',
},
legendDot: {
width: 8,
height: 8,
borderRadius: 4,
marginRight: 6,
},
legendText: {
...typography.caption,
},
});

View File

@@ -0,0 +1,3 @@
export * from './TrendLineChart';
export * from './CategoryPieChart';
export * from './MonthlyBarChart';

View File

@@ -18,6 +18,7 @@ import { useTheme } from '../../contexts';
import { reportService, TransactionSummaryResponse, CategorySummaryItem } from '../../services/reportService';
import { formatCurrency } from '../../utils';
import { spacing, borderRadius, typography } from '../../theme';
import { TrendLineChart, CategoryPieChart, MonthlyBarChart } from '../../components/reports';
const { width: screenWidth } = Dimensions.get('window');
@@ -136,6 +137,9 @@ export default function ReportsScreen() {
</View>
</View>
{/* 趋势图表 */}
<TrendLineChart startDate={startDate} endDate={endDate} />
{/* 分类统计 */}
<View style={styles.categorySection}>
<Text style={styles.sectionTitle}></Text>
@@ -160,6 +164,9 @@ export default function ReportsScreen() {
</TouchableOpacity>
</View>
{/* 分类饼图 */}
<CategoryPieChart data={currentCategories} type={activeTab} />
{/* 分类列表 */}
{currentCategories.length === 0 ? (
<View style={styles.emptyState}>
@@ -198,6 +205,9 @@ export default function ReportsScreen() {
))
)}
</View>
{/* 月度对比 */}
<MonthlyBarChart />
</ScrollView>
</View>
);

View File

@@ -104,10 +104,10 @@
>
> 目标: 集成专业图表库,实现可视化报表
- [ ] 9.1 集成图表库 (e.g. react-native-gifted-charts or victory-native)
- [ ] 9.2 收支趋势折线图 (Trend Line Chart)
- [ ] 9.3 支出分类饼图 (Category Pie Chart)
- [ ] 9.4 月度收支对比柱状图 (Bar Chart)
- [x] 9.1 集成图表库 (e.g. react-native-gifted-charts or victory-native)
- [x] 9.2 收支趋势折线图 (Trend Line Chart)
- [x] 9.3 支出分类饼图 (Category Pie Chart)
- [x] 9.4 月度收支对比柱状图 (Bar Chart)
## 🔄 Phase 10: 核心功能补全
>