v1.3.0 热度排行+搜索+详情+水印下载:速度项按钮改+对比(去购物车图标);排行默认按热度显示前10(后台可配)+顶部模型搜索框(点击显示热门5个:配置简介+模型,支持手动搜索,速度项热度/个数/简介后台可改);每行详情链接到具体详情页;图表带水印(默认首页网址,后台可配)+生成图片下载按钮(首页/对比页)
This commit is contained in:
@@ -23,6 +23,69 @@ app.secret_key = config.SESSION_SECRET
|
||||
|
||||
db.init_db()
|
||||
|
||||
# 水印字体(支持中文)
|
||||
_WATERMARK_FONT = "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"
|
||||
|
||||
|
||||
def _watermark_text():
|
||||
return db.get_setting("watermark_text", config.DEFAULT_WATERMARK)
|
||||
|
||||
|
||||
def _top_n():
|
||||
try:
|
||||
return max(1, int(db.get_setting("top_n", str(config.DEFAULT_TOP_N))))
|
||||
except Exception:
|
||||
return config.DEFAULT_TOP_N
|
||||
|
||||
|
||||
def apply_watermark(img_bytes: bytes, text: str) -> bytes:
|
||||
"""给图片叠加简单水印:全图浅淡斜纹 + 右下角标注。颜色自适应当前背景(浅底深字/深底浅字)"""
|
||||
if not text:
|
||||
return img_bytes
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except Exception:
|
||||
return img_bytes
|
||||
try:
|
||||
img = Image.open(io.BytesIO(img_bytes)).convert("RGBA")
|
||||
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
|
||||
|
||||
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)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
font_big = 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,))
|
||||
|
||||
# 右下角较清晰标注
|
||||
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,))
|
||||
|
||||
out = Image.alpha_composite(img, overlay)
|
||||
buf = io.BytesIO()
|
||||
out.convert("RGB").save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
@app.after_request
|
||||
def _cors(resp):
|
||||
@@ -94,6 +157,7 @@ def submit():
|
||||
account_name = (body.get("account") or "").strip() or "默认账号"
|
||||
aid = db.get_or_create_account(account_name, remark="来自 llm-speed-tester")
|
||||
sid = db.add_submission(aid, body)
|
||||
db.touch_speed_item(body.get("provider") or "", model) # 速度项热度/个数自动关联
|
||||
account = db.list_accounts()
|
||||
acc = next((a for a in account if a["id"] == aid), None)
|
||||
return jsonify({
|
||||
@@ -115,9 +179,9 @@ def stats():
|
||||
|
||||
@app.route("/api/leaderboard")
|
||||
def leaderboard():
|
||||
sort = request.args.get("sort", "avg_decode_speed")
|
||||
sort = request.args.get("sort", "heat") # 默认按热度
|
||||
order = request.args.get("order", "desc")
|
||||
limit = min(int(request.args.get("limit", 200) or 200), 500)
|
||||
limit = min(int(request.args.get("limit", 500) or 500), 1000)
|
||||
rows = db.leaderboard(sort=sort, order=order, limit=limit)
|
||||
|
||||
# 可选:指定对比哪些模型(provider|model 或 仅 model,逗号分隔)
|
||||
@@ -135,9 +199,18 @@ def leaderboard():
|
||||
keys.add(("", item))
|
||||
rows = [r for r in rows if (r["provider"], r["model"]) in keys]
|
||||
|
||||
# 可选:搜索过滤(模型/提供商/配置简介)
|
||||
q = (request.args.get("q") or "").strip().lower()
|
||||
if q:
|
||||
rows = [r for r in rows
|
||||
if q in (r["model"] or "").lower()
|
||||
or q in (r["provider"] or "").lower()
|
||||
or q in (r.get("intro") or "").lower()]
|
||||
|
||||
chart_rows = rows[: int(request.args.get("chart_top", 30) or 30)]
|
||||
chart_csv = _speed_csv(chart_rows)
|
||||
return jsonify({"ok": True, "rows": rows, "chart_csv": chart_csv})
|
||||
return jsonify({"ok": True, "rows": rows, "chart_csv": chart_csv, "top_n": _top_n(),
|
||||
"watermark": _watermark_text()})
|
||||
|
||||
|
||||
def _speed_csv(chart_rows):
|
||||
@@ -312,6 +385,72 @@ def account_delete(aid):
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ───────────────────────── 速度项管理(后台) / 设置 ─────────────────────────
|
||||
|
||||
@app.route("/api/settings")
|
||||
def public_settings():
|
||||
return jsonify({"ok": True, "watermark": _watermark_text(), "top_n": _top_n()})
|
||||
|
||||
|
||||
@app.route("/api/admin/speed-items")
|
||||
@admin_required
|
||||
def admin_speed_items():
|
||||
return jsonify(db.list_speed_items())
|
||||
|
||||
|
||||
@app.route("/api/admin/speed-items", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_speed_item_add():
|
||||
body = request.get_json(force=True) or {}
|
||||
try:
|
||||
sid = db.add_speed_item(body.get("provider", ""), body.get("model", ""),
|
||||
body.get("heat", 0), body.get("intro", ""),
|
||||
body.get("count", 0), body.get("remark", ""))
|
||||
return jsonify({"ok": True, "id": sid})
|
||||
except ValueError as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 400
|
||||
|
||||
|
||||
@app.route("/api/admin/speed-items/<int:sid>", methods=["PUT"])
|
||||
@admin_required
|
||||
def admin_speed_item_update(sid):
|
||||
body = request.get_json(force=True) or {}
|
||||
db.update_speed_item(sid, body)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/admin/speed-items/<int:sid>", methods=["DELETE"])
|
||||
@admin_required
|
||||
def admin_speed_item_delete(sid):
|
||||
db.delete_speed_item(sid)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/admin/speed-items/sync", methods=["POST"])
|
||||
@admin_required
|
||||
def admin_speed_item_sync():
|
||||
added = db.sync_speed_items()
|
||||
return jsonify({"ok": True, "added": added})
|
||||
|
||||
|
||||
@app.route("/api/admin/settings", methods=["GET"])
|
||||
@admin_required
|
||||
def admin_settings_get():
|
||||
return jsonify({"ok": True, "settings": db.get_all_settings(),
|
||||
"watermark": _watermark_text(), "top_n": _top_n()})
|
||||
|
||||
|
||||
@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"}
|
||||
for k, v in body.items():
|
||||
if k in allow:
|
||||
db.set_setting(k, v)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
# ───────────────────────── 模型能力测试模块(前台只读,内容后台编辑) ─────────────────────────
|
||||
|
||||
@app.route("/api/capabilities")
|
||||
@@ -361,7 +500,9 @@ 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
|
||||
return send_file(io.BytesIO(resp.content), mimetype="image/png")
|
||||
# 叠加水印(默认首页网址,后台可配置)
|
||||
data = apply_watermark(resp.content, _watermark_text())
|
||||
return send_file(io.BytesIO(data), mimetype="image/png")
|
||||
|
||||
|
||||
@app.route("/api/chart", methods=["POST"])
|
||||
@@ -435,6 +576,7 @@ def seed_demo():
|
||||
"runs": [],
|
||||
}
|
||||
db.add_submission(aid, payload)
|
||||
db.touch_speed_item(provider, model)
|
||||
cnt += 1
|
||||
return jsonify({"ok": True, "seeded": cnt, "stats": db.get_stats()})
|
||||
|
||||
|
||||
@@ -18,5 +18,9 @@ ADMIN_USER = "admin"
|
||||
ADMIN_PASSWORD = "admin123"
|
||||
SESSION_SECRET = "model-eval-site-session-secret"
|
||||
|
||||
# 图片水印(默认=首页网址,后台可改)
|
||||
DEFAULT_WATERMARK = "http://121.40.164.32:16066"
|
||||
DEFAULT_TOP_N = 10 # 排行榜默认显示热度最高的前 N 个
|
||||
|
||||
# data-chart-tool 图表服务地址(画速度对比图)
|
||||
CHART_API_BASE = "http://127.0.0.1:16016"
|
||||
+174
-4
@@ -61,6 +61,23 @@ CREATE TABLE IF NOT EXISTS capabilities(
|
||||
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS speed_items(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
provider TEXT DEFAULT '',
|
||||
model TEXT DEFAULT '',
|
||||
heat INTEGER DEFAULT 0,
|
||||
intro TEXT DEFAULT '',
|
||||
count INTEGER DEFAULT 0,
|
||||
remark TEXT DEFAULT '',
|
||||
updated_at TEXT DEFAULT (datetime('now','localtime'))
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_si_model ON speed_items(provider, model);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings(
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sub_model ON submissions(model, provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_sub_account ON submissions(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sub_created ON submissions(created_at);
|
||||
@@ -313,14 +330,16 @@ def get_stats():
|
||||
conn.close()
|
||||
|
||||
|
||||
def leaderboard(sort="avg_decode_speed", order="desc", limit=200):
|
||||
"""按 (provider, model) 聚合所有提交 → 模型速度排行(含硬件)"""
|
||||
def leaderboard(sort="heat", order="desc", limit=500):
|
||||
"""按 (provider, model) 聚合所有提交 → 模型速度排行(含热度/配置简介/硬件)
|
||||
默认按热度(heat)倒序;sort 支持 heat/avg_decode_speed 等"""
|
||||
sort_whitelist = {
|
||||
"heat": "heat",
|
||||
"avg_decode_speed": "avg_decode_speed", "avg_prefill_speed": "avg_prefill_speed",
|
||||
"avg_ttft_ms": "avg_ttft_ms", "best_decode": "best_decode",
|
||||
"cnt": "cnt", "last_tested": "last_tested", "avg_stream_decode": "avg_stream_decode",
|
||||
}
|
||||
srt = sort_whitelist.get(sort, "avg_decode_speed")
|
||||
srt = sort_whitelist.get(sort, "heat")
|
||||
od = "DESC" if order == "asc" and srt not in ("avg_ttft_ms", "avg_total_ms") else (
|
||||
"ASC" if order == "asc" else "DESC")
|
||||
with _lock:
|
||||
@@ -338,10 +357,15 @@ def leaderboard(sort="avg_decode_speed", order="desc", limit=200):
|
||||
"AVG(s.avg_total_ms) AS avg_total_ms, "
|
||||
"MAX(s.avg_decode_speed) AS best_decode, "
|
||||
"MAX(s.created_at) AS last_tested, "
|
||||
"COALESCE(MAX(si.heat), 0) AS heat, "
|
||||
"MAX(si.intro) AS intro, "
|
||||
"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<>'' "
|
||||
" ORDER BY s2.id DESC LIMIT 1) AS hardware "
|
||||
"FROM submissions s WHERE s.model<>'' "
|
||||
"FROM submissions s "
|
||||
"LEFT JOIN speed_items si ON si.provider=s.provider AND si.model=s.model "
|
||||
"WHERE s.model<>'' "
|
||||
"GROUP BY s.provider, s.model ORDER BY %s %s LIMIT ?" % (srt, od),
|
||||
(limit,)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -502,3 +526,149 @@ def delete_capability(cid: int):
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ───────────────────────── 速度项(热度/配置简介/个数,后台可改) ─────────────────────────
|
||||
|
||||
def touch_speed_item(provider, model):
|
||||
"""提交到达时确保速度项存在;不存在则以当前提交数为默认热度"""
|
||||
provider = provider or ""
|
||||
model = (model or "").strip()
|
||||
if not model:
|
||||
return None
|
||||
with _lock:
|
||||
conn = _connect()
|
||||
try:
|
||||
r = conn.execute("SELECT id FROM speed_items WHERE provider=? AND model=?",
|
||||
(provider, model)).fetchone()
|
||||
if r:
|
||||
return r["id"]
|
||||
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))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_speed_items():
|
||||
with _lock:
|
||||
conn = _connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT si.*, "
|
||||
"(SELECT COUNT(*) FROM submissions s WHERE s.provider=si.provider AND s.model=si.model) AS real_cnt "
|
||||
"FROM speed_items si ORDER BY si.heat DESC, si.model").fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def add_speed_item(provider, model, heat=0, intro="", count=0, remark=""):
|
||||
provider = provider or ""
|
||||
model = (model or "").strip()
|
||||
if not model:
|
||||
raise ValueError("模型名称不能为空")
|
||||
with _lock:
|
||||
conn = _connect()
|
||||
try:
|
||||
r = conn.execute("SELECT id FROM speed_items WHERE provider=? AND model=?",
|
||||
(provider, model)).fetchone()
|
||||
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 ""))
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def update_speed_item(sid: int, data: dict):
|
||||
with _lock:
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE speed_items SET provider=?,model=?,heat=?,intro=?,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(),
|
||||
int(data.get("count") or 0),
|
||||
(data.get("remark") or "").strip(),
|
||||
sid))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_speed_item(sid: int):
|
||||
with _lock:
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute("DELETE FROM speed_items WHERE id=?", (sid,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def sync_speed_items():
|
||||
"""从提交自动同步速度项:为所有有提交的模型建速度项(默认热度=提交数)"""
|
||||
added = 0
|
||||
with _lock:
|
||||
conn = _connect()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT provider, model, COUNT(*) c FROM submissions WHERE model<>'' "
|
||||
"GROUP BY provider, model").fetchall()
|
||||
for r in rows:
|
||||
r0 = conn.execute("SELECT id FROM speed_items WHERE provider=? AND model=?",
|
||||
(r["provider"], r["model"])).fetchone()
|
||||
if r0:
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO speed_items(provider,model,heat,intro,count) VALUES(?,?,?,?,?)",
|
||||
(r["provider"], r["model"], r["c"], "", r["c"]))
|
||||
added += 1
|
||||
conn.commit()
|
||||
return added
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ───────────────────────── 设置(水印/默认条数等) ─────────────────────────
|
||||
|
||||
def get_setting(key, default=""):
|
||||
with _lock:
|
||||
conn = _connect()
|
||||
try:
|
||||
r = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
||||
return r["value"] if r else default
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_setting(key, value):
|
||||
with _lock:
|
||||
conn = _connect()
|
||||
try:
|
||||
conn.execute("INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)",
|
||||
(key, str(value or "")))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_all_settings():
|
||||
with _lock:
|
||||
conn = _connect()
|
||||
try:
|
||||
rows = conn.execute("SELECT key,value FROM settings").fetchall()
|
||||
return {r["key"]: r["value"] for r in rows}
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -36,6 +36,8 @@
|
||||
<button data-tab="subs" class="active">📥 提交管理</button>
|
||||
<button data-tab="accs">👥 账号管理</button>
|
||||
<button data-tab="caps">🧪 能力测试管理</button>
|
||||
<button data-tab="speed">🚀 速度项管理</button>
|
||||
<button data-tab="settings">⚙️ 设置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,6 +92,37 @@
|
||||
</tr></thead><tbody><tr><td colspan="9" class="empty">加载中…</td></tr></tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 速度项管理 -->
|
||||
<div class="panel" id="tab-speed" hidden>
|
||||
<div class="form-row">
|
||||
<button class="btn primary" id="btn-si-add">➕ 新增速度项</button>
|
||||
<button class="btn" id="btn-si-sync">🔄 从提交同步</button>
|
||||
<span class="hint">热度/个数/配置简介 由这里维护;排行榜默认按热度显示前N个,搜索框热门5个取自这里</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置 -->
|
||||
<div class="panel" id="tab-settings" hidden>
|
||||
<h2>⚙️ 站点设置</h2>
|
||||
<div class="form-grid" style="max-width:640px">
|
||||
<div><label>图片水印文字(生成图表时叠加,默认首页网址)</label>
|
||||
<input type="text" id="set-watermark" placeholder="http://121.40.164.32:16066" style="width:100%"></div>
|
||||
<div><label>排行榜默认显示热度前 N 个</label>
|
||||
<input type="number" id="set-topn" value="10" min="1" max="50" style="width:100%"></div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
<option value="3">隔3个</option>
|
||||
</select>
|
||||
<button class="btn small primary" id="cmp-chart-gen">🎨 生成/刷新图表</button>
|
||||
<button class="btn small" id="cmp-chart-dl">⬇ 下载 PNG</button>
|
||||
<span class="hint" id="cmp-chart-status"></span>
|
||||
</div>
|
||||
<div class="chart-img-wrap" id="cmp-chart-img"><div class="hint">配置好后点击生成;底部横轴字太多可旋转/加间隔避免挤在一起</div></div>
|
||||
|
||||
@@ -253,3 +253,16 @@ textarea.items-editor { min-height: 160px; font-family: ui-monospace, monospace;
|
||||
white-space: nowrap; max-width: 260px;
|
||||
}
|
||||
#cart-bar .cart-items b { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* 搜索下拉 */
|
||||
.search-drop {
|
||||
position: absolute; top: 100%; left: 0; margin-top: 4px; width: 100%; max-width: 480px;
|
||||
background: var(--panel2); border: 1px solid var(--accent); border-radius: 10px;
|
||||
z-index: 30; box-shadow: 0 10px 30px rgba(0,0,0,.45); overflow: hidden;
|
||||
}
|
||||
.search-drop[hidden] { display: none; }
|
||||
.search-item { padding: 9px 14px; cursor: pointer; border-bottom: 1px solid var(--border); }
|
||||
.search-item:last-child { border-bottom: none; }
|
||||
.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; }
|
||||
+11
-5
@@ -27,13 +27,18 @@
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>🏆 模型运行速度排行 <span class="hint">(点击表头可排序;按模型+提供商聚合所有提交)</span></h2>
|
||||
<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">
|
||||
<div class="search-drop" id="lb-search-drop" hidden></div>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table id="lb">
|
||||
<thead><tr>
|
||||
<th class="num">#</th>
|
||||
<th>提供商</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>
|
||||
@@ -42,21 +47,22 @@
|
||||
<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>
|
||||
<th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody><tr><td colspan="12" class="empty">加载中…</td></tr></tbody>
|
||||
<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>
|
||||
<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 primary" id="lb-chart-gen">🎨 生成/刷新图表</button>
|
||||
<button class="btn small" id="lb-chart-dl">⬇ 下载 PNG</button>
|
||||
<span class="hint" id="lb-chart-status"></span>
|
||||
</div>
|
||||
<div class="chart-img-wrap" id="lb-chart-img">
|
||||
|
||||
+233
-26
@@ -45,8 +45,8 @@ function getCart() {
|
||||
function saveCart(c) { localStorage.setItem(CART_KEY, JSON.stringify(c)); }
|
||||
function cartAdd(key) {
|
||||
const c = getCart();
|
||||
if (!c.includes(key)) { c.push(key); saveCart(c); toast("🛒 已加入对比车"); }
|
||||
else { toast("已在对比车中"); }
|
||||
if (!c.includes(key)) { c.push(key); saveCart(c); toast("+ 已加入对比"); }
|
||||
else { toast("已在对比中"); }
|
||||
renderCartBar();
|
||||
}
|
||||
function cartRemove(key) { saveCart(getCart().filter((k) => k !== key)); renderCartBar(); }
|
||||
@@ -112,14 +112,24 @@ function navActive() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ───────────────────────── 首页:排行 ───────────────────────── */
|
||||
let sortKey = "avg_decode_speed";
|
||||
/* ───────────────────────── 首页:排行(热度前10 + 搜索) ───────────────────────── */
|
||||
let sortKey = "heat";
|
||||
let sortOrder = "desc";
|
||||
let lbModels = []; // [{provider, model, key}]
|
||||
let lbSelected = new Set(); // selected keys
|
||||
let lbModels = []; // 全部模型(含 heat/intro/count_override),已按热度排序
|
||||
let lbAll = [];
|
||||
let lbSelected = new Set(); // selected keys(对比图用)
|
||||
let lbTopN = 10;
|
||||
let lbQuery = "";
|
||||
|
||||
const modelKey = (p, m) => `${p}|${m}`;
|
||||
|
||||
const LB_SORT_GETTER = {
|
||||
heat: (r) => r.heat || 0, cnt: (r) => r.cnt, accounts: (r) => r.accounts,
|
||||
avg_decode_speed: (r) => r.avg_decode_speed || 0, avg_prefill_speed: (r) => r.avg_prefill_speed || 0,
|
||||
avg_ttft_ms: (r) => r.avg_ttft_ms || 0, best_decode: (r) => r.best_decode || 0,
|
||||
last_tested: (r) => r.last_tested || "",
|
||||
};
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const s = await api("/api/stats");
|
||||
@@ -134,7 +144,16 @@ async function loadStats() {
|
||||
function sortClick(key) {
|
||||
if (sortKey === key) sortOrder = sortOrder === "desc" ? "asc" : "desc";
|
||||
else { sortKey = key; sortOrder = "desc"; }
|
||||
loadLeaderboard();
|
||||
applyView();
|
||||
}
|
||||
|
||||
function cmpLb(a, b) {
|
||||
const g = LB_SORT_GETTER[sortKey] || LB_SORT_GETTER.heat;
|
||||
const va = g(a), vb = g(b);
|
||||
let c = 0;
|
||||
if (typeof va === "number" && typeof vb === "number") c = va - vb;
|
||||
else c = String(va).localeCompare(String(vb));
|
||||
return sortOrder === "desc" ? -c : c;
|
||||
}
|
||||
|
||||
function renderLbChips() {
|
||||
@@ -159,35 +178,53 @@ function renderLbChips() {
|
||||
async function loadLeaderboard() {
|
||||
const tb = $("#lb tbody");
|
||||
if (!tb) return;
|
||||
tb.innerHTML = '<tr><td colspan="12" class="empty">加载中…</td></tr>';
|
||||
tb.innerHTML = '<tr><td colspan="13" class="empty">加载中…</td></tr>';
|
||||
let d;
|
||||
try {
|
||||
d = await api(`/api/leaderboard?sort=${sortKey}&order=${sortOrder}`);
|
||||
d = await api(`/api/leaderboard?sort=heat&order=desc&limit=500`);
|
||||
} catch (e) {
|
||||
tb.innerHTML = `<tr><td colspan="12" class="empty">加载失败:${esc(e.message)}</td></tr>`;
|
||||
tb.innerHTML = `<tr><td colspan="13" class="empty">加载失败:${esc(e.message)}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
if (d.top_n) lbTopN = d.top_n;
|
||||
lbAll = d.rows;
|
||||
lbModels = lbAll.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model), intro: r.intro || "", heat: r.heat || 0 }));
|
||||
if (!lbSelected.size) lbModels.forEach((m) => lbSelected.add(m.key));
|
||||
else lbModels.forEach((m) => { if (!lbSelected.has(m.key)) lbSelected.add(m.key); });
|
||||
renderLbChips();
|
||||
applyView();
|
||||
}
|
||||
|
||||
function applyView() {
|
||||
const tb = $("#lb tbody");
|
||||
if (!tb) return;
|
||||
let rows = lbAll;
|
||||
const q = lbQuery.trim().toLowerCase();
|
||||
if (q) {
|
||||
rows = lbAll.filter((r) =>
|
||||
(r.model || "").toLowerCase().includes(q) ||
|
||||
(r.provider || "").toLowerCase().includes(q) ||
|
||||
((r.intro || "") || "").toLowerCase().includes(q));
|
||||
}
|
||||
rows = [...rows].sort(cmpLb);
|
||||
if (!q) rows = rows.slice(0, lbTopN); // 默认显示热度前 N 个
|
||||
$$("#lb th[data-sort]").forEach((th) => {
|
||||
th.querySelector(".arrow").textContent = (th.dataset.sort === sortKey) ? (sortOrder === "desc" ? " ▼" : " ▲") : "";
|
||||
});
|
||||
if (!d.rows.length) {
|
||||
tb.innerHTML = '<tr><td colspan="12" class="empty">暂无评测数据。可在后台管理「提交管理」里点击「🎲 生成演示数据」或从 llm-speed-tester 一键发送。</td></tr>';
|
||||
if (!rows.length) {
|
||||
tb.innerHTML = '<tr><td colspan="13" class="empty">' + (q ? `未找到匹配「${esc(lbQuery)}」的模型` : "暂无评测数据。可在后台管理「提交管理」里点击「🎲 生成演示数据」或从 llm-speed-tester 一键发送。") + '</td></tr>';
|
||||
return;
|
||||
}
|
||||
// 记录模型列表,用于对比图选择
|
||||
lbModels = d.rows.map((r) => ({ provider: r.provider, model: r.model, key: modelKey(r.provider, r.model) }));
|
||||
if (!lbSelected.size) lbModels.forEach((m) => lbSelected.add(m.key));
|
||||
else lbModels.forEach((m) => { if (!lbSelected.has(m.key)) lbSelected.add(m.key); }); // 新模型默认选中
|
||||
renderLbChips();
|
||||
|
||||
const maxDecode = Math.max(...d.rows.map((r) => r.avg_decode_speed || 0));
|
||||
tb.innerHTML = d.rows.map((r, i) => {
|
||||
const maxDecode = Math.max(...rows.map((r) => r.avg_decode_speed || 0));
|
||||
tb.innerHTML = rows.map((r, i) => {
|
||||
const meter = r.avg_decode_speed ? `<div class="meter"><div style="width:${(r.avg_decode_speed / maxDecode * 100).toFixed(1)}%"></div></div>` : "";
|
||||
const intro = r.intro ? `<div class="hint">${esc(r.intro)}</div>` : "";
|
||||
return `<tr>
|
||||
<td class="num">${i + 1}</td>
|
||||
<td><span class="pill">${esc(providerLabel(r.provider))}</span></td>
|
||||
<td><a href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}" title="查看详情">${esc(r.model)}</a></td>
|
||||
<td class="num">${fmtInt(r.cnt)}</td>
|
||||
<td><a href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}" title="查看详情">${esc(r.model)}</a>${intro}</td>
|
||||
<td class="num"><span class="pill amber">🔥 ${fmtInt(r.heat || 0)}</span></td>
|
||||
<td class="num">${fmtInt(r.count_override > 0 ? r.count_override : r.cnt)}</td>
|
||||
<td class="num">${fmtInt(r.accounts)}</td>
|
||||
<td class="num"><span class="badge-decode">${fmt(r.avg_decode_speed)}</span>${meter}</td>
|
||||
<td class="num">${fmt(r.avg_prefill_speed)}</td>
|
||||
@@ -195,11 +232,59 @@ async function loadLeaderboard() {
|
||||
<td class="num">${fmt(r.best_decode)}</td>
|
||||
<td>${hwTag(r.hardware)}</td>
|
||||
<td class="num">${esc((r.last_tested || "").slice(5, 16))}</td>
|
||||
<td><button class="btn small" data-cart="${modelKey(r.provider, r.model)}" title="加入对比车">🛒 加入对比车</button></td>
|
||||
<td>
|
||||
<a class="btn small" href="/model.html?provider=${encodeURIComponent(r.provider)}&model=${encodeURIComponent(r.model)}" title="查看详情">详情</a>
|
||||
<button class="btn small" data-cart="${modelKey(r.provider, r.model)}" title="加入对比">+对比</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
/* 搜索框:点击显示热门5个,输入过滤 */
|
||||
function renderSearchDrop(items) {
|
||||
const drop = $("#lb-search-drop");
|
||||
if (!drop) return;
|
||||
if (!items || !items.length) { drop.hidden = true; return; }
|
||||
drop.hidden = false;
|
||||
drop.innerHTML = items.map((r) => {
|
||||
const key = modelKey(r.provider, r.model);
|
||||
const intro = r.intro ? `<span class="hint">${esc(r.intro)}</span>` : `<span class="hint">${esc(providerLabel(r.provider))}</span>`;
|
||||
return `<div class="search-item" data-key="${key}">
|
||||
<div class="si-main">${esc(r.model)} <span class="pill amber" style="margin-left:6px">🔥${fmtInt(r.heat || 0)}</span></div>
|
||||
<div class="si-sub">${intro} · 提交 ${fmtInt(r.cnt)} · 解码 ${fmt(r.avg_decode_speed)} tok/s</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function bindSearch() {
|
||||
const input = $("#lb-search");
|
||||
const drop = $("#lb-search-drop");
|
||||
if (!input || !drop) return;
|
||||
input.addEventListener("focus", () => {
|
||||
// 点击输入框 → 显示最常见的5个速度项(按热度)
|
||||
renderSearchDrop(lbAll.slice(0, 5));
|
||||
});
|
||||
input.addEventListener("input", () => {
|
||||
lbQuery = input.value;
|
||||
applyView();
|
||||
const q = lbQuery.trim().toLowerCase();
|
||||
if (q) renderSearchDrop(lbAll.filter((r) =>
|
||||
(r.model || "").toLowerCase().includes(q) ||
|
||||
(r.provider || "").toLowerCase().includes(q) ||
|
||||
((r.intro || "") || "").toLowerCase().includes(q)).slice(0, 5));
|
||||
else renderSearchDrop(lbAll.slice(0, 5));
|
||||
});
|
||||
input.addEventListener("blur", () => { setTimeout(() => { drop.hidden = true; }, 150); });
|
||||
drop.addEventListener("mousedown", (e) => {
|
||||
const item = e.target.closest(".search-item");
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
const key = item.dataset.key;
|
||||
const m = lbModels.find((x) => x.key === key);
|
||||
if (m) location.href = `/model.html?provider=${encodeURIComponent(m.provider)}&model=${encodeURIComponent(m.model)}`;
|
||||
});
|
||||
}
|
||||
|
||||
let lbChartUrl = "";
|
||||
async function genLbChart() {
|
||||
const status = $("#lb-chart-status");
|
||||
@@ -209,7 +294,7 @@ async function genLbChart() {
|
||||
try {
|
||||
// 重新请求指定模型的排行数据(对比图只包含所选模型)
|
||||
const models = [...lbSelected].join(",");
|
||||
const d = await api(`/api/leaderboard?sort=avg_decode_speed&order=desc&models=${encodeURIComponent(models)}`);
|
||||
const d = await api(`/api/leaderboard?sort=heat&order=desc&models=${encodeURIComponent(models)}`);
|
||||
if (!d.rows.length) { status.textContent = "所选模型暂无数据"; return; }
|
||||
const n = d.rows.length;
|
||||
// 运行速度对比:预填充(左轴·空心柱) + 解码(右轴·实心柱) + 首字(虚线),三色
|
||||
@@ -243,6 +328,14 @@ async function genLbChart() {
|
||||
} catch (e) { status.textContent = "❌ " + e.message; }
|
||||
}
|
||||
|
||||
function dlChartUrl() {
|
||||
if (!lbChartUrl) { toast("请先生成图表"); return null; }
|
||||
const a = document.createElement("a");
|
||||
a.href = lbChartUrl;
|
||||
a.download = "运行速度对比图.png";
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
}
|
||||
|
||||
/* ───────────────────────── 模型详情页 ───────────────────────── */
|
||||
async function loadModel() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -673,6 +766,8 @@ async function checkAdmin() {
|
||||
loadSubmissions(true);
|
||||
loadAccounts();
|
||||
loadCaps();
|
||||
loadSpeedItems();
|
||||
loadSettings();
|
||||
return true;
|
||||
}
|
||||
} catch (e) { /* not logged in */ }
|
||||
@@ -686,6 +781,8 @@ function switchTab(tab) {
|
||||
$("#tab-subs").hidden = tab !== "subs";
|
||||
$("#tab-accs").hidden = tab !== "accs";
|
||||
$("#tab-caps").hidden = tab !== "caps";
|
||||
$("#tab-speed").hidden = tab !== "speed";
|
||||
$("#tab-settings").hidden = tab !== "settings";
|
||||
}
|
||||
|
||||
async function loadSubmissions(resetPage = false) {
|
||||
@@ -784,6 +881,54 @@ async function loadCaps() {
|
||||
</tr>`).join("");
|
||||
}
|
||||
|
||||
async function loadSpeedItems() {
|
||||
const tb = $("#si tbody");
|
||||
if (!tb) return;
|
||||
let list;
|
||||
try { list = await api("/api/admin/speed-items"); }
|
||||
catch (e) { tb.innerHTML = `<tr><td colspan="9" class="empty">加载失败:${esc(e.message)}</td></tr>`; return; }
|
||||
if (!list.length) {
|
||||
tb.innerHTML = '<tr><td colspan="9" class="empty">暂无速度项,点击「🔄 从提交同步」自动生成。</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = list.map((s) => `<tr>
|
||||
<td>${s.id}</td>
|
||||
<td><span class="pill">${esc(providerLabel(s.provider))}</span></td>
|
||||
<td>${esc(s.model)}</td>
|
||||
<td class="num"><span class="pill amber">🔥 ${fmtInt(s.heat)}</span></td>
|
||||
<td class="num">${fmtInt(s.count)}</td>
|
||||
<td title="${esc(s.intro || "")}">${esc((s.intro || "—").slice(0, 22))}</td>
|
||||
<td>${esc(s.remark || "—")}</td>
|
||||
<td class="num">${fmtInt(s.real_cnt)}</td>
|
||||
<td>
|
||||
<button class="btn small" data-edit-si="${s.id}" data-provider="${esc(s.provider)}" data-model="${esc(s.model)}" data-heat="${s.heat}" data-count="${s.count}" data-intro="${esc(s.intro || "")}" data-remark="${esc(s.remark || "")}">编辑</button>
|
||||
<button class="btn small danger" data-del-si="${s.id}">删除</button>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const s = await api("/api/admin/settings");
|
||||
if ($("#set-watermark")) $("#set-watermark").value = s.watermark || "";
|
||||
if ($("#set-topn")) $("#set-topn").value = s.top_n || 10;
|
||||
}
|
||||
|
||||
function editSpeedItem(btn) {
|
||||
const provider = prompt("提供商:", btn.dataset.provider || "") ?? btn.dataset.provider;
|
||||
const model = prompt("模型名称:", btn.dataset.model || "");
|
||||
if (model === null) return;
|
||||
const heat = prompt("热度(数字):", btn.dataset.heat || "0");
|
||||
const count = prompt("个数(展示提交数,0=自动):", btn.dataset.count || "0");
|
||||
const intro = prompt("配置简介:", btn.dataset.intro || "");
|
||||
const remark = prompt("备注:", btn.dataset.remark || "");
|
||||
const body = {
|
||||
provider: provider || "", model: model || "",
|
||||
heat: parseInt(heat) || 0, count: parseInt(count) || 0,
|
||||
intro: intro || "", remark: remark || "",
|
||||
};
|
||||
api(`/api/admin/speed-items/${btn.dataset.editSi}`, "PUT", body).then(() => { toast("已保存"); loadSpeedItems(); });
|
||||
}
|
||||
|
||||
function openCapEditor(c) {
|
||||
capEditId = c ? c.id : null;
|
||||
$("#cap-edit-title").textContent = c ? `编辑能力测试 #${c.id}` : "新增能力测试";
|
||||
@@ -832,16 +977,19 @@ function bindIndex() {
|
||||
$$("#lb th[data-sort]").forEach((th) => th.addEventListener("click", () => sortClick(th.dataset.sort)));
|
||||
const gen = $("#lb-chart-gen");
|
||||
if (gen) gen.addEventListener("click", genLbChart);
|
||||
const dl = $("#lb-chart-dl");
|
||||
if (dl) dl.addEventListener("click", () => { if (!lbChartUrl) { toast("请先生成图表"); return; } dlChartUrl(); });
|
||||
const all = $("#lb-chart-all");
|
||||
if (all) all.addEventListener("click", () => { lbModels.forEach((m) => lbSelected.add(m.key)); renderLbChips(); });
|
||||
const none = $("#lb-chart-none");
|
||||
if (none) none.addEventListener("click", () => { lbSelected.clear(); renderLbChips(); });
|
||||
// 对比车按钮
|
||||
// +对比按钮 / 详情链接
|
||||
const tb = $("#lb tbody");
|
||||
if (tb) tb.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-cart]");
|
||||
if (btn) cartAdd(btn.dataset.cart);
|
||||
if (btn) { e.preventDefault(); cartAdd(btn.dataset.cart); }
|
||||
});
|
||||
bindSearch();
|
||||
bindCartBar();
|
||||
}
|
||||
|
||||
@@ -889,6 +1037,14 @@ function bindCompare() {
|
||||
if (run) run.addEventListener("click", runCompare);
|
||||
const gen = $("#cmp-chart-gen");
|
||||
if (gen) gen.addEventListener("click", genCmpChart);
|
||||
const dl = $("#cmp-chart-dl");
|
||||
if (dl) dl.addEventListener("click", () => {
|
||||
if (!cmpChartUrl) { toast("请先生成图表"); return; }
|
||||
const a = document.createElement("a");
|
||||
a.href = cmpChartUrl;
|
||||
a.download = "模型运行速度对比图.png";
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
});
|
||||
renderCmpColToggles();
|
||||
renderSeriesCfg();
|
||||
bindCartBar();
|
||||
@@ -1023,6 +1179,57 @@ function bindAdmin() {
|
||||
subClose.addEventListener("click", () => { $("#sub-mask").hidden = true; });
|
||||
$("#sub-mask").addEventListener("click", (e) => { if (e.target === $("#sub-mask")) $("#sub-mask").hidden = true; });
|
||||
}
|
||||
|
||||
// 速度项管理
|
||||
const siAdd = $("#btn-si-add");
|
||||
if (siAdd) siAdd.addEventListener("click", () => {
|
||||
const provider = prompt("提供商:", "") || "";
|
||||
const model = prompt("模型名称:", "");
|
||||
if (!model) { toast("请填写模型名称"); return; }
|
||||
const heat = prompt("热度:", "0") || 0;
|
||||
const count = prompt("个数(展示提交数,0=自动):", "0") || 0;
|
||||
const intro = prompt("配置简介:", "") || "";
|
||||
api("/api/admin/speed-items", "POST", { provider, model, heat: parseInt(heat) || 0, count: parseInt(count) || 0, intro })
|
||||
.then((r) => { if (!r.ok) throw new Error(r.error || "添加失败"); toast("已添加"); loadSpeedItems(); })
|
||||
.catch((e) => toast(e.message));
|
||||
});
|
||||
const siSync = $("#btn-si-sync");
|
||||
if (siSync) siSync.addEventListener("click", async () => {
|
||||
siSync.disabled = true; siSync.textContent = "同步中…";
|
||||
try {
|
||||
const r = await api("/api/admin/speed-items/sync", "POST", {});
|
||||
toast(`已同步 ${r.added} 个速度项`);
|
||||
loadSpeedItems();
|
||||
} catch (e) { toast("同步失败:" + e.message); }
|
||||
siSync.disabled = false; siSync.textContent = "🔄 从提交同步";
|
||||
});
|
||||
const siTb = $("#si tbody");
|
||||
if (siTb) siTb.addEventListener("click", (e) => {
|
||||
const ed = e.target.closest("[data-edit-si]");
|
||||
const dl = e.target.closest("[data-del-si]");
|
||||
if (ed) editSpeedItem(ed);
|
||||
if (dl) {
|
||||
if (confirm(`确定删除速度项 #${dl.dataset.delSi}?`)) {
|
||||
api(`/api/admin/speed-items/${dl.dataset.delSi}`, "DELETE").then(() => { toast("已删除"); loadSpeedItems(); });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 设置
|
||||
const save = $("#btn-settings-save");
|
||||
if (save) save.addEventListener("click", async () => {
|
||||
const body = {
|
||||
watermark_text: $("#set-watermark").value.trim(),
|
||||
top_n: parseInt($("#set-topn").value) || 10,
|
||||
};
|
||||
try {
|
||||
await api("/api/admin/settings", "PUT", body);
|
||||
const st = $("#set-status");
|
||||
st.textContent = "✅ 已保存,刷新页面生效";
|
||||
setTimeout(() => { st.textContent = ""; }, 3000);
|
||||
toast("设置已保存");
|
||||
} catch (e) { toast("保存失败:" + e.message); }
|
||||
});
|
||||
}
|
||||
|
||||
/* ───────────────────────── 初始化 ───────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user