v1.4.0 全局搜索+水印5风格+对比列表管理+排行列可配:搜索框拆分(网页顶部居中=选中跳详情; 排行内搜索=添加速度项到本区域查看,可✕移除); 水印预置5种风格(右下角/全图斜纹/底部横条/四角/对角大字)后台可选; 运行速度对比图要对比模型列表可叉掉/清空一键去除/+对比添加; 图表改深色主题适配深色背景; 排行榜新增推理框架列,首字ms默认隐藏,平均解码+最佳解码合并,硬件+推理框架合并,列显示/合并后台⚙️设置可配

This commit is contained in:
2026-09-03 00:50:05 +08:00
parent ad863077c6
commit 3a6a1e02a2
7 changed files with 407 additions and 184 deletions
+68 -21
View File
@@ -31,6 +31,11 @@ def _watermark_text():
return db.get_setting("watermark_text", config.DEFAULT_WATERMARK) return db.get_setting("watermark_text", config.DEFAULT_WATERMARK)
def _watermark_style():
st = db.get_setting("watermark_style", config.DEFAULT_WATERMARK_STYLE)
return st if st in config.WATERMARK_STYLES else config.DEFAULT_WATERMARK_STYLE
def _top_n(): def _top_n():
try: try:
return max(1, int(db.get_setting("top_n", str(config.DEFAULT_TOP_N)))) return max(1, int(db.get_setting("top_n", str(config.DEFAULT_TOP_N))))
@@ -38,10 +43,22 @@ def _top_n():
return config.DEFAULT_TOP_N return config.DEFAULT_TOP_N
def apply_watermark(img_bytes: bytes, text: str) -> bytes: def _column_config():
"""给图片叠加简单水印:全图浅淡斜纹 + 右下角标注。颜色自适应当前背景(浅底深字/深底浅字)""" out = {}
for k, default in config.COLUMN_DEFAULTS.items():
v = db.get_setting(k, default)
out[k] = 1 if str(v).strip() in ("1", "true", "on") else 0
return out
def apply_watermark(img_bytes: bytes, text: str, style: str = "") -> bytes:
"""给图片叠加水印,5 种风格可选:corner右下角标注 / diagonal全图斜纹 / bottom底部横条 / corners四角标注 / diag-big对角大字
颜色自适应:浅底深字 / 深底浅字"""
if not text: if not text:
return img_bytes return img_bytes
style = style or config.DEFAULT_WATERMARK_STYLE
if style not in config.WATERMARK_STYLES:
style = config.DEFAULT_WATERMARK_STYLE
try: try:
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
except Exception: except Exception:
@@ -51,34 +68,59 @@ def apply_watermark(img_bytes: bytes, text: str) -> bytes:
except Exception: except Exception:
return img_bytes return img_bytes
w, h = img.size w, h = img.size
# 自适应水印颜色:取样左上角判断背景深浅
bg = img.getpixel((4, 4))[:3] bg = img.getpixel((4, 4))[:3]
bg_luma = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2] bg_luma = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2]
dark_bg = bg_luma < 140 dark_bg = bg_luma < 140
fill = (50, 50, 60) if not dark_bg else (255, 255, 255) fill = (50, 50, 60) if not dark_bg else (255, 255, 255)
diag_alpha = 30 if not dark_bg else 26 a_diag = 30 if not dark_bg else 26
corner_alpha = 120 if not dark_bg else 128 a_main = 130 if not dark_bg else 135
overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0)) overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(overlay) d = ImageDraw.Draw(overlay)
try: try:
font = ImageFont.truetype(_WATERMARK_FONT, 16) font = ImageFont.truetype(_WATERMARK_FONT, 16)
font_big = ImageFont.truetype(_WATERMARK_FONT, 22) font_big = ImageFont.truetype(_WATERMARK_FONT, 24)
font_huge = ImageFont.truetype(_WATERMARK_FONT, 60)
except Exception: except Exception:
font = ImageFont.load_default() font = ImageFont.load_default()
font_big = ImageFont.load_default() font_big = ImageFont.load_default()
font_huge = ImageFont.load_default()
# 全图浅淡斜纹水印(45° 重复) def _corner_label():
box = d.textbbox((0, 0), text, font=font_big)
tw = box[2] - box[0]; th = box[3] - box[1]
d.text((w - tw - 16, h - th - 12), text, font=font_big, fill=fill + (a_main,))
if style == "corner":
_corner_label()
elif style == "diagonal":
step = 170 step = 170
for y in range(-h, h, step): for y in range(-h, h, step):
for x in range(-w, w, step): for x in range(-w, w, step):
d.text((x + y * 0.4, y), text, font=font, fill=fill + (diag_alpha,)) d.text((x + y * 0.4, y), text, font=font, fill=fill + (a_diag,))
elif style == "bottom":
# 右下角较清晰标注 # 底部半透明横条
box = d.textbbox((0, 0), text, font=font_big) box = d.textbbox((0, 0), text, font=font_big)
tw = box[2] - box[0] tw = box[2] - box[0]; th = box[3] - box[1]
th = box[3] - box[1] bh = th + 24
d.text((w - tw - 16, h - th - 12), text, font=font_big, fill=fill + (corner_alpha,)) d.rectangle([0, h - bh, w, h], fill=(0, 0, 0, 90) if not dark_bg else (255, 255, 255, 70))
d.text((16, h - bh + 10), text, font=font_big, fill=fill + (a_main,))
# 底部中央小字
cx = (w - tw) // 2
d.text((cx, h - 20), text, font=font_big, fill=fill + (110,))
elif style == "corners":
box = d.textbbox((0, 0), text, font=font_big)
tw = box[2] - box[0]; th = box[3] - box[1]
for px, py in [(12, 12), (w - tw - 12, 12), (12, h - th - 12), (w - tw - 12, h - th - 12)]:
d.text((px, py), text, font=font_big, fill=fill + (a_main,))
elif style == "diag-big":
box = d.textbbox((0, 0), text, font=font_huge)
tw = box[2] - box[0]; th = box[3] - box[1]
for i in range(-1, 2):
d.text(((w - tw) // 2 + i * 14, (h - th) // 2 + i * 28), text, font=font_huge, fill=fill + (38,))
d.text(((w - tw) // 2, (h - th) // 2), text, font=font_huge, fill=fill + (80,))
else:
_corner_label()
out = Image.alpha_composite(img, overlay) out = Image.alpha_composite(img, overlay)
buf = io.BytesIO() buf = io.BytesIO()
@@ -209,8 +251,10 @@ def leaderboard():
chart_rows = rows[: int(request.args.get("chart_top", 30) or 30)] chart_rows = rows[: int(request.args.get("chart_top", 30) or 30)]
chart_csv = _speed_csv(chart_rows) chart_csv = _speed_csv(chart_rows)
cols = _column_config()
return jsonify({"ok": True, "rows": rows, "chart_csv": chart_csv, "top_n": _top_n(), return jsonify({"ok": True, "rows": rows, "chart_csv": chart_csv, "top_n": _top_n(),
"watermark": _watermark_text()}) "watermark": _watermark_text(), "watermark_style": _watermark_style(),
"columns": cols})
def _speed_csv(chart_rows): def _speed_csv(chart_rows):
@@ -271,7 +315,7 @@ def model_detail():
line_payload = { line_payload = {
"data": line_csv, "chartType": "line", "data": line_csv, "chartType": "line",
"title": "%s %s · 解码速度随上下文长度变化" % (provider or "", model), "title": "%s %s · 解码速度随上下文长度变化" % (provider or "", model),
"theme": "default", "showLegend": True, "showGrid": True, "showLabel": False, "theme": "dark", "showLegend": True, "showGrid": True, "showLabel": False,
"smoothLine": True, "dualYAxis": True, "smoothLine": True, "dualYAxis": True,
"leftAxisName": "预填充速度(tok/s)", "rightAxisName": "解码速度(tok/s)", "leftAxisName": "预填充速度(tok/s)", "rightAxisName": "解码速度(tok/s)",
"seriesTypes": ["line", "line"], "seriesAxis": [0, 1], "seriesTypes": ["line", "line"], "seriesAxis": [0, 1],
@@ -341,7 +385,7 @@ def submission_chart(sid):
payload = { payload = {
"data": "\n".join(csv_lines), "chartType": "line", "data": "\n".join(csv_lines), "chartType": "line",
"title": "%s %s · 速度随上下文长度(提交#%d" % (s["provider"], s["model"], sid), "title": "%s %s · 速度随上下文长度(提交#%d" % (s["provider"], s["model"], sid),
"theme": "default", "showLegend": True, "showGrid": True, "showLabel": False, "theme": "dark", "showLegend": True, "showGrid": True, "showLabel": False,
"smoothLine": True, "dualYAxis": True, "smoothLine": True, "dualYAxis": True,
"leftAxisName": "预填充速度(tok/s)", "rightAxisName": "解码速度(tok/s)", "leftAxisName": "预填充速度(tok/s)", "rightAxisName": "解码速度(tok/s)",
"seriesTypes": ["line", "line"], "seriesAxis": [0, 1], "seriesTypes": ["line", "line"], "seriesAxis": [0, 1],
@@ -389,7 +433,9 @@ def account_delete(aid):
@app.route("/api/settings") @app.route("/api/settings")
def public_settings(): def public_settings():
return jsonify({"ok": True, "watermark": _watermark_text(), "top_n": _top_n()}) return jsonify({"ok": True, "watermark": _watermark_text(),
"watermark_style": _watermark_style(),
"top_n": _top_n(), "columns": _column_config()})
@app.route("/api/admin/speed-items") @app.route("/api/admin/speed-items")
@@ -437,14 +483,15 @@ def admin_speed_item_sync():
@admin_required @admin_required
def admin_settings_get(): def admin_settings_get():
return jsonify({"ok": True, "settings": db.get_all_settings(), return jsonify({"ok": True, "settings": db.get_all_settings(),
"watermark": _watermark_text(), "top_n": _top_n()}) "watermark": _watermark_text(), "watermark_style": _watermark_style(),
"top_n": _top_n(), "columns": _column_config()})
@app.route("/api/admin/settings", methods=["PUT"]) @app.route("/api/admin/settings", methods=["PUT"])
@admin_required @admin_required
def admin_settings_put(): def admin_settings_put():
body = request.get_json(force=True) or {} body = request.get_json(force=True) or {}
allow = {"watermark_text", "top_n"} allow = {"watermark_text", "watermark_style", "top_n"} | set(config.COLUMN_DEFAULTS.keys())
for k, v in body.items(): for k, v in body.items():
if k in allow: if k in allow:
db.set_setting(k, v) db.set_setting(k, v)
@@ -500,8 +547,8 @@ def _chart_proxy(payload):
return jsonify({"ok": False, "error": "图表服务不可用: %s" % e}), 502 return jsonify({"ok": False, "error": "图表服务不可用: %s" % e}), 502
if resp.status_code != 200: if resp.status_code != 200:
return jsonify({"ok": False, "error": "图表生成失败(%d): %s" % (resp.status_code, resp.text[:300])}), 502 return jsonify({"ok": False, "error": "图表生成失败(%d): %s" % (resp.status_code, resp.text[:300])}), 502
# 叠加水印(默认首页网址,后台可配置) # 叠加水印(默认首页网址,后台可配置文字+5种风格
data = apply_watermark(resp.content, _watermark_text()) data = apply_watermark(resp.content, _watermark_text(), _watermark_style())
return send_file(io.BytesIO(data), mimetype="image/png") return send_file(io.BytesIO(data), mimetype="image/png")
+13 -1
View File
@@ -18,9 +18,21 @@ ADMIN_USER = "admin"
ADMIN_PASSWORD = "admin123" ADMIN_PASSWORD = "admin123"
SESSION_SECRET = "model-eval-site-session-secret" SESSION_SECRET = "model-eval-site-session-secret"
# 图片水印(默认=首页网址,后台可改) # 图片水印(默认=首页网址,后台可改;样式后台可选
DEFAULT_WATERMARK = "http://121.40.164.32:16066" DEFAULT_WATERMARK = "http://121.40.164.32:16066"
DEFAULT_WATERMARK_STYLE = "corner" # corner/diagonal/bottom/corners/diag-big
DEFAULT_TOP_N = 10 # 排行榜默认显示热度最高的前 N 个 DEFAULT_TOP_N = 10 # 排行榜默认显示热度最高的前 N 个
# 排行榜列配置默认值(后台⚙️设置可改)
COLUMN_DEFAULTS = {
"lb_show_ttft": "0", # 是否显示首字ms
"lb_show_hardware": "1", # 是否显示硬件
"lb_show_framework": "1", # 是否显示推理框架
"lb_merge_decode": "1", # 平均解码+最佳解码合并为一列
"lb_merge_hwfw": "1", # 硬件+推理框架合并在同一列
}
WATERMARK_STYLES = ["corner", "diagonal", "bottom", "corners", "diag-big"]
# data-chart-tool 图表服务地址(画速度对比图) # data-chart-tool 图表服务地址(画速度对比图)
CHART_API_BASE = "http://127.0.0.1:16016" CHART_API_BASE = "http://127.0.0.1:16016"
+15 -7
View File
@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS speed_items(
model TEXT DEFAULT '', model TEXT DEFAULT '',
heat INTEGER DEFAULT 0, heat INTEGER DEFAULT 0,
intro TEXT DEFAULT '', intro TEXT DEFAULT '',
framework TEXT DEFAULT '',
count INTEGER DEFAULT 0, count INTEGER DEFAULT 0,
remark TEXT DEFAULT '', remark TEXT DEFAULT '',
updated_at TEXT DEFAULT (datetime('now','localtime')) updated_at TEXT DEFAULT (datetime('now','localtime'))
@@ -97,6 +98,10 @@ def _migrate(conn):
cols = [r[1] for r in cur.fetchall()] cols = [r[1] for r in cur.fetchall()]
if "hardware" not in cols: if "hardware" not in cols:
conn.execute("ALTER TABLE submissions ADD COLUMN hardware TEXT DEFAULT ''") conn.execute("ALTER TABLE submissions ADD COLUMN hardware TEXT DEFAULT ''")
cur = conn.execute("PRAGMA table_info(speed_items)")
scols = [r[1] for r in cur.fetchall()]
if "framework" not in scols:
conn.execute("ALTER TABLE speed_items ADD COLUMN framework TEXT DEFAULT ''")
def init_db(): def init_db():
@@ -359,6 +364,7 @@ def leaderboard(sort="heat", order="desc", limit=500):
"MAX(s.created_at) AS last_tested, " "MAX(s.created_at) AS last_tested, "
"COALESCE(MAX(si.heat), 0) AS heat, " "COALESCE(MAX(si.heat), 0) AS heat, "
"MAX(si.intro) AS intro, " "MAX(si.intro) AS intro, "
"MAX(si.framework) AS framework, "
"COALESCE(MAX(si.count), 0) AS count_override, " "COALESCE(MAX(si.count), 0) AS count_override, "
"(SELECT s2.hardware FROM submissions s2 " "(SELECT s2.hardware FROM submissions s2 "
" WHERE s2.provider=s.provider AND s2.model=s.model AND s2.hardware<>'' " " WHERE s2.provider=s.provider AND s2.model=s.model AND s2.hardware<>'' "
@@ -546,8 +552,8 @@ def touch_speed_item(provider, model):
cnt = conn.execute("SELECT COUNT(*) c FROM submissions WHERE provider=? AND model=?", cnt = conn.execute("SELECT COUNT(*) c FROM submissions WHERE provider=? AND model=?",
(provider, model)).fetchone()["c"] (provider, model)).fetchone()["c"]
cur = conn.execute( cur = conn.execute(
"INSERT INTO speed_items(provider,model,heat,intro,count) VALUES(?,?,?,?,?)", "INSERT INTO speed_items(provider,model,heat,intro,framework,count) VALUES(?,?,?,?,?,?)",
(provider, model, cnt, "", cnt)) (provider, model, cnt, "", "", cnt))
conn.commit() conn.commit()
return cur.lastrowid return cur.lastrowid
finally: finally:
@@ -580,8 +586,9 @@ def add_speed_item(provider, model, heat=0, intro="", count=0, remark=""):
if r: if r:
raise ValueError("该速度项已存在") raise ValueError("该速度项已存在")
cur = conn.execute( cur = conn.execute(
"INSERT INTO speed_items(provider,model,heat,intro,count,remark) VALUES(?,?,?,?,?,?)", "INSERT INTO speed_items(provider,model,heat,intro,framework,count,remark) VALUES(?,?,?,?,?,?,?)",
(provider, model, int(heat or 0), intro or "", int(count or 0), remark or "")) (provider, model, int(heat or 0), intro or "", (data.get("framework") or "").strip(),
int(count or 0), remark or ""))
conn.commit() conn.commit()
return cur.lastrowid return cur.lastrowid
finally: finally:
@@ -593,12 +600,13 @@ def update_speed_item(sid: int, data: dict):
conn = _connect() conn = _connect()
try: try:
conn.execute( conn.execute(
"UPDATE speed_items SET provider=?,model=?,heat=?,intro=?,count=?,remark=?," "UPDATE speed_items SET provider=?,model=?,heat=?,intro=?,framework=?,count=?,remark=?,"
"updated_at=datetime('now','localtime') WHERE id=?", "updated_at=datetime('now','localtime') WHERE id=?",
((data.get("provider") or "").strip(), ((data.get("provider") or "").strip(),
(data.get("model") or "").strip(), (data.get("model") or "").strip(),
int(data.get("heat") or 0), int(data.get("heat") or 0),
(data.get("intro") or "").strip(), (data.get("intro") or "").strip(),
(data.get("framework") or "").strip(),
int(data.get("count") or 0), int(data.get("count") or 0),
(data.get("remark") or "").strip(), (data.get("remark") or "").strip(),
sid)) sid))
@@ -632,8 +640,8 @@ def sync_speed_items():
if r0: if r0:
continue continue
conn.execute( conn.execute(
"INSERT INTO speed_items(provider,model,heat,intro,count) VALUES(?,?,?,?,?)", "INSERT INTO speed_items(provider,model,heat,intro,framework,count) VALUES(?,?,?,?,?,?)",
(r["provider"], r["model"], r["c"], "", r["c"])) (r["provider"], r["model"], r["c"], "", "", r["c"]))
added += 1 added += 1
conn.commit() conn.commit()
return added return added
+23 -5
View File
@@ -103,25 +103,43 @@
<div class="tbl-wrap"> <div class="tbl-wrap">
<table id="si"><thead><tr> <table id="si"><thead><tr>
<th>#</th><th>提供商</th><th>模型</th><th>🔥热度</th><th>个数</th> <th>#</th><th>提供商</th><th>模型</th><th>🔥热度</th><th>个数</th>
<th>配置简介</th><th>备注</th><th>实际提交</th><th>操作</th> <th>配置简介</th><th>推理框架</th><th>备注</th><th>实际提交</th><th>操作</th>
</tr></thead><tbody><tr><td colspan="9" class="empty">加载中…</td></tr></tbody></table> </tr></thead><tbody><tr><td colspan="10" class="empty">加载中…</td></tr></tbody></table>
</div> </div>
</div> </div>
<!-- 设置 --> <!-- 设置 -->
<div class="panel" id="tab-settings" hidden> <div class="panel" id="tab-settings" hidden>
<h2>⚙️ 站点设置</h2> <h2>⚙️ 站点设置</h2>
<div class="form-grid" style="max-width:640px"> <div class="form-grid" style="max-width:700px">
<div><label>图片水印文字(生成图表时叠加,默认首页网址)</label> <div><label>图片水印文字(默认首页网址)</label>
<input type="text" id="set-watermark" placeholder="http://121.40.164.32:16066" style="width:100%"></div> <input type="text" id="set-watermark" placeholder="http://121.40.164.32:16066" style="width:100%"></div>
<div><label>水印风格(5 种预置可选)</label>
<select id="set-wmstyle" style="width:100%">
<option value="corner">右下角标注</option>
<option value="diagonal">全图斜纹</option>
<option value="bottom">底部横条</option>
<option value="corners">四角标注</option>
<option value="diag-big">对角大字</option>
</select></div>
<div><label>排行榜默认显示热度前 N 个</label> <div><label>排行榜默认显示热度前 N 个</label>
<input type="number" id="set-topn" value="10" min="1" max="50" style="width:100%"></div> <input type="number" id="set-topn" value="10" min="1" max="50" style="width:100%"></div>
</div> </div>
<h3 style="margin-top:16px">📋 排行榜列显示配置</h3>
<div class="col-toggles" id="set-cols">
<label><input type="checkbox" data-col="lb_show_ttft"> 显示首字 ms</label>
<label><input type="checkbox" data-col="lb_show_hardware"> 显示硬件</label>
<label><input type="checkbox" data-col="lb_show_framework"> 显示推理框架</label>
<label><input type="checkbox" data-col="lb_merge_decode"> 平均解码+最佳解码合并为一列</label>
<label><input type="checkbox" data-col="lb_merge_hwfw"> 硬件+推理框架合并在同一列</label>
</div>
<div class="form-row" style="margin-top:12px"> <div class="form-row" style="margin-top:12px">
<button class="btn primary" id="btn-settings-save">💾 保存设置</button> <button class="btn primary" id="btn-settings-save">💾 保存设置</button>
<span class="hint" id="set-status"></span> <span class="hint" id="set-status"></span>
</div> </div>
<div class="notice green" style="margin-top:8px">💡 水印默认使用首页网址,可改成任意文字;排行榜默认显示热度最高的前 N 个速度项</div> <div class="notice green" style="margin-top:8px">💡 水印默认使用首页网址,可改成任意文字,风格 5 选 1;排行榜列可自由开关/合并。保存后刷新首页生效</div>
</div> </div>
</div> </div>
</div> </div>
+21
View File
@@ -266,3 +266,24 @@ textarea.items-editor { min-height: 160px; font-family: ui-monospace, monospace;
.search-item:hover { background: rgba(91,148,255,.12); } .search-item:hover { background: rgba(91,148,255,.12); }
.search-item .si-main { font-weight: 700; color: var(--text-strong); } .search-item .si-main { font-weight: 700; color: var(--text-strong); }
.search-item .si-sub { color: var(--muted); font-size: 12px; margin-top: 2px; } .search-item .si-sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
.search-item .si-act { float: right; font-size: 12px; color: var(--accent); }
/* 网页顶部居中搜索 */
.top-search {
position: relative; max-width: 640px; margin: 4px auto 18px;
}
.top-search input {
width: 100%; padding: 11px 16px; font-size: 14px; border-radius: 22px;
background: var(--panel2); border: 1px solid var(--border); color: var(--text);
}
.top-search input:focus { border-color: var(--accent); outline: none; }
.top-search .search-drop { max-width: 640px; }
/* 已添加(钉住)的速度项 */
.pinned-chip {
display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 14px;
background: rgba(255,180,60,.12); border: 1px solid rgba(255,180,60,.4); font-size: 12.5px;
}
.pinned-chip .x { color: var(--muted); cursor: pointer; font-weight: 700; }
.pinned-chip .x:hover { color: var(--danger); }
.pinned-empty { color: var(--muted); font-size: 12px; }
+14 -21
View File
@@ -18,6 +18,12 @@
</div> </div>
<div class="container"> <div class="container">
<!-- 网页最上面居中:全局搜索(点击热门5个,选中跳详情) -->
<div class="top-search">
<input type="text" id="top-search" placeholder="🔍 搜索模型 / 提供商 / 配置简介…(点击输入框显示热门5个,选中跳转详情页)">
<div class="search-drop" id="top-search-drop" hidden></div>
</div>
<div class="stat-cards"> <div class="stat-cards">
<div class="stat-card"><div class="k">评测模型数</div><div class="v" id="st-models"></div></div> <div class="stat-card"><div class="k">评测模型数</div><div class="v" id="st-models"></div></div>
<div class="stat-card"><div class="k">评测提交数</div><div class="v" id="st-subs"></div></div> <div class="stat-card"><div class="k">评测提交数</div><div class="v" id="st-subs"></div></div>
@@ -27,40 +33,27 @@
</div> </div>
<div class="panel"> <div class="panel">
<h2>🏆 模型运行速度排行 <span class="hint">(默认显示热度最高的前10个;点表头可排序</span></h2> <h2>🏆 模型运行速度排行 <span class="hint">(默认显示热度最高的前10个;下方搜索框可把指定速度项添加进本区域查看</span></h2>
<div class="search-wrap" style="position:relative;margin-bottom:12px"> <div class="search-wrap" style="position:relative;margin-bottom:10px">
<input type="text" id="lb-search" placeholder="🔍 搜索模型 / 提供商 / 配置简介…(点击输入框显示热门5个)" style="width:100%;max-width:480px"> <input type="text" id="lb-search" placeholder="🔍 搜索并「添加」具体速度项到下方列表中查看…(点击显示热门5个)" style="width:100%;max-width:480px">
<div class="search-drop" id="lb-search-drop" hidden></div> <div class="search-drop" id="lb-search-drop" hidden></div>
</div> </div>
<div class="form-row" id="lb-pinned" style="margin-bottom:8px"></div>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table id="lb"> <table id="lb">
<thead><tr> <thead></thead>
<th class="num">#</th>
<th>提供商</th>
<th>模型 / 配置简介</th>
<th class="num" data-sort="heat">🔥热度<span class="arrow"></span></th>
<th class="num" data-sort="cnt">提交<span class="arrow"></span></th>
<th class="num" data-sort="accounts">账号<span class="arrow"></span></th>
<th class="num" data-sort="avg_decode_speed">平均解码 tok/s<span class="arrow"></span></th>
<th class="num" data-sort="avg_prefill_speed">平均预填充 tok/s<span class="arrow"></span></th>
<th class="num" data-sort="avg_ttft_ms">首字 ms<span class="arrow"></span></th>
<th class="num" data-sort="best_decode">最佳解码 tok/s<span class="arrow"></span></th>
<th>硬件</th>
<th class="num" data-sort="last_tested">最后测试<span class="arrow"></span></th>
<th>操作</th>
</tr></thead>
<tbody><tr><td colspan="13" class="empty">加载中…</td></tr></tbody> <tbody><tr><td colspan="13" class="empty">加载中…</td></tr></tbody>
</table> </table>
</div> </div>
</div> </div>
<div class="panel"> <div class="panel">
<h2>📊 运行速度对比图 <span class="hint">默认对比全部模型,可勾选指定模型后重新生成</span></h2> <h2>📊 运行速度对比图 <span class="hint">要对比的模型列表:每项可✕掉,点排行榜「+对比」可添加,清空一键去除</span></h2>
<div class="hint" style="margin-bottom:8px">选择要对比的模型(默认全部;也可点排行榜每行「+对比」加入对比车后到「⚖️ 模型对比」页对比):</div> <div class="hint" style="margin-bottom:8px">要对比的模型列表(默认对比全部):</div>
<div class="chips" id="lb-chart-models"></div> <div class="chips" id="lb-chart-models"></div>
<div class="form-row" style="margin-top:10px"> <div class="form-row" style="margin-top:10px">
<button class="btn small" id="lb-chart-all">✅ 全选</button> <button class="btn small" id="lb-chart-all">✅ 全选</button>
<button class="btn small" id="lb-chart-none"> 清空</button> <button class="btn small" id="lb-chart-clear">🗑 清空</button>
<button class="btn small primary" id="lb-chart-gen">🎨 生成/刷新图表</button> <button class="btn small primary" id="lb-chart-gen">🎨 生成/刷新图表</button>
<button class="btn small" id="lb-chart-dl">⬇ 下载 PNG</button> <button class="btn small" id="lb-chart-dl">⬇ 下载 PNG</button>
<span class="hint" id="lb-chart-status"></span> <span class="hint" id="lb-chart-status"></span>
+248 -124
View File
@@ -47,6 +47,11 @@ function cartAdd(key) {
const c = getCart(); const c = getCart();
if (!c.includes(key)) { c.push(key); saveCart(c); toast(" 已加入对比"); } if (!c.includes(key)) { c.push(key); saveCart(c); toast(" 已加入对比"); }
else { toast("已在对比中"); } else { toast("已在对比中"); }
// 同步到首页运行速度对比图的要对比列表
if (typeof lbChartSel !== "undefined" && lbChartSel) {
lbChartSel.add(key);
if (typeof renderLbChips === "function") renderLbChips();
}
renderCartBar(); renderCartBar();
} }
function cartRemove(key) { saveCart(getCart().filter((k) => k !== key)); renderCartBar(); } function cartRemove(key) { saveCart(getCart().filter((k) => k !== key)); renderCartBar(); }
@@ -87,7 +92,7 @@ function buildRunPayload(opts) {
const series = (opts.series || RUN_SERIES).filter((s) => s.show); const series = (opts.series || RUN_SERIES).filter((s) => s.show);
const payload = { const payload = {
data: opts.csv, chartType: "bar", title: opts.title || "模型运行速度对比", data: opts.csv, chartType: "bar", title: opts.title || "模型运行速度对比",
theme: "default", showLegend: series.length > 1, showGrid: true, theme: opts.theme || "dark", showLegend: series.length > 1, showGrid: true,
showLabel: opts.showLabel ?? false, smoothLine: true, showLabel: opts.showLabel ?? false, smoothLine: true,
dualYAxis: series.some((s) => s.axis === 1), dualYAxis: series.some((s) => s.axis === 1),
leftAxisName: opts.leftAxisName || "预填充速度(tok/s)", leftAxisName: opts.leftAxisName || "预填充速度(tok/s)",
@@ -112,13 +117,15 @@ function navActive() {
}); });
} }
/* ───────────────────────── 首页:排行(热度前10 + 搜索) ───────────────────────── */ /* ───────────────────────── 首页:排行(热度前N + 搜索添加 + 动态列) ───────────────────────── */
let sortKey = "heat"; let sortKey = "heat";
let sortOrder = "desc"; let sortOrder = "desc";
let lbModels = []; // 全部模型(含 heat/intro/count_override),按热度排序 let lbAll = []; // 全部模型(含 heat/intro/framework/count_override),按热度
let lbAll = []; let lbModels = [];
let lbSelected = new Set(); // selected keys(对比图用)
let lbTopN = 10; let lbTopN = 10;
let lbPinned = []; // 搜索框“添加”到本区域的模型 key(按添加顺序)
let lbChartSel = new Set(); // 运行速度对比图要对比的模型(默认全部)
let lbCols = { lb_show_ttft: 0, lb_show_hardware: 1, lb_show_framework: 1, lb_merge_decode: 1, lb_merge_hwfw: 1 };
let lbQuery = ""; let lbQuery = "";
const modelKey = (p, m) => `${p}|${m}`; const modelKey = (p, m) => `${p}|${m}`;
@@ -130,6 +137,8 @@ const LB_SORT_GETTER = {
last_tested: (r) => r.last_tested || "", last_tested: (r) => r.last_tested || "",
}; };
const fwTag = (fw) => (fw ? `<span class="hw-tag" style="background:rgba(46,230,122,.12);border-color:rgba(46,230,122,.35);color:#8ef5b8" title="推理框架">⚙ ${esc(fw)}</span>` : "");
async function loadStats() { async function loadStats() {
try { try {
const s = await api("/api/stats"); const s = await api("/api/stats");
@@ -156,125 +165,184 @@ function cmpLb(a, b) {
return sortOrder === "desc" ? -c : c; return sortOrder === "desc" ? -c : c;
} }
/* 排行榜列定义(后台可配) */
function lbColDefs() {
const cols = lbCols, d = [];
d.push({ type: "rank", label: "#", cls: "num" });
d.push({ type: "provider", label: "提供商" });
d.push({ type: "model", label: "模型 / 配置简介" });
d.push({ type: "heat", label: "🔥热度", cls: "num", sort: "heat" });
d.push({ type: "cnt", label: "提交", cls: "num", sort: "cnt" });
d.push({ type: "accounts", label: "账号", cls: "num", sort: "accounts" });
if (cols.lb_merge_decode) d.push({ type: "decode_merged", label: "解码 tok/s(均 / 最佳)", cls: "num", sort: "avg_decode_speed" });
else {
d.push({ type: "avg_decode", label: "平均解码 tok/s", cls: "num", sort: "avg_decode_speed" });
d.push({ type: "best_decode", label: "最佳解码 tok/s", cls: "num", sort: "best_decode" });
}
d.push({ type: "prefill", label: "平均预填充 tok/s", cls: "num", sort: "avg_prefill_speed" });
if (cols.lb_show_ttft) d.push({ type: "ttft", label: "首字 ms", cls: "num", sort: "avg_ttft_ms" });
if (cols.lb_merge_hwfw) d.push({ type: "hwfw", label: "硬件 / 推理框架" });
else {
if (cols.lb_show_hardware) d.push({ type: "hw", label: "硬件" });
if (cols.lb_show_framework) d.push({ type: "fw", label: "推理框架" });
}
d.push({ type: "last", label: "最后测试", cls: "num", sort: "last_tested" });
d.push({ type: "ops", label: "操作" });
return d;
}
function lbCell(r, c, idx, maxDecode) {
const key = modelKey(r.provider, r.model);
switch (c.type) {
case "rank": return `<td class="num">${idx + 1}</td>`;
case "provider": return `<td><span class="pill">${esc(providerLabel(r.provider))}</span></td>`;
case "model": {
const intro = r.intro ? `<div class="hint">${esc(r.intro)}</div>` : "";
return `<td><a href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}" title="查看详情">${esc(r.model)}</a>${intro}</td>`;
}
case "heat": return `<td class="num"><span class="pill amber">🔥 ${fmtInt(r.heat || 0)}</span></td>`;
case "cnt": return `<td class="num">${fmtInt(r.count_override > 0 ? r.count_override : r.cnt)}</td>`;
case "accounts": return `<td class="num">${fmtInt(r.accounts)}</td>`;
case "decode_merged": {
const meter = r.avg_decode_speed ? `<div class="meter"><div style="width:${((r.avg_decode_speed || 0) / maxDecode * 100).toFixed(1)}%"></div></div>` : "";
return `<td class="num"><span class="badge-decode">${fmt(r.avg_decode_speed)}</span> / <span class="hint">${fmt(r.best_decode)}</span>${meter}</td>`;
}
case "avg_decode": {
const meter = r.avg_decode_speed ? `<div class="meter"><div style="width:${((r.avg_decode_speed || 0) / maxDecode * 100).toFixed(1)}%"></div></div>` : "";
return `<td class="num"><span class="badge-decode">${fmt(r.avg_decode_speed)}</span>${meter}</td>`;
}
case "best_decode": return `<td class="num">${fmt(r.best_decode)}</td>`;
case "prefill": return `<td class="num">${fmt(r.avg_prefill_speed)}</td>`;
case "ttft": return `<td class="num">${fmt(r.avg_ttft_ms)}</td>`;
case "hwfw": return `<td>${hwTag(r.hardware)} ${fwTag(r.framework)}</td>`;
case "hw": return `<td>${hwTag(r.hardware)}</td>`;
case "fw": return `<td>${fwTag(r.framework)}</td>`;
case "last": return `<td class="num">${esc((r.last_tested || "").slice(5, 16))}</td>`;
case "ops": return `<td>
<a class="btn small" href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}" title="查看详情">详情</a>
<button class="btn small" data-cart="${key}" title="加入对比">+对比</button>
</td>`;
}
return "<td></td>";
}
function renderLbTable(rows, pinnedSet) {
const thead = $("#lb thead");
const tbody = $("#lb tbody");
if (!thead || !tbody) return;
const defs = lbColDefs();
thead.innerHTML = "<tr>" + defs.map((c) => {
const arrow = c.sort ? (c.sort === sortKey ? (sortOrder === "desc" ? " ▼" : " ▲") : "") : "";
return `<th class="${c.cls || ""}" ${c.sort ? `data-sort="${c.sort}"` : ""}>${esc(c.label)}<span class="arrow">${arrow}</span></th>`;
}).join("") + "</tr>";
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="${defs.length}" class="empty">${pinnedSet ? "请在下方搜索框中搜索并添加速度项查看" : "暂无评测数据"}</td></tr>`;
return;
}
const maxDecode = Math.max(...rows.map((r) => r.avg_decode_speed || 0));
tbody.innerHTML = rows.map((r, i) => {
const pinned = pinnedSet && pinnedSet.has(modelKey(r.provider, r.model));
const mark = pinned ? ` <span class="pill amber">📌 已添加</span>` : "";
const cells = defs.map((c, ci) => {
if (ci === 0) return `<td class="num">${i + 1}${mark}</td>`;
return lbCell(r, c, i, maxDecode);
}).join("");
return `<tr>${cells}</tr>`;
}).join("");
$$("#lb th[data-sort]").forEach((th) => th.addEventListener("click", () => sortClick(th.dataset.sort)));
}
async function loadLeaderboard() {
const tbody = $("#lb tbody");
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="13" class="empty">加载中…</td></tr>';
let d;
try {
d = await api(`/api/leaderboard?sort=heat&order=desc&limit=500`);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="13" class="empty">加载失败:${esc(e.message)}</td></tr>`;
return;
}
if (d.top_n) lbTopN = d.top_n;
if (d.columns) lbCols = d.columns;
lbAll = d.rows;
lbModels = lbAll.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model), intro: r.intro || "", heat: r.heat || 0 }));
if (!lbChartSel.size) lbModels.forEach((m) => lbChartSel.add(m.key)); // 默认对比全部
renderLbChips();
renderPinned();
applyView();
}
/* 视图 = 已添加(pinned) + 热度前N */
function applyView() {
const rows = [];
const seen = new Set();
const pinSet = new Set(lbPinned);
for (const key of lbPinned) {
const m = lbAll.find((r) => modelKey(r.provider, r.model) === key);
if (m) { rows.push(m); seen.add(key); }
}
let top = [...lbAll].sort(cmpLb);
if (pinSet.size) top = top.filter((r) => !pinSet.has(modelKey(r.provider, r.model)));
rows.push(...top.slice(0, lbTopN));
renderLbTable(rows, pinSet);
}
function renderPinned() {
const bar = $("#lb-pinned");
if (!bar) return;
if (!lbPinned.length) { bar.innerHTML = '<span class="pinned-empty">尚未添加速度项 —— 用下方搜索框搜索并点「+ 添加」可把指定速度项固定到本区域查看</span>'; return; }
bar.innerHTML = lbPinned.map((key) => {
const m = lbAll.find((r) => modelKey(r.provider, r.model) === key);
return `<span class="pinned-chip" data-pin="${key}">📌 ${esc(m ? m.model : cartModelName(key))} <span class="x" data-pinx="${key}">✕</span></span>`;
}).join("");
}
/* 运行速度对比图:要对比的模型 chips(每项可✕,清空一键去除) */
function renderLbChips() { function renderLbChips() {
const box = $("#lb-chart-models"); const box = $("#lb-chart-models");
if (!box) return; if (!box) return;
box.innerHTML = ""; box.innerHTML = "";
if (!lbModels.length) { box.innerHTML = '<span class="hint">暂无模型</span>'; return; } if (!lbModels.length) { box.innerHTML = '<span class="hint">暂无模型</span>'; return; }
for (const m of lbModels) { for (const m of lbModels) {
const on = lbSelected.has(m.key); const on = lbChartSel.has(m.key);
const c = document.createElement("span"); const c = document.createElement("span");
c.className = "chip" + (on ? " on" : ""); c.className = "chip" + (on ? " on" : "");
c.title = "点击选择/取消该模型"; c.title = "✕ 移出对比列表;在排行榜点「+对比」可再加回";
c.innerHTML = `<span>${esc(m.model)}</span>`; c.innerHTML = `<span>${esc(m.model)}</span><span class="x" data-chipx="${m.key}">✕</span>`;
c.addEventListener("click", () => { c.addEventListener("click", (e) => {
if (lbSelected.has(m.key)) lbSelected.delete(m.key); else lbSelected.add(m.key); if (e.target.closest("[data-chipx]")) {
lbChartSel.delete(m.key);
c.classList.remove("on");
return;
}
if (lbChartSel.has(m.key)) lbChartSel.delete(m.key); else lbChartSel.add(m.key);
c.classList.toggle("on"); c.classList.toggle("on");
}); });
box.appendChild(c); box.appendChild(c);
} }
} }
async function loadLeaderboard() { /* 顶部居中搜索(选中跳详情) */
const tb = $("#lb tbody"); function bindTopSearch() {
if (!tb) return; const input = $("#top-search");
tb.innerHTML = '<tr><td colspan="13" class="empty">加载中…</td></tr>'; const drop = $("#top-search-drop");
let d; if (!input || !drop) return;
try { const show = (items) => {
d = await api(`/api/leaderboard?sort=heat&order=desc&limit=500`);
} catch (e) {
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 (!rows.length) {
tb.innerHTML = '<tr><td colspan="13" class="empty">' + (q ? `未找到匹配「${esc(lbQuery)}」的模型` : "暂无评测数据。可在后台管理「提交管理」里点击「🎲 生成演示数据」或从 llm-speed-tester 一键发送。") + '</td></tr>';
return;
}
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>${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>
<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>
<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; } if (!items || !items.length) { drop.hidden = true; return; }
drop.hidden = false; drop.hidden = false;
drop.innerHTML = items.map((r) => { drop.innerHTML = items.map((r) => `<div class="search-item" data-key="${modelKey(r.provider, r.model)}">
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-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 class="si-sub">${esc(r.intro || providerLabel(r.provider))} · 提交 ${fmtInt(r.cnt)} · 解码 ${fmt(r.avg_decode_speed)} tok/s · ${r.hardware ? esc(r.hardware) : ""} ${r.framework ? esc(r.framework) : ""}</div>
</div>`; </div>`).join("");
}).join(""); };
} input.addEventListener("focus", () => show(lbAll.slice(0, 5)));
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", () => { input.addEventListener("input", () => {
lbQuery = input.value; const q = input.value.trim().toLowerCase();
applyView(); const list = q ? lbAll.filter((r) => (r.model + " " + r.provider + " " + (r.intro || "")).toLowerCase().includes(q)).slice(0, 5) : lbAll.slice(0, 5);
const q = lbQuery.trim().toLowerCase(); show(list);
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); }); input.addEventListener("blur", () => setTimeout(() => { drop.hidden = true; }, 150));
drop.addEventListener("mousedown", (e) => { drop.addEventListener("mousedown", (e) => {
const item = e.target.closest(".search-item"); const item = e.target.closest(".search-item");
if (!item) return; if (!item) return;
@@ -285,19 +353,62 @@ function bindSearch() {
}); });
} }
/* 排行内搜索(点击“添加”把速度项固定到本区域) */
function bindLbSearch() {
const input = $("#lb-search");
const drop = $("#lb-search-drop");
if (!input || !drop) return;
const show = (items) => {
if (!items || !items.length) { drop.hidden = true; return; }
drop.hidden = false;
drop.innerHTML = items.map((r) => {
const key = modelKey(r.provider, r.model);
const pinned = lbPinned.includes(key);
return `<div class="search-item" data-add="${key}">
<div class="si-main">${esc(r.model)} <span class="pill amber" style="margin-left:6px">🔥${fmtInt(r.heat || 0)}</span></div>
<div class="si-sub">${esc(r.intro || providerLabel(r.provider))} · 解码 ${fmt(r.avg_decode_speed)} tok/s</div>
<span class="si-act">${pinned ? "✓ 已添加" : " 添加"}</span>
</div>`;
}).join("");
};
input.addEventListener("focus", () => show(lbAll.slice(0, 5)));
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
const list = q ? lbAll.filter((r) => (r.model + " " + r.provider + " " + (r.intro || "")).toLowerCase().includes(q)).slice(0, 6) : lbAll.slice(0, 5);
show(list);
});
input.addEventListener("blur", () => setTimeout(() => { drop.hidden = true; }, 150));
drop.addEventListener("mousedown", (e) => {
const item = e.target.closest("[data-add]");
if (!item) return;
e.preventDefault();
const key = item.dataset.add;
if (!lbPinned.includes(key)) {
lbPinned.push(key);
toast("📌 已添加到本区域");
renderPinned();
applyView();
}
});
// 钉住项的 ✕
const bar = $("#lb-pinned");
if (bar) bar.addEventListener("click", (e) => {
const x = e.target.closest("[data-pinx]");
if (x) { lbPinned = lbPinned.filter((k) => k !== x.dataset.pinx); renderPinned(); applyView(); }
});
}
let lbChartUrl = ""; let lbChartUrl = "";
async function genLbChart() { async function genLbChart() {
const status = $("#lb-chart-status"); const status = $("#lb-chart-status");
const imgWrap = $("#lb-chart-img"); const imgWrap = $("#lb-chart-img");
if (!lbSelected.size) { status.textContent = "请至少选择一个模型"; return; } if (!lbChartSel.size) { status.textContent = "请先添加要对比的模型(点排行榜「+对比」或点「✅ 全选」)"; return; }
status.textContent = "⏳ 正在生成..."; status.textContent = "⏳ 正在生成...";
try { try {
// 重新请求指定模型的排行数据(对比图只包含所选模型) const models = [...lbChartSel].join(",");
const models = [...lbSelected].join(",");
const d = await api(`/api/leaderboard?sort=heat&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; } if (!d.rows.length) { status.textContent = "所选模型暂无数据"; return; }
const n = d.rows.length; const n = d.rows.length;
// 运行速度对比:预填充(左轴·空心柱) + 解码(右轴·实心柱) + 首字(虚线),三色
const series = RUN_SERIES.map((s, i) => ({ const series = RUN_SERIES.map((s, i) => ({
...s, show: true, ...s, show: true,
type: ["bar", "bar", "line"][i], type: ["bar", "bar", "line"][i],
@@ -888,7 +999,7 @@ async function loadSpeedItems() {
try { list = await api("/api/admin/speed-items"); } try { list = await api("/api/admin/speed-items"); }
catch (e) { tb.innerHTML = `<tr><td colspan="9" class="empty">加载失败:${esc(e.message)}</td></tr>`; return; } catch (e) { tb.innerHTML = `<tr><td colspan="9" class="empty">加载失败:${esc(e.message)}</td></tr>`; return; }
if (!list.length) { if (!list.length) {
tb.innerHTML = '<tr><td colspan="9" class="empty">暂无速度项,点击「🔄 从提交同步」自动生成。</td></tr>'; tb.innerHTML = '<tr><td colspan="10" class="empty">暂无速度项,点击「🔄 从提交同步」自动生成。</td></tr>';
return; return;
} }
tb.innerHTML = list.map((s) => `<tr> tb.innerHTML = list.map((s) => `<tr>
@@ -898,10 +1009,11 @@ async function loadSpeedItems() {
<td class="num"><span class="pill amber">🔥 ${fmtInt(s.heat)}</span></td> <td class="num"><span class="pill amber">🔥 ${fmtInt(s.heat)}</span></td>
<td class="num">${fmtInt(s.count)}</td> <td class="num">${fmtInt(s.count)}</td>
<td title="${esc(s.intro || "")}">${esc((s.intro || "—").slice(0, 22))}</td> <td title="${esc(s.intro || "")}">${esc((s.intro || "—").slice(0, 22))}</td>
<td>${s.framework ? `<span class="hw-tag">⚙ ${esc(s.framework)}</span>` : "—"}</td>
<td>${esc(s.remark || "—")}</td> <td>${esc(s.remark || "—")}</td>
<td class="num">${fmtInt(s.real_cnt)}</td> <td class="num">${fmtInt(s.real_cnt)}</td>
<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" data-edit-si="${s.id}" data-provider="${esc(s.provider)}" data-model="${esc(s.model)}" data-heat="${s.heat}" data-count="${s.count}" data-intro="${esc(s.intro || "")}" data-framework="${esc(s.framework || "")}" data-remark="${esc(s.remark || "")}">编辑</button>
<button class="btn small danger" data-del-si="${s.id}">删除</button> <button class="btn small danger" data-del-si="${s.id}">删除</button>
</td> </td>
</tr>`).join(""); </tr>`).join("");
@@ -910,7 +1022,12 @@ async function loadSpeedItems() {
async function loadSettings() { async function loadSettings() {
const s = await api("/api/admin/settings"); const s = await api("/api/admin/settings");
if ($("#set-watermark")) $("#set-watermark").value = s.watermark || ""; if ($("#set-watermark")) $("#set-watermark").value = s.watermark || "";
if ($("#set-wmstyle")) $("#set-wmstyle").value = s.watermark_style || "corner";
if ($("#set-topn")) $("#set-topn").value = s.top_n || 10; if ($("#set-topn")) $("#set-topn").value = s.top_n || 10;
const cols = s.columns || {};
$$("#set-cols input[data-col]").forEach((cb) => {
cb.checked = !!cols[cb.dataset.col];
});
} }
function editSpeedItem(btn) { function editSpeedItem(btn) {
@@ -920,11 +1037,12 @@ function editSpeedItem(btn) {
const heat = prompt("热度(数字):", btn.dataset.heat || "0"); const heat = prompt("热度(数字):", btn.dataset.heat || "0");
const count = prompt("个数(展示提交数,0=自动):", btn.dataset.count || "0"); const count = prompt("个数(展示提交数,0=自动):", btn.dataset.count || "0");
const intro = prompt("配置简介:", btn.dataset.intro || ""); const intro = prompt("配置简介:", btn.dataset.intro || "");
const framework = prompt("推理框架(如 vLLM / llama.cpp / TensorRT-LLM / SGLang):", btn.dataset.framework || "");
const remark = prompt("备注:", btn.dataset.remark || ""); const remark = prompt("备注:", btn.dataset.remark || "");
const body = { const body = {
provider: provider || "", model: model || "", provider: provider || "", model: model || "",
heat: parseInt(heat) || 0, count: parseInt(count) || 0, heat: parseInt(heat) || 0, count: parseInt(count) || 0,
intro: intro || "", remark: remark || "", intro: intro || "", framework: framework || "", remark: remark || "",
}; };
api(`/api/admin/speed-items/${btn.dataset.editSi}`, "PUT", body).then(() => { toast("已保存"); loadSpeedItems(); }); api(`/api/admin/speed-items/${btn.dataset.editSi}`, "PUT", body).then(() => { toast("已保存"); loadSpeedItems(); });
} }
@@ -974,22 +1092,25 @@ async function saveCapEditor() {
/* ───────────────────────── 事件绑定 ───────────────────────── */ /* ───────────────────────── 事件绑定 ───────────────────────── */
function bindIndex() { function bindIndex() {
$$("#lb th[data-sort]").forEach((th) => th.addEventListener("click", () => sortClick(th.dataset.sort))); // 顶部居中搜索(跳详情)
const gen = $("#lb-chart-gen"); bindTopSearch();
if (gen) gen.addEventListener("click", genLbChart); // 排行内搜索(添加速度项到本区域)
const dl = $("#lb-chart-dl"); bindLbSearch();
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"); const tb = $("#lb tbody");
if (tb) tb.addEventListener("click", (e) => { if (tb) tb.addEventListener("click", (e) => {
const btn = e.target.closest("[data-cart]"); const btn = e.target.closest("[data-cart]");
if (btn) { e.preventDefault(); cartAdd(btn.dataset.cart); } if (btn) { e.preventDefault(); cartAdd(btn.dataset.cart); }
}); });
bindSearch(); // 运行速度对比图:生成 / 下载 / 全选 / 清空
const gen = $("#lb-chart-gen");
if (gen) gen.addEventListener("click", genLbChart);
const dl = $("#lb-chart-dl");
if (dl) dl.addEventListener("click", () => { if (!lbChartUrl) { toast("请先生成图表"); return; } dlChartUrl(); });
const all = $("#lb-chart-all");
if (all) all.addEventListener("click", () => { lbModels.forEach((m) => lbChartSel.add(m.key)); renderLbChips(); });
const clear = $("#lb-chart-clear");
if (clear) clear.addEventListener("click", () => { lbChartSel.clear(); renderLbChips(); });
bindCartBar(); bindCartBar();
} }
@@ -1189,7 +1310,8 @@ function bindAdmin() {
const heat = prompt("热度:", "0") || 0; const heat = prompt("热度:", "0") || 0;
const count = prompt("个数(展示提交数,0=自动):", "0") || 0; const count = prompt("个数(展示提交数,0=自动):", "0") || 0;
const intro = prompt("配置简介:", "") || ""; const intro = prompt("配置简介:", "") || "";
api("/api/admin/speed-items", "POST", { provider, model, heat: parseInt(heat) || 0, count: parseInt(count) || 0, intro }) const framework = prompt("推理框架(如 vLLM / llama.cpp):", "") || "";
api("/api/admin/speed-items", "POST", { provider, model, heat: parseInt(heat) || 0, count: parseInt(count) || 0, intro, framework })
.then((r) => { if (!r.ok) throw new Error(r.error || "添加失败"); toast("已添加"); loadSpeedItems(); }) .then((r) => { if (!r.ok) throw new Error(r.error || "添加失败"); toast("已添加"); loadSpeedItems(); })
.catch((e) => toast(e.message)); .catch((e) => toast(e.message));
}); });
@@ -1220,8 +1342,10 @@ function bindAdmin() {
if (save) save.addEventListener("click", async () => { if (save) save.addEventListener("click", async () => {
const body = { const body = {
watermark_text: $("#set-watermark").value.trim(), watermark_text: $("#set-watermark").value.trim(),
watermark_style: $("#set-wmstyle").value,
top_n: parseInt($("#set-topn").value) || 10, top_n: parseInt($("#set-topn").value) || 10,
}; };
$$("#set-cols input[data-col]").forEach((cb) => { body[cb.dataset.col] = cb.checked ? "1" : "0"; });
try { try {
await api("/api/admin/settings", "PUT", body); await api("/api/admin/settings", "PUT", body);
const st = $("#set-status"); const st = $("#set-status");