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
137 changes: 137 additions & 0 deletions src/catalog_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ use std::collections::{BTreeMap, BTreeSet};
/// (`main.rs` 的 `DEFAULT_CONFIG`),因此这里 `include_str!` 它不引入任何新东西。
const DATA_JS: &str = include_str!("../ui/js/data.js");
const CONFIG_TOML: &str = include_str!("../config/config.example.toml");
/// 市场行模板与可用性 pill 的所在文件。
///
/// 兜底表与渲染器是**一对**:表里的字段名只有在渲染器读同一个名字时才有意义
/// (本模块诞生于 `ce6d0db`:表里的模型名与配置漂移,游客看到错误价格与「未上架」单元格)。
/// 「渲染器读了什么」因此也必须在输入面里,否则「字段缺失」永远无法被静态断言看见。
const APP_JS: &str = include_str!("../ui/js/app.js");

/// 阳性对照真值:**改动模型目录 / 兜底表 / plan 清单时应刻意更新这些数字**。
///
Expand Down Expand Up @@ -596,4 +602,135 @@ mod tests {
assert!(subset_violations(&js_market, &js_names).is_empty());
assert!(all_row_mismatches(&js_models, &cfg_models).is_empty());
}

/// 切出 `function <name>(` 之后的**函数体**(含收尾 `}`)。
///
/// 按行收尾:本文件的 JS 函数体一律 2 空格缩进,收尾行恰为 ` }`。
/// 不用括号配对是因为体里有字符串与嵌套块,手写配对容易被 JS 的引号/注释骗到;
/// 而「首个恰为 ` }` 的行」在这种缩进约定下是稳定的。**调用方必须自证收尾正确**
/// (见 `js_function_body_stops_at_the_right_place`),否则提取器会静默吞掉下一个函数。
fn js_function_body<'a>(src: &'a str, name: &str) -> Option<&'a str> {
let head = format!("function {name}(");
let start = src.find(&head)?;
let rest = &src[start..];
let mut offset = 0usize;
for line in rest.split_inclusive('\n') {
offset += line.len();
if line.trim_end_matches(['\n', '\r']) == " }" {
return Some(&rest[..offset]);
}
}
None
}

/// 市场行的「可用性」只有**一个**事实:`avail`。
///
/// 圆点(`.dot`)、「使用」按钮的 `disabled`、可用性筛选、详情网格四处都渲染它;
/// key 计数(`keys`)只是**登录态**由 `modelsToView()` 从 `available_keys` 填上的补充说明 ——
/// 游客兜底表(`MARKET`)按 rant 2026-08-19T15:54:06「虚构数据已移除」**不携带**计数。
///
/// 真实事故(C2128,jsdom 真 `index.html` + 四个真脚本跑出来的):可用性 pill 只认 `keys`,
/// 于是游客市场 7/7 行的这一格都渲染成「无 key」,其中 6 行的圆点是绿的、「使用」按钮可点 ——
/// 同一行里两处文案互相打脸。本测试把「pill 必须回落到行自己的 `avail`」钉住,
/// 并同时钉住**不许**用手写计数去"修"它(那是往游客面前放虚构的运营数据)。
#[test]
fn market_availability_pill_agrees_with_the_row_it_renders() {
// ── 前置①:兜底表解析得到一整张表(空表上的集合断言会假绿,坑 68)───────────
let rows = top_level_objects(array_region(DATA_JS, "MARKET: ["));
assert_eq!(
rows.len(),
MARKET_COUNT,
"data.js MARKET 应扫出 {MARKET_COUNT} 行,实得 {}",
rows.len()
);

// ── 前置②:每行都声明了**布尔** `avail` —— pill 回落读的就是这个名字 ─────────
// 这一条同时是「字段名不是拼错」的阳性对照:若 pill 读 `m.availability`(#94 之前的名字),
// 下面的断言会指出兜底表根本没有这个字段。
let bad_flag: Vec<String> = rows
.iter()
.enumerate()
.filter_map(|(i, r)| match field_raw(r, "avail").map(str::trim) {
Some("true") | Some("false") => None,
other => Some(format!("第 {} 行 avail = {other:?}", i + 1)),
})
.collect();
assert!(
bad_flag.is_empty(),
"MARKET 每行都必须有布尔 `avail`(市场行可用性的唯一事实):\n{}",
bad_flag.join("\n")
);

// ── 不变量③(消费侧):pill 必须读行自己的 `avail` ────────────────────────
let pill = js_function_body(APP_JS, "availPill").expect("app.js 里找不到 availPill()");
assert!(
pill.contains("m.avail"),
"可用性 pill 必须回落到行自己的 `avail`(绿点/「使用」按钮/可用性筛选都读它):\
没有 key 计数时(游客兜底表)否则会渲染成「无 key」,与同一行的绿点互相打脸(C2128)。\
当前 availPill 体:\n{pill}"
);

// ── 不变量④(数据侧):游客兜底表不得手写 key 计数 ────────────────────────
// `keys` 只在登录态由 `available_keys` 填;写进兜底表等同于把虚构的运营数据展示给游客,
// 也正是 #94 删掉 `multi`/`success` 的同源行为。要显示计数就必须来自后端。
let fabricated: Vec<String> = rows
.iter()
.enumerate()
.filter(|(_, r)| field_raw(r, "keys").is_some())
.map(|(i, r)| format!("第 {} 行:{}", i + 1, field_raw(r, "model").unwrap_or("?")))
.collect();
assert!(
fabricated.is_empty(),
"MARKET 行不得携带手写的 `keys` 计数(游客看到的 key 数必须来自 /api/models 的 \
available_keys,不能是兜底表里的虚构数字):\n{}",
fabricated.join("\n")
);

// ── 阳性对照⑤:pill 要与之保持一致的那一行,确实也读 `m.avail` ──────────────
let button_line = APP_JS
.lines()
.find(|l| l.contains("data-use-model="))
.expect("市场行模板里找不到 data-use-model 按钮");
assert!(
button_line.contains("m.avail"),
"「使用」按钮的可用性必须与 pill 同源(都读 `m.avail`),当前行:\n{button_line}"
);
}

/// 提取器自证:`js_function_body` 必须停在**本函数**的收尾处,不能吞掉下一个函数,
/// 且删掉回落分支后必须报出「不读 `m.avail`」。
#[test]
fn js_function_body_stops_at_the_right_place() {
let pill = js_function_body(APP_JS, "availPill").expect("找不到 availPill()");
// ① 收尾正确:体内含自己的三个计数分支,且不含紧随其后的函数
assert!(
pill.contains("mk.avail.multi") && pill.contains("mk.avail.none"),
"提取到的 availPill 体不含它自己的文案键:\n{pill}"
);
assert!(
!pill.contains("function loadMarketplace"),
"提取器吞掉了下一个函数(收尾行判定错了):\n{pill}"
);

// ② 合成输入对照:把回落分支摘掉后,同一条断言必须变红(否则它测的不是它宣称的东西)
let without_fallback = APP_JS
.lines()
.filter(|l| {
!l.contains(
"if (m.avail) return '<span class=\"pill pill-ok\">' + esc(T(\"mk.avail.on\"))",
)
})
.collect::<Vec<_>>()
.join("\n");
assert_ne!(
without_fallback, APP_JS,
"对照构造失败:回落分支这一行不存在,测试会变成自证(先确认 availPill 的回落写法)"
);
let mutated =
js_function_body(&without_fallback, "availPill").expect("变异体里找不到 availPill()");
assert!(
!mutated.contains("m.avail"),
"阴性对照失败:删掉回落分支后提取器仍报「读了 m.avail」—— 断言没有牙齿:\n{mutated}"
);
}
}
8 changes: 4 additions & 4 deletions src/i18n_pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@ const EN_END: &str = "\n };";
///
/// ⚠️ `T_LITERAL_COUNT` 是 `T("…")` **调用点**总数,不是键数,也不是去重后的键数 ——
/// 三个集合各不相同(坑 99);说「这个数不该变」之前先确认它在数哪个集合。
const ZH_KEY_COUNT: usize = 786;
const EN_KEY_COUNT: usize = 786;
const ZH_KEY_COUNT: usize = 787;
const EN_KEY_COUNT: usize = 787;
const STATIC_ATTR_COUNT: usize = 330;
const STATIC_ATTR_DISTINCT: usize = 305;
const T_LITERAL_COUNT: usize = 538;
const T_LITERAL_DISTINCT: usize = 430;
const T_LITERAL_COUNT: usize = 539;
const T_LITERAL_DISTINCT: usize = 431;

/// 切出语言包区段(起点标记 → 终点标记,含起点)。
fn pack_region<'a>(src: &'a str, start_mark: &str, end_mark: &str) -> &'a str {
Expand Down
12 changes: 10 additions & 2 deletions ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,14 +324,14 @@ ui/

## 界面国际化 i18n 约定(v1.21.1,rant 2026-08-18T20:49:22 + 21:40:10 去中英混排)

- **语言包**:`ui/js/i18n.js` 零依赖 IIFE,`I18N = { zh, en }` 双词典(786 键 ×2,覆盖导航/登录/视图标题/通用/仪表盘/市场/共享/钱包/交易/设置/管理/运营/聊天/游客/相对时间/帮助/tour/主题/错误映射);`window.t(key, vars)` 查当前语言,**缺失回退 zh,再缺回退 key 本身**;`{var}` 占位符插值;
- **语言包**:`ui/js/i18n.js` 零依赖 IIFE,`I18N = { zh, en }` 双词典(787 键 ×2,覆盖导航/登录/视图标题/通用/仪表盘/市场/共享/钱包/交易/设置/管理/运营/聊天/游客/相对时间/帮助/tour/主题/错误映射);`window.t(key, vars)` 查当前语言,**缺失回退 zh,再缺回退 key 本身**;`{var}` 占位符插值;
- **切换机制**:设置页「偏好 → 界面语言」下拉(`#prefs-lang`,zh/en)→ `I18n.setLang()`:写 `localStorage('atp_lang')` + `document.documentElement.lang` 同步(zh→`zh-CN` / en→`en`)+ 派发 `atp:langchange` → app.js 重渲染 `renderNav()` + `renderView(activeView)` + `document.title`(引导中额外 `renderTourStep()`);**首载**:localStorage → `navigator.language` 前缀(`zh*`→zh,否则 en)→ 默认 zh;切换即时生效无需刷新;
- **静态文案**:`index.html` 内静态中文用 `data-i18n` / `data-i18n-ph`(placeholder)/ `data-i18n-title`(title)标记,`applyStatic()` 启动时与每次切换时批量替换;**容器含表单控件的 `<label>` 用 `<label><span data-i18n="KEY">文本</span><input…></label>` 结构**(避免 innerHTML 替换销毁控件);
- **动态文案**:`app.js` 面向用户字符串全部走 `t('key')`;**语言敏感常量存 key 而非文案**(NAV/VIEW_TITLE/TOUR_STEPS/HELP_KEYS 存 key,渲染时 `T()` 解析;SHARE_STATUS/RAISE_STATUS 的 `text` 为函数;TX_COLUMNS 的 `title`/`options` 为函数;`DAY_LABELS` 动态 `T("share.day."+n)`)——保证切换语言后重渲染即时生效;
- **数字/时间本地化**:`I18n.fmtNum`(zh→`zh-CN` / en→`en-US` `toLocaleString`);`I18n.fmtRelTime`(刚刚/N 分钟前/N 小时前/昨天 ↔ just now/N min ago/N hr ago/yesterday);数量单位(人/个/笔/次)用 `cnt.*` 键(zh 带量词,en 纯数字);
- **后端错误映射**:`api.js` 抛错前过 `I18n.mapErr()`——en 模式下已知中文错误(「该模型暂无可用 key」「点数余额不足」「需要管理员权限」等 15 组)映射为英文,未知原样返回;zh 模式原样透传;**`api.js` 自己的文案一律按 key 取**(`T("err.network")` / `T("err.http", {n})` 等),文件内不写中文原文——本文件不在 `src/i18n_pack.rs` 的输入面内,写原文会同时逃过三道门禁(见 `api_client_error_text_is_key_based`);
- **单语原则(v1.21.1 去混排)**:zh 词典值一律纯中文(仅保留 API/Key/Plan/tokens/CSV 等专有名词、键盘快捷键与占位符),不再内联英文注释;`index.html` 已移除全部 `<span class="en">` 静态小字(55 处);`.en` CSS 样式已删除;en 词典保持纯英文;
- 冒烟测试:node 无 DOM 桩跑 i18n.js(t/setLang/mapErr/fmtNum/fmtRelTime 断言,见开发记录);Key 一致性扫描(`src/i18n_pack.rs` 门禁:app.js 的 `T()` 字面量 430 个、index.html 的 `data-i18n*` 305 个,去重并集 680 键全部存在于 ZH/EN)。
- 冒烟测试:node 无 DOM 桩跑 i18n.js(t/setLang/mapErr/fmtNum/fmtRelTime 断言,见开发记录);Key 一致性扫描(`src/i18n_pack.rs` 门禁:app.js 的 `T()` 字面量 431 个、index.html 的 `data-i18n*` 305 个,去重并集 681 键全部存在于 ZH/EN)。

## 仪表盘「我的共享」数据源与降级约定(v1.21.2,rant 2026-08-19T15:48:17 BUG)

Expand Down Expand Up @@ -404,3 +404,11 @@ ui/
- **请求只走一处**:委托只 `btn.click()`,不自己复制一遍校验与请求 —— 忙碌态(`withLoading`)、字段校验、API 调用都留在确认按钮自己的监听器里。
- **CI 覆盖**:`src/i18n_pack.rs::enter_submit_is_delegated_to_the_card` 钉形状 —— ① 任何让 Enter 去点确认按钮的 `keydown` 登记都不得挂在 `ui/index.html` 里声明于 `input` / `select` / `textarea` 的 id 上(阳性对照:`#chat-input` 的 Enter 是「发送消息」不是「提交表单」,不点按钮 ⇒ 不得被误判);② `wireEnterSubmit` 必须在容器参数上登记、早退非 `INPUT` 与非文本类型;③ 五张卡片都必须走这个 helper,且 helper 调用数与卡片数相等。
- **冒烟测试注意**:每个控件跑**同一件事两遍** —— 在控件里按 Enter vs 用**相同的字段值**点确认按钮,比较是否发出了同一个请求(点确认那一遍是控件**自带的阳性对照**,排除「这个表单本来就用这些值提交不了」)。⚠️ `withLoading` 会把提交推迟 **320ms**,等待须 > 320ms,且每条腿前清空请求记录,否则会读到上一条腿的请求(结论完全反向)。

## 市场行的「可用性」只有一个事实:`avail`(C2128)

- **一个事实,四处渲染**:一个市场行的可用性由 `avail` 表达 —— 厂商单元格的绿点(`.dot`)、「使用」按钮的 `disabled`、可用性筛选下拉(「仅可用」)、展开详情里的「当前可用 / 当前繁忙」四处都读它。`keys`(key 计数)**不是**第二个事实,它只是**有计数时才存在**的补充说明:登录态由 `modelsToView()` 从 `/api/models` 的 `available_keys` 填,游客兜底表(`data.js > MARKET`)按 rant 2026-08-19T15:54:06「虚构数据已移除」**不携带**任何计数(同源被删的还有 `multi` / `success`)。
- **不变量**:**没有计数时不得把它读成「无 key」** —— 可用性 pill 必须先看计数(≥2 多 key / ==1 紧张),没有计数时回落到 `m.avail`(可用 / 无 key)。违反的后果是同一行自相矛盾:C2128 实测(jsdom 真 `index.html` + 四脚本)游客市场 **7/7 行**的这一格都渲染成「无 key」,而其中 **6 行**的圆点是绿的、「使用」按钮可点。
- **修法**:`availPill(m)` 增加一个**由 `m.avail` 驱动**的回落档(新键 `mk.avail.on`,zh「可用」/ en「Available」)。**不给兜底表补手写计数** —— 那等于把虚构的运营数据展示给游客,正是 v1.22 清理掉 `multi`/`success` 的那件事;游客要看到真实计数,就得让数据来自 `/api/models`。
- **CI 覆盖**:`src/catalog_gate.rs::market_availability_pill_agrees_with_the_row_it_renders` 钉两半 —— ① **消费侧**:`availPill` 体内必须出现 `m.avail`(阳性对照:兜底表每一行都必须有布尔 `avail`,同时证明这个字段名不是拼错的);② **数据侧**:`MARKET` 行不得出现手写的 `keys`(挡住「给兜底表补计数」这条错修法);③ 「使用」按钮所在行也必须读 `m.avail`(pill 要与它同源)。`js_function_body_stops_at_the_right_place` 再自证提取器不吞下一个函数,并用**合成输入**(删掉回落分支)证明断言有牙齿。
- **冒烟测试注意**:本轴只在**游客腿**可见(登录态 `keys` 恒存在,三档计数 pill 正常)⇒ 探针必须两条腿都跑,并把登录腿当作**阳性对照**(`keys=3/1/0` 必须渲染成三档),否则「pill 只会渲染一档」与「游客数据缺字段」两种解读都被拒不了。游客腿的行数(7)、圆点、按钮禁用态都要读**渲染后的 DOM**,不要读 `MARKET` 字面量。
4 changes: 2 additions & 2 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,7 @@ <h3 id="chat-title">使用模型</h3>

<script src="js/api.js?v=20260914-13"></script>
<script src="js/data.js?v=20260914-11"></script>
<script src="js/i18n.js?v=20260914-12"></script>
<script src="js/app.js?v=20260915-2"></script>
<script src="js/i18n.js?v=20260915-1"></script>
<script src="js/app.js?v=20260915-3"></script>
</body>
</html>
11 changes: 10 additions & 1 deletion ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,8 @@
'<td class="num" data-label="' + T("mk.col.ctx") + '">' + D.ctxFmt(m.ctx) + "</td>" +
// 能力标签(rant 第 4 节:旗舰/推理 tag-accent,读图另加 tag)——仅渲染后端真实字段
'<td data-label="' + T("mk.col.caps") + '">' + capabilityTags(m) + "</td>" +
// 可用性 pill(rant 第 4 节:keys>=2 可用·N key / keys==1 紧张 / 无 key)
// 可用性 pill(rant 第 4 节:keys>=2 可用·N key / keys==1 紧张 / 无计数但标着可用 → 可用 / 无 key)
// —— 有计数用计数,没计数用 `avail`(游客兜底表只有布尔,见 availPill 的注释)
"<td data-label='" + T('mk.col.avail') + "'>" + availPill(m) + "</td>" +
"<td data-label='" + T('mk.col.action') + "'><button class='btn btn-primary btn-sm' data-use-model='" + m.id + "'" + (m.avail ? "" : " disabled") + ">" + T("mk.use") + "</button>" +
// 零 mock:成功率后端暂无字段 → 仅当有真实值时展示(multi/success 已从 data.js 移除)
Expand All @@ -867,10 +868,18 @@
}

// 可用性 pill(rant 第 4 节):key 数三态;无 key → muted 且「使用」按钮禁用
//
// ⚠️ 一个市场行的「可用」只有**一个**事实:`avail`(绿点 / 「使用」按钮是否可点 / 可用性筛选
// / 详情里的「当前可用」四处都读它)。`keys` 只是**有计数时才存在**的补充说明 ——
// 登录态由 `modelsToView()` 从 `available_keys` 填,游客兜底表(`data.js > MARKET`)
// 按 rant 2026-08-19T15:54:06「虚构数据已移除」**不携带计数**。
// 因此没有计数时**不能**读成「无 key」:那会让同一行的绿点 + 可点的「使用」按钮
// 与这一格文案互相打脸(C2128 实测:游客市场 7/7 行都渲染成「无 key」,其中 6 行按钮可点)。
function availPill(m) {
const n = m.keys || 0;
if (n >= 2) return '<span class="pill pill-ok">' + esc(T("mk.avail.multi", { n: n })) + "</span>";
if (n === 1) return '<span class="pill pill-warn">' + esc(T("mk.avail.tight")) + "</span>";
if (m.avail) return '<span class="pill pill-ok">' + esc(T("mk.avail.on")) + "</span>";
return '<span class="pill pill-muted">' + esc(T("mk.avail.none")) + "</span>";
}

Expand Down
2 changes: 2 additions & 0 deletions ui/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
"mk.avail.yes": "仅可用",
"mk.avail.multi": "可用 · {n} key",
"mk.avail.tight": "紧张",
"mk.avail.on": "可用",
"mk.avail.none": "无 key",
"mk.col.providerModel": "厂商 / 模型",
"mk.col.caps": "能力",
Expand Down Expand Up @@ -1012,6 +1013,7 @@
"mk.avail.yes": "Available only",
"mk.avail.multi": "Available · {n} keys",
"mk.avail.tight": "Tight",
"mk.avail.on": "Available",
"mk.avail.none": "No key",
"mk.col.providerModel": "Provider / model",
"mk.col.caps": "Capabilities",
Expand Down
Loading