Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 84 additions & 6 deletions src/routes/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?,
};
Expand All @@ -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<String>>(11)?,
}))
})
.map_err(internal)?
Expand All @@ -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<String>>(11)?,
}))
})
.map_err(internal)?
Expand Down Expand Up @@ -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(),
"/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");
Expand Down
10 changes: 5 additions & 5 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AITokenPool</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%234ecdc4'/%3E%3Cstop offset='1' stop-color='%232a9d8f'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='64' height='64' rx='14' fill='url(%23g)'/%3E%3Ctext x='32' y='43' font-family='-apple-system,Segoe UI,Arial,sans-serif' font-size='30' font-weight='800' text-anchor='middle' fill='%23062021'%3EAT%3C/text%3E%3C/svg%3E">
<link rel="stylesheet" href="css/style.css?v=20260821-2">
<link rel="stylesheet" href="css/style.css?v=20260822-1">
</head>
<body>

Expand Down Expand Up @@ -673,9 +673,9 @@ <h3 id="chat-title">使用模型</h3>
</div>
</div>

<script src="js/api.js?v=20260821-2"></script>
<script src="js/data.js?v=20260821-2"></script>
<script src="js/i18n.js?v=20260821-2"></script>
<script src="js/app.js?v=20260821-2"></script>
<script src="js/api.js?v=20260822-1"></script>
<script src="js/data.js?v=20260822-1"></script>
<script src="js/i18n.js?v=20260822-1"></script>
<script src="js/app.js?v=20260822-1"></script>
</body>
</html>
47 changes: 26 additions & 21 deletions ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1171,11 +1171,16 @@
options: () => ["consume", "earn", "topup", "withdraw", "gift"].map(txType),
filterVal: (t) => txType(t.type),
render: (t) => t.type === "earn" ? '<span class="badge ok">' + T("tx.type.earn") + "</span>" : t.type === "consume" ? '<span class="badge accent">' + T("tx.type.consume") + "</span>" : t.type === "gift" ? '<span class="badge ok">' + T("tx.type.gift") + "</span>" : '<span class="badge dim">' + esc(txType(t.type)) + "</span>" },
{ 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
? '<div>' + t.tokens + '</div><div class="tx-brk" title="' + esc(T("tx.brk.title")) + '">' + t.tokenDetail + "</div>"
: 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) => '<span style="color:' + (t.pts > 0 ? "var(--ok)" : "var(--text)") + ';font-weight:600">' + (t.pts > 0 ? "+" : "") + D.fmt(t.pts) + "</span>" },
{ key: "status", title: () => T("tx.col.status"), sort: "string", filter: "select",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -2466,33 +2471,33 @@
});
}

// 后端 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) => {
let tokens = "—";
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
? '<span class="brk-i">' + esc(T("tx.brk.input")) + " " + fmtTokens(input) + "</span>" +
'<span class="brk-c">' + esc(T("tx.brk.cache")) + " " + fmtTokens(cached) + "</span>" +
'<span class="brk-o">' + esc(T("tx.brk.output")) + " " + fmtTokens(output) + "</span>"
: "";
// 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 || "成功",
};
Expand Down
16 changes: 12 additions & 4 deletions ui/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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": "输出",
Expand Down Expand Up @@ -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",
Expand Down
Loading