v2.3.0 多并发测试 + 多测试结果对比
- 并发数配置:默认单流(1),预设2/4并发档,支持丝滑添加任意自定义并发数(≥1), 多档勾选时同一测试分别跑各并发档并并排对比 - 并发执行:每采样同时发起N个并行流(ThreadPoolExecutor),整批吞吐聚合 (多流prompt/output之和/批首字/批耗时),并记录每流明细streams; 并发=1 与旧版单流行为一致;每流独立随机前缀避免共享缓存 - 汇总:新增 by_concurrency(按并发分组,含单流均解码) + by_length_concurrency(长度x并发网格) + concurrency_levels - 详情页:新增「按并发数汇总」表 + 并发对比折线图(X=并发数,预填充左虚线/解码右实线, /concurrency-chart) - 多测试对比:历史表格勾选(可全选)多个测试 -> /api/compare 返回对比表+柱状图CSV+并发折线图CSV, 前端弹窗展示指标对比表 + 柱状图(预填充空心/解码实心) + 解码随并发折线图,画图CSV可复制/PNG可下载 - 通用图表代理 POST /api/chart(转发 data-chart-tool 请求体返回 PNG) - 历史列表增加「并发」列与勾选列;Excel 导出增加并发数列表/按并发分组/采样并发列 - 文档:README/API.md 同步更新
This commit is contained in:
+294
-4
@@ -26,6 +26,11 @@ const DEFAULT_CONTEXT_LENGTHS = [512, 2048, 4096, 8192, 16384, 32768, 65536, 131
|
||||
let contextLengths = [...DEFAULT_CONTEXT_LENGTHS];
|
||||
let contextLengthsActive = new Set(contextLengths);
|
||||
|
||||
// 并发数:chips 列表 + 启用集合(默认单流 [1],预设 2/4,可丝滑添加自定义并发数)
|
||||
const DEFAULT_CONCURRENCY_LEVELS = [1, 2, 4];
|
||||
let concurrencyLevels = [...DEFAULT_CONCURRENCY_LEVELS];
|
||||
let concurrencyLevelsActive = new Set([1]); // 默认单流
|
||||
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
const fmt = (v, d = "—") => (v === null || v === undefined || isNaN(v) ? d : v);
|
||||
@@ -61,6 +66,7 @@ function currentGen() {
|
||||
context_lengths: lens.length ? lens : [2048],
|
||||
max_tokens: parseInt($("#gen-max-tokens").value) || 128,
|
||||
samples: parseInt($("#gen-samples").value) || 2,
|
||||
concurrency_levels: [...concurrencyLevelsActive].sort((a, b) => a - b),
|
||||
avoid_cache: $("#gen-avoid-cache").checked,
|
||||
warmup: $("#gen-warmup").checked,
|
||||
};
|
||||
@@ -108,6 +114,49 @@ function addContextLength() {
|
||||
$("#gen-context-add").value = "";
|
||||
}
|
||||
|
||||
/* ───────────────────────── 并发数 chips ───────────────────────── */
|
||||
|
||||
function renderConcurrencyChips() {
|
||||
const box = $("#gen-concurrency");
|
||||
if (!box) return;
|
||||
box.innerHTML = "";
|
||||
if (!concurrencyLevels.length) {
|
||||
box.innerHTML = '<span class="chips-empty">暂无并发档,点击下方“添加”自定义</span>';
|
||||
return;
|
||||
}
|
||||
for (const c of concurrencyLevels) {
|
||||
const active = concurrencyLevelsActive.has(c);
|
||||
const el = document.createElement("span");
|
||||
el.className = "chip" + (active ? "" : " off");
|
||||
el.title = "点击启用/禁用并发档";
|
||||
el.innerHTML = `<span class="chip-v">${c}</span><span class="chip-x">✕</span>`;
|
||||
el.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".chip-x")) {
|
||||
concurrencyLevels = concurrencyLevels.filter((x) => x !== c);
|
||||
concurrencyLevelsActive.delete(c);
|
||||
} else {
|
||||
if (concurrencyLevelsActive.has(c)) concurrencyLevelsActive.delete(c);
|
||||
else concurrencyLevelsActive.add(c);
|
||||
}
|
||||
if (!concurrencyLevelsActive.size) concurrencyLevelsActive.add(1); // 至少保留一档
|
||||
renderConcurrencyChips();
|
||||
});
|
||||
box.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
function addConcurrency() {
|
||||
const v = parseInt($("#gen-conc-add").value);
|
||||
if (!v || v < 1) { toast("请输入有效并发数(≥1)"); return; }
|
||||
if (!concurrencyLevels.includes(v)) {
|
||||
concurrencyLevels.push(v);
|
||||
concurrencyLevelsActive.add(v);
|
||||
concurrencyLevels.sort((a, b) => a - b);
|
||||
renderConcurrencyChips();
|
||||
}
|
||||
$("#gen-conc-add").value = "";
|
||||
}
|
||||
|
||||
function updateDefaultUrlHint() {
|
||||
const p = $("#cfg-provider").value;
|
||||
$("#cfg-default-url").textContent = "默认地址:" + PROVIDER_DEFAULT_URL[p];
|
||||
@@ -296,23 +345,37 @@ function stopTest() {
|
||||
|
||||
/* ───────────────────────── 测试历史 ───────────────────────── */
|
||||
|
||||
let histSelected = new Set(); // 勾选用于对比的测试 id
|
||||
|
||||
function updateHistSelectedLabel() {
|
||||
const el = $("#hist-selected");
|
||||
if (el) el.textContent = histSelected.size ? `已选 ${histSelected.size} 个` : "";
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const list = await api("/api/tests");
|
||||
const tb = $("#history tbody");
|
||||
tb.innerHTML = "";
|
||||
const selAll = $("#hist-check-all");
|
||||
if (selAll) selAll.checked = false;
|
||||
if (!list.length) {
|
||||
tb.innerHTML = '<tr><td colspan="11" style="color:var(--muted);text-align:center">暂无测试记录</td></tr>';
|
||||
tb.innerHTML = '<tr><td colspan="13" style="color:var(--muted);text-align:center">暂无测试记录</td></tr>';
|
||||
updateHistSelectedLabel();
|
||||
return;
|
||||
}
|
||||
for (const t of list) {
|
||||
const s = t.summary || {};
|
||||
const g = t.gen || {};
|
||||
const cls = s.concurrency_levels || g.concurrency_levels || [1];
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td class="chk-col"><input type="checkbox" class="hist-chk" data-id="${t.id}" ${histSelected.has(t.id) ? "checked" : ""}></td>
|
||||
<td>#${t.id}</td>
|
||||
<td>${esc(t.created_at)}</td>
|
||||
<td title="${esc(t.name || "")}">${esc(t.name || "—")}</td>
|
||||
<td>${esc(PROVIDER_LABEL[t.provider] || t.provider)}</td>
|
||||
<td>${esc(t.model)}</td>
|
||||
<td class="num">${esc(cls.join("/"))}</td>
|
||||
<td class="num">${fmt(s.samples_ok)}/${fmt(s.samples_total)}</td>
|
||||
<td class="num">${fmt(s.avg_ttft_ms)}</td>
|
||||
<td class="num">${fmt(s.avg_prefill_speed)}</td>
|
||||
@@ -325,8 +388,131 @@ async function loadHistory() {
|
||||
</td>`;
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
updateHistSelectedLabel();
|
||||
}
|
||||
|
||||
/* ───────────────────────── 多测试对比 ───────────────────────── */
|
||||
|
||||
function cmpChartBlock(id, csv, payload, title) {
|
||||
return `<div class="chart-block">
|
||||
<div class="chart-toolbar">
|
||||
<button class="btn small primary" data-cmp-gen="${id}">🎨 生成/刷新图表</button>
|
||||
<span class="hint" data-cmp-status="${id}"></span>
|
||||
</div>
|
||||
<div class="chart-img-wrap" data-cmp-img="${id}"><div class="hint">点击“生成/刷新图表”绘制:${esc(title)}</div></div>
|
||||
<div class="chart-data-head">📋 画图数据(CSV,可一键复制)</div>
|
||||
<textarea class="chart-csv" data-cmp-csv="${id}" readonly spellcheck="false">${esc(csv)}</textarea>
|
||||
<div class="chart-toolbar" style="margin-top:6px">
|
||||
<button class="btn small" data-cmp-copy="${id}">📋 复制画图数据</button>
|
||||
<button class="btn small" data-cmp-dl="${id}">⬇ 下载 PNG</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function openCompare() {
|
||||
const ids = [...histSelected];
|
||||
if (!ids.length) { toast("请先在测试历史中勾选要对比的测试"); return; }
|
||||
const mask = $("#cmp-mask");
|
||||
const body = $("#cmp-body");
|
||||
mask.hidden = false;
|
||||
body.innerHTML = '<div class="hint">⏳ 正在汇总对比数据...</div>';
|
||||
let d;
|
||||
try {
|
||||
d = await api("/api/compare", "POST", { ids });
|
||||
} catch (e) {
|
||||
body.innerHTML = '<div class="conn-result fail">对比失败:' + esc(e.message) + "</div>"; return;
|
||||
}
|
||||
if (!d.ok) {
|
||||
body.innerHTML = '<div class="conn-result fail">' + esc(d.error || "对比失败") + "</div>"; return;
|
||||
}
|
||||
window.__cmpPayloads = {
|
||||
bar: d.bar_payload,
|
||||
line: d.line ? d.line.payload : null,
|
||||
};
|
||||
|
||||
const table = `<table class="mini compare"><thead><tr>
|
||||
<th>测试</th><th>时间</th><th>名称</th><th>模型</th><th>并发档</th><th>采样(成功/总数)</th>
|
||||
<th>首字ms</th><th>预填充tok/s</th><th>解码tok/s</th><th>单流均解码tok/s</th><th>输出tok</th><th>总耗时ms</th>
|
||||
</tr></thead><tbody>` +
|
||||
d.rows.map((r) => `<tr>
|
||||
<td><button class="btn link" data-vd="${r.id}">${esc(r.label)}</button></td>
|
||||
<td>${esc(r.created_at)}</td>
|
||||
<td title="${esc(r.name || "")}">${esc(r.name || "—")}</td>
|
||||
<td>${esc(r.model)}</td>
|
||||
<td>${(r.concurrency_levels || []).join("/") || "1"}</td>
|
||||
<td class="num">${fmt(r.samples_ok)}/${fmt(r.samples_total)}</td>
|
||||
<td class="num">${fmt(r.avg_ttft_ms)}</td>
|
||||
<td class="num">${fmt(r.avg_prefill_speed)}</td>
|
||||
<td class="num">${fmt(r.avg_decode_speed)}</td>
|
||||
<td class="num">${fmt(r.avg_stream_decode)}</td>
|
||||
<td class="num">${fmt(r.avg_output_tokens)}</td>
|
||||
<td class="num">${fmt(r.avg_total_ms)}</td>
|
||||
</tr>`).join("") + `</tbody></table>`;
|
||||
|
||||
const lineBlock = d.line
|
||||
? cmpChartBlock("line", d.line.csv, d.line.payload, "解码速度随并发变化(各测试对比)")
|
||||
: '<div class="hint">未生成并发折线图:所选测试需至少有 2 个共同并发档(同一批并发数都测过)。可改用上方柱状图对比。</div>';
|
||||
|
||||
body.innerHTML = `
|
||||
<h3>📊 指标对比表(点击测试名可查看详情)</h3>
|
||||
${table}
|
||||
<h3>📈 柱状图:速度对比</h3>
|
||||
${cmpChartBlock("bar", d.bar_csv, d.bar_payload, "预填充空心柱 / 解码实心柱")}
|
||||
<h3>📈 折线图:解码速度随并发变化</h3>
|
||||
${lineBlock}
|
||||
`;
|
||||
|
||||
// 绑定图表生成/复制/下载
|
||||
body.querySelectorAll("[data-cmp-gen]").forEach((btn) => {
|
||||
const id = btn.dataset.cmpGen;
|
||||
const payload = window.__cmpPayloads[id];
|
||||
const imgWrap = body.querySelector(`[data-cmp-img="${id}"]`);
|
||||
const statusEl = body.querySelector(`[data-cmp-status="${id}"]`);
|
||||
const csvTa = body.querySelector(`[data-cmp-csv="${id}"]`);
|
||||
let url = "";
|
||||
btn.addEventListener("click", async () => {
|
||||
statusEl.textContent = "⏳ 正在生成...";
|
||||
try {
|
||||
const resp = await fetch("/api/chart", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const j = await resp.json().catch(() => ({}));
|
||||
statusEl.textContent = "❌ " + (j.error || "生成失败"); return;
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
url = URL.createObjectURL(blob);
|
||||
imgWrap.innerHTML = "";
|
||||
const img = document.createElement("img");
|
||||
img.src = url;
|
||||
img.style.maxWidth = "100%";
|
||||
img.onload = () => { statusEl.textContent = "✅ 生成完成"; };
|
||||
imgWrap.appendChild(img);
|
||||
} catch (e) { statusEl.textContent = "❌ " + e.message; }
|
||||
});
|
||||
body.querySelector(`[data-cmp-copy="${id}"]`).addEventListener("click", () => {
|
||||
csvTa.select();
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(csvTa.value).then(() => toast("画图数据已复制"));
|
||||
else document.execCommand("copy");
|
||||
});
|
||||
body.querySelector(`[data-cmp-dl="${id}"]`).addEventListener("click", () => {
|
||||
if (!url) { toast("请先生成图表"); return; }
|
||||
const a = document.createElement("a");
|
||||
a.href = url; a.download = `compare_${id}.png`;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
});
|
||||
});
|
||||
|
||||
// 对比表里的“查看”按钮
|
||||
body.querySelectorAll("[data-vd]").forEach((b) => {
|
||||
b.addEventListener("click", () => viewDetail(Number(b.dataset.vd)));
|
||||
});
|
||||
}
|
||||
|
||||
function closeCompare() { $("#cmp-mask").hidden = true; }
|
||||
|
||||
/* ───────────────────────── 详情弹窗 ───────────────────────── */
|
||||
|
||||
async function viewDetail(id) {
|
||||
@@ -362,6 +548,32 @@ async function viewDetail(id) {
|
||||
byLengthHtml = '<div class="hint">无成功采样数据</div>';
|
||||
}
|
||||
|
||||
// 按并发数分组汇总(多并发测试的对比数据)
|
||||
const byConc = s.by_concurrency || {};
|
||||
let byConcHtml;
|
||||
const concs = Object.keys(byConc).sort((a, b) => a - b);
|
||||
if (concs.length > 1) {
|
||||
byConcHtml = `<table class="mini"><thead><tr>
|
||||
<th>并发数</th><th>采样(成功/总数)</th><th>首字ms</th><th>预填充tok/s</th>
|
||||
<th>解码tok/s</th><th>单流均解码tok/s</th><th>输出tok</th><th>总耗时ms</th>
|
||||
</tr></thead><tbody>` +
|
||||
concs.map((C) => {
|
||||
const bl = byConc[C] || {};
|
||||
return `<tr>
|
||||
<td class="num">${C}</td>
|
||||
<td class="num">${fmt(bl.samples_ok)}/${fmt(bl.samples_total)}</td>
|
||||
<td class="num">${fmt(bl.avg_ttft_ms)}</td>
|
||||
<td class="num">${fmt(bl.avg_prefill_speed)}</td>
|
||||
<td class="num">${fmt(bl.avg_decode_speed)}</td>
|
||||
<td class="num">${fmt(bl.avg_stream_decode)}</td>
|
||||
<td class="num">${fmt(bl.avg_output_tokens)}</td>
|
||||
<td class="num">${fmt(bl.avg_total_ms)}</td>
|
||||
</tr>`;
|
||||
}).join("") + `</tbody></table>`;
|
||||
} else {
|
||||
byConcHtml = '<div class="hint">本次仅测试了单流(并发 1),无并发对比数据;勾选多个并发档后再测即可对比。</div>';
|
||||
}
|
||||
|
||||
let runsHtml;
|
||||
if (runs.length) {
|
||||
runsHtml = `<table class="mini"><thead><tr>
|
||||
@@ -421,6 +633,9 @@ async function viewDetail(id) {
|
||||
<h3>📏 按上下文长度汇总</h3>
|
||||
${byLengthHtml}
|
||||
|
||||
<h3>🔀 按并发数汇总(整批吞吐)</h3>
|
||||
${byConcHtml}
|
||||
|
||||
<h3>📈 折线图(预填充左轴虚线 / 解码右轴实线)</h3>
|
||||
<div class="chart-block">
|
||||
<div class="chart-toolbar">
|
||||
@@ -438,12 +653,30 @@ async function viewDetail(id) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>🔀 并发对比折线图(X=并发数)</h3>
|
||||
<div class="chart-block">
|
||||
<div class="chart-toolbar">
|
||||
<button class="btn small primary" id="dt-cc-gen">🎨 生成/刷新图表</button>
|
||||
<span class="hint" id="dt-cc-status"></span>
|
||||
</div>
|
||||
<div class="chart-img-wrap" id="dt-cc-img">
|
||||
<div class="hint">X 轴=并发数,左轴=预填充(虚线),右轴=解码(实线),直观看出吞吐随并发的变化</div>
|
||||
</div>
|
||||
<div class="chart-data-head">📋 画图数据(CSV,可一键复制)</div>
|
||||
<textarea class="chart-csv" id="dt-cc-csv" readonly spellcheck="false" placeholder="(生成图表后自动填充,或手动复制)"></textarea>
|
||||
<div class="chart-toolbar" style="margin-top:6px">
|
||||
<button class="btn small" id="dt-cc-copy">📋 复制画图数据</button>
|
||||
<button class="btn small" id="dt-cc-download">⬇ 下载图表 PNG</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>⚙️ 测试参数</h3>
|
||||
<div class="kv">
|
||||
<div class="kv-item"><div class="kv-k">测试名称</div><div class="kv-v">${esc(t.name || "—")}</div></div>
|
||||
<div class="kv-item"><div class="kv-k">上下文长度</div><div class="kv-v">${(g.context_lengths || []).join(" / ") || "—"} tok</div></div>
|
||||
<div class="kv-item"><div class="kv-k">并发数</div><div class="kv-v">${(s.concurrency_levels || g.concurrency_levels || [1]).join(" / ")}</div></div>
|
||||
<div class="kv-item"><div class="kv-k">解码输出长度</div><div class="kv-v">${g.max_tokens ?? "—"} tok</div></div>
|
||||
<div class="kv-item"><div class="kv-k">每个长度采样</div><div class="kv-v">${g.samples ?? "—"}</div></div>
|
||||
<div class="kv-item"><div class="kv-k">每个组合采样</div><div class="kv-v">${g.samples ?? "—"}</div></div>
|
||||
<div class="kv-item"><div class="kv-k">预热(空转)</div><div class="kv-v">${g.warmup === false ? "关" : "开"}</div></div>
|
||||
<div class="kv-item"><div class="kv-k">避免缓存</div><div class="kv-v">${g.avoid_cache ? "开" : "关"}</div></div>
|
||||
<div class="kv-item"><div class="kv-k">温度</div><div class="kv-v">${fmt(cfg.temperature)}</div></div>
|
||||
@@ -500,6 +733,41 @@ async function viewDetail(id) {
|
||||
a.download = `llm_speed_chart_${id}.png`;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
});
|
||||
|
||||
// 并发对比折线图:生成/刷新 + 复制数据 + 下载
|
||||
const ccGen = $("#dt-cc-gen");
|
||||
const ccCsv = $("#dt-cc-csv");
|
||||
const ccImg = $("#dt-cc-img");
|
||||
const ccStatus = $("#dt-cc-status");
|
||||
let ccUrl = "";
|
||||
const genCcChart = () => {
|
||||
ccStatus.textContent = "⏳ 正在生成...";
|
||||
fetch(`/api/tests/${id}/concurrency-chart-data`).then((r) => r.json()).then((d) => {
|
||||
if (!d.ok) { ccStatus.textContent = "❌ " + (d.error || "生成失败"); return; }
|
||||
ccCsv.value = d.csv;
|
||||
ccUrl = `/api/tests/${id}/concurrency-chart?t=${Date.now()}`;
|
||||
const img = document.createElement("img");
|
||||
img.src = ccUrl;
|
||||
img.alt = "并发对比折线图";
|
||||
img.style.maxWidth = "100%";
|
||||
img.onload = () => { ccImg.innerHTML = ""; ccImg.appendChild(img); ccStatus.textContent = "✅ 生成完成"; };
|
||||
img.onerror = () => { ccStatus.textContent = "❌ 图表生成失败"; };
|
||||
}).catch((e) => { ccStatus.textContent = "❌ " + e.message; });
|
||||
};
|
||||
ccGen.addEventListener("click", genCcChart);
|
||||
$("#dt-cc-copy").addEventListener("click", () => {
|
||||
if (!ccCsv.value) { toast("暂无画图数据,请先生成图表"); return; }
|
||||
ccCsv.select();
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(ccCsv.value).then(() => toast("画图数据已复制"));
|
||||
else { document.execCommand("copy"); toast("画图数据已复制"); }
|
||||
});
|
||||
$("#dt-cc-download").addEventListener("click", () => {
|
||||
if (!ccUrl) { toast("请先生成图表"); return; }
|
||||
const a = document.createElement("a");
|
||||
a.href = ccUrl;
|
||||
a.download = `llm_concurrency_chart_${id}.png`;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function closeDetail() { $("#detail-mask").hidden = true; }
|
||||
@@ -599,10 +867,13 @@ function bind() {
|
||||
$("#btn-cancel").addEventListener("click", stopTest);
|
||||
$("#btn-context-add").addEventListener("click", addContextLength);
|
||||
$("#gen-context-add").addEventListener("keydown", (e) => { if (e.key === "Enter") addContextLength(); });
|
||||
$("#btn-conc-add").addEventListener("click", addConcurrency);
|
||||
$("#gen-conc-add").addEventListener("keydown", (e) => { if (e.key === "Enter") addConcurrency(); });
|
||||
|
||||
$("#btn-clear-console").addEventListener("click", clearConsole);
|
||||
$("#btn-export-log").addEventListener("click", exportCurrentLog);
|
||||
$("#btn-refresh-history").addEventListener("click", loadHistory);
|
||||
$("#btn-compare").addEventListener("click", openCompare);
|
||||
|
||||
$("#history tbody").addEventListener("click", (e) => {
|
||||
const v = e.target.closest("[data-view]");
|
||||
@@ -613,17 +884,35 @@ function bind() {
|
||||
if (d) {
|
||||
const id = Number(d.dataset.del);
|
||||
if (confirm(`确定删除测试 #${id} 及其全部日志?`)) {
|
||||
api(`/api/tests/${id}`, "DELETE").then(() => loadHistory());
|
||||
histSelected.delete(id);
|
||||
api(`/api/tests/${id}`, "DELETE").then(() => { loadHistory(); updateHistSelectedLabel(); });
|
||||
}
|
||||
}
|
||||
});
|
||||
// 历史勾选(用于多测试对比)
|
||||
$("#history tbody").addEventListener("change", (e) => {
|
||||
const chk = e.target.closest(".hist-chk");
|
||||
if (!chk) return;
|
||||
const id = Number(chk.dataset.id);
|
||||
if (chk.checked) histSelected.add(id);
|
||||
else histSelected.delete(id);
|
||||
updateHistSelectedLabel();
|
||||
});
|
||||
$("#hist-check-all").addEventListener("change", (e) => {
|
||||
$$(".hist-chk").forEach((c) => { c.checked = e.target.checked; });
|
||||
histSelected.clear();
|
||||
if (e.target.checked) $$(".hist-chk").forEach((c) => histSelected.add(Number(c.dataset.id)));
|
||||
updateHistSelectedLabel();
|
||||
});
|
||||
|
||||
$("#dt-close").addEventListener("click", closeDetail);
|
||||
$("#detail-mask").addEventListener("click", (e) => { if (e.target === $("#detail-mask")) closeDetail(); });
|
||||
$("#cmp-close").addEventListener("click", closeCompare);
|
||||
$("#cmp-mask").addEventListener("click", (e) => { if (e.target === $("#cmp-mask")) closeCompare(); });
|
||||
$("#dt-export").addEventListener("click", exportDetail);
|
||||
$("#dt-export-xlsx").addEventListener("click", () => { if (window.__detail) exportXlsx(window.__detail.id); });
|
||||
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeDetail(); });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { closeDetail(); closeCompare(); } });
|
||||
}
|
||||
|
||||
/* ───────────────────────── 初始化 ───────────────────────── */
|
||||
@@ -631,6 +920,7 @@ function bind() {
|
||||
(async function init() {
|
||||
bind();
|
||||
renderChips();
|
||||
renderConcurrencyChips();
|
||||
updateDefaultUrlHint();
|
||||
loadConfigs();
|
||||
loadHistory();
|
||||
|
||||
Reference in New Issue
Block a user