From e5392369fc5e095f25c77267c40aa53a90776d85 Mon Sep 17 00:00:00 2001 From: openclaw Date: Sun, 26 Jul 2026 16:25:49 +0800 Subject: [PATCH] feat: add interactive grafana-style flow chart --- apps/web/src/views/StatsView.vue | 66 ++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/apps/web/src/views/StatsView.vue b/apps/web/src/views/StatsView.vue index 9fe3149..6afbed7 100644 --- a/apps/web/src/views/StatsView.vue +++ b/apps/web/src/views/StatsView.vue @@ -291,6 +291,37 @@ function chartPoints(field: "income" | "expense") { }); } +const chartHoverIndex = ref(null); +const hoveredChartPoint = computed(() => { + const index = chartHoverIndex.value; + const item = index === null ? null : lineStats.value[index]; + if (!item || index === null) return null; + const x = lineStats.value.length === 1 ? 168 : 32 + (index / (lineStats.value.length - 1)) * 272; + return { ...item, x }; +}); + +const chartTooltipStyle = computed(() => { + if (!hoveredChartPoint.value) return undefined; + const percentage = (hoveredChartPoint.value.x / 320) * 100; + return { left: `${Math.max(24, Math.min(76, percentage))}%` }; +}); + +function updateChartHover(event: PointerEvent) { + const target = event.currentTarget as SVGElement | null; + if (!target || !lineStats.value.length) return; + const rect = target.getBoundingClientRect(); + const viewBoxX = ((event.clientX - rect.left) / rect.width) * 320; + const normalizedX = Math.max(32, Math.min(304, viewBoxX)); + const index = lineStats.value.length === 1 + ? 0 + : Math.round(((normalizedX - 32) / 272) * (lineStats.value.length - 1)); + chartHoverIndex.value = Math.max(0, Math.min(lineStats.value.length - 1, index)); +} + +function clearChartHover(event: PointerEvent) { + if (event.pointerType !== "touch") chartHoverIndex.value = null; +} + type CategorySummaryNode = { key: string; label: string; @@ -639,7 +670,25 @@ function shiftCalendarYear(offset: -1 | 1) {
收入支出
-
{{ compactDailyAmount(lineMaxAmount * tick) }}
{{ lineStats[0]?.label }}{{ lineStats.at(-1)?.label }}
暂无可绘制的流水
+
+
+ {{ hoveredChartPoint.label }} + 收入¥ {{ money(hoveredChartPoint.income) }} + 支出¥ {{ money(hoveredChartPoint.expense) }} + 结余 ¥ {{ money(hoveredChartPoint.income - hoveredChartPoint.expense) }} +
+ + + + {{ compactDailyAmount(lineMaxAmount * tick) }} + + + + + + +
{{ lineStats[0]?.label }}{{ lineStats.at(-1)?.label }}
+
暂无可绘制的流水
收入支出
@@ -761,17 +810,28 @@ function shiftCalendarYear(offset: -1 | 1) { .daily-view-legend { display:flex; justify-content:center; gap:15px; margin-top:10px; font-size:10px; } .daily-view-legend span::before { display:inline-block; width:7px; height:7px; margin-right:4px; border-radius:50%; background:currentColor; content:""; } .daily-line-chart { padding:12px; } -.line-chart-wrap { min-width:0; } -.line-chart-wrap svg { width:100%; height:auto; overflow:visible; } +.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-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; } .chart-line { fill:none; stroke-width:3; stroke-linecap:round; stroke-linejoin:round; } .income-line { stroke:#20a57d; } .expense-line { stroke:#ef6a4d; } .chart-dot { stroke:#fff; stroke-width:2; } .income-dot { fill:#20a57d; } .expense-dot { fill:#ef6a4d; } +.chart-dot:not(.active) { opacity:.5; } +.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; } +.chart-tooltip span { display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:10px; } +.chart-tooltip span i { font-style:normal; } +.chart-tooltip span b { color:#fff; font-size:10px; font-variant-numeric:tabular-nums; } +.chart-tooltip .income { color:#6ee0b5; } +.chart-tooltip .expense { color:#ff9a83; } +.chart-tooltip small { border-top:1px solid rgba(231,245,241,.15); padding-top:4px; color:#b9ccc6; font-size:9px; } .line-chart-labels { display:flex; justify-content:space-between; color:#8a9994; font-size:9px; } .daily-view-empty { min-height:180px; display:grid; place-items:center; color:#7b8884; font-size:12px; } .daily-stat-row { min-height: 58px; display: grid; grid-template-columns: 34px minmax(0,1fr) 82px; align-items: center; gap: 9px; border-bottom: 1px solid #edf2f0; padding: 8px 12px; }