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

1384 lines
62 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("已在对比中"); }
// 同步到首页运行速度对比图的要对比列表
if (typeof lbChartSel !== "undefined" && lbChartSel) {
lbChartSel.add(key);
if (typeof renderLbChips === "function") renderLbChips();
}
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: opts.theme || "dark", 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);
});
}
/* ───────────────────────── 首页:排行(热度前N + 搜索添加 + 动态列) ───────────────────────── */
let sortKey = "heat";
let sortOrder = "desc";
let lbAll = []; // 全部模型(含 heat/intro/framework/count_override),按热度
let lbModels = [];
let lbTopN = 10;
let lbPinned = []; // 搜索框“添加”到本区域的模型 key(按添加顺序)
let lbChartSel = new Set(); // 运行速度对比图要对比的模型(默认全部)
let lbCols = { lb_show_ttft: 0, lb_show_hardware: 1, lb_show_framework: 1, lb_merge_decode: 1, lb_merge_hwfw: 1 };
let lbQuery = "";
const modelKey = (p, m) => `${p}|${m}`;
const LB_SORT_GETTER = {
heat: (r) => r.heat || 0, cnt: (r) => r.cnt, accounts: (r) => r.accounts,
avg_decode_speed: (r) => r.avg_decode_speed || 0, avg_prefill_speed: (r) => r.avg_prefill_speed || 0,
avg_ttft_ms: (r) => r.avg_ttft_ms || 0, best_decode: (r) => r.best_decode || 0,
last_tested: (r) => r.last_tested || "",
};
const fwTag = (fw) => (fw ? `<span class="hw-tag" style="background:rgba(46,230,122,.12);border-color:rgba(46,230,122,.35);color:#8ef5b8" title="推理框架">⚙ ${esc(fw)}</span>` : "");
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"; }
applyView();
}
function cmpLb(a, b) {
const g = LB_SORT_GETTER[sortKey] || LB_SORT_GETTER.heat;
const va = g(a), vb = g(b);
let c = 0;
if (typeof va === "number" && typeof vb === "number") c = va - vb;
else c = String(va).localeCompare(String(vb));
return sortOrder === "desc" ? -c : c;
}
/* 排行榜列定义(后台可配) */
function lbColDefs() {
const cols = lbCols, d = [];
d.push({ type: "rank", label: "#", cls: "num" });
d.push({ type: "provider", label: "提供商" });
d.push({ type: "model", label: "模型 / 配置简介" });
d.push({ type: "heat", label: "🔥热度", cls: "num", sort: "heat" });
d.push({ type: "cnt", label: "提交", cls: "num", sort: "cnt" });
d.push({ type: "accounts", label: "账号", cls: "num", sort: "accounts" });
if (cols.lb_merge_decode) d.push({ type: "decode_merged", label: "解码 tok/s(均 / 最佳)", cls: "num", sort: "avg_decode_speed" });
else {
d.push({ type: "avg_decode", label: "平均解码 tok/s", cls: "num", sort: "avg_decode_speed" });
d.push({ type: "best_decode", label: "最佳解码 tok/s", cls: "num", sort: "best_decode" });
}
d.push({ type: "prefill", label: "平均预填充 tok/s", cls: "num", sort: "avg_prefill_speed" });
if (cols.lb_show_ttft) d.push({ type: "ttft", label: "首字 ms", cls: "num", sort: "avg_ttft_ms" });
if (cols.lb_merge_hwfw) d.push({ type: "hwfw", label: "硬件 / 推理框架" });
else {
if (cols.lb_show_hardware) d.push({ type: "hw", label: "硬件" });
if (cols.lb_show_framework) d.push({ type: "fw", label: "推理框架" });
}
d.push({ type: "last", label: "最后测试", cls: "num", sort: "last_tested" });
d.push({ type: "ops", label: "操作" });
return d;
}
function lbCell(r, c, idx, maxDecode) {
const key = modelKey(r.provider, r.model);
switch (c.type) {
case "rank": return `<td class="num">${idx + 1}</td>`;
case "provider": return `<td><span class="pill">${esc(providerLabel(r.provider))}</span></td>`;
case "model": {
const intro = r.intro ? `<div class="hint">${esc(r.intro)}</div>` : "";
return `<td><a href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}" title="查看详情">${esc(r.model)}</a>${intro}</td>`;
}
case "heat": return `<td class="num"><span class="pill amber">🔥 ${fmtInt(r.heat || 0)}</span></td>`;
case "cnt": return `<td class="num">${fmtInt(r.count_override > 0 ? r.count_override : r.cnt)}</td>`;
case "accounts": return `<td class="num">${fmtInt(r.accounts)}</td>`;
case "decode_merged": {
const meter = r.avg_decode_speed ? `<div class="meter"><div style="width:${((r.avg_decode_speed || 0) / maxDecode * 100).toFixed(1)}%"></div></div>` : "";
return `<td class="num"><span class="badge-decode">${fmt(r.avg_decode_speed)}</span> / <span class="hint">${fmt(r.best_decode)}</span>${meter}</td>`;
}
case "avg_decode": {
const meter = r.avg_decode_speed ? `<div class="meter"><div style="width:${((r.avg_decode_speed || 0) / maxDecode * 100).toFixed(1)}%"></div></div>` : "";
return `<td class="num"><span class="badge-decode">${fmt(r.avg_decode_speed)}</span>${meter}</td>`;
}
case "best_decode": return `<td class="num">${fmt(r.best_decode)}</td>`;
case "prefill": return `<td class="num">${fmt(r.avg_prefill_speed)}</td>`;
case "ttft": return `<td class="num">${fmt(r.avg_ttft_ms)}</td>`;
case "hwfw": return `<td>${hwTag(r.hardware)} ${fwTag(r.framework)}</td>`;
case "hw": return `<td>${hwTag(r.hardware)}</td>`;
case "fw": return `<td>${fwTag(r.framework)}</td>`;
case "last": return `<td class="num">${esc((r.last_tested || "").slice(5, 16))}</td>`;
case "ops": return `<td>
<a class="btn small" href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}" title="查看详情">详情</a>
<button class="btn small" data-cart="${key}" title="加入对比">+对比</button>
</td>`;
}
return "<td></td>";
}
function renderLbTable(rows, pinnedSet) {
const thead = $("#lb thead");
const tbody = $("#lb tbody");
if (!thead || !tbody) return;
const defs = lbColDefs();
thead.innerHTML = "<tr>" + defs.map((c) => {
const arrow = c.sort ? (c.sort === sortKey ? (sortOrder === "desc" ? " ▼" : " ▲") : "") : "";
return `<th class="${c.cls || ""}" ${c.sort ? `data-sort="${c.sort}"` : ""}>${esc(c.label)}<span class="arrow">${arrow}</span></th>`;
}).join("") + "</tr>";
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="${defs.length}" class="empty">${pinnedSet ? "请在下方搜索框中搜索并添加速度项查看" : "暂无评测数据"}</td></tr>`;
return;
}
const maxDecode = Math.max(...rows.map((r) => r.avg_decode_speed || 0));
tbody.innerHTML = rows.map((r, i) => {
const pinned = pinnedSet && pinnedSet.has(modelKey(r.provider, r.model));
const mark = pinned ? ` <span class="pill amber">📌 已添加</span>` : "";
const cells = defs.map((c, ci) => {
if (ci === 0) return `<td class="num">${i + 1}${mark}</td>`;
return lbCell(r, c, i, maxDecode);
}).join("");
return `<tr>${cells}</tr>`;
}).join("");
$$("#lb th[data-sort]").forEach((th) => th.addEventListener("click", () => sortClick(th.dataset.sort)));
}
async function loadLeaderboard() {
const tbody = $("#lb tbody");
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="13" class="empty">加载中…</td></tr>';
let d;
try {
d = await api(`/api/leaderboard?sort=heat&order=desc&limit=500`);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="13" class="empty">加载失败:${esc(e.message)}</td></tr>`;
return;
}
if (d.top_n) lbTopN = d.top_n;
if (d.columns) lbCols = d.columns;
lbAll = d.rows;
lbModels = lbAll.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model), intro: r.intro || "", heat: r.heat || 0 }));
if (!lbChartSel.size) lbModels.forEach((m) => lbChartSel.add(m.key)); // 默认对比全部
renderLbChips();
renderPinned();
applyView();
}
/* 视图 = 已添加(pinned) + 热度前N */
function applyView() {
const rows = [];
const seen = new Set();
const pinSet = new Set(lbPinned);
for (const key of lbPinned) {
const m = lbAll.find((r) => modelKey(r.provider, r.model) === key);
if (m) { rows.push(m); seen.add(key); }
}
let top = [...lbAll].sort(cmpLb);
if (pinSet.size) top = top.filter((r) => !pinSet.has(modelKey(r.provider, r.model)));
rows.push(...top.slice(0, lbTopN));
renderLbTable(rows, pinSet);
}
function renderPinned() {
const bar = $("#lb-pinned");
if (!bar) return;
if (!lbPinned.length) { bar.innerHTML = '<span class="pinned-empty">尚未添加速度项 —— 用下方搜索框搜索并点「+ 添加」可把指定速度项固定到本区域查看</span>'; return; }
bar.innerHTML = lbPinned.map((key) => {
const m = lbAll.find((r) => modelKey(r.provider, r.model) === key);
return `<span class="pinned-chip" data-pin="${key}">📌 ${esc(m ? m.model : cartModelName(key))} <span class="x" data-pinx="${key}">✕</span></span>`;
}).join("");
}
/* 运行速度对比图:要对比的模型 chips(每项可✕,清空一键去除) */
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 = lbChartSel.has(m.key);
const c = document.createElement("span");
c.className = "chip" + (on ? " on" : "");
c.title = "✕ 移出对比列表;在排行榜点「+对比」可再加回";
c.innerHTML = `<span>${esc(m.model)}</span><span class="x" data-chipx="${m.key}">✕</span>`;
c.addEventListener("click", (e) => {
if (e.target.closest("[data-chipx]")) {
lbChartSel.delete(m.key);
c.classList.remove("on");
return;
}
if (lbChartSel.has(m.key)) lbChartSel.delete(m.key); else lbChartSel.add(m.key);
c.classList.toggle("on");
});
box.appendChild(c);
}
}
/* 顶部居中搜索(选中跳详情) */
function bindTopSearch() {
const input = $("#top-search");
const drop = $("#top-search-drop");
if (!input || !drop) return;
const show = (items) => {
if (!items || !items.length) { drop.hidden = true; return; }
drop.hidden = false;
drop.innerHTML = items.map((r) => `<div class="search-item" data-key="${modelKey(r.provider, r.model)}">
<div class="si-main">${esc(r.model)} <span class="pill amber" style="margin-left:6px">🔥${fmtInt(r.heat || 0)}</span></div>
<div class="si-sub">${esc(r.intro || providerLabel(r.provider))} · 提交 ${fmtInt(r.cnt)} · 解码 ${fmt(r.avg_decode_speed)} tok/s · ${r.hardware ? esc(r.hardware) : ""} ${r.framework ? esc(r.framework) : ""}</div>
</div>`).join("");
};
input.addEventListener("focus", () => show(lbAll.slice(0, 5)));
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
const list = q ? lbAll.filter((r) => (r.model + " " + r.provider + " " + (r.intro || "")).toLowerCase().includes(q)).slice(0, 5) : lbAll.slice(0, 5);
show(list);
});
input.addEventListener("blur", () => setTimeout(() => { drop.hidden = true; }, 150));
drop.addEventListener("mousedown", (e) => {
const item = e.target.closest(".search-item");
if (!item) return;
e.preventDefault();
const key = item.dataset.key;
const m = lbModels.find((x) => x.key === key);
if (m) location.href = `/model.html?provider=${encodeURIComponent(m.provider)}&model=${encodeURIComponent(m.model)}`;
});
}
/* 排行内搜索(点击“添加”把速度项固定到本区域) */
function bindLbSearch() {
const input = $("#lb-search");
const drop = $("#lb-search-drop");
if (!input || !drop) return;
const show = (items) => {
if (!items || !items.length) { drop.hidden = true; return; }
drop.hidden = false;
drop.innerHTML = items.map((r) => {
const key = modelKey(r.provider, r.model);
const pinned = lbPinned.includes(key);
return `<div class="search-item" data-add="${key}">
<div class="si-main">${esc(r.model)} <span class="pill amber" style="margin-left:6px">🔥${fmtInt(r.heat || 0)}</span></div>
<div class="si-sub">${esc(r.intro || providerLabel(r.provider))} · 解码 ${fmt(r.avg_decode_speed)} tok/s</div>
<span class="si-act">${pinned ? "✓ 已添加" : " 添加"}</span>
</div>`;
}).join("");
};
input.addEventListener("focus", () => show(lbAll.slice(0, 5)));
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
const list = q ? lbAll.filter((r) => (r.model + " " + r.provider + " " + (r.intro || "")).toLowerCase().includes(q)).slice(0, 6) : lbAll.slice(0, 5);
show(list);
});
input.addEventListener("blur", () => setTimeout(() => { drop.hidden = true; }, 150));
drop.addEventListener("mousedown", (e) => {
const item = e.target.closest("[data-add]");
if (!item) return;
e.preventDefault();
const key = item.dataset.add;
if (!lbPinned.includes(key)) {
lbPinned.push(key);
toast("📌 已添加到本区域");
renderPinned();
applyView();
}
});
// 钉住项的 ✕
const bar = $("#lb-pinned");
if (bar) bar.addEventListener("click", (e) => {
const x = e.target.closest("[data-pinx]");
if (x) { lbPinned = lbPinned.filter((k) => k !== x.dataset.pinx); renderPinned(); applyView(); }
});
}
let lbChartUrl = "";
async function genLbChart() {
const status = $("#lb-chart-status");
const imgWrap = $("#lb-chart-img");
if (!lbChartSel.size) { status.textContent = "请先添加要对比的模型(点排行榜「+对比」或点「✅ 全选」)"; return; }
status.textContent = "⏳ 正在生成...";
try {
const models = [...lbChartSel].join(",");
const d = await api(`/api/leaderboard?sort=heat&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; }
}
function dlChartUrl() {
if (!lbChartUrl) { toast("请先生成图表"); return null; }
const a = document.createElement("a");
a.href = lbChartUrl;
a.download = "运行速度对比图.png";
document.body.appendChild(a); a.click(); a.remove();
}
/* ───────────────────────── 模型详情页 ───────────────────────── */
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();
loadSpeedItems();
loadSettings();
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";
$("#tab-speed").hidden = tab !== "speed";
$("#tab-settings").hidden = tab !== "settings";
}
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("");
}
async function loadSpeedItems() {
const tb = $("#si tbody");
if (!tb) return;
let list;
try { list = await api("/api/admin/speed-items"); }
catch (e) { tb.innerHTML = `<tr><td colspan="9" class="empty">加载失败:${esc(e.message)}</td></tr>`; return; }
if (!list.length) {
tb.innerHTML = '<tr><td colspan="10" class="empty">暂无速度项,点击「🔄 从提交同步」自动生成。</td></tr>';
return;
}
tb.innerHTML = list.map((s) => `<tr>
<td>${s.id}</td>
<td><span class="pill">${esc(providerLabel(s.provider))}</span></td>
<td>${esc(s.model)}</td>
<td class="num"><span class="pill amber">🔥 ${fmtInt(s.heat)}</span></td>
<td class="num">${fmtInt(s.count)}</td>
<td title="${esc(s.intro || "")}">${esc((s.intro || "—").slice(0, 22))}</td>
<td>${s.framework ? `<span class="hw-tag">⚙ ${esc(s.framework)}</span>` : "—"}</td>
<td>${esc(s.remark || "—")}</td>
<td class="num">${fmtInt(s.real_cnt)}</td>
<td>
<button class="btn small" data-edit-si="${s.id}" data-provider="${esc(s.provider)}" data-model="${esc(s.model)}" data-heat="${s.heat}" data-count="${s.count}" data-intro="${esc(s.intro || "")}" data-framework="${esc(s.framework || "")}" data-remark="${esc(s.remark || "")}">编辑</button>
<button class="btn small danger" data-del-si="${s.id}">删除</button>
</td>
</tr>`).join("");
}
async function loadSettings() {
const s = await api("/api/admin/settings");
if ($("#set-watermark")) $("#set-watermark").value = s.watermark || "";
if ($("#set-wmstyle")) $("#set-wmstyle").value = s.watermark_style || "corner";
if ($("#set-topn")) $("#set-topn").value = s.top_n || 10;
const cols = s.columns || {};
$$("#set-cols input[data-col]").forEach((cb) => {
cb.checked = !!cols[cb.dataset.col];
});
}
function editSpeedItem(btn) {
const provider = prompt("提供商:", btn.dataset.provider || "") ?? btn.dataset.provider;
const model = prompt("模型名称:", btn.dataset.model || "");
if (model === null) return;
const heat = prompt("热度(数字):", btn.dataset.heat || "0");
const count = prompt("个数(展示提交数,0=自动):", btn.dataset.count || "0");
const intro = prompt("配置简介:", btn.dataset.intro || "");
const framework = prompt("推理框架(如 vLLM / llama.cpp / TensorRT-LLM / SGLang):", btn.dataset.framework || "");
const remark = prompt("备注:", btn.dataset.remark || "");
const body = {
provider: provider || "", model: model || "",
heat: parseInt(heat) || 0, count: parseInt(count) || 0,
intro: intro || "", framework: framework || "", remark: remark || "",
};
api(`/api/admin/speed-items/${btn.dataset.editSi}`, "PUT", body).then(() => { toast("已保存"); loadSpeedItems(); });
}
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() {
// 顶部居中搜索(跳详情)
bindTopSearch();
// 排行内搜索(添加速度项到本区域)
bindLbSearch();
// 排行榜行:+对比 / 详情
const tb = $("#lb tbody");
if (tb) tb.addEventListener("click", (e) => {
const btn = e.target.closest("[data-cart]");
if (btn) { e.preventDefault(); cartAdd(btn.dataset.cart); }
});
// 运行速度对比图:生成 / 下载 / 全选 / 清空
const gen = $("#lb-chart-gen");
if (gen) gen.addEventListener("click", genLbChart);
const dl = $("#lb-chart-dl");
if (dl) dl.addEventListener("click", () => { if (!lbChartUrl) { toast("请先生成图表"); return; } dlChartUrl(); });
const all = $("#lb-chart-all");
if (all) all.addEventListener("click", () => { lbModels.forEach((m) => lbChartSel.add(m.key)); renderLbChips(); });
const clear = $("#lb-chart-clear");
if (clear) clear.addEventListener("click", () => { lbChartSel.clear(); renderLbChips(); });
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);
const dl = $("#cmp-chart-dl");
if (dl) dl.addEventListener("click", () => {
if (!cmpChartUrl) { toast("请先生成图表"); return; }
const a = document.createElement("a");
a.href = cmpChartUrl;
a.download = "模型运行速度对比图.png";
document.body.appendChild(a); a.click(); a.remove();
});
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; });
}
// 速度项管理
const siAdd = $("#btn-si-add");
if (siAdd) siAdd.addEventListener("click", () => {
const provider = prompt("提供商:", "") || "";
const model = prompt("模型名称:", "");
if (!model) { toast("请填写模型名称"); return; }
const heat = prompt("热度:", "0") || 0;
const count = prompt("个数(展示提交数,0=自动):", "0") || 0;
const intro = prompt("配置简介:", "") || "";
const framework = prompt("推理框架(如 vLLM / llama.cpp):", "") || "";
api("/api/admin/speed-items", "POST", { provider, model, heat: parseInt(heat) || 0, count: parseInt(count) || 0, intro, framework })
.then((r) => { if (!r.ok) throw new Error(r.error || "添加失败"); toast("已添加"); loadSpeedItems(); })
.catch((e) => toast(e.message));
});
const siSync = $("#btn-si-sync");
if (siSync) siSync.addEventListener("click", async () => {
siSync.disabled = true; siSync.textContent = "同步中…";
try {
const r = await api("/api/admin/speed-items/sync", "POST", {});
toast(`已同步 ${r.added} 个速度项`);
loadSpeedItems();
} catch (e) { toast("同步失败:" + e.message); }
siSync.disabled = false; siSync.textContent = "🔄 从提交同步";
});
const siTb = $("#si tbody");
if (siTb) siTb.addEventListener("click", (e) => {
const ed = e.target.closest("[data-edit-si]");
const dl = e.target.closest("[data-del-si]");
if (ed) editSpeedItem(ed);
if (dl) {
if (confirm(`确定删除速度项 #${dl.dataset.delSi}`)) {
api(`/api/admin/speed-items/${dl.dataset.delSi}`, "DELETE").then(() => { toast("已删除"); loadSpeedItems(); });
}
}
});
// 设置
const save = $("#btn-settings-save");
if (save) save.addEventListener("click", async () => {
const body = {
watermark_text: $("#set-watermark").value.trim(),
watermark_style: $("#set-wmstyle").value,
top_n: parseInt($("#set-topn").value) || 10,
};
$$("#set-cols input[data-col]").forEach((cb) => { body[cb.dataset.col] = cb.checked ? "1" : "0"; });
try {
await api("/api/admin/settings", "PUT", body);
const st = $("#set-status");
st.textContent = "✅ 已保存,刷新页面生效";
setTimeout(() => { st.textContent = ""; }, 3000);
toast("设置已保存");
} catch (e) { toast("保存失败:" + e.message); }
});
}
/* ───────────────────────── 初始化 ───────────────────────── */
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; }
});
});