feat: 添加报告屏幕及分类饼图、月度柱状图和趋势折线图组件。
This commit is contained in:
155
src/components/reports/CategoryPieChart.tsx
Normal file
155
src/components/reports/CategoryPieChart.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
169
src/components/reports/MonthlyBarChart.tsx
Normal file
169
src/components/reports/MonthlyBarChart.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
173
src/components/reports/TrendLineChart.tsx
Normal file
173
src/components/reports/TrendLineChart.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
3
src/components/reports/index.ts
Normal file
3
src/components/reports/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './TrendLineChart';
|
||||
export * from './CategoryPieChart';
|
||||
export * from './MonthlyBarChart';
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
8
task.md
8
task.md
@@ -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: 核心功能补全
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user