From 2bb5374ac7de20cd011619920cbe68e8eee2e1d1 Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 22 Aug 2026 06:55:49 +0800 Subject: [PATCH] feat(wallet): transaction table model/key columns + token four-column breakdown (rants 2026-08-22T06:36:54/06:37:50/06:39:04) --- src/routes/wallet.rs | 90 +++++++++++++++++++++++++++++++++++++++++--- ui/index.html | 10 ++--- ui/js/app.js | 47 ++++++++++++----------- ui/js/i18n.js | 16 ++++++-- 4 files changed, 127 insertions(+), 36 deletions(-) diff --git a/src/routes/wallet.rs b/src/routes/wallet.rs index 59d8467..d65ecb3 100644 --- a/src/routes/wallet.rs +++ b/src/routes/wallet.rs @@ -149,19 +149,31 @@ pub async fn transactions( .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 id, counterpart, key_id, model, tokens, cached_tokens, output_tokens, pts, type, status, time \ - FROM transactions WHERE user_id = ?1 AND type = ?2 \ - ORDER BY id DESC LIMIT ?3 OFFSET ?4", + "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 id, counterpart, key_id, model, tokens, cached_tokens, output_tokens, pts, type, status, time \ - FROM transactions WHERE user_id = ?1 \ - ORDER BY id DESC LIMIT ?2 OFFSET ?3", + "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)?, }; @@ -186,6 +198,7 @@ pub async fn transactions( "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)? @@ -211,6 +224,7 @@ pub async fn transactions( "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)? @@ -476,6 +490,70 @@ mod tests { ); } + #[tokio::test] + async fn transactions_key_label() { + // rant 2026-08-22T06:36:54/06:37:50:/api/transactions 每行返回 key_label + // (note 非空 → note;否则 provider / plan;key 已删/无 key → null) + let st = test_state("keylabel"); + let key = login(st.clone()).await; + { + let conn = st.db.lock().unwrap(); + // 种子 key(id=1,note 空):key_label = provider / plan + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, cached_tokens, output_tokens, pts, type, status) \ + VALUES (1, '2', 1, 'deepseek-v4-flash', 1000, 200.0, 100.0, 0.5, 'consume', '成功')", + [], + ) + .unwrap(); + // note 非空的 key → key_label = note + conn.execute( + "INSERT INTO keys (provider, plan, model, status, owner_id, encrypted_key, quota, note) \ + VALUES ('openai', 'gpt-paygo', 'gpt-5.3', 'on', 2, 'enc2', 1000, '工作日共享')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, cached_tokens, output_tokens, pts, type, status) \ + VALUES (1, '2', 2, 'gpt-5.3', 500, 0, 0, 0.3, 'consume', '成功')", + [], + ) + .unwrap(); + // 无 key(topup)→ key_label = null + conn.execute( + "INSERT INTO transactions (user_id, counterpart, key_id, model, tokens, pts, type, status) \ + VALUES (1, 'admin', NULL, '', 0, 2000.0, 'topup', '成功')", + [], + ) + .unwrap(); + } + let (s, body) = get( + st.clone(), + "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/api/transactions?type=all&page=1&page_size=10", + &key, + ) + .await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let items = v["items"].as_array().unwrap(); + let find = |ty: &str, model: &str| { + items + .iter() + .find(|i| i["type"] == ty && i["model"] == model) + .expect("row found") + .clone() + }; + let seeded = find("consume", "deepseek-v4-flash"); + assert_eq!(seeded["key_label"], "deepseek / deepseek-paygo"); + assert_eq!(seeded["model"], "deepseek-v4-flash"); + let noted = find("consume", "gpt-5.3"); + assert_eq!(noted["key_label"], "工作日共享"); + let topup = find("topup", ""); + assert!( + topup["key_label"].is_null(), + "无 key 交易 key_label 为 null: {topup}" + ); + } + #[tokio::test] async fn transactions_filter_and_pagination() { let st = test_state("tx"); diff --git a/ui/index.html b/ui/index.html index c3802af..2765b26 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,7 +5,7 @@ AITokenPool - + @@ -673,9 +673,9 @@

使用模型

- - - - + + + + diff --git a/ui/js/app.js b/ui/js/app.js index fbe0b6c..f5c261a 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -1171,11 +1171,16 @@ options: () => ["consume", "earn", "topup", "withdraw", "gift"].map(txType), filterVal: (t) => txType(t.type), render: (t) => t.type === "earn" ? '' + T("tx.type.earn") + "" : t.type === "consume" ? '' + T("tx.type.consume") + "" : t.type === "gift" ? '' + T("tx.type.gift") + "" : '' + esc(txType(t.type)) + "" }, - { key: "partner", title: () => T("tx.col.partner"), sort: "string", filter: "text" }, - { key: "tokens", title: () => T("tx.col.tokens"), sort: "string", filter: "text", align: "num", - render: (t) => t.tokenDetail - ? '
' + t.tokens + '
' + t.tokenDetail + "
" - : t.tokens }, + { key: "model", title: () => T("tx.col.model"), sort: "string", filter: "text" }, + { key: "key", title: () => T("tx.col.key"), sort: "string", filter: "text" }, + { key: "input", title: () => T("tx.col.input"), sort: "number", filter: "number-range", align: "num", + render: (t) => t.inputTokens }, + { key: "cached", title: () => T("tx.col.cached"), sort: "number", filter: "number-range", align: "num", + render: (t) => t.cachedTokens }, + { key: "output", title: () => T("tx.col.output"), sort: "number", filter: "number-range", align: "num", + render: (t) => t.outputTokens }, + { key: "tokens", title: () => T("tx.col.tokens"), sort: "number", filter: "number-range", align: "num", + render: (t) => t.tokens }, { key: "pts", title: () => T("tx.col.pts"), sort: "number", filter: "number-range", align: "num", render: (t) => '' + (t.pts > 0 ? "+" : "") + D.fmt(t.pts) + "" }, { key: "status", title: () => T("tx.col.status"), sort: "string", filter: "select", @@ -1259,8 +1264,8 @@ list = filterRows(list, TX_COLUMNS, txTable.filters); // 与表格可见行一致(含列筛选) if (!list.length) { toast(T("tx.export.none"), "info"); return; } const cell = (v) => { const s = String(v == null ? "" : v); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; - const headers = [T("tx.col.time"), T("tx.col.type"), T("tx.col.partner"), T("tx.col.tokens"), T("tx.col.pts"), T("tx.col.status")]; - const lines = list.map((t) => [t.time, txType(t.type), t.partner, t.tokens, t.pts, txStatus(t.status)].map(cell).join(",")); + const headers = [T("tx.col.time"), T("tx.col.type"), T("tx.col.model"), T("tx.col.key"), T("tx.col.input"), T("tx.col.cached"), T("tx.col.output"), T("tx.col.tokens"), T("tx.col.pts"), T("tx.col.status")]; + const lines = list.map((t) => [t.time, txType(t.type), t.model, t.key, t.inputTokens, t.cachedTokens, t.outputTokens, t.tokens, t.pts, txStatus(t.status)].map(cell).join(",")); const csv = "\uFEFF" + [headers.join(","), ...lines].join("\r\n"); // UTF-8 BOM,Excel 中文不乱码 const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); @@ -2466,8 +2471,7 @@ }); } - // 后端 transactions items → 视图行(partner=counterpart、tokens 格式化、detail 用模型) - // rant 2026-08-21T14:53:20:单次调用 token 明细(输入/缓存命中/输出)随行展示 + // 后端 transactions items → 视图行(模型 / Key 两列 + Token 四列平铺;rant 2026-08-22T06:36:54/06:37:50/06:39:04) const fmtTokens = (n) => (typeof n === "number" && n > 0 ? (n >= 1e6 ? (n / 1e6).toFixed(2) + "M" : String(Math.round(n))) : "0"); function txsToView(items) { return items.map((t) => { @@ -2475,24 +2479,25 @@ if (typeof t.tokens === "number" && t.tokens > 0) { tokens = t.tokens >= 1e6 ? (t.tokens / 1e6).toFixed(2) + "M" : String(Math.round(t.tokens)); } - // 明细仅当后端提供了拆分且确有输出/缓存(旧记录 0/0 不显示,保持整洁) - const input = typeof t.input_tokens === "number" ? t.input_tokens : null; - const cached = typeof t.cached_tokens === "number" ? t.cached_tokens : null; - const output = typeof t.output_tokens === "number" ? t.output_tokens : null; - const hasBrk = input !== null && cached !== null && output !== null && (cached > 0 || output > 0); - const tokenDetail = hasBrk - ? '' + esc(T("tx.brk.input")) + " " + fmtTokens(input) + "" + - '' + esc(T("tx.brk.cache")) + " " + fmtTokens(cached) + "" + - '' + esc(T("tx.brk.output")) + " " + fmtTokens(output) + "" - : ""; + // Token 四列:输入(非缓存) / 缓存 / 输出 / 总;旧记录(cached/output=0)输入=总 + const inputTokens = fmtTokens(typeof t.input_tokens === "number" ? t.input_tokens : null); + const cachedTokens = fmtTokens(typeof t.cached_tokens === "number" ? t.cached_tokens : null); + const outputTokens = fmtTokens(typeof t.output_tokens === "number" ? t.output_tokens : null); + // 模型列:consume/earn 显示模型名;无模型(topup/gift 等)显示交易类型说明 + // Key 列:key_label(note / provider / provider / plan);无 key → — + const model = t.model || txType(t.type); + const key = t.key_label || "—"; return { id: t.id, time: (t.time || "").replace("T", " ").slice(0, 16), type: t.type, - partner: t.counterpart || "—", + model, + key, detail: t.model ? "消费 · " + t.model : "交易", tokens, - tokenDetail, + inputTokens, + cachedTokens, + outputTokens, pts: t.pts, status: t.status || "成功", }; diff --git a/ui/js/i18n.js b/ui/js/i18n.js index 34c1129..24f3f38 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -314,8 +314,12 @@ "tx.summary.output": "输出", "tx.col.time": "时间", "tx.col.type": "类型", - "tx.col.partner": "模型 / Key", - "tx.col.tokens": "Token 用量", + "tx.col.model": "模型", + "tx.col.key": "Key", + "tx.col.input": "输入(非缓存)", + "tx.col.cached": "输入(缓存)", + "tx.col.output": "输出", + "tx.col.tokens": "总 Token", "tx.brk.input": "输入", "tx.brk.cache": "缓存", "tx.brk.output": "输出", @@ -1007,8 +1011,12 @@ "tx.summary.output": "Output", "tx.col.time": "Time", "tx.col.type": "Type", - "tx.col.partner": "Model / Key", - "tx.col.tokens": "Tokens", + "tx.col.model": "Model", + "tx.col.key": "Key", + "tx.col.input": "Input (non-cache)", + "tx.col.cached": "Input (cache)", + "tx.col.output": "Output", + "tx.col.tokens": "Total", "tx.brk.input": "In", "tx.brk.cache": "Cache", "tx.brk.output": "Out",