@te-river/opencode-alibabatokenplan 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 +21 -0
- package/README.md +86 -0
- package/dist/core.mjs +918 -0
- package/dist/server.js +1258 -0
- package/dist/tui.js +1142 -0
- package/package.json +79 -0
- package/src/core/bailian-snapshot.ts +146 -0
- package/src/core/index.ts +10 -0
- package/src/core/merge.ts +148 -0
- package/src/core/qoder-snapshot.ts +153 -0
- package/src/core/rules.ts +105 -0
- package/src/core/status.ts +407 -0
- package/src/core/text.ts +68 -0
- package/src/core/types.ts +102 -0
- package/src/server/index.ts +476 -0
- package/src/server/panel.html +137 -0
- package/src/tui/index.tsx +418 -0
- package/src/tui/read-status.ts +45 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// server 插件入口 —— 移植 bailian-discount-panel.ts 全部行为(三级回退/缓存迁移/
|
|
2
|
+
// 端口粘滞/toast/工具/面板),新增:Qoder 快照并入、status.json 原子写、
|
|
3
|
+
// tokenplan_discounts 工具(旧名委托)、config hook 注入 tokenplan 命令、webpanel 默认关。
|
|
4
|
+
//
|
|
5
|
+
// 模块形态(03-D1 / quota dist/index.js 实证):default export { id, server },
|
|
6
|
+
// 避免宿主 getLegacyPlugins 兜底路径把命名导出当第二个插件重复装载。
|
|
7
|
+
|
|
8
|
+
import * as http from "node:http";
|
|
9
|
+
import * as cp from "node:child_process";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
buildStatus, writeStatusAtomic, loadConfig, cacheFilePath,
|
|
16
|
+
portFilePath, legacyCacheFile, legacyPortFile,
|
|
17
|
+
type AtpConfig, type CatalogInput, type QuotaInput,
|
|
18
|
+
} from "../core/status";
|
|
19
|
+
import { parseGroups, bailianSnapshotModels, BAILIAN_SNAPSHOT_AT, CREDITS_PER_YUAN } from "../core/bailian-snapshot";
|
|
20
|
+
import { QODER_SNAPSHOT_DATE, QODER_INSALE_FACTORS } from "../core/qoder-snapshot";
|
|
21
|
+
import { windowState, fmtCountdown } from "../core/rules";
|
|
22
|
+
import { fmtFactor, strikethrough, showStrike } from "../core/text";
|
|
23
|
+
import { isCrossVerified } from "../core/merge";
|
|
24
|
+
import type { ModelEntry, OfferRule, StatusFile } from "../core/types";
|
|
25
|
+
|
|
26
|
+
const PKG_ID = "@te-river/opencode-alibabatokenplan";
|
|
27
|
+
|
|
28
|
+
// ─────────────────────────── 数据层状态(旧文件语义不变) ───────────────────────────
|
|
29
|
+
|
|
30
|
+
const state: CatalogInput = { source: "snapshot", fetchedAt: BAILIAN_SNAPSHOT_AT, models: bailianSnapshotModels() };
|
|
31
|
+
const quotaState: QuotaInput = { available: false, hint: "实时额度需要控制台授权:执行一次 bl auth login --console --console-site domestic 后自动启用" };
|
|
32
|
+
|
|
33
|
+
// 源1:百炼公开模型目录(匿名 POST,零登录)
|
|
34
|
+
const ZELDA_API = "zeldaHttp.dashscopeModel./zelda/api/v1/modelCenter/listFoundationModels";
|
|
35
|
+
const GATEWAY_URL =
|
|
36
|
+
"https://bailian-cs.console.aliyun.com/cli/api.json?action=BroadScopeAspnGateway&product=sfm_bailian&api=" +
|
|
37
|
+
encodeURIComponent(ZELDA_API);
|
|
38
|
+
|
|
39
|
+
async function fetchCatalogPage(pageNo: number, pageSize: number): Promise<{ total: number; list: any[] }> {
|
|
40
|
+
const params = JSON.stringify({
|
|
41
|
+
Api: ZELDA_API,
|
|
42
|
+
V: "1.0",
|
|
43
|
+
Data: {
|
|
44
|
+
input: {
|
|
45
|
+
pageNo, pageSize, name: "", providers: [], inferenceProviders: [], features: [],
|
|
46
|
+
group: false, capabilities: [], contextWindows: [],
|
|
47
|
+
queryPermissions: false, queryApplyStatus: false, queryActivationStatus: false,
|
|
48
|
+
queryPrice: true, queryQpmInfo: false, supports: { inference: true },
|
|
49
|
+
},
|
|
50
|
+
cornerstoneParam: { protocol: "V2", console: "ONE_CONSOLE", productCode: "p_efm", switchUserType: 3, consoleSite: "BAILIAN_ALIYUN" },
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const res = await fetch(GATEWAY_URL, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
56
|
+
body: "params=" + encodeURIComponent(params) + "®ion=cn-beijing",
|
|
57
|
+
signal: AbortSignal.timeout(20_000),
|
|
58
|
+
});
|
|
59
|
+
if (!res.ok) throw new Error(`catalog HTTP ${res.status}`);
|
|
60
|
+
const j: any = await res.json();
|
|
61
|
+
const inner = j?.data?.DataV2?.data?.data;
|
|
62
|
+
if (!inner || !Array.isArray(inner.list)) throw new Error("catalog 响应结构异常: " + JSON.stringify(j).slice(0, 160));
|
|
63
|
+
return { total: Number(inner.total) || inner.list.length, list: inner.list };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function fetchCatalogLive(): Promise<ModelEntry[]> {
|
|
67
|
+
const all: any[] = [];
|
|
68
|
+
const pageSize = 50;
|
|
69
|
+
let total = Infinity;
|
|
70
|
+
for (let page = 1; page <= 15 && all.length < total; page++) {
|
|
71
|
+
const r = await fetchCatalogPage(page, pageSize);
|
|
72
|
+
total = r.total;
|
|
73
|
+
if (!r.list.length) break;
|
|
74
|
+
all.push(...r.list);
|
|
75
|
+
}
|
|
76
|
+
if (!all.length) throw new Error("catalog 返回为空");
|
|
77
|
+
return parseGroups(all);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 源2:本机 bl CLI
|
|
81
|
+
/** 组装 bl 子进程调用参数(独立导出便于单测断言 argv 形态,见 test.mjs M1)。
|
|
82
|
+
* 关键:command 恒为 "bl"、args 传数组——POSIX 下绝不能把整串当 command(shell:false 时
|
|
83
|
+
* execvp 会把 "bl model list" 整串当可执行名查找 → 永久 ENOENT,cli 源静默失效)。
|
|
84
|
+
* win32 下 bl 是 .cmd shim 需经 shell,args 里的参数由 Node 逐个加引号安全拼接。 */
|
|
85
|
+
export function blSpawnPlan(args: string[]): { command: string; args: string[]; options: cp.SpawnOptions } {
|
|
86
|
+
return { command: "bl", args: [...args], options: { shell: process.platform === "win32", env: { ...process.env, NO_COLOR: "1" } } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function runBl(args: string[], timeoutMs = 30_000): Promise<{ code: number; stdout: string; stderr: string }> {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const plan = blSpawnPlan(args);
|
|
92
|
+
const child = cp.spawn(plan.command, plan.args, plan.options);
|
|
93
|
+
let stdout = "", stderr = "";
|
|
94
|
+
let done = false;
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
if (!done) { done = true; child.kill(); resolve({ code: -1, stdout, stderr: stderr + "\ntimeout" }); }
|
|
97
|
+
}, timeoutMs);
|
|
98
|
+
child.stdout!.on("data", (d: Buffer) => (stdout += d.toString()));
|
|
99
|
+
child.stderr!.on("data", (d: Buffer) => (stderr += d.toString()));
|
|
100
|
+
child.on("error", (e: Error) => { if (!done) { done = true; clearTimeout(timer); resolve({ code: -1, stdout, stderr: String(e) }); } });
|
|
101
|
+
child.on("close", (code: number | null) => { if (!done) { done = true; clearTimeout(timer); resolve({ code: code ?? -1, stdout, stderr }); } });
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function fetchCatalogCli(): Promise<ModelEntry[]> {
|
|
106
|
+
const r = await runBl(["model", "list", "--page-size", "50", "--page", "1", "--output", "json"]);
|
|
107
|
+
if (r.code !== 0) throw new Error("bl model list 退出码 " + r.code + " " + r.stderr.slice(0, 120));
|
|
108
|
+
const j = JSON.parse(r.stdout);
|
|
109
|
+
const groups = Array.isArray(j) ? j : (j.items || []);
|
|
110
|
+
const models = parseGroups(groups);
|
|
111
|
+
if (!models.length) throw new Error("bl model list 无数据");
|
|
112
|
+
return models;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 缓存:新命名空间 + 旧路径一次性迁移(03-D7)
|
|
116
|
+
function loadCache(): ModelEntry[] | null {
|
|
117
|
+
try {
|
|
118
|
+
const j = JSON.parse(fs.readFileSync(cacheFilePath(), "utf8"));
|
|
119
|
+
if (Array.isArray(j.models) && j.models.length) return j.models;
|
|
120
|
+
} catch {}
|
|
121
|
+
try {
|
|
122
|
+
const j = JSON.parse(fs.readFileSync(legacyCacheFile(), "utf8"));
|
|
123
|
+
if (Array.isArray(j.models) && j.models.length) return j.models; // 调用方 saveCache 落到新目录=迁移
|
|
124
|
+
} catch {}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function saveCache(models: ModelEntry[]) {
|
|
129
|
+
try {
|
|
130
|
+
fs.mkdirSync(path.dirname(cacheFilePath()), { recursive: true });
|
|
131
|
+
fs.writeFileSync(cacheFilePath(), JSON.stringify({ savedAt: new Date().toISOString(), models }, null, 2));
|
|
132
|
+
} catch {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 端口粘滞:避免重启后面板地址漂移(.port 迁新目录,旧文件仅作候选)
|
|
136
|
+
function lastPort(): number {
|
|
137
|
+
for (const f of [portFilePath(), legacyPortFile()]) {
|
|
138
|
+
try {
|
|
139
|
+
const v = Number(fs.readFileSync(f, "utf8").trim());
|
|
140
|
+
if (Number.isInteger(v) && v >= 1024 && v <= 65535) return v;
|
|
141
|
+
} catch {}
|
|
142
|
+
}
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
function savePort(port: number) {
|
|
146
|
+
try {
|
|
147
|
+
fs.mkdirSync(path.dirname(portFilePath()), { recursive: true });
|
|
148
|
+
fs.writeFileSync(portFilePath(), String(port));
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let refreshing = false;
|
|
153
|
+
async function refreshCatalog(cfg: AtpConfig, force = false): Promise<void> {
|
|
154
|
+
if (refreshing) return;
|
|
155
|
+
refreshing = true;
|
|
156
|
+
try {
|
|
157
|
+
let models: ModelEntry[] | null = null;
|
|
158
|
+
let source: CatalogInput["source"] = "snapshot";
|
|
159
|
+
let err = "";
|
|
160
|
+
try { models = await fetchCatalogLive(); source = "live"; } catch (e: any) {
|
|
161
|
+
err = "live: " + String(e?.message || e);
|
|
162
|
+
try { models = await fetchCatalogCli(); source = "cli"; } catch (e2: any) {
|
|
163
|
+
err += " | cli: " + String(e2?.message || e2);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (models && models.length) {
|
|
167
|
+
state.source = source;
|
|
168
|
+
state.fetchedAt = new Date().toISOString();
|
|
169
|
+
state.models = models;
|
|
170
|
+
state.error = err || undefined;
|
|
171
|
+
saveCache(models);
|
|
172
|
+
} else {
|
|
173
|
+
// D1:双败(live+cli 均未产出)时降级原因必须落进 state.error → status.sources.error,
|
|
174
|
+
// 不再只在 force 分支写——静默的「源失效」会让面板/工具显示成"正常快照态"。
|
|
175
|
+
state.error = err || state.error;
|
|
176
|
+
if (force) {
|
|
177
|
+
const cached = loadCache();
|
|
178
|
+
if (cached) { state.models = cached; state.fetchedAt = BAILIAN_SNAPSHOT_AT + "(缓存)"; }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
publishStatus();
|
|
182
|
+
if (cfg.toast) void maybeToast(); // 刷新后推进 toast 状态机(保留旧行为)
|
|
183
|
+
} finally {
|
|
184
|
+
refreshing = false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function refreshQuota(): Promise<void> {
|
|
189
|
+
quotaState.checkedAt = new Date().toISOString();
|
|
190
|
+
const r = await runBl(["usage", "token-plan", "--output", "json"], 45_000);
|
|
191
|
+
const out = (r.stdout || "").trim();
|
|
192
|
+
if (r.code === 0 && out.startsWith("{")) {
|
|
193
|
+
try {
|
|
194
|
+
quotaState.data = JSON.parse(out);
|
|
195
|
+
quotaState.available = true;
|
|
196
|
+
quotaState.hint = undefined;
|
|
197
|
+
publishStatus();
|
|
198
|
+
return;
|
|
199
|
+
} catch {}
|
|
200
|
+
}
|
|
201
|
+
quotaState.data = undefined;
|
|
202
|
+
quotaState.available = false;
|
|
203
|
+
const s = r.stderr + " " + r.stdout;
|
|
204
|
+
quotaState.hint = /console|token|login|auth/i.test(s)
|
|
205
|
+
? "实时额度需要控制台授权:执行一次 bl auth login --console --console-site domestic 后自动启用"
|
|
206
|
+
: "无法获取实时额度(bl CLI 不可用或查询失败:" + (r.stderr || r.stdout || "").slice(0, 120) + ")";
|
|
207
|
+
publishStatus();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function currentStatus(): StatusFile {
|
|
211
|
+
return buildStatus(state, new Date(), { ...quotaState });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** 每次状态变化原子写 status.json(TUI/他器消费的唯一运行时契约)。 */
|
|
215
|
+
function publishStatus(): void {
|
|
216
|
+
try { writeStatusAtomic(currentStatus()); } catch {}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─────────────────────────── toast(边界翻转提醒,双源同窗只弹一次) ───────────────────────────
|
|
220
|
+
|
|
221
|
+
let client: any = null;
|
|
222
|
+
// 去重键=窗口(非 rule.id):qoder 与百炼两套单位同窗合并后仍是一个物理窗口 → 恰 2 次/日
|
|
223
|
+
const lastActive = new Map<string, boolean>();
|
|
224
|
+
|
|
225
|
+
async function maybeToast(): Promise<void> {
|
|
226
|
+
const st = currentStatus();
|
|
227
|
+
for (const w of st.windows) {
|
|
228
|
+
const ws = windowState(w, new Date());
|
|
229
|
+
const prev = lastActive.get(w.id);
|
|
230
|
+
// m6:判翻转后先落键再 await showToast——并发/重入(tick 定时器与面板刷新同时触发)时,
|
|
231
|
+
// 第二个调用读到的 prev 已是新态 → 不会二次弹同窗 toast(旧顺序留有 await 窗口期竞态)。
|
|
232
|
+
lastActive.set(w.id, ws.active);
|
|
233
|
+
if (cfgNow?.toast !== false && prev !== undefined && prev !== ws.active && client) {
|
|
234
|
+
const models = w.models.map((m) => `${m.model}${m.effective ? "→" + m.effective : ""}`);
|
|
235
|
+
const msg = ws.active
|
|
236
|
+
? `${w.label}已开始:${models.join("、")}(至 ${w.end},北京时间)`
|
|
237
|
+
: `${w.label}已结束:${models.join("、")}(下次 ${w.start} 开始)`;
|
|
238
|
+
try {
|
|
239
|
+
await client.tui?.showToast?.({ body: { title: "Token Plan 优惠提醒", message: msg, variant: ws.active ? "success" : "info", duration: 8000 } });
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─────────────────────────── 可选 web 面板(默认关,03-D5) ───────────────────────────
|
|
246
|
+
|
|
247
|
+
function renderHtml(): string {
|
|
248
|
+
// 优先读随包 panel.html:改样式只需保存文件并刷新浏览器
|
|
249
|
+
try {
|
|
250
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
251
|
+
for (const p of [path.join(here, "..", "src", "server", "panel.html"), path.join(here, "panel.html")]) {
|
|
252
|
+
if (fs.existsSync(p)) return fs.readFileSync(p, "utf8");
|
|
253
|
+
}
|
|
254
|
+
} catch {}
|
|
255
|
+
// 兜底:最小内置渲染(真实样式以随包 panel.html 为准)
|
|
256
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>Token Plan 优惠雷达</title></head><body><pre id="p">加载中…</pre><script>async function l(){var r=await fetch("/api/status");document.getElementById("p").textContent=JSON.stringify(await r.json(),null,2)}l();setInterval(l,30000);</script></body></html>`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let server: http.Server | null = null;
|
|
260
|
+
const REFRESH_THROTTLE_MS = 5_000;
|
|
261
|
+
let lastRefreshAt = 0; // /api/refresh 进程内节流戳(M2)
|
|
262
|
+
|
|
263
|
+
/** M2 同源校验:Origin 缺省(同源 GET/curl 常不带)或 host 与本服务 Host 完全一致才放行;
|
|
264
|
+
* 请求带 Sec-Fetch-Site 时额外闸——必须 same-origin(浏览器发起的跨站请求会被标记拦截)。 */
|
|
265
|
+
function isSameOrigin(req: http.IncomingMessage): boolean {
|
|
266
|
+
const origin = req.headers.origin;
|
|
267
|
+
if (origin !== undefined) {
|
|
268
|
+
let oh: string;
|
|
269
|
+
try { oh = new URL(String(origin)).host; } catch { return false; }
|
|
270
|
+
if (oh !== String(req.headers.host ?? "")) return false;
|
|
271
|
+
}
|
|
272
|
+
const sfs = req.headers["sec-fetch-site"];
|
|
273
|
+
if (sfs !== undefined && String(sfs).toLowerCase() !== "same-origin") return false;
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function startPanel(port: number): Promise<string> {
|
|
278
|
+
return new Promise((resolve) => {
|
|
279
|
+
const handler = (req: http.IncomingMessage, res: http.ServerResponse) => {
|
|
280
|
+
const url = (req.url || "/").split("?")[0];
|
|
281
|
+
if (url === "/api/status") {
|
|
282
|
+
// M2:panel 与本服务同源,删除 CORS 通配头(不再允许任意站点跨源读状态)
|
|
283
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
284
|
+
res.end(JSON.stringify(currentStatus()));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (url === "/api/refresh") {
|
|
288
|
+
if (!isSameOrigin(req)) {
|
|
289
|
+
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
|
|
290
|
+
res.end("forbidden");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const t0 = Date.now();
|
|
294
|
+
if (t0 - lastRefreshAt < REFRESH_THROTTLE_MS) {
|
|
295
|
+
res.writeHead(429, { "Retry-After": "5", "Content-Type": "text/plain; charset=utf-8" });
|
|
296
|
+
res.end("throttled");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
lastRefreshAt = t0;
|
|
300
|
+
refreshCatalog(cfgNow ?? loadConfig(), true)
|
|
301
|
+
.then(() => { res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); res.end("ok"); })
|
|
302
|
+
.catch(() => { res.writeHead(500); res.end("refresh failed"); });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
306
|
+
res.end(renderHtml());
|
|
307
|
+
};
|
|
308
|
+
const candidates = [...new Set([lastPort(), port, ...Array.from({ length: 20 }, (_, i) => port + 1 + i)])].filter((p) => p >= 1024 && p <= 65535);
|
|
309
|
+
let idx = 0;
|
|
310
|
+
const tryListen = () => {
|
|
311
|
+
const p = candidates[idx];
|
|
312
|
+
const s = http.createServer(handler);
|
|
313
|
+
s.on("error", () => {
|
|
314
|
+
try { s.close(); } catch {} // m8:失败实例显式 close,不留悬挂句柄
|
|
315
|
+
if (++idx >= candidates.length) { resolve(""); return; }
|
|
316
|
+
tryListen();
|
|
317
|
+
});
|
|
318
|
+
// m8:只在 listen 成功回调内赋 server 句柄(旧代码把绑定失败的实例也写进 server,dispose 关错对象)
|
|
319
|
+
s.listen(p, "127.0.0.1", () => { server = s; savePort(p); resolve(`http://127.0.0.1:${p}`); });
|
|
320
|
+
};
|
|
321
|
+
tryListen();
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ─────────────────────────── 工具(新名实现 + 旧名委托) ───────────────────────────
|
|
326
|
+
|
|
327
|
+
async function loadToolHelper(): Promise<any> {
|
|
328
|
+
try {
|
|
329
|
+
const m: any = await import("@opencode-ai/plugin");
|
|
330
|
+
if (m?.tool) return m.tool;
|
|
331
|
+
} catch {}
|
|
332
|
+
const chain: any = function () { return chain; };
|
|
333
|
+
chain.optional = () => chain;
|
|
334
|
+
chain.describe = () => chain;
|
|
335
|
+
const shim: any = (t: any) => t;
|
|
336
|
+
shim.schema = { string: () => chain, number: () => chain, boolean: () => chain };
|
|
337
|
+
return shim;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function unitLabel(r: OfferRule): string {
|
|
341
|
+
return r.unit === "credits-factor" ? "Credits 倍率" : "¥价格倍率";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function renderToolOutput(filter: string): string {
|
|
345
|
+
const st = currentStatus();
|
|
346
|
+
const lines: string[] = [];
|
|
347
|
+
const srcNote = st.sources.catalog === "bailian-live" ? "实时接口" : st.sources.catalog === "bailian-cli" ? "本机 CLI" : "快照";
|
|
348
|
+
lines.push(`数据源: ${srcNote}(更新于 ${state.fetchedAt})+ Qoder 快照 ${QODER_SNAPSHOT_DATE}(静态,不调私有端点);1 元 = ${CREDITS_PER_YUAN} Credits(计费示例换算,非官方汇率);时区 Asia/Shanghai`);
|
|
349
|
+
for (const w of st.windows) {
|
|
350
|
+
const ws = windowState(w, new Date());
|
|
351
|
+
const cd = ws.active ? `${fmtCountdown(ws.minutesToNextBoundary)}后结束(${w.end})` : `${fmtCountdown(ws.minutesToNextBoundary)}后开始(${w.start})`;
|
|
352
|
+
for (const m of w.models) {
|
|
353
|
+
if (filter && m.model !== filter && !m.model.includes(filter)) continue;
|
|
354
|
+
const strike = showStrike(parseFloat(m.regular ?? ""), parseFloat(m.effective ?? ""));
|
|
355
|
+
lines.push(`• [${w.start}–${w.end}] ${m.model}: ${strike ? strikethrough(m.regular!) + " " : ""}${m.effective ?? ""} ${m.badge ?? ""}${isCrossVerified(m) ? "(双源印证)" : ""} → ${w.activeNow ? "优惠中," : ""}${cd}`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const flat = st.offers.filter((r) => r.kind === "flat-cut" && (!filter || r.models.some((x) => x === filter || x.includes(filter))));
|
|
359
|
+
if (flat.length) {
|
|
360
|
+
lines.push("限时优惠(两套单位不互算):");
|
|
361
|
+
for (const r of flat.slice(0, 30)) {
|
|
362
|
+
const tags = [r.factor !== undefined ? `×${r.factor}` : "", r.batchStack !== undefined ? `Batch叠加×${r.batchStack}` : ""].filter(Boolean).join(" ");
|
|
363
|
+
lines.push(`• ${r.models.join(", ")}(${unitLabel(r)}): ${tags}${r.note ? " — " + r.note : ""}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const tms = st.textModels.filter((m) => !filter || m.model.includes(filter));
|
|
367
|
+
for (const m of tms) {
|
|
368
|
+
lines.push(`• ${m.model}: 输入 ${m.input} / 缓存 ${m.cache} / 输出 ${m.output} Credits/百万tokens${m.bands ? `(${m.bands})` : ""}${m.creditsFactor ? ` · Qoder 倍率 ${m.creditsFactor}` : ""}${m.badge ? `(${m.badge.label},${m.badge.countdown})` : ""}`);
|
|
369
|
+
}
|
|
370
|
+
const inSale = Object.entries(QODER_INSALE_FACTORS).filter(([k]) => !filter || k === filter);
|
|
371
|
+
if (inSale.length && !filter) lines.push("Qoder 在卖常规倍率(非优惠):" + inSale.map(([k, v]) => `${k} ${fmtFactor(v.factor)}${v.note ? "(" + v.note + ")" : ""}`).join(" · "));
|
|
372
|
+
for (const n of st.notices) if (!n.expired || filter) lines.push(`• [${n.kind}] ${n.text}${n.expired ? "(已过期)" : ""} 来源:${n.source}`);
|
|
373
|
+
if (!st.quota.available) lines.push(`额度: ${st.quota.hint ?? "不可用"}`);
|
|
374
|
+
else lines.push(`额度: 实时可用(${st.quota.checkedAt})`);
|
|
375
|
+
if (st.sources.error) lines.push(`数据源降级: ${st.sources.error}`);
|
|
376
|
+
lines.push("口径:消耗/Cost 单位 Credit;runtimeConfig 会变实际消耗;折扣以控制台账单为准。");
|
|
377
|
+
return lines.join("\n");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─────────────────────────── 插件入口 ───────────────────────────
|
|
381
|
+
|
|
382
|
+
let cfgNow: AtpConfig | null = null;
|
|
383
|
+
|
|
384
|
+
const plugin = async (input: any, options?: any) => {
|
|
385
|
+
client = input?.client ?? null;
|
|
386
|
+
const g = globalThis as any;
|
|
387
|
+
if (g.__atpServerStarted) return { event: async () => {}, dispose: () => {} };
|
|
388
|
+
g.__atpServerStarted = true;
|
|
389
|
+
|
|
390
|
+
// 配置:文件优先,宿主若传 tuple options 则叠加(D5:传了就赢,没传不输)
|
|
391
|
+
cfgNow = loadConfig();
|
|
392
|
+
try {
|
|
393
|
+
const extra = typeof options === "string" ? undefined : options;
|
|
394
|
+
if (extra && typeof extra === "object") {
|
|
395
|
+
const merged = JSON.parse(JSON.stringify(cfgNow));
|
|
396
|
+
for (const k of ["webpanel", "sidebar", "toast", "refresh"]) {
|
|
397
|
+
const v = (extra as any)[k];
|
|
398
|
+
if (v === undefined) continue;
|
|
399
|
+
// m7:toast 等布尔/标量键直接赋值——旧 Object.assign(bool, v) 静默失效/抛错
|
|
400
|
+
if (typeof v !== "object" || v === null) (merged as any)[k] = v;
|
|
401
|
+
else Object.assign((merged as any)[k] ?? {}, v);
|
|
402
|
+
}
|
|
403
|
+
cfgNow = merged;
|
|
404
|
+
}
|
|
405
|
+
} catch {}
|
|
406
|
+
|
|
407
|
+
const cfg: AtpConfig = cfgNow ?? loadConfig();
|
|
408
|
+
cfgNow = cfg;
|
|
409
|
+
const cached = loadCache();
|
|
410
|
+
if (cached) { state.models = cached; state.fetchedAt = BAILIAN_SNAPSHOT_AT + "(缓存)"; }
|
|
411
|
+
publishStatus(); // 先落盘一份(TUI 冷启动即有数据)
|
|
412
|
+
refreshCatalog(cfg);
|
|
413
|
+
refreshQuota().catch(() => {});
|
|
414
|
+
|
|
415
|
+
let panelUrl = "";
|
|
416
|
+
if (cfg.webpanel.enabled) {
|
|
417
|
+
panelUrl = await startPanel(cfg.webpanel.port);
|
|
418
|
+
console.log(`[alibabatokenplan] 面板已启动: ${panelUrl || "启动失败(端口耗尽)"}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const catalogTimer = setInterval(() => { refreshCatalog(cfg).catch(() => {}); }, cfg.refresh.catalogMin * 60_000);
|
|
422
|
+
const quotaTimer = setInterval(() => { refreshQuota().catch(() => {}); }, cfg.refresh.quotaMin * 60_000);
|
|
423
|
+
const tickTimer = setInterval(() => { if (cfg.toast) maybeToast().catch(() => {}); publishStatus(); }, 60_000);
|
|
424
|
+
for (const t of [catalogTimer, quotaTimer, tickTimer]) t.unref?.();
|
|
425
|
+
|
|
426
|
+
const toolHelper = await loadToolHelper();
|
|
427
|
+
const S: any = toolHelper.schema;
|
|
428
|
+
const toolDef = (descPrefix: string) => toolHelper({
|
|
429
|
+
description:
|
|
430
|
+
descPrefix +
|
|
431
|
+
"查询百炼 Token Plan 当前优惠策略、优惠时段与开始/结束倒计时(含 Qoder 快照 2026-08-31 静态数据),以及各模型 Credits 消耗倍率。" +
|
|
432
|
+
"可选参数 model 过滤单个模型。无需登录即可使用。",
|
|
433
|
+
args: {
|
|
434
|
+
model: S.string().optional().describe("按模型名过滤,例如 qwen3.8-max"),
|
|
435
|
+
},
|
|
436
|
+
async execute(args: any) {
|
|
437
|
+
return { title: "Token Plan 优惠与倍率", output: renderToolOutput(args?.model ? String(args.model) : "") };
|
|
438
|
+
},
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
event: async () => {},
|
|
443
|
+
// m8:宿主卸载 → 关面板 server + 清全部定时器,不留端口/句柄/后台刷新泄漏
|
|
444
|
+
dispose: () => {
|
|
445
|
+
for (const t of [catalogTimer, quotaTimer, tickTimer]) clearInterval(t);
|
|
446
|
+
try { server?.close(); } catch {}
|
|
447
|
+
server = null;
|
|
448
|
+
},
|
|
449
|
+
config: async (config: any) => {
|
|
450
|
+
// 兜底命令(03-D4):无 opentui 层的宿主经此路径走 LLM,接受其 token 成本并注明
|
|
451
|
+
config.command = config.command ?? {};
|
|
452
|
+
config.command.tokenplan = {
|
|
453
|
+
description: "查询 Token Plan 优惠/倍率/倒计时(由 @te-river/opencode-alibabatokenplan 注入;本兜底路径经 LLM,会消耗 token)",
|
|
454
|
+
template: "调用 tokenplan_discounts 工具,把当前 Token Plan 优惠窗口、倒计时与各模型倍率原样整理给我,不要额外发挥。$ARGUMENTS",
|
|
455
|
+
};
|
|
456
|
+
},
|
|
457
|
+
tool: {
|
|
458
|
+
tokenplan_discounts: toolDef(""),
|
|
459
|
+
bailian_discounts: toolDef("(已并入 tokenplan_discounts) "),
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
export default { id: PKG_ID, server: plugin };
|
|
465
|
+
// 命名导出仅为可测试性;宿主装载契约是 default 的 { id, server } 模块形态。
|
|
466
|
+
export const AliBabaTokenPlanPlugin = plugin;
|
|
467
|
+
/** test.mjs 零宿主回归锚点(M1 argv 形态 / M2 同源校验 / m6 toast 竞态序)。
|
|
468
|
+
* 只读/受控注入引用,不改变任何运行时行为。 */
|
|
469
|
+
export const __testing = {
|
|
470
|
+
blSpawnPlan,
|
|
471
|
+
isSameOrigin,
|
|
472
|
+
maybeToast,
|
|
473
|
+
lastActive,
|
|
474
|
+
getStatus: currentStatus,
|
|
475
|
+
setClient: (c: any) => { client = c; },
|
|
476
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN"><head><meta charset="utf-8">
|
|
3
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
4
|
+
<title>Token Plan 优惠雷达</title>
|
|
5
|
+
<style>
|
|
6
|
+
:root{
|
|
7
|
+
--bg0:#090b11;--bg1:#0f131c;--card:rgba(21,26,37,.66);--line:rgba(148,163,199,.10);
|
|
8
|
+
--tx:#e8ecf4;--dim:#7e8798;--faint:#565e70;
|
|
9
|
+
--ok:#34d399;--warn:#fbbf24;--hot:#f87171;--accent:#7aa2ff;
|
|
10
|
+
--mono:ui-monospace,"Cascadia Mono",Consolas,"SF Mono",monospace;
|
|
11
|
+
}
|
|
12
|
+
*{box-sizing:border-box}
|
|
13
|
+
html{scrollbar-gutter:stable}
|
|
14
|
+
body{
|
|
15
|
+
margin:0;padding:16px 14px 20px;min-height:100vh;color:var(--tx);
|
|
16
|
+
font:13px/1.5 "Segoe UI","Microsoft YaHei",system-ui,sans-serif;
|
|
17
|
+
background:radial-gradient(900px 420px at 85% -180px,rgba(99,102,241,.16),transparent 62%),
|
|
18
|
+
radial-gradient(700px 380px at -10% 8%,rgba(52,211,153,.07),transparent 55%),
|
|
19
|
+
linear-gradient(180deg,var(--bg0),var(--bg1));
|
|
20
|
+
background-attachment:fixed;
|
|
21
|
+
}
|
|
22
|
+
::-webkit-scrollbar{width:8px}::-webkit-scrollbar-thumb{background:rgba(148,163,199,.16);border-radius:99px}
|
|
23
|
+
h1{font-size:15px;margin:0;font-weight:650;letter-spacing:.2px}
|
|
24
|
+
.sub{color:var(--dim);font-size:11px}
|
|
25
|
+
.dot{width:7px;height:7px;border-radius:99px;background:var(--warn);box-shadow:0 0 8px currentColor;display:inline-block}
|
|
26
|
+
.dot.ok{background:var(--ok)} .dot.warn{background:var(--warn)}
|
|
27
|
+
.pulse{animation:pulse 2.4s ease-in-out infinite}@keyframes pulse{50%{opacity:.35}}
|
|
28
|
+
header{display:flex;align-items:center;gap:8px;margin-bottom:12px}
|
|
29
|
+
header .grow{flex:1}
|
|
30
|
+
.pill{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);border-radius:99px;padding:2px 9px;font-size:11px;color:var(--dim);background:rgba(255,255,255,.025);white-space:nowrap}
|
|
31
|
+
.pill b{color:var(--tx);font-weight:600}
|
|
32
|
+
.card{
|
|
33
|
+
position:relative;background:var(--card);border:1px solid var(--line);border-radius:14px;
|
|
34
|
+
padding:14px;margin-bottom:12px;backdrop-filter:blur(6px);overflow:hidden;
|
|
35
|
+
}
|
|
36
|
+
.card::before{content:"";position:absolute;inset:0 0 auto 0;height:1px;background:linear-gradient(90deg,transparent,rgba(255,255,255,.14),transparent)}
|
|
37
|
+
.hero{padding:16px}
|
|
38
|
+
.hero .top{display:flex;align-items:center;gap:8px;margin-bottom:10px}
|
|
39
|
+
.hero .name{font-size:14px;font-weight:650}
|
|
40
|
+
.factor{font-family:var(--mono);font-weight:700;color:var(--hot);font-size:13px;
|
|
41
|
+
border:1px solid rgba(248,113,113,.35);background:rgba(248,113,113,.08);border-radius:8px;padding:1px 7px}
|
|
42
|
+
.cd{display:flex;align-items:baseline;gap:9px;margin:2px 0 4px}
|
|
43
|
+
.cd .num{font-family:var(--mono);font-size:38px;font-weight:700;letter-spacing:1px;line-height:1;
|
|
44
|
+
font-variant-numeric:tabular-nums;color:var(--tx)}
|
|
45
|
+
.cd.on .num{color:var(--ok);text-shadow:0 0 22px rgba(52,211,153,.35)}
|
|
46
|
+
.cd.off .num{color:#c7cfdf}
|
|
47
|
+
.cd .lbl{font-size:12px;color:var(--dim);margin-left:4px}
|
|
48
|
+
.cd .unit{font-size:13px;color:var(--dim);margin:0 10px 0 3px;font-weight:500}
|
|
49
|
+
.bar{height:4px;border-radius:99px;background:rgba(148,163,199,.14);margin:12px 0 10px;overflow:hidden}
|
|
50
|
+
.bar i{display:block;height:100%;border-radius:99px;background:linear-gradient(90deg,rgba(52,211,153,.55),var(--ok));transition:width 1s linear}
|
|
51
|
+
.meta{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
|
|
52
|
+
.chip{border:1px solid var(--line);border-radius:99px;padding:2.5px 9px;color:var(--tx);background:rgba(255,255,255,.03);font-family:var(--mono);font-size:10.5px}
|
|
53
|
+
.chip s{color:var(--faint);margin-right:4px}
|
|
54
|
+
.chip em{color:var(--hot);font-style:normal;font-weight:600}
|
|
55
|
+
h2{font-size:11px;margin:16px 2px 8px;color:var(--dim);font-weight:600;letter-spacing:.6px}
|
|
56
|
+
.kv{display:flex;justify-content:space-between;gap:10px;padding:6px 10px;border-radius:8px;font-size:12px}
|
|
57
|
+
.kv:hover{background:rgba(255,255,255,.03)}
|
|
58
|
+
.kv .k{color:var(--dim);font-family:var(--mono);font-size:11.5px}
|
|
59
|
+
.kv .v{font-family:var(--mono);font-variant-numeric:tabular-nums}
|
|
60
|
+
.kv .v .d{color:var(--hot)}
|
|
61
|
+
.hint{color:var(--warn);font-size:11.5px;margin:8px 10px 4px}
|
|
62
|
+
.glab{font-size:10.5px;color:var(--faint);letter-spacing:.5px;padding:8px 10px 2px}
|
|
63
|
+
.foot{color:var(--faint);font-size:10.5px;margin-top:12px;line-height:1.7}
|
|
64
|
+
.dimv{color:var(--faint)}
|
|
65
|
+
.srcq{color:var(--accent)}
|
|
66
|
+
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
|
|
67
|
+
</style></head><body>
|
|
68
|
+
<header>
|
|
69
|
+
<span class="dot pulse" id="dot"></span>
|
|
70
|
+
<h1>Token Plan 优惠雷达</h1>
|
|
71
|
+
<span class="grow"></span>
|
|
72
|
+
<span class="pill" id="src"></span>
|
|
73
|
+
</header>
|
|
74
|
+
<div id="rules"></div>
|
|
75
|
+
<h2 id="offerTitle">优惠明细</h2>
|
|
76
|
+
<div class="card" style="padding:6px 4px"><div id="offers"></div></div>
|
|
77
|
+
<h2>文本模型价目(Credits / 百万 tokens)</h2>
|
|
78
|
+
<div class="card" style="padding:6px 4px"><div id="tms"></div></div>
|
|
79
|
+
<div class="foot" id="foot"></div>
|
|
80
|
+
<script>
|
|
81
|
+
var base=null;
|
|
82
|
+
function esc(s){return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")}
|
|
83
|
+
function shMin(){var d=new Date();var cn=new Date(d.getTime()+(d.getTimezoneOffset()+480)*60000);return cn.getHours()*60+cn.getMinutes()+cn.getSeconds()/60}
|
|
84
|
+
function parseHm(s){var p=s.split(":");return (+p[0])*60+(+p[1]||0)}
|
|
85
|
+
function winState(s,e){var m=shMin();var wraps=s>e;var total=wraps?e+1440-s:e-s;var active=wraps?(m>=s||m<e):(m>=s&&m<e);var toNext;
|
|
86
|
+
if(active){toNext=wraps?(m>=s?e+1440-m:e-m):(e-m)}else{toNext=(m<s?s-m:s+1440-m)}
|
|
87
|
+
return{active:active,toNext:Math.max(0,toNext),total:total,pct:active?Math.round(100*(total-toNext)/total):0}}
|
|
88
|
+
function durParts(mins){var m=Math.round(mins);var h=Math.floor(m/60);m=m%60;return{h:h,m:m}}
|
|
89
|
+
function srcName(s){return{"bailian-live":"实时接口","bailian-cli":"本机 CLI","bailian-snapshot":"快照","qoder-snapshot":"Qoder 快照"}[s]||s}
|
|
90
|
+
async function load(){try{var r=await fetch("/api/status");base=await r.json();render()}catch(e){document.getElementById("foot").textContent="加载失败: "+e}}
|
|
91
|
+
function render(){
|
|
92
|
+
if(!base)return;
|
|
93
|
+
var anyOn=base.windows.some(function(w){var s=winState(parseHm(w.start),parseHm(w.end));return s.active});
|
|
94
|
+
document.getElementById("dot").className="dot pulse "+(anyOn?"ok":"warn");
|
|
95
|
+
document.getElementById("src").innerHTML='<b>'+esc(srcName(base.sources.catalog))+'</b> + <span class="srcq">Qoder快照 '+esc(base.sources.qoderSnapshot)+'</span> · '+
|
|
96
|
+
new Date(base.updatedAt).toLocaleTimeString("zh-CN",{hour:"2-digit",minute:"2-digit",timeZone:"Asia/Shanghai"});
|
|
97
|
+
// ── 主卡:窗口 ──
|
|
98
|
+
var R=document.getElementById("rules");R.innerHTML="";
|
|
99
|
+
base.windows.forEach(function(w){
|
|
100
|
+
var s=winState(parseHm(w.start),parseHm(w.end));
|
|
101
|
+
var d=document.createElement("div");d.className="card hero";
|
|
102
|
+
var chips=w.models.map(function(m){
|
|
103
|
+
return '<span class="chip">'+esc(m.model)+(m.regular&&m.regular!==m.effective?' <s>'+esc(m.regular)+'</s>':'')+(m.effective?' <em>'+esc(m.effective)+'</em>':'')+'</span>';
|
|
104
|
+
}).join("");
|
|
105
|
+
d.innerHTML='<div class="top"><span class="dot '+(s.active?"ok":"warn")+' pulse"></span>'
|
|
106
|
+
+'<span class="name">'+esc(w.label)+'</span>'
|
|
107
|
+
+'<span class="grow" style="flex:1"></span><span class="sub">'+esc(w.start)+'–'+esc(w.end)+'(北京时间)</span></div>'
|
|
108
|
+
+'<div class="cd '+(s.active?"on":"off")+'"><span class="num">'+durParts(s.toNext).h+'</span><span class="unit">小时</span><span class="num">'+durParts(s.toNext).m+'</span><span class="unit">分</span>'
|
|
109
|
+
+'<span class="lbl">'+(s.active?"后结束 · 优惠进行中":"后开始")+'</span></div>'
|
|
110
|
+
+'<div class="bar"><i style="width:'+(s.active?s.pct:0)+'%"></i></div>'
|
|
111
|
+
+'<div class="meta">'+chips+'</div>';
|
|
112
|
+
R.appendChild(d);
|
|
113
|
+
});
|
|
114
|
+
// ── 优惠明细(两套单位各居其 facet) ──
|
|
115
|
+
var flat=base.offers.filter(function(r){return r.kind==="flat-cut"});
|
|
116
|
+
document.getElementById("offerTitle").textContent="限时优惠 · "+flat.length+" 项(Credits 倍率与 ¥ 价格倍率不互算)";
|
|
117
|
+
document.getElementById("offers").innerHTML=flat.length?flat.map(function(r){
|
|
118
|
+
var tags=(r.factor!==undefined?"×"+r.factor:"")+(r.batchStack!==undefined?" Batch叠加×"+r.batchStack:"");
|
|
119
|
+
return '<div class="kv"><span class="k">'+esc(r.models.join(", "))+'('+esc(r.unit==="credits-factor"?"Qoder Credits":"百炼 ¥")+')</span>'
|
|
120
|
+
+'<span class="v"><span class="d">'+esc(tags)+'</span> '+esc(r.label)+(r.origins.length>1?' <span class="srcq">[双源印证]</span>':"")+'</span></div>';
|
|
121
|
+
}).join(""):'<div class="dimv" style="padding:6px 10px;font-size:11.5px">当前没有限时优惠</div>';
|
|
122
|
+
// ── 文本模型 ──
|
|
123
|
+
document.getElementById("tms").innerHTML=base.textModels.map(function(m){
|
|
124
|
+
return '<div class="kv"><span class="k">'+esc(m.model)+(m.bands?'('+esc(m.bands)+')':"")+(m.creditsFactor?' · Qoder '+esc(m.creditsFactor):"")+'</span>'
|
|
125
|
+
+'<span class="v">输入 '+esc(m.input)+' / 缓存 '+esc(m.cache)+' / 输出 '+esc(m.output)
|
|
126
|
+
+(m.badge?' <span class="d">'+esc(m.badge.label)+' '+esc(m.badge.countdown)+"</span>":"")+'</span></div>';
|
|
127
|
+
}).join("");
|
|
128
|
+
// ── 页脚 ──
|
|
129
|
+
var notices=base.notices.map(function(n){return esc(n.text)+(n.expired?"(已过期)":"")+" 〔"+esc(n.source)+"〕"}).join("<br>");
|
|
130
|
+
document.getElementById("foot").innerHTML=
|
|
131
|
+
"1 元 = "+base.creditsPerYuan+" Credits("+esc(base.creditsRateNote)+")· 对话里可用 tokenplan_discounts 工具查询 · 折扣以控制台账单为准"
|
|
132
|
+
+(base.quota.available?"":"<br>"+esc(base.quota.hint||""))
|
|
133
|
+
+(notices?"<br>"+notices:"")
|
|
134
|
+
+(base.sources.error?"<br>数据源降级: "+esc(base.sources.error):"");
|
|
135
|
+
}
|
|
136
|
+
setInterval(load,30000);setInterval(render,1000);load();
|
|
137
|
+
</script></body></html>
|