mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
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:
@ -462,30 +462,65 @@ plugin.ui = {
|
||||
icon = [==[<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M14.5 9.5a3 3 0 0 0-2.5-1.3c-1.4 0-2.4.7-2.4 1.8 0 2.6 5.2 1.4 5.2 4 0 1.1-1 1.8-2.5 1.8-1.1 0-2.1-.4-2.7-1.2"/><path d="M12 6.4v11.2"/></svg>]==],
|
||||
order = 40,
|
||||
mount = [==[
|
||||
<div id="billing-root" style="padding:16px">
|
||||
<div id="billing-root" style="padding:16px;min-width:0;max-width:100%;overflow-x:auto">
|
||||
<div class="kpis" id="billing-kpis" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:18px"></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px">
|
||||
<div class="card" style="padding:14px">
|
||||
<h3 style="margin:0 0 10px;font-size:14px">Per source</h3>
|
||||
<h3 id="billing-h-src" style="margin:0 0 10px;font-size:14px"></h3>
|
||||
<div id="billing-by-source"></div>
|
||||
</div>
|
||||
<div class="card" style="padding:14px">
|
||||
<h3 style="margin:0 0 10px;font-size:14px">Per model</h3>
|
||||
<h3 id="billing-h-model" style="margin:0 0 10px;font-size:14px"></h3>
|
||||
<div id="billing-by-model"></div>
|
||||
</div>
|
||||
<div class="card" style="padding:14px">
|
||||
<h3 style="margin:0 0 10px;font-size:14px">Per gateway key</h3>
|
||||
<h3 id="billing-h-key" style="margin:0 0 10px;font-size:14px"></h3>
|
||||
<div id="billing-by-key"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="padding:14px;margin-top:16px">
|
||||
<h3 style="margin:0 0 10px;font-size:14px">Daily</h3>
|
||||
<h3 id="billing-h-day" style="margin:0 0 10px;font-size:14px"></h3>
|
||||
<div id="billing-by-day"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var ROOT = "billing";
|
||||
// UI strings, bilingual. The host page i18n (applyI18n/data-i) only covers
|
||||
// markup the HOST renders; a plugin's injected markup is invisible to it, so
|
||||
// the Billing page stayed English while the rest of the UI switched. These go
|
||||
// through pluginAPI.lang / onLangChange, the small surface the host exposes
|
||||
// for exactly this.
|
||||
var STR = {
|
||||
en: {
|
||||
total: "Total", requests: "Requests", degraded: "Degraded",
|
||||
unpriced: "Unpriced", prompt: "Prompt tokens",
|
||||
completion: "Completion tokens", failures: "Failures",
|
||||
cacheRate: "Cache hit rate", cacheTokens: "Cache read tokens",
|
||||
perSource: "Per source", perModel: "Per model",
|
||||
perKey: "Per gateway key", perDay: "Daily",
|
||||
thName: "name", thCost: "cost", thReqs: "reqs", thPrompt: "prompt",
|
||||
thFresh: "fresh", thCache: "cache", thCachePct: "cache%",
|
||||
thCompletion: "completion",
|
||||
noData: "no data yet",
|
||||
},
|
||||
zh: {
|
||||
total: "总开销", requests: "请求数", degraded: "降级",
|
||||
unpriced: "未定价", prompt: "输入 tokens",
|
||||
completion: "输出 tokens", failures: "失败",
|
||||
cacheRate: "缓存命中率", cacheTokens: "缓存读取 tokens",
|
||||
perSource: "按源", perModel: "按模型",
|
||||
perKey: "按网关密钥", perDay: "按天",
|
||||
thName: "名称", thCost: "开销", thReqs: "请求", thPrompt: "输入",
|
||||
thFresh: "新鲜", thCache: "缓存", thCachePct: "缓存%",
|
||||
thCompletion: "输出",
|
||||
noData: "暂无数据",
|
||||
},
|
||||
};
|
||||
function L() {
|
||||
var lang = (window.pluginAPI && pluginAPI.lang) || "zh";
|
||||
return STR[lang] || STR.zh;
|
||||
}
|
||||
function fmt(n) {
|
||||
if (n === null || n === undefined) return "-";
|
||||
n = Number(n);
|
||||
@ -525,37 +560,56 @@ plugin.ui = {
|
||||
var keys = Object.keys(obj || {});
|
||||
if (!keys.length) { el.innerHTML = '<div class="muted">' + empty + "</div>"; return; }
|
||||
keys.sort(function (a, b) { return (obj[b].cost || 0) - (obj[a].cost || 0); });
|
||||
var h = "<table style='width:100%;border-collapse:collapse;font-size:13px'>" +
|
||||
"<tr style='text-align:left;opacity:.65'><th>name</th><th>cost</th><th>reqs</th>" +
|
||||
"<th>prompt</th><th>fresh</th><th>cache</th><th>cache%</th>" +
|
||||
"<th>completion</th></tr>";
|
||||
var TH = L();
|
||||
var h = "<table style='width:100%;border-collapse:collapse;font-size:13px;min-width:560px'>" +
|
||||
"<tr style='text-align:left;opacity:.65'><th>" + TH.thName + "</th><th>" + TH.thCost +
|
||||
"</th><th>" + TH.thReqs + "</th><th>" + TH.thPrompt +
|
||||
"</th><th>" + TH.thFresh + "</th><th>" + TH.thCache + "</th><th>" + TH.thCachePct +
|
||||
"</th><th>" + TH.thCompletion + "</th></tr>";
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var k = keys[i];
|
||||
h += "<tr style='border-top:1px solid rgba(120,90,150,.14)'>" + row(k, obj[k], cur) + "</tr>";
|
||||
}
|
||||
el.innerHTML = h + "</table>";
|
||||
}
|
||||
function renderTitles() {
|
||||
var T = L();
|
||||
var m = { "billing-h-src": T.perSource, "billing-h-model": T.perModel,
|
||||
"billing-h-key": T.perKey, "billing-h-day": T.perDay };
|
||||
for (var id in m) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = m[id];
|
||||
}
|
||||
}
|
||||
function render(st) {
|
||||
if (!st) return;
|
||||
renderTitles();
|
||||
var T = L();
|
||||
var cur = (st.currency || "USD");
|
||||
var t = st.total || {};
|
||||
document.getElementById("billing-kpis").innerHTML = [
|
||||
["Total", money(t.cost, cur)],
|
||||
["Requests", t.requests || 0],
|
||||
["Degraded", st.degraded_reqs || 0],
|
||||
["Unpriced", st.unpriced_reqs || 0],
|
||||
["Prompt tokens", t.prompt_tokens || 0],
|
||||
["Completion tokens", t.completion_tokens || 0],
|
||||
["Failures", t.failures || 0]
|
||||
[T.total, money(t.cost, cur)],
|
||||
[T.requests, t.requests || 0],
|
||||
[T.degraded, st.degraded_reqs || 0],
|
||||
[T.unpriced, st.unpriced_reqs || 0],
|
||||
[T.prompt, t.prompt_tokens || 0],
|
||||
[T.completion, t.completion_tokens || 0],
|
||||
[T.failures, t.failures || 0],
|
||||
// Cache KPIs: last session added the table columns but the KPI cards
|
||||
// were left out — the edit's assert failed and the retry only re-did the
|
||||
// tables. The numbers existed in state and nowhere in the UI.
|
||||
[T.cacheRate, cacheRate(t)],
|
||||
[T.cacheTokens, t.cache_hit_tokens || 0]
|
||||
].map(function (kv) {
|
||||
return "<div class='card' style='padding:12px'><div style='font-size:11px;opacity:.65'>" +
|
||||
kv[0] + "</div><div style='font-size:19px;font-weight:600;margin-top:4px'>" +
|
||||
esc(kv[1]) + "</div></div>";
|
||||
}).join("");
|
||||
tableFor(document.getElementById("billing-by-source"), st.by_source, cur, "no per-source data yet");
|
||||
tableFor(document.getElementById("billing-by-model"), st.by_model, cur, "no per-model data yet");
|
||||
tableFor(document.getElementById("billing-by-key"), st.by_key, cur, "no per-key data yet");
|
||||
tableFor(document.getElementById("billing-by-day"), st.by_day, cur, "no daily data yet");
|
||||
var TD = L();
|
||||
tableFor(document.getElementById("billing-by-source"), st.by_source, cur, TD.noData);
|
||||
tableFor(document.getElementById("billing-by-model"), st.by_model, cur, TD.noData);
|
||||
tableFor(document.getElementById("billing-by-key"), st.by_key, cur, TD.noData);
|
||||
tableFor(document.getElementById("billing-by-day"), st.by_day, cur, TD.noData);
|
||||
}
|
||||
async function refresh() {
|
||||
try {
|
||||
@ -574,7 +628,15 @@ plugin.ui = {
|
||||
}
|
||||
window.__billingRefresh = refresh;
|
||||
refresh();
|
||||
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(refresh);
|
||||
if (window.pluginAPI) {
|
||||
if (pluginAPI.onTabShown) pluginAPI.onTabShown(refresh);
|
||||
if (pluginAPI.onLangChange) {
|
||||
pluginAPI.onLangChange(function () {
|
||||
renderTitles();
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
]==],
|
||||
@ -589,7 +651,7 @@ plugin.ui = {
|
||||
order = 5,
|
||||
mount = [==[
|
||||
<div class="card" id="billing-status-tile" style="padding:12px;margin-bottom:12px">
|
||||
<div style="font-size:11px;opacity:.65">Total spend (billing plugin)</div>
|
||||
<div style="font-size:11px;opacity:.65" id="billing-tile-label"></div>
|
||||
<div id="billing-status-total" style="font-size:22px;font-weight:600;margin-top:4px">—</div>
|
||||
<div id="billing-status-sub" style="font-size:12px;opacity:.65;margin-top:2px"></div>
|
||||
</div>
|
||||
@ -609,7 +671,21 @@ plugin.ui = {
|
||||
var st = j.state;
|
||||
if (!st || !st.total) return;
|
||||
var cur = st.currency || "USD";
|
||||
document.getElementById("billing-status-total").textContent = cur + " " + fmt(st.total.cost);
|
||||
// The await above yields, so the host page may have rebuilt or torn down
|
||||
// this element in the meantime — and it does: renderStatus assigns
|
||||
// pane.innerHTML wholesale on every refresh. Assigning to a null element
|
||||
// threw a TypeError that the surrounding catch logged on every repaint.
|
||||
// Re-check after every await rather than assuming the DOM survived it.
|
||||
var totalEl = document.getElementById("billing-status-total");
|
||||
if (!totalEl) return;
|
||||
totalEl.textContent = cur + " " + fmt(st.total.cost);
|
||||
// The tile's label is plugin UI text, so it follows the host language via
|
||||
// the same pluginAPI surface the Billing page uses.
|
||||
var lab = document.getElementById("billing-tile-label");
|
||||
if (lab) {
|
||||
var lang = (window.pluginAPI && pluginAPI.lang) || "zh";
|
||||
lab.textContent = lang === "zh" ? "总开销(billing 插件)" : "Total spend (billing plugin)";
|
||||
}
|
||||
var parts = [];
|
||||
var srcs = st.by_source || {};
|
||||
var names = Object.keys(srcs).sort(function (a, b) {
|
||||
@ -618,7 +694,8 @@ plugin.ui = {
|
||||
for (var i = 0; i < Math.min(3, names.length); i++) {
|
||||
parts.push(names[i] + " " + fmt(srcs[names[i]].cost));
|
||||
}
|
||||
document.getElementById("billing-status-sub").textContent =
|
||||
var sub2 = document.getElementById("billing-status-sub");
|
||||
if (sub2) sub2.textContent =
|
||||
(st.total.requests || 0) + " requests" + (parts.length ? " · top: " + parts.join(" · ") : "");
|
||||
} catch (e) {
|
||||
// Same reasoning as the Billing page: decoration must never break the
|
||||
|
||||
Reference in New Issue
Block a user