diff --git a/src/i18n_pack.rs b/src/i18n_pack.rs index b95b4ac..a226ae9 100644 --- a/src/i18n_pack.rs +++ b/src/i18n_pack.rs @@ -32,21 +32,23 @@ const EN_END: &str = "\n };"; /// 阳性对照真值:**改动语言包/前端文案时应刻意更新这些数字**。 /// 它们的作用是把「提取器静默失真」与「语言包真的变了」区分开(见 `packs()`)。 /// -/// 本次更新(运营概览「服务版本 / 运行时长」两张卡):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` 的静态属性。 +/// 本次更新(共享页「本月新增」卡):783 → 786 = **+3 键** +/// (`share.stats.newthis{,.sub,.none}`),531 → 535 / 426 → 429 = **+4 个 `T()` 字面量调用点 / +3 个去重键** +/// (卡片 1 个 + 副标题三元 3 个:单个模型取模型名不上键、多个 / 空各 1 个 `T()`,共 4 处调用 3 个新键)。 +/// `STATIC_ATTR_*` 不变:本次未改 `ui/index.html` 的静态属性(仅 cache-bust 串)。 +/// +/// 上一轮(运营概览「服务版本 / 运行时长」两张卡):775 → 783 键,520 → 531 / 418 → 426。 /// /// ⚠️ 加数时**别口算**:C2013 我先把增量估成 +6(漏了 `fmtUptime` 里的重复调用), /// 是这条阳性对照把 531 报了出来。改动后请以提取器的实际输出校准,再核对是否与 diff 相符。 -const ZH_KEY_COUNT: usize = 783; -const EN_KEY_COUNT: usize = 783; +/// 本轮实测的校准路径就是这条规则:783/531/426 三处**依次**报出 786 → 535 → 429, +/// 每一步都是先让门禁报出真值、再照抄,而不是先写一个自己算的数。 +const ZH_KEY_COUNT: usize = 786; +const EN_KEY_COUNT: usize = 786; const STATIC_ATTR_COUNT: usize = 330; const STATIC_ATTR_DISTINCT: usize = 305; -const T_LITERAL_COUNT: usize = 531; -const T_LITERAL_DISTINCT: usize = 426; +const T_LITERAL_COUNT: usize = 535; +const T_LITERAL_DISTINCT: usize = 429; /// 切出语言包区段(起点标记 → 终点标记,含起点)。 fn pack_region<'a>(src: &'a str, start_mark: &str, end_mark: &str) -> &'a str { diff --git a/src/routes/sharing.rs b/src/routes/sharing.rs index b23c13a..b0c5aff 100644 --- a/src/routes/sharing.rs +++ b/src/routes/sharing.rs @@ -119,6 +119,10 @@ pub async fn create( } /// 单条共享(含收益汇总);key 先解密再脱敏展示 +/// +/// ⚠️ 本函数**按下标读列**(`r.get(N)`),因此列顺序由调用方的 `SELECT` 决定: +/// 两个调用点(`list` / `patch`)的列表**必须逐字一致**。只改一处会让所有字段静默错位 +/// (不报错、类型也往往恰好兼容),所以新增列一律**追加在末尾**,并同步改两处。 fn sharing_row( conn: &rusqlite::Connection, crypto: &crate::crypto::Crypto, @@ -136,6 +140,7 @@ fn sharing_row( let start: String = r.get(9)?; let end: String = r.get(10)?; let note: String = r.get(11)?; + let created_at: String = r.get(12)?; // 解密 → 脱敏(sk-****xxxx);解密失败展示 **** let masked = crypto .decrypt(&encrypted_key) @@ -165,6 +170,8 @@ fn sharing_row( "available_start": start, "available_end": end, "note": note, + // 上架时间(UTC 带 Z,与 api_keys 的 created_at 同口径);前端据此算「本月新增」与表格上架时间列 + "created_at": crate::dao::utc_iso(&created_at), })) } @@ -178,7 +185,7 @@ pub async fn list( let mut stmt = conn .prepare( "SELECT id, provider, plan, model, status, encrypted_key, quota, used, \ - available_days, available_start, available_end, note \ + available_days, available_start, available_end, note, created_at \ FROM keys WHERE owner_id = ?1 ORDER BY id DESC", ) .map_err(internal)?; @@ -222,7 +229,7 @@ pub async fn patch( let row = conn .query_row( "SELECT id, provider, plan, model, status, encrypted_key, quota, used, \ - available_days, available_start, available_end, note \ + available_days, available_start, available_end, note, created_at \ FROM keys WHERE id = ?1", [id], |r| sharing_row(&conn, &crypto, r), @@ -432,6 +439,74 @@ mod tests { assert_eq!(s, axum::http::StatusCode::NOT_FOUND); } + /// 上架时间字段(C2015,rant 驱动:共享页「本月新增」卡的数据源) + /// + /// 锁三件事: + /// ① `created_at` 随**两个** SELECT 一起到位(`list` 与 `patch` 走的是两份列清单, + /// 而 `sharing_row` 按下标读列 —— 只改一处会让既有字段**静默错位**,故两处都断言); + /// ② 既有字段未因新增列而错位(下标读取的回归护栏); + /// ③ 序列化口径是 **UTC**(带 `Z`)。前端据此算「本月」,用本地时间解析会在月末跨月: + /// 本用例把上架时间按到 `2026-08-31 17:30:00Z` —— UTC 月是 8 月, + /// 而本地(UTC+8)已是 9 月 1 日 01:30 ⇒ 两种口径**可区分**,不是自证。 + #[tokio::test] + async fn created_at_is_exposed_and_utc_serialized() { + let st = test_state("created_at"); + let key = login(st.clone()).await; + let (_, body) = send( + st.clone(), + "POST", + "/api/sharings", + Some(r#"{"provider":"deepseek","model":"deepseek-v4-flash","key":"sk-created1234","used":0,"note":"utc"}"#), + &key, + ) + .await; + let id: i64 = serde_json::from_str::(&body).unwrap()["id"] + .as_i64() + .unwrap(); + + // 回拨到「UTC 月末、本地已跨月」的时刻 + { + let conn = st.db.lock().unwrap(); + conn.execute( + "UPDATE keys SET created_at = '2026-08-31 17:30:00' WHERE id = ?1", + [id], + ) + .unwrap(); + } + + let (s, body) = send(st.clone(), "GET", "/api/sharings", None, &key).await; + assert_eq!(s, axum::http::StatusCode::OK, "body: {body}"); + let arr: Vec = serde_json::from_str(&body).unwrap(); + let row = arr.iter().find(|r| r["id"] == id).expect("列表应含该行"); + + assert_eq!( + row["created_at"], "2026-08-31T17:30:00Z", + "上架时间应为 UTC ISO(带 Z,且时刻未被平移到本地时区)" + ); + assert_eq!(&row["created_at"].as_str().unwrap()[..7], "2026-08"); + // 既有字段未错位 + assert_eq!(row["note"], "utc"); + assert_eq!(row["provider"], "deepseek"); + assert_eq!(row["status"], "on"); + assert_eq!(row["key"], "sk-****1234"); + assert_eq!(row["used"], 0.0); + + // PATCH 的单条响应走**另一份** SELECT —— 两处不一致时这里会错位 + let (s, body) = send( + st.clone(), + "PATCH", + &format!("/api/sharings/{id}"), + Some(r#"{"status":"paused"}"#), + &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["created_at"], "2026-08-31T17:30:00Z", "PATCH 响应同样带"); + assert_eq!(v["note"], "utc"); + assert_eq!(v["status"], "paused"); + } + #[tokio::test] async fn sharing_requires_bearer() { let st = test_state("nobearer"); diff --git a/ui/README.md b/ui/README.md index 1be3265..c6af3bb 100644 --- a/ui/README.md +++ b/ui/README.md @@ -89,7 +89,8 @@ ui/ ## 时间显示约定(v1.17,rant 2026-08-17T16:57:17 B 相对时间) - **相对时间**:`timeAgo(s)` 支持 `MM-DD HH:mm`(默认今年)与 `YYYY-MM-DD[ HH:mm]`,输出 `刚刚 / N 分钟前 / N 小时前 / 昨天 / MM-DD`,非标准格式原样返回;`timeCell(s)` 输出带 `title`(完整绝对时间)的 `.timeago` 单元格,hover 显示; -- **统一使用**:交易列表(时间列)、加额申请列表、共享列表(上架时间列)、API Key 最近使用时间;数据新增 / 生成时间用 `nowTime()`(`MM-DD HH:mm`)写入即可自动相对化。 +- **统一使用**:交易列表(时间列)、加额申请列表、API Key 最近使用时间;数据新增 / 生成时间用 `nowTime()`(`MM-DD HH:mm`)写入即可自动相对化。 + ⚠️ **不含共享列表**:本行原写「共享列表(上架时间列)」,但该列**从未存在**(原型 `docs/prototype/aitokenpool-console.html:574` 与实现 `ui/index.html:338` 的 thead 均无时间列,`timeCell` 也只有交易/API Key 两个调用点)。后端 `GET /api/sharings` 自 C2015 起才返回 `created_at`,前端把它收进视图行的 `time` 字段(备用),但**未加列**——若要加列,需同时补 `share.col.time` 键与 8 列表头的 `colspan` 对齐。 ## 表格数字列约定(v1.17,rant 2026-08-17T16:57:17 C 数字列对齐) @@ -297,6 +298,17 @@ ui/ - 删除 = 彻底下架(key 从平台移除,不可恢复);暂停 = 临时不接单,可恢复 - 登录态数据全部来自后端 API(`ui/js/api.js`);`data.js` 内嵌数据仅限游客市场浏览与上架表单兜底(见 v1.22 约定),无后端依赖的纯静态浏览不再成立 +## 共享页「本月新增」卡约定(v1.24,C2015) + +- 共享管理页统计区 `#share-stats` 由 3 张卡扩为 **4 张**(对齐原型 `docs/prototype/aitokenpool-console.html:1197`):在架 Key / 累计收益 / 已用量 / **本月新增**; +- **数据源**:`GET /api/sharings` 自本次起返回每行 `created_at`(UTC ISO,带 `Z`;走既有 `dao::utc_iso`,与 `api_keys.created_at` 同口径); + ⚠️ `sharing_row()` **按下标读列**,两个调用点(`list` / `patch`)的 SELECT 列清单必须逐字一致 ⇒ 新增列一律**追加在末尾并同改两处**,否则字段静默错位; +- **月份口径 = UTC,且只在一处计算**:视图行上的 `month` 由 `utcMonth(created_at)` 取 `YYYY-MM`,与后端 SQL 的 `strftime('%Y-%m', …)` 同源(ops/wallet/admin/org 全站聚合口径一致);当前月键取 `new Date().toISOString().slice(0,7)`。 + **不得**改用 `getMonth()`(本地月份)——那会让每月 1 日 00:00–08:00(UTC+8)的本页数字与同页其它统计不一致; +- **副标题不复刻原型**:原型写死 `deepseek-v4-flash`,而该卡聚合的可能是多个模型(原型自己的表格就渲染多行)⇒ 单个模型时显示该模型名,多个时显示 `share.stats.newthis.sub`(多个模型),为空时显示 `share.stats.newthis.none`; +- 拿不到 `created_at` 的行**不计入**(宁可少报不误报);`Live.sharings` 未就绪时统计区整体清空(沿用零 mock 降级); +- 新键(zh/en 各 3 个):`share.stats.newthis{,.sub,.none}`。 + ## 登录态零 mock 约定(v1.22,rant 2026-08-19T15:54:06 系统性清理) - **铁律**:登录态(`loggedIn()`)任何视图**不得渲染 `data.js` 的 `D.*` mock 数据**;mock 仅限游客模式(`GUEST_VIEWS = ["marketplace"]`,且市场模型列表登录态用 `GET /api/models` 真实数据); diff --git a/ui/index.html b/ui/index.html index bef2fbb..614d512 100644 --- a/ui/index.html +++ b/ui/index.html @@ -5,7 +5,7 @@ AITokenPool - + @@ -842,9 +842,9 @@

使用模型

- - - - + + + + diff --git a/ui/js/app.js b/ui/js/app.js index 74ab071..816c049 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -898,11 +898,20 @@ const on = list.filter((s) => s.status === "on"); const totalEarned = list.reduce((a, s) => a + s.earned, 0); const totalUsed = list.reduce((a, s) => a + s.used, 0); + // 本月新增(原型 4 张卡的第 4 张):按上架时间(UTC)落在本月计数。 + // 月键与后端 SQL 同源取 created_at;拿不到 created_at 的行**不计入**(宁可少报,不误报)。 + const nowMonth = utcMonth(new Date().toISOString()); + const thisMonth = list.filter((s) => s.month && s.month === nowMonth); $("#share-stats").innerHTML = [ stat(T("share.stats.listings"), T("cnt.keys", { n: on.length }), T("cnt.hist", { n: list.length })), stat(T("share.stats.earnings"), "+" + D.fmt(totalEarned) + " " + T("common.points"), T("share.stats.earnings.sub")), stat(T("share.stats.used"), D.fmt(totalUsed) + " " + T("common.points"), T("cnt.quota", { n: D.fmt(list.reduce((a, s) => a + s.quota, 0)) })), + // 副标题**不复刻原型**:原型写死模型名 `deepseek-v4-flash`,而这张卡聚合的可能是多个模型 + // (原型自己的表格就渲染多行)⇒ 多个时只报计数,单个时才带模型名(见下) + stat(T("share.stats.newthis"), T("cnt.keys", { n: thisMonth.length }), + thisMonth.length === 1 ? thisMonth[0].model + : (thisMonth.length ? T("share.stats.newthis.sub") : T("share.stats.newthis.none"))), ].join(""); // 表单下拉(厂商 → Plan → 模型 三级联动;Plan 中「API」= 按量计价的 key) @@ -3002,7 +3011,15 @@ }); } - // 后端 sharings → 视图行(字段对齐 mock:earned=earn、price 用 autoPrice、time 占位) + // 后端 sharings → 视图行(字段对齐 mock:earned=earn、price 用 autoPrice、time=上架时间) + // 「本月新增」的**月份口径**在这里算,且只算一次(见 utcMonth): + // 服务端返回的 created_at 是 UTC ISO,全站聚合口径也都是 UTC(ops/wallet/admin/org), + // 所以卡片不能改用本地月份——那会在每月 1 日 00:00–08:00(UTC+8)与同页其它数字不一致。 + function utcMonth(iso) { + if (!iso || iso.length < 7) return ""; + return iso.slice(0, 7); + } + function sharingsToView(list) { return list.map((s) => { let days = []; @@ -3018,7 +3035,8 @@ price: autoPrice(s.model), earned: s.earn || 0, status: s.status, - time: "", + time: s.created_at || "", + month: utcMonth(s.created_at), note: s.note || "", available: days.length ? { days, start: s.available_start || "", end: s.available_end || "" } : null, }; diff --git a/ui/js/i18n.js b/ui/js/i18n.js index 9b7a3a5..829d49a 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -271,6 +271,9 @@ "share.stats.earnings.sub": "全部 key", "share.stats.used": "已用量", "share.stats.used.sub": "{n} 点总额度", + "share.stats.newthis": "本月新增", + "share.stats.newthis.sub": "多个模型", + "share.stats.newthis.none": "本月还没有上架", "share.col.provider": "厂商 · Plan / 模型", "share.col.key": "Key", "share.col.used": "已用/额度", @@ -1084,6 +1087,9 @@ "share.stats.earnings.sub": "all keys", "share.stats.used": "Used", "share.stats.used.sub": "{n} pts total quota", + "share.stats.newthis": "New this month", + "share.stats.newthis.sub": "several models", + "share.stats.newthis.none": "nothing listed this month", "share.col.provider": "Provider · Plan / Model", "share.col.key": "Key", "share.col.used": "Used/Quota",