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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "aitokenpool"
version = "0.7.11"
version = "0.7.12"
edition = "2021"
description = "AI Token 共享池 — 企业 key 池 + 公共共享市场"
license = "MIT"
Expand Down
1 change: 1 addition & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,7 @@ pub fn router() -> Router<AppState> {
.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))
Expand Down
253 changes: 253 additions & 0 deletions src/routes/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 结束时间(同 /api/transactions,ISO 8601 UTC)
pub end: Option<String>,
/// 聚合粒度: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<AppState>,
auth: AuthUser,
Query(q): Query<TxTrendQuery>,
) -> Result<Json<serde_json::Value>, 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<String>| -> Result<Option<String>, (axum::http::StatusCode, Json<serde_json::Value>)> {
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<serde_json::Value> = 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::<rusqlite::Result<Vec<_>>>()
.map_err(internal)?;
Ok(Json(serde_json::json!({
"bucket": bucket,
"buckets": buckets,
})))
}

/// GET /api/dashboard:本月按类型聚合 + 净变化 + 近 7 天净额序列
pub async fn dashboard(
State(st): State<AppState>,
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions ui/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
11 changes: 6 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=20260823-2">
<link rel="stylesheet" href="css/style.css?v=20260823-3">
</head>
<body>

Expand Down Expand Up @@ -350,6 +350,7 @@ <h2 data-i18n="view.transactions">交易记录</h2>
</div>
<div class="card">
<div class="tx-summary" id="tx-summary"></div>
<div class="tx-trend" id="tx-trend"></div>
<div class="table-wrap compact" id="tx-table"></div>
</div>
</section>
Expand Down Expand Up @@ -682,9 +683,9 @@ <h3 id="chat-title">使用模型</h3>
</div>
</div>

<script src="js/api.js?v=20260823-2"></script>
<script src="js/data.js?v=20260823-2"></script>
<script src="js/i18n.js?v=20260823-2"></script>
<script src="js/app.js?v=20260823-2"></script>
<script src="js/api.js?v=20260823-3"></script>
<script src="js/data.js?v=20260823-3"></script>
<script src="js/i18n.js?v=20260823-3"></script>
<script src="js/app.js?v=20260823-3"></script>
</body>
</html>
Loading
Loading