feat: add interactive income expense bar chart
This commit is contained in:
parent
e5392369fc
commit
65ce9670b8
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import type { LedgerEntry } from "@cents/domain";
|
||||
import { ArrowLeft, CalendarDays, ChartNoAxesCombined, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, CircleUserRound, TrendingDown, TrendingUp, WalletCards, X } from "@lucide/vue";
|
||||
import { ArrowLeft, BarChart3, CalendarDays, ChartNoAxesCombined, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, CircleUserRound, TrendingDown, TrendingUp, WalletCards, X } from "@lucide/vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter, type LocationQueryRaw } from "vue-router";
|
||||
import QuickEntryHost from "../components/QuickEntryHost.vue";
|
||||
|
|
@ -11,7 +11,7 @@ import { useLedgerStore } from "../stores/ledgers";
|
|||
|
||||
type StatsTab = "flow" | "category" | "member";
|
||||
type RangeMode = "month" | "year" | "custom" | "all";
|
||||
type DailyView = "calendar" | "line";
|
||||
type DailyView = "calendar" | "line" | "bar";
|
||||
type FlowGranularity = "day" | "month" | "year";
|
||||
|
||||
const router = useRouter();
|
||||
|
|
@ -50,7 +50,7 @@ const dailyViewQuery = queryString(route.query.view);
|
|||
const granularityQuery = queryString(route.query.granularity);
|
||||
const activeTab = ref<StatsTab>(isStatsTab(tabQuery) ? tabQuery : "flow");
|
||||
const rangeMode = ref<RangeMode>(isRangeMode(rangeQuery) ? rangeQuery : "month");
|
||||
const dailyView = ref<DailyView>(dailyViewQuery === "line" ? "line" : "calendar");
|
||||
const dailyView = ref<DailyView>(dailyViewQuery === "line" || dailyViewQuery === "bar" ? dailyViewQuery : "calendar");
|
||||
const flowGranularity = ref<FlowGranularity>(granularityQuery === "month" || granularityQuery === "year" ? granularityQuery : "day");
|
||||
const rangePickerOpen = ref(false);
|
||||
const rangePickerView = ref<"months" | "years">("months");
|
||||
|
|
@ -66,7 +66,8 @@ function syncStatsStateFromRoute() {
|
|||
const range = queryString(route.query.range);
|
||||
activeTab.value = isStatsTab(tab) ? tab : "flow";
|
||||
rangeMode.value = isRangeMode(range) ? range : "month";
|
||||
dailyView.value = queryString(route.query.view) === "line" ? "line" : "calendar";
|
||||
const view = queryString(route.query.view);
|
||||
dailyView.value = view === "line" || view === "bar" ? view : "calendar";
|
||||
const granularity = queryString(route.query.granularity);
|
||||
flowGranularity.value = granularity === "month" || granularity === "year" ? granularity : "day";
|
||||
selectedMonth.value = queryMonth(queryString(route.query.month));
|
||||
|
|
@ -291,6 +292,25 @@ function chartPoints(field: "income" | "expense") {
|
|||
});
|
||||
}
|
||||
|
||||
function chartBarWidth() {
|
||||
return Math.max(2, Math.min(18, 240 / Math.max(1, lineStats.value.length)));
|
||||
}
|
||||
|
||||
function chartBarX(index: number) {
|
||||
const width = chartBarWidth();
|
||||
const step = lineStats.value.length === 1 ? 0 : 272 / (lineStats.value.length - 1);
|
||||
const center = lineStats.value.length === 1 ? 168 : 32 + index * step;
|
||||
return center - width / 2;
|
||||
}
|
||||
|
||||
function chartBarY(value: number) {
|
||||
return 91 - (value / lineMaxAmount.value) * 63;
|
||||
}
|
||||
|
||||
function chartBarHeight(value: number) {
|
||||
return (value / lineMaxAmount.value) * 63;
|
||||
}
|
||||
|
||||
const chartHoverIndex = ref<number | null>(null);
|
||||
const hoveredChartPoint = computed(() => {
|
||||
const index = chartHoverIndex.value;
|
||||
|
|
@ -643,7 +663,7 @@ function shiftCalendarYear(offset: -1 | 1) {
|
|||
</div>
|
||||
|
||||
<section v-if="activeTab === 'flow'" class="stats-section" aria-label="每日收支">
|
||||
<header><strong>收支趋势</strong><div class="daily-controls"><div class="daily-granularity-switch" role="group" aria-label="统计粒度"><button type="button" :class="{ active: flowGranularity === 'day' }" @click="flowGranularity = 'day'">日</button><button type="button" :class="{ active: flowGranularity === 'month' }" @click="flowGranularity = 'month'">月</button><button type="button" :class="{ active: flowGranularity === 'year' }" @click="flowGranularity = 'year'">年</button></div><div class="daily-view-switch" role="group" aria-label="收支视图"><button type="button" :class="{ active: dailyView === 'calendar' }" aria-label="日历视图" title="日历视图" @click="dailyView = 'calendar'"><CalendarDays :size="15" /></button><button type="button" :class="{ active: dailyView === 'line' }" aria-label="折线图视图" title="折线图视图" @click="dailyView = 'line'"><ChartNoAxesCombined :size="15" /></button></div></div></header>
|
||||
<header><strong>收支趋势</strong><div class="daily-controls"><div class="daily-granularity-switch" role="group" aria-label="统计粒度"><button type="button" :class="{ active: flowGranularity === 'day' }" @click="flowGranularity = 'day'">日</button><button type="button" :class="{ active: flowGranularity === 'month' }" @click="flowGranularity = 'month'">月</button><button type="button" :class="{ active: flowGranularity === 'year' }" @click="flowGranularity = 'year'">年</button></div><div class="daily-view-switch" role="group" aria-label="收支视图"><button type="button" :class="{ active: dailyView === 'calendar' }" aria-label="日历视图" title="日历视图" @click="dailyView = 'calendar'"><CalendarDays :size="15" /></button><button type="button" :class="{ active: dailyView === 'line' }" aria-label="折线图视图" title="折线图视图" @click="dailyView = 'line'"><ChartNoAxesCombined :size="15" /></button><button type="button" :class="{ active: dailyView === 'bar' }" aria-label="柱状图视图" title="柱状图视图" @click="dailyView = 'bar'"><BarChart3 :size="15" /></button></div></div></header>
|
||||
<div v-if="dailyView === 'calendar'" class="daily-calendar">
|
||||
<template v-if="flowGranularity === 'day'">
|
||||
<div class="calendar-month-nav"><button type="button" aria-label="上个月" title="上个月" :disabled="calendarMonthStart?.getTime() === new Date(rangeBounds?.start.getFullYear() ?? 0, rangeBounds?.start.getMonth() ?? 0, 1).getTime()" @click="shiftCalendarMonth(-1)"><ChevronLeft :size="17" /></button><strong>{{ calendarMonthLabel }}</strong><button type="button" aria-label="下个月" title="下个月" :disabled="calendarMonthStart?.getMonth() === new Date(rangeBounds?.end.getFullYear() ?? 0, rangeBounds?.end.getMonth() ?? 0, 1).getMonth() && calendarMonthStart?.getFullYear() === new Date(rangeBounds?.end.getTime() ?? 0).getFullYear()" @click="shiftCalendarMonth(1)"><ChevronRight :size="17" /></button></div>
|
||||
|
|
@ -669,7 +689,7 @@ function shiftCalendarYear(offset: -1 | 1) {
|
|||
</div>
|
||||
<div class="daily-view-legend"><span class="income">收入</span><span class="expense">支出</span></div>
|
||||
</div>
|
||||
<div v-else class="daily-line-chart">
|
||||
<div v-else-if="dailyView === 'line'" class="daily-line-chart">
|
||||
<div v-if="lineStats.length" class="line-chart-wrap">
|
||||
<div v-if="hoveredChartPoint" class="chart-tooltip" :style="chartTooltipStyle">
|
||||
<strong>{{ hoveredChartPoint.label }}</strong>
|
||||
|
|
@ -691,6 +711,27 @@ function shiftCalendarYear(offset: -1 | 1) {
|
|||
</div><div v-else class="daily-view-empty">暂无可绘制的流水</div>
|
||||
<div class="daily-view-legend"><span class="income">收入</span><span class="expense">支出</span></div>
|
||||
</div>
|
||||
<div v-else class="daily-line-chart">
|
||||
<div v-if="lineStats.length" class="line-chart-wrap">
|
||||
<div v-if="hoveredChartPoint" class="chart-tooltip" :style="chartTooltipStyle">
|
||||
<strong>{{ hoveredChartPoint.label }}</strong>
|
||||
<span><i class="income">收入</i><b>¥ {{ money(hoveredChartPoint.income) }}</b></span>
|
||||
<span><i class="expense">支出</i><b>¥ {{ money(hoveredChartPoint.expense) }}</b></span>
|
||||
<small>结余 ¥ {{ money(hoveredChartPoint.income - hoveredChartPoint.expense) }}</small>
|
||||
</div>
|
||||
<svg viewBox="0 0 320 190" role="img" aria-label="收支柱状图" @pointermove="updateChartHover" @pointerdown="updateChartHover" @pointerleave="clearChartHover" @pointercancel="clearChartHover">
|
||||
<line x1="32" y1="28" x2="32" y2="154" class="chart-axis-line" /><line x1="32" y1="91" x2="304" y2="91" class="chart-zero-line" />
|
||||
<line v-for="y in [28,60,91,122,154]" :key="y" x1="32" :y1="y" x2="304" :y2="y" class="chart-grid-line" />
|
||||
<text v-for="(tick, index) in [1, .5, 0, -.5, -1]" :key="tick" x="0" :y="[28,60,91,122,154][index] + 3" class="chart-axis-label">{{ tick < 0 ? '-' : '' }}{{ compactDailyAmount(lineMaxAmount * Math.abs(tick)) }}</text>
|
||||
<line v-if="hoveredChartPoint" :x1="hoveredChartPoint.x" y1="28" :x2="hoveredChartPoint.x" y2="154" class="chart-crosshair" />
|
||||
<rect v-for="(item, index) in lineStats" :key="`income-${item.date}`" :x="chartBarX(index)" :y="chartBarY(item.income)" :width="chartBarWidth()" :height="chartBarHeight(item.income)" rx="1.5" class="bar income-bar" :class="{ active: chartHoverIndex === index }" />
|
||||
<rect v-for="(item, index) in lineStats" :key="`expense-${item.date}`" :x="chartBarX(index)" y="91" :width="chartBarWidth()" :height="chartBarHeight(item.expense)" rx="1.5" class="bar expense-bar" :class="{ active: chartHoverIndex === index }" />
|
||||
<rect x="32" y="28" width="272" height="126" class="chart-hit-area" aria-label="查看详细数据" />
|
||||
</svg>
|
||||
<div class="line-chart-labels"><span>{{ lineStats[0]?.label }}</span><span>{{ lineStats.at(-1)?.label }}</span></div>
|
||||
</div><div v-else class="daily-view-empty">暂无可绘制的流水</div>
|
||||
<div class="daily-view-legend"><span class="income">收入</span><span class="expense">支出</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'category'" class="stats-section" aria-label="分类统计">
|
||||
|
|
@ -813,6 +854,7 @@ function shiftCalendarYear(offset: -1 | 1) {
|
|||
.line-chart-wrap { position:relative; min-width:0; }
|
||||
.line-chart-wrap svg { width:100%; height:auto; overflow:visible; touch-action:none; }
|
||||
.chart-axis-line { stroke:#aebdb8; stroke-width:1.2; }
|
||||
.chart-zero-line { stroke:#879a93; stroke-width:1.4; }
|
||||
.chart-grid-line { stroke:#e6eeec; stroke-width:1; }
|
||||
.chart-axis-label { fill:#8a9994; font-size:9px; text-anchor:start; }
|
||||
.chart-crosshair { stroke:#667c75; stroke-width:1; stroke-dasharray:3 3; }
|
||||
|
|
@ -823,6 +865,10 @@ function shiftCalendarYear(offset: -1 | 1) {
|
|||
.income-dot { fill:#20a57d; }
|
||||
.expense-dot { fill:#ef6a4d; }
|
||||
.chart-dot:not(.active) { opacity:.5; }
|
||||
.bar { opacity:.78; }
|
||||
.bar.active { opacity:1; filter:brightness(.92); }
|
||||
.income-bar { fill:#20a57d; }
|
||||
.expense-bar { fill:#ef6a4d; }
|
||||
.chart-hit-area { fill:transparent; cursor:crosshair; }
|
||||
.chart-tooltip { position:absolute; top:7px; z-index:2; display:grid; min-width:132px; gap:4px; transform:translateX(-50%); border:1px solid #435b54; border-radius:5px; padding:8px 9px; background:#263a35; color:#eaf4f1; box-shadow:0 5px 14px rgba(30,50,44,.2); pointer-events:none; }
|
||||
.chart-tooltip strong { color:#fff; font-size:11px; font-weight:720; white-space:nowrap; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue