mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
de23216e49
* feat(usage): enhance usage stats backend and query hooks * feat(usage): redesign calendar date range picker with auto-switch and simplified layout * refactor(usage): streamline dashboard layout and stats components * refactor(usage): compact request log table with merged cache/multiplier columns and centered layout * feat(i18n): add cache short labels and usage stats translations for zh/en/ja * Align usage dashboard stats with range boundaries The usage dashboard mixed second-precision detail rows with day-level rollups, which caused custom half-day ranges to overcount historical rollup data and left the request log paginator on stale pages after top-level filter changes. This change limits rollups to fully covered local days, aligns multi-day trend buckets with natural local days, and resets request log pagination when the dashboard range or app filter changes. Constraint: usage_daily_rollups stores only daily aggregates after pruning old detail rows Rejected: Include partial boundary rollups proportionally | historical intra-day detail is unavailable after pruning Rejected: Force RequestLogTable remount on range change | would discard local draft filters unnecessarily Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep summary, trends, provider stats, and model stats on the same rollup-boundary rules Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx Tested: pnpm typecheck Not-tested: Manual UI validation in the Tauri app * Preserve full-day usage filters at minute precision The latest review surfaced two interaction bugs in the usage dashboard: rollup-backed stats undercounted end days selected via the minute-precision picker, and immediate select changes accidentally applied unsubmitted text drafts from the request log filters. This change treats 23:59 as a fully selected local end day for rollup inclusion and narrows select-side state syncing so app/status updates do not commit provider/model drafts. Constraint: The custom range picker emits minute-precision timestamps, while rollups are stored at day granularity Rejected: Require exact 23:59:59 end timestamps | unreachable from the current picker UI Rejected: Rebuild applied filters from the full draft state on select changes | silently commits unsaved text input Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep request-log text fields on explicit apply semantics even when select filters remain immediate Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx Tested: pnpm typecheck Not-tested: Manual Tauri dashboard interaction * refactor(usage): move range presets into date picker, single-row layout - UsageDateRangePicker: add preset shortcuts (今天/1d/7d/14d/30d) inside popover top; clicking a preset applies immediately and closes popover - UsageDashboard: collapse to single row (app filters + refresh + picker); remove standalone preset buttons and summary stats bar - RequestLogTable: replace static Calendar badge with interactive UsageDateRangePicker via onRangeChange prop; single filter row * Keep usage pagination regression coverage aligned with the rendered UI The new regression test was asserting a non-existent pagination label and page summary text, so it failed before it could verify the real page-reset behavior. This commit switches the assertions to the numbered pagination buttons that the component actually renders and validates the reset through the query hook arguments. Constraint: RequestLogTable exposes numbered pagination buttons, not a "Next page" label or "2 / 6" summary text Rejected: Add synthetic pagination labels solely for the test | would couple production markup to a test-only assumption Confidence: high Scope-risk: narrow Reversibility: clean Directive: Prefer pagination assertions that follow the rendered controls or hook inputs instead of invented summary text Tested: pnpm vitest run tests/components/RequestLogTable.test.tsx; pnpm typecheck; pnpm test:unit * refactor(usage): clean up dead code and polish date range picker - Remove unused exports MAX_CUSTOM_USAGE_RANGE_SECONDS, timestampToLocalDatetime, and localDatetimeToTimestamp from usageRange.ts (replaced by the calendar picker) - Deduplicate getPresetLabel from UsageDashboard and UsageDateRangePicker into shared getUsageRangePresetLabel helper - Add aria-label, aria-current and aria-pressed to calendar day buttons so screen readers can disambiguate same-numbered days across adjacent months - Drop unused cacheReadShort and cacheWriteShort i18n keys (zh/en/ja); the request log table renders R/W prefixes inline - Align customRangeHint copy with the removed 30-day limit by dropping "up to 30 days" wording (zh/en/ja) * fix(usage): align rollup cutoff to local midnight to keep days complete `rollup_and_prune` previously used `Utc::now() - retain_days * 86400` as the cutoff. Because rollups are bucketed by *local* date and detail rows below the cutoff are pruned, an unaligned cutoff left the youngest rolled-up day half-rolled-up and half-pruned. Combined with the new `compute_rollup_date_bounds` boundary trimming (which excludes any rollup day not fully covered by the requested range), custom range queries that touch that day silently under-count summary, trend, provider, and model stats. Fix the invariant at the source: snap the cutoff to the next local midnight after `(now - retain_days)`. Every rollup row now reflects a complete local day, so the boundary trimmer's all-or-nothing assumption holds. Includes unit tests for the cutoff math (typical case + already-on- midnight case). DST gap is handled defensively by bumping forward by an hour. Addresses Codex P2 review finding on PR #2002. --------- Co-authored-by: Jason <farion1231@gmail.com>
245 lines
7.0 KiB
Rust
245 lines
7.0 KiB
Rust
//! 使用统计相关命令
|
|
|
|
use crate::error::AppError;
|
|
use crate::services::usage_stats::*;
|
|
use crate::store::AppState;
|
|
use tauri::State;
|
|
|
|
/// 获取使用量汇总
|
|
#[tauri::command]
|
|
pub fn get_usage_summary(
|
|
state: State<'_, AppState>,
|
|
start_date: Option<i64>,
|
|
end_date: Option<i64>,
|
|
app_type: Option<String>,
|
|
) -> Result<UsageSummary, AppError> {
|
|
state
|
|
.db
|
|
.get_usage_summary(start_date, end_date, app_type.as_deref())
|
|
}
|
|
|
|
/// 获取每日趋势
|
|
#[tauri::command]
|
|
pub fn get_usage_trends(
|
|
state: State<'_, AppState>,
|
|
start_date: Option<i64>,
|
|
end_date: Option<i64>,
|
|
app_type: Option<String>,
|
|
) -> Result<Vec<DailyStats>, AppError> {
|
|
state
|
|
.db
|
|
.get_daily_trends(start_date, end_date, app_type.as_deref())
|
|
}
|
|
|
|
/// 获取 Provider 统计
|
|
#[tauri::command]
|
|
pub fn get_provider_stats(
|
|
state: State<'_, AppState>,
|
|
start_date: Option<i64>,
|
|
end_date: Option<i64>,
|
|
app_type: Option<String>,
|
|
) -> Result<Vec<ProviderStats>, AppError> {
|
|
state
|
|
.db
|
|
.get_provider_stats(start_date, end_date, app_type.as_deref())
|
|
}
|
|
|
|
/// 获取模型统计
|
|
#[tauri::command]
|
|
pub fn get_model_stats(
|
|
state: State<'_, AppState>,
|
|
start_date: Option<i64>,
|
|
end_date: Option<i64>,
|
|
app_type: Option<String>,
|
|
) -> Result<Vec<ModelStats>, AppError> {
|
|
state
|
|
.db
|
|
.get_model_stats(start_date, end_date, app_type.as_deref())
|
|
}
|
|
|
|
/// 获取请求日志列表
|
|
#[tauri::command]
|
|
pub fn get_request_logs(
|
|
state: State<'_, AppState>,
|
|
filters: LogFilters,
|
|
page: u32,
|
|
page_size: u32,
|
|
) -> Result<PaginatedLogs, AppError> {
|
|
state.db.get_request_logs(&filters, page, page_size)
|
|
}
|
|
|
|
/// 获取单个请求详情
|
|
#[tauri::command]
|
|
pub fn get_request_detail(
|
|
state: State<'_, AppState>,
|
|
request_id: String,
|
|
) -> Result<Option<RequestLogDetail>, AppError> {
|
|
state.db.get_request_detail(&request_id)
|
|
}
|
|
|
|
/// 获取模型定价列表
|
|
#[tauri::command]
|
|
pub fn get_model_pricing(state: State<'_, AppState>) -> Result<Vec<ModelPricingInfo>, AppError> {
|
|
log::info!("获取模型定价列表");
|
|
state.db.ensure_model_pricing_seeded()?;
|
|
|
|
let db = state.db.clone();
|
|
let conn = crate::database::lock_conn!(db.conn);
|
|
|
|
// 检查表是否存在
|
|
let table_exists: bool = conn
|
|
.query_row(
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='model_pricing'",
|
|
[],
|
|
|row| row.get::<_, i64>(0).map(|count| count > 0),
|
|
)
|
|
.unwrap_or(false);
|
|
|
|
if !table_exists {
|
|
log::error!("model_pricing 表不存在,可能需要重启应用以触发数据库迁移");
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut stmt = conn.prepare(
|
|
"SELECT model_id, display_name, input_cost_per_million, output_cost_per_million,
|
|
cache_read_cost_per_million, cache_creation_cost_per_million
|
|
FROM model_pricing
|
|
ORDER BY display_name",
|
|
)?;
|
|
|
|
let rows = stmt.query_map([], |row| {
|
|
Ok(ModelPricingInfo {
|
|
model_id: row.get(0)?,
|
|
display_name: row.get(1)?,
|
|
input_cost_per_million: row.get(2)?,
|
|
output_cost_per_million: row.get(3)?,
|
|
cache_read_cost_per_million: row.get(4)?,
|
|
cache_creation_cost_per_million: row.get(5)?,
|
|
})
|
|
})?;
|
|
|
|
let mut pricing = Vec::new();
|
|
for row in rows {
|
|
pricing.push(row?);
|
|
}
|
|
|
|
log::info!("成功获取 {} 条模型定价数据", pricing.len());
|
|
Ok(pricing)
|
|
}
|
|
|
|
/// 更新模型定价
|
|
#[tauri::command]
|
|
pub fn update_model_pricing(
|
|
state: State<'_, AppState>,
|
|
model_id: String,
|
|
display_name: String,
|
|
input_cost: String,
|
|
output_cost: String,
|
|
cache_read_cost: String,
|
|
cache_creation_cost: String,
|
|
) -> Result<(), AppError> {
|
|
let db = state.db.clone();
|
|
let conn = crate::database::lock_conn!(db.conn);
|
|
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO model_pricing (
|
|
model_id, display_name, input_cost_per_million, output_cost_per_million,
|
|
cache_read_cost_per_million, cache_creation_cost_per_million
|
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
rusqlite::params![
|
|
model_id,
|
|
display_name,
|
|
input_cost,
|
|
output_cost,
|
|
cache_read_cost,
|
|
cache_creation_cost
|
|
],
|
|
)
|
|
.map_err(|e| AppError::Database(format!("更新模型定价失败: {e}")))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// 检查 Provider 使用限额
|
|
#[tauri::command]
|
|
pub fn check_provider_limits(
|
|
state: State<'_, AppState>,
|
|
provider_id: String,
|
|
app_type: String,
|
|
) -> Result<crate::services::usage_stats::ProviderLimitStatus, AppError> {
|
|
state.db.check_provider_limits(&provider_id, &app_type)
|
|
}
|
|
|
|
/// 删除模型定价
|
|
#[tauri::command]
|
|
pub fn delete_model_pricing(state: State<'_, AppState>, model_id: String) -> Result<(), AppError> {
|
|
let db = state.db.clone();
|
|
let conn = crate::database::lock_conn!(db.conn);
|
|
|
|
conn.execute(
|
|
"DELETE FROM model_pricing WHERE model_id = ?1",
|
|
rusqlite::params![model_id],
|
|
)
|
|
.map_err(|e| AppError::Database(format!("删除模型定价失败: {e}")))?;
|
|
|
|
log::info!("已删除模型定价: {model_id}");
|
|
Ok(())
|
|
}
|
|
|
|
/// 手动触发会话日志同步
|
|
#[tauri::command]
|
|
pub fn sync_session_usage(
|
|
state: State<'_, AppState>,
|
|
) -> Result<crate::services::session_usage::SessionSyncResult, AppError> {
|
|
// 同步 Claude 会话日志
|
|
let mut result = crate::services::session_usage::sync_claude_session_logs(&state.db)?;
|
|
|
|
// 同步 Codex 使用数据
|
|
match crate::services::session_usage_codex::sync_codex_usage(&state.db) {
|
|
Ok(codex_result) => {
|
|
result.imported += codex_result.imported;
|
|
result.skipped += codex_result.skipped;
|
|
result.files_scanned += codex_result.files_scanned;
|
|
result.errors.extend(codex_result.errors);
|
|
}
|
|
Err(e) => {
|
|
result.errors.push(format!("Codex 同步失败: {e}"));
|
|
}
|
|
}
|
|
|
|
// 同步 Gemini 使用数据
|
|
match crate::services::session_usage_gemini::sync_gemini_usage(&state.db) {
|
|
Ok(gemini_result) => {
|
|
result.imported += gemini_result.imported;
|
|
result.skipped += gemini_result.skipped;
|
|
result.files_scanned += gemini_result.files_scanned;
|
|
result.errors.extend(gemini_result.errors);
|
|
}
|
|
Err(e) => {
|
|
result.errors.push(format!("Gemini 同步失败: {e}"));
|
|
}
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
/// 获取数据来源分布
|
|
#[tauri::command]
|
|
pub fn get_usage_data_sources(
|
|
state: State<'_, AppState>,
|
|
) -> Result<Vec<crate::services::session_usage::DataSourceSummary>, AppError> {
|
|
crate::services::session_usage::get_data_source_breakdown(&state.db)
|
|
}
|
|
|
|
/// 模型定价信息
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct ModelPricingInfo {
|
|
pub model_id: String,
|
|
pub display_name: String,
|
|
pub input_cost_per_million: String,
|
|
pub output_cost_per_million: String,
|
|
pub cache_read_cost_per_million: String,
|
|
pub cache_creation_cost_per_million: String,
|
|
}
|