diff --git a/CHANGELOG.md b/CHANGELOG.md index ccea000..854a622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes are recorded here. Versions follow [SemVer](https://semver.org/). +## v0.7.12 (2026-08-23) + +- **交易页 UI 改进(rant 2026-08-23T16:01:07)** — + - 移除交易列表内 time 列的内部筛选(外部时间段筛选已覆盖,两套并存冗余且易混淆) + - 列表上方新增**趋势图**(手写 SVG 折线图,无外部依赖):新增 `GET /api/transactions/trend`,按时间桶(hour/day/week)聚合收入/支出点数,口径与 summary 一致;趋势图跟随 tab + 时间段筛选联动 + - **修复统计指标未联动表格内部筛选**:内部筛选(类型/模型/用户/Key 等)变化后,汇总条基于筛选后可见行本地加总(含 Token 总/输入/缓存/输出四指标),无筛选时仍用后端全量 SQL 聚合 + - **修复筛选输入框逐字符刷新**:`.th-filter` 的 input 事件由立即重建改为 300ms 防抖,刷新后恢复焦点并置光标到末尾(此前输入 "sh" 会因中间刷新变成 "hs") + ## v0.7.11 (2026-08-23) - **P0 cache-billing fix (passthrough path)** — PR #128 (v0.7.10) only patched the cross-protocol SSE conversion path; the same-protocol passthrough path (`UsageCapture::finish()` in `src/gateway.rs`, used when client and upstream speak the same protocol, e.g. openai→openai) still passed the **full** `prompt_tokens` as input (including cache-hit tokens) and only recognized OpenAI's `prompt_tokens_details.cached_tokens` spelling — so DeepSeek responses double-counted cached tokens into the total (~2× tokens, per-call uncached showed 241,776 instead of 112). `finish()` now extracts cached via all three spellings (DeepSeek native `prompt_cache_hit_tokens` → OpenAI `prompt_tokens_details.cached_tokens` → Anthropic `cache_read_input_tokens`) and returns `(input − cached).max(0.0)` disjoint, matching the `sse.rs` logic; tests extended with cached cases for all three spellings (rant 2026-08-23T14:05:02, PR #130) diff --git a/Cargo.lock b/Cargo.lock index 7b6c3db..b28c2d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,7 +51,7 @@ dependencies = [ [[package]] name = "aitokenpool" -version = "0.7.11" +version = "0.7.12" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index a79cd7a..1656f5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aitokenpool" -version = "0.7.11" +version = "0.7.12" edition = "2021" description = "AI Token 共享池 — 企业 key 池 + 公共共享市场" license = "MIT" diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 2304309..e44586e 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -517,6 +517,7 @@ pub fn router() -> Router { .route("/api/sharings/:id", axum::routing::patch(sharing::patch)) .route("/api/wallet", get(wallet::wallet)) .route("/api/transactions", get(wallet::transactions)) + .route("/api/transactions/trend", get(wallet::transactions_trend)) .route("/api/dashboard", get(wallet::dashboard)) // P1:管理员(充值 / 成员列表 / 用量报表) .route("/api/admin/credits", post(admin::credits)) diff --git a/src/routes/wallet.rs b/src/routes/wallet.rs index 6f95d8f..9feab17 100644 --- a/src/routes/wallet.rs +++ b/src/routes/wallet.rs @@ -244,6 +244,118 @@ pub async fn transactions( }))) } +/// GET /api/transactions/trend 查询参数 +#[derive(Debug, Deserialize)] +pub struct TxTrendQuery { + /// consume / earn / topup / gift / all(缺省 all) + #[serde(default)] + pub r#type: String, + /// 起始时间(同 /api/transactions,ISO 8601 UTC) + pub start: Option, + /// 结束时间(同 /api/transactions,ISO 8601 UTC) + pub end: Option, + /// 聚合粒度:hour / day / week(缺省 day;非法值回退 day) + #[serde(default)] + pub bucket: String, +} + +/// GET /api/transactions/trend(rant 2026-08-23T16:01:07:交易页趋势图数据源) +/// 按时间桶聚合,口径与 summary 一致(income 白名单 earn/topup/gift 为正、consume 为负; +/// token 字段:tokens 总 / input = tokens − cached − output / cached / output)。 +/// 返回仅含非空桶,按时间升序;前端负责连续时间轴补齐。 +pub async fn transactions_trend( + State(st): State, + auth: AuthUser, + Query(q): Query, +) -> Result, ApiErr> { + let type_filter = match q.r#type.as_str() { + "" | "all" => None, + t @ ("consume" | "earn" | "topup" | "gift") => Some(t.to_string()), + _ => { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + Json( + serde_json::json!({ "error": "type 必须为 consume / earn / topup / gift / all" }), + ), + )) + } + }; + let bucket = match q.bucket.as_str() { + "hour" | "day" | "week" => q.bucket.clone(), + _ => "day".to_string(), + }; + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + 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)?; + let (where_sql, where_binds) = tx_where("t", auth.user_id, &type_filter, &start, &end); + // 桶表达式:day/week 产出 "YYYY-MM-DD",hour 产出 "YYYY-MM-DD HH:00" + let expr = match bucket.as_str() { + "hour" => "%Y-%m-%d %H:00", + "week" => "%Y-%m-%d", // 配 modifier:周一起始 + _ => "%Y-%m-%d", + }; + let mods = if bucket == "week" { + ", 'weekday 1', '-7 days'" + } else { + "" + }; + let trend_sql = format!( + "SELECT strftime('{expr}', t.time{mods}) AS b, \ + COALESCE(SUM(CASE WHEN t.type IN ('earn','topup','gift') THEN t.pts ELSE 0 END), 0), \ + COALESCE(SUM(CASE WHEN t.type = 'consume' THEN t.pts ELSE 0 END), 0), \ + COALESCE(SUM(CASE WHEN t.type IN ('earn','topup','gift') THEN t.pts ELSE -t.pts END), 0), \ + COALESCE(SUM(t.tokens), 0), \ + COALESCE(SUM(t.tokens - t.cached_tokens - t.output_tokens), 0), \ + COALESCE(SUM(t.cached_tokens), 0), \ + COALESCE(SUM(t.output_tokens), 0), \ + COUNT(*) \ + FROM transactions t WHERE {where_sql} GROUP BY b ORDER BY b" + ); + let mut stmt = conn.prepare(&trend_sql).map_err(internal)?; + let buckets: Vec = stmt + .query_map(params_from_iter(where_binds.iter()), |r| { + let b: String = r.get(0)?; + // 桶起点 → UTC ISO(hour 桶带小时,day/week 桶为当日 00:00) + let iso = if bucket == "hour" { + format!("{}:00Z", b.replace(' ', "T")) + } else { + format!("{b}T00:00:00Z") + }; + Ok(serde_json::json!({ + "t": iso, + "income": r.get::<_, f64>(1)?, + "expense": r.get::<_, f64>(2)?, + "net": r.get::<_, f64>(3)?, + "tokens": r.get::<_, f64>(4)?, + "input_tokens": r.get::<_, f64>(5)?, + "cached_tokens": r.get::<_, f64>(6)?, + "output_tokens": r.get::<_, f64>(7)?, + "count": r.get::<_, i64>(8)?, + })) + }) + .map_err(internal)? + .collect::>>() + .map_err(internal)?; + Ok(Json(serde_json::json!({ + "bucket": bucket, + "buckets": buckets, + }))) +} + /// GET /api/dashboard:本月按类型聚合 + 净变化 + 近 7 天净额序列 pub async fn dashboard( State(st): State, @@ -587,6 +699,147 @@ mod tests { ); } + #[tokio::test] + async fn transactions_trend_buckets() { + // rant 2026-08-23T16:01:07:/api/transactions/trend 按时间桶聚合趋势 + // (口径与 summary 一致:income 白名单 earn/topup/gift 为正、consume 为负;token 四字段) + let st = test_state("trend"); + let key = login(st.clone()).await; + { + let conn = st.db.lock().unwrap(); + // 今天:consume 1.0(token: 总100 / 缓存80 / 输出10 → 输入10)+ earn 2.0 + conn.execute( + "INSERT INTO transactions (user_id, counterpart, model, tokens, cached_tokens, output_tokens, pts, type, status) \ + VALUES (1, 'a', 'm', 100, 80, 10, 1.0, 'consume', '成功')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, model, tokens, pts, type, status) \ + VALUES (1, 'b', 'm', 0, 2.0, 'earn', '成功')", + [], + ) + .unwrap(); + // 2 天前:consume 5.0 + conn.execute( + "INSERT INTO transactions (user_id, counterpart, model, tokens, pts, type, status, time) \ + VALUES (1, 'old', 'm', 0, 5.0, 'consume', '成功', datetime('now', '-2 days'))", + [], + ) + .unwrap(); + } + let now = chrono::Utc::now(); + let z = |d: chrono::Duration| (now - d).to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + // day bucket + 3 天窗口 → 2 个非空桶(2 天前 + 今天),时间升序 + let (s, body) = get( + st.clone(), + &format!( + "/api/transactions/trend?type=all&bucket=day&start={}", + z(chrono::Duration::days(3)) + ), + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["bucket"], "day", "body: {body}"); + let buckets = v["buckets"].as_array().unwrap(); + assert_eq!(buckets.len(), 2, "应有 2 个非空桶: {body}"); + let old = &buckets[0]; + assert!( + (old["expense"].as_f64().unwrap() - 5.0).abs() < 1e-9, + "旧桶 expense=5: {body}" + ); + assert_eq!(old["count"].as_i64().unwrap(), 1, "旧桶 count: {body}"); + let today = &buckets[1]; + assert!( + (today["income"].as_f64().unwrap() - 2.0).abs() < 1e-9, + "今天桶 income=2: {body}" + ); + assert!( + (today["expense"].as_f64().unwrap() - 1.0).abs() < 1e-9, + "今天桶 expense=1: {body}" + ); + assert!( + (today["net"].as_f64().unwrap() - 1.0).abs() < 1e-9, + "今天桶 net=1: {body}" + ); + assert!( + (today["tokens"].as_f64().unwrap() - 100.0).abs() < 1e-9, + "今天桶 tokens=100: {body}" + ); + assert!( + (today["input_tokens"].as_f64().unwrap() - 10.0).abs() < 1e-9, + "今天桶 input=10: {body}" + ); + assert!( + (today["cached_tokens"].as_f64().unwrap() - 80.0).abs() < 1e-9, + "今天桶 cached=80: {body}" + ); + assert!( + (today["output_tokens"].as_f64().unwrap() - 10.0).abs() < 1e-9, + "今天桶 output=10: {body}" + ); + assert_eq!(today["count"].as_i64().unwrap(), 2, "今天桶 count: {body}"); + assert!( + today["t"].as_str().unwrap().ends_with("T00:00:00Z"), + "day 桶时间应为当日 00:00Z: {body}" + ); + // hour bucket:今天两条 + 2 天前一条 → 2 个桶(跨天),t 带小时 + let (s, body) = get( + st.clone(), + &format!( + "/api/transactions/trend?type=all&bucket=hour&start={}", + z(chrono::Duration::days(3)) + ), + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let buckets = v["buckets"].as_array().unwrap(); + assert_eq!(buckets.len(), 2, "hour 桶应 2 个: {body}"); + assert!( + buckets + .iter() + .all(|b| chrono::DateTime::parse_from_rfc3339(b["t"].as_str().unwrap()).is_ok()), + "hour 桶 t 应为合法 RFC3339: {body}" + ); + // type=consume 筛选 → 只聚合 consume + let (s, body) = get( + st.clone(), + &format!( + "/api/transactions/trend?type=consume&bucket=day&start={}", + z(chrono::Duration::days(3)) + ), + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let buckets = v["buckets"].as_array().unwrap(); + assert_eq!(buckets.len(), 2, "consume 桶应 2 个: {body}"); + assert!( + buckets + .iter() + .all(|b| (b["income"].as_f64().unwrap()).abs() < 1e-9), + "consume 筛选下 income 应全 0: {body}" + ); + // 非法 bucket → 回退 day 不报错 + let (s, body) = get( + st.clone(), + &format!( + "/api/transactions/trend?type=all&bucket=month&start={}", + z(chrono::Duration::days(3)) + ), + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["bucket"], "day", "非法 bucket 应回退 day: {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/css/style.css b/ui/css/style.css index 07faaf6..2722931 100644 --- a/ui/css/style.css +++ b/ui/css/style.css @@ -326,6 +326,22 @@ select.input option { background: var(--bg-card); } .tx-summary .ts-value.ok { color: var(--ok); } .tx-summary .ts-value.danger { color: var(--danger-text); } +/* 交易趋势图(rant 2026-08-23T16:01:07 需求 2):SVG 折线图,无外部依赖 */ +.tx-trend { padding: 10px 16px 8px; border-bottom: 1px solid var(--border); } +.tx-trend-title { color: var(--text-mute); font-size: 12px; margin-bottom: 2px; } +.tx-trend svg { width: 100%; height: auto; display: block; } +.tx-trend-grid { stroke: var(--border); stroke-width: 1; } +.tx-trend-axis { fill: var(--text-mute); font-size: 10px; } +.tx-trend svg .tx-trend-inc { fill: none; stroke: var(--ok); stroke-width: 2; } +.tx-trend svg .tx-trend-exp { fill: none; stroke: var(--danger-text); stroke-width: 2; } +.tx-trend svg circle.tx-trend-inc { fill: var(--ok); stroke: none; } +.tx-trend svg circle.tx-trend-exp { fill: var(--danger-text); stroke: none; } +.tx-trend-legend { display: flex; gap: 18px; font-size: 12px; color: var(--text-mute); padding-top: 2px; } +.tx-trend-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; } +.tx-trend-dot.inc { background: var(--ok); } +.tx-trend-dot.exp { background: var(--danger-text); } +.tx-trend-empty { color: var(--text-mute); font-size: 13px; padding: 14px 16px; border-bottom: 1px solid var(--border); } + .table-wrap { overflow-x: auto; } .table-wrap.compact { margin-top: 4px; } .table { width: 100%; border-collapse: collapse; font-size: 13px; } diff --git a/ui/index.html b/ui/index.html index 04c829c..07c9410 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,7 +5,7 @@ AITokenPool - + @@ -350,6 +350,7 @@

交易记录

+
@@ -682,9 +683,9 @@

使用模型

- - - - + + + + diff --git a/ui/js/app.js b/ui/js/app.js index 043d29f..04056e3 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -1169,7 +1169,8 @@ const txStatus = (s) => s === "成功" ? T("tx.status.success") : s === "处理中" ? T("tx.status.pending") : s === "入账" ? T("tx.status.credited") : s; const TX_COLUMNS = [ - { key: "time", title: () => T("tx.col.time"), sort: "string", filter: "text", render: (t) => timeCell(t.time) }, + // rant 2026-08-23T16:01:07:列表内移除 time 列筛选(外部 tx-range 已有时间段筛选,两套并存冗余) + { key: "time", title: () => T("tx.col.time"), sort: "string", render: (t) => timeCell(t.time) }, { key: "type", title: () => T("tx.col.type"), sort: "string", filter: "select", options: () => ["consume", "earn", "topup", "withdraw", "gift"].map(txType), filterVal: (t) => txType(t.type), @@ -1200,15 +1201,26 @@ // 附带 Token 统计 总/输入/缓存/输出,M 单位) function renderTxSummary(list) { const s = (Live.transactions && Live.transactions.summary) ? Live.transactions.summary : null; - let income = 0, expense = 0; - if (s) { + // rant 2026-08-23T16:01:07 Bug 1:统计指标与表格内部筛选联动—— + // 内部筛选非空时基于筛选后的行本地加总(含 token 口径一致);无内部筛选才用后端 summary(全量 SQL 聚合)。 + const hasFilter = Object.keys(txTable.filters).some((k) => txTable.filters[k] !== ""); + let income = 0, expense = 0, tokens = 0, inputT = 0, cachedT = 0, outputT = 0; + if (s && !hasFilter) { income = s.income_pts || 0; expense = s.expense_pts || 0; + tokens = s.tokens || 0; + inputT = s.input_tokens || 0; + cachedT = s.cached_tokens || 0; + outputT = s.output_tokens || 0; } else { - // 兜底(无 summary 的旧数据/游客 mock):按 type 而非 pts 符号(rant 00:04:21 Bug A) + // 兜底(无 summary 的旧数据/游客 mock / 内部筛选后):按 type 而非 pts 符号(rant 00:04:21 Bug A) list.forEach((t) => { if (t.type === "consume") expense += Math.abs(t.pts); else income += Math.abs(t.pts); + if (typeof t.tokensRaw === "number") tokens += t.tokensRaw; + if (typeof t.inputRaw === "number") inputT += t.inputRaw; + if (typeof t.cachedRaw === "number") cachedT += t.cachedRaw; + if (typeof t.outputRaw === "number") outputT += t.outputRaw; }); } const net = income - expense; @@ -1219,30 +1231,116 @@ '
' + T("tx.summary.income") + "" + fmt(income) + "
" + '
' + T("tx.summary.expense") + "" + fmt(-expense) + "
" + '
' + T("tx.summary.net") + "" + fmt(net) + "
"; - // Token 统计(仅后端 summary 提供时显示):总 / 输入 / 缓存 / 输出 - if (s) { - const t = (k) => fmtM(s[k] || 0); - html += - '
' + T("tx.summary.tokens") + "" + t("tokens") + "
" + - '
' + T("tx.summary.input") + "" + t("input_tokens") + "
" + - '
' + T("tx.summary.cached") + "" + t("cached_tokens") + "
" + - '
' + T("tx.summary.output") + "" + t("output_tokens") + "
"; - } + // Token 统计:总 / 输入 / 缓存 / 输出(后端 summary 或本地筛选后加总均可提供) + html += + '
' + T("tx.summary.tokens") + "" + fmtM(tokens) + "
" + + '
' + T("tx.summary.input") + "" + fmtM(inputT) + "
" + + '
' + T("tx.summary.cached") + "" + fmtM(cachedT) + "
" + + '
' + T("tx.summary.output") + "" + fmtM(outputT) + "
"; $("#tx-summary").innerHTML = html; } + // 交易趋势图(rant 2026-08-23T16:01:07 需求 2):手写 SVG 折线图(无外部依赖), + // 数据源 = /api/transactions/trend(跟随 tab + 外部时间段筛选,与 summary 同口径); + // 横轴 = 时间桶(hour/day/week),纵轴 = 点数;收入(绿) / 支出(红) 两条线,点悬停显示精确值。 + function renderTxTrend() { + const el = $("#tx-trend"); + const tr = (Live.transactions && Live.transactions.trend) ? Live.transactions.trend : null; + const buckets = (tr && Array.isArray(tr.buckets)) ? tr.buckets : []; + if (!buckets.length) { + el.innerHTML = '
' + esc(T("tx.trend.empty")) + "
"; + return; + } + const bucket = tr.bucket || "day"; + const income = buckets.map((b) => b.income || 0); + const expense = buckets.map((b) => b.expense || 0); + const maxV = Math.max(1, ...income, ...expense); + const W = 640, H = 170, PL = 46, PR = 12, PT = 12, PB = 24; + const iw = W - PL - PR, ih = H - PT - PB; + const X = (i) => PL + (buckets.length === 1 ? iw / 2 : iw * i / (buckets.length - 1)); + const Y = (v) => PT + ih * (1 - v / maxV); + const lbl = (b) => bucketLabel(b.t, bucket); + const fmt = (n) => (n > 0 ? "+" : "") + D.fmt(n); + const fmtShort = (n) => (n >= 1e6 ? (n / 1e6).toFixed(2) + "M" : (n >= 1000 ? Math.round(n / 1000) + "K" : String(Math.round(n)))); + // 网格 + 纵轴刻度(4 档) + let grid = ""; + for (let g = 0; g <= 4; g++) { + const gy = PT + ih * g / 4; + grid += '' + + '' + fmtShort(maxV * (4 - g) / 4) + ""; + } + // 横轴标签:首 / 中 / 尾 + const idxs = [0, Math.floor((buckets.length - 1) / 2), buckets.length - 1].filter((v, i, a) => a.indexOf(v) === i); + let xlabels = ""; + idxs.forEach((i) => { + xlabels += '' + esc(lbl(buckets[i])) + ""; + }); + // 折线 + 数据点( 悬停显示精确值) + const lineHtml = (arr, cls, name) => { + let pts = "", dots = ""; + arr.forEach((v, i) => { + const xi = X(i).toFixed(1), yi = Y(v).toFixed(1); + pts += (i ? " " : "") + xi + "," + yi; + dots += '<circle class="' + cls + '" cx="' + xi + '" cy="' + yi + '" r="2.5"><title>' + + esc(lbl(buckets[i]) + " · " + name + " " + fmt(v)) + ""; + }); + return '' + dots; + }; + const html = + '
' + esc(T("tx.trend.title")) + "
" + + '' + + grid + xlabels + + lineHtml(income, "tx-trend-inc", T("tx.summary.income")) + + lineHtml(expense, "tx-trend-exp", T("tx.summary.expense")) + + "" + + '
' + esc(T("tx.summary.income")) + "" + + '' + esc(T("tx.summary.expense")) + "
"; + el.innerHTML = html; + } + + // 趋势桶标签:hour → "MM-DD HH:00";day/week → "MM-DD"(bucket 起点均为 UTC,转本地显示) + function bucketLabel(iso, bucket) { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + const p = (n) => String(n).padStart(2, "0"); + const md = p(d.getMonth() + 1) + "-" + p(d.getDate()); + return bucket === "hour" ? md + " " + p(d.getHours()) + ":00" : md; + } + + // 趋势聚合粒度:跟随外部时间段筛选(24h→小时;≤3.5 天→小时;≤60 天→天;其余→周) + function txTrendBucket() { + if (txRange === "24h") return "hour"; + if (txRange === "7d") return "day"; + if (txRange === "30d") return "day"; + if (txRange === "custom") { + const s = txCustomStart ? new Date(txCustomStart) : null; + const e = txCustomEnd ? new Date(txCustomEnd) : null; + const now = new Date(); + const from = (s && !isNaN(s.getTime())) ? s : ((e && !isNaN(e.getTime())) ? new Date(e.getTime() - 30 * 86400000) : now); + const to = (e && !isNaN(e.getTime())) ? e : now; + const days = (to.getTime() - from.getTime()) / 86400000; + if (days <= 3.5) return "hour"; + if (days <= 60) return "day"; + return "week"; + } + return "week"; // all:跨度过大按周聚合 + } + function renderTransactions() { $$("#tx-tabs .tab").forEach((b) => b.classList.toggle("active", b.dataset.txTab === txTab)); // 零 mock(rant 2026-08-19T15:54:06):登录态绝不 fallback D.TRANSACTIONS; // 加载失败 → 空态 + 重试;游客不可达(导航拦截) if (loggedIn() && !Live.transactions) { renderTxSummary([]); + renderTxTrend(); $("#tx-table").innerHTML = loadErrorHtml(T("tx.loadFail"), null, T("err.loadFail")); return; } let list = Live.transactions ? txsToView(Live.transactions.items || []) : []; // 交易汇总条:与 tab + 列筛选联动,与表格可见行一致(rant 20:39:30 B) renderTxSummary(filterRows(list, TX_COLUMNS, txTable.filters)); + // 趋势图:跟随 tab + 外部时间段(rant 2026-08-23T16:01:07 需求 2) + renderTxTrend(); buildDataTable({ container: $("#tx-table"), columns: TX_COLUMNS, @@ -1279,6 +1377,12 @@ try { await liveLoad("transactions", q); } catch (e) { Live.transactions = null; /* 登录态降级空态 */ } + // 趋势图数据(rant 2026-08-23T16:01:07 需求 2):独立拉取,失败不阻塞列表 + const bucket = txTrendBucket(); + try { + const trend = await api.get("/api/transactions/trend?type=" + type + "&bucket=" + bucket + (range ? "&" + range : "")); + if (Live.transactions) Live.transactions.trend = trend; + } catch (e) { if (Live.transactions) Live.transactions.trend = null; } renderTransactions(); } @@ -1489,10 +1593,18 @@ state.filters[key] = el.value; } state.page = 1; + // rant 2026-08-23T16:01:07 Bug 2:逐字符 input 立即 onState() 重建表格会打断输入 + // (如想输入 "sh" 变成 "hs")。改为 300ms 防抖,刷新后恢复焦点并置光标到末尾。 + if (el._dbTimer) clearTimeout(el._dbTimer); const focusSel = range ? '[data-filter-key="' + key + '"][data-range="' + range + '"]' : '[data-filter-key="' + key + '"]'; - onState(); - const n = container.querySelector(focusSel); - if (n) n.focus(); + el._dbTimer = setTimeout(() => { + onState(); + const n = container.querySelector(focusSel); + if (n) { + n.focus(); + try { n.setSelectionRange(n.value.length, n.value.length); } catch (e) { /* 非 text 元素忽略 */ } + } + }, 300); }); }); container.querySelectorAll("[data-p]").forEach((b) => { diff --git a/ui/js/i18n.js b/ui/js/i18n.js index 1c8b96b..3ada100 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -320,6 +320,8 @@ "tx.summary.input": "输入", "tx.summary.cached": "缓存", "tx.summary.output": "输出", + "tx.trend.title": "点数趋势", + "tx.trend.empty": "当前时间段暂无趋势数据", "tx.col.time": "时间", "tx.col.type": "类型", "tx.col.user": "用户", @@ -1028,6 +1030,8 @@ "tx.summary.input": "Input", "tx.summary.cached": "Cached", "tx.summary.output": "Output", + "tx.trend.title": "Points Trend", + "tx.trend.empty": "No trend data in this range", "tx.col.time": "Time", "tx.col.type": "Type", "tx.col.user": "User",