5 Commits
Author SHA1 Message Date
yuez c2b8d89ba6 fix(quota): declare monthlyTargetTime (undeclared since v0.4.2 refactor), ReferenceError when Command Code data absent; add undeclared-identifier scan to pagecheck (v0.4.5) 2026-09-23 10:09:23 +08:00
yuez 9d1884cf20 feat(quota): All tab Command Code card shows Plan + 5h/weekly/monthly windows with reset times (v0.4.4) 2026-09-20 15:25:29 +08:00
yuez 447ce65c9b feat(quota): All tab first in tab bar; remove header usage-alert badge (v0.4.3)
- Tab order: All | Command Code | OpenCode Go (All remains the default)
- Drop the global status badge in the header action row and its
  updateGlobalBadge aggregation; per-card chips and All-tab provider
  badges are unaffected
2026-09-20 14:52:24 +08:00
yuez 4fdc9ad6c8 feat(quota): All as default tab with per-key OpenCode cards grouped by provider, bump v0.4.2 2026-09-20 14:20:01 +08:00
yuez 2f1b2fb821 build: pagecheck as standalone node script (inline escaping was fragile) 2026-09-20 13:18:21 +08:00
5 changed files with 365 additions and 111 deletions
+9 -2
View File
@@ -7,15 +7,22 @@ else
TARGET := commandcode.so TARGET := commandcode.so
endif endif
.PHONY: all build test clean lint .PHONY: all build test clean lint pagecheck
all: build all: build pagecheck
build: build:
CGO_ENABLED=1 go build -buildmode=c-shared -o $(TARGET) main.go CGO_ENABLED=1 go build -buildmode=c-shared -o $(TARGET) main.go
# Extract embedded JS from quota_page.go and syntax-check it with node.
# Guards against parse-time SyntaxErrors (e.g. duplicate const) that break
# the whole resource page; Go substring tests cannot catch these.
pagecheck:
@node scripts/pagecheck.js
test: test:
go test -v -race ./... go test -v -race ./...
$(MAKE) pagecheck
clean: clean:
rm -f commandcode.dylib commandcode.so commandcode.dll commandcode.h rm -f commandcode.dylib commandcode.so commandcode.dll commandcode.h
+22 -3
View File
@@ -3,6 +3,7 @@ package plugin
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
@@ -74,8 +75,8 @@ func TestHandleManagement_QuotaResource(t *testing.T) {
if !strings.Contains(bodyStr, "用量配额") { if !strings.Contains(bodyStr, "用量配额") {
t.Errorf("Body does not contain expected menu text 用量配额") t.Errorf("Body does not contain expected menu text 用量配额")
} }
if !strings.Contains(bodyStr, "v0.4.1") { if !strings.Contains(bodyStr, "v0.4.5") {
t.Errorf("Body does not contain version badge v0.4.0") t.Errorf("Body does not contain version badge v0.4.5")
} }
} }
} }
@@ -175,6 +176,24 @@ const mockOpencodeUsageJSON = `{"usage":{
"monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"} "monthly": {"status":"ok","percent":23,"resetsAt":"2026-10-14T09:13:49.000Z"}
}}` }}`
// mockOpencodeUsageJSONFuture returns the same usage envelope but with reset
// timestamps relative to now. The hardcoded dates in mockOpencodeUsageJSON
// eventually fall into the past (weekly 2026-09-21 did), which makes
// weekly.ResetInSeconds = 0 and breaks the `want > 0` assertion in
// TestHandleManagement_OpencodeUsageRoute. Use this fixture for tests that
// assert positive reset_in_seconds.
func mockOpencodeUsageJSONFuture() string {
now := time.Now().UTC()
ts := func(d time.Duration) string {
return now.Add(d).Format("2006-01-02T15:04:05.000Z")
}
return fmt.Sprintf(`{"usage":{
"rolling": {"status":"ok","percent":4, "resetsAt":"%s"},
"weekly": {"status":"ok","percent":46,"resetsAt":"%s"},
"monthly": {"status":"ok","percent":23,"resetsAt":"%s"}
}}`, ts(2*time.Hour), ts(72*time.Hour), ts(30*24*time.Hour))
}
// Verifies that /plugins/commandcode/opencode/usage is matched by the dedicated // Verifies that /plugins/commandcode/opencode/usage is matched by the dedicated
// OpenCode handler and NOT swallowed by the generic "/usage" suffix match // OpenCode handler and NOT swallowed by the generic "/usage" suffix match
// (which would route it to the Command Code handler). // (which would route it to the Command Code handler).
@@ -194,7 +213,7 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
} }
sawAuthHeader = true sawAuthHeader = true
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(mockOpencodeUsageJSON)) _, _ = w.Write([]byte(mockOpencodeUsageJSONFuture()))
})) }))
defer ts.Close() defer ts.Close()
+1 -1
View File
@@ -13,7 +13,7 @@ import (
const ( const (
PluginID = "commandcode" PluginID = "commandcode"
PluginName = "commandcode" PluginName = "commandcode"
PluginVersion = "0.4.1" PluginVersion = "0.4.5"
PluginAuthor = "zgs225" PluginAuthor = "zgs225"
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode" PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg" PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
+116 -105
View File
@@ -781,6 +781,17 @@ const QuotaPageHTML = `<!DOCTYPE html>
} }
} }
.all-group-title {
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
margin: 8px 0 10px 2px;
}
.all-group-title:first-child {
margin-top: 0;
}
.all-provider-card { .all-provider-card {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -907,17 +918,12 @@ const QuotaPageHTML = `<!DOCTYPE html>
<div> <div>
<div class="brand-title"> <div class="brand-title">
用量配额 用量配额
<span class="version-tag">v0.4.1</span> <span class="version-tag">v0.4.5</span>
</div> </div>
</div> </div>
</div> </div>
<div class="action-group"> <div class="action-group">
<div id="statusBadge" class="status-badge">
<span class="status-dot"></span>
<span id="statusText">正在检查...</span>
</div>
<button id="btnSettings" class="btn" title="配置选项"> <button id="btnSettings" class="btn" title="配置选项">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle> <circle cx="12" cy="12" r="3"></circle>
@@ -938,9 +944,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
<!-- Tab Bar --> <!-- Tab Bar -->
<div id="tabBar" class="tab-bar"> <div id="tabBar" class="tab-bar">
<button type="button" class="tab-btn active" data-tab="commandcode">Command Code</button> <button type="button" class="tab-btn active" data-tab="all">All</button>
<button type="button" class="tab-btn" data-tab="commandcode">Command Code</button>
<button type="button" class="tab-btn" data-tab="opencode">OpenCode Go</button> <button type="button" class="tab-btn" data-tab="opencode">OpenCode Go</button>
<button type="button" class="tab-btn" data-tab="all">All</button>
</div> </div>
<!-- Alert Message --> <!-- Alert Message -->
@@ -976,7 +982,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
</div> </div>
<!-- Tab: Command Code --> <!-- Tab: Command Code -->
<div id="sectionCommandcode" class="tab-section active"> <div id="sectionCommandcode" class="tab-section">
<div id="ccErrorCard" class="error-card"> <div id="ccErrorCard" class="error-card">
<div class="error-card-title"> <div class="error-card-title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
@@ -1135,23 +1141,22 @@ const QuotaPageHTML = `<!DOCTYPE html>
<!-- /Tab: OpenCode Go --> <!-- /Tab: OpenCode Go -->
<!-- Tab: All --> <!-- Tab: All -->
<div id="sectionAll" class="tab-section"> <div id="sectionAll" class="tab-section active">
<div class="all-grid"> <!-- All tab: vertical groups, one group title per provider -->
<div class="all-provider-card"> <div class="all-group-title">Command Code</div>
<div class="all-provider-head"> <div class="all-provider-card">
<span class="all-provider-name">Command Code</span> <div class="all-provider-head">
<span id="allBadgeCommandcode" class="status-badge"><span class="status-dot"></span><span id="allBadgeTextCommandcode">-</span></span> <span id="allBadgeCommandcode" class="status-badge"><span class="status-dot"></span><span id="allBadgeTextCommandcode">-</span></span>
</div>
<div id="allBodyCommandcode" class="all-provider-body">尚未加载</div>
</div> </div>
<div id="allBodyCommandcode" class="all-provider-body">尚未加载</div>
</div>
<div class="all-provider-card"> <div class="all-group-title">OpenCode Go</div>
<div class="all-provider-head"> <div class="all-provider-card">
<span class="all-provider-name">OpenCode Go</span> <div class="all-provider-head">
<span id="allBadgeOpencode" class="status-badge"><span class="status-dot"></span><span id="allBadgeTextOpencode">-</span></span> <span id="allBadgeOpencode" class="status-badge"><span class="status-dot"></span><span id="allBadgeTextOpencode">-</span></span>
</div>
<div id="allBodyOpencode" class="all-provider-body">尚未加载</div>
</div> </div>
<div id="allBodyOpencode" class="all-provider-body">尚未加载</div>
</div> </div>
</div> </div>
<!-- /Tab: All --> <!-- /Tab: All -->
@@ -1181,8 +1186,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
const inputOpenCodeKeys = document.getElementById("inputOpenCodeKeys"); const inputOpenCodeKeys = document.getElementById("inputOpenCodeKeys");
const alertBox = document.getElementById("alertBox"); const alertBox = document.getElementById("alertBox");
const alertMsg = document.getElementById("alertMsg"); const alertMsg = document.getElementById("alertMsg");
const statusBadge = document.getElementById("statusBadge");
const statusText = document.getElementById("statusText");
const planBadge = document.getElementById("planBadge"); const planBadge = document.getElementById("planBadge");
// Tab sections and per-provider error cards // Tab sections and per-provider error cards
@@ -1231,6 +1234,8 @@ const QuotaPageHTML = `<!DOCTYPE html>
const lastUpdated = document.getElementById("lastUpdated"); const lastUpdated = document.getElementById("lastUpdated");
let fiveHourTargetTime = null; let fiveHourTargetTime = null;
let monthlyTargetTime = null;
let weeklyTargetTime = null; let weeklyTargetTime = null;
let timerInterval = null; let timerInterval = null;
@@ -1239,9 +1244,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
commandcode: { level: "unknown", err: null, data: null }, commandcode: { level: "unknown", err: null, data: null },
opencode: { level: "unknown", err: null, data: null } opencode: { level: "unknown", err: null, data: null }
}; };
let activeTab = "commandcode"; let activeTab = "all";
// Multi-key countdown targets: [{el, target}] across all keys x 3 windows
let ocTargets = [];
function getStoredManagementKey() { function getStoredManagementKey() {
if (inputMgmtKey.value.trim()) { if (inputMgmtKey.value.trim()) {
@@ -1337,26 +1340,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
return level === "error" ? "exceeded" : level === "unknown" ? "" : level; return level === "error" ? "exceeded" : level === "unknown" ? "" : level;
} }
function updateGlobalBadge() {
let p;
if (activeTab === "commandcode") {
p = providerState.commandcode;
} else if (activeTab === "opencode") {
p = providerState.opencode;
} else {
const a = providerState.commandcode;
const b = providerState.opencode;
p = LEVEL_RANK[a.level] >= LEVEL_RANK[b.level] ? a : b;
}
const visual = badgeVisualClass(p.level);
statusBadge.className = visual ? "status-badge " + visual : "status-badge";
statusText.textContent = BADGE_TEXT[p.level] || "正在检查...";
}
function setProviderStatus(provider, level) { function setProviderStatus(provider, level) {
providerState[provider].level = level; providerState[provider].level = level;
providerState[provider].err = null; providerState[provider].err = null;
updateGlobalBadge();
} }
function summaryRow(label, value) { function summaryRow(label, value) {
@@ -1382,9 +1368,11 @@ const QuotaPageHTML = `<!DOCTYPE html>
ocErrorMsg.textContent = msg; ocErrorMsg.textContent = msg;
ocErrorCard.classList.add("show"); ocErrorCard.classList.add("show");
} }
updateGlobalBadge();
} }
// Multi-window countdown elements carry their reset info in data
// attributes (data-reset-at ISO string, or data-reset-secs seconds),
// so updateTimers() can walk every tab容器's .oc-win-reset uniformly
function updateTimers() { function updateTimers() {
if (monthlyTargetTime) { if (monthlyTargetTime) {
timerMonthly.textContent = formatCountdown(monthlyTargetTime); timerMonthly.textContent = formatCountdown(monthlyTargetTime);
@@ -1395,11 +1383,20 @@ const QuotaPageHTML = `<!DOCTYPE html>
if (weeklyTargetTime) { if (weeklyTargetTime) {
timerWeekly.textContent = formatCountdown(weeklyTargetTime); timerWeekly.textContent = formatCountdown(weeklyTargetTime);
} }
for (let i = 0; i < ocTargets.length; i++) { const allResetEls = document.querySelectorAll(".oc-win-reset");
const t = ocTargets[i]; for (let i = 0; i < allResetEls.length; i++) {
if (t && t.el) { const el = allResetEls[i];
t.el.textContent = t.target ? formatCountdown(t.target) : "-"; let target = null;
const at = el.getAttribute("data-reset-at");
if (at) {
const t = new Date(at);
if (!isNaN(t.getTime())) target = t;
} }
if (!target) {
const secs = Number(el.getAttribute("data-reset-secs"));
if (secs > 0) target = new Date(Date.now() + secs * 1000);
}
el.textContent = target ? formatCountdown(target) : "-";
} }
} }
@@ -1480,7 +1477,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
weeklyTargetTime = null; weeklyTargetTime = null;
} }
// Per-provider status; the header badge is aggregated in updateGlobalBadge() // Per-provider status; consumed by All tab provider badges
let ccLevel; let ccLevel;
if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) { if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) {
ccLevel = "exceeded"; ccLevel = "exceeded";
@@ -1493,7 +1490,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
updateTimers(); updateTimers();
} }
function renderOpencode(data) { function renderOpencode(data) {
providerState.opencode.err = null; providerState.opencode.err = null;
providerState.opencode.data = data; providerState.opencode.data = data;
@@ -1507,8 +1503,21 @@ const QuotaPageHTML = `<!DOCTYPE html>
ocContent.style.display = ""; ocContent.style.display = "";
ocErrorCard.classList.remove("show"); ocErrorCard.classList.remove("show");
ocTargets = [];
// All tab 的 OpenCode 组与本 tab 共用同一套逐 key 渲染逻辑
const rendered = renderOpenCodeKeyGroups(keys);
ocContent.innerHTML = rendered.html;
updateTimers();
setProviderStatus("opencode", rendered.worst);
}
// Shared per-key group renderer: the OpenCode tab and the All tab's
// OpenCode group both consume this to avoid logic drift. Returns
// { html, worst }. Countdown info is stamped into data-reset-at (ISO
// string) or data-reset-secs (seconds) attributes on .oc-win-reset
// elements, so updateTimers() uniformly walks every tab container.
function renderOpenCodeKeyGroups(keys) {
const WIN_DEFS = [ const WIN_DEFS = [
{ name: "Rolling 5h", key: "rolling" }, { name: "Rolling 5h", key: "rolling" },
{ name: "Weekly", key: "weekly" }, { name: "Weekly", key: "weekly" },
@@ -1533,15 +1542,19 @@ const QuotaPageHTML = `<!DOCTYPE html>
if (rank[level] > rank[keyWorst]) keyWorst = level; if (rank[level] > rank[keyWorst]) keyWorst = level;
if (rank[level] > rank[worst]) worst = level; if (rank[level] > rank[worst]) worst = level;
// reset_at 优先;缺失/不可解析时回退 reset_in_seconds;皆无显示 "-" // reset_at 优先;缺失/不可解析时回退 reset_in_seconds;皆无显 "-"
let target = null; let resetAt = "";
let resetSecs = "";
if (w.reset_at) { if (w.reset_at) {
const t = new Date(w.reset_at); const t = new Date(w.reset_at);
if (!isNaN(t.getTime())) target = t; if (!isNaN(t.getTime())) resetAt = t.toISOString();
} }
if (!target && w.reset_in_seconds > 0) { if (!resetAt && w.reset_in_seconds > 0) {
target = new Date(Date.now() + Number(w.reset_in_seconds) * 1000); resetSecs = String(w.reset_in_seconds);
} }
const resetAttr = resetAt
? " data-reset-at=\"" + esc(resetAt) + "\""
: (resetSecs ? " data-reset-secs=\"" + esc(resetSecs) + "\"" : "");
const pctText = pct === null ? "-" : Math.round(pct) + "%"; const pctText = pct === null ? "-" : Math.round(pct) + "%";
const pctCls = level === "exceeded" ? " bad" : pct !== null && pct >= 80 ? " warn" : ""; const pctCls = level === "exceeded" ? " bad" : pct !== null && pct >= 80 ? " warn" : "";
@@ -1551,9 +1564,8 @@ const QuotaPageHTML = `<!DOCTYPE html>
'<span class="oc-win-name">' + def.name + '</span>' + '<span class="oc-win-name">' + def.name + '</span>' +
'<div class="progress-track oc-win-bar"><div class="progress-bar' + barCls + '" style="width:' + (pct === null ? 0 : pct) + '%"></div></div>' + '<div class="progress-track oc-win-bar"><div class="progress-bar' + barCls + '" style="width:' + (pct === null ? 0 : pct) + '%"></div></div>' +
'<span class="oc-win-pct' + pctCls + '">' + pctText + '</span>' + '<span class="oc-win-pct' + pctCls + '">' + pctText + '</span>' +
'<span class="oc-win-reset countdown-timer">-</span>' + '<span class="oc-win-reset countdown-timer"' + resetAttr + '>-</span>' +
'</div>'; '</div>';
ocTargets.push({ el: null, target: target });
}); });
const chipText = keyWorst === "exceeded" ? "超限" : keyWorst === "warning" ? "紧张" : "正常"; const chipText = keyWorst === "exceeded" ? "超限" : keyWorst === "warning" ? "紧张" : "正常";
@@ -1578,16 +1590,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
} }
}); });
ocContent.innerHTML = html; return { html: html, worst: worst };
// 绑定多 key x 3 窗口的倒计时元素(与 ocTargets 顺序一致)
const resetEls = ocContent.querySelectorAll(".oc-win-reset");
for (let i = 0; i < ocTargets.length; i++) {
if (resetEls[i]) ocTargets[i].el = resetEls[i];
}
updateTimers();
setProviderStatus("opencode", worst);
} }
function renderAllTab() { function renderAllTab() {
@@ -1624,12 +1627,43 @@ const QuotaPageHTML = `<!DOCTYPE html>
}); });
allBadgeCommandcode.className = "status-badge " + badgeVisualClass(cc.level); allBadgeCommandcode.className = "status-badge " + badgeVisualClass(cc.level);
allBadgeTextCommandcode.textContent = BADGE_TEXT[cc.level] || "-"; allBadgeTextCommandcode.textContent = BADGE_TEXT[cc.level] || "-";
// Plan + 三个限额窗口(百分比/进度条/重置时间),行结构与 OpenCode 卡片一致,
// 倒计时走 data-reset-at/data-reset-secs 属性,由 updateTimers() 统一刷新
const ccWins = [
{ name: "Rolling 5h", o: limits.five_hour || {} },
{ name: "Weekly", o: limits.weekly || {} },
{ name: "Monthly", o: limits.monthly || {} }
];
let ccRows = "";
ccWins.forEach(function (d) {
const w = d.o;
const pct = clampPercent(w.percentage);
const level = levelOf(pct, w.exceeded, "");
let resetAt = "";
let resetSecs = "";
if (w.reset_at) {
const t = new Date(w.reset_at);
if (!isNaN(t.getTime())) resetAt = t.toISOString();
}
if (!resetAt && w.reset_in_seconds > 0) {
resetSecs = String(w.reset_in_seconds);
}
const resetAttr = resetAt
? " data-reset-at=\"" + esc(resetAt) + "\""
: (resetSecs ? " data-reset-secs=\"" + esc(resetSecs) + "\"" : "");
const pctText = pct === null ? "-" : Math.round(pct) + "%";
const pctCls = level === "exceeded" ? " bad" : pct !== null && pct >= 80 ? " warn" : "";
const barCls = level === "exceeded" ? " danger" : level === "warning" ? " warning" : "";
ccRows += '<div class="oc-win-row">' +
'<span class="oc-win-name">' + d.name + '</span>' +
'<div class="progress-track oc-win-bar"><div class="progress-bar' + barCls + '" style="width:' + (pct === null ? 0 : pct) + '%"></div></div>' +
'<span class="oc-win-pct' + pctCls + '">' + pctText + '</span>' +
'<span class="oc-win-reset countdown-timer"' + resetAttr + '>-</span>' +
'</div>';
});
allBodyCommandcode.innerHTML = allBodyCommandcode.innerHTML =
summaryRow("Plan", esc(planName)) + summaryRow("Plan", esc(planName)) +
summaryRow("Total Credits", formatUSD(credits.total_credits)) + '<div class="oc-key-body">' + ccRows + '</div>';
summaryRow("Monthly Credits", formatUSD(credits.monthly_credits)) +
summaryRow("最差窗口", esc(worstName) + " " + (worstPct === null ? "-" : worstPct.toFixed(1) + "%")) +
miniBar(worstPct, worstLevel);
} else { } else {
allBadgeCommandcode.className = "status-badge"; allBadgeCommandcode.className = "status-badge";
allBadgeTextCommandcode.textContent = "尚未加载"; allBadgeTextCommandcode.textContent = "尚未加载";
@@ -1643,32 +1677,10 @@ const QuotaPageHTML = `<!DOCTYPE html>
allBodyOpencode.innerHTML = '<div class="error-card-title">OpenCode Go 查询失败</div><div class="error-card-msg">' + esc(oc.err) + '</div>'; allBodyOpencode.innerHTML = '<div class="error-card-title">OpenCode Go 查询失败</div><div class="error-card-msg">' + esc(oc.err) + '</div>';
} else if (oc.data) { } else if (oc.data) {
const data = oc.data; const data = oc.data;
// 多 key 契约:逐 key 紧凑列表(key_id + 最差窗口 percent + miniBar) // 多 key 契约:逐 key 完整卡片,与 OpenCode tab 共用同一渲染函数
const keys = Array.isArray(data.keys) ? data.keys : []; const keys = Array.isArray(data.keys) ? data.keys : [];
let html = ""; const rendered = renderOpenCodeKeyGroups(keys);
keys.forEach(function (k) { const html = rendered.html || '<div class="all-summary-value">无 key 数据</div>';
const keyId = '<span class="all-key-id">' + esc(k.key_id || "***") + '</span>';
if (k.ok && k.windows) {
const wins = [k.windows.rolling || {}, k.windows.weekly || {}, k.windows.monthly || {}];
let worstPct = null;
let worstLevel = "online";
wins.forEach(function (w) {
const pct = clampPercent(w.percent);
const level = levelOf(pct, w.exceeded, w.status);
if (worstPct === null || (pct !== null && pct > worstPct) || level === "exceeded") {
worstPct = pct === null ? 0 : pct;
worstLevel = level;
}
});
html += summaryRow(keyId, worstPct === null ? "-" : Math.round(worstPct) + "%") + miniBar(worstPct, worstLevel);
} else {
const label = k.status_code === 401 ? "凭据无效" : "查询错误";
html += summaryRow(keyId, '<span class="error-card-msg" style="font-size:12px">' + label + '</span>');
}
});
if (!html) {
html = '<div class="all-summary-value">无 key 数据</div>';
}
allBadgeOpencode.className = "status-badge " + badgeVisualClass(oc.level); allBadgeOpencode.className = "status-badge " + badgeVisualClass(oc.level);
allBadgeTextOpencode.textContent = BADGE_TEXT[oc.level] || "-"; allBadgeTextOpencode.textContent = BADGE_TEXT[oc.level] || "-";
allBodyOpencode.innerHTML = html; allBodyOpencode.innerHTML = html;
@@ -1688,9 +1700,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
document.getElementById("sectionCommandcode").classList.toggle("active", tab === "commandcode"); document.getElementById("sectionCommandcode").classList.toggle("active", tab === "commandcode");
document.getElementById("sectionOpencode").classList.toggle("active", tab === "opencode"); document.getElementById("sectionOpencode").classList.toggle("active", tab === "opencode");
document.getElementById("sectionAll").classList.toggle("active", tab === "all"); document.getElementById("sectionAll").classList.toggle("active", tab === "all");
updateGlobalBadge();
if (updateHash) { if (updateHash) {
if (tab === "commandcode") { if (tab === "all") {
// All 为默认 tab:激活 All 时清掉 hash(刷新回到默认)
history.replaceState(null, "", location.pathname + location.search); history.replaceState(null, "", location.pathname + location.search);
} else { } else {
location.hash = tab; location.hash = tab;
@@ -1736,8 +1748,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
settingsDrawer.classList.add("open"); settingsDrawer.classList.add("open");
showAlert("需要 CLIProxyAPI 管理密钥 (401/403)。请在上方输入框填入 Management Key 并保存。", true); showAlert("需要 CLIProxyAPI 管理密钥 (401/403)。请在上方输入框填入 Management Key 并保存。", true);
statusBadge.className = "status-badge warning";
statusText.textContent = "未授权";
return; return;
} }
@@ -1890,11 +1900,12 @@ const QuotaPageHTML = `<!DOCTYPE html>
} }
// Restore tab from location.hash, then initial fetch // Restore tab from location.hash, then initial fetch
// 无 hash 默认 All:All tab 才能通过刷新后的 #all hash 恢复
const initHash = location.hash.replace(/^#/, ""); const initHash = location.hash.replace(/^#/, "");
if (initHash === "opencode" || initHash === "all") { if (initHash === "opencode" || initHash === "commandcode") {
setActiveTab(initHash, false); setActiveTab(initHash, false);
} else { } else {
setActiveTab("commandcode", false); setActiveTab("all", false);
} }
// Initial fetch // Initial fetch
+217
View File
@@ -0,0 +1,217 @@
// pagecheck: syntax-check the embedded quota page JS (guards against
// parse-time SyntaxErrors like duplicate const that break the whole page).
const fs = require("fs");
const src = fs.readFileSync("plugin/quota_page.go", "utf8");
const m = src.match(/const QuotaPageHTML = `([\s\S]*)`/);
if (!m) {
console.error("pagecheck: QuotaPageHTML not found");
process.exit(1);
}
const js = [...m[1].matchAll(/<script>([\s\S]*?)<\/script>/g)]
.map((x) => x[1])
.join("\n");
if (!js.trim()) {
console.error("pagecheck: no <script> content found");
process.exit(1);
}
try {
new Function(js);
} catch (e) {
console.error("pagecheck: embedded JS SyntaxError:", e.message);
process.exit(1);
}
console.log("pagecheck: embedded JS syntax OK");
// --- Undeclared-identifier audit (guards against ReferenceErrors like the
// v0.4.4 `monthlyTargetTime is not defined` bug: a bare identifier read in
// updateTimers() that was never declared and only existed as a global
// property accidentally created by renderUsage()).
//
// Approach (deliberately simple/grep-style, no DOM execution):
// 1. strip comments and string literals
// 2. collect every var/let/const/function declaration name + function/
// callback/catch parameter
// 3. flag candidates: bare assignment targets (x =, x +=, x++, ...),
// bare if()/while() condition identifiers, and for-loop init identifiers
// 4. whitelist known globals; anything left is reported and fails the check
const whitelist = new Set([
// browser builtins referenced by the page
"document", "window", "localStorage", "sessionStorage", "fetch",
"setInterval", "setTimeout", "clearInterval", "clearTimeout", "console",
"alert", "Date", "Math", "Number", "String", "Boolean", "Array",
"Object", "JSON", "parseInt", "parseFloat", "isNaN", "Promise", "Error",
"escape", "unescape", "navigator", "location", "history", "URL",
"URLSearchParams", "FormData", "Headers", "Request", "Response",
"Intl", "Map", "Set", "AbortController", "requestAnimationFrame",
"cancelAnimationFrame", "structuredClone", "globalThis", "arguments",
]);
function stripLiterals(source) {
// Remove comments, string literals, and regex literals; replace with
// harmless placeholders so identifier scanning never sees string content.
// Template literals are NOT fully discarded: their ${...} interpolations
// are kept as "( ... )" so identifiers inside them stay visible.
//
// A "/" only starts a regex literal when it appears in expression
// position (previous significant token is an operator/open bracket or a
// keyword such as return/typeof). Otherwise it is division.
let out = "";
let i = 0;
const n = source.length;
// Stack of lexer contexts. Each entry: { type: "expr", depth: number } or
// { type: "tmpl" }. The initial code runs in a never-ending expr context.
const stack = [{ type: "expr", depth: Infinity }];
// Last significant (non-whitespace) emitted char + last identifier word,
// used for the regex-vs-division heuristic.
let lastSig = "";
let lastWord = "";
const KEYWORDS_BEFORE_REGEX = new Set([
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void",
"do", "else", "case", "throw", "await", "yield",
]);
const emit = (text) => {
for (const ch of text) {
if (/\s/.test(ch)) continue;
if (/[A-Za-z0-9_$]/.test(ch)) {
lastWord = /[A-Za-z0-9_$]/.test(lastSig) ? lastWord + ch : ch;
} else {
lastWord = "";
}
lastSig = ch;
}
out += text;
};
const regexAllowed = () =>
lastSig === "" ||
"(,=:[!&|?+-*/%<>~^;{".includes(lastSig) ||
KEYWORDS_BEFORE_REGEX.has(lastWord);
while (i < n) {
const ctx = stack[stack.length - 1];
const c = source[i];
const next = source[i + 1];
if (ctx.type === "tmpl") {
// Inside template literal text: skip until ` or ${ ... }
if (c === "\\") { i += 2; continue; }
if (c === "`") { i++; stack.pop(); emit(" "); continue; }
if (c === "$" && next === "{") {
i += 2;
stack.push({ type: "expr", depth: 0 });
emit(" ( ");
continue;
}
i++;
continue;
}
// code context (top-level or template ${...} expression)
if (c === "/" && next === "/") {
while (i < n && source[i] !== "\n") i++;
} else if (c === "/" && next === "*") {
i += 2;
while (i < n && !(source[i] === "*" && source[i + 1] === "/")) i++;
i += 2;
} else if (c === "/" && regexAllowed()) {
// regex literal: skip to unescaped closing / (not inside [...])
i++;
let inClass = false;
while (i < n) {
if (source[i] === "\\") { i += 2; continue; }
if (source[i] === "[") { inClass = true; i++; continue; }
if (source[i] === "]") { inClass = false; i++; continue; }
if (source[i] === "/" && !inClass) { i++; break; }
if (source[i] === "\n") break; // malformed; bail out safely
i++;
}
while (i < n && /[a-z]/i.test(source[i])) i++; // flags
emit(" / ");
} else if (c === '"' || c === "'") {
const quote = c;
i++;
while (i < n) {
if (source[i] === "\\") { i += 2; continue; }
if (source[i] === quote) { i++; break; }
if (source[i] === "\n") break;
i++;
}
emit(" " + quote + quote + " ");
} else if (c === "`") {
i++;
stack.push({ type: "tmpl" });
emit(" ");
} else {
if (c === "{") ctx.depth++;
if (c === "}") {
if (ctx.depth === 0) {
// closes a template interpolation: back into template text
stack.pop();
i++;
emit(" ) ");
continue;
}
ctx.depth--;
}
emit(c);
i++;
}
}
return out;
}
function collectDeclarations(clean) {
const declared = new Set();
const addParamList = (raw) => {
for (const p of raw.split(",")) {
const name = p.trim().split(/[\s=]/)[0].replace(/^\.\.\./, "");
if (/^[A-Za-z_$][\w$]*$/.test(name)) declared.add(name);
}
};
for (const m of clean.matchAll(/\b(?:var|let|const)\s+([A-Za-z_$][\w$]*)/g))
declared.add(m[1]);
// function declarations/expressions: name + params
for (const m of clean.matchAll(/\bfunction\s*([A-Za-z_$][\w$]*)?\s*\(([^()]*)\)/g)) {
if (m[1]) declared.add(m[1]);
addParamList(m[2]);
}
// arrow functions: (a, b) => and a =>
for (const m of clean.matchAll(/\(\s*([^()]*?)\s*\)\s*=>/g)) addParamList(m[1]);
for (const m of clean.matchAll(/(?<![\w$.(])\b([A-Za-z_$][\w$]*)\s*=>/g)) declared.add(m[1]);
// catch (e) and destructuring catch
for (const m of clean.matchAll(/\bcatch\s*\(?\s*\{?\s*([A-Za-z_$][\w$]*)/g)) declared.add(m[1]);
return declared;
}
const clean = stripLiterals(js);
const declared = collectDeclarations(clean);
const flagged = new Set();
// 1. bare assignment targets / updates: x =, x +=, x++, x--, x ??=
for (const m of clean.matchAll(/(?:^|[{};\n])\s*([A-Za-z_$][\w$]*)\s*(?:=[^=>]|[+*\/%-]?=[^=]|\+\+|\-\-)/gm)) {
const name = m[1];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
// 2. bare if()/while() condition identifiers
for (const m of clean.matchAll(/\b(?:if|while)\s*\(\s*(!*)\s*([A-Za-z_$][\w$]*)\s*(?:\)|&&|\|\||\?)/g)) {
const name = m[2];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
// 3. for-loop init without let/var: for (i = 0; ...)
for (const m of clean.matchAll(/\bfor\s*\(\s*([A-Za-z_$][\w$]*)\s*=[^=]/g)) {
const name = m[1];
if (!declared.has(name) && !whitelist.has(name)) flagged.add(name);
}
if (flagged.size > 0) {
console.error("pagecheck: undeclared identifier(s) referenced in embedded JS:");
for (const name of [...flagged].sort()) console.error(" - " + name);
console.error(
"pagecheck: fix by declaring with let/const (see v0.4.5 monthlyTargetTime bug); " +
"if this is a false positive, extend scripts/pagecheck.js"
);
process.exit(1);
}
console.log(
"pagecheck: undeclared-identifier scan OK (" + declared.size + " declarations checked)"
);