diff --git a/CHANGELOG.md b/CHANGELOG.md index c7e6708..2ed0fc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes are recorded here. Versions follow [SemVer](https://semver.org/). +## v0.7.8 (2026-08-22) + +- **Transaction-table filter row fix** — filter row no longer stretches to 236px (constrained to ~48px via fixed-height/th-top-aligned filter inputs); the four token columns (input/cached/output/tokens) drop their number-range filters, keeping sort and right-align (rants 2026-08-22T10:11:48/10:12:57) + ## v0.7.7 (2026-08-22) - **Transaction-page UE/UI** — token columns use K/M abbreviation with exact-value hover tooltip; summary bar values right-aligned; Key column shows a transaction-type label for non-keyed rows (topup/gift/withdraw) instead of a bare dash (rant 2026-08-22T08:58:54) diff --git a/Cargo.lock b/Cargo.lock index 01cd3a4..7004071 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,7 +51,7 @@ dependencies = [ [[package]] name = "aitokenpool" -version = "0.7.7" +version = "0.7.8" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 2df3d4d..cd55524 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aitokenpool" -version = "0.7.7" +version = "0.7.8" edition = "2021" description = "AI Token 共享池 — 企业 key 池 + 公共共享市场" license = "MIT" diff --git a/src/routes/wallet.rs b/src/routes/wallet.rs index d65ecb3..f83ea48 100644 --- a/src/routes/wallet.rs +++ b/src/routes/wallet.rs @@ -7,7 +7,7 @@ use axum::extract::{Query, State}; use axum::Json; -use rusqlite::params; +use rusqlite::params_from_iter; use serde::Deserialize; use crate::dao; @@ -57,6 +57,48 @@ pub struct TxQuery { pub page: u32, #[serde(default = "default_page_size")] pub page_size: u32, + /// 起始时间(ISO 8601,UTC,SQLite 可解析),time >= start;缺省不限 + pub start: Option, + /// 结束时间(ISO 8601,UTC,SQLite 可解析),time < end;缺省不限 + pub end: Option, +} + +/// 构建 transactions 查询条件与绑定参数。 +/// 参数顺序固定:user_id → type → start → end(存在的才加入,占位符序号递增)。 +/// `prefix` 非空时列名加前缀(如 "t"),供带 JOIN 的列表查询使用。 +fn tx_where( + prefix: &str, + user_id: i64, + type_filter: &Option, + start: &Option, + end: &Option, +) -> (String, Vec) { + let col = |c: &str| { + if prefix.is_empty() { + c.to_string() + } else { + format!("{prefix}.{c}") + } + }; + let mut conds: Vec = Vec::new(); + let mut binds: Vec = Vec::new(); + conds.push(format!("{} = ?1", col("user_id"))); + binds.push(rusqlite::types::Value::Integer(user_id)); + if let Some(t) = type_filter { + conds.push(format!("{} = ?{}", col("type"), binds.len() + 1)); + binds.push(rusqlite::types::Value::Text(t.clone())); + } + // 库内 time 为 datetime('now')(UTC "YYYY-MM-DD HH:MM:SS"), + // 前端传 ISO 8601(RFC3339)由 handler 用 chrono 规范化为同格式后再比较(字符串序 = 时间序)。 + for (c, v) in [(start, ">="), (end, "<")] { + if let Some(s) = c { + if !s.trim().is_empty() { + conds.push(format!("{} {v} ?{}", col("time"), binds.len() + 1)); + binds.push(rusqlite::types::Value::Text(s.trim().to_string())); + } + } + } + (conds.join(" AND "), binds) } fn default_page() -> u32 { @@ -87,10 +129,29 @@ pub async fn transactions( } }; let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; - // 汇总(rant 2026-08-22T00:04:21/00:07:08):全量 SQL 聚合(不依赖分页),按当前 type 筛选; - // 口径 = income 白名单(earn/topup/gift)为正、consume 为负;token 统计同口径。 - let summary: serde_json::Value = { - let sql = "SELECT \ + // start/end(rant 2026-08-22T10:50:00):RFC3339/ISO 8601 → 规范化为 UTC "YYYY-MM-DD HH:MM:SS" + // (与库内 datetime('now') 一致,字符串比较即时间比较);非法格式 400。 + let norm = |s: &Option| -> Result, (axum::http::StatusCode, Json)> { + match s { + None => Ok(None), + Some(v) if v.trim().is_empty() => Ok(None), + Some(v) => chrono::DateTime::parse_from_rfc3339(v.trim()) + .map(|dt| Some(dt.with_timezone(&chrono::Utc).format("%Y-%m-%d %H:%M:%S").to_string())) + .map_err(|_| { + ( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "时间参数需为 ISO 8601(RFC3339),如 2026-08-22T00:00:00Z" })), + ) + }), + } + }; + let start = norm(&q.start)?; + let end = norm(&q.end)?; + // 汇总(rant 2026-08-22T00:04:21/00:07:08):全量 SQL 聚合(不依赖分页), + // 按当前 type + 时间段筛选;口径 = income 白名单(earn/topup/gift)为正、consume 为负。 + let (where_sql, where_binds) = tx_where("", auth.user_id, &type_filter, &start, &end); + let summary_sql = format!( + "SELECT \ COALESCE(SUM(CASE WHEN type IN ('earn','topup','gift') THEN pts ELSE 0 END), 0), \ COALESCE(SUM(CASE WHEN type = 'consume' THEN pts ELSE 0 END), 0), \ COALESCE(SUM(CASE WHEN type IN ('earn','topup','gift') THEN pts ELSE -pts END), 0), \ @@ -98,139 +159,74 @@ pub async fn transactions( COALESCE(SUM(tokens - cached_tokens - output_tokens), 0), \ COALESCE(SUM(cached_tokens), 0), \ COALESCE(SUM(output_tokens), 0) \ - FROM transactions WHERE user_id = ?1"; - match &type_filter { - Some(t) => conn - .query_row( - &format!("{sql} AND type = ?2"), - params![auth.user_id, t], - |r| { - Ok(serde_json::json!({ - "income_pts": r.get::<_, f64>(0)?, - "expense_pts": r.get::<_, f64>(1)?, - "net_pts": r.get::<_, f64>(2)?, - "tokens": r.get::<_, f64>(3)?, - "input_tokens": r.get::<_, f64>(4)?, - "cached_tokens": r.get::<_, f64>(5)?, - "output_tokens": r.get::<_, f64>(6)?, - })) - }, - ) - .map_err(internal)?, - None => conn - .query_row(sql, [auth.user_id], |r| { - Ok(serde_json::json!({ - "income_pts": r.get::<_, f64>(0)?, - "expense_pts": r.get::<_, f64>(1)?, - "net_pts": r.get::<_, f64>(2)?, - "tokens": r.get::<_, f64>(3)?, - "input_tokens": r.get::<_, f64>(4)?, - "cached_tokens": r.get::<_, f64>(5)?, - "output_tokens": r.get::<_, f64>(6)?, - })) - }) - .map_err(internal)?, - } - }; - let total: i64 = match &type_filter { - Some(t) => conn - .query_row( - "SELECT COUNT(*) FROM transactions WHERE user_id = ?1 AND type = ?2", - params![auth.user_id, t], - |r| r.get(0), - ) - .unwrap_or(0), - None => conn - .query_row( - "SELECT COUNT(*) FROM transactions WHERE user_id = ?1", - [auth.user_id], - |r| r.get(0), - ) - .unwrap_or(0), - }; + FROM transactions WHERE {where_sql}" + ); + let summary: serde_json::Value = conn + .query_row(&summary_sql, params_from_iter(where_binds.iter()), |r| { + Ok(serde_json::json!({ + "income_pts": r.get::<_, f64>(0)?, + "expense_pts": r.get::<_, f64>(1)?, + "net_pts": r.get::<_, f64>(2)?, + "tokens": r.get::<_, f64>(3)?, + "input_tokens": r.get::<_, f64>(4)?, + "cached_tokens": r.get::<_, f64>(5)?, + "output_tokens": r.get::<_, f64>(6)?, + })) + }) + .map_err(internal)?; + let total: i64 = conn + .query_row( + &format!("SELECT COUNT(*) FROM transactions WHERE {where_sql}"), + params_from_iter(where_binds.iter()), + |r| r.get(0), + ) + .unwrap_or(0); let offset = (page - 1) * page_size; // rant 2026-08-22T06:36:54/06:37:50:模型/Key 列 — 补 key_label(JOIN keys: // note 非空用 note,否则 provider / plan,plan 空则仅 provider;key 已删 → NULL) - let mut stmt = match &type_filter { - Some(_) => conn - .prepare( - "SELECT t.id, t.counterpart, t.key_id, t.model, t.tokens, t.cached_tokens, t.output_tokens, \ - t.pts, t.type, t.status, t.time, \ - CASE WHEN k.note <> '' THEN k.note \ - WHEN k.plan <> '' THEN k.provider || ' / ' || k.plan \ - ELSE k.provider END AS key_label \ - FROM transactions t LEFT JOIN keys k ON k.id = t.key_id \ - WHERE t.user_id = ?1 AND t.type = ?2 \ - ORDER BY t.id DESC LIMIT ?3 OFFSET ?4", - ) - .map_err(internal)?, - None => conn - .prepare( - "SELECT t.id, t.counterpart, t.key_id, t.model, t.tokens, t.cached_tokens, t.output_tokens, \ - t.pts, t.type, t.status, t.time, \ - CASE WHEN k.note <> '' THEN k.note \ - WHEN k.plan <> '' THEN k.provider || ' / ' || k.plan \ - ELSE k.provider END AS key_label \ - FROM transactions t LEFT JOIN keys k ON k.id = t.key_id \ - WHERE t.user_id = ?1 \ - ORDER BY t.id DESC LIMIT ?2 OFFSET ?3", - ) - .map_err(internal)?, - }; - let rows: Vec = match &type_filter { - Some(t) => stmt - .query_map(params![auth.user_id, t, page_size, offset], |r| { - let time: String = r.get(10)?; - let tokens: f64 = r.get(4)?; - let cached: f64 = r.get(5)?; - let output: f64 = r.get(6)?; - let input = tokens - cached - output; - Ok(serde_json::json!({ - "id": r.get::<_, i64>(0)?, - "counterpart": r.get::<_, String>(1)?, - "key_id": r.get::<_, Option>(2)?, - "model": r.get::<_, String>(3)?, - "tokens": tokens, - "input_tokens": input, - "cached_tokens": cached, - "output_tokens": output, - "pts": r.get::<_, f64>(7)?, - "type": r.get::<_, String>(8)?, - "status": r.get::<_, String>(9)?, - "time": crate::dao::utc_iso(&time), - "key_label": r.get::<_, Option>(11)?, - })) - }) - .map_err(internal)? - .collect::>>() - .map_err(internal)?, - None => stmt - .query_map(params![auth.user_id, page_size, offset], |r| { - let time: String = r.get(10)?; - let tokens: f64 = r.get(4)?; - let cached: f64 = r.get(5)?; - let output: f64 = r.get(6)?; - let input = tokens - cached - output; - Ok(serde_json::json!({ - "id": r.get::<_, i64>(0)?, - "counterpart": r.get::<_, String>(1)?, - "key_id": r.get::<_, Option>(2)?, - "model": r.get::<_, String>(3)?, - "tokens": tokens, - "input_tokens": input, - "cached_tokens": cached, - "output_tokens": output, - "pts": r.get::<_, f64>(7)?, - "type": r.get::<_, String>(8)?, - "status": r.get::<_, String>(9)?, - "time": crate::dao::utc_iso(&time), - "key_label": r.get::<_, Option>(11)?, - })) - }) - .map_err(internal)? - .collect::>>() - .map_err(internal)?, - }; + let (list_where, mut list_binds) = tx_where("t", auth.user_id, &type_filter, &start, &end); + let n = list_binds.len(); + list_binds.push(rusqlite::types::Value::Integer(page_size as i64)); + list_binds.push(rusqlite::types::Value::Integer(offset as i64)); + let list_sql = format!( + "SELECT t.id, t.counterpart, t.key_id, t.model, t.tokens, t.cached_tokens, t.output_tokens, \ + t.pts, t.type, t.status, t.time, \ + CASE WHEN k.note <> '' THEN k.note \ + WHEN k.plan <> '' THEN k.provider || ' / ' || k.plan \ + ELSE k.provider END AS key_label \ + FROM transactions t LEFT JOIN keys k ON k.id = t.key_id \ + WHERE {list_where} \ + ORDER BY t.id DESC LIMIT ?{} OFFSET ?{}", + n + 1, + n + 2 + ); + let mut stmt = conn.prepare(&list_sql).map_err(internal)?; + let rows: Vec = stmt + .query_map(params_from_iter(list_binds.iter()), |r| { + let time: String = r.get(10)?; + let tokens: f64 = r.get(4)?; + let cached: f64 = r.get(5)?; + let output: f64 = r.get(6)?; + let input = tokens - cached - output; + Ok(serde_json::json!({ + "id": r.get::<_, i64>(0)?, + "counterpart": r.get::<_, String>(1)?, + "key_id": r.get::<_, Option>(2)?, + "model": r.get::<_, String>(3)?, + "tokens": tokens, + "input_tokens": input, + "cached_tokens": cached, + "output_tokens": output, + "pts": r.get::<_, f64>(7)?, + "type": r.get::<_, String>(8)?, + "status": r.get::<_, String>(9)?, + "time": crate::dao::utc_iso(&time), + "key_label": r.get::<_, Option>(11)?, + })) + }) + .map_err(internal)? + .collect::>>() + .map_err(internal)?; Ok(Json(serde_json::json!({ "items": rows, "total": total, @@ -490,6 +486,99 @@ mod tests { ); } + #[tokio::test] + async fn transactions_time_range_filter() { + // rant 2026-08-22T10:50:00:start/end 时间段过滤(列表 + summary 联动) + let st = test_state("txrange"); + let key = login(st.clone()).await; + { + let conn = st.db.lock().unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, model, tokens, pts, type, status, time) \ + VALUES (1, 'old', 'm', 0, 5.0, 'consume', '成功', datetime('now', '-3 days'))", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, model, tokens, pts, type, status) \ + VALUES (1, 'new', 'm', 0, 2.0, 'consume', '成功')", + [], + ) + .unwrap(); + } + let now = chrono::Utc::now(); + // 注意:query string 中 "+" 会被解码为空格,故测试用 Z 结尾格式(前端 encodeURIComponent 无此问题) + let z = |d: chrono::Duration| (now - d).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let start_2d = z(chrono::Duration::hours(48)); + let end_1d = z(chrono::Duration::hours(24)); + let has_pts = |items: &serde_json::Value, pts: f64| { + items + .as_array() + .unwrap() + .iter() + .any(|it| (it["pts"].as_f64().unwrap_or(0.0) - pts).abs() < 1e-9) + }; + let sum_of = |v: &serde_json::Value| -> f64 { + v["items"] + .as_array() + .unwrap() + .iter() + .map(|it| it["pts"].as_f64().unwrap()) + .sum() + }; + // start = 2 天前 → 含今天的记录(2.0),不含 3 天前(5.0) + let (s, body) = get( + st.clone(), + &format!("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/api/transactions?type=consume&start={start_2d}"), + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!(has_pts(&v["items"], 2.0), "start 过滤应含今天记录: {body}"); + assert!( + !has_pts(&v["items"], 5.0), + "start 过滤应排除 3 天前: {body}" + ); + // summary 与列表同区间联动(start 过滤后 expense_pts = 区间内列表 pts 之和) + assert!( + (v["summary"]["expense_pts"].as_f64().unwrap() - sum_of(&v)).abs() < 1e-9, + "summary 应随 start 时间段联动: {body}" + ); + // end = 1 天前 → 含 3 天前(5.0),不含今天(2.0) + let (s, body) = get( + st.clone(), + &format!("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/api/transactions?type=consume&end={end_1d}"), + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!(has_pts(&v["items"], 5.0), "end 过滤应含 3 天前: {body}"); + assert!(!has_pts(&v["items"], 2.0), "end 过滤应排除今天: {body}"); + assert!( + (v["summary"]["expense_pts"].as_f64().unwrap() - sum_of(&v)).abs() < 1e-9, + "summary 应随 end 时间段联动: {body}" + ); + // start+end 窄区间(now-49h ~ now-47h):显式插入的两条都不在区间 + let (s, body) = get( + st.clone(), + &format!( + "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/api/transactions?type=consume&start={}&end={}", + z(chrono::Duration::hours(49)), + z(chrono::Duration::hours(47)) + ), + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!( + !has_pts(&v["items"], 5.0) && !has_pts(&v["items"], 2.0), + "窄区间不应含显式插入的两条: {body}" + ); + } + #[tokio::test] async fn transactions_key_label() { // rant 2026-08-22T06:36:54/06:37:50:/api/transactions 每行返回 key_label diff --git a/ui/index.html b/ui/index.html index 674c7d0..07953b8 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,7 +5,7 @@ AITokenPool - + @@ -337,6 +337,15 @@

交易记录

+ + +
@@ -673,9 +682,9 @@

使用模型

- - - - + + + + diff --git a/ui/js/app.js b/ui/js/app.js index 055e11d..623429d 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -14,6 +14,9 @@ let activeView = "dashboard"; let txTab = "all"; + let txRange = "24h"; // 交易时间段快捷范围:24h / 7d / 30d / all / custom(默认最近 24 小时,rant 2026-08-22T10:50:00) + let txCustomStart = ""; // 自定义开始(datetime-local 值,本地时区) + let txCustomEnd = ""; // 自定义结束 let isGuest = false; // 游客模式(US-1:未登录可浏览市场) let pendingHashView = null; // URL hash 路由(rant 20:39:30 A):刷新后登录时恢复上次视图 let mkExpanded = null; // 市场行展开(rant 20:39:30 F):当前展开的模型 id,null=全部收起;仅展开当前行 @@ -1246,12 +1249,32 @@ }); } - // P2-B:按 tab 拉取交易(后端分页) + // 交易时间段 → start/end 查询参数(UTC ISO,后端 datetime() 解析;默认最近 24 小时) + function txRangeParams() { + const now = new Date(); + const MS = (h) => h * 3600 * 1000; + let start = null, end = null; + if (txRange === "24h") start = new Date(now - MS(24)); + else if (txRange === "7d") start = new Date(now - MS(24 * 7)); + else if (txRange === "30d") start = new Date(now - MS(24 * 30)); + else if (txRange === "custom") { + if (txCustomStart) start = new Date(txCustomStart); + if (txCustomEnd) end = new Date(txCustomEnd); + } + const p = []; + if (start && !isNaN(start.getTime())) p.push("start=" + encodeURIComponent(start.toISOString())); + if (end && !isNaN(end.getTime())) p.push("end=" + encodeURIComponent(end.toISOString())); + return p.join("&"); + } + + // P2-B:按 tab 拉取交易(后端分页;带时间段过滤) async function loadTransactions() { if (!loggedIn()) return; const type = txTab === "all" ? "" : txTab; + const range = txRangeParams(); + const q = "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/api/transactions?type=" + type + "&page=1&page_size=100" + (range ? "&" + range : ""); try { - await liveLoad("transactions", "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/api/transactions?type=" + type + "&page=1&page_size=100"); + await liveLoad("transactions", q); } catch (e) { Live.transactions = null; /* 登录态降级空态 */ } renderTransactions(); } @@ -2945,6 +2968,28 @@ $$("#tx-tabs .tab").forEach((b) => b.addEventListener("click", () => { txTab = b.dataset.txTab; txTable.page = 1; renderTransactions(); if (loggedIn()) loadTransactions(); })); $("#tx-export-btn").addEventListener("click", exportTxCsv); // 导出 CSV(rant 20:46:57 E) + // 交易时间段(rant 2026-08-22T10:50:00:快捷范围 + 自定义起止,切换后重载列表与汇总) + const txRangeEl = $("#tx-range"); + const txStartEl = $("#tx-range-start"); + const txEndEl = $("#tx-range-end"); + if (txRangeEl && txStartEl && txEndEl) { + const showCustom = () => { + const custom = txRangeEl.value === "custom"; + txStartEl.style.display = custom ? "" : "none"; + txEndEl.style.display = custom ? "" : "none"; + }; + txRangeEl.addEventListener("change", () => { + txRange = txRangeEl.value; + showCustom(); + txTable.page = 1; + renderTransactions(); + if (loggedIn()) loadTransactions(); + }); + txStartEl.addEventListener("change", () => { txCustomStart = txStartEl.value; txTable.page = 1; if (loggedIn()) loadTransactions(); }); + txEndEl.addEventListener("change", () => { txCustomEnd = txEndEl.value; txTable.page = 1; if (loggedIn()) loadTransactions(); }); + showCustom(); + } + // 表格键盘导航(rant 20:46:57 F):点击行 → 激活高亮,之后 ↑/↓/Enter/Esc 可用 KBD_TABLE_IDS.forEach((id) => { const c = document.getElementById(id); diff --git a/ui/js/i18n.js b/ui/js/i18n.js index 24f3f38..b4814f8 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -303,6 +303,14 @@ "tx.tab.all": "全部", "tx.tab.consume": "消费", "tx.tab.earn": "收益", + "tx.range.title": "时间范围", + "tx.range.24h": "最近 24 小时", + "tx.range.7d": "最近 7 天", + "tx.range.30d": "最近 30 天", + "tx.range.all": "全部时间", + "tx.range.custom": "自定义", + "tx.range.start": "开始时间", + "tx.range.end": "结束时间", "tx.export": "导出 CSV", "tx.export.title": "导出当前筛选的交易记录为 CSV", "tx.summary.income": "总收入", @@ -1000,6 +1008,14 @@ "tx.tab.all": "All", "tx.tab.consume": "Consume", "tx.tab.earn": "Earn", + "tx.range.title": "Time range", + "tx.range.24h": "Last 24 hours", + "tx.range.7d": "Last 7 days", + "tx.range.30d": "Last 30 days", + "tx.range.all": "All time", + "tx.range.custom": "Custom", + "tx.range.start": "Start time", + "tx.range.end": "End time", "tx.export": "Export CSV", "tx.export.title": "Export visible transactions as CSV", "tx.summary.income": "Income",