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:
@@ -1,29 +1,59 @@
|
||||
// app.js — 视图路由、运行列表渲染、健康检查。整个控制台的入口编排。
|
||||
// 会话保持:URL hash 路由(#runs / #new / #profiles / #report/{runId})
|
||||
// + sessionStorage 兜底,F5 刷新 / 浏览器前进后退均可恢复。
|
||||
|
||||
const App = {
|
||||
currentRunId: null,
|
||||
activeView: null,
|
||||
views: ["runs", "new", "report", "profiles"],
|
||||
titles: { runs: "运行列表", new: "新建评估", report: "报告详情", profiles: "LLM 配置" },
|
||||
|
||||
// 初始化:绑定导航、加载首屏、启动健康检查。
|
||||
// 初始化:绑定导航、从 URL/sessionStorage 恢复上次位置、启动健康检查。
|
||||
init() {
|
||||
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());
|
||||
|
||||
Runner.init();
|
||||
Profiles.init();
|
||||
App.switchView("runs");
|
||||
|
||||
// 恢复上次会话(优先 URL hash,其次 sessionStorage)
|
||||
App._restoreSession();
|
||||
|
||||
App.checkHealth();
|
||||
setInterval(App.checkHealth, 15000);
|
||||
|
||||
// 浏览器前进 / 后退按钮
|
||||
window.addEventListener("popstate", () => App._restoreSession());
|
||||
},
|
||||
|
||||
// 切换主视图,并同步导航高亮与标题。
|
||||
switchView(view) {
|
||||
if (view === "report" && !App.currentRunId) {
|
||||
// 没有选中的运行时,报告页显示占位。
|
||||
// ----------------------------------------------------------------
|
||||
// 路由 —— 有历史记录的主动导航(更新 URL hash)
|
||||
// ----------------------------------------------------------------
|
||||
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) => {
|
||||
const el = document.getElementById(`view-${name}`);
|
||||
if (el) el.hidden = name !== view;
|
||||
@@ -34,18 +64,53 @@ const App = {
|
||||
document.getElementById("view-title").textContent = App.titles[view] || view;
|
||||
App.activeView = view;
|
||||
|
||||
if (view === "runs") App.loadRuns();
|
||||
if (view === "new") Runner.loadScenarios();
|
||||
if (view === "report") Report.render(App.currentRunId);
|
||||
// 持久化到 sessionStorage(URL 共享场景的备份)
|
||||
sessionStorage.setItem("rag_view", view);
|
||||
if (App.currentRunId) sessionStorage.setItem("rag_run_id", App.currentRunId);
|
||||
|
||||
if (view === "runs") App.loadRuns();
|
||||
if (view === "new") Runner.loadScenarios();
|
||||
if (view === "report") Report.render(App.currentRunId);
|
||||
if (view === "profiles") Profiles.load();
|
||||
},
|
||||
|
||||
// 刷新当前视图的数据。
|
||||
refreshCurrent() {
|
||||
App.switchView(App.activeView || "runs");
|
||||
// ----------------------------------------------------------------
|
||||
// Hash 工具
|
||||
// ----------------------------------------------------------------
|
||||
_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() {
|
||||
const container = document.getElementById("runs-container");
|
||||
const empty = document.getElementById("runs-empty");
|
||||
@@ -66,14 +131,16 @@ const App = {
|
||||
}
|
||||
},
|
||||
|
||||
// 构造一张运行卡片。
|
||||
renderRunCard(run) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "run-card";
|
||||
card.className = "run-card" + (run.run_id === App.currentRunId ? " selected" : "");
|
||||
|
||||
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.switchView("report");
|
||||
App.navigate("report", run.run_id);
|
||||
});
|
||||
|
||||
const chips = (run.metrics || [])
|
||||
@@ -81,7 +148,7 @@ const App = {
|
||||
const val = run.metric_means ? run.metric_means[m] : null;
|
||||
const cls = App.scoreClass(val);
|
||||
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("");
|
||||
|
||||
@@ -98,13 +165,14 @@ const App = {
|
||||
return card;
|
||||
},
|
||||
|
||||
// 启用报告导航项(选中运行后)。
|
||||
// ----------------------------------------------------------------
|
||||
// 工具方法
|
||||
// ----------------------------------------------------------------
|
||||
enableReportNav() {
|
||||
const btn = document.querySelector('.nav-item[data-view="report"]');
|
||||
if (btn) btn.disabled = false;
|
||||
},
|
||||
|
||||
// 根据分值返回 good/warn/bad/na 配色类。
|
||||
scoreClass(value) {
|
||||
if (value === null || value === undefined) return "na";
|
||||
if (value >= 0.8) return "good";
|
||||
@@ -112,43 +180,39 @@ const App = {
|
||||
return "bad";
|
||||
},
|
||||
|
||||
// 指标名缩写,节省卡片横向空间。
|
||||
shortMetric(name) {
|
||||
const map = {
|
||||
faithfulness: "faith.",
|
||||
answer_relevancy: "ans.rel.",
|
||||
context_recall: "ctx.recall",
|
||||
context_precision: "ctx.prec.",
|
||||
noise_sensitivity: "noise.sens.",
|
||||
faithfulness: "faith.",
|
||||
answer_relevancy: "ans.rel.",
|
||||
context_recall: "ctx.recall",
|
||||
context_precision: "ctx.prec.",
|
||||
noise_sensitivity: "noise.sens.",
|
||||
factual_correctness: "fact.corr.",
|
||||
semantic_similarity: "sem.sim.",
|
||||
};
|
||||
return map[name] || name;
|
||||
},
|
||||
|
||||
// 截取时间戳到分钟,便于阅读。
|
||||
shortTime(iso) {
|
||||
if (!iso) return "—";
|
||||
return String(iso).replace("T", " ").slice(0, 16);
|
||||
},
|
||||
|
||||
// 简单 HTML 转义,防止注入。
|
||||
escape(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text == null ? "" : String(text);
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
// 健康检查,更新左下角状态点。
|
||||
async checkHealth() {
|
||||
const dot = document.getElementById("health-dot");
|
||||
const dot = document.getElementById("health-dot");
|
||||
const label = document.getElementById("health-text");
|
||||
try {
|
||||
await API.health();
|
||||
dot.className = "dot ok";
|
||||
dot.className = "dot ok";
|
||||
label.textContent = "服务正常";
|
||||
} catch (_e) {
|
||||
dot.className = "dot bad";
|
||||
dot.className = "dot bad";
|
||||
label.textContent = "服务离线";
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user