v1.3.0 热度排行+搜索+详情+水印下载:速度项按钮改+对比(去购物车图标);排行默认按热度显示前10(后台可配)+顶部模型搜索框(点击显示热门5个:配置简介+模型,支持手动搜索,速度项热度/个数/简介后台可改);每行详情链接到具体详情页;图表带水印(默认首页网址,后台可配)+生成图片下载按钮(首页/对比页)
This commit is contained in:
+233
-26
@@ -45,8 +45,8 @@ function getCart() {
|
||||
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 (!c.includes(key)) { c.push(key); saveCart(c); toast("+ 已加入对比"); }
|
||||
else { toast("已在对比中"); }
|
||||
renderCartBar();
|
||||
}
|
||||
function cartRemove(key) { saveCart(getCart().filter((k) => k !== key)); renderCartBar(); }
|
||||
@@ -112,14 +112,24 @@ function navActive() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ───────────────────────── 首页:排行 ───────────────────────── */
|
||||
let sortKey = "avg_decode_speed";
|
||||
/* ───────────────────────── 首页:排行(热度前10 + 搜索) ───────────────────────── */
|
||||
let sortKey = "heat";
|
||||
let sortOrder = "desc";
|
||||
let lbModels = []; // [{provider, model, key}]
|
||||
let lbSelected = new Set(); // selected keys
|
||||
let lbModels = []; // 全部模型(含 heat/intro/count_override),已按热度排序
|
||||
let lbAll = [];
|
||||
let lbSelected = new Set(); // selected keys(对比图用)
|
||||
let lbTopN = 10;
|
||||
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 || "",
|
||||
};
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await api("/api/stats");
|
||||
@@ -134,7 +144,16 @@ async function loadStats() {
|
||||
function sortClick(key) {
|
||||
if (sortKey === key) sortOrder = sortOrder === "desc" ? "asc" : "desc";
|
||||
else { sortKey = key; sortOrder = "desc"; }
|
||||
loadLeaderboard();
|
||||
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 renderLbChips() {
|
||||
@@ -159,35 +178,53 @@ function renderLbChips() {
|
||||
async function loadLeaderboard() {
|
||||
const tb = $("#lb tbody");
|
||||
if (!tb) return;
|
||||
tb.innerHTML = '<tr><td colspan="12" class="empty">加载中…</td></tr>';
|
||||
tb.innerHTML = '<tr><td colspan="13" class="empty">加载中…</td></tr>';
|
||||
let d;
|
||||
try {
|
||||
d = await api(`/api/leaderboard?sort=${sortKey}&order=${sortOrder}`);
|
||||
d = await api(`/api/leaderboard?sort=heat&order=desc&limit=500`);
|
||||
} catch (e) {
|
||||
tb.innerHTML = `<tr><td colspan="12" class="empty">加载失败:${esc(e.message)}</td></tr>`;
|
||||
tb.innerHTML = `<tr><td colspan="13" class="empty">加载失败:${esc(e.message)}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
if (d.top_n) lbTopN = d.top_n;
|
||||
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 (!lbSelected.size) lbModels.forEach((m) => lbSelected.add(m.key));
|
||||
else lbModels.forEach((m) => { if (!lbSelected.has(m.key)) lbSelected.add(m.key); });
|
||||
renderLbChips();
|
||||
applyView();
|
||||
}
|
||||
|
||||
function applyView() {
|
||||
const tb = $("#lb tbody");
|
||||
if (!tb) return;
|
||||
let rows = lbAll;
|
||||
const q = lbQuery.trim().toLowerCase();
|
||||
if (q) {
|
||||
rows = lbAll.filter((r) =>
|
||||
(r.model || "").toLowerCase().includes(q) ||
|
||||
(r.provider || "").toLowerCase().includes(q) ||
|
||||
((r.intro || "") || "").toLowerCase().includes(q));
|
||||
}
|
||||
rows = [...rows].sort(cmpLb);
|
||||
if (!q) rows = rows.slice(0, lbTopN); // 默认显示热度前 N 个
|
||||
$$("#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="12" class="empty">暂无评测数据。可在后台管理「提交管理」里点击「🎲 生成演示数据」或从 llm-speed-tester 一键发送。</td></tr>';
|
||||
if (!rows.length) {
|
||||
tb.innerHTML = '<tr><td colspan="13" class="empty">' + (q ? `未找到匹配「${esc(lbQuery)}」的模型` : "暂无评测数据。可在后台管理「提交管理」里点击「🎲 生成演示数据」或从 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 maxDecode = Math.max(...rows.map((r) => r.avg_decode_speed || 0));
|
||||
tb.innerHTML = 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>` : "";
|
||||
const intro = r.intro ? `<div class="hint">${esc(r.intro)}</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><a href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}" title="查看详情">${esc(r.model)}</a>${intro}</td>
|
||||
<td class="num"><span class="pill amber">🔥 ${fmtInt(r.heat || 0)}</span></td>
|
||||
<td class="num">${fmtInt(r.count_override > 0 ? r.count_override : 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>
|
||||
@@ -195,11 +232,59 @@ async function loadLeaderboard() {
|
||||
<td class="num">${fmt(r.best_decode)}</td>
|
||||
<td>${hwTag(r.hardware)}</td>
|
||||
<td class="num">${esc((r.last_tested || "").slice(5, 16))}</td>
|
||||
<td><button class="btn small" data-cart="${modelKey(r.provider, r.model)}" title="加入对比车">🛒 加入对比车</button></td>
|
||||
<td>
|
||||
<a class="btn small" href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}" title="查看详情">详情</a>
|
||||
<button class="btn small" data-cart="${modelKey(r.provider, r.model)}" title="加入对比">+对比</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/* 搜索框:点击显示热门5个,输入过滤 */
|
||||
function renderSearchDrop(items) {
|
||||
const drop = $("#lb-search-drop");
|
||||
if (!drop) return;
|
||||
if (!items || !items.length) { drop.hidden = true; return; }
|
||||
drop.hidden = false;
|
||||
drop.innerHTML = items.map((r) => {
|
||||
const key = modelKey(r.provider, r.model);
|
||||
const intro = r.intro ? `<span class="hint">${esc(r.intro)}</span>` : `<span class="hint">${esc(providerLabel(r.provider))}</span>`;
|
||||
return `<div class="search-item" data-key="${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">${intro} · 提交 ${fmtInt(r.cnt)} · 解码 ${fmt(r.avg_decode_speed)} tok/s</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function bindSearch() {
|
||||
const input = $("#lb-search");
|
||||
const drop = $("#lb-search-drop");
|
||||
if (!input || !drop) return;
|
||||
input.addEventListener("focus", () => {
|
||||
// 点击输入框 → 显示最常见的5个速度项(按热度)
|
||||
renderSearchDrop(lbAll.slice(0, 5));
|
||||
});
|
||||
input.addEventListener("input", () => {
|
||||
lbQuery = input.value;
|
||||
applyView();
|
||||
const q = lbQuery.trim().toLowerCase();
|
||||
if (q) renderSearchDrop(lbAll.filter((r) =>
|
||||
(r.model || "").toLowerCase().includes(q) ||
|
||||
(r.provider || "").toLowerCase().includes(q) ||
|
||||
((r.intro || "") || "").toLowerCase().includes(q)).slice(0, 5));
|
||||
else renderSearchDrop(lbAll.slice(0, 5));
|
||||
});
|
||||
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)}`;
|
||||
});
|
||||
}
|
||||
|
||||
let lbChartUrl = "";
|
||||
async function genLbChart() {
|
||||
const status = $("#lb-chart-status");
|
||||
@@ -209,7 +294,7 @@ async function genLbChart() {
|
||||
try {
|
||||
// 重新请求指定模型的排行数据(对比图只包含所选模型)
|
||||
const models = [...lbSelected].join(",");
|
||||
const d = await api(`/api/leaderboard?sort=avg_decode_speed&order=desc&models=${encodeURIComponent(models)}`);
|
||||
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;
|
||||
// 运行速度对比:预填充(左轴·空心柱) + 解码(右轴·实心柱) + 首字(虚线),三色
|
||||
@@ -243,6 +328,14 @@ async function genLbChart() {
|
||||
} 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);
|
||||
@@ -673,6 +766,8 @@ async function checkAdmin() {
|
||||
loadSubmissions(true);
|
||||
loadAccounts();
|
||||
loadCaps();
|
||||
loadSpeedItems();
|
||||
loadSettings();
|
||||
return true;
|
||||
}
|
||||
} catch (e) { /* not logged in */ }
|
||||
@@ -686,6 +781,8 @@ function switchTab(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) {
|
||||
@@ -784,6 +881,54 @@ async function loadCaps() {
|
||||
</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="9" 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>${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-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-topn")) $("#set-topn").value = s.top_n || 10;
|
||||
}
|
||||
|
||||
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 remark = prompt("备注:", btn.dataset.remark || "");
|
||||
const body = {
|
||||
provider: provider || "", model: model || "",
|
||||
heat: parseInt(heat) || 0, count: parseInt(count) || 0,
|
||||
intro: intro || "", 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}` : "新增能力测试";
|
||||
@@ -832,16 +977,19 @@ 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 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) => lbSelected.add(m.key)); renderLbChips(); });
|
||||
const none = $("#lb-chart-none");
|
||||
if (none) none.addEventListener("click", () => { lbSelected.clear(); renderLbChips(); });
|
||||
// 对比车按钮
|
||||
// +对比按钮 / 详情链接
|
||||
const tb = $("#lb tbody");
|
||||
if (tb) tb.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-cart]");
|
||||
if (btn) cartAdd(btn.dataset.cart);
|
||||
if (btn) { e.preventDefault(); cartAdd(btn.dataset.cart); }
|
||||
});
|
||||
bindSearch();
|
||||
bindCartBar();
|
||||
}
|
||||
|
||||
@@ -889,6 +1037,14 @@ function bindCompare() {
|
||||
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();
|
||||
@@ -1023,6 +1179,57 @@ function bindAdmin() {
|
||||
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("配置简介:", "") || "";
|
||||
api("/api/admin/speed-items", "POST", { provider, model, heat: parseInt(heat) || 0, count: parseInt(count) || 0, intro })
|
||||
.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(),
|
||||
top_n: parseInt($("#set-topn").value) || 10,
|
||||
};
|
||||
try {
|
||||
await api("/api/admin/settings", "PUT", body);
|
||||
const st = $("#set-status");
|
||||
st.textContent = "✅ 已保存,刷新页面生效";
|
||||
setTimeout(() => { st.textContent = ""; }, 3000);
|
||||
toast("设置已保存");
|
||||
} catch (e) { toast("保存失败:" + e.message); }
|
||||
});
|
||||
}
|
||||
|
||||
/* ───────────────────────── 初始化 ───────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user