From 7cde8942d37957fa8226e0192eb7fb6212cc8cc5 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 18 Sep 2026 17:35:02 +0800 Subject: [PATCH] fix(usage): prevent trend chart clipping on scaled displays Make the overview vertically scrollable and preserve the chart's minimum height when summary cards and legends wrap. Fixes #269 --- src/styles.css | 28 +++++++++- tests/fixtures/usage-layout.html | 1 + tests/fixtures/usage-layout.tsx | 92 ++++++++++++++++++++++++++++++++ tests/usage-layout-ui.cjs | 55 +++++++++++++++++++ 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/usage-layout.html create mode 100644 tests/fixtures/usage-layout.tsx create mode 100644 tests/usage-layout-ui.cjs diff --git a/src/styles.css b/src/styles.css index 583260b..e6eb57a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2350,6 +2350,32 @@ textarea:focus-visible { color: var(--theme-777067); } +.config-proxy-heading { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.config-proxy-heading > .config-network-label { + min-width: 0; + color: var(--theme-39352f); + font-size: var(--font-size-body); + font-weight: 700; +} + +.config-proxy-system-toggle { + display: inline-flex; + flex: none; + align-items: center; + gap: 7px; + color: var(--theme-7a746c); + font-size: var(--font-size-meta); + font-weight: 500; + cursor: pointer; +} + .config-network-input { width: 100%; height: 38px; @@ -8129,7 +8155,7 @@ textarea:focus-visible { .usage-overview-layout { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.65fr); - grid-template-rows: auto minmax(250px, 1fr); + grid-template-rows: auto minmax(280px, 1fr); gap: 14px; align-items: stretch; overflow-x: hidden; diff --git a/tests/fixtures/usage-layout.html b/tests/fixtures/usage-layout.html new file mode 100644 index 0000000..66cb4ab --- /dev/null +++ b/tests/fixtures/usage-layout.html @@ -0,0 +1 @@ +
diff --git a/tests/fixtures/usage-layout.tsx b/tests/fixtures/usage-layout.tsx new file mode 100644 index 0000000..f518e84 --- /dev/null +++ b/tests/fixtures/usage-layout.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { mockIPC } from '@tauri-apps/api/mocks'; +import { I18nProvider } from '../../src/i18n'; +import { UsageRecordsPage } from '../../src/pages/UsageRecordsPage'; +import '../../src/styles.css'; + +const params = new URLSearchParams(location.search); +localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-TW'); +localStorage.setItem('cpa-gui.usage-records-tab.v1', 'overview'); +localStorage.setItem('cpa-gui.usage-records-range.v1', '4h'); +document.documentElement.dataset.theme = params.get('theme') || 'dark'; + +const hourKey = (date: Date) => [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), + String(date.getHours()).padStart(2, '0'), +].join('-'); +const now = new Date(); +const trendModels = [ + 'gpt-test', + 'claude-test', + 'gemini-test', + 'deepseek-test', + 'grok-test', + 'qwen-test', + 'mistral-test', + 'llama-test', +]; +const timeline = Array.from({ length: 4 }, (_, index) => ({ + hour: hourKey(new Date(now.getTime() - (3 - index) * 60 * 60 * 1000)), + requests: 12 + index, + success: 11 + index, + failure: 1, + canceled: 0, + tokens: 22_000 + index * 2_000, + models: trendModels.map((key, modelIndex) => ({ + key, + label: key, + tokens: 1_700 + modelIndex * 300 + index * 250, + })), +})); + +mockIPC(async (cmd) => { + if (cmd === 'plugin:event|listen') return 1; + if (cmd === 'plugin:event|unlisten' || cmd === 'set_app_locale') return null; + if (cmd === 'get_usage_collector_status') { + return { state: 'collecting', message: '', lastCollectedAt: now.toISOString(), totalRecords: 54 }; + } + if (cmd === 'get_usage_analysis') { + return { models: [], providers: [], sources: [], apiKeys: [] }; + } + if (cmd === 'get_usage_overview') { + return { + totalRequests: 54, + successCount: 50, + failureCount: 4, + canceledCount: 0, + successRate: 92.6, + inputTokens: 52_000, + outputTokens: 24_000, + reasoningTokens: 8_000, + cacheReadTokens: 16_000, + cacheCreationTokens: 0, + totalTokens: 100_000, + rpm: 0.23, + tpm: 416.7, + tps: 31.2, + tpsSampleCount: 54, + averageLatencyMs: 2_840, + cacheHitRate: 0.307, + estimatedCost: 1.2345, + pricedRequests: 54, + timeline, + }; + } + throw new Error(`Unhandled fixture command: ${cmd}`); +}); + +createRoot(document.getElementById('root')!).render( + +
+
+
, +); diff --git a/tests/usage-layout-ui.cjs b/tests/usage-layout-ui.cjs new file mode 100644 index 0000000..880504c --- /dev/null +++ b/tests/usage-layout-ui.cjs @@ -0,0 +1,55 @@ +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright'); +const assert = require('node:assert/strict'); + +const base = 'http://127.0.0.1:1421'; + +(async () => { + const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] }); + try { + const page = await browser.newPage({ viewport: { width: 1100, height: 600 } }); + await page.route('**/*', (route) => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort()); + await page.goto(`${base}/tests/fixtures/usage-layout.html`, { waitUntil: 'domcontentloaded' }); + await page.locator('.usage-trend-x-axis').waitFor(); + + const geometry = await page.evaluate(() => { + const layout = document.querySelector('.usage-overview-layout'); + const panel = document.querySelector('.usage-trend-panel'); + const plot = document.querySelector('.usage-trend-plot'); + const xAxis = document.querySelector('.usage-trend-x-axis'); + const cards = Array.from(document.querySelectorAll('.usage-stat-card')); + if (!(layout instanceof HTMLElement) || !(panel instanceof HTMLElement) + || !(plot instanceof HTMLElement) || !(xAxis instanceof HTMLElement)) throw new Error('Fixture did not render'); + const panelRect = panel.getBoundingClientRect(); + const xAxisRect = xAxis.getBoundingClientRect(); + return { + layoutClientHeight: layout.clientHeight, + layoutScrollHeight: layout.scrollHeight, + layoutClientWidth: layout.clientWidth, + layoutScrollWidth: layout.scrollWidth, + plotHeight: plot.getBoundingClientRect().height, + xAxisInsidePanel: xAxisRect.bottom <= panelRect.bottom, + cardRows: new Set(cards.map((card) => Math.round(card.getBoundingClientRect().top))).size, + }; + }); + + assert.ok(geometry.cardRows >= 2, 'The constrained layout reproduces wrapped summary cards'); + assert.ok(geometry.layoutScrollHeight > geometry.layoutClientHeight, 'The overview becomes vertically scrollable'); + assert.equal(geometry.layoutScrollWidth, geometry.layoutClientWidth, 'The overview does not introduce horizontal scrolling'); + assert.ok(geometry.plotHeight >= 140, 'The trend plot retains its minimum drawing height'); + assert.equal(geometry.xAxisInsidePanel, true, 'The X axis remains inside the trend panel instead of being clipped'); + + await page.locator('.usage-overview-layout').evaluate((layout) => { layout.scrollTop = layout.scrollHeight; }); + assert.ok(await page.locator('.usage-trend-x-axis').evaluate((axis) => { + const layout = axis.closest('.usage-overview-layout'); + if (!layout) return false; + return axis.getBoundingClientRect().bottom <= layout.getBoundingClientRect().bottom + 1; + }), 'The complete X axis is reachable by scrolling the overview'); + + console.log('PASS: wrapped usage cards preserve the full trend chart and use vertical overview scrolling.'); + } finally { + await browser.close(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +});