v1.4.0 全局搜索+水印5风格+对比列表管理+排行列可配:搜索框拆分(网页顶部居中=选中跳详情; 排行内搜索=添加速度项到本区域查看,可✕移除); 水印预置5种风格(右下角/全图斜纹/底部横条/四角/对角大字)后台可选; 运行速度对比图要对比模型列表可叉掉/清空一键去除/+对比添加; 图表改深色主题适配深色背景; 排行榜新增推理框架列,首字ms默认隐藏,平均解码+最佳解码合并,硬件+推理框架合并,列显示/合并后台⚙️设置可配
This commit is contained in:
@@ -31,6 +31,11 @@ def _watermark_text():
|
||||
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():
|
||||
try:
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
return img_bytes
|
||||
style = style or config.DEFAULT_WATERMARK_STYLE
|
||||
if style not in config.WATERMARK_STYLES:
|
||||
style = config.DEFAULT_WATERMARK_STYLE
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except Exception:
|
||||
@@ -51,34 +68,59 @@ def apply_watermark(img_bytes: bytes, text: str) -> bytes:
|
||||
except Exception:
|
||||
return img_bytes
|
||||
w, h = img.size
|
||||
# 自适应水印颜色:取样左上角判断背景深浅
|
||||
bg = img.getpixel((4, 4))[:3]
|
||||
bg_luma = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2]
|
||||
dark_bg = bg_luma < 140
|
||||
fill = (50, 50, 60) if not dark_bg else (255, 255, 255)
|
||||
diag_alpha = 30 if not dark_bg else 26
|
||||
corner_alpha = 120 if not dark_bg else 128
|
||||
a_diag = 30 if not dark_bg else 26
|
||||
a_main = 130 if not dark_bg else 135
|
||||
|
||||
overlay = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(overlay)
|
||||
try:
|
||||
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:
|
||||
font = ImageFont.load_default()
|
||||
font_big = ImageFont.load_default()
|
||||
font_huge = ImageFont.load_default()
|
||||
|
||||
# 全图浅淡斜纹水印(45° 重复)
|
||||
step = 170
|
||||
for y in range(-h, h, step):
|
||||
for x in range(-w, w, step):
|
||||
d.text((x + y * 0.4, y), text, font=font, fill=fill + (diag_alpha,))
|
||||
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,))
|
||||
|
||||
# 右下角较清晰标注
|
||||
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 + (corner_alpha,))
|
||||
if style == "corner":
|
||||
_corner_label()
|
||||
elif style == "diagonal":
|
||||
step = 170
|
||||
for y in range(-h, h, step):
|
||||
for x in range(-w, w, step):
|
||||
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)
|
||||
tw = box[2] - box[0]; th = box[3] - box[1]
|
||||
bh = th + 24
|
||||
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)
|
||||
buf = io.BytesIO()
|
||||
@@ -209,8 +251,10 @@ def leaderboard():
|
||||
|
||||
chart_rows = rows[: int(request.args.get("chart_top", 30) or 30)]
|
||||
chart_csv = _speed_csv(chart_rows)
|
||||
cols = _column_config()
|
||||
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):
|
||||
@@ -271,7 +315,7 @@ def model_detail():
|
||||
line_payload = {
|
||||
"data": line_csv, "chartType": "line",
|
||||
"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,
|
||||
"leftAxisName": "预填充速度(tok/s)", "rightAxisName": "解码速度(tok/s)",
|
||||
"seriesTypes": ["line", "line"], "seriesAxis": [0, 1],
|
||||
@@ -341,7 +385,7 @@ def submission_chart(sid):
|
||||
payload = {
|
||||
"data": "\n".join(csv_lines), "chartType": "line",
|
||||
"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,
|
||||
"leftAxisName": "预填充速度(tok/s)", "rightAxisName": "解码速度(tok/s)",
|
||||
"seriesTypes": ["line", "line"], "seriesAxis": [0, 1],
|
||||
@@ -389,7 +433,9 @@ def account_delete(aid):
|
||||
|
||||
@app.route("/api/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")
|
||||
@@ -437,14 +483,15 @@ def admin_speed_item_sync():
|
||||
@admin_required
|
||||
def admin_settings_get():
|
||||
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"])
|
||||
@admin_required
|
||||
def admin_settings_put():
|
||||
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():
|
||||
if k in allow:
|
||||
db.set_setting(k, v)
|
||||
@@ -500,8 +547,8 @@ def _chart_proxy(payload):
|
||||
return jsonify({"ok": False, "error": "图表服务不可用: %s" % e}), 502
|
||||
if resp.status_code != 200:
|
||||
return jsonify({"ok": False, "error": "图表生成失败(%d): %s" % (resp.status_code, resp.text[:300])}), 502
|
||||
# 叠加水印(默认首页网址,后台可配置)
|
||||
data = apply_watermark(resp.content, _watermark_text())
|
||||
# 叠加水印(默认首页网址,后台可配置文字+5种风格)
|
||||
data = apply_watermark(resp.content, _watermark_text(), _watermark_style())
|
||||
return send_file(io.BytesIO(data), mimetype="image/png")
|
||||
|
||||
|
||||
|
||||
@@ -18,9 +18,21 @@ ADMIN_USER = "admin"
|
||||
ADMIN_PASSWORD = "admin123"
|
||||
SESSION_SECRET = "model-eval-site-session-secret"
|
||||
|
||||
# 图片水印(默认=首页网址,后台可改)
|
||||
# 图片水印(默认=首页网址,后台可改;样式后台可选)
|
||||
DEFAULT_WATERMARK = "http://121.40.164.32:16066"
|
||||
DEFAULT_WATERMARK_STYLE = "corner" # corner/diagonal/bottom/corners/diag-big
|
||||
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 图表服务地址(画速度对比图)
|
||||
CHART_API_BASE = "http://127.0.0.1:16016"
|
||||
+15
-7
@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS speed_items(
|
||||
model TEXT DEFAULT '',
|
||||
heat INTEGER DEFAULT 0,
|
||||
intro TEXT DEFAULT '',
|
||||
framework TEXT DEFAULT '',
|
||||
count INTEGER DEFAULT 0,
|
||||
remark TEXT DEFAULT '',
|
||||
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
@@ -97,6 +98,10 @@ def _migrate(conn):
|
||||
cols = [r[1] for r in cur.fetchall()]
|
||||
if "hardware" not in cols:
|
||||
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():
|
||||
@@ -359,6 +364,7 @@ def leaderboard(sort="heat", order="desc", limit=500):
|
||||
"MAX(s.created_at) AS last_tested, "
|
||||
"COALESCE(MAX(si.heat), 0) AS heat, "
|
||||
"MAX(si.intro) AS intro, "
|
||||
"MAX(si.framework) AS framework, "
|
||||
"COALESCE(MAX(si.count), 0) AS count_override, "
|
||||
"(SELECT s2.hardware FROM submissions s2 "
|
||||
" 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=?",
|
||||
(provider, model)).fetchone()["c"]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO speed_items(provider,model,heat,intro,count) VALUES(?,?,?,?,?)",
|
||||
(provider, model, cnt, "", cnt))
|
||||
"INSERT INTO speed_items(provider,model,heat,intro,framework,count) VALUES(?,?,?,?,?,?)",
|
||||
(provider, model, cnt, "", "", cnt))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
finally:
|
||||
@@ -580,8 +586,9 @@ def add_speed_item(provider, model, heat=0, intro="", count=0, remark=""):
|
||||
if r:
|
||||
raise ValueError("该速度项已存在")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO speed_items(provider,model,heat,intro,count,remark) VALUES(?,?,?,?,?,?)",
|
||||
(provider, model, int(heat or 0), intro or "", int(count or 0), remark or ""))
|
||||
"INSERT INTO speed_items(provider,model,heat,intro,framework,count,remark) VALUES(?,?,?,?,?,?,?)",
|
||||
(provider, model, int(heat or 0), intro or "", (data.get("framework") or "").strip(),
|
||||
int(count or 0), remark or ""))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
finally:
|
||||
@@ -593,12 +600,13 @@ def update_speed_item(sid: int, data: dict):
|
||||
conn = _connect()
|
||||
try:
|
||||
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=?",
|
||||
((data.get("provider") or "").strip(),
|
||||
(data.get("model") or "").strip(),
|
||||
int(data.get("heat") or 0),
|
||||
(data.get("intro") or "").strip(),
|
||||
(data.get("framework") or "").strip(),
|
||||
int(data.get("count") or 0),
|
||||
(data.get("remark") or "").strip(),
|
||||
sid))
|
||||
@@ -632,8 +640,8 @@ def sync_speed_items():
|
||||
if r0:
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO speed_items(provider,model,heat,intro,count) VALUES(?,?,?,?,?)",
|
||||
(r["provider"], r["model"], r["c"], "", r["c"]))
|
||||
"INSERT INTO speed_items(provider,model,heat,intro,framework,count) VALUES(?,?,?,?,?,?)",
|
||||
(r["provider"], r["model"], r["c"], "", "", r["c"]))
|
||||
added += 1
|
||||
conn.commit()
|
||||
return added
|
||||
|
||||
+23
-5
@@ -103,25 +103,43 @@
|
||||
<div class="tbl-wrap">
|
||||
<table id="si"><thead><tr>
|
||||
<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>
|
||||
<th>配置简介</th><th>推理框架</th><th>备注</th><th>实际提交</th><th>操作</th>
|
||||
</tr></thead><tbody><tr><td colspan="10" class="empty">加载中…</td></tr></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置 -->
|
||||
<div class="panel" id="tab-settings" hidden>
|
||||
<h2>⚙️ 站点设置</h2>
|
||||
<div class="form-grid" style="max-width:640px">
|
||||
<div><label>图片水印文字(生成图表时叠加,默认首页网址)</label>
|
||||
<div class="form-grid" style="max-width:700px">
|
||||
<div><label>图片水印文字(默认首页网址)</label>
|
||||
<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>
|
||||
<input type="number" id="set-topn" value="10" min="1" max="50" style="width:100%"></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">
|
||||
<button class="btn primary" id="btn-settings-save">💾 保存设置</button>
|
||||
<span class="hint" id="set-status"></span>
|
||||
</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>
|
||||
|
||||
@@ -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 .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-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
@@ -18,6 +18,12 @@
|
||||
</div>
|
||||
|
||||
<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-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>
|
||||
@@ -27,40 +33,27 @@
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>🏆 模型运行速度排行 <span class="hint">(默认显示热度最高的前10个;点表头可排序)</span></h2>
|
||||
<div class="search-wrap" style="position:relative;margin-bottom:12px">
|
||||
<input type="text" id="lb-search" placeholder="🔍 搜索模型 / 提供商 / 配置简介…(点击输入框显示热门5个)" style="width:100%;max-width:480px">
|
||||
<h2>🏆 模型运行速度排行 <span class="hint">(默认显示热度最高的前10个;下方搜索框可把指定速度项添加进本区域查看)</span></h2>
|
||||
<div class="search-wrap" style="position:relative;margin-bottom:10px">
|
||||
<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>
|
||||
<div class="form-row" id="lb-pinned" style="margin-bottom:8px"></div>
|
||||
<div class="tbl-wrap">
|
||||
<table id="lb">
|
||||
<thead><tr>
|
||||
<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>
|
||||
<thead></thead>
|
||||
<tbody><tr><td colspan="13" class="empty">加载中…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>📊 运行速度对比图 <span class="hint">(默认对比全部模型,可勾选指定模型后重新生成)</span></h2>
|
||||
<div class="hint" style="margin-bottom:8px">选择要对比的模型(默认全部;也可点排行榜每行「+对比」加入对比车后到「⚖️ 模型对比」页对比):</div>
|
||||
<h2>📊 运行速度对比图 <span class="hint">(要对比的模型列表:每项可✕掉,点排行榜「+对比」可添加,清空一键去除)</span></h2>
|
||||
<div class="hint" style="margin-bottom:8px">要对比的模型列表(默认对比全部):</div>
|
||||
<div class="chips" id="lb-chart-models"></div>
|
||||
<div class="form-row" style="margin-top:10px">
|
||||
<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" id="lb-chart-dl">⬇ 下载 PNG</button>
|
||||
<span class="hint" id="lb-chart-status"></span>
|
||||
|
||||
+250
-126
@@ -47,6 +47,11 @@ function cartAdd(key) {
|
||||
const c = getCart();
|
||||
if (!c.includes(key)) { c.push(key); saveCart(c); toast("+ 已加入对比"); }
|
||||
else { toast("已在对比中"); }
|
||||
// 同步到首页运行速度对比图的要对比列表
|
||||
if (typeof lbChartSel !== "undefined" && lbChartSel) {
|
||||
lbChartSel.add(key);
|
||||
if (typeof renderLbChips === "function") renderLbChips();
|
||||
}
|
||||
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 payload = {
|
||||
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,
|
||||
dualYAxis: series.some((s) => s.axis === 1),
|
||||
leftAxisName: opts.leftAxisName || "预填充速度(tok/s)",
|
||||
@@ -112,13 +117,15 @@ function navActive() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ───────────────────────── 首页:排行(热度前10 + 搜索) ───────────────────────── */
|
||||
/* ───────────────────────── 首页:排行(热度前N + 搜索添加 + 动态列) ───────────────────────── */
|
||||
let sortKey = "heat";
|
||||
let sortOrder = "desc";
|
||||
let lbModels = []; // 全部模型(含 heat/intro/count_override),已按热度排序
|
||||
let lbAll = [];
|
||||
let lbSelected = new Set(); // selected keys(对比图用)
|
||||
let lbAll = []; // 全部模型(含 heat/intro/framework/count_override),按热度
|
||||
let lbModels = [];
|
||||
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 = "";
|
||||
|
||||
const modelKey = (p, m) => `${p}|${m}`;
|
||||
@@ -130,6 +137,8 @@ const LB_SORT_GETTER = {
|
||||
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() {
|
||||
try {
|
||||
const s = await api("/api/stats");
|
||||
@@ -156,125 +165,184 @@ function cmpLb(a, b) {
|
||||
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() {
|
||||
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 on = lbChartSel.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.title = "✕ 移出对比列表;在排行榜点「+对比」可再加回";
|
||||
c.innerHTML = `<span>${esc(m.model)}</span><span class="x" data-chipx="${m.key}">✕</span>`;
|
||||
c.addEventListener("click", (e) => {
|
||||
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");
|
||||
});
|
||||
box.appendChild(c);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLeaderboard() {
|
||||
const tb = $("#lb tbody");
|
||||
if (!tb) return;
|
||||
tb.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) {
|
||||
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; }
|
||||
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");
|
||||
/* 顶部居中搜索(选中跳详情) */
|
||||
function bindTopSearch() {
|
||||
const input = $("#top-search");
|
||||
const drop = $("#top-search-drop");
|
||||
if (!input || !drop) return;
|
||||
input.addEventListener("focus", () => {
|
||||
// 点击输入框 → 显示最常见的5个速度项(按热度)
|
||||
renderSearchDrop(lbAll.slice(0, 5));
|
||||
});
|
||||
const show = (items) => {
|
||||
if (!items || !items.length) { drop.hidden = true; return; }
|
||||
drop.hidden = false;
|
||||
drop.innerHTML = items.map((r) => `<div class="search-item" data-key="${modelKey(r.provider, r.model)}">
|
||||
<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))} · 提交 ${fmtInt(r.cnt)} · 解码 ${fmt(r.avg_decode_speed)} tok/s · ${r.hardware ? esc(r.hardware) : ""} ${r.framework ? esc(r.framework) : ""}</div>
|
||||
</div>`).join("");
|
||||
};
|
||||
input.addEventListener("focus", () => show(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));
|
||||
const q = input.value.trim().toLowerCase();
|
||||
const list = q ? lbAll.filter((r) => (r.model + " " + r.provider + " " + (r.intro || "")).toLowerCase().includes(q)).slice(0, 5) : lbAll.slice(0, 5);
|
||||
show(list);
|
||||
});
|
||||
input.addEventListener("blur", () => { setTimeout(() => { drop.hidden = true; }, 150); });
|
||||
input.addEventListener("blur", () => setTimeout(() => { drop.hidden = true; }, 150));
|
||||
drop.addEventListener("mousedown", (e) => {
|
||||
const item = e.target.closest(".search-item");
|
||||
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 = "";
|
||||
async function genLbChart() {
|
||||
const status = $("#lb-chart-status");
|
||||
const imgWrap = $("#lb-chart-img");
|
||||
if (!lbSelected.size) { status.textContent = "请至少选择一个模型"; return; }
|
||||
if (!lbChartSel.size) { status.textContent = "请先添加要对比的模型(点排行榜「+对比」或点「✅ 全选」)"; return; }
|
||||
status.textContent = "⏳ 正在生成...";
|
||||
try {
|
||||
// 重新请求指定模型的排行数据(对比图只包含所选模型)
|
||||
const models = [...lbSelected].join(",");
|
||||
const models = [...lbChartSel].join(",");
|
||||
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;
|
||||
// 运行速度对比:预填充(左轴·空心柱) + 解码(右轴·实心柱) + 首字(虚线),三色
|
||||
const series = RUN_SERIES.map((s, i) => ({
|
||||
...s, show: true,
|
||||
type: ["bar", "bar", "line"][i],
|
||||
@@ -888,7 +999,7 @@ async function loadSpeedItems() {
|
||||
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>';
|
||||
tb.innerHTML = '<tr><td colspan="10" class="empty">暂无速度项,点击「🔄 从提交同步」自动生成。</td></tr>';
|
||||
return;
|
||||
}
|
||||
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">${fmtInt(s.count)}</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 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" 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>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
@@ -910,7 +1022,12 @@ async function loadSpeedItems() {
|
||||
async function loadSettings() {
|
||||
const s = await api("/api/admin/settings");
|
||||
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;
|
||||
const cols = s.columns || {};
|
||||
$$("#set-cols input[data-col]").forEach((cb) => {
|
||||
cb.checked = !!cols[cb.dataset.col];
|
||||
});
|
||||
}
|
||||
|
||||
function editSpeedItem(btn) {
|
||||
@@ -920,11 +1037,12 @@ function editSpeedItem(btn) {
|
||||
const heat = prompt("热度(数字):", btn.dataset.heat || "0");
|
||||
const count = prompt("个数(展示提交数,0=自动):", btn.dataset.count || "0");
|
||||
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 body = {
|
||||
provider: provider || "", model: model || "",
|
||||
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(); });
|
||||
}
|
||||
@@ -974,22 +1092,25 @@ async function saveCapEditor() {
|
||||
|
||||
/* ───────────────────────── 事件绑定 ───────────────────────── */
|
||||
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(); });
|
||||
// +对比按钮 / 详情链接
|
||||
// 顶部居中搜索(跳详情)
|
||||
bindTopSearch();
|
||||
// 排行内搜索(添加速度项到本区域)
|
||||
bindLbSearch();
|
||||
// 排行榜行:+对比 / 详情
|
||||
const tb = $("#lb tbody");
|
||||
if (tb) tb.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-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();
|
||||
}
|
||||
|
||||
@@ -1189,7 +1310,8 @@ function bindAdmin() {
|
||||
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 })
|
||||
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(); })
|
||||
.catch((e) => toast(e.message));
|
||||
});
|
||||
@@ -1220,8 +1342,10 @@ function bindAdmin() {
|
||||
if (save) save.addEventListener("click", async () => {
|
||||
const body = {
|
||||
watermark_text: $("#set-watermark").value.trim(),
|
||||
watermark_style: $("#set-wmstyle").value,
|
||||
top_n: parseInt($("#set-topn").value) || 10,
|
||||
};
|
||||
$$("#set-cols input[data-col]").forEach((cb) => { body[cb.dataset.col] = cb.checked ? "1" : "0"; });
|
||||
try {
|
||||
await api("/api/admin/settings", "PUT", body);
|
||||
const st = $("#set-status");
|
||||
|
||||
Reference in New Issue
Block a user