fix(billing): 插件页不可达(真·空白根因)+ i18n + 溢出 + 状态页 tile

## ★ 用户报告「Billing 页还是空白」—— 上一轮的验证有漏洞
上一轮我用 goTab('billing') 直接调用验证,显示"有数据、无错误"就下了结论。
但用户是**点侧栏按钮**。真实点击路径走 goTab,而 goTab 只遍历硬编码的 TABS
常量来切换 `hidden` 类 —— 插件页不在 TABS 里,所以 #tab-billing 的 hidden
**永远不会被移除**。内容一直躺在 DOM 里(KPI/表格都填好了),只是不可见。

这个 bug 没有任何报错:注入正确、数据正确、API 200,唯一的问题是宿主页的
路由逻辑没把插件页纳入。而它是上一轮「TABS 收敛为单一常量」时留下的:
收敛让三处共用一个常量,却没让插件页进入它。

修法:goTab 同时遍历 PLUGIN_PAGES。PLUGIN_PAGES 从 const 改为 var 并**提前到
goTab 之前声明** —— const 在文件后部声明的话,goTab 的读取落在 TDZ 里,
第一次点击插件页就会抛 ReferenceError(同类问题这个文件里已是第二次)。

判据 TestPluginPagesAreReachableByGoTab 锁两件事:goTab 遍历插件页集合 +
声明在使用之前。变异验证:删掉遍历 → 红;var 改回 const → 红。

## 插件 UI 不跟随多语言
宿主的 applyI18n/data-i 只覆盖**宿主渲染的标记**;插件注入的 HTML 对它不可见,
所以整个 UI 切中文时 Billing 页还是英文。

pluginAPI 增加 lang(getter,实时值)与 onLangChange(切换回调)。
billing 页所有文案改走双语字典:KPI、表头(fresh/cache/cache%)、区块标题
(占位后由脚本填)、空态、状态页 tile 标签。切换时立即重渲染标题,
不用等下一次 fetch。

## 部分页面超出 UI 区域
#main 只有 overflow-y,插件页内容(8 列表格 min-width、长字符串)会横向撑破。
两层修:插件 pane 统一 min-width:0/max-width:100%/overflow-x:auto(第三方
任意 HTML 的兜底,与原生 pane 一致);billing 的宽表格在自身容器内滚动。

## 状态页 tile 的 TypeError(每次重绘都报)
tile 的 tick() 在 await 之后直接 getElementById(...).textContent = ...,
但状态页每次刷新都整体重建 pane,元素可能已不存在 → null 属性赋值。
await 之后重新取元素并判空。

## 顺手补的缺口
上一轮加了缓存表格列,但 KPI 卡片漏了(那次替换 assert 失败后重试只重做了
表格)—— 缓存命中率在表格里有、KPI 里没有。本次补上。

## 验证
真实浏览器(禁缓存、真实点击侧栏按钮):pane 可见、KPI 9 项、表头双语、
语言双向切换正确(Per source ⇄ 按源)、无水平溢出、无 billing 控制台错误。
生产数据:Total USD 0.566798 / 732 请求 / 降级 231 / 2.09 亿 prompt tokens。
387+ 测试全绿。

## DSL(进行中,未完)
config.BillingDSL(active + profiles + rules,rule 按 url 匹配 mode=free/
token/subscription/unpriced)与 internal/billing.Compile(url 规则 → 插件
prices 表,含峰谷窗口的形状编译 —— 之前手写 JSON 两次弄错的正是这个形状)
已落地并通过校验/编译;core 启动接线已写。profile 切换 API 与 WebUI 选择器
未做,生产 config.yaml 也尚未写 billing 段 —— 下一轮继续。
This commit is contained in:
JianFeeeee
2026-10-02 11:47:24 +08:00
parent fbdf0dea10
commit aa10ee8c27
6 changed files with 647 additions and 25 deletions

View File

@ -1357,6 +1357,7 @@
localStorage.setItem("llms-proxy.lang", LANG);
applyI18n();
updateBreadcrumb(lastTab);
notifyPluginLang();
refresh(lastTab);
};
document.getElementById("btn-logout").onclick = () => {
@ -1386,6 +1387,12 @@
// that is routed but never shown is exactly the kind of silent gap that
// survives review.
const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"];
// Declared HERE, with var, because goTab() below reads it. It used to be a
// `const` further down the file next to the other plugin state, which put
// goTab's read inside the temporal dead zone: the first click on a plugin
// page would have thrown a ReferenceError. var hoists, so the read sees
// the (still empty) Set and injectPluginUI fills it moments later.
var PLUGIN_PAGES = new Set();
document.querySelectorAll("nav button.sb-i").forEach((b) => {
b.onclick = () => goTab(b.dataset.tab);
});
@ -1397,7 +1404,22 @@
document
.querySelectorAll(".sb-i")
.forEach((x) => x.classList.toggle("active", x.dataset.tab === name));
// TABS plus every plugin-contributed page.
//
// Iterating only TABS left a plugin page permanently unreachable: the
// pane exists and is filled (the data was right there in the DOM) but
// its `hidden` class was never removed, so clicking the sidebar entry
// did nothing visible. The symptom reads as "the page is blank" because
// the content is present in the DOM and only invisible.
//
// PLUGIN_PAGES is consulted here rather than relying on the page's own
// script: the pane's visibility is the HOST's job, and a plugin that
// forgot to unhide itself must still be reachable.
TABS.forEach((tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name));
PLUGIN_PAGES.forEach((pn) => {
const el = $("#tab-" + pn);
if (el) el.classList.toggle("hidden", pn !== name);
});
updateBreadcrumb(name);
const pane = $("#tab-" + name);
if (pane) {
@ -5082,7 +5104,6 @@
// GET /api/ui-inject, because the sidebar needs all of them before it can
// be built. Injection happens once at boot, BEFORE the first goTab, so a
// plugin page is a real tab rather than a special case in the router.
const PLUGIN_PAGES = new Set();
const PLUGIN_TAB_CBS = {};
const PLUGIN_ELEMENTS = [];
// PLUGIN_MOUNT_HOOKS holds re-mount functions for plugin ELEMENTS. A host
@ -5117,7 +5138,32 @@
PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || [];
PLUGIN_TAB_CBS.__last.push(fn);
},
// The current UI language ("zh" | "en"). A plugin cannot read the
// host's LANG binding (it is module-local), and hardcoding one language
// is what left the Billing page English-only while the rest of the UI
// switched. Exposed as a getter so a plugin always sees the live value.
get lang() {
return LANG;
},
// Register a callback fired whenever the user switches language, so a
// plugin page can re-render in the new language without a reload. The
// callback also fires is NOT automatic on first paint — the plugin
// renders itself once on load as it always did.
onLangChange(fn) {
if (typeof fn === "function") PLUGIN_LANG_CBS.push(fn);
},
};
// Callbacks registered via pluginAPI.onLangChange.
const PLUGIN_LANG_CBS = [];
function notifyPluginLang() {
PLUGIN_LANG_CBS.forEach((fn) => {
try {
fn(LANG);
} catch (e) {
console.warn("plugin lang callback failed", e);
}
});
}
// pluginIconHTML renders a plugin-declared sidebar icon.
//
@ -5257,6 +5303,14 @@
const pane = document.createElement("div");
pane.id = "tab-" + id;
pane.className = "tab-pane hidden";
// A plugin page is arbitrary HTML from a third party. Without
// min-width:0 its content (a wide table, a long unbroken string)
// stretches the pane past #main, which does not scroll sideways —
// the operator sees the page spill over the UI instead of a
// scrollbar. The same guard every native pane gets.
pane.style.minWidth = "0";
pane.style.maxWidth = "100%";
pane.style.overflowX = "auto";
main.appendChild(pane);
const btn = document.createElement("button");
btn.className = "sb-i";