// dashboard.js — 指标看板:运行选择器 + 折线图(指标趋势) + 柱状图(vs 达标阈值)。 // 纯前端,数据来自 GET /api/runs,复用 MetricPresenter 的方向语义与阈值。 (function attachDashboard(globalObj) { const Dashboard = { _runs: [], // 全量 runs(已按 finished_at 倒序来自 API) _selected: new Set(), // 当前勾选的 run_id 集合 _focusId: null, // 柱状图聚焦的 run_id _trendChart: null, _barChart: null, // ── 入口 ───────────────────────────────────────────────────────────────── async load() { const wrap = document.getElementById("dashboard-wrap"); if (!wrap) return; wrap.innerHTML = '

加载中…

'; try { const data = await API.runs(); Dashboard._runs = (data.runs || []).slice().sort( (a, b) => (a.finished_at || "").localeCompare(b.finished_at || "") ); Dashboard._selected = new Set(Dashboard._runs.map((r) => r.run_id)); Dashboard._focusId = Dashboard._runs.length ? Dashboard._runs[Dashboard._runs.length - 1].run_id : null; Dashboard._render(wrap); } catch (err) { wrap.innerHTML = `

加载失败:${App.escape(err.message)}

`; } }, // ── 渲染 ───────────────────────────────────────────────────────────────── _render(wrap) { if (!Dashboard._runs.length) { wrap.innerHTML = `

暂无评测运行数据。

触发一次评测或通过 Dify 工具调用后,数据将在此显示。

`; return; } wrap.innerHTML = ""; // 运行选择器面板 wrap.appendChild(Dashboard._buildSelector()); // 图表区 const chartRow = document.createElement("div"); chartRow.className = "dashboard-charts"; chartRow.innerHTML = `
按时间顺序展示所选运行的各指标均值变化

实际均值 vs 达标阈值(深绿柱)

达标阈值:higher-better 指标 0.85 · noise_sensitivity 0.15

`; wrap.appendChild(chartRow); Dashboard._populateFocusSelect(); Dashboard._drawTrend(); Dashboard._drawBar(); }, // 运行选择器 _buildSelector() { const panel = document.createElement("div"); panel.className = "panel"; panel.innerHTML = `
勾选≥2个运行可看趋势
`; setTimeout(() => { Dashboard._renderRunList(); document.getElementById("db-sel-all").onclick = () => { Dashboard._runs.forEach((r) => Dashboard._selected.add(r.run_id)); Dashboard._renderRunList(); Dashboard._drawTrend(); }; document.getElementById("db-sel-none").onclick = () => { Dashboard._selected.clear(); Dashboard._renderRunList(); Dashboard._drawTrend(); }; document.getElementById("db-filter-input").oninput = (e) => { Dashboard._renderRunList(e.target.value.toLowerCase()); }; }); return panel; }, _renderRunList(filter) { const list = document.getElementById("db-run-list"); if (!list) return; list.innerHTML = ""; const visible = filter ? Dashboard._runs.filter((r) => (r.scenario_name || r.run_id).toLowerCase().includes(filter) ) : Dashboard._runs; [...visible].reverse().forEach((run) => { const row = document.createElement("label"); row.className = "db-run-row"; const chips = (run.metrics || []) .slice(0, 4) .map((m) => { const v = run.metric_means ? run.metric_means[m] : null; const cls = App.scoreClass(m, v); const text = v === null || v === undefined ? "n/a" : Number(v).toFixed(2); return `${App.escape(App.shortMetric(m))} ${text}`; }) .join(""); row.innerHTML = ` ${App.escape(run.scenario_name || run.run_id)} ${App.escape(App.shortTime(run.finished_at))} · ${App.escape(run.judge_model || "")} ${chips} `; row.querySelector(".db-run-cb").addEventListener("change", (e) => { if (e.target.checked) Dashboard._selected.add(run.run_id); else Dashboard._selected.delete(run.run_id); Dashboard._drawTrend(); }); list.appendChild(row); }); }, // 填充柱状图聚焦下拉 _populateFocusSelect() { const sel = document.getElementById("db-focus-select"); if (!sel) return; sel.innerHTML = ""; [...Dashboard._runs].reverse().forEach((run) => { const opt = document.createElement("option"); opt.value = run.run_id; opt.textContent = `${run.scenario_name || run.run_id} ${App.shortTime(run.finished_at)}`; if (run.run_id === Dashboard._focusId) opt.selected = true; sel.appendChild(opt); }); sel.onchange = () => { Dashboard._focusId = sel.value; Dashboard._drawBar(); }; }, // ── 折线图 ──────────────────────────────────────────────────────────────── _drawTrend() { const canvas = document.getElementById("db-trend-chart"); const hint = document.getElementById("db-trend-hint"); if (!canvas) return; const selected = Dashboard._runs.filter((r) => Dashboard._selected.has(r.run_id)); if (selected.length === 0) { if (Dashboard._trendChart) { Dashboard._trendChart.destroy(); Dashboard._trendChart = null; } if (hint) hint.textContent = "请在上方勾选至少 1 个运行。"; return; } if (hint) { hint.textContent = selected.length === 1 ? "只有 1 个运行,折线退化为单点——勾选更多运行可看趋势。" : `共 ${selected.length} 个运行 · 横轴按完成时间升序`; } const { labels, datasets } = Dashboard._buildTrendDatasets(selected); // 精选对比度高、色盲友好的颜色组合 const colors = [ "#009999", // petrol brand "#3b82f6", // blue "#f97316", // orange "#8b5cf6", // violet "#ec4899", // pink "#06b6d4", // cyan "#f59e0b", // amber ]; if (Dashboard._trendChart) Dashboard._trendChart.destroy(); Dashboard._trendChart = new Chart(canvas, { type: "line", data: { labels, datasets: [ // 达标参考线(0.85,虚线,不显示在图例前列) { label: "达标参考线 0.85", data: Array(labels.length).fill(0.85), borderColor: "#16a34a", borderDash: [6, 4], borderWidth: 1.5, pointRadius: 0, fill: false, order: 99, }, ...datasets.map((ds, i) => ({ label: ds.label, data: ds.data, borderColor: colors[i % colors.length], backgroundColor: colors[i % colors.length] + "18", borderWidth: 2.5, pointRadius: 5, pointHoverRadius: 7, pointBackgroundColor: colors[i % colors.length], pointBorderColor: "#fff", pointBorderWidth: 2, tension: 0.25, spanGaps: false, })), ], }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: "index", intersect: false }, plugins: { legend: { position: "bottom", labels: { font: { size: 12 }, boxWidth: 14, padding: 16, usePointStyle: true, pointStyleWidth: 12 }, }, tooltip: { backgroundColor: "#1a2942", titleColor: "#e2e8f0", bodyColor: "#cbd5e1", borderColor: "#334155", borderWidth: 1, padding: 10, callbacks: { label: (ctx) => { if (ctx.raw === null) return ` ${ctx.dataset.label}: —`; return ` ${ctx.dataset.label}: ${Number(ctx.raw).toFixed(3)}`; }, }, }, }, scales: { y: { min: 0, max: 1, ticks: { stepSize: 0.1, font: { size: 11 }, color: "#94a3b8" }, grid: { color: "#f1f5f9" }, border: { display: false }, }, x: { ticks: { font: { size: 11 }, color: "#64748b", maxRotation: 30 }, grid: { display: false }, border: { display: false }, }, }, }, }); }, // 柱状图 ────────────────────────────────────────────────────────────────── _drawBar() { const canvas = document.getElementById("db-bar-chart"); if (!canvas) return; const run = Dashboard._runs.find((r) => r.run_id === Dashboard._focusId); if (!run) return; const { labels, actual, thresholds, colors, targetMet } = Dashboard._buildComparisonData(run); if (Dashboard._barChart) Dashboard._barChart.destroy(); Dashboard._barChart = new Chart(canvas, { type: "bar", data: { labels, datasets: [ { label: "实际分数", data: actual, backgroundColor: colors, borderRadius: 5, barPercentage: 0.6, categoryPercentage: 0.75, }, { label: "达标阈值", data: thresholds, // 深绿实心柱(不透明,无边框线) backgroundColor: "#15803d", borderRadius: 5, barPercentage: 0.6, categoryPercentage: 0.75, }, ], }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: "index", intersect: false }, plugins: { legend: { position: "bottom", labels: { font: { size: 12 }, boxWidth: 14, padding: 16, usePointStyle: false }, }, tooltip: { backgroundColor: "#1a2942", titleColor: "#e2e8f0", bodyColor: "#cbd5e1", borderColor: "#334155", borderWidth: 1, padding: 10, callbacks: { afterBody: (ctx) => { if (!ctx.length) return []; const idx = ctx[0].dataIndex; return [targetMet[idx] ? "✓ 达标" : "✗ 未达标"]; }, }, }, }, scales: { y: { min: 0, max: 1, ticks: { stepSize: 0.1, font: { size: 11 }, color: "#94a3b8" }, grid: { color: "#f1f5f9" }, border: { display: false }, }, x: { ticks: { font: { size: 11 }, color: "#64748b" }, grid: { display: false }, border: { display: false }, }, }, }, }); }, // ── 纯数据函数(便于测试)──────────────────────────────────────────────── /** * 从选中的 runs(按时间升序)构建折线图数据集。 * 每条线 = 一个指标;X 轴 = 各运行的简短标签;无值处补 null(断线)。 * @param {Array} runs - 已按 finished_at 升序排序的 run 对象数组 * @returns {{ labels: string[], datasets: Array<{label:string, data:Array}> }} */ _buildTrendDatasets(runs) { // 合并所有 runs 出现过的指标(保持首次出现顺序) const metricSet = []; runs.forEach((r) => { (r.metrics || []).forEach((m) => { if (!metricSet.includes(m)) metricSet.push(m); }); }); const labels = runs.map( (r) => `${r.scenario_name || r.run_id}\n${App.shortTime(r.finished_at)}` ); const datasets = metricSet.map((m) => ({ label: m + (MetricPresenter.isLowerBetter(m) ? " (越低越好)" : ""), data: runs.map((r) => { const v = r.metric_means ? r.metric_means[m] : null; return v !== null && v !== undefined ? Number(v) : null; }), })); return { labels, datasets }; }, /** * 从单个 run 构建柱状图对比数据。 * @param {Object} run * @returns {{ labels, actual, thresholds, colors, targetMet }} */ _buildComparisonData(run) { const metrics = run.metrics || []; const labels = metrics.map((m) => App.shortMetric(m)); const actual = metrics.map((m) => { const v = run.metric_means ? run.metric_means[m] : null; return v !== null && v !== undefined ? Number(v) : null; }); const thresholds = metrics.map((m) => MetricPresenter.passThreshold(m)); const targetMet = metrics.map((m, i) => MetricPresenter.meetsTarget(m, actual[i]) ); const colorMap = { good: "#4ade80", warn: "#fbbf24", bad: "#f87171", na: "#cbd5e1" }; const colors = metrics.map((m, i) => colorMap[App.scoreClass(m, actual[i])] || "#cbd5e1"); return { labels, actual, thresholds, colors, targetMet }; }, }; globalObj.Dashboard = Dashboard; })(typeof window !== "undefined" ? window : this);