feat(webapp): add session persistence via URL hash routing + sessionStorage

- app.js: hash-based router (#runs / #new / #profiles / #report/{runId})
  - navigate() pushes history entries for back/forward support
  - _restoreSession() reads hash on load and popstate
  - sessionStorage fallback for same-tab refreshes
  - run-card highlights selected run (.run-card.selected)
- runner.js: use App.navigate() for report redirect; persist lastRunId to sessionStorage
- index.html: report nav button starts disabled (enabled on run select/restore)
- app.css: .run-card.selected with petrol border + ring

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-16 17:55:07 +08:00
parent 1a2cc534b8
commit ca01e44ad2
4 changed files with 106 additions and 36 deletions

View File

@@ -295,6 +295,12 @@ table.group-table td { border-bottom: 1px solid #f1f5f9; font-variant-numeric: t
.btn-danger { color: var(--bad); border-color: var(--bad); } .btn-danger { color: var(--bad); border-color: var(--bad); }
.btn-danger:hover { background: #fee2e2; } .btn-danger:hover { background: #fee2e2; }
/* 选中态 run 卡片 */
.run-card.selected {
border-color: var(--petrol);
box-shadow: 0 0 0 2px rgba(0,153,153,0.25), var(--shadow);
}
/* ---------- LLM 角色配置面板 ---------- */ /* ---------- LLM 角色配置面板 ---------- */
.llm-assignment-panel { border-left: 3px solid var(--petrol); } .llm-assignment-panel { border-left: 3px solid var(--petrol); }
.llm-role-rows { display: flex; flex-direction: column; gap: 10px; } .llm-role-rows { display: flex; flex-direction: column; gap: 10px; }

View File

@@ -22,7 +22,7 @@
<button class="nav-item" data-view="new"> <button class="nav-item" data-view="new">
<span class="nav-ico"></span><span>新建评估</span> <span class="nav-ico"></span><span>新建评估</span>
</button> </button>
<button class="nav-item" data-view="report" data-requires-run="1"> <button class="nav-item" data-view="report" data-requires-run="1" disabled>
<span class="nav-ico"></span><span>报告详情</span> <span class="nav-ico"></span><span>报告详情</span>
</button> </button>
<button class="nav-item" data-view="profiles"> <button class="nav-item" data-view="profiles">

View File

@@ -1,29 +1,59 @@
// app.js — 视图路由、运行列表渲染、健康检查。整个控制台的入口编排。 // app.js — 视图路由、运行列表渲染、健康检查。整个控制台的入口编排。
// 会话保持URL hash 路由(#runs / #new / #profiles / #report/{runId}
// + sessionStorage 兜底F5 刷新 / 浏览器前进后退均可恢复。
const App = { const App = {
currentRunId: null, currentRunId: null,
activeView: null,
views: ["runs", "new", "report", "profiles"], views: ["runs", "new", "report", "profiles"],
titles: { runs: "运行列表", new: "新建评估", report: "报告详情", profiles: "LLM 配置" }, titles: { runs: "运行列表", new: "新建评估", report: "报告详情", profiles: "LLM 配置" },
// 初始化:绑定导航、加载首屏、启动健康检查。 // 初始化:绑定导航、从 URL/sessionStorage 恢复上次位置、启动健康检查。
init() { init() {
document.querySelectorAll(".nav-item").forEach((btn) => { document.querySelectorAll(".nav-item").forEach((btn) => {
btn.addEventListener("click", () => App.switchView(btn.dataset.view)); btn.addEventListener("click", () => App.navigate(btn.dataset.view));
}); });
document.getElementById("refresh-btn").addEventListener("click", () => App.refreshCurrent()); document.getElementById("refresh-btn").addEventListener("click", () => App.refreshCurrent());
Runner.init(); Runner.init();
Profiles.init(); Profiles.init();
App.switchView("runs");
// 恢复上次会话(优先 URL hash其次 sessionStorage
App._restoreSession();
App.checkHealth(); App.checkHealth();
setInterval(App.checkHealth, 15000); setInterval(App.checkHealth, 15000);
// 浏览器前进 / 后退按钮
window.addEventListener("popstate", () => App._restoreSession());
}, },
// 切换主视图,并同步导航高亮与标题。 // ----------------------------------------------------------------
switchView(view) { // 路由 —— 有历史记录的主动导航(更新 URL hash
if (view === "report" && !App.currentRunId) { // ----------------------------------------------------------------
// 没有选中的运行时,报告页显示占位。 navigate(view, runId) {
if (runId !== undefined) App.currentRunId = runId;
const hash = App._buildHash(view, App.currentRunId);
if (location.hash !== `#${hash}`) {
history.pushState({ view, runId: App.currentRunId }, "", `#${hash}`);
} }
App._doSwitch(view);
},
// 供内部调用(不产生历史记录),例如刷新同一视图
switchView(view) {
App._doSwitch(view);
},
// 刷新当前视图数据
refreshCurrent() {
App._doSwitch(App.activeView || "runs");
},
// ----------------------------------------------------------------
// 内部:实际切换 DOM + 触发数据加载
// ----------------------------------------------------------------
_doSwitch(view) {
App.views.forEach((name) => { App.views.forEach((name) => {
const el = document.getElementById(`view-${name}`); const el = document.getElementById(`view-${name}`);
if (el) el.hidden = name !== view; if (el) el.hidden = name !== view;
@@ -34,18 +64,53 @@ const App = {
document.getElementById("view-title").textContent = App.titles[view] || view; document.getElementById("view-title").textContent = App.titles[view] || view;
App.activeView = view; App.activeView = view;
// 持久化到 sessionStorageURL 共享场景的备份)
sessionStorage.setItem("rag_view", view);
if (App.currentRunId) sessionStorage.setItem("rag_run_id", App.currentRunId);
if (view === "runs") App.loadRuns(); if (view === "runs") App.loadRuns();
if (view === "new") Runner.loadScenarios(); if (view === "new") Runner.loadScenarios();
if (view === "report") Report.render(App.currentRunId); if (view === "report") Report.render(App.currentRunId);
if (view === "profiles") Profiles.load(); if (view === "profiles") Profiles.load();
}, },
// 刷新当前视图的数据。 // ----------------------------------------------------------------
refreshCurrent() { // Hash 工具
App.switchView(App.activeView || "runs"); // ----------------------------------------------------------------
_buildHash(view, runId) {
if (view === "report" && runId) {
return `report/${encodeURIComponent(runId)}`;
}
return view || "runs";
}, },
// 加载并渲染运行列表。 _parseHash() {
const raw = location.hash.replace(/^#\/?/, "");
if (!raw) return { view: null, runId: null };
if (raw.startsWith("report/")) {
const runId = decodeURIComponent(raw.slice("report/".length));
return { view: "report", runId };
}
const view = App.views.includes(raw) ? raw : null;
return { view, runId: null };
},
// 会话恢复hash → sessionStorage → 默认 runs
_restoreSession() {
const { view: hView, runId: hRunId } = App._parseHash();
const view = hView || sessionStorage.getItem("rag_view") || "runs";
const runId = hRunId || sessionStorage.getItem("rag_run_id") || null;
if (runId) {
App.currentRunId = runId;
App.enableReportNav();
}
App._doSwitch(view);
},
// ----------------------------------------------------------------
// 运行列表
// ----------------------------------------------------------------
async loadRuns() { async loadRuns() {
const container = document.getElementById("runs-container"); const container = document.getElementById("runs-container");
const empty = document.getElementById("runs-empty"); const empty = document.getElementById("runs-empty");
@@ -66,14 +131,16 @@ const App = {
} }
}, },
// 构造一张运行卡片。
renderRunCard(run) { renderRunCard(run) {
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "run-card"; card.className = "run-card" + (run.run_id === App.currentRunId ? " selected" : "");
card.addEventListener("click", () => { card.addEventListener("click", () => {
App.currentRunId = run.run_id; // 更新选中高亮
document.querySelectorAll(".run-card").forEach((c) => c.classList.remove("selected"));
card.classList.add("selected");
App.enableReportNav(); App.enableReportNav();
App.switchView("report"); App.navigate("report", run.run_id);
}); });
const chips = (run.metrics || []) const chips = (run.metrics || [])
@@ -81,7 +148,7 @@ const App = {
const val = run.metric_means ? run.metric_means[m] : null; const val = run.metric_means ? run.metric_means[m] : null;
const cls = App.scoreClass(val); const cls = App.scoreClass(val);
const text = val === null || val === undefined ? "n/a" : val.toFixed(2); const text = val === null || val === undefined ? "n/a" : val.toFixed(2);
return `<span class="metric-chip">${App.escape(App.shortMetric(m))} <b class="${cls}">${text}</b></span>`; return `<span class="metric-chip" title="${App.escape(m)}">${App.escape(App.shortMetric(m))} <b class="${cls}">${text}</b></span>`;
}) })
.join(""); .join("");
@@ -98,13 +165,14 @@ const App = {
return card; return card;
}, },
// 启用报告导航项(选中运行后)。 // ----------------------------------------------------------------
// 工具方法
// ----------------------------------------------------------------
enableReportNav() { enableReportNav() {
const btn = document.querySelector('.nav-item[data-view="report"]'); const btn = document.querySelector('.nav-item[data-view="report"]');
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
}, },
// 根据分值返回 good/warn/bad/na 配色类。
scoreClass(value) { scoreClass(value) {
if (value === null || value === undefined) return "na"; if (value === null || value === undefined) return "na";
if (value >= 0.8) return "good"; if (value >= 0.8) return "good";
@@ -112,7 +180,6 @@ const App = {
return "bad"; return "bad";
}, },
// 指标名缩写,节省卡片横向空间。
shortMetric(name) { shortMetric(name) {
const map = { const map = {
faithfulness: "faith.", faithfulness: "faith.",
@@ -126,20 +193,17 @@ const App = {
return map[name] || name; return map[name] || name;
}, },
// 截取时间戳到分钟,便于阅读。
shortTime(iso) { shortTime(iso) {
if (!iso) return "—"; if (!iso) return "—";
return String(iso).replace("T", " ").slice(0, 16); return String(iso).replace("T", " ").slice(0, 16);
}, },
// 简单 HTML 转义,防止注入。
escape(text) { escape(text) {
const div = document.createElement("div"); const div = document.createElement("div");
div.textContent = text == null ? "" : String(text); div.textContent = text == null ? "" : String(text);
return div.innerHTML; return div.innerHTML;
}, },
// 健康检查,更新左下角状态点。
async checkHealth() { async checkHealth() {
const dot = document.getElementById("health-dot"); const dot = document.getElementById("health-dot");
const label = document.getElementById("health-text"); const label = document.getElementById("health-text");

View File

@@ -10,9 +10,8 @@ const Runner = {
document.getElementById("run-btn").addEventListener("click", () => Runner.trigger()); document.getElementById("run-btn").addEventListener("click", () => Runner.trigger());
document.getElementById("view-report-btn").addEventListener("click", () => { document.getElementById("view-report-btn").addEventListener("click", () => {
if (Runner.lastRunId) { if (Runner.lastRunId) {
App.currentRunId = Runner.lastRunId;
App.enableReportNav(); App.enableReportNav();
App.switchView("report"); App.navigate("report", Runner.lastRunId);
} }
}); });
}, },
@@ -164,6 +163,7 @@ const Runner = {
runBtn.disabled = false; runBtn.disabled = false;
if (status.status === "completed" && status.run_id) { if (status.status === "completed" && status.run_id) {
Runner.lastRunId = status.run_id; Runner.lastRunId = status.run_id;
sessionStorage.setItem("rag_run_id", status.run_id);
reportBtn.hidden = false; reportBtn.hidden = false;
} }
} }