482 lines
22 KiB
JavaScript
482 lines
22 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));
|
|||
|
|
const toastEl = () => $("#toast");
|
|||
|
|
let toastTimer = null;
|
|||
|
|
function toast(msg) {
|
|||
|
|
const el = toastEl();
|
|||
|
|
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);
|
|||
|
|
const j = await resp.json().catch(() => ({}));
|
|||
|
|
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 || "—";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 顶部导航高亮 */
|
|||
|
|
function navActive() {
|
|||
|
|
const path = location.pathname.split("/").pop() || "index.html";
|
|||
|
|
$$(".nav a").forEach((a) => {
|
|||
|
|
a.classList.toggle("active", a.getAttribute("href") === path);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ───────────────────────── 首页:排行 ───────────────────────── */
|
|||
|
|
let sortKey = "avg_decode_speed";
|
|||
|
|
let sortOrder = "desc";
|
|||
|
|
|
|||
|
|
async function loadStats() {
|
|||
|
|
try {
|
|||
|
|
const s = await api("/api/stats");
|
|||
|
|
$("#st-models").textContent = s.models;
|
|||
|
|
$("#st-subs").textContent = s.submissions;
|
|||
|
|
$("#st-accounts").textContent = s.accounts;
|
|||
|
|
$("#st-samples").innerHTML = fmtInt(s.samples_ok) + " <small>次成功采样</small>";
|
|||
|
|
} catch (e) { /* ignore */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sortClick(key) {
|
|||
|
|
if (sortKey === key) sortOrder = sortOrder === "desc" ? "asc" : "desc";
|
|||
|
|
else { sortKey = key; sortOrder = "desc"; }
|
|||
|
|
loadLeaderboard();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadLeaderboard() {
|
|||
|
|
const tb = $("#lb tbody");
|
|||
|
|
if (!tb) return;
|
|||
|
|
tb.innerHTML = '<tr><td colspan="9" class="empty">加载中…</td></tr>';
|
|||
|
|
let d;
|
|||
|
|
try {
|
|||
|
|
d = await api(`/api/leaderboard?sort=${sortKey}&order=${sortOrder}`);
|
|||
|
|
} catch (e) {
|
|||
|
|
tb.innerHTML = `<tr><td colspan="9" class="empty">加载失败:${esc(e.message)}</td></tr>`;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
$$("#lb th[data-sort]").forEach((th) => {
|
|||
|
|
th.querySelector(".arrow").textContent = (th.dataset.sort === sortKey) ? (sortOrder === "desc" ? " ▼" : " ▲") : "";
|
|||
|
|
});
|
|||
|
|
if (!d.rows.length) {
|
|||
|
|
tb.innerHTML = '<tr><td colspan="9" class="empty">暂无评测数据。可在「提交管理」页面点击「🎲 生成演示数据」,或从 llm-speed-tester 一键发送。</td></tr>';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const maxDecode = Math.max(...d.rows.map((r) => r.avg_decode_speed || 0));
|
|||
|
|
tb.innerHTML = d.rows.map((r, i) => {
|
|||
|
|
const meter = r.avg_decode_speed ? `<div class="meter"><div style="width:${(r.avg_decode_speed / maxDecode * 100).toFixed(1)}%"></div></div>` : "";
|
|||
|
|
return `<tr>
|
|||
|
|
<td class="num">${i + 1}</td>
|
|||
|
|
<td><span class="pill">${esc(providerLabel(r.provider))}</span></td>
|
|||
|
|
<td><a href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}" title="查看详情">${esc(r.model)}</a></td>
|
|||
|
|
<td class="num">${fmtInt(r.cnt)}</td>
|
|||
|
|
<td class="num">${fmtInt(r.accounts)}</td>
|
|||
|
|
<td class="num"><span class="badge-decode">${fmt(r.avg_decode_speed)}</span>${meter}</td>
|
|||
|
|
<td class="num">${fmt(r.avg_prefill_speed)}</td>
|
|||
|
|
<td class="num">${fmt(r.avg_ttft_ms)}</td>
|
|||
|
|
<td class="num">${fmt(r.best_decode)}</td>
|
|||
|
|
<td class="num">${esc((r.last_tested || "").slice(5, 16))}</td>
|
|||
|
|
</tr>`;
|
|||
|
|
}).join("");
|
|||
|
|
window.__lbRows = d.rows;
|
|||
|
|
window.__lbChart = d.bar_payload;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let lbChartUrl = "";
|
|||
|
|
async function genLbChart() {
|
|||
|
|
if (!window.__lbChart) { toast("暂无数据可画图"); return; }
|
|||
|
|
const status = $("#lb-chart-status");
|
|||
|
|
const imgWrap = $("#lb-chart-img");
|
|||
|
|
status.textContent = "⏳ 正在生成...";
|
|||
|
|
try {
|
|||
|
|
const resp = await fetch("/api/chart", {
|
|||
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify(window.__lbChart),
|
|||
|
|
});
|
|||
|
|
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 = "✅ 生成完成"; };
|
|||
|
|
imgWrap.appendChild(img);
|
|||
|
|
} catch (e) { status.textContent = "❌ " + e.message; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ───────────────────────── 模型详情页 ───────────────────────── */
|
|||
|
|
async function loadModel() {
|
|||
|
|
const params = new URLSearchParams(location.search);
|
|||
|
|
const provider = params.get("provider") || "";
|
|||
|
|
const model = params.get("model") || "";
|
|||
|
|
if (!model) { $("#model-body").innerHTML = '<div class="empty">缺少模型参数</div>'; return; }
|
|||
|
|
let d;
|
|||
|
|
try {
|
|||
|
|
d = await api(`/api/model?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model)}`);
|
|||
|
|
} catch (e) {
|
|||
|
|
$("#model-body").innerHTML = `<div class="empty">${esc(e.message)}</div>`;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
document.title = `${d.model} · 模型评测`;
|
|||
|
|
$("#m-title").textContent = `${d.model} 详情`;
|
|||
|
|
$("#m-sub").textContent = `${providerLabel(d.provider)} · 共 ${d.sub_cnt} 条提交`;
|
|||
|
|
$("#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 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 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">${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}
|
|||
|
|
`;
|
|||
|
|
window.__subId = sid;
|
|||
|
|
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 = "";
|
|||
|
|
|
|||
|
|
async function loadSubmissions(resetPage = false) {
|
|||
|
|
const tb = $("#subs tbody");
|
|||
|
|
if (!tb) return;
|
|||
|
|
if (resetPage) subPage = 1;
|
|||
|
|
tb.innerHTML = '<tr><td colspan="8" 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="8" 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="8" 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 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>${s.test_name ? esc(s.test_name.slice(0, 14)) : "—"}</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 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("");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ───────────────────────── 事件绑定 ───────────────────────── */
|
|||
|
|
function bindIndex() {
|
|||
|
|
$$("#lb th[data-sort]").forEach((th) => th.addEventListener("click", () => sortClick(th.dataset.sort)));
|
|||
|
|
const gen = $("#lb-chart-gen");
|
|||
|
|
if (gen) gen.addEventListener("click", genLbChart);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 bindSubmissions() {
|
|||
|
|
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 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 tb = $("#subs tbody");
|
|||
|
|
tb.addEventListener("click", (e) => {
|
|||
|
|
const v = e.target.closest("[data-view]");
|
|||
|
|
const d = e.target.closest("[data-del]");
|
|||
|
|
const l = e.target.closest(".s-link");
|
|||
|
|
if (v) { e.preventDefault(); openSubmission(v.dataset.view); }
|
|||
|
|
if (l) { e.preventDefault(); openSubmission(l.dataset.sid); }
|
|||
|
|
if (d) {
|
|||
|
|
const id = d.dataset.del;
|
|||
|
|
if (confirm(`确定删除提交 #${id}?`)) {
|
|||
|
|
api(`/api/submissions/${id}`, "DELETE").then(() => { toast("已删除"); loadSubmissions(); });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
const close = $("#sub-close");
|
|||
|
|
if (close) {
|
|||
|
|
close.addEventListener("click", () => { $("#sub-mask").hidden = true; });
|
|||
|
|
$("#sub-mask").addEventListener("click", (e) => { if (e.target === $("#sub-mask")) $("#sub-mask").hidden = true; });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function bindAccounts() {
|
|||
|
|
const add = $("#btn-add-acc");
|
|||
|
|
if (add) add.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 tb = $("#accs tbody");
|
|||
|
|
tb.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(); });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ───────────────────────── 初始化 ───────────────────────── */
|
|||
|
|
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 === "submissions.html") {
|
|||
|
|
loadSubmissions(true);
|
|||
|
|
bindSubmissions();
|
|||
|
|
} else if (page === "accounts.html") {
|
|||
|
|
loadAccounts();
|
|||
|
|
bindAccounts();
|
|||
|
|
}
|
|||
|
|
document.addEventListener("keydown", (e) => {
|
|||
|
|
if (e.key === "Escape") { const m = $("#sub-mask"); if (m) m.hidden = true; }
|
|||
|
|
});
|
|||
|
|
});
|