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
18 changes: 14 additions & 4 deletions src/i18n_pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,22 @@ const EN_END: &str = "\n };";

/// 阳性对照真值:**改动语言包/前端文案时应刻意更新这些数字**。
/// 它们的作用是把「提取器静默失真」与「语言包真的变了」区分开(见 `packs()`)。
const ZH_KEY_COUNT: usize = 775;
const EN_KEY_COUNT: usize = 775;
///
/// 本次更新(运营概览「服务版本 / 运行时长」两张卡):775 → 783 = **+8 键**
/// (`ops.stats.version{,.sub}` / `ops.stats.uptime{,.sub}` / `ops.uptime.{days,hours,minutes,seconds}`),
/// 520 → 531 / 418 → 426 = **+11 个 `T()` 字面量调用点 / +8 个去重键**
/// (两张卡各 1 个 + `fmtUptime` 的 8 处:name/sub 各 1,四个单位里 `days` 1 处、
/// `hours`/`minutes`/`seconds` 各 2 处 ⇒ 11 处调用只有 8 个不同键)。
/// `STATIC_ATTR_*` 不变:本次未改 `ui/index.html` 的静态属性。
///
/// ⚠️ 加数时**别口算**:C2013 我先把增量估成 +6(漏了 `fmtUptime` 里的重复调用),
/// 是这条阳性对照把 531 报了出来。改动后请以提取器的实际输出校准,再核对是否与 diff 相符。
const ZH_KEY_COUNT: usize = 783;
const EN_KEY_COUNT: usize = 783;
const STATIC_ATTR_COUNT: usize = 330;
const STATIC_ATTR_DISTINCT: usize = 305;
const T_LITERAL_COUNT: usize = 520;
const T_LITERAL_DISTINCT: usize = 418;
const T_LITERAL_COUNT: usize = 531;
const T_LITERAL_DISTINCT: usize = 426;

/// 切出语言包区段(起点标记 → 终点标记,含起点)。
fn pack_region<'a>(src: &'a str, start_mark: &str, end_mark: &str) -> &'a str {
Expand Down
27 changes: 26 additions & 1 deletion src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,17 @@ use crate::dao;
use crate::gateway;
use crate::router::RouterState;

/// 共享状态:数据库连接 + 配置 + 路由状态 + HTTP 客户端 + 密钥加密器
/// 共享状态:数据库连接 + 配置 + 路由状态 + HTTP 客户端 + 密钥加密器 + 进程启动时刻
#[derive(Clone)]
pub struct AppState {
pub db: Arc<Mutex<Connection>>,
pub cfg: Arc<Config>,
pub router: Arc<RouterState>,
pub http: reqwest::Client,
pub crypto: Crypto,
/// 进程启动时刻(单调时钟):`/api/ops/runtime` 用它算运行时长。
/// 取 `Instant` 而非墙上时间——运行时长不怕系统时钟被调整。
pub started_at: std::time::Instant,
}

impl AppState {
Expand All @@ -55,6 +58,7 @@ impl AppState {
.build()
.expect("reqwest client 构建失败"),
crypto,
started_at: std::time::Instant::now(),
}
}
}
Expand Down Expand Up @@ -1846,6 +1850,27 @@ mod tests {
let kh = v["key_health"].as_array().expect("key_health 数组");
assert!(!kh.is_empty(), "至少有 seed 的上游 key: {body}");
assert!(kh.iter().all(|k| k["off"] == 0), "seed key 均为 on: {body}");
// 服务版本 / 运行时长(原型「服务版本」「运行时长」两张卡)
assert_eq!(
v["version"],
env!("CARGO_PKG_VERSION"),
"version 应与 /healthz 同源: {body}"
);
let secs = v["uptime_secs"].as_u64().expect("uptime_secs: {body}");
let d = v["uptime_days"].as_u64().expect("uptime_days");
let h = v["uptime_hours"].as_u64().expect("uptime_hours");
let m = v["uptime_minutes"].as_u64().expect("uptime_minutes");
let s = v["uptime_secs_rest"].as_u64().expect("uptime_secs_rest");
assert_eq!(
d * 86400 + h * 3600 + m * 60 + s,
secs,
"四位分解应能拼回 uptime_secs: {body}"
);
assert!(h < 24 && m < 60 && s < 60, "各位未归一(进位漏了): {body}");
assert!(
secs < 60,
"AppState 刚构造,运行时长应 < 60s,实得 {secs}s —— 取错了起点? {body}"
);
// users 列表(含余额)
let (s, body) = get(st.clone(), "/api/ops/users", Some(&ops_bearer)).await;
assert_eq!(s, StatusCode::OK);
Expand Down
82 changes: 82 additions & 0 deletions src/routes/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
//! - GET /api/ops/runtime:平台运行概览(用户数 / 上架 key 数 / 本月调用 / 本月点数流水,全库聚合)
//! + rant 2026-09-11T16:23:43(PR6):今日按小时调用量 today_hours(0-23 全量补零,UTC 小时)
//! + 上游 key 健康 key_health(按厂商聚合 total/on/off)
//! + 服务版本 version(= `CARGO_PKG_VERSION`,与 /healthz 同源)与运行时长
//! uptime_{secs,days,hours,minutes,secs_rest}(AppState.started_at 单调时钟)
//! - POST /api/ops/credits {user_id, amount}:给任意用户充值(写 transactions counterpart='运营者')
//! - GET /api/ops/users:全平台用户列表(含余额)
//! - 权限:require_role(&["admin","ops"])——普通 user 403
Expand Down Expand Up @@ -34,12 +36,36 @@ pub struct OpsCreditReq {
pub amount: f64,
}

/// 一天的秒数(`uptime_days` / `uptime_hours` 的进位基数)
const DAY: u64 = 24 * 60 * 60;

/// 把运行秒数分解成 `(整日, 整小时, 分钟, 秒)`。
///
/// 放在后端而不是前端:前端两份语言包都要拼「6 天 4 小时」,交给前端就得在
/// JS 里再实现一遍进位(还要两语言各写一套),而这里只有一处。
fn split_uptime(secs: u64) -> (u64, u64, u64, u64) {
(
secs / DAY,
(secs % DAY) / 3600,
(secs % 3600) / 60,
secs % 60,
)
}

/// GET /api/ops/runtime:平台运行概览(全库聚合)
pub async fn runtime(
State(st): State<AppState>,
auth: AuthUser,
) -> Result<Json<serde_json::Value>, ApiErr> {
require_role(&auth)?;

// 版本与运行时长:不查库,先算——它们与下面的聚合无关,且 uptime 越早取越准。
// version 取 `env!("CARGO_PKG_VERSION")`,与 /healthz 同源(绝不在前端硬编码版本号:
// 原型里那张卡写死的 "v0.7.20" 从写下的一刻就已经过期了)。
let uptime_secs = st.started_at.elapsed().as_secs();
let (days, hours, minutes, secs) = split_uptime(uptime_secs);
let version = env!("CARGO_PKG_VERSION");

let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?;
let users: i64 = conn
.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))
Expand Down Expand Up @@ -134,6 +160,13 @@ pub async fn runtime(
}

Ok(Json(serde_json::json!({
// 版本与运行时长(原型「服务版本」/「运行时长」两张卡)
"version": version,
"uptime_secs": uptime_secs,
"uptime_days": days,
"uptime_hours": hours,
"uptime_minutes": minutes,
"uptime_secs_rest": secs,
"users": users,
"active_keys": active_keys,
"month_calls": month_calls,
Expand Down Expand Up @@ -233,3 +266,52 @@ pub async fn users(
}
Ok(Json(out))
}

#[cfg(test)]
mod tests {
use super::*;

/// `split_uptime` 的进位:**已知真值**断言,不是自证。
///
/// 每例都写明 `天:时:分:秒` 的期望值 —— 若把 `/` 与 `%` 写反、或漏掉某一位的归一,
/// 会在这里被点名,而不是在只断言「能拼回去」的地方悄悄溜过
/// (拼回式断言对「天=总秒数、其余全 0」这种错法同样成立)。
#[test]
fn split_uptime_carries_correctly() {
assert_eq!(split_uptime(0), (0, 0, 0, 0), "刚启动");
assert_eq!(split_uptime(59), (0, 0, 0, 59));
assert_eq!(split_uptime(60), (0, 0, 1, 0), "满 60 秒进 1 分");
assert_eq!(split_uptime(3599), (0, 0, 59, 59));
assert_eq!(split_uptime(3600), (0, 1, 0, 0), "满 60 分进 1 小时");
assert_eq!(split_uptime(86399), (0, 23, 59, 59));
assert_eq!(split_uptime(86400), (1, 0, 0, 0), "满 24 小时进 1 天");
assert_eq!(split_uptime(86460), (1, 0, 1, 0));
// 原型里那张卡的示例值:「6 天 4 小时」
assert_eq!(split_uptime(6 * 86400 + 4 * 3600), (6, 4, 0, 0));
// 长跑一年有余:不得溢出位宽
assert_eq!(
split_uptime(400 * 86400 + 23 * 3600 + 59 * 60 + 59),
(400, 23, 59, 59)
);
}

/// 分解结果必须**无损**(拼回去等于原值)且各位**已归一**。
///
/// 与上面的真值例互补:真值例证明具体取值对,这一条覆盖一片区间,
/// 保证不存在「某个具体秒数进位不对」的漏网值。
#[test]
fn split_uptime_is_lossless_and_normalized() {
for secs in (0..100_000u64).step_by(7) {
let (d, h, m, s) = split_uptime(secs);
assert_eq!(
d * 86400 + h * 3600 + m * 60 + s,
secs,
"拼不回原值: {secs}"
);
assert!(
h < 24 && m < 60 && s < 60,
"位未归一: {secs} -> {h}/{m}/{s}"
);
}
}
}
2 changes: 1 addition & 1 deletion ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,5 +333,5 @@ ui/

- **page-head crumb**:8 个视图统一 `.crumb`(`/ 设置` `/ 管理视图` `/ 运营视图`),与仪表盘/市场/共享/钱包/交易一致;
- **管理视图**(4 tab 不变):成员管理 pane 增加 `#emp-search` 成员搜索(成员名 / 邮箱 / 部门,`hl()` 高亮)+ 表格「角色 / 部门 / 永久点数 / 赠送点数 / 可用」列;用量报表用 `.card-grid-3` 三栏;组织管理与模型管理的工具栏为「搜索 + `.grow` + 主按钮(`btn-sm`)」,与原型一致;
- **运营视图**(2 tab 不变):运行概览新增两张卡片——**今日调用量(按小时)** `.bar-list`(`/api/ops/runtime` 的 `today_hours`,服务端 0-23 全量补零;GROUP BY 会省略无调用的小时,不补零会让柱子整体左移,与交易页 `txTrendDays` 同款坑)与**上游 key 健康** `.mini-list`(`key_health` 按厂商聚合 total/on/off,三态 pill:健康 / N 个异常 / 全部失败);成员充值的搜索框移到卡片标题行右侧(原型 `.spread`);
- **运营视图**(2 tab 不变):运行概览新增四张卡片——**服务版本**(`/api/ops/runtime` 的 `version`,取自 `env!("CARGO_PKG_VERSION")`,与 `/healthz` 同源;前端**不得**写死版本号)与**运行时长**(`uptime_secs` / `uptime_days` / `uptime_hours` / `uptime_minutes` / `uptime_secs_rest`,进位在后端 `split_uptime` 完成,前端 `fmtUptime` 只挑「最高两个非零位」并取 `ops.uptime.{days,hours,minutes,seconds}` 单位)、**今日调用量(按小时)** `.bar-list`(`today_hours`,服务端 0-23 全量补零;GROUP BY 会省略无调用的小时,不补零会让柱子整体左移,与交易页 `txTrendDays` 同款坑)与**上游 key 健康** `.mini-list`(`key_health` 按厂商聚合 total/on/off,三态 pill:健康 / N 个异常 / 全部失败);成员充值的搜索框移到卡片标题行右侧(原型 `.spread`);
- **零 mock 不破**:以上数据全部来自真实端点,加载失败仍走空态 + 重试(`.mini-item` / `.bar-row` 只在有真实数据时才渲染)。
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=20260911-15">
<link rel="stylesheet" href="css/style.css?v=20260911-16">
</head>
<body>

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

<script src="js/api.js?v=20260911-15"></script>
<script src="js/data.js?v=20260911-15"></script>
<script src="js/i18n.js?v=20260911-15"></script>
<script src="js/app.js?v=20260911-15"></script>
<script src="js/api.js?v=20260911-16"></script>
<script src="js/data.js?v=20260911-16"></script>
<script src="js/i18n.js?v=20260911-16"></script>
<script src="js/app.js?v=20260911-16"></script>
</body>
</html>
18 changes: 18 additions & 0 deletions ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2546,6 +2546,10 @@
const rt = Live.opsRuntime;
$("#ops-stats").innerHTML = [
stat(T("ops.stats.status"), '<span class="pill pill-ok">' + T("common.online") + "</span>", T("ops.stats.status.sub")),
// 服务版本 / 运行时长:版本来自后端 env!("CARGO_PKG_VERSION")(与 /healthz 同源),
// 绝不在前端写死——原型那张卡里的 "v0.7.20" 是一写就过期的字面量。
stat(T("ops.stats.version"), "v" + esc(rt.version || ""), T("ops.stats.version.sub")),
stat(T("ops.stats.uptime"), esc(fmtUptime(rt)), T("ops.stats.uptime.sub")),
stat(T("ops.stats.users"), T("cnt.people", { n: rt.users }), T("ops.stats.users.sub")),
stat(T("ops.stats.keys"), T("cnt.keys", { n: rt.active_keys }), T("ops.stats.keys.sub.on")),
stat(T("ops.stats.calls"), T("cnt.calls", { n: rt.month_calls }), T("ops.stats.calls.sub")),
Expand Down Expand Up @@ -2698,6 +2702,20 @@
return full;
}

// 运行时长(运营概览「运行时长」卡):后端给分解好的 days/hours/minutes/secs_rest
// (进位在后端做,前端只挑单位——两语言各写一遍进位没必要)。
// 取最高两个非零位:天+小时 / 小时+分 / 分+秒 / 秒(刚启动)。
function fmtUptime(rt) {
const d = rt.uptime_days || 0;
const h = rt.uptime_hours || 0;
const m = rt.uptime_minutes || 0;
const s = rt.uptime_secs_rest || 0;
if (d > 0) return T("ops.uptime.days", { n: d }) + (h > 0 ? " " + T("ops.uptime.hours", { n: h }) : "");
if (h > 0) return T("ops.uptime.hours", { n: h }) + (m > 0 ? " " + T("ops.uptime.minutes", { n: m }) : "");
if (m > 0) return T("ops.uptime.minutes", { n: m }) + (s > 0 ? " " + T("ops.uptime.seconds", { n: s }) : "");
return T("ops.uptime.seconds", { n: s });
}

// 精确本地时间(YYYY-MM-DD HH:MM:SS,到秒)——交易列表时间列(rant 2026-08-24T12:38:44)
function fmtPrecise(s) {
if (!s) return "";
Expand Down
16 changes: 16 additions & 0 deletions ui/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,14 @@
"ops.keys.empty": "尚未上架任何上游 key",
"ops.stats.status": "运行状态",
"ops.stats.status.sub": "平台服务正常",
"ops.stats.version": "服务版本",
"ops.stats.version.sub": "单二进制运行中",
"ops.stats.uptime": "运行时长",
"ops.stats.uptime.sub": "SQLite · 无外部依赖",
"ops.uptime.days": "{n} 天",
"ops.uptime.hours": "{n} 小时",
"ops.uptime.minutes": "{n} 分",
"ops.uptime.seconds": "{n} 秒",
"ops.stats.users": "用户数",
"ops.stats.users.sub": "全平台注册用户",
"ops.stats.keys": "上架 key 数",
Expand Down Expand Up @@ -1476,6 +1484,14 @@
"ops.keys.empty": "No upstream keys listed yet",
"ops.stats.status": "Status",
"ops.stats.status.sub": "platform service OK",
"ops.stats.version": "Service version",
"ops.stats.version.sub": "single binary, running",
"ops.stats.uptime": "Uptime",
"ops.stats.uptime.sub": "SQLite · no external deps",
"ops.uptime.days": "{n} d",
"ops.uptime.hours": "{n} h",
"ops.uptime.minutes": "{n} min",
"ops.uptime.seconds": "{n} s",
"ops.stats.users": "Users",
"ops.stats.users.sub": "all registered users",
"ops.stats.keys": "Active keys",
Expand Down
Loading