483 lines
22 KiB
JavaScript
483 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; }
|
||
});
|
||
});
|