@dijkspicy/opencode-stats-reporter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +198 -0
- package/README.md +135 -0
- package/bin/opencode-stats-reporter.js +7 -0
- package/contrib/opencode-session-reporter/README.md +92 -0
- package/contrib/opencode-session-reporter/package.json +16 -0
- package/contrib/opencode-session-reporter/plugin-server.ts +14 -0
- package/contrib/opencode-session-reporter/reporter.test.ts +378 -0
- package/contrib/opencode-session-reporter/session-usage-reporter.ts +610 -0
- package/package.json +30 -0
- package/panel/config.example.js +35 -0
- package/panel/css/style.css +615 -0
- package/panel/index.html +398 -0
- package/panel/js/api.js +154 -0
- package/panel/js/app.js +1167 -0
- package/panel/js/charts.js +241 -0
- package/panel/js/config.js +125 -0
- package/panel/js/format.js +156 -0
- package/panel/js/sessions.js +412 -0
- package/src/cli.js +164 -0
- package/src/config.js +57 -0
- package/src/identity.js +34 -0
- package/src/paths.js +37 -0
- package/src/proxy.js +31 -0
- package/src/server.js +164 -0
- package/src/session-api.js +97 -0
- package/src/store.js +246 -0
package/panel/js/app.js
ADDED
|
@@ -0,0 +1,1167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 视图切换、数据编排、手动刷新
|
|
3
|
+
* 视图:#view-onboarding(引导)/ #view-panel(面板,内含 用量/会话 两个分页视图,见 applyPanelView)
|
|
4
|
+
* token 口径:req_tokens 含缓存命中(OpenAI 规范),展示拆分为
|
|
5
|
+
* 输入(缓存未命中)= req_tokens - cached_tokens、输入(缓存命中)、输出
|
|
6
|
+
* Credit 口径:逐日分段 —— 每日 Credit = 当日总 Tokens × 当日生效系数 ÷ 百万,
|
|
7
|
+
* 系数版本历史来自 /v1/models 的 credit_history([from, 下一条 from) 区间生效);
|
|
8
|
+
* 无 daily 明细(旧后端)或无历史(旧目录)时回退 总量 × 当前系数
|
|
9
|
+
*/
|
|
10
|
+
(function () {
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
var C = window.BRU.config;
|
|
14
|
+
var F = window.BRU.format;
|
|
15
|
+
var API = window.BRU.api;
|
|
16
|
+
var CH = window.BRU.charts;
|
|
17
|
+
|
|
18
|
+
// Promise.allSettled 兜底(ES2020 特性,老内核降级为等价实现)
|
|
19
|
+
// 注意: 原生方法必须保持 this=Promise 调用,不能直接解构引用
|
|
20
|
+
var allSettled = Promise.allSettled
|
|
21
|
+
? function (arr) { return Promise.allSettled(arr); }
|
|
22
|
+
: function (arr) {
|
|
23
|
+
return Promise.all(arr.map(function (p) {
|
|
24
|
+
return p.then(
|
|
25
|
+
function (v) { return { status: 'fulfilled', value: v }; },
|
|
26
|
+
function (e) { return { status: 'rejected', reason: e }; }
|
|
27
|
+
);
|
|
28
|
+
}));
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// 周期语义:今日/本周/本月均为日历对齐(后端按 UTC+8 切分)。
|
|
32
|
+
// KPI 用 summary(window)(含环比 compare);daily 供趋势图(本周=周一至今、本月=1日至今);
|
|
33
|
+
// performance 的 hours = 周期起点至今的小时数(向上取整);今日额外拉 hourly 分时
|
|
34
|
+
function periodParams(key) {
|
|
35
|
+
var now = new Date();
|
|
36
|
+
if (key === 'week') {
|
|
37
|
+
var wd = (now.getDay() + 6) % 7; // 周一=0 … 周日=6
|
|
38
|
+
return { window: 'week', days: wd + 1, hours: wd * 24 + now.getHours() + 1 };
|
|
39
|
+
}
|
|
40
|
+
if (key === 'month') {
|
|
41
|
+
var dom = now.getDate();
|
|
42
|
+
return { window: 'month', days: dom, hours: (dom - 1) * 24 + now.getHours() + 1 };
|
|
43
|
+
}
|
|
44
|
+
return { window: 'today', days: null, hours: 24 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 后端 compare.window -> 展示文案
|
|
48
|
+
var COMPARE_LABELS = { yesterday: '较昨日', prev_week: '较上周', prev_month: '较上月' };
|
|
49
|
+
|
|
50
|
+
var state = {
|
|
51
|
+
cfg: null, // 生效配置
|
|
52
|
+
consumer: '', // 网关注入的消费者 ID(/v1/models 回显)
|
|
53
|
+
modelCatalog: [], // 网关可用模型列表 [{id, credit, creditHistory}]
|
|
54
|
+
creditMap: {}, // 模型 id -> 当前生效 Credit 系数(null = 未提供,展示降级 --)
|
|
55
|
+
creditHistMap: {}, // 模型 id -> [{from, credit}] 系数版本历史(按生效日升序)
|
|
56
|
+
period: 'today',
|
|
57
|
+
panelView: 'usage', // 面板内当前分页视图:usage(用量)/ sessions(会话)
|
|
58
|
+
cooldownTimer: null,
|
|
59
|
+
cooldownLeft: 0,
|
|
60
|
+
autoTimer: null, // 每 5min 自动刷新定时器
|
|
61
|
+
inPanel: false, // 是否处于面板视图(自动刷新仅在面板生效)
|
|
62
|
+
consecutiveFails: 0, // 连续失败次数
|
|
63
|
+
inFlight: false,
|
|
64
|
+
tableRows: [], // 明细表当前数据
|
|
65
|
+
tableSort: { key: 'total_tokens', dir: -1 },
|
|
66
|
+
cache: { trend: null, hourly: null, model: null, provider: null, byModelMonth: null } // 主题切换重建图表用
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// ---------- DOM 工具 ----------
|
|
70
|
+
function $(id) { return document.getElementById(id); }
|
|
71
|
+
function show(el) { el.classList.remove('hidden'); }
|
|
72
|
+
function hide(el) { el.classList.add('hidden'); }
|
|
73
|
+
|
|
74
|
+
/** 卡片状态层:loading / empty / error / ready */
|
|
75
|
+
function setCardState(cardEl, st, msg, retryFn) {
|
|
76
|
+
var layer = cardEl.querySelector('.card-state');
|
|
77
|
+
if (!layer) return;
|
|
78
|
+
layer.className = 'card-state state-' + st;
|
|
79
|
+
if (st === 'ready') { layer.innerHTML = ''; hide(layer); return; }
|
|
80
|
+
show(layer);
|
|
81
|
+
if (st === 'loading') {
|
|
82
|
+
layer.innerHTML = '<div class="skeleton"></div><div class="skeleton sk-60"></div><div class="skeleton sk-40"></div>';
|
|
83
|
+
} else if (st === 'empty') {
|
|
84
|
+
layer.innerHTML = '<div class="state-text">该周期暂无调用记录</div>';
|
|
85
|
+
} else if (st === 'error') {
|
|
86
|
+
layer.innerHTML = '';
|
|
87
|
+
var div = document.createElement('div');
|
|
88
|
+
div.className = 'state-text state-error-text';
|
|
89
|
+
div.textContent = msg || '加载失败';
|
|
90
|
+
var btn = document.createElement('button');
|
|
91
|
+
btn.className = 'btn btn-sm';
|
|
92
|
+
btn.textContent = '重试';
|
|
93
|
+
btn.addEventListener('click', retryFn || loadAll);
|
|
94
|
+
layer.appendChild(div);
|
|
95
|
+
layer.appendChild(btn);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------- 主题 ----------
|
|
100
|
+
var mediaDark = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
|
|
101
|
+
|
|
102
|
+
function resolveTheme() {
|
|
103
|
+
var t = (state.cfg && state.cfg.theme) || 'auto';
|
|
104
|
+
if (t === 'auto') return (mediaDark && mediaDark.matches) ? 'dark' : 'light';
|
|
105
|
+
return t;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function applyTheme(rebuild) {
|
|
109
|
+
document.documentElement.setAttribute('data-theme', resolveTheme());
|
|
110
|
+
// 同步主题切换按钮高亮
|
|
111
|
+
var cur = (state.cfg && state.cfg.theme) || 'auto';
|
|
112
|
+
['light', 'dark', 'auto'].forEach(function (t) {
|
|
113
|
+
var b = $('theme-' + t);
|
|
114
|
+
if (b) b.classList.toggle('active', t === cur);
|
|
115
|
+
});
|
|
116
|
+
if (rebuild) {
|
|
117
|
+
// 主题变化:销毁重建图表(ECharts 主题只能 init 时指定)
|
|
118
|
+
CH.disposeAll();
|
|
119
|
+
renderCachedCharts();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (mediaDark && mediaDark.addEventListener) {
|
|
124
|
+
mediaDark.addEventListener('change', function () {
|
|
125
|
+
if ((state.cfg && state.cfg.theme || 'auto') === 'auto') applyTheme(true);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function setTheme(t) {
|
|
130
|
+
state.cfg.theme = t;
|
|
131
|
+
applyTheme(true);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------- 视图切换 ----------
|
|
135
|
+
function switchView(name) {
|
|
136
|
+
state.inPanel = (name === 'panel');
|
|
137
|
+
if (name === 'panel') { hide($('view-onboarding')); show($('view-panel')); }
|
|
138
|
+
else { hide($('view-panel')); show($('view-onboarding')); }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 401:整页退回引导视图并红字提示 */
|
|
142
|
+
function backToOnboarding(msg) {
|
|
143
|
+
switchView('onboarding');
|
|
144
|
+
stopAutoRefresh();
|
|
145
|
+
var err = $('onboard-error');
|
|
146
|
+
err.textContent = msg;
|
|
147
|
+
show(err);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------- 面板内视图切换(用量 / 会话) ----------
|
|
151
|
+
/** 仅切换 DOM 与状态,不触发加载(enterPanel 复位时用) */
|
|
152
|
+
function applyPanelView(name) {
|
|
153
|
+
state.panelView = name;
|
|
154
|
+
Array.prototype.forEach.call(document.querySelectorAll('#view-seg button'), function (b) {
|
|
155
|
+
b.classList.toggle('active', b.getAttribute('data-view') === name);
|
|
156
|
+
});
|
|
157
|
+
if (name === 'sessions') { hide($('view-usage')); show($('view-sessions')); }
|
|
158
|
+
else { show($('view-usage')); hide($('view-sessions')); }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 切换分页视图并刷新其数据(与周期切换一致:切到哪刷到哪) */
|
|
162
|
+
function switchPanelView(name) {
|
|
163
|
+
if (name === state.panelView) return;
|
|
164
|
+
applyPanelView(name);
|
|
165
|
+
refreshActive();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 刷新当前激活视图:手动刷新 / 自动刷新 / 周期切换共用 */
|
|
169
|
+
function refreshActive() {
|
|
170
|
+
if (state.panelView === 'sessions' && window.BRU.sessions) window.BRU.sessions.load();
|
|
171
|
+
else loadAll();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------- KPI 渲染 ----------
|
|
175
|
+
/** 环比徽章:升红降绿(A 股风格),无基准时留空 */
|
|
176
|
+
function setDelta(id, change, label) {
|
|
177
|
+
var el = $(id);
|
|
178
|
+
if (!change) { el.textContent = ''; el.className = 'kpi-delta'; return; }
|
|
179
|
+
var arrow = change.dir > 0 ? '▲' : (change.dir < 0 ? '▼' : '—');
|
|
180
|
+
el.textContent = arrow + ' ' + change.text + ' ' + label;
|
|
181
|
+
el.className = 'kpi-delta ' + (change.dir > 0 ? 'delta-up' : (change.dir < 0 ? 'delta-down' : 'delta-flat'));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderKpis(kpi) {
|
|
185
|
+
$('kpi-requests').textContent = F.fullNumber(kpi.requestCount);
|
|
186
|
+
$('kpi-total-tokens').textContent = F.abbrev(kpi.totalTokens, state.cfg.numberStyle);
|
|
187
|
+
$('kpi-uncached-tokens').textContent = F.abbrev(kpi.uncachedTokens, state.cfg.numberStyle);
|
|
188
|
+
$('kpi-cached-tokens').textContent = F.abbrev(kpi.cachedTokens, state.cfg.numberStyle);
|
|
189
|
+
$('kpi-rsp-tokens').textContent = F.abbrev(kpi.rspTokens, state.cfg.numberStyle);
|
|
190
|
+
$('kpi-cache-rate').textContent = kpi.cacheRateText;
|
|
191
|
+
$('kpi-ttft').textContent = F.latency(kpi.ttftMs);
|
|
192
|
+
// 悬停显示完整值
|
|
193
|
+
$('kpi-total-tokens').title = F.fullNumber(kpi.totalTokens);
|
|
194
|
+
$('kpi-uncached-tokens').title = F.fullNumber(kpi.uncachedTokens);
|
|
195
|
+
$('kpi-cached-tokens').title = F.fullNumber(kpi.cachedTokens);
|
|
196
|
+
$('kpi-rsp-tokens').title = F.fullNumber(kpi.rspTokens);
|
|
197
|
+
// 环比徽章(基准 = 上一等长周期)
|
|
198
|
+
var cmp = kpi.compare, label = kpi.compareLabel;
|
|
199
|
+
setDelta('kpi-requests-delta', cmp && F.pctChange(kpi.requestCount, cmp.request_count), label);
|
|
200
|
+
setDelta('kpi-total-delta', cmp && F.pctChange(kpi.totalTokens, (cmp.req_tokens || 0) + (cmp.rsp_tokens || 0)), label);
|
|
201
|
+
setDelta('kpi-uncached-delta', cmp && F.pctChange(kpi.uncachedTokens, Math.max(cmp.req_tokens - cmp.cached_tokens, 0)), label);
|
|
202
|
+
setDelta('kpi-cached-delta', cmp && F.pctChange(kpi.cachedTokens, cmp.cached_tokens), label);
|
|
203
|
+
setDelta('kpi-rsp-delta', cmp && F.pctChange(kpi.rspTokens, cmp.rsp_tokens), label);
|
|
204
|
+
setDelta('kpi-cache-rate-delta',
|
|
205
|
+
cmp && cmp.req_tokens > 0 && kpi.cacheHitRate !== null
|
|
206
|
+
? F.ppChange(kpi.cacheHitRate, cmp.cached_tokens / cmp.req_tokens) : null, label);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 某天 (YYYY-MM-DD) 生效的系数:版本历史最后一条 from <= 当天;无历史回退当前系数 */
|
|
210
|
+
function creditOn(modelId, dateStr) {
|
|
211
|
+
var hist = state.creditHistMap[modelId];
|
|
212
|
+
if (hist && hist.length && dateStr) {
|
|
213
|
+
var v = null; // 日期早于首条历史(如 08-01 前)= 无生效系数,当天不计
|
|
214
|
+
for (var i = 0; i < hist.length; i++) {
|
|
215
|
+
if (hist[i].from <= dateStr) v = hist[i].credit; else break;
|
|
216
|
+
}
|
|
217
|
+
return v;
|
|
218
|
+
}
|
|
219
|
+
var cur = state.creditMap[modelId];
|
|
220
|
+
return (cur === null || cur === undefined) ? null : cur;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 单行 by-model 的 Credit 估算用量:
|
|
225
|
+
* 有 daily 逐日明细 → Σ 当日总Tokens × 当日生效系数 ÷ 百万(跨系数变更正确分段);
|
|
226
|
+
* 无 daily(旧后端)→ 回退 窗口总量 × 当前系数 ÷ 百万;无系数返回 null(展示 --)
|
|
227
|
+
*/
|
|
228
|
+
function modelCreditUsage(r) {
|
|
229
|
+
var i, c, sum = 0, has = false;
|
|
230
|
+
if (r.daily && r.daily.length) {
|
|
231
|
+
for (i = 0; i < r.daily.length; i++) {
|
|
232
|
+
c = creditOn(r.model, r.daily[i].date);
|
|
233
|
+
if (c !== null) { has = true; sum += (r.daily[i].total_tokens || 0) * c / 1e6; }
|
|
234
|
+
}
|
|
235
|
+
return has ? sum : null;
|
|
236
|
+
}
|
|
237
|
+
c = creditOn(r.model, null);
|
|
238
|
+
return c !== null ? (r.total_tokens || 0) * c / 1e6 : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 列表内 ∑ 各模型逐日分段 Credit 估算用量;无任何有效系数返回 null(展示 --) */
|
|
242
|
+
function sumCredit(list) {
|
|
243
|
+
var sum = 0, has = false;
|
|
244
|
+
(list || []).forEach(function (r) {
|
|
245
|
+
var v = modelCreditUsage(r);
|
|
246
|
+
if (v !== null) { has = true; sum += v; }
|
|
247
|
+
});
|
|
248
|
+
return has ? sum : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 归一化限额配置:空/非法/负数 -> null(未配置) */
|
|
252
|
+
function toNum(v) {
|
|
253
|
+
if (v === undefined || v === null || v === '') return null;
|
|
254
|
+
var n = Number(v);
|
|
255
|
+
return (!isFinite(n) || n < 0) ? null : n;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 限额紧凑显示:整数不带小数点,其余最多 2 位小数(如 500 -> "500"、0.5 -> "0.5") */
|
|
259
|
+
function compactCredit(v) {
|
|
260
|
+
var n = Number(v);
|
|
261
|
+
if (!isFinite(n)) return '';
|
|
262
|
+
return n.toFixed(2).replace(/\.?0+$/, '');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
var LIMIT_LABELS = { today: '每日限额', week: '每周限额', month: '每月限额' };
|
|
266
|
+
var LIMIT_KEYS = { today: 'dailyCreditLimit', week: 'weeklyCreditLimit', month: 'monthlyCreditLimit' };
|
|
267
|
+
|
|
268
|
+
/** 当前分页对应的 Credit 限额(未配置返回 null) */
|
|
269
|
+
function periodLimit() {
|
|
270
|
+
return toNum((state.cfg || {})[LIMIT_KEYS[state.period]]);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Credit 估算用量卡:Σ 各模型逐日分段用量;目录未提供系数时降级 -- */
|
|
274
|
+
function renderCreditKpi(list) {
|
|
275
|
+
var sum = sumCredit(list);
|
|
276
|
+
var el = $('kpi-credit');
|
|
277
|
+
var valText = sum === null ? '--' : F.credit(sum);
|
|
278
|
+
var limit = periodLimit();
|
|
279
|
+
if (sum !== null && limit !== null) {
|
|
280
|
+
el.innerHTML = valText + '<span class="kpi-credit-limit">/' + compactCredit(limit) + '</span>';
|
|
281
|
+
} else {
|
|
282
|
+
el.textContent = valText;
|
|
283
|
+
}
|
|
284
|
+
el.title = sum === null ? '模型目录未返回 Credit 系数' : 'Σ 逐日(模型总Tokens × 当日生效 Credit 系数)÷ 1,000,000(估算)';
|
|
285
|
+
renderCreditRemain(sum);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Credit 卡剩余额度子行:按分页匹配 每日/每周/每月限额,未超过绿、超过红 */
|
|
289
|
+
function renderCreditRemain(used) {
|
|
290
|
+
var box = $('kpi-credit-remain');
|
|
291
|
+
if (!box) return;
|
|
292
|
+
var limit = periodLimit();
|
|
293
|
+
var label = LIMIT_LABELS[state.period] || '限额';
|
|
294
|
+
box.className = 'kpi-remain';
|
|
295
|
+
if (limit === null || used === null) { box.textContent = ''; return; }
|
|
296
|
+
var diff = limit - used;
|
|
297
|
+
if (diff >= 0) {
|
|
298
|
+
box.textContent = '离' + label + '还差 ' + F.credit(diff);
|
|
299
|
+
box.className = 'kpi-remain kpi-remain-ok';
|
|
300
|
+
} else {
|
|
301
|
+
box.textContent = '已超出' + label + ' ' + F.credit(-diff);
|
|
302
|
+
box.className = 'kpi-remain kpi-remain-over';
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 本月用量(进度条用):空列表视为 0;有记录但系数缺失返回 null */
|
|
307
|
+
function monthCreditOrZero(list) {
|
|
308
|
+
list = list || [];
|
|
309
|
+
if (!list.length) return 0;
|
|
310
|
+
return sumCredit(list);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* 本月 Credit 进度条(月均 pacing,独立于分页):
|
|
315
|
+
* 月均累计可用 = 每月限额 ÷ 本月实际天数 × 本月第几天;
|
|
316
|
+
* 进度 = 本月实际用量 ÷ 月均累计可用;未超淡黄、超出橙色闪烁。
|
|
317
|
+
*/
|
|
318
|
+
function renderQuotaBar(monthList) {
|
|
319
|
+
var monthly = toNum((state.cfg || {}).monthlyCreditLimit);
|
|
320
|
+
var used = monthCreditOrZero(monthList);
|
|
321
|
+
var meta = $('quota-meta'), note = $('quota-note'), fill = $('quota-fill');
|
|
322
|
+
var hint = $('quota-hint');
|
|
323
|
+
if (!meta || !fill) return;
|
|
324
|
+
|
|
325
|
+
if (monthly === null) {
|
|
326
|
+
if (hint) show(hint);
|
|
327
|
+
meta.textContent = '尚未配置每月 Credit 限额';
|
|
328
|
+
note.textContent = '';
|
|
329
|
+
note.className = 'quota-note';
|
|
330
|
+
fill.style.width = '0';
|
|
331
|
+
fill.className = 'quota-fill';
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (hint) hide(hint);
|
|
335
|
+
|
|
336
|
+
if (used === null) {
|
|
337
|
+
meta.textContent = '本月有调用记录,但模型目录未返回 Credit 系数,无法估算本月用量';
|
|
338
|
+
note.textContent = '';
|
|
339
|
+
note.className = 'quota-note';
|
|
340
|
+
fill.style.width = '0';
|
|
341
|
+
fill.className = 'quota-fill';
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
var now = new Date();
|
|
346
|
+
var day = now.getDate();
|
|
347
|
+
var dim = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
348
|
+
var expected = monthly * day / dim;
|
|
349
|
+
var pct = expected > 0 ? used / expected * 100 : 0;
|
|
350
|
+
var over = used > expected;
|
|
351
|
+
|
|
352
|
+
fill.style.width = Math.min(pct, 100) + '%';
|
|
353
|
+
fill.className = 'quota-fill' + (over ? ' over' : '');
|
|
354
|
+
meta.textContent = '本月日均可用累计额 ' + F.credit(expected) + ' Credit(月限额 ' + F.credit(monthly) +
|
|
355
|
+
'/' + dim + '天 * 本月第 ' + day + ' 天)';
|
|
356
|
+
if (over) {
|
|
357
|
+
note.textContent = '已用 ' + F.credit(used) + ' Credit · 已超出日均累计值 ' + (pct - 100).toFixed(1) + '%';
|
|
358
|
+
note.className = 'quota-note quota-note-over';
|
|
359
|
+
} else {
|
|
360
|
+
note.textContent = '已用 ' + F.credit(used) + ' Credit · 已消费日均累计值的 ' + pct.toFixed(1) + '%';
|
|
361
|
+
note.className = 'quota-note quota-note-ok';
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 将三类限额写回 state.cfg 并持久化到 localStorage(不覆盖 gatewayBaseUrl/apiKey),随后重渲染 */
|
|
366
|
+
function applyLimits(daily, weekly, monthly) {
|
|
367
|
+
var cfg = Object.assign({}, state.cfg, {
|
|
368
|
+
dailyCreditLimit: toNum(daily),
|
|
369
|
+
weeklyCreditLimit: toNum(weekly),
|
|
370
|
+
monthlyCreditLimit: toNum(monthly)
|
|
371
|
+
});
|
|
372
|
+
state.cfg = cfg;
|
|
373
|
+
var stored = C.readLocal() || {};
|
|
374
|
+
C.saveLocal(Object.assign({}, stored, {
|
|
375
|
+
dailyCreditLimit: cfg.dailyCreditLimit,
|
|
376
|
+
weeklyCreditLimit: cfg.weeklyCreditLimit,
|
|
377
|
+
monthlyCreditLimit: cfg.monthlyCreditLimit
|
|
378
|
+
}));
|
|
379
|
+
if (state.cache.model) renderCreditKpi(state.cache.model);
|
|
380
|
+
if (state.cache.byModelMonth) renderQuotaBar(state.cache.byModelMonth);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** 进度条卡片的「配置限额」小菜单:展开三段输入并写入配置 */
|
|
384
|
+
function initQuotaCard() {
|
|
385
|
+
var btn = $('btn-quota-config'), form = $('quota-form'), msg = $('q-msg');
|
|
386
|
+
function closeForm() {
|
|
387
|
+
hide(form);
|
|
388
|
+
btn.textContent = '配置限额';
|
|
389
|
+
if (msg) hide(msg);
|
|
390
|
+
}
|
|
391
|
+
btn.addEventListener('click', function () {
|
|
392
|
+
if (form.classList.contains('hidden')) {
|
|
393
|
+
var cfg = state.cfg || {};
|
|
394
|
+
$('q-daily-limit').value = cfg.dailyCreditLimit == null ? '' : cfg.dailyCreditLimit;
|
|
395
|
+
$('q-weekly-limit').value = cfg.weeklyCreditLimit == null ? '' : cfg.weeklyCreditLimit;
|
|
396
|
+
$('q-monthly-limit').value = cfg.monthlyCreditLimit == null ? '' : cfg.monthlyCreditLimit;
|
|
397
|
+
if (msg) hide(msg);
|
|
398
|
+
show(form);
|
|
399
|
+
btn.textContent = '收起';
|
|
400
|
+
} else {
|
|
401
|
+
closeForm();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
$('q-cancel').addEventListener('click', closeForm);
|
|
405
|
+
$('q-save').addEventListener('click', function () {
|
|
406
|
+
var daily = numField($('q-daily-limit'));
|
|
407
|
+
var weekly = numField($('q-weekly-limit'));
|
|
408
|
+
var monthly = numField($('q-monthly-limit'));
|
|
409
|
+
var v = C.validate(Object.assign({}, state.cfg, {
|
|
410
|
+
dailyCreditLimit: daily, weeklyCreditLimit: weekly, monthlyCreditLimit: monthly
|
|
411
|
+
}));
|
|
412
|
+
if (!v.ok) {
|
|
413
|
+
if (msg) { msg.textContent = v.errors[0]; show(msg); }
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
applyLimits(daily, weekly, monthly);
|
|
417
|
+
closeForm();
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
var KPI_IDS = ['kpi-requests', 'kpi-total-tokens', 'kpi-uncached-tokens', 'kpi-cached-tokens', 'kpi-rsp-tokens', 'kpi-credit', 'kpi-cache-rate', 'kpi-ttft'];
|
|
422
|
+
var KPI_DELTA_IDS = ['kpi-requests-delta', 'kpi-total-delta', 'kpi-uncached-delta', 'kpi-cached-delta', 'kpi-rsp-delta', 'kpi-cache-rate-delta'];
|
|
423
|
+
|
|
424
|
+
function setKpiState(st, msg) {
|
|
425
|
+
KPI_IDS.forEach(function (id) {
|
|
426
|
+
var el = $(id);
|
|
427
|
+
if (st === 'loading') { el.textContent = '…'; el.classList.add('kpi-loading'); }
|
|
428
|
+
else if (st === 'error') { el.textContent = '--'; el.classList.remove('kpi-loading'); }
|
|
429
|
+
else el.classList.remove('kpi-loading');
|
|
430
|
+
});
|
|
431
|
+
if (st !== 'ready') {
|
|
432
|
+
KPI_DELTA_IDS.forEach(function (id) { $(id).textContent = ''; $(id).className = 'kpi-delta'; });
|
|
433
|
+
var remain = $('kpi-credit-remain');
|
|
434
|
+
if (remain) { remain.textContent = ''; remain.className = 'kpi-remain'; }
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ---------- 数据编排 ----------
|
|
439
|
+
/** 统一取数:summary(window) 供 KPI+环比;by-*(window);performance;daily 供趋势;今日加 hourly 分时;非本月额外取 byModel(month) 供月进度条 */
|
|
440
|
+
function fetchAll(cfg) {
|
|
441
|
+
var p = periodParams(state.period);
|
|
442
|
+
var jobs = {
|
|
443
|
+
summary: API.summary(cfg, p.window),
|
|
444
|
+
byModel: API.byModel(cfg, p.window),
|
|
445
|
+
byProvider: API.byProvider(cfg, p.window),
|
|
446
|
+
perf: API.performance(cfg, p.hours, 'model'),
|
|
447
|
+
daily: API.daily(cfg, p.days || cfg.trendDays || 14)
|
|
448
|
+
};
|
|
449
|
+
if (state.period === 'today') jobs.hourly = API.hourly(cfg);
|
|
450
|
+
if (state.period !== 'month') jobs.byModelMonth = API.byModel(cfg, 'month');
|
|
451
|
+
var names = Object.keys(jobs);
|
|
452
|
+
return allSettled(names.map(function (n) { return jobs[n]; })).then(function (r) {
|
|
453
|
+
var out = {};
|
|
454
|
+
names.forEach(function (n, i) { out[n] = r[i]; });
|
|
455
|
+
return out;
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** performance 数据中取第一个模型的 ttft_avg_ms 作为整体均值(接口已按窗口聚合) */
|
|
460
|
+
function extractTtft(perfData) {
|
|
461
|
+
if (!perfData || !perfData.data || !perfData.data.length) return null;
|
|
462
|
+
// 样本数加权平均
|
|
463
|
+
var sumW = 0, sum = 0;
|
|
464
|
+
perfData.data.forEach(function (d) {
|
|
465
|
+
var w = d.samples_min || 1;
|
|
466
|
+
sum += (d.ttft_avg_ms || 0) * w;
|
|
467
|
+
sumW += w;
|
|
468
|
+
});
|
|
469
|
+
return sumW ? sum / sumW : null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function loadAll() {
|
|
473
|
+
if (state.inFlight) return Promise.resolve();
|
|
474
|
+
state.inFlight = true;
|
|
475
|
+
var isToday = state.period === 'today';
|
|
476
|
+
setKpiState('loading');
|
|
477
|
+
setCardState($('card-trend'), 'loading');
|
|
478
|
+
setCardState($('card-model'), 'loading');
|
|
479
|
+
setCardState($('card-provider'), 'loading');
|
|
480
|
+
setCardState($('card-table'), 'loading');
|
|
481
|
+
var chartsGrid = document.querySelector('.charts-grid');
|
|
482
|
+
if (isToday) { chartsGrid.classList.remove('no-hourly'); show($('card-hourly')); setCardState($('card-hourly'), 'loading'); }
|
|
483
|
+
else { chartsGrid.classList.add('no-hourly'); hide($('card-hourly')); }
|
|
484
|
+
|
|
485
|
+
return fetchAll(state.cfg).then(function (res) {
|
|
486
|
+
state.inFlight = false;
|
|
487
|
+
|
|
488
|
+
// 任一接口 401 → 整页退回引导
|
|
489
|
+
var results = [res.summary, res.daily, res.byModel, res.byProvider, res.perf, res.hourly, res.byModelMonth].filter(Boolean);
|
|
490
|
+
var unauthorized = results.some(function (r) {
|
|
491
|
+
return r.status === 'rejected' && r.reason && r.reason.code === 'unauthorized';
|
|
492
|
+
});
|
|
493
|
+
if (unauthorized) {
|
|
494
|
+
backToOnboarding('API Key 无效或已过期,请重新输入');
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
var anyFail = false;
|
|
499
|
+
|
|
500
|
+
// --- KPI(全周期统一走 summary,自带环比 compare) ---
|
|
501
|
+
if (res.summary.status === 'fulfilled') {
|
|
502
|
+
var v = res.summary.value;
|
|
503
|
+
var d = v.data;
|
|
504
|
+
var cached = d.cached_tokens || 0;
|
|
505
|
+
renderKpis({
|
|
506
|
+
requestCount: d.request_count,
|
|
507
|
+
totalTokens: (d.req_tokens || 0) + (d.rsp_tokens || 0),
|
|
508
|
+
uncachedTokens: Math.max((d.req_tokens || 0) - cached, 0),
|
|
509
|
+
cachedTokens: cached,
|
|
510
|
+
rspTokens: d.rsp_tokens,
|
|
511
|
+
cacheHitRate: d.cache_hit_rate,
|
|
512
|
+
cacheRateText: F.hitRate(d.cache_hit_rate),
|
|
513
|
+
ttftMs: res.perf.status === 'fulfilled' ? extractTtft(res.perf.value) : null,
|
|
514
|
+
compare: v.compare || null,
|
|
515
|
+
compareLabel: COMPARE_LABELS[(v.compare || {}).window] || '环比'
|
|
516
|
+
});
|
|
517
|
+
setKpiState('ready');
|
|
518
|
+
$('last-updated').textContent = '更新于 ' + F.timeOfDay(v.as_of);
|
|
519
|
+
} else {
|
|
520
|
+
anyFail = true;
|
|
521
|
+
setKpiState('error', res.summary.reason && res.summary.reason.message);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// --- 每日趋势 ---
|
|
525
|
+
handleTrend(res.daily);
|
|
526
|
+
|
|
527
|
+
// --- 今日分时 ---
|
|
528
|
+
if (isToday) handleHourly(res.hourly);
|
|
529
|
+
|
|
530
|
+
// --- 模型分布 / 明细表(共用 by-model) ---
|
|
531
|
+
if (res.byModel.status === 'fulfilled') {
|
|
532
|
+
var list = res.byModel.value.data || [];
|
|
533
|
+
state.cache.model = list;
|
|
534
|
+
state.tableRows = list;
|
|
535
|
+
renderModelChart(list);
|
|
536
|
+
renderTable(list);
|
|
537
|
+
renderCreditKpi(list);
|
|
538
|
+
} else {
|
|
539
|
+
anyFail = true;
|
|
540
|
+
setCardState($('card-model'), 'error', res.byModel.reason && res.byModel.reason.message);
|
|
541
|
+
setCardState($('card-table'), 'error', res.byModel.reason && res.byModel.reason.message);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// --- 供应商分布 ---
|
|
545
|
+
if (res.byProvider.status === 'fulfilled') {
|
|
546
|
+
var plist = res.byProvider.value.data || [];
|
|
547
|
+
state.cache.provider = plist;
|
|
548
|
+
renderProviderChart(plist);
|
|
549
|
+
} else {
|
|
550
|
+
anyFail = true;
|
|
551
|
+
setCardState($('card-provider'), 'error', res.byProvider.reason && res.byProvider.reason.message);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// --- 本月累计 Credit 进度条(月均 pacing,独立于分页) ---
|
|
555
|
+
if (state.period === 'month') {
|
|
556
|
+
state.cache.byModelMonth = res.byModel.status === 'fulfilled' ? (res.byModel.value.data || []) : null;
|
|
557
|
+
} else if (res.byModelMonth && res.byModelMonth.status === 'fulfilled') {
|
|
558
|
+
state.cache.byModelMonth = res.byModelMonth.value.data || [];
|
|
559
|
+
} else {
|
|
560
|
+
state.cache.byModelMonth = null;
|
|
561
|
+
}
|
|
562
|
+
renderQuotaBar(state.cache.byModelMonth);
|
|
563
|
+
|
|
564
|
+
// --- 失败 Header 提示 ---
|
|
565
|
+
if (anyFail) {
|
|
566
|
+
state.consecutiveFails++;
|
|
567
|
+
showHeaderNotice('部分数据加载失败,将在下次刷新时重试', 'warn');
|
|
568
|
+
} else {
|
|
569
|
+
state.consecutiveFails = 0;
|
|
570
|
+
hideHeaderNotice();
|
|
571
|
+
}
|
|
572
|
+
}).catch(function (err) {
|
|
573
|
+
// 编排层异常兜底
|
|
574
|
+
state.inFlight = false;
|
|
575
|
+
state.consecutiveFails++;
|
|
576
|
+
showHeaderNotice('刷新失败:' + ((err && err.message) || '未知错误'), 'warn');
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function handleTrend(dailyResult) {
|
|
581
|
+
if (dailyResult.status === 'fulfilled') {
|
|
582
|
+
var rows = dailyResult.value.data || [];
|
|
583
|
+
state.cache.trend = rows;
|
|
584
|
+
renderTrendChart(rows);
|
|
585
|
+
} else {
|
|
586
|
+
setCardState($('card-trend'), 'error', dailyResult.reason && dailyResult.reason.message);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function handleHourly(hourlyResult) {
|
|
591
|
+
if (hourlyResult && hourlyResult.status === 'fulfilled') {
|
|
592
|
+
var rows = hourlyResult.value.data || [];
|
|
593
|
+
state.cache.hourly = rows;
|
|
594
|
+
renderHourlyChart(rows);
|
|
595
|
+
} else {
|
|
596
|
+
setCardState($('card-hourly'), 'error', hourlyResult && hourlyResult.reason && hourlyResult.reason.message);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ---------- 图表渲染 ----------
|
|
601
|
+
function fmt() {
|
|
602
|
+
return {
|
|
603
|
+
abbrev: function (v) { return F.abbrev(v, state.cfg.numberStyle); },
|
|
604
|
+
fullNumber: F.fullNumber, shortDate: F.shortDate, hourLabel: F.hourLabel, hitRate: F.hitRate
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function renderTrendChart(rows) {
|
|
609
|
+
var card = $('card-trend');
|
|
610
|
+
if (!rows.length) { setCardState(card, 'empty'); return; }
|
|
611
|
+
setCardState(card, 'ready');
|
|
612
|
+
var chart = CH.init('trend', $('chart-trend'));
|
|
613
|
+
if (chart) chart.setOption(CH.trendOption(rows, fmt()));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function renderHourlyChart(rows) {
|
|
617
|
+
var card = $('card-hourly');
|
|
618
|
+
if (!rows.length) { setCardState(card, 'empty'); return; }
|
|
619
|
+
setCardState(card, 'ready');
|
|
620
|
+
var chart = CH.init('hourly', $('chart-hourly'));
|
|
621
|
+
if (chart) chart.setOption(CH.hourlyOption(rows, fmt()));
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function renderModelChart(list) {
|
|
625
|
+
var card = $('card-model');
|
|
626
|
+
if (!list.length) { setCardState(card, 'empty'); return; }
|
|
627
|
+
setCardState(card, 'ready');
|
|
628
|
+
var chart = CH.init('model', $('chart-model'));
|
|
629
|
+
if (chart) chart.setOption(CH.modelBarOption(list, fmt()));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function renderProviderChart(list) {
|
|
633
|
+
var card = $('card-provider');
|
|
634
|
+
if (!list.length) { setCardState(card, 'empty'); return; }
|
|
635
|
+
setCardState(card, 'ready');
|
|
636
|
+
var chart = CH.init('provider', $('chart-provider'));
|
|
637
|
+
if (chart) chart.setOption(CH.providerDonutOption(list, fmt()));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** 主题切换后用缓存数据重建图表 */
|
|
641
|
+
function renderCachedCharts() {
|
|
642
|
+
if (state.cache.trend) renderTrendChart(state.cache.trend);
|
|
643
|
+
if (state.cache.hourly && state.period === 'today') renderHourlyChart(state.cache.hourly);
|
|
644
|
+
if (state.cache.model) renderModelChart(state.cache.model);
|
|
645
|
+
if (state.cache.provider) renderProviderChart(state.cache.provider);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// ---------- 可用模型目录(/v1/models) ----------
|
|
649
|
+
/** 落库模型目录并构建 Credit 映射(models: [{id, credit, creditHistory}]) */
|
|
650
|
+
function setModelCatalog(models) {
|
|
651
|
+
state.modelCatalog = models;
|
|
652
|
+
var cur = {}, hist = {};
|
|
653
|
+
models.forEach(function (m) {
|
|
654
|
+
if (m && m.id) { cur[m.id] = m.credit; hist[m.id] = m.creditHistory || []; }
|
|
655
|
+
});
|
|
656
|
+
state.creditMap = cur;
|
|
657
|
+
state.creditHistMap = hist;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** 目录表系数单元格 tooltip:版本历史逐行 'YYYY-MM-DD 起 x.xx' */
|
|
661
|
+
function creditHistoryTip(m) {
|
|
662
|
+
var hist = m.creditHistory || [];
|
|
663
|
+
if (!hist.length) return '';
|
|
664
|
+
return 'Credit 系数生效历史:\n' + hist.map(function (e) {
|
|
665
|
+
return e.from + ' 起 ' + Number(e.credit).toFixed(2);
|
|
666
|
+
}).join('\n');
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** 目录表:三对"模型 | Credit 系数"列紧凑排布;系数缺失显示 --,悬停看版本历史 */
|
|
670
|
+
function renderModelCatalog(models) {
|
|
671
|
+
$('catalog-count').textContent = models.length ? '(' + models.length + ' 个)' : '';
|
|
672
|
+
var tbody = $('catalog-tbody');
|
|
673
|
+
tbody.innerHTML = '';
|
|
674
|
+
if (!models.length) {
|
|
675
|
+
var tr0 = document.createElement('tr');
|
|
676
|
+
var c0 = td('暂无可用模型');
|
|
677
|
+
c0.colSpan = 6;
|
|
678
|
+
tr0.appendChild(c0);
|
|
679
|
+
tbody.appendChild(tr0);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
var PER_ROW = 3;
|
|
683
|
+
for (var i = 0; i < models.length; i += PER_ROW) {
|
|
684
|
+
var tr = document.createElement('tr');
|
|
685
|
+
for (var j = 0; j < PER_ROW; j++) {
|
|
686
|
+
var m = models[i + j];
|
|
687
|
+
if (m) {
|
|
688
|
+
tr.appendChild(td(m.id, 'mono'));
|
|
689
|
+
var cell = td(m.credit === null || m.credit === undefined ? '--' : Number(m.credit).toFixed(2), 'num');
|
|
690
|
+
var tip = creditHistoryTip(m);
|
|
691
|
+
if (tip) cell.title = tip;
|
|
692
|
+
tr.appendChild(cell);
|
|
693
|
+
} else {
|
|
694
|
+
tr.appendChild(td(''));
|
|
695
|
+
tr.appendChild(td(''));
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
tbody.appendChild(tr);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ---------- 明细表 ----------
|
|
703
|
+
var SORT_KEYS = {
|
|
704
|
+
model: function (r) { return r.model; },
|
|
705
|
+
request_count: function (r) { return r.request_count; },
|
|
706
|
+
input_uncached: function (r) { return r._uncached; },
|
|
707
|
+
cached_tokens: function (r) { return r.cached_tokens; },
|
|
708
|
+
rsp_tokens: function (r) { return r.rsp_tokens; },
|
|
709
|
+
total_tokens: function (r) { return r.total_tokens; },
|
|
710
|
+
cache_hit_rate: function (r) { return r._cacheRate; },
|
|
711
|
+
credit: function (r) { return r._credit; },
|
|
712
|
+
share: function (r) { return r.share; }
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
function renderTable(list) {
|
|
716
|
+
var card = $('card-table');
|
|
717
|
+
if (!list.length) { setCardState(card, 'empty'); renderTbody([]); return; }
|
|
718
|
+
setCardState(card, 'ready');
|
|
719
|
+
// by-model 已返回 cached_tokens:拆 输入(未命中) = req - cached,命中率逐模型可算;
|
|
720
|
+
// Credit 按日分段:Σ 当日总Tokens × 当日生效系数 ÷ 1e6(无 daily/系数时降级,见 modelCreditUsage)
|
|
721
|
+
var rows = list.map(function (r) {
|
|
722
|
+
var c = Object.assign({}, r);
|
|
723
|
+
var cached = r.cached_tokens || 0;
|
|
724
|
+
c._uncached = Math.max((r.req_tokens || 0) - cached, 0);
|
|
725
|
+
c._cacheRate = r.req_tokens > 0 ? cached / r.req_tokens : null;
|
|
726
|
+
c._credit = modelCreditUsage(r);
|
|
727
|
+
return c;
|
|
728
|
+
});
|
|
729
|
+
state.tableRows = rows;
|
|
730
|
+
sortAndRender();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function sortAndRender() {
|
|
734
|
+
var key = state.tableSort.key, dir = state.tableSort.dir;
|
|
735
|
+
var getter = SORT_KEYS[key] || SORT_KEYS.total_tokens;
|
|
736
|
+
var rows = state.tableRows.slice().sort(function (a, b) {
|
|
737
|
+
var va = getter(a), vb = getter(b);
|
|
738
|
+
if (va === null || va === undefined) return 1;
|
|
739
|
+
if (vb === null || vb === undefined) return -1;
|
|
740
|
+
if (typeof va === 'string') return va.localeCompare(vb) * dir;
|
|
741
|
+
return (va - vb) * dir;
|
|
742
|
+
});
|
|
743
|
+
renderTbody(rows);
|
|
744
|
+
// 更新列头排序指示
|
|
745
|
+
var ths = document.querySelectorAll('#model-table th[data-key]');
|
|
746
|
+
Array.prototype.forEach.call(ths, function (th) {
|
|
747
|
+
th.classList.remove('sort-asc', 'sort-desc');
|
|
748
|
+
if (th.getAttribute('data-key') === key) {
|
|
749
|
+
th.classList.add(dir === 1 ? 'sort-asc' : 'sort-desc');
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function renderTbody(rows) {
|
|
755
|
+
var tbody = $('model-tbody');
|
|
756
|
+
tbody.innerHTML = '';
|
|
757
|
+
rows.forEach(function (r) {
|
|
758
|
+
var tr = document.createElement('tr');
|
|
759
|
+
tr.appendChild(td(r.model));
|
|
760
|
+
tr.appendChild(td(F.fullNumber(r.request_count), 'num'));
|
|
761
|
+
tr.appendChild(td(F.fullNumber(r._uncached), 'num'));
|
|
762
|
+
tr.appendChild(td(F.fullNumber(r.cached_tokens || 0), 'num'));
|
|
763
|
+
tr.appendChild(td(F.fullNumber(r.rsp_tokens), 'num'));
|
|
764
|
+
tr.appendChild(td(F.credit(r._credit), 'num'));
|
|
765
|
+
tr.appendChild(td(r._cacheRate === null ? '--' : F.hitRate(r._cacheRate), 'num'));
|
|
766
|
+
tr.appendChild(td(F.hitRate(r.share), 'num'));
|
|
767
|
+
tbody.appendChild(tr);
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function td(text, cls) {
|
|
772
|
+
var el = document.createElement('td');
|
|
773
|
+
el.textContent = text;
|
|
774
|
+
if (cls) el.className = cls;
|
|
775
|
+
return el;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ---------- CSV 导出 ----------
|
|
779
|
+
function exportCsv() {
|
|
780
|
+
if (!state.tableRows.length) return;
|
|
781
|
+
var header = ['模型', '请求数', '输入 Tokens(缓存未命中)', '输入 Tokens(缓存命中)', '输出 Tokens', 'Credit 用量(估算)', '缓存命中率', '占比'];
|
|
782
|
+
var key = state.tableSort.key, dir = state.tableSort.dir;
|
|
783
|
+
var getter = SORT_KEYS[key] || SORT_KEYS.total_tokens;
|
|
784
|
+
var rows = state.tableRows.slice().sort(function (a, b) {
|
|
785
|
+
var va = getter(a), vb = getter(b);
|
|
786
|
+
if (va === null || va === undefined) return 1;
|
|
787
|
+
if (vb === null || vb === undefined) return -1;
|
|
788
|
+
if (typeof va === 'string') return va.localeCompare(vb) * dir;
|
|
789
|
+
return (va - vb) * dir;
|
|
790
|
+
});
|
|
791
|
+
var lines = [header.join(',')];
|
|
792
|
+
rows.forEach(function (r) {
|
|
793
|
+
lines.push([
|
|
794
|
+
csvEscape(r.model),
|
|
795
|
+
r.request_count,
|
|
796
|
+
r._uncached,
|
|
797
|
+
r.cached_tokens || 0,
|
|
798
|
+
r.rsp_tokens,
|
|
799
|
+
r._credit === null ? '--' : r._credit.toFixed(4),
|
|
800
|
+
r._cacheRate === null ? '--' : F.hitRate(r._cacheRate),
|
|
801
|
+
F.hitRate(r.share)
|
|
802
|
+
].join(','));
|
|
803
|
+
});
|
|
804
|
+
// UTF-8 BOM 保证 Excel 打开中文不乱码
|
|
805
|
+
var blob = new Blob(['\ufeff' + lines.join('\r\n')], { type: 'text/csv;charset=utf-8' });
|
|
806
|
+
var a = document.createElement('a');
|
|
807
|
+
a.href = URL.createObjectURL(blob);
|
|
808
|
+
a.download = 'usage-models-' + state.period + '-' + F.todayStr() + '.csv';
|
|
809
|
+
document.body.appendChild(a);
|
|
810
|
+
a.click();
|
|
811
|
+
document.body.removeChild(a);
|
|
812
|
+
setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function csvEscape(s) {
|
|
816
|
+
s = String(s == null ? '' : s);
|
|
817
|
+
if (/[",\n]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
|
|
818
|
+
return s;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ---------- 自动刷新(每 5min) ----------
|
|
822
|
+
// 页面隐藏时暂停(避免后台空跑请求),恢复可见时立即补一次再续期;
|
|
823
|
+
// 与手动刷新共用 loadAll,其 inFlight 守卫保证不会并发重叠。
|
|
824
|
+
var AUTO_REFRESH_MS = 300000;
|
|
825
|
+
|
|
826
|
+
function startAutoRefresh() {
|
|
827
|
+
stopAutoRefresh();
|
|
828
|
+
state.autoTimer = setInterval(function () { refreshActive(); }, AUTO_REFRESH_MS);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function stopAutoRefresh() {
|
|
832
|
+
if (state.autoTimer) { clearInterval(state.autoTimer); state.autoTimer = null; }
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
document.addEventListener('visibilitychange', function () {
|
|
836
|
+
if (document.hidden) {
|
|
837
|
+
stopAutoRefresh();
|
|
838
|
+
} else if (state.inPanel && state.cfg) {
|
|
839
|
+
refreshActive();
|
|
840
|
+
startAutoRefresh();
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
// ---------- 手动刷新(10 秒冷却) ----------
|
|
845
|
+
function manualRefresh() {
|
|
846
|
+
if (state.cooldownLeft > 0) return;
|
|
847
|
+
refreshActive();
|
|
848
|
+
state.cooldownLeft = 10;
|
|
849
|
+
var btn = $('btn-refresh');
|
|
850
|
+
btn.disabled = true;
|
|
851
|
+
tickCooldown();
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function tickCooldown() {
|
|
855
|
+
var btn = $('btn-refresh');
|
|
856
|
+
if (state.cooldownLeft <= 0) {
|
|
857
|
+
btn.disabled = false;
|
|
858
|
+
btn.textContent = '刷新';
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
btn.textContent = '刷新(' + state.cooldownLeft + 's)';
|
|
862
|
+
state.cooldownLeft--;
|
|
863
|
+
state.cooldownTimer = setTimeout(tickCooldown, 1000);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ---------- Header 提示 ----------
|
|
867
|
+
function showHeaderNotice(msg, kind) {
|
|
868
|
+
var el = $('header-notice');
|
|
869
|
+
el.textContent = msg;
|
|
870
|
+
el.className = 'header-notice notice-' + (kind || 'info');
|
|
871
|
+
show(el);
|
|
872
|
+
}
|
|
873
|
+
function hideHeaderNotice() { hide($('header-notice')); }
|
|
874
|
+
|
|
875
|
+
// ---------- 连通徽标 ----------
|
|
876
|
+
/**
|
|
877
|
+
* 调稳定的 /v1/models 探测连通性:
|
|
878
|
+
* 成功 → 绿勾 + 消费者 ID,并落版"可用模型"目录卡;失败 → 红叉 + 未连接
|
|
879
|
+
*/
|
|
880
|
+
function refreshConnBadge() {
|
|
881
|
+
var badge = $('header-conn'), label = $('header-consumer');
|
|
882
|
+
if (!badge || !state.cfg) return;
|
|
883
|
+
badge.className = 'conn-badge conn-pending';
|
|
884
|
+
label.textContent = '连接中…';
|
|
885
|
+
badge.title = '正在调用 /v1/models 检测网关连通性…';
|
|
886
|
+
API.testConnection(state.cfg).then(function (r) {
|
|
887
|
+
if (r.ok) {
|
|
888
|
+
state.consumer = r.consumer;
|
|
889
|
+
setModelCatalog(r.models);
|
|
890
|
+
badge.className = 'conn-badge conn-ok';
|
|
891
|
+
label.textContent = r.consumer || '已连接';
|
|
892
|
+
badge.title = '已连接网关,鉴权通过\n消费者:' + (r.consumer || '-') +
|
|
893
|
+
'\n可用模型 ' + r.models.length + ' 个:' + r.modelIds.join('、');
|
|
894
|
+
renderModelCatalog(r.models);
|
|
895
|
+
// 目录载入后 Credit 系数才可用,补渲染用量卡与月进度条(消除与 by-model 的竞态)
|
|
896
|
+
if (state.cache.model) renderCreditKpi(state.cache.model);
|
|
897
|
+
if (state.cache.byModelMonth) renderQuotaBar(state.cache.byModelMonth);
|
|
898
|
+
} else {
|
|
899
|
+
badge.className = 'conn-badge conn-fail';
|
|
900
|
+
label.textContent = '未连接';
|
|
901
|
+
badge.title = '网关连通性检测失败:' + ((r.error && r.error.message) || '未知错误');
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ---------- 引导视图 ----------
|
|
907
|
+
function initOnboarding() {
|
|
908
|
+
var keyInput = $('onboard-key');
|
|
909
|
+
$('onboard-toggle-key').addEventListener('click', function () {
|
|
910
|
+
var isPwd = keyInput.type === 'password';
|
|
911
|
+
keyInput.type = isPwd ? 'text' : 'password';
|
|
912
|
+
this.textContent = isPwd ? '隐藏' : '显示';
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
$('onboard-test').addEventListener('click', function () {
|
|
916
|
+
var btn = this;
|
|
917
|
+
var err = $('onboard-error');
|
|
918
|
+
hide(err);
|
|
919
|
+
var cfg = Object.assign({}, C.DEFAULTS, {
|
|
920
|
+
gatewayBaseUrl: $('onboard-url').value.trim(),
|
|
921
|
+
apiKey: keyInput.value.trim(),
|
|
922
|
+
dailyCreditLimit: numField($('onboard-daily-limit')),
|
|
923
|
+
weeklyCreditLimit: numField($('onboard-weekly-limit')),
|
|
924
|
+
monthlyCreditLimit: numField($('onboard-monthly-limit'))
|
|
925
|
+
});
|
|
926
|
+
var v = C.validate(cfg);
|
|
927
|
+
if (!v.ok) { err.textContent = v.errors[0]; show(err); return; }
|
|
928
|
+
|
|
929
|
+
btn.disabled = true;
|
|
930
|
+
btn.textContent = '测试中…';
|
|
931
|
+
API.testConnection(cfg).then(function (r) {
|
|
932
|
+
btn.disabled = false;
|
|
933
|
+
btn.textContent = '测试连接';
|
|
934
|
+
if (r.ok) {
|
|
935
|
+
state.cfg = cfg;
|
|
936
|
+
state.consumer = r.consumer;
|
|
937
|
+
setModelCatalog(r.models);
|
|
938
|
+
// 询问是否记住到 localStorage
|
|
939
|
+
if (confirm('连接成功!网关鉴权通过\n消费者:' + (r.consumer || '-') +
|
|
940
|
+
'\n可用模型 ' + r.models.length + ' 个:' + r.modelIds.join('、') +
|
|
941
|
+
'\n\n是否将配置保存到浏览器 localStorage?\n(下次打开免输入;选择"取消"则仅本次会话生效)')) {
|
|
942
|
+
C.saveLocal(cfg);
|
|
943
|
+
}
|
|
944
|
+
enterPanel();
|
|
945
|
+
} else {
|
|
946
|
+
var e = r.error || {};
|
|
947
|
+
if (e.code === 'unauthorized') {
|
|
948
|
+
err.textContent = 'API Key 无效(401),请检查后重试';
|
|
949
|
+
} else if (e.code === 'network' || e.code === 'timeout') {
|
|
950
|
+
err.textContent = e.message + '。若通过 file:// 打开,请确认网关已放行 CORS(Access-Control-Allow-Origin: *)';
|
|
951
|
+
} else {
|
|
952
|
+
err.textContent = e.message || '连接失败';
|
|
953
|
+
}
|
|
954
|
+
show(err);
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function enterPanel() {
|
|
961
|
+
switchView('panel');
|
|
962
|
+
applyPanelView('usage');
|
|
963
|
+
applyTheme(false);
|
|
964
|
+
refreshConnBadge();
|
|
965
|
+
loadAll();
|
|
966
|
+
startAutoRefresh();
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ---------- 设置抽屉 ----------
|
|
970
|
+
function openDrawer() {
|
|
971
|
+
var cfg = state.cfg || C.load();
|
|
972
|
+
$('set-url').value = cfg.gatewayBaseUrl || '';
|
|
973
|
+
$('set-key').value = cfg.apiKey || '';
|
|
974
|
+
$('set-trenddays').value = cfg.trendDays || 14;
|
|
975
|
+
$('set-theme').value = cfg.theme || 'auto';
|
|
976
|
+
$('set-numstyle').value = cfg.numberStyle || 'wan';
|
|
977
|
+
$('set-daily-limit').value = cfg.dailyCreditLimit == null ? '' : cfg.dailyCreditLimit;
|
|
978
|
+
$('set-weekly-limit').value = cfg.weeklyCreditLimit == null ? '' : cfg.weeklyCreditLimit;
|
|
979
|
+
$('set-monthly-limit').value = cfg.monthlyCreditLimit == null ? '' : cfg.monthlyCreditLimit;
|
|
980
|
+
$('set-masked').textContent = cfg.apiKey ? '当前 Key:' + F.maskKey(cfg.apiKey) : '';
|
|
981
|
+
hide($('set-message'));
|
|
982
|
+
show($('drawer-mask'));
|
|
983
|
+
show($('drawer'));
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function closeDrawer() {
|
|
987
|
+
hide($('drawer-mask'));
|
|
988
|
+
hide($('drawer'));
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/** 数字输入读取:空串返回 ''(语义=不限),否则返回 Number(负数交由校验层拦截) */
|
|
992
|
+
function numField(input) {
|
|
993
|
+
var v = String(input.value || '').trim();
|
|
994
|
+
if (v === '') return '';
|
|
995
|
+
return Number(v);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function drawerCollect() {
|
|
999
|
+
return {
|
|
1000
|
+
gatewayBaseUrl: $('set-url').value.trim(),
|
|
1001
|
+
apiKey: $('set-key').value.trim(),
|
|
1002
|
+
trendDays: Number($('set-trenddays').value),
|
|
1003
|
+
theme: $('set-theme').value,
|
|
1004
|
+
numberStyle: $('set-numstyle').value,
|
|
1005
|
+
dailyCreditLimit: numField($('set-daily-limit')),
|
|
1006
|
+
weeklyCreditLimit: numField($('set-weekly-limit')),
|
|
1007
|
+
monthlyCreditLimit: numField($('set-monthly-limit'))
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function drawerMessage(msg, ok) {
|
|
1012
|
+
var el = $('set-message');
|
|
1013
|
+
el.textContent = msg;
|
|
1014
|
+
el.className = 'drawer-message ' + (ok ? 'msg-ok' : 'msg-error');
|
|
1015
|
+
show(el);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function initDrawer() {
|
|
1019
|
+
$('btn-settings').addEventListener('click', openDrawer);
|
|
1020
|
+
$('drawer-close').addEventListener('click', closeDrawer);
|
|
1021
|
+
$('drawer-mask').addEventListener('click', closeDrawer);
|
|
1022
|
+
|
|
1023
|
+
var keyInput = $('set-key');
|
|
1024
|
+
$('set-toggle-key').addEventListener('click', function () {
|
|
1025
|
+
var isPwd = keyInput.type === 'password';
|
|
1026
|
+
keyInput.type = isPwd ? 'text' : 'password';
|
|
1027
|
+
this.textContent = isPwd ? '隐藏' : '显示';
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
$('set-test').addEventListener('click', function () {
|
|
1031
|
+
var cfg = drawerCollect();
|
|
1032
|
+
var v = C.validate(cfg);
|
|
1033
|
+
if (!v.ok) { drawerMessage(v.errors[0]); return; }
|
|
1034
|
+
var btn = this;
|
|
1035
|
+
btn.disabled = true;
|
|
1036
|
+
btn.textContent = '测试中…';
|
|
1037
|
+
API.testConnection(cfg).then(function (r) {
|
|
1038
|
+
btn.disabled = false;
|
|
1039
|
+
btn.textContent = '测试连接';
|
|
1040
|
+
if (r.ok) {
|
|
1041
|
+
drawerMessage('连接成功 · 消费者 ' + (r.consumer || '-') +
|
|
1042
|
+
' · 可用模型 ' + r.models.length + ' 个:' + r.modelIds.join('、'), true);
|
|
1043
|
+
} else {
|
|
1044
|
+
drawerMessage((r.error && r.error.message) || '连接失败');
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
$('set-save').addEventListener('click', function () {
|
|
1050
|
+
var cfg = drawerCollect();
|
|
1051
|
+
var v = C.validate(cfg);
|
|
1052
|
+
if (!v.ok) { drawerMessage(v.errors[0]); return; }
|
|
1053
|
+
C.saveLocal(cfg);
|
|
1054
|
+
state.cfg = cfg;
|
|
1055
|
+
applyTheme(true);
|
|
1056
|
+
refreshConnBadge();
|
|
1057
|
+
drawerMessage('已保存到浏览器 localStorage', true);
|
|
1058
|
+
setTimeout(function () { closeDrawer(); refreshActive(); }, 600);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
$('set-clear').addEventListener('click', function () {
|
|
1062
|
+
C.clearLocal();
|
|
1063
|
+
drawerMessage('已清除浏览器中保存的配置(config.js 仍生效)', true);
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
// ---------- 启动 ----------
|
|
1067
|
+
function init() {
|
|
1068
|
+
// http:// 协议打开的黄色警告横幅(file:// 与 https:// 不提示)
|
|
1069
|
+
if (location.protocol === 'http:') show($('insecure-banner'));
|
|
1070
|
+
|
|
1071
|
+
// 周期切换
|
|
1072
|
+
Array.prototype.forEach.call(document.querySelectorAll('#period-seg button'), function (btn) {
|
|
1073
|
+
btn.addEventListener('click', function () {
|
|
1074
|
+
var p = this.getAttribute('data-period');
|
|
1075
|
+
if (p === state.period) return;
|
|
1076
|
+
state.period = p;
|
|
1077
|
+
Array.prototype.forEach.call(document.querySelectorAll('#period-seg button'), function (b) {
|
|
1078
|
+
b.classList.toggle('active', b.getAttribute('data-period') === p);
|
|
1079
|
+
});
|
|
1080
|
+
refreshActive();
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
// 主题切换
|
|
1085
|
+
['light', 'dark', 'auto'].forEach(function (t) {
|
|
1086
|
+
$('theme-' + t).addEventListener('click', function () { setTheme(t); });
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
$('btn-refresh').addEventListener('click', manualRefresh);
|
|
1090
|
+
$('btn-export').addEventListener('click', exportCsv);
|
|
1091
|
+
|
|
1092
|
+
// 表格排序
|
|
1093
|
+
Array.prototype.forEach.call(document.querySelectorAll('#model-table th[data-key]'), function (th) {
|
|
1094
|
+
th.addEventListener('click', function () {
|
|
1095
|
+
var key = this.getAttribute('data-key');
|
|
1096
|
+
if (state.tableSort.key === key) state.tableSort.dir *= -1;
|
|
1097
|
+
else state.tableSort = { key: key, dir: key === 'model' ? 1 : -1 };
|
|
1098
|
+
sortAndRender();
|
|
1099
|
+
});
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
// 面板内分页视图切换(用量 / 会话)
|
|
1103
|
+
Array.prototype.forEach.call(document.querySelectorAll('#view-seg button'), function (btn) {
|
|
1104
|
+
btn.addEventListener('click', function () {
|
|
1105
|
+
switchPanelView(this.getAttribute('data-view'));
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
// 会话视图模块:数据在首次进入该视图时才拉取
|
|
1110
|
+
if (window.BRU.sessions) {
|
|
1111
|
+
window.BRU.sessions.init({
|
|
1112
|
+
getCfg: function () { return state.cfg; },
|
|
1113
|
+
getPeriod: function () { return state.period; },
|
|
1114
|
+
getModelCatalog: function () { return state.modelCatalog; },
|
|
1115
|
+
onUnauthorized: backToOnboarding
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
initOnboarding();
|
|
1120
|
+
initDrawer();
|
|
1121
|
+
initQuotaCard();
|
|
1122
|
+
applyTheme(false);
|
|
1123
|
+
|
|
1124
|
+
// 配置加载与校验:localStorage > config.js
|
|
1125
|
+
var cfg = C.load();
|
|
1126
|
+
var v = C.validate(cfg);
|
|
1127
|
+
if (v.ok) {
|
|
1128
|
+
state.cfg = {
|
|
1129
|
+
gatewayBaseUrl: cfg.gatewayBaseUrl.trim().replace(/\/+$/, ''),
|
|
1130
|
+
apiKey: cfg.apiKey.trim(),
|
|
1131
|
+
trendDays: cfg.trendDays !== undefined ? Number(cfg.trendDays) : 14,
|
|
1132
|
+
theme: cfg.theme || 'auto',
|
|
1133
|
+
numberStyle: cfg.numberStyle || 'wan',
|
|
1134
|
+
dailyCreditLimit: toNum(cfg.dailyCreditLimit),
|
|
1135
|
+
weeklyCreditLimit: toNum(cfg.weeklyCreditLimit),
|
|
1136
|
+
monthlyCreditLimit: toNum(cfg.monthlyCreditLimit)
|
|
1137
|
+
};
|
|
1138
|
+
enterPanel();
|
|
1139
|
+
} else {
|
|
1140
|
+
// 预填已有值,展示首个错误原因
|
|
1141
|
+
$('onboard-url').value = cfg.gatewayBaseUrl || '';
|
|
1142
|
+
$('onboard-key').value = cfg.apiKey || '';
|
|
1143
|
+
if (cfg.gatewayBaseUrl || cfg.apiKey) {
|
|
1144
|
+
var err = $('onboard-error');
|
|
1145
|
+
err.textContent = '配置校验未通过:' + v.errors[0];
|
|
1146
|
+
show(err);
|
|
1147
|
+
}
|
|
1148
|
+
switchView('onboarding');
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// ECharts 未加载成功时进入面板会报错,这里兜底提示
|
|
1153
|
+
if (!window.echarts) {
|
|
1154
|
+
window.addEventListener('load', function () {
|
|
1155
|
+
if (!window.echarts) {
|
|
1156
|
+
var err = $('onboard-error');
|
|
1157
|
+
if (err) { err.textContent = 'ECharts 加载失败(CDN 不可达),图表将无法显示'; }
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (document.readyState === 'loading') {
|
|
1163
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
1164
|
+
} else {
|
|
1165
|
+
init();
|
|
1166
|
+
}
|
|
1167
|
+
})();
|