diff --git a/API.md b/API.md index 7b52817..53c7374 100644 --- a/API.md +++ b/API.md @@ -163,6 +163,9 @@ ### `DELETE /api/tests` 一键清空全部测试历史(测试记录 + 采样指标 + 日志),不可恢复。响应 `{ "ok": true }`。 +### `GET /api/tests/export` +一键导出全部历史记录为 JSON 文件下载(`Content-Disposition: attachment`,文件名带时间戳)。每条含基本字段 + 配置(**API Key 打码为 `******`**)+ 生成参数 + 汇总指标,不含逐条采样与完整日志。 + ### `POST /api/tests//cancel` 停止正在运行的测试。响应 `{ "ok": true, "msg": "正在停止..." }` @@ -369,6 +372,8 @@ curl "$BASE/api/tests?page=1&page_size=20" curl "$BASE/api/tests?q=Qwen&status=done,error&sort=decode&order=desc&page=1&page_size=10" # 一键清空全部历史(不可恢复) curl -X DELETE $BASE/api/tests +# 一键导出全部历史(JSON 文件下载) +curl -OJ $BASE/api/tests/export curl $BASE/api/tests/9 # 画图数据(CSV) diff --git a/README.md b/README.md index 97b0953..5ff5b13 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ - **访问地址:** `http://:16097/` - **技术栈:** Python 3 + Flask + SQLite(纯 REST,无额外依赖) -- **版本:** v2.6.0 +- **版本:** v2.7.0 --- @@ -40,9 +40,11 @@ - **文件下载 Excel(xlsx)**:历史记录「Excel」按钮或详情弹窗「导出 Excel」,包含 汇总 / 采样明细 / 日志 三个 Sheet - **文件下载 JSON**:详情弹窗「导出 JSON」 - 测试历史留存(含测试名称),可随时刷新、查看、导出、删除 +- **📥 一键导出全部历史**:历史卡片「导出全部」按钮,把全部测试记录(配置 Key 打码 + 生成参数 + 汇总指标)导出为一个 JSON 文件下载(`GET /api/tests/export`) - **📄 历史分页 + 筛选 + 搜索 + 排序**:历史列表支持分页(10/20/50/100 条每页)、多条件筛选(状态多选、提供商)、关键词搜索(编号/名称/模型/提供商/状态/时间)、点击表头升降排序(#/时间/名称/提供商/模型/采样/首字/预填充/解码/状态,数值列空值恒排最后);提供「✕ 重置」一键恢复默认 - **🗑 一键清空历史**:历史卡片右上角「清空历史」二次确认后,删除全部测试记录/采样/日志(不可恢复) -- **⬆⬇ 一键置顶/置底**:右下角固定悬浮按钮,平滑滚动到页面顶部/底部 +- **🗜 左侧配置块可折叠**:大模型接口配置 / 速度测试配置 / 评测站同步 三个卡片点击标题即可折叠/展开(箭头指示),状态本地持久化 +- **⬆⬇ 一键置顶/置底**:右下角固定悬浮按钮(亮蓝渐变高对比配色,易于发现),平滑滚动到页面顶部/底部 - **⚖️ 多测试结果对比**:测试历史表格勾选多个测试(可全选),点「⚖️ 对比所选」弹出对比面板: - **指标对比表**:时间 / 名称 / 模型 / 并发档 / 采样 / 首字 / 预填充 / 解码 / 单流均解码 / 输出 / 总耗时 并排展示(点击测试名可跳详情) - **柱状图**:各测试预填充(空心柱)vs 解码(实心柱)速度对比 @@ -106,6 +108,7 @@ pip install -r requirements.txt | POST | `/api/configs/test` | 测试连接 | | GET/POST | `/api/tests?page=&page_size=&q=&status=&provider=&sort=&order=` | 测试历史(分页/搜索/筛选/排序)/ 启动测试(异步) | | DELETE | `/api/tests` | 一键清空全部测试历史(含采样与日志) | +| GET | `/api/tests/export` | 一键导出全部历史记录(JSON 文件下载,API Key 打码) | | GET | `/api/tests/` | 测试详情(含 runs / logs / summary / 按长度分组) | | GET | `/api/tests//logs?after=` | 增量日志(前端轮询用) | | POST | `/api/tests//cancel` | 停止测试 | @@ -183,4 +186,4 @@ llm-speed-tester/ ## Git - **仓库:** `hz4th_coder/llm-speed-tester` -- **版本:** v2.6.0(历史分页+筛选+搜索+表头排序;一键清空历史;右下角置顶/置底悬浮按钮) +- **版本:** v2.7.0(一键导出全部历史(JSON);左侧三块配置卡片可折叠展开(状态持久化);置顶/置底按钮高亮蓝渐变配色) diff --git a/app.py b/app.py index 2d1e2fd..7a2c856 100644 --- a/app.py +++ b/app.py @@ -3,6 +3,7 @@ import io import json import subprocess +from datetime import datetime import requests from flask import Flask, jsonify, request, send_file, send_from_directory @@ -146,6 +147,24 @@ def start_test(): return jsonify({"ok": True, "id": tid}) +@app.route("/api/tests/export") +def export_tests(): + """一键导出全部历史记录(JSON 文件下载,API Key 打码)""" + data = db.export_tests() + payload = { + "ok": True, + "count": len(data), + "exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "tests": data, + } + resp = app.response_class(json.dumps(payload, ensure_ascii=False), + mimetype="application/json") + resp.headers["Content-Disposition"] = ( + 'attachment; filename="llm_speed_tests_%s.json"' + % datetime.now().strftime("%Y%m%d_%H%M%S")) + return resp + + @app.route("/api/tests", methods=["GET"]) def list_tests(): """历史记录列表:支持关键词搜索 q、状态 status(逗号多选)、提供商 provider、 diff --git a/database.py b/database.py index 0679930..ad6e848 100644 --- a/database.py +++ b/database.py @@ -301,6 +301,30 @@ def delete_test(tid: int): conn.close() +def export_tests(): + """导出全部历史:每条含 基本字段 + 配置(Key打码) + 生成参数 + 摘要指标, + 不含逐条采样与完整日志(避免文件过大)""" + with _lock: + conn = _connect() + try: + rows = conn.execute( + "SELECT id,created_at,status,provider,model,name,config_json,gen_cfg_json,summary_json,error " + "FROM tests ORDER BY id").fetchall() + out = [] + for r in rows: + d = dict(r) + cfg = json.loads(d.pop("config_json") or "{}") + if cfg.get("api_key"): + cfg["api_key"] = "******" + d["config"] = cfg + d["gen"] = json.loads(d.pop("gen_cfg_json") or "{}") + d["summary"] = json.loads(d.pop("summary_json") or "{}") + out.append(d) + return out + finally: + conn.close() + + # ───────────────────────── 采样指标 ───────────────────────── def add_run(tid: int, run_index: int, metrics: dict, error: str = "", context_length: int = 0): diff --git a/static/css/style.css b/static/css/style.css index fbcacd4..32cb8c3 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -50,10 +50,24 @@ body { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px; } +/* ── 可折叠卡片(左侧三块) ── */ .card h2 { font-size: 15px; margin-bottom: 14px; } +.collapse-head { + display: flex; align-items: center; justify-content: space-between; + cursor: pointer; user-select: none; +} +.collapse-head:hover { color: var(--accent); } +.collapse-arrow { + font-size: 12px; color: var(--muted); transition: transform .15s; +} +.card.collapsed .collapse-arrow { transform: rotate(-90deg); } +.card.collapsed .collapse-body { display: none; } + +/* ── 卡片头部 ── */ .card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; } .card-head h2 { margin-bottom: 0; } + /* ── 表单 ── */ .field { margin-bottom: 12px; } .field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 5px; } @@ -279,17 +293,19 @@ pre.json-box { padding: 10px; font: 11.5px/1.6 var(--mono); overflow: auto; max-height: 260px; color: #a8f0d0; } -/* ── 右下角一键置顶/置底 ── */ +/* ── 右下角一键置顶/置底(高对比配色,易于发现) ── */ .scroll-fab { position: fixed; right: 20px; bottom: 20px; display: flex; flex-direction: column; gap: 8px; z-index: 60; } .fab { - width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--border); - background: var(--panel2); color: var(--text); font-size: 16px; cursor: pointer; - box-shadow: 0 4px 14px rgba(0,0,0,.45); transition: .15s; line-height: 1; + width: 44px; height: 44px; border-radius: 50%; border: none; + background: linear-gradient(135deg, #5a92ff, #2f6bff); + color: #fff; font-size: 18px; cursor: pointer; + box-shadow: 0 6px 18px rgba(47,107,255,.55); transition: .15s; line-height: 1; } -.fab:hover { border-color: var(--accent); color: var(--accent); } +.fab:hover { filter: brightness(1.15); transform: translateY(-1px); } +.fab:active { transform: translateY(0); } @media (max-width: 1000px) { .layout { grid-template-columns: 1fr; } diff --git a/static/index.html b/static/index.html index acd8cd3..ba7edd5 100644 --- a/static/index.html +++ b/static/index.html @@ -19,8 +19,9 @@
@@ -179,6 +185,7 @@
+
diff --git a/static/js/app.js b/static/js/app.js index 11ed298..a506e21 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1098,8 +1098,23 @@ function bind() { $("#btn-export-log").addEventListener("click", exportCurrentLog); $("#btn-refresh-history").addEventListener("click", () => { histState.page = 1; loadHistory(); }); $("#btn-compare").addEventListener("click", openCompare); + $("#btn-export-history").addEventListener("click", exportAllHistory); $("#btn-clear-history").addEventListener("click", clearAllHistory); + // 左侧三块:点击标题折叠/展开(状态存 localStorage) + const COLLAPSE_KEY = "llmst.collapsed"; + let collapsedSections = new Set(); + try { collapsedSections = new Set(JSON.parse(localStorage.getItem(COLLAPSE_KEY) || "[]")); } catch (e) {} + document.querySelectorAll(".card.collapsible").forEach((sec) => { + if (collapsedSections.has(sec.id)) sec.classList.add("collapsed"); + const head = sec.querySelector(".collapse-head"); + if (head) head.addEventListener("click", () => { + const on = sec.classList.toggle("collapsed"); + if (on) collapsedSections.add(sec.id); else collapsedSections.delete(sec.id); + try { localStorage.setItem(COLLAPSE_KEY, JSON.stringify([...collapsedSections])); } catch (e) {} + }); + }); + // 历史:筛选 / 搜索 / 每页条数 / 分页 / 重置 let histQTimer = null; $("#hist-q").addEventListener("input", () => { @@ -1223,6 +1238,25 @@ async function clearAllHistory() { } } +async function exportAllHistory() { + try { + const resp = await fetch("/api/tests/export"); + if (!resp.ok) throw new Error("HTTP " + resp.status); + const blob = await resp.blob(); + const cd = resp.headers.get("Content-Disposition") || ""; + const m = cd.match(/filename\"?=?\"?([^";]+)\"?/); + const name = m ? m[1] : "llm_speed_tests.json"; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; a.download = name; + document.body.appendChild(a); a.click(); a.remove(); + URL.revokeObjectURL(url); + toast("✅ 已导出全部历史(共 " + (blob.size / 1024).toFixed(1) + " KB)"); + } catch (e) { + toast("❌ 导出失败:" + e.message); + } +} + /* ───────────────────────── 初始化 ───────────────────────── */ (async function init() {