app.js 12.7 KB
// 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 -- everything else is still
// a PH3 placeholder.

// 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 (U-025) exists to carry
// "current project" state, 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);
}

const routes = {
  "#/": { render: () => "<h1>Dashboard</h1><p>placeholder -- real content is PH3 (v_dashboard, U-025)</p>" },
  "#/tasks": { render: () => "<h1>Tasks</h1><p>placeholder -- real content is PH3 (v_task_board, U-025)</p>" },
  "#/settings": { render: () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>" },
  "#/setup": { render: renderProjectSetup, mount: mountProjectSetup },
  "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer },
  "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback },
};

function render() {
  const hash = window.location.hash || "#/";
  const route = routes[hash] || { render: () => "<h1>Not found</h1><p>" + hash + "</p>" };
  document.getElementById("app").innerHTML = route.render();
  if (route.mount) route.mount();

  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 `
    <div class="top-back"><a href="#/">← 返回仪表盘</a></div>
    <div class="page">
      <h1>创建 / 切换项目</h1>
      <div class="page-sub">选择一个本地文件夹作为目标项目的代码空间</div>
      <div class="panel">
        <div id="setup_error" class="error-banner" style="display:none;"></div>
        <div class="field">
          <label>目标文件夹(folder_path)</label>
          <input type="text" id="folder_path" readonly value="">
          <div class="hint" id="folder_path_hint">点击"选择文件夹"会由后端代为弹出系统原生对话框,不支持手动输入路径(除非返回 DIALOG_UNAVAILABLE)</div>
        </div>
        <button class="btn" id="btn_pick_folder" type="button">📁 选择文件夹</button>
        <div class="field" style="margin-top:20px;">
          <label>项目名称(project_name,非必填)</label>
          <input type="text" id="project_name" placeholder="留空则取文件夹名">
        </div>
        <div class="btn-row">
          <button class="btn btn-primary" id="btn_create" type="button">创建项目</button>
        </div>
        <div class="confirm-banner" id="confirm_banner" style="display:none;">
          该文件夹还不是 Git 仓库,是否代为初始化(<code>git init</code>)?确认前不写入任何文件。
          <div class="btn-row" style="margin-top:10px;">
            <button class="btn" id="btn_cancel_init" type="button">取消</button>
            <button class="btn btn-primary" id="btn_confirm_init" type="button">确认初始化并创建项目</button>
          </div>
        </div>
      </div>
    </div>
  `;
}

function mountProjectSetup() {
  const folderInput = document.getElementById("folder_path");
  const folderHint = document.getElementById("folder_path_hint");
  const nameInput = document.getElementById("project_name");
  const errorBox = document.getElementById("setup_error");
  const confirmBanner = document.getElementById("confirm_banner");

  function showError(message) {
    errorBox.textContent = message;
    errorBox.style.display = "block";
  }
  function clearError() {
    errorBox.style.display = "none";
  }

  document.getElementById("btn_pick_folder").addEventListener("click", async () => {
    clearError();
    const resp = await fetch("/api/system/pick-folder", { method: "POST", body: "{}" });
    const body = await resp.json();
    if (resp.status === 422 && body.error === "DIALOG_UNAVAILABLE") {
      folderInput.removeAttribute("readonly");
      folderHint.textContent = "当前环境不支持弹窗选择,请手动输入路径";
      return;
    }
    if (body.selected_path) {
      folderInput.value = body.selected_path;
    }
    // user canceled the native dialog -> folder_path stays as it was (02-V §2.1)
  });

  async function createProject(initGitIfMissing) {
    clearError();
    confirmBanner.style.display = "none";
    const folderPath = folderInput.value.trim();
    if (!folderPath) {
      showError("请先选择目标文件夹");
      return;
    }
    const resp = await fetch("/api/projects", {
      method: "POST",
      body: JSON.stringify({
        target_folder_path: folderPath,
        name: nameInput.value.trim() || undefined,
        init_git_if_missing: initGitIfMissing,
      }),
    });
    const body = await resp.json();
    if (resp.status === 200) {
      setCurrentProjectId(body.project_id);
      window.location.hash = "#/";
      return;
    }
    if (body.error === "GIT_INIT_DECLINED") {
      confirmBanner.style.display = "block";
      return;
    }
    showError(body.error + (body.detail ? ":" + body.detail : ""));
  }

  document.getElementById("btn_create").addEventListener("click", () => createProject(false));
  document.getElementById("btn_confirm_init").addEventListener("click", () => createProject(true));
  document.getElementById("btn_cancel_init").addEventListener("click", () => {
    confirmBanner.style.display = "none";
  });
}

// ---- shared: pages that need a "current project" to already exist ----

function renderNoProjectSelected() {
  return `
    <div class="page">
      <div class="error-banner" style="display:block;">
        还没有选择项目。请先<a href="#/setup">创建或切换项目</a>。
      </div>
    </div>
  `;
}

// ---- v_goal_composer (02-V §2.3): propose a new goal ----

const GOAL_TEXT_MAX_LEN = 4000;

function renderGoalComposer() {
  if (!getCurrentProjectId()) return renderNoProjectSelected();
  return `
    <div class="top-back"><a href="#/">← 返回仪表盘</a></div>
    <div class="page">
      <h1>提出新目标</h1>
      <div class="page-sub">用自然语言描述你想让 graphlemon 完成的目标</div>
      <div class="panel">
        <div id="goal_error" class="error-banner" style="display:none;"></div>
        <div id="goal_success" class="success-banner" style="display:none;"></div>
        <div class="field" id="goal_form">
          <label>目标描述(goal_text,必填,最长 ${GOAL_TEXT_MAX_LEN} 字)</label>
          <textarea id="goal_text" rows="6" maxlength="${GOAL_TEXT_MAX_LEN}"></textarea>
          <div class="btn-row">
            <button class="btn btn-primary" id="btn_submit_goal" type="button">创建任务</button>
          </div>
        </div>
        <div class="confirm-banner" id="goal_confirm_banner" style="display:none;">
          <div id="goal_confirm_summary"></div>
          <div class="btn-row" style="margin-top:10px;">
            <button class="btn" id="btn_edit_goal" type="button">返回编辑</button>
            <button class="btn btn-primary" id="btn_confirm_goal" type="button">确认创建</button>
          </div>
        </div>
      </div>
    </div>
  `;
}

function mountGoalComposer() {
  if (!getCurrentProjectId()) return;
  const textArea = document.getElementById("goal_text");
  const errorBox = document.getElementById("goal_error");
  const successBox = document.getElementById("goal_success");
  const form = document.getElementById("goal_form");
  const confirmBanner = document.getElementById("goal_confirm_banner");
  const confirmSummary = document.getElementById("goal_confirm_summary");

  document.getElementById("btn_submit_goal").addEventListener("click", () => {
    errorBox.style.display = "none";
    const goalText = textArea.value.trim();
    if (!goalText) {
      errorBox.textContent = "目标描述不能为空";
      errorBox.style.display = "block";
      return;
    }
    confirmSummary.textContent = "即将创建任务:" + goalText;
    form.style.display = "none";
    confirmBanner.style.display = "block";
  });

  document.getElementById("btn_edit_goal").addEventListener("click", () => {
    confirmBanner.style.display = "none";
    form.style.display = "block";
  });

  document.getElementById("btn_confirm_goal").addEventListener("click", async () => {
    const resp = await fetch("/api/tasks", {
      method: "POST",
      body: JSON.stringify({
        project_id: getCurrentProjectId(),
        goal_text: textArea.value.trim(),
        task_type: "goal",
        created_via: "human_console",
      }),
    });
    const body = await resp.json();
    if (resp.status !== 200) {
      confirmBanner.style.display = "none";
      form.style.display = "block";
      errorBox.textContent = body.error + (body.detail ? ":" + body.detail : "");
      errorBox.style.display = "block";
      return;
    }
    confirmBanner.style.display = "none";
    // v_task_detail doesn't exist yet (U-025) -- show the created task_id
    // here instead of redirecting to a route that isn't built.
    successBox.innerHTML = "已创建任务 <code>" + body.task_id + "</code>(" + body.status + ")。"
      + '<a href="#/">返回仪表盘</a>';
    successBox.style.display = "block";
  });
}

// ---- v_bug_feedback (02-V §2.4): report a bug ----

function renderBugFeedback() {
  if (!getCurrentProjectId()) return renderNoProjectSelected();
  return `
    <div class="top-back"><a href="#/">← 返回仪表盘</a></div>
    <div class="page">
      <h1>反馈 Bug</h1>
      <div class="page-sub">人工反馈,与 Validator 自动生成的 bug_record 复用同一张表(source=human)</div>
      <div class="panel">
        <div id="bug_error" class="error-banner" style="display:none;"></div>
        <div id="bug_success" class="success-banner" style="display:none;"></div>
        <div id="bug_form">
          <div class="field">
            <label>问题描述(description,必填)</label>
            <textarea id="bug_description" rows="4"></textarea>
          </div>
          <div class="field">
            <label>复现步骤(repro_steps,非必填)</label>
            <textarea id="bug_repro_steps" rows="3"></textarea>
          </div>
          <div class="field">
            <label>关联任务 ID(related_task_id,非必填)</label>
            <input type="text" id="bug_related_task_id" placeholder="留空则视为新发现的独立问题">
            <div class="hint">任务列表选择器随 U-025 补上,目前先直接填 task_id</div>
          </div>
          <div class="btn-row">
            <button class="btn btn-primary" id="btn_submit_bug" type="button">提交反馈</button>
          </div>
        </div>
      </div>
    </div>
  `;
}

function mountBugFeedback() {
  if (!getCurrentProjectId()) return;
  const descriptionArea = document.getElementById("bug_description");
  const reproStepsArea = document.getElementById("bug_repro_steps");
  const relatedTaskIdInput = document.getElementById("bug_related_task_id");
  const errorBox = document.getElementById("bug_error");
  const successBox = document.getElementById("bug_success");
  const form = document.getElementById("bug_form");

  document.getElementById("btn_submit_bug").addEventListener("click", async () => {
    errorBox.style.display = "none";
    const description = descriptionArea.value.trim();
    if (!description) {
      errorBox.textContent = "问题描述不能为空";
      errorBox.style.display = "block";
      return;
    }
    const resp = await fetch("/api/bug-reports", {
      method: "POST",
      body: JSON.stringify({
        project_id: getCurrentProjectId(),
        description: description,
        repro_steps: reproStepsArea.value.trim() || undefined,
        related_task_id: relatedTaskIdInput.value.trim() || undefined,
      }),
    });
    const body = await resp.json();
    if (resp.status !== 200) {
      errorBox.textContent = body.error + (body.detail ? ":" + body.detail : "");
      errorBox.style.display = "block";
      return;
    }
    form.style.display = "none";
    successBox.innerHTML = "已提交,Validator 将进行归因确认(bug <code>" + body.bug_id + "</code>)。"
      + '<a href="#/">返回仪表盘</a>';
    successBox.style.display = "block";
  });
}