1384 lines
62 KiB
JavaScript
1384 lines
62 KiB
JavaScript
/* 模型评测网站 前端逻辑 */
|
||
"use strict";
|
||
|
||
const $ = (s) => document.querySelector(s);
|
||
const $$ = (s) => Array.from(document.querySelectorAll(s));
|
||
|
||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g,
|
||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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; }
|
||
});
|
||
});
|