feat: add interactive income expense bar chart

This commit is contained in:
openclaw 2026-07-26 16:30:10 +08:00
parent e5392369fc
commit 65ce9670b8
1 changed files with 52 additions and 6 deletions

View File

@ -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; }