Files
model-eval-site/static/js/app.js
T

1053 lines
47 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 模型评测网站 前端逻辑 */
"use strict";
const $ = (s) => document.querySelector(s);
const $$ = (s) => Array.from(document.querySelectorAll(s));
const esc = (s) => String(s ?? "").replace(/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const fmt = (v, d = "—") => (v === null || v === undefined || isNaN(v) ? d : Number(v).toFixed(2).replace(/\.?0+$/, ""));
const fmtInt = (v, d = "—") => (v === null || v === undefined || isNaN(v) ? d : Math.round(v));
let toastTimer = null;
function toast(msg) {
const el = $("#toast");
el.textContent = msg;
el.style.display = "block";
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.style.display = "none"; }, 3200);
}
async function api(path, method = "GET", body) {
const opt = { method, headers: {} };
if (body !== undefined) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); }
const resp = await fetch(path, opt);
let j = {};
try { j = await resp.json(); } catch (e) { j = {}; }
if (!resp.ok && !j.ok) throw new Error(j.error || `请求失败(${resp.status})`);
return j;
}
function providerLabel(p) {
const m = { autodl: "Autodl", siliconflow: "SiliconFlow", "local-qwen": "本地Qwen",
openai: "OpenAI", anthropic: "Anthropic", google: "Gemini", local: "本地" };
return m[p] || p || "—";
}
const hwTag = (hw) => (hw ? `<span class="hw-tag" title="${esc(hw)}">🖥 ${esc(hw)}</span>` : "—");
/* ───────────────────────── 对比车(localStorage 持久化) ───────────────────────── */
const CART_KEY = "evalCompareCart";
function getCart() {
try { const c = JSON.parse(localStorage.getItem(CART_KEY) || "[]"); return Array.isArray(c) ? c : []; }
catch (e) { return []; }
}
function saveCart(c) { localStorage.setItem(CART_KEY, JSON.stringify(c)); }
function cartAdd(key) {
const c = getCart();
if (!c.includes(key)) { c.push(key); saveCart(c); toast("🛒 已加入对比车"); }
else { toast("已在对比车中"); }
renderCartBar();
}
function cartRemove(key) { saveCart(getCart().filter((k) => k !== key)); renderCartBar(); }
function cartClear() { saveCart([]); renderCartBar(); }
function cartModelName(key) {
const i = String(key).indexOf("|");
return i >= 0 ? String(key).slice(i + 1) : String(key);
}
function renderCartBar() {
const bar = $("#cart-bar");
if (!bar) return;
const c = getCart();
if (!c.length) { bar.hidden = true; return; }
bar.hidden = false;
const cnt = $("#cart-count");
if (cnt) cnt.textContent = c.length;
const items = $("#cart-items");
if (items) items.innerHTML = c.map((k) => `<b>${esc(cartModelName(k))}</b>`).join("、");
}
function bindCartBar() {
const bar = $("#cart-bar");
if (!bar) return;
const clear = $("#cart-clear");
if (clear) clear.addEventListener("click", cartClear);
const go = $("#cart-go");
if (go) go.addEventListener("click", () => { location.href = "/compare.html"; });
renderCartBar();
}
/* 运行速度对比:3 个系列(颜色深浅三色),前端可配置类型/轴 */
const RUN_SERIES = [
{ key: "avg_prefill_speed", label: "预填充速度", unit: "tok/s", color: "#5b94ff", depth: "浅" },
{ key: "avg_decode_speed", label: "解码速度", unit: "tok/s", color: "#2ee67a", depth: "深" },
{ key: "avg_ttft_ms", label: "首字延迟", unit: "ms", color: "#ffb43c", depth: "中" },
];
function buildRunPayload(opts) {
const series = (opts.series || RUN_SERIES).filter((s) => s.show);
const payload = {
data: opts.csv, chartType: "bar", title: opts.title || "模型运行速度对比",
theme: "default", showLegend: series.length > 1, showGrid: true,
showLabel: opts.showLabel ?? false, smoothLine: true,
dualYAxis: series.some((s) => s.axis === 1),
leftAxisName: opts.leftAxisName || "预填充速度(tok/s)",
rightAxisName: opts.rightAxisName || "解码速度(tok/s)",
seriesTypes: series.map((s) => s.type),
seriesStyles: series.map((s) => s.style),
seriesAxis: series.map((s) => s.axis),
seriesColors: series.map((s) => s.color),
width: 1100, height: 560, pixelRatio: 2,
};
if (opts.xRotate !== undefined && opts.xRotate !== "" && opts.xRotate !== null) payload.xLabelRotate = Number(opts.xRotate);
if (opts.xFont) payload.xLabelFontSize = Number(opts.xFont);
if (opts.xInterval !== undefined && opts.xInterval !== "" && opts.xInterval !== null) payload.xLabelInterval = Number(opts.xInterval);
if (!payload.dualYAxis) payload.seriesAxis = null;
return payload;
}
function navActive() {
const path = location.pathname.split("/").pop() || "index.html";
$$(".nav a").forEach((a) => {
a.classList.toggle("active", a.getAttribute("href") === path);
});
}
/* ───────────────────────── 首页:排行 ───────────────────────── */
let sortKey = "avg_decode_speed";
let sortOrder = "desc";
let lbModels = []; // [{provider, model, key}]
let lbSelected = new Set(); // selected keys
const modelKey = (p, m) => `${p}|${m}`;
async function loadStats() {
try {
const s = await api("/api/stats");
if ($("#st-models")) $("#st-models").textContent = s.models;
if ($("#st-subs")) $("#st-subs").textContent = s.submissions;
if ($("#st-accounts")) $("#st-accounts").textContent = s.accounts;
if ($("#st-samples")) $("#st-samples").innerHTML = fmtInt(s.samples_ok) + " <small>次成功采样</small>";
if ($("#st-caps")) $("#st-caps").textContent = s.capabilities;
} catch (e) { /* ignore */ }
}
function sortClick(key) {
if (sortKey === key) sortOrder = sortOrder === "desc" ? "asc" : "desc";
else { sortKey = key; sortOrder = "desc"; }
loadLeaderboard();
}
function renderLbChips() {
const box = $("#lb-chart-models");
if (!box) return;
box.innerHTML = "";
if (!lbModels.length) { box.innerHTML = '<span class="hint">暂无模型</span>'; return; }
for (const m of lbModels) {
const on = lbSelected.has(m.key);
const c = document.createElement("span");
c.className = "chip" + (on ? " on" : "");
c.title = "点击选择/取消该模型";
c.innerHTML = `<span>${esc(m.model)}</span>`;
c.addEventListener("click", () => {
if (lbSelected.has(m.key)) lbSelected.delete(m.key); else lbSelected.add(m.key);
c.classList.toggle("on");
});
box.appendChild(c);
}
}
async function loadLeaderboard() {
const tb = $("#lb tbody");
if (!tb) return;
tb.innerHTML = '<tr><td colspan="12" class="empty">加载中…</td></tr>';
let d;
try {
d = await api(`/api/leaderboard?sort=${sortKey}&order=${sortOrder}`);
} catch (e) {
tb.innerHTML = `<tr><td colspan="12" class="empty">加载失败:${esc(e.message)}</td></tr>`;
return;
}
$$("#lb th[data-sort]").forEach((th) => {
th.querySelector(".arrow").textContent = (th.dataset.sort === sortKey) ? (sortOrder === "desc" ? " ▼" : " ▲") : "";
});
if (!d.rows.length) {
tb.innerHTML = '<tr><td colspan="12" class="empty">暂无评测数据。可在后台管理「提交管理」里点击「🎲 生成演示数据」或从 llm-speed-tester 一键发送。</td></tr>';
return;
}
// 记录模型列表,用于对比图选择
lbModels = d.rows.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model) }));
if (!lbSelected.size) lbModels.forEach((m) => lbSelected.add(m.key));
else lbModels.forEach((m) => { if (!lbSelected.has(m.key)) lbSelected.add(m.key); }); // 新模型默认选中
renderLbChips();
const maxDecode = Math.max(...d.rows.map((r) => r.avg_decode_speed || 0));
tb.innerHTML = d.rows.map((r, i) => {
const meter = r.avg_decode_speed ? `<div class="meter"><div style="width:${(r.avg_decode_speed / maxDecode * 100).toFixed(1)}%"></div></div>` : "";
return `<tr>
<td class="num">${i + 1}</td>
<td><span class="pill">${esc(providerLabel(r.provider))}</span></td>
<td><a href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}" title="查看详情">${esc(r.model)}</a></td>
<td class="num">${fmtInt(r.cnt)}</td>
<td class="num">${fmtInt(r.accounts)}</td>
<td class="num"><span class="badge-decode">${fmt(r.avg_decode_speed)}</span>${meter}</td>
<td class="num">${fmt(r.avg_prefill_speed)}</td>
<td class="num">${fmt(r.avg_ttft_ms)}</td>
<td class="num">${fmt(r.best_decode)}</td>
<td>${hwTag(r.hardware)}</td>
<td class="num">${esc((r.last_tested || "").slice(5, 16))}</td>
<td><button class="btn small" data-cart="${modelKey(r.provider, r.model)}" title="加入对比车">🛒 加入对比车</button></td>
</tr>`;
}).join("");
}
let lbChartUrl = "";
async function genLbChart() {
const status = $("#lb-chart-status");
const imgWrap = $("#lb-chart-img");
if (!lbSelected.size) { status.textContent = "请至少选择一个模型"; return; }
status.textContent = "⏳ 正在生成...";
try {
// 重新请求指定模型的排行数据(对比图只包含所选模型)
const models = [...lbSelected].join(",");
const d = await api(`/api/leaderboard?sort=avg_decode_speed&order=desc&models=${encodeURIComponent(models)}`);
if (!d.rows.length) { status.textContent = "所选模型暂无数据"; return; }
const n = d.rows.length;
// 运行速度对比:预填充(左轴·空心柱) + 解码(右轴·实心柱) + 首字(虚线),三色
const series = RUN_SERIES.map((s, i) => ({
...s, show: true,
type: ["bar", "bar", "line"][i],
style: ["hollow", "solid", "dashed"][i],
axis: [0, 1, 1][i],
}));
const payload = buildRunPayload({
csv: d.chart_csv,
title: `模型运行速度对比(${n} 个模型)`,
series,
xRotate: n > 14 ? 45 : (n > 8 ? 30 : 0),
xFont: n > 14 ? 11 : 12,
});
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(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; }
const blob = await resp.blob();
if (lbChartUrl) URL.revokeObjectURL(lbChartUrl);
lbChartUrl = URL.createObjectURL(blob);
imgWrap.innerHTML = "";
const img = document.createElement("img");
img.src = lbChartUrl;
img.alt = "模型运行速度对比图";
img.onload = () => { status.textContent = `✅ 已生成(${n} 个模型)`; };
imgWrap.appendChild(img);
} catch (e) { status.textContent = "❌ " + e.message; }
}
/* ───────────────────────── 模型详情页 ───────────────────────── */
async function loadModel() {
const params = new URLSearchParams(location.search);
const provider = params.get("provider") || "";
const model = params.get("model") || "";
if (!model) { $("#model-body").innerHTML = '<div class="empty">缺少模型参数</div>'; return; }
let d;
try {
d = await api(`/api/model?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model)}`);
} catch (e) {
$("#m-sub").textContent = e.message;
return;
}
document.title = `${d.model} · 模型评测`;
$("#m-title").textContent = `${d.model} 详情`;
$("#m-sub").textContent = `${providerLabel(d.provider)} · 共 ${d.sub_cnt} 条提交`;
// 硬件汇总
const hws = {};
d.submissions.forEach((s) => { if (s.hardware) hws[s.hardware] = (hws[s.hardware] || 0) + 1; });
const hwList = Object.entries(hws).map(([hw, n]) => `${esc(hw)} ×${n}`).join("");
$("#m-hw").textContent = hwList ? `🖥 硬件:${hwList}` : "";
$("#md-submissions").innerHTML = d.submissions.map((s) => {
const conc = (s.concurrency_levels || [1]).join("/");
return `<tr>
<td><a href="#" data-sid="${s.id}" class="s-link">#${s.id}</a></td>
<td>${esc(s.account)}</td>
<td>${esc(s.created_at)}</td>
<td>${hwTag(s.hardware)}</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>
<td class="num"><span class="badge-decode">${fmt(s.avg_decode_speed)}</span></td>
<td class="num">${fmt(s.avg_stream_decode)}</td>
<td class="num">${conc}</td>
<td class="num">${fmt(s.avg_output_tokens)}</td>
<td>${s.test_name ? `<span class="pill gray" title="${esc(s.test_name)}">${esc(s.test_name.slice(0, 16))}</span>` : "—"}</td>
</tr>`;
}).join("");
window.__modelChart = d.line_payload;
window.__modelLineCsv = d.line_csv;
$("#m-chart-csv").value = d.line_csv;
$("#m-chart-img").innerHTML = '<div class="hint">点击「🎨 生成折线图」绘制</div>';
$("#m-chart-status").textContent = "";
$("#md-lengths").innerHTML = d.by_length.map((r) => `<tr>
<td class="num">${fmtInt(r.length)}</td>
<td class="num">${fmtInt(r.count)}</td>
<td class="num">${fmt(r.avg_prefill_speed)}</td>
<td class="num"><span class="badge-decode">${fmt(r.avg_decode_speed)}</span></td>
<td class="num">${fmt(r.avg_ttft_ms)}</td>
</tr>`).join("");
}
let modelChartUrl = "";
async function genModelChart() {
if (!window.__modelChart) { toast("暂无数据可画图"); return; }
const status = $("#m-chart-status");
const imgWrap = $("#m-chart-img");
status.textContent = "⏳ 正在生成...";
try {
const resp = await fetch("/api/chart", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(window.__modelChart),
});
if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; }
const blob = await resp.blob();
if (modelChartUrl) URL.revokeObjectURL(modelChartUrl);
modelChartUrl = URL.createObjectURL(blob);
imgWrap.innerHTML = "";
const img = document.createElement("img");
img.src = modelChartUrl;
img.alt = "速度随上下文长度";
img.onload = () => { status.textContent = "✅ 生成完成"; };
imgWrap.appendChild(img);
} catch (e) { status.textContent = "❌ " + e.message; }
}
/* ───────────────────────── 能力测试页 ───────────────────────── */
async function loadCapabilities() {
const grid = $("#cap-grid");
if (!grid) return;
grid.innerHTML = '<div class="hint">加载中…</div>';
let list;
try { list = await api("/api/capabilities"); }
catch (e) { grid.innerHTML = `<div class="hint">加载失败:${esc(e.message)}</div>`; return; }
if ($("#cap-sub")) $("#cap-sub").textContent = `共 ${list.length} 项能力测试`;
if (!list.length) {
grid.innerHTML = '<div class="hint">暂无能力测试内容,请到「⚙️ 后台管理 → 能力测试管理」添加。</div>';
return;
}
grid.innerHTML = list.map((c) => `
<div class="cap-card" data-cap="${c.id}">
<div class="cap-head">
<span class="cap-icon">${esc(c.icon || "🧪")}</span>
<span class="cap-name">${esc(c.name)}</span>
<span class="cap-cat"><span class="pill green">${esc(c.category || "未分类")}</span></span>
</div>
<div class="cap-desc">${esc(c.description || "暂无描述")}</div>
<div class="cap-count">📝 含 ${(c.items || []).length} 个测试项,点击查看详情</div>
</div>`).join("");
grid.querySelectorAll(".cap-card").forEach((card) => {
card.addEventListener("click", () => openCapDetail(list.find((c) => c.id === Number(card.dataset.cap))));
});
}
function openCapDetail(c) {
const mask = $("#cap-mask");
if (!mask) return;
mask.hidden = false;
$("#cap-modal-title").textContent = `${c.icon || "🧪"} ${c.name}`;
const items = (c.items || []).map((it, i) => `
<div class="cap-item">
<div class="it-title">${i + 1}. ${esc(it.title || "未命名")}</div>
<div class="it-prompt">📝 ${esc(it.prompt || "")}</div>
${it.expect ? `<div class="it-expect">🎯 期望:${esc(it.expect)}</div>` : ""}
</div>`).join("");
$("#cap-modal-body").innerHTML = `
<div class="kv" style="margin-bottom:14px">
<div class="kv-item"><div class="kv-k">分类</div><div class="kv-v">${esc(c.category || "未分类")}</div></div>
<div class="kv-item"><div class="kv-k">测试项数</div><div class="kv-v">${(c.items || []).length}</div></div>
<div class="kv-item"><div class="kv-k">更新时间</div><div class="kv-v">${esc(c.updated_at || c.created_at || "")}</div></div>
</div>
<div class="hint" style="margin-bottom:8px">${esc(c.description || "")}</div>
<h3>📝 测试项</h3>
${items || '<div class="hint">暂无测试项</div>'}
`;
}
/* ───────────────────────── 模型对比页(对比车驱动) ───────────────────────── */
let cmpModels = [];
let cmpSelected = new Set();
let cmpOrder = []; // 对比顺序:对比车加入顺序 或 默认前5
let cmpCols = new Set(["model", "avg_prefill_speed", "avg_decode_speed"]); // 默认只对比预填充+解码
let cmpRows = [];
let cmpCsv = "";
let cmpChartUrl = "";
const CMP_COL_DEFS = [
{ key: "provider", label: "提供商", type: "text" },
{ key: "model", label: "模型", type: "text" },
{ key: "hardware", label: "硬件", type: "hw" },
{ key: "cnt", label: "提交数", type: "num" },
{ key: "accounts", label: "账号数", type: "num" },
{ key: "samples_ok", label: "采样数", type: "num" },
{ key: "avg_decode_speed", label: "平均解码 tok/s", type: "num" },
{ key: "avg_prefill_speed", label: "平均预填充 tok/s", type: "num" },
{ key: "avg_stream_decode", label: "单流均解码 tok/s", type: "num" },
{ key: "avg_ttft_ms", label: "首字 ms", type: "num" },
{ key: "best_decode", label: "最佳解码 tok/s", type: "num" },
{ key: "min_decode_speed", label: "最低解码 tok/s", type: "num" },
{ key: "avg_output_tokens", label: "输出 tok", type: "num" },
{ key: "avg_total_ms", label: "总耗时 ms", type: "num" },
{ key: "last_tested", label: "最后测试", type: "time" },
];
function renderCmpColToggles() {
const box = $("#cmp-cols");
if (!box) return;
box.innerHTML = CMP_COL_DEFS.map((c) => `
<label><input type="checkbox" value="${c.key}" data-cmpcol="${c.key}" ${cmpCols.has(c.key) ? "checked" : ""}> ${esc(c.label)}</label>`).join("");
box.querySelectorAll("input[data-cmpcol]").forEach((inp) => {
inp.addEventListener("change", () => {
if (inp.checked) cmpCols.add(inp.value); else cmpCols.delete(inp.value);
renderCmpTable();
});
});
}
async function initCompare() {
const box = $("#cmp-models");
if (!box) return;
let d;
try { d = await api("/api/leaderboard?limit=500"); }
catch (e) { box.innerHTML = `<span class="hint">加载失败</span>`; return; }
cmpModels = d.rows.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model) }));
// 对比车优先(按加入顺序);否则默认前5
const cart = getCart();
const cartValid = cart.filter((k) => cmpModels.some((m) => m.key === k));
if (cartValid.length) {
cmpOrder = cartValid;
cmpSelected = new Set(cartValid);
} else {
cmpOrder = cmpModels.slice(0, 5).map((m) => m.key);
cmpSelected = new Set(cmpOrder);
}
renderCmpChips();
renderCmpColToggles();
renderSeriesCfg();
runCompare();
}
function renderCmpChips() {
const box = $("#cmp-models");
box.innerHTML = "";
if (!cmpModels.length) { box.innerHTML = '<span class="hint">暂无模型</span>'; return; }
for (const m of cmpModels) {
const on = cmpSelected.has(m.key);
const c = document.createElement("span");
c.className = "chip" + (on ? " on" : "");
c.innerHTML = `<span>${esc(m.model)}</span>`;
c.addEventListener("click", () => {
if (cmpSelected.has(m.key)) { cmpSelected.delete(m.key); cmpOrder = cmpOrder.filter((k) => k !== m.key); }
else { cmpSelected.add(m.key); if (!cmpOrder.includes(m.key)) cmpOrder.push(m.key); }
c.classList.toggle("on");
});
box.appendChild(c);
}
}
async function runCompare() {
const status = $("#cmp-status");
const tbody = $("#cmp-table tbody");
if (!cmpOrder.length) { status.textContent = "请至少选择一个模型"; return; }
status.textContent = "⏳ 正在对比...";
const models = cmpOrder.join(",");
try {
const d = await api(`/api/compare?models=${encodeURIComponent(models)}`);
cmpRows = d.rows;
cmpCsv = d.chart_csv;
renderCmpTable();
status.textContent = `✅ 已对比 ${cmpRows.length} 个模型(${cmpOrder.length} 个选中)`;
} catch (e) {
status.textContent = "❌ " + e.message;
}
}
function renderCmpTable() {
const table = $("#cmp-table");
const thead = table.querySelector("thead");
const tbody = table.querySelector("tbody");
const cols = CMP_COL_DEFS.filter((c) => cmpCols.has(c.key));
thead.innerHTML = "<tr>" + cols.map((c) => `<th class="${c.type === "num" ? "num" : ""}">${esc(c.label)}</th>`).join("") + "</tr>";
if (!cmpRows.length) { tbody.innerHTML = '<tr><td class="empty" colspan="' + cols.length + '">请先选择模型并点击「🔄 生成对比」</td></tr>'; return; }
tbody.innerHTML = cmpRows.map((r) => "<tr>" + cols.map((c) => {
const v = r[c.key];
if (c.type === "hw") return `<td>${hwTag(v)}</td>`;
if (c.type === "num") return `<td class="num">${fmt(v)}</td>`;
if (c.type === "time") return `<td class="num">${esc((v || "").slice(5, 16))}</td>`;
if (c.key === "model") return `<td><a href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}">${esc(v)}</a></td>`;
if (c.key === "provider") return `<td><span class="pill">${esc(providerLabel(v))}</span></td>`;
return `<td>${esc(v)}</td>`;
}).join("") + "</tr>").join("");
}
/* 对比图系列配置:柱状(实心/空心) / 折线(实线/虚线) / 左右双轴 / 三色深度 */
function renderSeriesCfg() {
const tbody = $("#cmp-series-cfg tbody");
if (!tbody) return;
tbody.innerHTML = RUN_SERIES.map((s, i) => {
const show = i < 2; // 默认预填充+解码
const type = ["bar", "bar", "line"][i];
const style = ["hollow", "solid", "dashed"][i];
const axis = [0, 1, 1][i];
return `<tr data-skey="${s.key}">
<td><span style="display:inline-block;width:12px;height:12px;border-radius:3px;background:${s.color};margin-right:6px;vertical-align:middle"></span>${esc(s.label)} <span class="hint">${s.unit}·${s.depth}色</span></td>
<td><input type="checkbox" class="sf-show" ${show ? "checked" : ""}></td>
<td><select class="sf-type">
<option value="bar|solid" ${type === "bar" && style === "solid" ? "selected" : ""}>柱状·实心</option>
<option value="bar|hollow" ${type === "bar" && style === "hollow" ? "selected" : ""}>柱状·空心</option>
<option value="line|solid" ${type === "line" && style === "solid" ? "selected" : ""}>折线·实线</option>
<option value="line|dashed" ${type === "line" && style === "dashed" ? "selected" : ""}>折线·虚线</option>
</select></td>
<td><select class="sf-axis">
<option value="0" ${axis === 0 ? "selected" : ""}>左轴</option>
<option value="1" ${axis === 1 ? "selected" : ""}>右轴</option>
</select></td>
<td><span class="hint" style="color:${s.color}">${s.color}</span></td>
</tr>`;
}).join("");
}
function collectSeriesCfg() {
return RUN_SERIES.map((s) => {
const tr = $(`#cmp-series-cfg tr[data-skey="${s.key}"]`);
if (!tr) return { ...s, show: false };
const show = tr.querySelector(".sf-show").checked;
const [type, style] = tr.querySelector(".sf-type").value.split("|");
const axis = Number(tr.querySelector(".sf-axis").value);
return { ...s, show, type, style, axis };
});
}
async function genCmpChart() {
const status = $("#cmp-chart-status");
const imgWrap = $("#cmp-chart-img");
if (!cmpOrder.length) { status.textContent = "请至少选择一个模型"; return; }
const series = collectSeriesCfg();
if (!series.some((s) => s.show)) { status.textContent = "请至少勾选一个系列"; return; }
status.textContent = "⏳ 正在生成...";
const xRotate = $("#cmp-xrotate").value;
const xFont = parseInt($("#cmp-xfont").value) || 12;
const xInterval = $("#cmp-xinterval").value;
try {
if (!cmpCsv) {
const models = cmpOrder.join(",");
const d = await api(`/api/compare?models=${encodeURIComponent(models)}`);
cmpCsv = d.chart_csv;
}
const payload = buildRunPayload({
csv: cmpCsv,
title: `模型运行速度对比(${cmpOrder.length} 个模型)`,
series,
xRotate: xRotate === "auto" ? "" : xRotate,
xFont,
xInterval: xInterval === "auto" ? "" : xInterval,
});
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(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; }
const blob = await resp.blob();
if (cmpChartUrl) URL.revokeObjectURL(cmpChartUrl);
cmpChartUrl = URL.createObjectURL(blob);
imgWrap.innerHTML = "";
const img = document.createElement("img");
img.src = cmpChartUrl;
img.onload = () => { status.textContent = "✅ 已生成"; };
imgWrap.appendChild(img);
} catch (e) { status.textContent = "❌ " + e.message; }
}
/* ───────────────────────── 提交详情弹窗 ───────────────────────── */
async function openSubmission(sid) {
const mask = $("#sub-mask");
if (!mask) return;
mask.hidden = false;
$("#sub-body").innerHTML = '<div class="empty">加载中…</div>';
let s;
try { s = await api(`/api/submissions/${sid}`); }
catch (e) { $("#sub-body").innerHTML = `<div class="empty">${esc(e.message)}</div>`; return; }
$("#sub-id").textContent = `#${sid}`;
const byLength = s.by_length || {};
const byConc = s.by_concurrency || {};
const lengthRows = Object.keys(byLength).sort((a, b) => a - b).map((L) => {
const bl = byLength[L];
return `<tr><td class="num">${L}</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_prompt_tokens)}</td><td class="num">${fmt(bl.avg_output_tokens)}</td></tr>`;
}).join("");
const concRows = Object.keys(byConc).sort((a, b) => a - b).map((C) => {
const bl = byConc[C];
return `<tr><td class="num">${C}</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_total_ms)}</td></tr>`;
}).join("");
const runs = s.runs || [];
const runsHtml = runs.length ? `<table class="mini"><thead><tr><th>#</th><th>上下文tok</th><th>提示词tok</th>
<th>首字ms</th><th>预填充tok/s</th><th>输出tok</th><th>解码tok/s</th><th>总耗时ms</th><th>备注</th></tr></thead><tbody>` +
runs.map((r, i) => {
const m = r.metrics || {};
return `<tr><td>${i + 1}</td><td class="num">${r.context_length || m.context_length || "—"}</td>
<td class="num">${fmt(m.prompt_tokens)}</td><td class="num">${fmt(m.ttft_ms)}</td>
<td class="num">${fmt(m.prefill_speed)}</td><td class="num">${fmt(m.output_tokens)}</td>
<td class="num">${fmt(m.decode_speed)}</td><td class="num">${fmt(m.total_ms)}</td>
<td>${r.error ? "❌" : "✅"}</td></tr>`;
}).join("") + "</tbody></table>"
: '<div class="hint">无采样明细(演示数据)</div>';
$("#sub-body").innerHTML = `
<div class="kv" style="margin-bottom:14px">
<div class="kv-item"><div class="kv-k">账号</div><div class="kv-v">${esc(s.account)}</div></div>
<div class="kv-item"><div class="kv-k">模型</div><div class="kv-v">${esc(s.model)}</div></div>
<div class="kv-item"><div class="kv-k">提供商</div><div class="kv-v">${esc(providerLabel(s.provider))}</div></div>
<div class="kv-item"><div class="kv-k">硬件</div><div class="kv-v">${hwTag(s.hardware)}</div></div>
<div class="kv-item"><div class="kv-k">测试名称</div><div class="kv-v">${esc(s.test_name || "—")}</div></div>
<div class="kv-item"><div class="kv-k">提交时间</div><div class="kv-v">${esc(s.created_at)}</div></div>
<div class="kv-item"><div class="kv-k">来源</div><div class="kv-v">${esc(s.source_site)} #${s.source_test_id}</div></div>
</div>
<h3>📊 整体指标</h3>
<div class="kv" style="margin-bottom:14px">
<div class="kv-item"><div class="kv-k">采样(成功/总数)</div><div class="kv-v">${fmtInt(s.samples_ok)} / ${fmtInt(s.samples_total)}</div></div>
<div class="kv-item"><div class="kv-k">首字延迟</div><div class="kv-v">${fmt(s.avg_ttft_ms)} ms</div></div>
<div class="kv-item"><div class="kv-k">预填充速度</div><div class="kv-v">${fmt(s.avg_prefill_speed)} tok/s</div></div>
<div class="kv-item"><div class="kv-k">解码速度</div><div class="kv-v" style="color:var(--accent2)">${fmt(s.avg_decode_speed)} tok/s</div></div>
<div class="kv-item"><div class="kv-k">单流均解码</div><div class="kv-v">${fmt(s.avg_stream_decode)} tok/s</div></div>
<div class="kv-item"><div class="kv-k">平均输出</div><div class="kv-v">${fmtInt(s.avg_output_tokens)} tok</div></div>
</div>
<h3>📏 按上下文长度</h3>
<div class="tbl-wrap" style="margin-bottom:12px"><table class="mini"><thead><tr>
<th>长度tok</th><th>首字ms</th><th>预填充tok/s</th><th>解码tok/s</th><th>提示词tok</th><th>输出tok</th>
</tr></thead><tbody>${lengthRows || '<tr><td colspan="6" class="empty">无数据</td></tr>'}</tbody></table></div>
<h3>🔀 按并发数</h3>
<div class="tbl-wrap" style="margin-bottom:12px"><table class="mini"><thead><tr>
<th>并发</th><th>首字ms</th><th>预填充tok/s</th><th>解码tok/s</th><th>单流均解码tok/s</th><th>总耗时ms</th>
</tr></thead><tbody>${concRows || '<tr><td colspan="6" class="empty">仅单流</td></tr>'}</tbody></table></div>
<h3>📈 图表</h3>
<div class="chart-img-wrap" id="sub-chart-img"><div class="hint">点击「🎨 生成图表」</div></div>
<div style="margin-top:6px"><button class="btn small primary" id="sub-chart-gen">🎨 生成图表</button>
<span class="hint" id="sub-chart-status"></span></div>
<h3>📋 采样明细</h3>
${runsHtml}
`;
let chartUrl = "";
$("#sub-chart-gen").addEventListener("click", async () => {
const status = $("#sub-chart-status");
status.textContent = "⏳ 正在生成...";
try {
const resp = await fetch(`/api/submissions/${sid}/chart`);
if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; }
const blob = await resp.blob();
if (chartUrl) URL.revokeObjectURL(chartUrl);
chartUrl = URL.createObjectURL(blob);
const wrap = $("#sub-chart-img");
wrap.innerHTML = "";
const img = document.createElement("img");
img.src = chartUrl;
img.onload = () => { status.textContent = "✅ 生成完成"; };
wrap.appendChild(img);
} catch (e) { status.textContent = "❌ " + e.message; }
});
}
/* ───────────────────────── 后台管理 ───────────────────────── */
let subPage = 1;
let subQuery = "";
let capEditId = null;
async function checkAdmin() {
try {
const d = await api("/api/admin/session");
if (d.admin) {
$("#login-box").hidden = true;
$("#admin-box").hidden = false;
$("#btn-logout").hidden = false;
loadSubmissions(true);
loadAccounts();
loadCaps();
return true;
}
} catch (e) { /* not logged in */ }
$("#login-box").hidden = false;
$("#admin-box").hidden = true;
return false;
}
function switchTab(tab) {
$$("#admin-tabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === tab));
$("#tab-subs").hidden = tab !== "subs";
$("#tab-accs").hidden = tab !== "accs";
$("#tab-caps").hidden = tab !== "caps";
}
async function loadSubmissions(resetPage = false) {
const tb = $("#subs tbody");
if (!tb) return;
if (resetPage) subPage = 1;
tb.innerHTML = '<tr><td colspan="11" class="empty">加载中…</td></tr>';
let d;
try {
d = await api(`/api/submissions?page=${subPage}&page_size=20&q=${encodeURIComponent(subQuery)}`);
} catch (e) {
tb.innerHTML = `<tr><td colspan="11" class="empty">加载失败:${esc(e.message)}</td></tr>`;
return;
}
$("#sub-total").textContent = `共 ${d.total} 条`;
$("#sub-pg").textContent = `${d.page} / ${d.pages}`;
$("#sub-prev").disabled = d.page <= 1;
$("#sub-next").disabled = d.page >= d.pages;
if (!d.items.length) {
tb.innerHTML = '<tr><td colspan="11" class="empty">暂无提交记录。可点击「🎲 生成演示数据」或从 llm-speed-tester 发送。</td></tr>';
return;
}
tb.innerHTML = d.items.map((s) => `<tr>
<td><a href="#" class="s-link" data-sid="${s.id}">#${s.id}</a></td>
<td>${esc(s.account)}</td>
<td>${esc(s.created_at)}</td>
<td><span class="pill">${esc(providerLabel(s.provider))}</span></td>
<td><a href="/model.html?provider=${encodeURIComponent(s.provider)}&model=${encodeURIComponent(s.model)}">${esc(s.model)}</a></td>
<td><span class="hw-tag" title="点击修改硬件" data-hw="${s.id}" style="cursor:pointer">${esc(s.hardware || "未标注 ✎")}</span></td>
<td class="num">${fmt(s.samples_ok)}/${fmt(s.samples_total)}</td>
<td class="num">${fmt(s.avg_prefill_speed)}</td>
<td class="num"><span class="badge-decode">${fmt(s.avg_decode_speed)}</span></td>
<td class="num">${fmt(s.avg_ttft_ms)}</td>
<td>
<button class="btn small" data-view="${s.id}">查看</button>
<button class="btn small danger" data-del="${s.id}">删除</button>
</td>
</tr>`).join("");
}
async function editHardware(sid) {
const v = prompt(`为提交 #${sid} 设置硬件/GPU(留空清除):`, "");
if (v === null) return;
try {
await api(`/api/submissions/${sid}/hardware`, "PUT", { hardware: v });
toast("硬件已更新");
loadSubmissions();
} catch (e) { toast("更新失败:" + e.message); }
}
async function loadAccounts() {
const tb = $("#accs tbody");
if (!tb) return;
let list;
try { list = await api("/api/accounts"); }
catch (e) { tb.innerHTML = `<tr><td colspan="5" class="empty">加载失败</td></tr>`; return; }
if (!list.length) {
tb.innerHTML = '<tr><td colspan="5" class="empty">暂无账号。llm-speed-tester 一键发送时账号会自动创建。</td></tr>';
return;
}
tb.innerHTML = list.map((a) => `<tr>
<td>${esc(a.name)}</td>
<td>${esc(a.remark || "—")}</td>
<td>${esc(a.created_at)}</td>
<td class="num"><span class="pill green">${fmtInt(a.cnt)} 条</span></td>
<td>
<button class="btn small" data-edit="${a.id}" data-name="${esc(a.name)}" data-remark="${esc(a.remark || "")}">编辑</button>
<button class="btn small danger" data-del="${a.id}">删除</button>
</td>
</tr>`).join("");
}
async function loadCaps() {
const tb = $("#caps tbody");
if (!tb) return;
let list;
try { list = await api("/api/admin/capabilities"); }
catch (e) { tb.innerHTML = `<tr><td colspan="9" class="empty">加载失败:${esc(e.message)}</td></tr>`; return; }
if (!list.length) {
tb.innerHTML = '<tr><td colspan="9" class="empty">暂无能力测试,点击「➕ 新增能力测试」或「🎲 生成演示能力测试」。</td></tr>';
return;
}
tb.innerHTML = list.map((c) => `<tr>
<td>${c.id}</td>
<td style="font-size:18px">${esc(c.icon || "🧪")}</td>
<td>${esc(c.name)}</td>
<td><span class="pill green">${esc(c.category || "未分类")}</span></td>
<td class="num">${(c.items || []).length}</td>
<td title="${esc(c.description || "")}">${esc((c.description || "").slice(0, 20))}</td>
<td class="num">${c.sort}</td>
<td>${c.status ? '<span class="pill green">启用</span>' : '<span class="pill gray">停用</span>'}</td>
<td>
<button class="btn small" data-edit-cap="${c.id}">编辑</button>
<button class="btn small danger" data-del-cap="${c.id}">删除</button>
</td>
</tr>`).join("");
}
function openCapEditor(c) {
capEditId = c ? c.id : null;
$("#cap-edit-title").textContent = c ? `编辑能力测试 #${c.id}` : "新增能力测试";
$("#ce-name").value = c ? c.name : "";
$("#ce-category").value = c ? c.category : "";
$("#ce-icon").value = c ? c.icon : "🧪";
$("#ce-sort").value = c ? c.sort : 0;
$("#ce-desc").value = c ? c.description : "";
$("#ce-items").value = c ? JSON.stringify(c.items || [], null, 2) : '[{"title":"题目1","prompt":"提示词内容","expect":"期望结果"}]';
$("#ce-status").checked = c ? !!c.status : true;
$("#cap-mask").hidden = false;
}
function parseItems(text) {
const t = (text || "").trim();
if (!t) return [];
let arr;
try { arr = JSON.parse(t); }
catch (e) { throw new Error("测试项 JSON 格式错误:" + e.message); }
if (!Array.isArray(arr)) throw new Error("测试项必须是 JSON 数组");
return arr;
}
async function saveCapEditor() {
const name = $("#ce-name").value.trim();
if (!name) { toast("请填写名称"); return; }
let items;
try { items = parseItems($("#ce-items").value); }
catch (e) { toast(e.message); return; }
const body = {
name, category: $("#ce-category").value.trim(), icon: $("#ce-icon").value.trim() || "🧪",
sort: parseInt($("#ce-sort").value) || 0, description: $("#ce-desc").value.trim(),
items, status: $("#ce-status").checked ? 1 : 0,
};
try {
if (capEditId) await api(`/api/admin/capabilities/${capEditId}`, "PUT", body);
else await api("/api/admin/capabilities", "POST", body);
toast("已保存");
$("#cap-mask").hidden = true;
loadCaps();
} catch (e) { toast("保存失败:" + e.message); }
}
/* ───────────────────────── 事件绑定 ───────────────────────── */
function bindIndex() {
$$("#lb th[data-sort]").forEach((th) => th.addEventListener("click", () => sortClick(th.dataset.sort)));
const gen = $("#lb-chart-gen");
if (gen) gen.addEventListener("click", genLbChart);
const all = $("#lb-chart-all");
if (all) all.addEventListener("click", () => { lbModels.forEach((m) => lbSelected.add(m.key)); renderLbChips(); });
const none = $("#lb-chart-none");
if (none) none.addEventListener("click", () => { lbSelected.clear(); renderLbChips(); });
// 对比车按钮
const tb = $("#lb tbody");
if (tb) tb.addEventListener("click", (e) => {
const btn = e.target.closest("[data-cart]");
if (btn) cartAdd(btn.dataset.cart);
});
bindCartBar();
}
function bindModel() {
const gen = $("#m-chart-gen");
if (gen) gen.addEventListener("click", genModelChart);
const copy = $("#m-chart-copy");
if (copy) copy.addEventListener("click", () => {
const ta = $("#m-chart-csv");
ta.select();
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(ta.value).then(() => toast("已复制"));
else { document.execCommand("copy"); toast("已复制"); }
});
const dl = $("#m-chart-dl");
if (dl) dl.addEventListener("click", () => {
if (!modelChartUrl) { toast("请先生成图表"); return; }
const a = document.createElement("a");
a.href = modelChartUrl;
a.download = "model_chart.png";
document.body.appendChild(a); a.click(); a.remove();
});
const body = $("#md-submissions");
if (body) body.addEventListener("click", (e) => {
const link = e.target.closest(".s-link");
if (link) { e.preventDefault(); openSubmission(link.dataset.sid); }
});
}
function bindCapabilities() {
const close = $("#cap-close");
if (close) {
close.addEventListener("click", () => { $("#cap-mask").hidden = true; });
$("#cap-mask").addEventListener("click", (e) => { if (e.target === $("#cap-mask")) $("#cap-mask").hidden = true; });
}
}
function bindCompare() {
const all = $("#cmp-all");
if (all) all.addEventListener("click", () => { cmpModels.forEach((m) => { cmpSelected.add(m.key); if (!cmpOrder.includes(m.key)) cmpOrder.push(m.key); }); renderCmpChips(); });
const none = $("#cmp-none");
if (none) none.addEventListener("click", () => { cmpSelected.clear(); cmpOrder = []; renderCmpChips(); });
const clearCart = $("#cmp-clear-cart");
if (clearCart) clearCart.addEventListener("click", () => { cartClear(); initCompare(); });
const run = $("#cmp-run");
if (run) run.addEventListener("click", runCompare);
const gen = $("#cmp-chart-gen");
if (gen) gen.addEventListener("click", genCmpChart);
renderCmpColToggles();
renderSeriesCfg();
bindCartBar();
}
function bindAdmin() {
const login = $("#btn-login");
if (login) login.addEventListener("click", async () => {
const u = $("#login-user").value.trim();
const p = $("#login-pass").value;
try {
await api("/api/admin/login", "POST", { username: u, password: p });
$("#login-err").hidden = true;
checkAdmin();
} catch (e) {
$("#login-err").hidden = false;
$("#login-err").textContent = e.message;
}
});
const logout = $("#btn-logout");
if (logout) logout.addEventListener("click", async () => {
await api("/api/admin/logout", "POST");
location.reload();
});
$$("#admin-tabs button").forEach((b) => b.addEventListener("click", () => switchTab(b.dataset.tab)));
// 提交管理
const search = $("#sub-search");
if (search) {
search.addEventListener("keydown", (e) => { if (e.key === "Enter") { subQuery = search.value.trim(); loadSubmissions(true); } });
$("#btn-search").addEventListener("click", () => { subQuery = search.value.trim(); loadSubmissions(true); });
}
$("#sub-prev").addEventListener("click", () => { if (subPage > 1) { subPage--; loadSubmissions(); } });
$("#sub-next").addEventListener("click", () => { subPage++; loadSubmissions(); });
const seed = $("#btn-seed");
if (seed) seed.addEventListener("click", async () => {
seed.disabled = true; seed.textContent = "生成中…";
try {
const r = await api("/api/seed-demo", "POST", {});
toast(`已生成 ${r.seeded} 条演示数据`);
loadSubmissions(true);
} catch (e) { toast("生成失败:" + e.message); }
seed.disabled = false; seed.textContent = "🎲 生成演示数据";
});
const subsTb = $("#subs tbody");
subsTb.addEventListener("click", (e) => {
const v = e.target.closest("[data-view]");
const d = e.target.closest("[data-del]");
const l = e.target.closest(".s-link");
const h = e.target.closest("[data-hw]");
if (v) { e.preventDefault(); openSubmission(v.dataset.view); }
if (l) { e.preventDefault(); openSubmission(l.dataset.sid); }
if (h) { editHardware(Number(h.dataset.hw)); }
if (d) {
const id = d.dataset.del;
if (confirm(`确定删除提交 #${id}`)) {
api(`/api/submissions/${id}`, "DELETE").then(() => { toast("已删除"); loadSubmissions(); });
}
}
});
// 账号管理
const addAcc = $("#btn-add-acc");
if (addAcc) addAcc.addEventListener("click", async () => {
const name = $("#acc-name").value.trim();
const remark = $("#acc-remark").value.trim();
if (!name) { toast("请输入账号名称"); return; }
try {
await api("/api/accounts", "POST", { name, remark });
toast("账号已创建");
$("#acc-name").value = ""; $("#acc-remark").value = "";
loadAccounts();
} catch (e) { toast(e.message); }
});
const accsTb = $("#accs tbody");
accsTb.addEventListener("click", (e) => {
const ed = e.target.closest("[data-edit]");
const dl = e.target.closest("[data-del]");
if (ed) {
const name = prompt("账号名称:", ed.dataset.name);
if (name === null) return;
const remark = prompt("备注:", ed.dataset.remark) || "";
api(`/api/accounts/${ed.dataset.edit}`, "PUT", { name, remark }).then(() => { toast("已保存"); loadAccounts(); });
}
if (dl) {
const id = dl.dataset.del;
if (confirm("删除该账号会同时删除其下所有提交,确定?")) {
api(`/api/accounts/${id}`, "DELETE").then(() => { toast("已删除"); loadAccounts(); });
}
}
});
// 能力测试管理
const capAdd = $("#btn-cap-add");
if (capAdd) capAdd.addEventListener("click", () => openCapEditor(null));
const capSeed = $("#btn-cap-seed");
if (capSeed) capSeed.addEventListener("click", async () => {
capSeed.disabled = true; capSeed.textContent = "生成中…";
try {
const r = await api("/api/seed-capabilities", "POST", {});
toast(`已生成 ${r.seeded} 项能力测试`);
loadCaps();
} catch (e) { toast("生成失败:" + e.message); }
capSeed.disabled = false; capSeed.textContent = "🎲 生成演示能力测试";
});
const capsTb = $("#caps tbody");
capsTb.addEventListener("click", async (e) => {
const ed = e.target.closest("[data-edit-cap]");
const dl = e.target.closest("[data-del-cap]");
if (ed) {
const list = await api("/api/admin/capabilities");
const c = list.find((x) => x.id === Number(ed.dataset.editCap));
if (c) openCapEditor(c);
}
if (dl) {
const id = dl.dataset.delCap;
if (confirm(`确定删除能力测试 #${id}`)) {
api(`/api/admin/capabilities/${id}`, "DELETE").then(() => { toast("已删除"); loadCaps(); });
}
}
});
const ceSave = $("#ce-save");
if (ceSave) ceSave.addEventListener("click", saveCapEditor);
const ceCancel = $("#ce-cancel");
if (ceCancel) ceCancel.addEventListener("click", () => { $("#cap-mask").hidden = true; });
const ceClose = $("#cap-edit-close");
if (ceClose) ceClose.addEventListener("click", () => { $("#cap-mask").hidden = true; });
// 提交详情弹窗关闭
const subClose = $("#sub-close");
if (subClose) {
subClose.addEventListener("click", () => { $("#sub-mask").hidden = true; });
$("#sub-mask").addEventListener("click", (e) => { if (e.target === $("#sub-mask")) $("#sub-mask").hidden = true; });
}
}
/* ───────────────────────── 初始化 ───────────────────────── */
document.addEventListener("DOMContentLoaded", () => {
navActive();
const page = location.pathname.split("/").pop() || "index.html";
if (page === "index.html" || page === "") {
loadStats();
loadLeaderboard();
bindIndex();
} else if (page === "model.html") {
loadModel();
bindModel();
} else if (page === "capabilities.html") {
loadCapabilities();
bindCapabilities();
} else if (page === "compare.html") {
initCompare();
bindCompare();
} else if (page === "admin.html") {
checkAdmin();
bindAdmin();
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") { $("#sub-mask").hidden = true; $("#cap-mask").hidden = true; }
});
});