// U-008: hash-based client-side router (no History API / server catch-all
// needed). U-022 adds the first real page, #/setup (v_project_setup, 02-V
// §2.1); U-023 adds #/goals/new and #/bugs/new; U-025 adds the real #/
// (v_dashboard), #/tasks (v_task_board), #/tasks/:task_id (v_task_detail).
// 02-V's console has no multi-project switcher UI (decision 2), but
// c_task_create/c_bug_report_create both require project_id (03-C §3.1;
// §3.1.1 doesn't list it, but needs it too -- see bug_record.py's
// create_bug_report docstring). Until v_dashboard carries "current project"
// state through some richer UI, the id picked/created in v_project_setup is
// stashed here and read back by the pages that need it.
const CURRENT_PROJECT_KEY = "graphlemon_project_id";
function getCurrentProjectId() {
return localStorage.getItem(CURRENT_PROJECT_KEY);
}
function setCurrentProjectId(projectId) {
localStorage.setItem(CURRENT_PROJECT_KEY, projectId);
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text == null ? "" : String(text);
return div.innerHTML;
}
// c_ws_subscribe (03-C §3.14, U-024): opens a live event feed for a project
// (optionally scoped to one task_id) and auto-reconnects on drop -- 02-V
// §2.2's "实时连接已断开,正在重连" exception state. REQ-12's deeper
// reconnect-catch-up semantics are U-024.5's job (03-C §3.14 itself routes
// that through REST snapshots, not WS replay); this only re-opens the
// socket and reports connection status back to the caller.
function connectEventSocket(projectId, taskId, { onEvent, onStatusChange }) {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
let url = wsProtocol + "//" + window.location.host + "/api/ws/projects/" + encodeURIComponent(projectId);
if (taskId) url += "?task_id=" + encodeURIComponent(taskId);
let closedByCaller = false;
let socket = null;
function open() {
socket = new WebSocket(url);
socket.onopen = () => onStatusChange("connected");
socket.onmessage = (event) => onEvent(JSON.parse(event.data));
socket.onclose = () => {
if (closedByCaller) return;
onStatusChange("disconnected");
setTimeout(open, 2000);
};
socket.onerror = () => socket.close();
}
open();
return {
close: () => {
closedByCaller = true;
if (socket) socket.close();
},
};
}
const routes = {
"#/": { render: renderDashboard, mount: mountDashboard },
"#/tasks": { render: renderTaskBoard, mount: mountTaskBoard },
"#/settings": { render: () => "
Settings
placeholder -- real content is PH3 (U-030)
" },
"#/setup": { render: renderProjectSetup, mount: mountProjectSetup },
"#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer },
"#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback },
};
// #/tasks/:task_id (v_task_detail) is the one dynamic route -- not worth a
// full path-matching router for a single pattern.
const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/;
let currentRouteCleanup = null;
function registerRouteCleanup(fn) {
currentRouteCleanup = fn;
}
function render() {
if (currentRouteCleanup) {
currentRouteCleanup();
currentRouteCleanup = null;
}
const hash = window.location.hash || "#/";
let route = routes[hash];
let routeArgs = [];
if (!route) {
const match = hash.match(TASK_DETAIL_RE);
if (match) {
route = { render: renderTaskDetail, mount: mountTaskDetail };
routeArgs = [match[1]];
}
}
if (!route) route = { render: () => "
Not found
" + hash + "
" };
document.getElementById("app").innerHTML = route.render(...routeArgs);
if (route.mount) route.mount(...routeArgs);
for (const a of document.querySelectorAll("#nav a")) {
a.classList.toggle("active", a.getAttribute("href") === hash);
}
}
window.addEventListener("hashchange", render);
window.addEventListener("DOMContentLoaded", render);
// ---- v_project_setup (02-V §2.1): create/switch project ----
function renderProjectSetup() {
return `
"
)).join("");
for (const row of rowsEl.querySelectorAll("tr[data-task-id]")) {
row.addEventListener("click", () => {
window.location.hash = "#/tasks/" + row.dataset.taskId;
});
}
}
keywordEl.addEventListener("input", refresh);
statusEl.addEventListener("change", refresh);
orgNodeEl.addEventListener("change", refresh);
refresh();
const socket = connectEventSocket(projectId, null, {
onEvent: (event) => {
if (event.event_type === "task_status_changed") refresh();
},
onStatusChange: () => {},
});
registerRouteCleanup(() => socket.close());
}
// ---- v_task_detail (02-V §2.6): task detail / process inspection ----
// Embeds a to-do tree built from graph_manifest (01-M 1.3's "补充说明") --
// the DAG-layout v_graph_visualizer and the raw-log v_log_viewer are each
// separate Units (U-026/U-027) and show as clearly-labeled placeholders
// here until they land, rather than faking their content.
function renderTaskDetail(taskId) {
if (!getCurrentProjectId()) return renderNoProjectSelected();
return `