Skip to content

Commit 02ca27d

Browse files
committed
feat:新增年度报告消费分析组件
1 parent 7ee5f56 commit 02ca27d

File tree

1 file changed

+339
-0
lines changed

1 file changed

+339
-0
lines changed
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
2+
<template>
3+
<ReportPage :class="{ 'animate-in': isActive }" ref="sectionRef">
4+
<div class="flex flex-col items-center justify-center h-full text-center px-4 py-8 overflow-y-auto">
5+
6+
<!-- Header -->
7+
<div class="mb-8 shrink-0">
8+
<h2 class="text-3xl font-bold text-primary-amber mb-2">交通费用年度分析</h2>
9+
<div class="h-1 w-20 bg-primary-amber/20 rounded-full mx-auto"></div>
10+
</div>
11+
12+
<!-- Key Metrics -->
13+
<div class="grid grid-cols-2 gap-4 w-full max-w-2xl mx-auto mb-8 animate-fade-in-up">
14+
<div class="bg-white/50 dark:bg-black/20 rounded-xl p-4 backdrop-blur-sm">
15+
<p class="text-sm text-light-text3 dark:text-gray-400 mb-1">年度总支出</p>
16+
<p class="text-2xl font-bold text-primary-amber">¥ {{ data.totalAmount.toLocaleString() }}</p>
17+
</div>
18+
<div class="bg-white/50 dark:bg-black/20 rounded-xl p-4 backdrop-blur-sm">
19+
<p class="text-sm text-light-text3 dark:text-gray-400 mb-1">平均票价</p>
20+
<p class="text-2xl font-bold text-primary-amber">¥ {{ Math.round(data.averagePrice).toLocaleString() }}</p>
21+
</div>
22+
</div>
23+
24+
<!-- Max Expense Info -->
25+
<div v-if="data.maxExpenseTicket" class="mb-8 animate-fade-in-up" style="animation-delay: 0.2s;">
26+
<p class="text-sm text-light-text3 dark:text-gray-400">单笔最高消费</p>
27+
<p class="text-lg text-primary-dark dark:text-gray-200 mt-1">
28+
<span class="font-bold">{{ data.maxExpenseTicket }}</span>
29+
<span class="ml-2 text-primary-amber font-bold">¥ {{ data.maxExpenseAmount }}</span>
30+
</p>
31+
</div>
32+
33+
<!-- Charts Container -->
34+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 w-full max-w-4xl mx-auto flex-1 min-h-[300px] animate-fade-in-up" style="animation-delay: 0.4s;">
35+
<!-- Monthly Trend Chart -->
36+
<div ref="trendChartRef" class="w-full h-[300px] bg-white/50 dark:bg-black/20 rounded-xl p-2"></div>
37+
38+
<!-- Monthly Proportion Chart -->
39+
<div ref="pieChartRef" class="w-full h-[300px] bg-white/50 dark:bg-black/20 rounded-xl p-2"></div>
40+
</div>
41+
42+
<!-- Details Button -->
43+
<div class="mt-6 animate-fade-in-up shrink-0" style="animation-delay: 0.5s;">
44+
<button @click="showDetails = true" class="px-6 py-2 bg-primary-amber/10 text-primary-amber rounded-full hover:bg-primary-amber/20 transition-colors text-sm font-medium">
45+
查看车票明细数据
46+
</button>
47+
</div>
48+
49+
<!-- Footer Note -->
50+
<p class="text-xs text-light-text3 dark:text-gray-500 mt-4 animate-fade-in-up shrink-0" style="animation-delay: 0.6s;">
51+
数据来源:车票收藏夹 | 统计时间:{{ new Date().toLocaleDateString() }}
52+
</p>
53+
54+
<!-- Details Modal -->
55+
<Teleport to="body">
56+
<div v-if="showDetails" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4" @click.self="showDetails = false">
57+
<div class="bg-white dark:bg-dark-navy rounded-2xl w-full max-w-4xl max-h-[80vh] flex flex-col shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
58+
<div class="p-4 border-b border-gray-100 dark:border-gray-800 flex justify-between items-center bg-white dark:bg-dark-navy sticky top-0 z-10">
59+
<h3 class="text-lg font-bold text-gray-900 dark:text-white">车票明细数据</h3>
60+
<button @click="showDetails = false" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300">
61+
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
62+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
63+
</svg>
64+
</button>
65+
</div>
66+
67+
<div class="overflow-auto flex-1 p-4">
68+
<div v-if="loadingDetails" class="flex justify-center py-10">
69+
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-amber"></div>
70+
</div>
71+
<div v-else-if="detailError" class="flex flex-col items-center justify-center py-12 text-red-500 gap-4">
72+
<AlertCircle class="w-10 h-10 opacity-80" />
73+
<div class="text-center">
74+
<p class="font-medium">{{ detailError }}</p>
75+
<button
76+
@click="fetchDetails"
77+
class="mt-4 px-4 py-2 bg-red-100 dark:bg-red-900/20 hover:bg-red-200 dark:hover:bg-red-900/40 text-red-600 dark:text-red-400 rounded-full text-sm transition-colors"
78+
>
79+
重试
80+
</button>
81+
</div>
82+
</div>
83+
<table v-else class="w-full text-sm text-left">
84+
<thead class="text-xs text-gray-500 uppercase bg-gray-50 dark:bg-gray-800/50 dark:text-gray-400 sticky top-0">
85+
<tr>
86+
<th class="px-4 py-3">日期</th>
87+
<th class="px-4 py-3">车次</th>
88+
<th class="px-4 py-3">出发地</th>
89+
<th class="px-4 py-3">目的地</th>
90+
<th class="px-4 py-3">席别</th>
91+
<th class="px-4 py-3">乘车人</th>
92+
<th class="px-4 py-3 text-right">价格</th>
93+
</tr>
94+
</thead>
95+
<tbody>
96+
<tr v-for="ticket in ticketDetails" :key="ticket.id" class="border-b dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50">
97+
<td class="px-4 py-3 font-medium">{{ new Date(ticket.date_time).toLocaleDateString() }} {{ new Date(ticket.date_time).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) }}</td>
98+
<td class="px-4 py-3">{{ ticket.train_code }}</td>
99+
<td class="px-4 py-3">{{ ticket.departure_station }}</td>
100+
<td class="px-4 py-3">{{ ticket.arrival_station }}</td>
101+
<td class="px-4 py-3">{{ ticket.seat_type }}</td>
102+
<td class="px-4 py-3">{{ ticket.name }}</td>
103+
<td class="px-4 py-3 text-right font-bold text-primary-amber">¥{{ ticket.price }}</td>
104+
</tr>
105+
<tr v-if="ticketDetails.length === 0">
106+
<td colspan="7" class="px-4 py-8 text-center text-gray-500">暂无数据</td>
107+
</tr>
108+
</tbody>
109+
</table>
110+
</div>
111+
112+
<div class="p-4 border-t border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800/30 text-right text-xs text-gray-500">
113+
共 {{ ticketDetails.length }} 条记录
114+
</div>
115+
</div>
116+
</div>
117+
</Teleport>
118+
119+
</div>
120+
</ReportPage>
121+
</template>
122+
123+
<script setup lang="ts">
124+
import ReportPage from './ReportPage.vue';
125+
import type { ExpenseMetrics, TicketDetail } from '@/types/annualReport';
126+
import { getReportExpenseDetails } from '@/api/annualReport';
127+
import { ref, onMounted, watch, nextTick, onUnmounted } from 'vue';
128+
import { useIntersectionObserver } from '@vueuse/core';
129+
import * as echarts from 'echarts';
130+
131+
const props = defineProps<{
132+
data: ExpenseMetrics;
133+
startTime: string;
134+
endTime: string;
135+
}>();
136+
137+
const isActive = ref(false);
138+
const showDetails = ref(false);
139+
const ticketDetails = ref<TicketDetail[]>([]);
140+
const loadingDetails = ref(false);
141+
const detailError = ref<string | null>(null);
142+
const detailsLoaded = ref(false);
143+
144+
const sectionRef = ref<HTMLElement | null>(null);
145+
146+
import { AlertCircle } from 'lucide-vue-next';
147+
148+
const fetchDetails = async () => {
149+
loadingDetails.value = true;
150+
detailError.value = null; // Clear previous error
151+
try {
152+
ticketDetails.value = await getReportExpenseDetails(props.startTime, props.endTime);
153+
detailsLoaded.value = true;
154+
} catch (e: any) {
155+
const errorCode = e?.response?.status || e?.code || 'UNKNOWN';
156+
detailError.value = `加载明细失败,请稍后重试(错误代码: ${errorCode} )`;
157+
console.error(e);
158+
} finally {
159+
loadingDetails.value = false;
160+
}
161+
};
162+
163+
watch(showDetails, async (val) => {
164+
if (val && !detailsLoaded.value) {
165+
fetchDetails();
166+
}
167+
});
168+
169+
useIntersectionObserver(
170+
sectionRef,
171+
([{ isIntersecting }]) => {
172+
if (isIntersecting && !isActive.value) {
173+
isActive.value = true;
174+
nextTick(() => {
175+
trendChart?.resize();
176+
pieChart?.resize();
177+
});
178+
}
179+
},
180+
{ threshold: 0.3 }
181+
);
182+
183+
const trendChartRef = ref<HTMLElement | null>(null);
184+
const pieChartRef = ref<HTMLElement | null>(null);
185+
let trendChart: echarts.ECharts | null = null;
186+
let pieChart: echarts.ECharts | null = null;
187+
188+
const initCharts = () => {
189+
if (trendChartRef.value && props.data.monthlyTrend) {
190+
trendChart = echarts.init(trendChartRef.value);
191+
192+
const months = props.data.monthlyTrend.map(m => m.month);
193+
const amounts = props.data.monthlyTrend.map(m => m.amount);
194+
195+
trendChart.setOption({
196+
title: {
197+
text: '月度支出趋势',
198+
left: 'center',
199+
textStyle: { fontSize: 14, color: '#888' }
200+
},
201+
tooltip: {
202+
trigger: 'axis',
203+
formatter: '{b}: ¥{c}'
204+
},
205+
grid: {
206+
left: '3%',
207+
right: '4%',
208+
bottom: '3%',
209+
containLabel: true
210+
},
211+
xAxis: {
212+
type: 'category',
213+
data: months,
214+
axisLine: { lineStyle: { color: '#ccc' } },
215+
axisLabel: { color: '#888', interval: 0, rotate: 30 }
216+
},
217+
yAxis: {
218+
type: 'value',
219+
axisLine: { show: false },
220+
axisLabel: { color: '#888' },
221+
splitLine: { lineStyle: { type: 'dashed', color: '#eee' } }
222+
},
223+
series: [
224+
{
225+
data: amounts,
226+
type: 'bar',
227+
itemStyle: {
228+
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
229+
{ offset: 0, color: '#F97316' }, // primary-amber
230+
{ offset: 1, color: '#EA580C' }
231+
]),
232+
borderRadius: [4, 4, 0, 0]
233+
},
234+
showBackground: true,
235+
backgroundStyle: {
236+
color: 'rgba(180, 180, 180, 0.1)'
237+
}
238+
}
239+
]
240+
});
241+
}
242+
243+
if (pieChartRef.value && props.data.monthlyTrend) {
244+
pieChart = echarts.init(pieChartRef.value);
245+
246+
const pieData = props.data.monthlyTrend.map(m => ({
247+
name: m.month,
248+
value: m.amount
249+
}));
250+
251+
pieChart.setOption({
252+
title: {
253+
text: '各月支出占比',
254+
left: 'center',
255+
textStyle: { fontSize: 14, color: '#888' }
256+
},
257+
tooltip: {
258+
trigger: 'item',
259+
formatter: '{b}: ¥{c} ({d}%)'
260+
},
261+
series: [
262+
{
263+
name: '支出占比',
264+
type: 'pie',
265+
radius: ['40%', '70%'],
266+
avoidLabelOverlap: false,
267+
itemStyle: {
268+
borderRadius: 10,
269+
borderColor: '#fff',
270+
borderWidth: 2
271+
},
272+
label: {
273+
show: false,
274+
position: 'center'
275+
},
276+
emphasis: {
277+
label: {
278+
show: true,
279+
fontSize: 16,
280+
fontWeight: 'bold',
281+
formatter: '{b}\n{d}%'
282+
}
283+
},
284+
labelLine: {
285+
show: false
286+
},
287+
data: pieData
288+
}
289+
]
290+
});
291+
}
292+
};
293+
294+
onMounted(() => {
295+
nextTick(() => {
296+
initCharts();
297+
});
298+
299+
window.addEventListener('resize', handleResize);
300+
});
301+
302+
onUnmounted(() => {
303+
window.removeEventListener('resize', handleResize);
304+
trendChart?.dispose();
305+
pieChart?.dispose();
306+
});
307+
308+
const handleResize = () => {
309+
trendChart?.resize();
310+
pieChart?.resize();
311+
};
312+
313+
watch(() => props.data, () => {
314+
nextTick(() => {
315+
trendChart?.dispose();
316+
pieChart?.dispose();
317+
initCharts();
318+
});
319+
}, { deep: true });
320+
321+
</script>
322+
323+
<style scoped>
324+
.animate-fade-in-up {
325+
opacity: 0;
326+
animation: fadeInUp 1s ease-out forwards;
327+
}
328+
329+
@keyframes fadeInUp {
330+
from {
331+
opacity: 0;
332+
transform: translateY(20px);
333+
}
334+
to {
335+
opacity: 1;
336+
transform: translateY(0);
337+
}
338+
}
339+
</style>

0 commit comments

Comments
 (0)