mirror of
https://github.com/zgs225/cliproxy-plugin-commandcode.git
synced 2026-09-26 03:32:50 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2b8d89ba6 | ||
|
|
9d1884cf20 | ||
|
|
447ce65c9b | ||
|
|
4fdc9ad6c8 | ||
|
|
2f1b2fb821 |
@@ -7,15 +7,22 @@ else
|
||||
TARGET := commandcode.so
|
||||
endif
|
||||
|
||||
.PHONY: all build test clean lint
|
||||
.PHONY: all build test clean lint pagecheck
|
||||
|
||||
all: build
|
||||
all: build pagecheck
|
||||
|
||||
build:
|
||||
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:
|
||||
go test -v -race ./...
|
||||
$(MAKE) pagecheck
|
||||
|
||||
clean:
|
||||
rm -f commandcode.dylib commandcode.so commandcode.dll commandcode.h
|
||||
|
||||
@@ -3,6 +3,7 @@ package plugin
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -74,8 +75,8 @@ func TestHandleManagement_QuotaResource(t *testing.T) {
|
||||
if !strings.Contains(bodyStr, "用量配额") {
|
||||
t.Errorf("Body does not contain expected menu text 用量配额")
|
||||
}
|
||||
if !strings.Contains(bodyStr, "v0.4.1") {
|
||||
t.Errorf("Body does not contain version badge v0.4.0")
|
||||
if !strings.Contains(bodyStr, "v0.4.5") {
|
||||
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"}
|
||||
}}`
|
||||
|
||||
// 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
|
||||
// OpenCode handler and NOT swallowed by the generic "/usage" suffix match
|
||||
// (which would route it to the Command Code handler).
|
||||
@@ -194,7 +213,7 @@ func TestHandleManagement_OpencodeUsageRoute(t *testing.T) {
|
||||
}
|
||||
sawAuthHeader = true
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(mockOpencodeUsageJSON))
|
||||
_, _ = w.Write([]byte(mockOpencodeUsageJSONFuture()))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import (
|
||||
const (
|
||||
PluginID = "commandcode"
|
||||
PluginName = "commandcode"
|
||||
PluginVersion = "0.4.1"
|
||||
PluginVersion = "0.4.5"
|
||||
PluginAuthor = "zgs225"
|
||||
PluginRepo = "https://github.com/zgs225/cliproxy-plugin-commandcode"
|
||||
PluginLogo = "https://raw.githubusercontent.com/zgs225/cliproxy-plugin-commandcode/main/assets/logo.svg"
|
||||
|
||||
+116
-105
@@ -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 {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -907,17 +918,12 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
<div>
|
||||
<div class="brand-title">
|
||||
用量配额
|
||||
<span class="version-tag">v0.4.1</span>
|
||||
<span class="version-tag">v0.4.5</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="配置选项">
|
||||
<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>
|
||||
@@ -938,9 +944,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
|
||||
<!-- 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="all">All</button>
|
||||
</div>
|
||||
|
||||
<!-- Alert Message -->
|
||||
@@ -976,7 +982,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Command Code -->
|
||||
<div id="sectionCommandcode" class="tab-section active">
|
||||
<div id="sectionCommandcode" class="tab-section">
|
||||
<div id="ccErrorCard" class="error-card">
|
||||
<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>
|
||||
@@ -1135,23 +1141,22 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
<!-- /Tab: OpenCode Go -->
|
||||
|
||||
<!-- Tab: All -->
|
||||
<div id="sectionAll" class="tab-section">
|
||||
<div class="all-grid">
|
||||
<div class="all-provider-card">
|
||||
<div class="all-provider-head">
|
||||
<span class="all-provider-name">Command Code</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 id="sectionAll" class="tab-section active">
|
||||
<!-- All tab: vertical groups, one group title per provider -->
|
||||
<div class="all-group-title">Command Code</div>
|
||||
<div class="all-provider-card">
|
||||
<div class="all-provider-head">
|
||||
<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 class="all-provider-card">
|
||||
<div class="all-provider-head">
|
||||
<span class="all-provider-name">OpenCode Go</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 class="all-group-title">OpenCode Go</div>
|
||||
<div class="all-provider-card">
|
||||
<div class="all-provider-head">
|
||||
<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>
|
||||
<!-- /Tab: All -->
|
||||
@@ -1181,8 +1186,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
const inputOpenCodeKeys = document.getElementById("inputOpenCodeKeys");
|
||||
const alertBox = document.getElementById("alertBox");
|
||||
const alertMsg = document.getElementById("alertMsg");
|
||||
const statusBadge = document.getElementById("statusBadge");
|
||||
const statusText = document.getElementById("statusText");
|
||||
const planBadge = document.getElementById("planBadge");
|
||||
|
||||
// Tab sections and per-provider error cards
|
||||
@@ -1231,6 +1234,8 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
const lastUpdated = document.getElementById("lastUpdated");
|
||||
|
||||
let fiveHourTargetTime = null;
|
||||
let monthlyTargetTime = null;
|
||||
|
||||
let weeklyTargetTime = null;
|
||||
let timerInterval = null;
|
||||
|
||||
@@ -1239,9 +1244,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
commandcode: { level: "unknown", err: null, data: null },
|
||||
opencode: { level: "unknown", err: null, data: null }
|
||||
};
|
||||
let activeTab = "commandcode";
|
||||
// Multi-key countdown targets: [{el, target}] across all keys x 3 windows
|
||||
let ocTargets = [];
|
||||
let activeTab = "all";
|
||||
|
||||
function getStoredManagementKey() {
|
||||
if (inputMgmtKey.value.trim()) {
|
||||
@@ -1337,26 +1340,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
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) {
|
||||
providerState[provider].level = level;
|
||||
providerState[provider].err = null;
|
||||
updateGlobalBadge();
|
||||
}
|
||||
|
||||
function summaryRow(label, value) {
|
||||
@@ -1382,9 +1368,11 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
ocErrorMsg.textContent = msg;
|
||||
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() {
|
||||
if (monthlyTargetTime) {
|
||||
timerMonthly.textContent = formatCountdown(monthlyTargetTime);
|
||||
@@ -1395,11 +1383,20 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
if (weeklyTargetTime) {
|
||||
timerWeekly.textContent = formatCountdown(weeklyTargetTime);
|
||||
}
|
||||
for (let i = 0; i < ocTargets.length; i++) {
|
||||
const t = ocTargets[i];
|
||||
if (t && t.el) {
|
||||
t.el.textContent = t.target ? formatCountdown(t.target) : "-";
|
||||
const allResetEls = document.querySelectorAll(".oc-win-reset");
|
||||
for (let i = 0; i < allResetEls.length; i++) {
|
||||
const el = allResetEls[i];
|
||||
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;
|
||||
}
|
||||
|
||||
// Per-provider status; the header badge is aggregated in updateGlobalBadge()
|
||||
// Per-provider status; consumed by All tab provider badges
|
||||
let ccLevel;
|
||||
if (fiveHour.exceeded || weekly.exceeded || monthly.exceeded) {
|
||||
ccLevel = "exceeded";
|
||||
@@ -1493,7 +1490,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
|
||||
updateTimers();
|
||||
}
|
||||
|
||||
function renderOpencode(data) {
|
||||
providerState.opencode.err = null;
|
||||
providerState.opencode.data = data;
|
||||
@@ -1507,8 +1503,21 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
|
||||
ocContent.style.display = "";
|
||||
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 = [
|
||||
{ name: "Rolling 5h", key: "rolling" },
|
||||
{ name: "Weekly", key: "weekly" },
|
||||
@@ -1533,15 +1542,19 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
if (rank[level] > rank[keyWorst]) keyWorst = level;
|
||||
if (rank[level] > rank[worst]) worst = level;
|
||||
|
||||
// reset_at 优先;缺失/不可解析时回退 reset_in_seconds;皆无显示 "-"
|
||||
let target = null;
|
||||
// reset_at 优先;缺失/不可解析时回退 reset_in_seconds;皆无显 "-"
|
||||
let resetAt = "";
|
||||
let resetSecs = "";
|
||||
if (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) {
|
||||
target = new Date(Date.now() + Number(w.reset_in_seconds) * 1000);
|
||||
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" : "";
|
||||
@@ -1551,9 +1564,8 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
'<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>' +
|
||||
'<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>';
|
||||
ocTargets.push({ el: null, target: target });
|
||||
});
|
||||
|
||||
const chipText = keyWorst === "exceeded" ? "超限" : keyWorst === "warning" ? "紧张" : "正常";
|
||||
@@ -1578,16 +1590,7 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
}
|
||||
});
|
||||
|
||||
ocContent.innerHTML = html;
|
||||
|
||||
// 绑定多 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);
|
||||
return { html: html, worst: worst };
|
||||
}
|
||||
|
||||
function renderAllTab() {
|
||||
@@ -1624,12 +1627,43 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
});
|
||||
allBadgeCommandcode.className = "status-badge " + badgeVisualClass(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 =
|
||||
summaryRow("Plan", esc(planName)) +
|
||||
summaryRow("Total Credits", formatUSD(credits.total_credits)) +
|
||||
summaryRow("Monthly Credits", formatUSD(credits.monthly_credits)) +
|
||||
summaryRow("最差窗口", esc(worstName) + " " + (worstPct === null ? "-" : worstPct.toFixed(1) + "%")) +
|
||||
miniBar(worstPct, worstLevel);
|
||||
'<div class="oc-key-body">' + ccRows + '</div>';
|
||||
} else {
|
||||
allBadgeCommandcode.className = "status-badge";
|
||||
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>';
|
||||
} else if (oc.data) {
|
||||
const data = oc.data;
|
||||
// 多 key 契约:逐 key 紧凑列表(key_id + 最差窗口 percent + miniBar)
|
||||
// 多 key 契约:逐 key 完整卡片,与 OpenCode tab 共用同一渲染函数
|
||||
const keys = Array.isArray(data.keys) ? data.keys : [];
|
||||
let html = "";
|
||||
keys.forEach(function (k) {
|
||||
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>';
|
||||
}
|
||||
const rendered = renderOpenCodeKeyGroups(keys);
|
||||
const html = rendered.html || '<div class="all-summary-value">无 key 数据</div>';
|
||||
allBadgeOpencode.className = "status-badge " + badgeVisualClass(oc.level);
|
||||
allBadgeTextOpencode.textContent = BADGE_TEXT[oc.level] || "-";
|
||||
allBodyOpencode.innerHTML = html;
|
||||
@@ -1688,9 +1700,9 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
document.getElementById("sectionCommandcode").classList.toggle("active", tab === "commandcode");
|
||||
document.getElementById("sectionOpencode").classList.toggle("active", tab === "opencode");
|
||||
document.getElementById("sectionAll").classList.toggle("active", tab === "all");
|
||||
updateGlobalBadge();
|
||||
if (updateHash) {
|
||||
if (tab === "commandcode") {
|
||||
if (tab === "all") {
|
||||
// All 为默认 tab:激活 All 时清掉 hash(刷新回到默认)
|
||||
history.replaceState(null, "", location.pathname + location.search);
|
||||
} else {
|
||||
location.hash = tab;
|
||||
@@ -1736,8 +1748,6 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
settingsDrawer.classList.add("open");
|
||||
showAlert("需要 CLIProxyAPI 管理密钥 (401/403)。请在上方输入框填入 Management Key 并保存。", true);
|
||||
statusBadge.className = "status-badge warning";
|
||||
statusText.textContent = "未授权";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1890,11 +1900,12 @@ const QuotaPageHTML = `<!DOCTYPE html>
|
||||
}
|
||||
|
||||
// Restore tab from location.hash, then initial fetch
|
||||
// 无 hash 默认 All:All tab 才能通过刷新后的 #all hash 恢复
|
||||
const initHash = location.hash.replace(/^#/, "");
|
||||
if (initHash === "opencode" || initHash === "all") {
|
||||
if (initHash === "opencode" || initHash === "commandcode") {
|
||||
setActiveTab(initHash, false);
|
||||
} else {
|
||||
setActiveTab("commandcode", false);
|
||||
setActiveTab("all", false);
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
Reference in New Issue
Block a user