From 56a88ccb6f9128793a810b2bb17a474dade2939b Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 5 Oct 2026 16:09:07 +0800 Subject: [PATCH] fix(usage): export filtered events and fix dark mode contrast Export every request matching the current filters, remember the selected page size, and use dark surfaces for filter menus and request cards. --- src-tauri/src/usage.rs | 9 ++++- src/i18n/ja.ts | 5 ++- src/i18n/locales/en.ts | 5 ++- src/i18n/locales/zh-CN.ts | 5 ++- src/pages/UsageEventsView.tsx | 71 +++++++++++++++++++++++++--------- src/pages/UsageRecordsPage.tsx | 21 +++++++++- src/styles/usage-subpages.css | 26 +++++++++++-- 7 files changed, 111 insertions(+), 31 deletions(-) diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 13f81b4..a0caf57 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -4255,7 +4255,7 @@ fn load_usage_events( .map(from_sql_i64) .map_err(|error| format!("Failed to count SQLite usage events: {error}"))? .min(usize::MAX as u64) as usize; - let page_size = query.page_size.unwrap_or(50).clamp(20, 200); + let page_size = query.page_size.unwrap_or(50).clamp(1, 5_000); let total_pages = total.div_ceil(page_size).max(1); let page = query.page.unwrap_or(1).clamp(1, total_pages); let offset = (page - 1).saturating_mul(page_size); @@ -6544,6 +6544,13 @@ mod tests { assert_eq!(analysis.models[0].key, "gpt-5.6-terra"); assert_eq!(events.total, 1); assert_eq!(events.items[0].id, "request-2"); + let exported = load_usage_events( + &connection, + &UsageQuery { page_size: Some(5_000), ..query }, + &config, + ).unwrap(); + assert_eq!(exported.page_size, 5_000); + assert_eq!(exported.items.len(), 1); drop(connection); fs::remove_dir_all(root).unwrap(); } diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 4f80eb7..a63e01f 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -738,8 +738,9 @@ export const jaOverrides = { 'usage.events.title': 'リクエストイベントログ', 'usage.events.description': '選択した期間のリクエストイベントを絞り込んで確認します。', 'usage.events.columns': '列', - 'usage.events.exportPage': 'このページを CSV 出力', - 'usage.events.exportDescription': '現在のページの全リクエスト項目を CSV ファイルに出力します', + 'usage.events.exportPage': '絞り込み結果を CSV 出力', + 'usage.events.exportDescription': '現在の絞り込み結果の全リクエスト項目を CSV ファイルに出力します', + 'usage.events.exporting': '出力中…', 'usage.events.loaded': '読み込み済み {count} / {total}', 'usage.events.total': '合計 {count} 件', 'usage.events.page': '{page} / {total} ページ', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index ce535ff..e8dfe18 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -726,8 +726,9 @@ export const en: Record = { 'usage.events.title': 'Request Event Log', 'usage.events.description': 'Filter and inspect request events in the selected time range.', 'usage.events.columns': 'Columns', - 'usage.events.exportPage': 'Export Page CSV', - 'usage.events.exportDescription': 'Export all request fields on the current page as a CSV file', + 'usage.events.exportPage': 'Export Filtered CSV', + 'usage.events.exportDescription': 'Export all request fields matching the current filters as a CSV file', + 'usage.events.exporting': 'Exporting…', 'usage.events.loaded': 'Loaded {count} / {total}', 'usage.events.total': '{count} records', 'usage.events.page': 'Page {page} of {total}', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 9b275e0..00ff43a 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -725,8 +725,9 @@ export const zhCN = { 'usage.events.title': '请求事件日志', 'usage.events.description': '筛选并查看所选时间范围内的请求事件。', 'usage.events.columns': '列', - 'usage.events.exportPage': '导出本页 CSV', - 'usage.events.exportDescription': '导出当前页的全部请求字段为 CSV 文件', + 'usage.events.exportPage': '导出筛选结果 CSV', + 'usage.events.exportDescription': '导出当前筛选结果中的全部请求字段为 CSV 文件', + 'usage.events.exporting': '正在导出…', 'usage.events.loaded': '已加载 {count} / {total}', 'usage.events.total': '共 {count} 条记录', 'usage.events.page': '第 {page} / {total} 页', diff --git a/src/pages/UsageEventsView.tsx b/src/pages/UsageEventsView.tsx index 9743e4d..b804a10 100644 --- a/src/pages/UsageEventsView.tsx +++ b/src/pages/UsageEventsView.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type KeyboardEvent } from 'react'; +import { invoke } from '@tauri-apps/api/core'; import { ArrowDown, ArrowUp, Brain, ChevronLeft, ChevronRight, Columns3Cog, Database, DatabaseZap, Download, RotateCcw, TriangleAlert, X } from 'lucide-react'; import { getCurrentLocale, useI18n } from '../i18n'; import type { MessageKey } from '../i18n/resources'; @@ -204,6 +205,39 @@ const getInitialColumnWidths = (): Record => { return initial; }; + +type UsageEventQuery = { + start?: string; + end?: string; + model?: string; + provider?: string; + source?: string; + api_key_hash?: string; + failed?: boolean; + canceled?: boolean; + page?: number; + page_size?: number; +}; + +function downloadUsageEvents(records: UsageRecord[]) { + const headers = ['id', 'row_id', 'timestamp', 'api_key_display', 'api_key_remark', 'api_key_hash', 'source', 'source_display', 'provider', 'model', 'alias', 'response_model', 'reasoning_effort', 'endpoint', 'failed', 'canceled', 'failure_status', 'failure_body', 'latency_ms', 'ttft_ms', 'input_tokens', 'output_tokens', 'reasoning_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'total_tokens', 'estimated_cost_usd', 'pricing_model']; + const csvCell = (value: string | number | boolean | null) => { + const text = value == null ? '' : String(value); + const safe = typeof value === 'string' && /^[\s\u0000-\u001f]*[=+@-]/.test(text) ? `'${text}` : text; + return `"${safe.replace(/"/g, '""')}"`; + }; + const rows = records.map((record) => [record.id, record.row_id, record.timestamp, record.api_key_display, record.api_key_remark, record.api_key_hash, record.source, record.source_display, record.provider, record.model, record.alias, record.response_model ?? '', record.reasoning_effort, record.endpoint, record.failed, record.canceled, record.failure_status, record.failure_body, record.latency_ms, record.ttft_ms, record.tokens.input_tokens, record.tokens.output_tokens, record.tokens.reasoning_tokens, record.tokens.cache_read_tokens, record.tokens.cache_creation_tokens, record.tokens.total_tokens, record.cost?.total ?? null, record.cost?.pricing_model ?? '']); + const csv = '\uFEFF' + [headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n'); + const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' })); + const link = document.createElement('a'); + link.href = url; + link.download = `usage-events-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); +} + function TableTopScrollbar({ tableWrapRef, }: { @@ -439,12 +473,14 @@ function UsageEventCell({ export function EventsView({ events, pageSize, + query, onPage, onPageSizeChange, loading = false, }: { events: UsageEventPage; pageSize: number; + query: UsageEventQuery; onPage: (page: number) => void; onPageSizeChange: (pageSize: number) => void; loading?: boolean; @@ -455,6 +491,7 @@ export function EventsView({ const [columnSettingsOpen, setColumnSettingsOpen] = useState(false); const [draftVisibleColumnKeys, setDraftVisibleColumnKeys] = useState(visibleColumnKeys); const [resizingCol, setResizingCol] = useState(null); + const [exporting, setExporting] = useState(false); const columnDialogRef = useDialogFocusTrap({ active: columnSettingsOpen, @@ -611,23 +648,19 @@ export function EventsView({ const startRecordNum = events.total > 0 ? (events.page - 1) * pageSize + 1 : 0; const endRecordNum = Math.min(events.page * pageSize, events.total); - const exportCurrentPage = () => { - const headers = ['id', 'row_id', 'timestamp', 'api_key_display', 'api_key_remark', 'api_key_hash', 'source', 'source_display', 'provider', 'model', 'alias', 'response_model', 'reasoning_effort', 'endpoint', 'failed', 'canceled', 'failure_status', 'failure_body', 'latency_ms', 'ttft_ms', 'input_tokens', 'output_tokens', 'reasoning_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'total_tokens', 'estimated_cost_usd', 'pricing_model']; - const csvCell = (value: string | number | boolean | null) => { - const text = value == null ? '' : String(value); - const safe = typeof value === 'string' && /^[\s\u0000-\u001f]*[=+@-]/.test(text) ? `'${text}` : text; - return `"${safe.replace(/"/g, '""')}"`; - }; - const rows = events.items.map((record) => [record.id, record.row_id, record.timestamp, record.api_key_display, record.api_key_remark, record.api_key_hash, record.source, record.source_display, record.provider, record.model, record.alias, record.response_model ?? '', record.reasoning_effort, record.endpoint, record.failed, record.canceled, record.failure_status, record.failure_body, record.latency_ms, record.ttft_ms, record.tokens.input_tokens, record.tokens.output_tokens, record.tokens.reasoning_tokens, record.tokens.cache_read_tokens, record.tokens.cache_creation_tokens, record.tokens.total_tokens, record.cost?.total ?? null, record.cost?.pricing_model ?? '']); - const csv = '\uFEFF' + [headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\r\n'); - const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8;' })); - const link = document.createElement('a'); - link.href = url; - link.download = `usage-events-page-${events.page}-${new Date().toISOString().slice(0, 10)}.csv`; - document.body.appendChild(link); - link.click(); - link.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 1000); + const exportFilteredEvents = async () => { + if (exporting || events.total === 0) return; + setExporting(true); + try { + const exported = await invoke('get_usage_events', { + query: { ...query, page: 1, page_size: Math.max(events.total, 1) }, + }); + downloadUsageEvents(exported.items); + } catch (error) { + window.alert(String(error)); + } finally { + setExporting(false); + } }; return ( @@ -729,8 +762,8 @@ export function EventsView({