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 略增)
This commit is contained in:
@@ -13,10 +13,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"echarts": "^6.1.0",
|
||||
"naive-ui": "^2.40.1",
|
||||
"pinia": "^2.2.6",
|
||||
"vfonts": "^0.0.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-echarts": "^8.3.0",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+37
@@ -11,6 +11,9 @@ importers:
|
||||
axios:
|
||||
specifier: ^1.7.7
|
||||
version: 1.20.0
|
||||
echarts:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
naive-ui:
|
||||
specifier: ^2.40.1
|
||||
version: 2.45.3(vue@3.5.42(typescript@5.6.3))
|
||||
@@ -23,6 +26,9 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.5.13
|
||||
version: 3.5.42(typescript@5.6.3)
|
||||
vue-echarts:
|
||||
specifier: ^8.3.0
|
||||
version: 8.3.0(echarts@6.1.0)(vue@3.5.42(typescript@5.6.3))
|
||||
vue-router:
|
||||
specifier: ^4.4.5
|
||||
version: 4.6.4(vue@3.5.42(typescript@5.6.3))
|
||||
@@ -639,6 +645,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
echarts@6.1.0:
|
||||
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
|
||||
|
||||
electron-to-chromium@1.5.427:
|
||||
resolution: {integrity: sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==}
|
||||
|
||||
@@ -1057,6 +1066,9 @@ packages:
|
||||
ts-interface-checker@0.1.13:
|
||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
typescript@5.6.3:
|
||||
resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -1167,6 +1179,12 @@ packages:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
vue-echarts@8.3.0:
|
||||
resolution: {integrity: sha512-gqOZ5uuBF4x4KTXQpTmgH5BuAwUSO+y9U7L1pHCIESQvgnTitXtnyjrfePH+9YXMRhmTLjchEx3EnsPhoiIX/A==}
|
||||
peerDependencies:
|
||||
echarts: ^6.0.0
|
||||
vue: ^3.3.0
|
||||
|
||||
vue-router@4.6.4:
|
||||
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
||||
peerDependencies:
|
||||
@@ -1194,6 +1212,9 @@ packages:
|
||||
webpack-virtual-modules@0.6.2:
|
||||
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
|
||||
|
||||
zrender@6.1.0:
|
||||
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.3.0': {}
|
||||
@@ -1644,6 +1665,11 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
echarts@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
zrender: 6.1.0
|
||||
|
||||
electron-to-chromium@1.5.427: {}
|
||||
|
||||
entities@7.0.1: {}
|
||||
@@ -2101,6 +2127,8 @@ snapshots:
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
typescript@5.6.3: {}
|
||||
|
||||
ufo@1.6.4: {}
|
||||
@@ -2198,6 +2226,11 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.42(typescript@5.6.3)
|
||||
|
||||
vue-echarts@8.3.0(echarts@6.1.0)(vue@3.5.42(typescript@5.6.3)):
|
||||
dependencies:
|
||||
echarts: 6.1.0
|
||||
vue: 3.5.42(typescript@5.6.3)
|
||||
|
||||
vue-router@4.6.4(vue@3.5.42(typescript@5.6.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
@@ -2231,3 +2264,7 @@ snapshots:
|
||||
vue: 3.5.42(typescript@5.6.3)
|
||||
|
||||
webpack-virtual-modules@0.6.2: {}
|
||||
|
||||
zrender@6.1.0:
|
||||
dependencies:
|
||||
tslib: 2.3.0
|
||||
|
||||
@@ -1,13 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { NEmpty } from 'naive-ui'
|
||||
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">
|
||||
<NEmpty description="统计图表(骨架占位,Phase 4 接入 ECharts)">
|
||||
<div class="text-sm text-ink-soft mt-2">
|
||||
油价趋势 / 油耗趋势 / 月度费用 / 每公里成本
|
||||
<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>
|
||||
</NEmpty>
|
||||
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user