v1.1.0 后台管理+能力测试+模型对比+硬件显示:颜色对比度优化;提交/账号管理移到后台(admin/admin123, session);移除前台提交/账号链接与一键同步提示;对比图默认全部+可指定模型;新增模型能力测试模块(后台编辑/前台展示);模型对比支持硬件显示+对比项可去留;提交带硬件字段(admin可改);llm-speed-tester 新增 /api/gpu 探测+发送带硬件
This commit is contained in:
+466
-60
@@ -8,10 +8,9 @@ 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();
|
||||
const el = $("#toast");
|
||||
el.textContent = msg;
|
||||
el.style.display = "block";
|
||||
clearTimeout(toastTimer);
|
||||
@@ -22,7 +21,8 @@ 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(() => ({}));
|
||||
let j = {};
|
||||
try { j = await resp.json(); } catch (e) { j = {}; }
|
||||
if (!resp.ok && !j.ok) throw new Error(j.error || `请求失败(${resp.status})`);
|
||||
return j;
|
||||
}
|
||||
@@ -33,7 +33,8 @@ function providerLabel(p) {
|
||||
return m[p] || p || "—";
|
||||
}
|
||||
|
||||
/* 顶部导航高亮 */
|
||||
const hwTag = (hw) => (hw ? `<span class="hw-tag" title="${esc(hw)}">🖥 ${esc(hw)}</span>` : "—");
|
||||
|
||||
function navActive() {
|
||||
const path = location.pathname.split("/").pop() || "index.html";
|
||||
$$(".nav a").forEach((a) => {
|
||||
@@ -44,14 +45,19 @@ function navActive() {
|
||||
/* ───────────────────────── 首页:排行 ───────────────────────── */
|
||||
let sortKey = "avg_decode_speed";
|
||||
let sortOrder = "desc";
|
||||
let lbModels = []; // [{provider, model, key}]
|
||||
let lbSelected = new Set(); // selected keys
|
||||
|
||||
const modelKey = (p, m) => `${p}|${m}`;
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await api("/api/stats");
|
||||
$("#st-models").textContent = s.models;
|
||||
$("#st-subs").textContent = s.submissions;
|
||||
$("#st-accounts").textContent = s.accounts;
|
||||
$("#st-samples").innerHTML = fmtInt(s.samples_ok) + " <small>次成功采样</small>";
|
||||
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 */ }
|
||||
}
|
||||
|
||||
@@ -61,24 +67,49 @@ function sortClick(key) {
|
||||
loadLeaderboard();
|
||||
}
|
||||
|
||||
function renderLbChips() {
|
||||
const box = $("#lb-chart-models");
|
||||
if (!box) return;
|
||||
box.innerHTML = "";
|
||||
if (!lbModels.length) { box.innerHTML = '<span class="hint">暂无模型</span>'; return; }
|
||||
for (const m of lbModels) {
|
||||
const on = lbSelected.has(m.key);
|
||||
const c = document.createElement("span");
|
||||
c.className = "chip" + (on ? " on" : "");
|
||||
c.title = "点击选择/取消该模型";
|
||||
c.innerHTML = `<span>${esc(m.model)}</span>`;
|
||||
c.addEventListener("click", () => {
|
||||
if (lbSelected.has(m.key)) lbSelected.delete(m.key); else lbSelected.add(m.key);
|
||||
c.classList.toggle("on");
|
||||
});
|
||||
box.appendChild(c);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLeaderboard() {
|
||||
const tb = $("#lb tbody");
|
||||
if (!tb) return;
|
||||
tb.innerHTML = '<tr><td colspan="9" class="empty">加载中…</td></tr>';
|
||||
tb.innerHTML = '<tr><td colspan="11" 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>`;
|
||||
tb.innerHTML = `<tr><td colspan="11" 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>';
|
||||
tb.innerHTML = '<tr><td colspan="11" class="empty">暂无评测数据。可在后台管理「提交管理」里点击「🎲 生成演示数据」或从 llm-speed-tester 一键发送。</td></tr>';
|
||||
return;
|
||||
}
|
||||
// 记录模型列表,用于对比图选择
|
||||
lbModels = d.rows.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model) }));
|
||||
if (!lbSelected.size) lbModels.forEach((m) => lbSelected.add(m.key));
|
||||
else lbModels.forEach((m) => { if (!lbSelected.has(m.key)) lbSelected.add(m.key); }); // 新模型默认选中
|
||||
renderLbChips();
|
||||
|
||||
const maxDecode = Math.max(...d.rows.map((r) => r.avg_decode_speed || 0));
|
||||
tb.innerHTML = d.rows.map((r, i) => {
|
||||
const meter = r.avg_decode_speed ? `<div class="meter"><div style="width:${(r.avg_decode_speed / maxDecode * 100).toFixed(1)}%"></div></div>` : "";
|
||||
@@ -92,23 +123,26 @@ async function loadLeaderboard() {
|
||||
<td class="num">${fmt(r.avg_prefill_speed)}</td>
|
||||
<td class="num">${fmt(r.avg_ttft_ms)}</td>
|
||||
<td class="num">${fmt(r.best_decode)}</td>
|
||||
<td>${hwTag(r.hardware)}</td>
|
||||
<td class="num">${esc((r.last_tested || "").slice(5, 16))}</td>
|
||||
</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");
|
||||
if (!lbSelected.size) { status.textContent = "请至少选择一个模型"; return; }
|
||||
status.textContent = "⏳ 正在生成...";
|
||||
try {
|
||||
// 重新请求指定模型的排行数据(对比图只包含所选模型)
|
||||
const models = [...lbSelected].join(",");
|
||||
const d = await api(`/api/leaderboard?sort=avg_decode_speed&order=desc&models=${encodeURIComponent(models)}`);
|
||||
if (!d.rows.length) { status.textContent = "所选模型暂无数据"; return; }
|
||||
const resp = await fetch("/api/chart", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(window.__lbChart),
|
||||
body: JSON.stringify(d.bar_payload),
|
||||
});
|
||||
if (!resp.ok) { const j = await resp.json().catch(() => ({})); status.textContent = "❌ " + (j.error || "生成失败"); return; }
|
||||
const blob = await resp.blob();
|
||||
@@ -117,8 +151,8 @@ async function genLbChart() {
|
||||
imgWrap.innerHTML = "";
|
||||
const img = document.createElement("img");
|
||||
img.src = lbChartUrl;
|
||||
img.alt = "模型速度排行图";
|
||||
img.onload = () => { status.textContent = "✅ 生成完成"; };
|
||||
img.alt = "模型解码速度对比图";
|
||||
img.onload = () => { status.textContent = `✅ 已生成(${d.rows.length} 个模型)`; };
|
||||
imgWrap.appendChild(img);
|
||||
} catch (e) { status.textContent = "❌ " + e.message; }
|
||||
}
|
||||
@@ -133,18 +167,25 @@ async function loadModel() {
|
||||
try {
|
||||
d = await api(`/api/model?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(model)}`);
|
||||
} catch (e) {
|
||||
$("#model-body").innerHTML = `<div class="empty">${esc(e.message)}</div>`;
|
||||
$("#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>
|
||||
@@ -156,14 +197,12 @@ async function loadModel() {
|
||||
</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>
|
||||
@@ -197,6 +236,184 @@ async function genModelChart() {
|
||||
} 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 cmpCols = new Set(["provider", "model", "hardware", "avg_decode_speed", "avg_prefill_speed", "avg_ttft_ms"]);
|
||||
let cmpRows = [];
|
||||
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 loadCmpModels() {
|
||||
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) }));
|
||||
if (!cmpSelected.size) cmpModels.forEach((m) => cmpSelected.add(m.key));
|
||||
renderCmpChips();
|
||||
}
|
||||
|
||||
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); else cmpSelected.add(m.key);
|
||||
c.classList.toggle("on");
|
||||
});
|
||||
box.appendChild(c);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCompare() {
|
||||
const status = $("#cmp-status");
|
||||
const tbody = $("#cmp-table tbody");
|
||||
if (!cmpSelected.size) { status.textContent = "请至少选择一个模型"; return; }
|
||||
status.textContent = "⏳ 正在对比...";
|
||||
const models = [...cmpSelected].join(",");
|
||||
try {
|
||||
const d = await api(`/api/compare?models=${encodeURIComponent(models)}`);
|
||||
cmpRows = d.rows;
|
||||
renderCmpTable();
|
||||
status.textContent = `✅ 已对比 ${cmpRows.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("");
|
||||
}
|
||||
|
||||
async function genCmpChart() {
|
||||
const status = $("#cmp-chart-status");
|
||||
const imgWrap = $("#cmp-chart-img");
|
||||
if (!cmpSelected.size) { status.textContent = "请至少选择一个模型"; return; }
|
||||
const metric = $("#cmp-metric").value;
|
||||
const ctype = $("#cmp-chart-type").value;
|
||||
status.textContent = "⏳ 正在生成...";
|
||||
try {
|
||||
const models = [...cmpSelected].join(",");
|
||||
const d = await api(`/api/compare?models=${encodeURIComponent(models)}&chart_metric=${metric}&chart_type=${ctype}`);
|
||||
if (!d.rows.length) { status.textContent = "所选模型暂无数据"; return; }
|
||||
const resp = await fetch("/api/chart", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(d.chart_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 = `✅ 已生成(${d.rows.length} 个模型)`; };
|
||||
imgWrap.appendChild(img);
|
||||
} catch (e) { status.textContent = "❌ " + e.message; }
|
||||
}
|
||||
|
||||
/* ───────────────────────── 提交详情弹窗 ───────────────────────── */
|
||||
async function openSubmission(sid) {
|
||||
const mask = $("#sub-mask");
|
||||
@@ -239,6 +456,7 @@ async function openSubmission(sid) {
|
||||
<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>
|
||||
@@ -267,7 +485,6 @@ async function openSubmission(sid) {
|
||||
<h3>📋 采样明细</h3>
|
||||
${runsHtml}
|
||||
`;
|
||||
window.__subId = sid;
|
||||
let chartUrl = "";
|
||||
$("#sub-chart-gen").addEventListener("click", async () => {
|
||||
const status = $("#sub-chart-status");
|
||||
@@ -288,20 +505,46 @@ async function openSubmission(sid) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ───────────────────────── 提交管理页 ───────────────────────── */
|
||||
/* ───────────────────────── 后台管理 ───────────────────────── */
|
||||
let subPage = 1;
|
||||
let subQuery = "";
|
||||
let capEditId = null;
|
||||
|
||||
async function checkAdmin() {
|
||||
try {
|
||||
const d = await api("/api/admin/session");
|
||||
if (d.admin) {
|
||||
$("#login-box").hidden = true;
|
||||
$("#admin-box").hidden = false;
|
||||
$("#btn-logout").hidden = false;
|
||||
loadSubmissions(true);
|
||||
loadAccounts();
|
||||
loadCaps();
|
||||
return true;
|
||||
}
|
||||
} catch (e) { /* not logged in */ }
|
||||
$("#login-box").hidden = false;
|
||||
$("#admin-box").hidden = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function switchTab(tab) {
|
||||
$$("#admin-tabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === tab));
|
||||
$("#tab-subs").hidden = tab !== "subs";
|
||||
$("#tab-accs").hidden = tab !== "accs";
|
||||
$("#tab-caps").hidden = tab !== "caps";
|
||||
}
|
||||
|
||||
async function loadSubmissions(resetPage = false) {
|
||||
const tb = $("#subs tbody");
|
||||
if (!tb) return;
|
||||
if (resetPage) subPage = 1;
|
||||
tb.innerHTML = '<tr><td colspan="8" class="empty">加载中…</td></tr>';
|
||||
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="8" class="empty">加载失败:${esc(e.message)}</td></tr>`;
|
||||
tb.innerHTML = `<tr><td colspan="11" class="empty">加载失败:${esc(e.message)}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
$("#sub-total").textContent = `共 ${d.total} 条`;
|
||||
@@ -309,7 +552,7 @@ async function loadSubmissions(resetPage = false) {
|
||||
$("#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>';
|
||||
tb.innerHTML = '<tr><td colspan="11" class="empty">暂无提交记录。可点击「🎲 生成演示数据」或从 llm-speed-tester 发送。</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = d.items.map((s) => `<tr>
|
||||
@@ -318,11 +561,11 @@ async function loadSubmissions(resetPage = false) {
|
||||
<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>${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>
|
||||
@@ -330,7 +573,16 @@ async function loadSubmissions(resetPage = false) {
|
||||
</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;
|
||||
@@ -338,7 +590,7 @@ async function loadAccounts() {
|
||||
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>';
|
||||
tb.innerHTML = '<tr><td colspan="5" class="empty">暂无账号。llm-speed-tester 一键发送时账号会自动创建。</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = list.map((a) => `<tr>
|
||||
@@ -353,11 +605,84 @@ async function loadAccounts() {
|
||||
</tr>`).join("");
|
||||
}
|
||||
|
||||
async function loadCaps() {
|
||||
const tb = $("#caps tbody");
|
||||
if (!tb) return;
|
||||
let list;
|
||||
try { list = await api("/api/admin/capabilities"); }
|
||||
catch (e) { tb.innerHTML = `<tr><td colspan="9" class="empty">加载失败:${esc(e.message)}</td></tr>`; return; }
|
||||
if (!list.length) {
|
||||
tb.innerHTML = '<tr><td colspan="9" class="empty">暂无能力测试,点击「➕ 新增能力测试」或「🎲 生成演示能力测试」。</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = list.map((c) => `<tr>
|
||||
<td>${c.id}</td>
|
||||
<td style="font-size:18px">${esc(c.icon || "🧪")}</td>
|
||||
<td>${esc(c.name)}</td>
|
||||
<td><span class="pill green">${esc(c.category || "未分类")}</span></td>
|
||||
<td class="num">${(c.items || []).length}</td>
|
||||
<td title="${esc(c.description || "")}">${esc((c.description || "").slice(0, 20))}</td>
|
||||
<td class="num">${c.sort}</td>
|
||||
<td>${c.status ? '<span class="pill green">启用</span>' : '<span class="pill gray">停用</span>'}</td>
|
||||
<td>
|
||||
<button class="btn small" data-edit-cap="${c.id}">编辑</button>
|
||||
<button class="btn small danger" data-del-cap="${c.id}">删除</button>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
}
|
||||
|
||||
function openCapEditor(c) {
|
||||
capEditId = c ? c.id : null;
|
||||
$("#cap-edit-title").textContent = c ? `编辑能力测试 #${c.id}` : "新增能力测试";
|
||||
$("#ce-name").value = c ? c.name : "";
|
||||
$("#ce-category").value = c ? c.category : "";
|
||||
$("#ce-icon").value = c ? c.icon : "🧪";
|
||||
$("#ce-sort").value = c ? c.sort : 0;
|
||||
$("#ce-desc").value = c ? c.description : "";
|
||||
$("#ce-items").value = c ? JSON.stringify(c.items || [], null, 2) : '[{"title":"题目1","prompt":"提示词内容","expect":"期望结果"}]';
|
||||
$("#ce-status").checked = c ? !!c.status : true;
|
||||
$("#cap-mask").hidden = false;
|
||||
}
|
||||
|
||||
function parseItems(text) {
|
||||
const t = (text || "").trim();
|
||||
if (!t) return [];
|
||||
let arr;
|
||||
try { arr = JSON.parse(t); }
|
||||
catch (e) { throw new Error("测试项 JSON 格式错误:" + e.message); }
|
||||
if (!Array.isArray(arr)) throw new Error("测试项必须是 JSON 数组");
|
||||
return arr;
|
||||
}
|
||||
|
||||
async function saveCapEditor() {
|
||||
const name = $("#ce-name").value.trim();
|
||||
if (!name) { toast("请填写名称"); return; }
|
||||
let items;
|
||||
try { items = parseItems($("#ce-items").value); }
|
||||
catch (e) { toast(e.message); return; }
|
||||
const body = {
|
||||
name, category: $("#ce-category").value.trim(), icon: $("#ce-icon").value.trim() || "🧪",
|
||||
sort: parseInt($("#ce-sort").value) || 0, description: $("#ce-desc").value.trim(),
|
||||
items, status: $("#ce-status").checked ? 1 : 0,
|
||||
};
|
||||
try {
|
||||
if (capEditId) await api(`/api/admin/capabilities/${capEditId}`, "PUT", body);
|
||||
else await api("/api/admin/capabilities", "POST", body);
|
||||
toast("已保存");
|
||||
$("#cap-mask").hidden = true;
|
||||
loadCaps();
|
||||
} catch (e) { toast("保存失败:" + e.message); }
|
||||
}
|
||||
|
||||
/* ───────────────────────── 事件绑定 ───────────────────────── */
|
||||
function bindIndex() {
|
||||
$$("#lb th[data-sort]").forEach((th) => th.addEventListener("click", () => sortClick(th.dataset.sort)));
|
||||
const gen = $("#lb-chart-gen");
|
||||
if (gen) gen.addEventListener("click", genLbChart);
|
||||
const all = $("#lb-chart-all");
|
||||
if (all) all.addEventListener("click", () => { lbModels.forEach((m) => lbSelected.add(m.key)); renderLbChips(); });
|
||||
const none = $("#lb-chart-none");
|
||||
if (none) none.addEventListener("click", () => { lbSelected.clear(); renderLbChips(); });
|
||||
}
|
||||
|
||||
function bindModel() {
|
||||
@@ -375,7 +700,7 @@ function bindModel() {
|
||||
if (!modelChartUrl) { toast("请先生成图表"); return; }
|
||||
const a = document.createElement("a");
|
||||
a.href = modelChartUrl;
|
||||
a.download = `model_chart.png`;
|
||||
a.download = "model_chart.png";
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
});
|
||||
const body = $("#md-submissions");
|
||||
@@ -385,19 +710,48 @@ function bindModel() {
|
||||
});
|
||||
}
|
||||
|
||||
function bindSubmissions() {
|
||||
const seed = $("#btn-seed");
|
||||
if (seed) seed.addEventListener("click", async () => {
|
||||
seed.disabled = true;
|
||||
seed.textContent = "生成中…";
|
||||
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)); renderCmpChips(); });
|
||||
const none = $("#cmp-none");
|
||||
if (none) none.addEventListener("click", () => { cmpSelected.clear(); renderCmpChips(); });
|
||||
const run = $("#cmp-run");
|
||||
if (run) run.addEventListener("click", runCompare);
|
||||
const gen = $("#cmp-chart-gen");
|
||||
if (gen) gen.addEventListener("click", genCmpChart);
|
||||
renderCmpColToggles();
|
||||
}
|
||||
|
||||
function bindAdmin() {
|
||||
const login = $("#btn-login");
|
||||
if (login) login.addEventListener("click", async () => {
|
||||
const u = $("#login-user").value.trim();
|
||||
const p = $("#login-pass").value;
|
||||
try {
|
||||
const r = await api("/api/seed-demo", "POST", {});
|
||||
toast(`已生成 ${r.seeded} 条演示数据`);
|
||||
loadSubmissions(true);
|
||||
} catch (e) { toast("生成失败:" + e.message); }
|
||||
seed.disabled = false;
|
||||
seed.textContent = "🎲 生成演示数据";
|
||||
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); } });
|
||||
@@ -405,13 +759,25 @@ function bindSubmissions() {
|
||||
}
|
||||
$("#sub-prev").addEventListener("click", () => { if (subPage > 1) { subPage--; loadSubmissions(); } });
|
||||
$("#sub-next").addEventListener("click", () => { subPage++; loadSubmissions(); });
|
||||
const tb = $("#subs tbody");
|
||||
tb.addEventListener("click", (e) => {
|
||||
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}?`)) {
|
||||
@@ -419,16 +785,10 @@ function bindSubmissions() {
|
||||
}
|
||||
}
|
||||
});
|
||||
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 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; }
|
||||
@@ -439,8 +799,8 @@ function bindAccounts() {
|
||||
loadAccounts();
|
||||
} catch (e) { toast(e.message); }
|
||||
});
|
||||
const tb = $("#accs tbody");
|
||||
tb.addEventListener("click", (e) => {
|
||||
const accsTb = $("#accs tbody");
|
||||
accsTb.addEventListener("click", (e) => {
|
||||
const ed = e.target.closest("[data-edit]");
|
||||
const dl = e.target.closest("[data-del]");
|
||||
if (ed) {
|
||||
@@ -456,6 +816,49 @@ function bindAccounts() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 能力测试管理
|
||||
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; });
|
||||
}
|
||||
}
|
||||
|
||||
/* ───────────────────────── 初始化 ───────────────────────── */
|
||||
@@ -469,14 +872,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
} else if (page === "model.html") {
|
||||
loadModel();
|
||||
bindModel();
|
||||
} else if (page === "submissions.html") {
|
||||
loadSubmissions(true);
|
||||
bindSubmissions();
|
||||
} else if (page === "accounts.html") {
|
||||
loadAccounts();
|
||||
bindAccounts();
|
||||
} else if (page === "capabilities.html") {
|
||||
loadCapabilities();
|
||||
bindCapabilities();
|
||||
} else if (page === "compare.html") {
|
||||
loadCmpModels();
|
||||
bindCompare();
|
||||
} else if (page === "admin.html") {
|
||||
checkAdmin();
|
||||
bindAdmin();
|
||||
}
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") { const m = $("#sub-mask"); if (m) m.hidden = true; }
|
||||
if (e.key === "Escape") { $("#sub-mask").hidden = true; $("#cap-mask").hidden = true; }
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user