feat: add interactive grafana-style flow chart

This commit is contained in:
openclaw 2026-07-26 16:25:49 +08:00
parent 863d62fd88
commit e5392369fc
1 changed files with 63 additions and 3 deletions

View File

@ -291,6 +291,37 @@ function chartPoints(field: "income" | "expense") {
});
}
const chartHoverIndex = ref<number | null>(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) {
<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"><svg viewBox="0 0 320 190" role="img" aria-label="收支折线图"><line x1="32" y1="28" x2="32" y2="154" class="chart-axis-line" /><line x1="32" y1="154" x2="304" y2="154" class="chart-axis-line" /><line v-for="y in [28,70,112,154]" :key="y" x1="32" :y1="y" x2="304" :y2="y" class="chart-grid-line" /><text v-for="(tick, index) in [1, .66, .33, 0]" :key="tick" x="0" :y="[28,70,112,154][index] + 3" class="chart-axis-label">{{ compactDailyAmount(lineMaxAmount * tick) }}</text><polyline :points="linePoints('income')" class="chart-line income-line" /><polyline :points="linePoints('expense')" class="chart-line expense-line" /><circle v-for="point in chartPoints('income')" :key="point.key" :cx="point.x" :cy="point.y" r="3" class="chart-dot income-dot" /><circle v-for="point in chartPoints('expense')" :key="point.key" :cx="point.x" :cy="point.y" r="3" class="chart-dot expense-dot" /></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 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="154" x2="304" y2="154" class="chart-axis-line" />
<line v-for="y in [28,70,112,154]" :key="y" x1="32" :y1="y" x2="304" :y2="y" class="chart-grid-line" />
<text v-for="(tick, index) in [1, .66, .33, 0]" :key="tick" x="0" :y="[28,70,112,154][index] + 3" class="chart-axis-label">{{ compactDailyAmount(lineMaxAmount * tick) }}</text>
<line v-if="hoveredChartPoint" :x1="hoveredChartPoint.x" y1="28" :x2="hoveredChartPoint.x" y2="154" class="chart-crosshair" />
<polyline :points="linePoints('income')" class="chart-line income-line" /><polyline :points="linePoints('expense')" class="chart-line expense-line" />
<circle v-for="(point, index) in chartPoints('income')" :key="point.key" :cx="point.x" :cy="point.y" :r="chartHoverIndex === index ? 5 : 2.5" class="chart-dot income-dot" :class="{ active: chartHoverIndex === index }" />
<circle v-for="(point, index) in chartPoints('expense')" :key="point.key" :cx="point.x" :cy="point.y" :r="chartHoverIndex === index ? 5 : 2.5" class="chart-dot expense-dot" :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>
@ -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; }