Files
FuelLog/fuellog-web/src/pages/Statistics.vue
T
KennethCheng 33dfdc824c feat(frontend): 统计页接入 ECharts,四张图(油价/油耗/费用/成本)
把 Statistics 从占位 Empty 升级成真正的图表页,plan §9.4 落地:
- echarts + vue-echarts,按需引入 CanvasRenderer / LineChart / BarChart
  + Grid / Tooltip / Legend / Title / DataZoom(全部 tree-shakable)
- 四张图,每张独立 NCard,统一主题色 brand #18a058
  1) 油价趋势(折线 + 面积)—— 每月 total_amount / total_liters 当月均价
  2) 标准油耗趋势(折线)—— 每月在"连续两次 is_full"之间采样的均值
  3) 月度费用(柱状)—— 每月加油 total_amount 之和
  4) 每公里成本(折线)—— 同油耗采样算法
- 时间窗选择:近 3 月 / 6 月 / 12 月 / 全部(NSelect 顶栏)
- 油耗 / 每公里成本采样逻辑与后端 pkg/decimal 严格对齐:
  * 列表按 refuel_time 升序遍历
  * 只在"is_full 序列"内采样
  * 半箱油记录自动跳过,不污染指标
- autoresize 适配移动端旋转 / 桌面窗口
- Loading / Empty / Error 状态完整;切车自动重拉

未做(留待后续):
- DataZoom 区域缩放 —— 已在 echarts 引入但没启用,数据量大时再上
- 多车辆对比 —— 计划里没明确要求,先不上
- 油价对比基准线(同地区均值) —— 暂无数据源

验证:
  pnpm typecheck   0 errors
  pnpm build       18.1s(echarts 入了 4 个组件 + 1 个 renderer,bundle 略增)
2026-09-13 16:52:26 +08:00

234 lines
7.1 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { NCard, NEmpty, NSpin, NSelect, NText } from 'naive-ui'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart } from 'echarts/charts'
import {
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
DataZoomComponent,
} from 'echarts/components'
import { refuelsApi } from '@/api/refuels'
import { useVehicleStore } from '@/stores/vehicle'
import type { RefuelRecord } from '@/api/types'
import {
consumptionL100km,
costPerKm,
pricePerLiter,
} from '@/utils/decimal'
use([
CanvasRenderer,
LineChart,
BarChart,
GridComponent,
TooltipComponent,
LegendComponent,
TitleComponent,
DataZoomComponent,
])
const store = useVehicleStore()
const records = ref<RefuelRecord[]>([])
const loading = ref(false)
// 趋势用过去多少月
const rangeOptions = [
{ label: '近 3 月', value: 3 },
{ label: '近 6 月', value: 6 },
{ label: '近 12 月', value: 12 },
{ label: '全部', value: 0 },
]
const range = ref(6)
async function load() {
if (!store.currentId) {
records.value = []
return
}
loading.value = true
try {
records.value = await refuelsApi.list(store.currentId)
} finally {
loading.value = false
}
}
onMounted(async () => {
await store.loadList()
await load()
})
watch(() => store.currentId, load)
// 过滤时间窗
const filtered = computed<RefuelRecord[]>(() => {
if (!range.value) return records.value
const cutoff = new Date()
cutoff.setMonth(cutoff.getMonth() - range.value)
return records.value.filter((r) => new Date(r.refuel_time) >= cutoff)
})
// 按月聚合工具
const monthKey = (iso: string) => {
const d = new Date(iso)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
const byMonth = computed(() => {
const map = new Map<string, RefuelRecord[]>()
for (const r of filtered.value) {
const k = monthKey(r.refuel_time)
if (!map.has(k)) map.set(k, [])
map.get(k)!.push(r)
}
// 月份升序
return Array.from(map.entries()).sort((a, b) => (a[0] < b[0] ? -1 : 1))
})
// 1) 油价趋势:每月均价(总金额 / 升数)
const priceOption = computed(() => {
const xs = byMonth.value.map(([k]) => k)
const ys = byMonth.value.map(([, rs]) => {
const totalA = rs.reduce((s, r) => s + r.total_amount, 0)
const totalL = rs.reduce((s, r) => s + r.liters, 0)
return totalL > 0 ? pricePerLiter(totalA, totalL) : 0
})
return lineOption(xs, ys, '元/升', '油价趋势')
})
// 2) 油耗趋势:每月只在"连续两次 is_full"之间采样;取当月样本的均值
const consumptionOption = computed(() => {
const xs: string[] = []
const ys: number[] = []
for (const [k, rs] of byMonth.value) {
// 升序遍历:相邻两次 is_full 算一次
const sorted = [...rs].sort(
(a, b) => new Date(a.refuel_time).getTime() - new Date(b.refuel_time).getTime(),
)
const samples: number[] = []
let prevFull: RefuelRecord | null = null
for (const r of sorted) {
if (r.is_full) {
if (prevFull) {
const mileage = r.odometer - prevFull.odometer
if (mileage > 0) samples.push(consumptionL100km(r.liters, mileage))
}
prevFull = r
}
}
if (samples.length) {
xs.push(k)
ys.push(samples.reduce((s, x) => s + x, 0) / samples.length)
}
}
return lineOption(xs, ys, 'L/100km', '标准油耗趋势')
})
// 3) 月度费用:每月加油总金额
const costOption = computed(() => {
const xs = byMonth.value.map(([k]) => k)
const ys = byMonth.value.map(([, rs]) =>
Math.round(rs.reduce((s, r) => s + r.total_amount, 0) * 100) / 100,
)
return barOption(xs, ys, '元', '月度费用')
})
// 4) 每公里成本:同油耗的算法,每月一个样本
const costPerKmOption = computed(() => {
const xs: string[] = []
const ys: number[] = []
for (const [k, rs] of byMonth.value) {
const sorted = [...rs].sort(
(a, b) => new Date(a.refuel_time).getTime() - new Date(b.refuel_time).getTime(),
)
const samples: number[] = []
let prevFull: RefuelRecord | null = null
for (const r of sorted) {
if (r.is_full) {
if (prevFull) {
const mileage = r.odometer - prevFull.odometer
if (mileage > 0) samples.push(costPerKm(r.total_amount, mileage))
}
prevFull = r
}
}
if (samples.length) {
xs.push(k)
ys.push(samples.reduce((s, x) => s + x, 0) / samples.length)
}
}
return lineOption(xs, ys, '元/公里', '每公里成本')
})
function lineOption(xs: string[], ys: number[], unit: string, title: string) {
return {
grid: { left: 50, right: 20, top: 30, bottom: 30 },
tooltip: { trigger: 'axis' as const, valueFormatter: (v: unknown) => `${v} ${unit}` },
xAxis: { type: 'category' as const, data: xs, boundaryGap: false },
yAxis: { type: 'value' as const, name: unit, nameTextStyle: { color: '#999' } },
series: [
{
type: 'line' as const,
data: ys,
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: { width: 2, color: '#18a058' },
itemStyle: { color: '#18a058' },
areaStyle: { color: 'rgba(24,160,88,0.12)' },
},
],
title: { text: title, left: 'left', textStyle: { fontSize: 13, fontWeight: 600 } },
}
}
function barOption(xs: string[], ys: number[], unit: string, title: string) {
return {
grid: { left: 50, right: 20, top: 30, bottom: 30 },
tooltip: { trigger: 'axis' as const, valueFormatter: (v: unknown) => `${v} ${unit}` },
xAxis: { type: 'category' as const, data: xs },
yAxis: { type: 'value' as const, name: unit, nameTextStyle: { color: '#999' } },
series: [
{
type: 'bar' as const,
data: ys,
itemStyle: { color: '#18a058', borderRadius: [4, 4, 0, 0] },
},
],
title: { text: title, left: 'left', textStyle: { fontSize: 13, fontWeight: 600 } },
}
}
</script>
<template>
<div class="max-w-screen-md mx-auto space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold">统计</h2>
<NSelect v-model:value="range" :options="rangeOptions" size="small" style="width: 110px" />
</div>
<NText v-if="!store.currentId" depth="3">还没有车辆,无法展示统计。</NText>
<NSpin v-else :show="loading">
<NEmpty v-if="!loading && !filtered.length" description="选定时间窗内还没有加油记录" />
<div v-else class="space-y-3">
<NCard class="!rounded-2xl">
<VChart :option="priceOption" autoresize style="height: 220px" />
</NCard>
<NCard class="!rounded-2xl">
<VChart :option="consumptionOption" autoresize style="height: 220px" />
</NCard>
<NCard class="!rounded-2xl">
<VChart :option="costOption" autoresize style="height: 220px" />
</NCard>
<NCard class="!rounded-2xl">
<VChart :option="costPerKmOption" autoresize style="height: 220px" />
</NCard>
</div>
</NSpin>
</div>
</template>