shell.js 16.3 KB
/* ─────────────────────────────────────────────────────────────
   LemonBoss · NewFunction PRD — Shared Shell JS v2
   3-level sidebar (Section → Subgroup → Page) + 全局搜索
   ───────────────────────────────────────────────────────────── */
(function(){
  // ── Site map (3-level: section → subgroup → page) ──
  const SITE = [
    { id:'overview', label:'OVERVIEW · 总览', groups:[
      { label:'Introduction', pages:[
        { f:'index.html',           t:'封面 · Hero',          s:'三层 Loop 一图速览' },
        { f:'reading-guide.html',   t:'阅读指南',             s:'人 + Claude Code 两套读法' },
      ]},
    ]},
    { id:'prd', label:'PRD · 产品需求', groups:[
      { label:'Foundation', pages:[
        { f:'prd-background.html',  t:'背景 / 问题陈述',      s:'为什么要做这个' },
        { f:'prd-personas.html',    t:'角色 · User Stories',  s:'三类用户 + 故事卡' },
        { f:'requirement.html',     t:'原始需求 Verbatim',    s:'人类原话 + 解析' },
      ]},
      { label:'Specification', pages:[
        { f:'scope.html',           t:'范围与原则',           s:'in / out / 原则' },
        { f:'prd-feature-tree.html',t:'功能树',               s:'4 级树状结构' },
        { f:'prd-user-flows.html',  t:'用户流程图',           s:'3 个核心 user flow' },
        { f:'prd-success-metrics.html', t:'成功指标 · KPI',   s:'P0 验收 + 长期指标' },
      ]},
      { label:'Reference', pages:[
        { f:'prd-glossary.html',    t:'术语表',               s:'30 个核心词' },
      ]},
    ]},
    { id:'design', label:'DESIGN · 系统设计', groups:[
      { label:'Loops', pages:[
        { f:'loops-overview.html',  t:'三层 Loop 总览',       s:'L1 ⊂ L2 ⊂ L3' },
        { f:'loops-l1.html',        t:'L1 · Unit Loop',       s:'代码 · 单测 · 重试' },
        { f:'loops-l2.html',        t:'L2 · Integration',     s:'集成 · 归因 · 回写' },
        { f:'loops-l3.html',        t:'L3 · Human',           s:'新需求 · 增量重跑' },
      ]},
      { label:'Agents', pages:[
        { f:'agents-overview.html', t:'六个 Agent 总览',      s:'职责矩阵 · 调用关系' },
        { f:'agents-pm.html',       t:'PM Agent',             s:'需求 → PRD' },
        { f:'agents-architect.html',t:'Architect Agent',      s:'PRD → SDD + units.json' },
        { f:'agents-coder.html',    t:'Coder Agent',          s:'写前后端代码' },
        { f:'agents-tester.html',   t:'Tester Agent',         s:'写 / 跑 Unit Test' },
        { f:'agents-integrator.html',t:'Integrator Agent',    s:'跑集成测试' },
        { f:'agents-triager.html',  t:'BugTriager Agent',     s:'集成失败归因' },
      ]},
    ]},
    { id:'sdd', label:'SDD · MVCP 设计', groups:[
      { label:'Overview', pages:[
        { f:'sdd-overview.html',    t:'MVCP 总览',            s:'四象限 + 机械化抽取' },
      ]},
      { label:'MVCP', pages:[
        { f:'sdd-model.html',       t:'M · Model',            s:'15 条数据结构' },
        { f:'sdd-view.html',        t:'V · View',             s:'11 个组件签名' },
        { f:'sdd-control.html',     t:'C · Control',          s:'17 个 API / 服务方法' },
        { f:'sdd-process.html',     t:'P · Process',          s:'9 个长进程 / 状态机' },
      ]},
    ]},
    { id:'proto', label:'PROTOTYPE · 原型', groups:[
      { label:'Pages', pages:[
        { f:'ui-dashboard.html',    t:'主看板 · Dashboard',   s:'4 个核心区域' },
        { f:'proto-feedback-modal.html', t:'反馈模态框',      s:'+ NEW REQUIREMENT' },
        { f:'proto-unit-drawer.html',    t:'Unit 详情抽屉',   s:'最近 3 次 run' },
        { f:'proto-prd-diff.html',  t:'PRD diff 面板',        s:'section 级差异' },
      ]},
      { label:'System', pages:[
        { f:'ui-design-system.html',t:'视觉设计语言',         s:'色板 · 字号 · 排版' },
      ]},
    ]},
    { id:'arch', label:'ARCHITECTURE · 架构', groups:[
      { label:'Mapping', pages:[
        { f:'arch-mapping.html',    t:'对 LemonBoss 的映射',  s:'REUSE / NEW / PATCH' },
        { f:'arch-components.html', t:'三个新组件',           s:'LoopController · DocStore · UnitRegistry' },
      ]},
      { label:'Runtime', pages:[
        { f:'arch-workflow.html',   t:'运行时工作流',         s:'端到端时序图' },
        { f:'data-layout.html',     t:'文件空间布局',         s:'data/companies/.../projects/' },
        { f:'data-documents.html',  t:'四类文档关系',         s:'PRD / SDD / units / runs' },
      ]},
      { label:'Implementation', pages:[
        { f:'arch-project-root.html',  t:'Project Root',      s:'根目录概念 · 三种模式 · 创建入口' },
        { f:'arch-tool-registry.html', t:'Tool Registry',     s:'79 个工具的 JS 实现层' },
        { f:'arch-acp-bridge.html',    t:'ACP Bridge',        s:'LoopController ↔ agent-manager' },
        { f:'arch-git-versioning.html',t:'Git Versioning',    s:'每项目 git repo + 自动 commit' },
      ]},
    ]},
    { id:'api', label:'API · 接口规范', groups:[
      { label:'Specs', pages:[
        { f:'api.html',             t:'REST 端点 + 骨架',     s:'/api/devloop/*' },
        { f:'api-sse.html',         t:'SSE 事件总线',         s:'15 类事件完整规格' },
        { f:'api-contracts.html',   t:'Unit Contract 规范',   s:'契约 JSON schema' },
      ]},
    ]},
    { id:'ops', label:'OPS · 运行视图', groups:[
      { label:'Runtime Views', pages:[
        { f:'ops-progress.html',    t:'Progress',             s:'项目实时进度' },
        { f:'ops-loop-log.html',    t:'Loop Log',             s:'L1/L2/L3 时间线' },
        { f:'ops-buglist.html',     t:'Buglist',              s:'跨 run bug 台账' },
        { f:'ops-changelog.html',   t:'Changelog',            s:'版本变更流' },
      ]},
    ]},
    { id:'plan', label:'PLAN · 实施与复现', groups:[
      { label:'Implementation', pages:[
        { f:'plan-migration.html',  t:'最小改造路径',         s:'P0 → P4 五阶段' },
      ]},
      { label:'Operations', pages:[
        { f:'plan-cost-concurrency.html', t:'成本 & 并发预算', s:'Token / $ / 墙钟 / 并发上限' },
        { f:'plan-code-lifecycle.html',   t:'代码归宿',         s:'生成代码留沙箱 / export / PR' },
      ]},
      { label:'Quality & Verification', pages:[
        { f:'plan-risks.html',      t:'风险与待决',           s:'7 个风险点' },
        { f:'plan-reproducibility.html', t:'Claude Code 复现指南', s:'一个 prompt 重建项目' },
      ]},
    ]},
  ];

  // Flat list for prev/next + search resolution
  const FLAT = SITE.flatMap(sec => sec.groups.flatMap(g =>
    g.pages.map(p => ({ ...p, section:sec.id, sectionLabel:sec.label, group:g.label }))
  ));

  const currentPage = (document.body.dataset.page || 'index.html').trim();

  // ── Build sidebar (3-level) ──
  const sidebar = document.getElementById('sidebar');
  if (sidebar){
    sidebar.classList.add('sidebar');
    sidebar.innerHTML = `
      <a class="brand" href="index.html">
        <span class="dot"></span>
        <span>
          <b>LemonBoss · PRD</b>
          <small>NewFunction · v0.3</small>
        </span>
      </a>
      <div class="sb-search">
        <div style="position:relative">
          <input type="text" id="sbSearchInput" placeholder="搜索 (Ctrl/Cmd + K)" autocomplete="off" />
          <button class="clear-btn" id="sbClearBtn" title="清除">×</button>
        </div>
        <div class="hint">在所有 ${FLAT.length} 页内搜索</div>
      </div>
      ${SITE.map((section, sIx) => {
        const sectionActive = section.groups.some(g => g.pages.some(p => p.f === currentPage));
        return `
          <div class="section-group" data-section="${section.id}" data-open="${sectionActive ? 'true':'false'}">
            <div class="group-head">
              <span><span class="ix">${String(sIx+1).padStart(2,'0')}</span>${section.label}</span>
              <span class="chev">›</span>
            </div>
            <ul class="flat">${section.groups.map(g => {
              const groupActive = g.pages.some(p => p.f === currentPage);
              return `
                <li><div class="subgroup" data-open="${groupActive ? 'true':'false'}">
                  <div class="sub-head">
                    <span>${g.label}</span>
                    <span class="chev2">›</span>
                  </div>
                  <ul>${g.pages.map(p => {
                    const active = p.f === currentPage;
                    return `<li><a href="${p.f}"${active?' class="active"':''}>
                      <span class="lbl">${p.t}</span>
                      ${p.s ? `<span class="sub">${p.s}</span>` : ''}
                    </a></li>`;
                  }).join('')}</ul>
                </div></li>`;
            }).join('')}</ul>
          </div>`;
      }).join('')}
      <div class="sb-footer">PRD-001 v0.3 · 2026-05-13<br/>Claude Opus 4.7</div>
    `;

    // Level 1 (Section) toggle
    sidebar.querySelectorAll('.section-group > .group-head').forEach(head => {
      head.addEventListener('click', () => {
        const g = head.parentElement;
        g.dataset.open = g.dataset.open === 'true' ? 'false' : 'true';
      });
    });
    // Level 2 (Subgroup) toggle
    sidebar.querySelectorAll('.subgroup > .sub-head').forEach(head => {
      head.addEventListener('click', () => {
        const g = head.parentElement;
        g.dataset.open = g.dataset.open === 'true' ? 'false' : 'true';
      });
    });
  }

  // ── Prev / Next ──
  const idx = FLAT.findIndex(p => p.f === currentPage);
  const prev = idx > 0 ? FLAT[idx-1] : null;
  const next = idx >= 0 && idx < FLAT.length-1 ? FLAT[idx+1] : null;
  const navEl = document.querySelector('nav.prev-next');
  if (navEl){
    navEl.innerHTML =
      (prev ? `<a class="prev" href="${prev.f}"><small>← Previous</small><b>${prev.t}</b></a>` : `<span class="prev"></span>`) +
      (next ? `<a class="next" href="${next.f}"><small>Next →</small><b>${next.t}</b></a>` : `<span class="next"></span>`);
  }

  // ── Tabs (page-level content) ──
  document.querySelectorAll('[data-tabs]').forEach(root => {
    const buttons = root.querySelectorAll('.tabbar button');
    const panels  = root.querySelectorAll('.tabpanel');
    buttons.forEach((btn, i) => btn.addEventListener('click', () => {
      buttons.forEach(b => b.setAttribute('aria-selected','false'));
      panels.forEach(p => p.hidden = true);
      btn.setAttribute('aria-selected','true');
      panels[i].hidden = false;
    }));
  });

  // ── Search ──
  const shell  = document.querySelector('.shell');
  const main   = document.querySelector('main.page');
  const input  = document.getElementById('sbSearchInput');
  const clearB = document.getElementById('sbClearBtn');
  const searchBox = document.querySelector('.sb-search');
  let panel = null;
  let searchIndex = null;
  let indexLoading = null;

  function ensurePanel(){
    if (panel) return panel;
    panel = document.createElement('aside');
    panel.className = 'search-panel';
    panel.innerHTML = `
      <div class="sp-head">
        <span><span class="title">Search Results</span><span class="count" id="spCount"></span></span>
        <button class="close-btn" id="spClose" title="关闭 (Esc)">×</button>
      </div>
      <div id="spList"></div>
    `;
    sidebar.parentElement.insertBefore(panel, main);
    panel.querySelector('#spClose').addEventListener('click', closeSearch);
    return panel;
  }

  async function loadIndex(){
    if (searchIndex) return searchIndex;
    if (indexLoading) return indexLoading;
    indexLoading = fetch('assets/search-index.json')
      .then(r => r.ok ? r.json() : { entries: [] })
      .then(j => { searchIndex = j.entries || []; return searchIndex; })
      .catch(() => { searchIndex = []; return searchIndex; });
    return indexLoading;
  }

  function highlight(text, query){
    if (!query) return text;
    const re = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
    return text.replace(re, '<mark>$1</mark>');
  }

  function snippet(content, query, len=180){
    if (!content) return '';
    const lc = content.toLowerCase();
    const ix = lc.indexOf(query.toLowerCase());
    if (ix < 0) return content.slice(0, len) + (content.length>len?'…':'');
    const start = Math.max(0, ix - 40);
    const end   = Math.min(content.length, ix + query.length + 140);
    return (start>0?'…':'') + content.slice(start, end) + (end<content.length?'…':'');
  }

  function pageMeta(f){
    return FLAT.find(p => p.f === f);
  }

  async function doSearch(q){
    q = q.trim();
    if (!q){ closeSearch(); return; }
    shell.classList.add('search-open');
    ensurePanel();
    const idx2 = await loadIndex();
    const ql = q.toLowerCase();
    const results = [];
    for (const e of idx2){
      let hits = 0;
      const fields = [e.title, e.content, ...(e.headings||[])];
      for (const f of fields) if (f && f.toLowerCase().includes(ql)) hits++;
      if (hits){
        results.push({ ...e, hits, snippet: snippet(e.content, q) });
      }
    }
    results.sort((a,b) => b.hits - a.hits);

    const panelEl = ensurePanel();
    panelEl.querySelector('#spCount').textContent = results.length + ' 条';
    const listEl = panelEl.querySelector('#spList');
    if (results.length === 0){
      listEl.innerHTML = `<div class="sp-empty">没找到 "<b>${q}</b>"<br/>试试别的关键词,或检查拼写</div>`;
      return;
    }
    listEl.innerHTML = results.slice(0, 50).map(r => {
      const meta = pageMeta(r.f) || { sectionLabel:'', group:'' };
      const url = encodeURI(r.f) + '?q=' + encodeURIComponent(q);
      return `
        <div class="sp-result" data-href="${url}">
          <div class="crumb">${meta.sectionLabel} ${meta.group ? '· '+meta.group : ''} <span class="count">${r.hits} hit${r.hits>1?'s':''}</span></div>
          <a class="title" href="${url}">${highlight(r.title, q)}</a>
          <div class="snippet">${highlight(r.snippet, q)}</div>
        </div>
      `;
    }).join('');
    listEl.querySelectorAll('.sp-result').forEach(el => {
      el.addEventListener('click', e => {
        if (e.target.tagName === 'A') return;
        window.location.href = el.dataset.href;
      });
    });
  }

  function closeSearch(){
    shell.classList.remove('search-open');
    if (input) { input.value=''; searchBox.classList.remove('has-text'); }
  }

  if (input){
    let timer = null;
    input.addEventListener('input', e => {
      const v = e.target.value;
      searchBox.classList.toggle('has-text', !!v.trim());
      clearTimeout(timer);
      timer = setTimeout(() => doSearch(v), 180);
    });
    clearB.addEventListener('click', closeSearch);
    document.addEventListener('keydown', e => {
      if ((e.ctrlKey || e.metaKey) && e.key === 'k'){
        e.preventDefault(); input.focus(); input.select();
      } else if (e.key === 'Escape' && shell.classList.contains('search-open')){
        closeSearch();
      }
    });
  }

  // ── On-page highlight if arrived from search (?q=...) ──
  const params = new URLSearchParams(location.search);
  const q = params.get('q');
  if (q){
    const article = document.querySelector('article.body') || document.body;
    const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT);
    const re = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
    let firstHit = null;
    const nodes = [];
    while (walker.nextNode()) nodes.push(walker.currentNode);
    for (const n of nodes){
      if (n.parentElement && /SCRIPT|STYLE|MARK/i.test(n.parentElement.tagName)) continue;
      if (re.test(n.textContent)){
        const span = document.createElement('span');
        span.innerHTML = n.textContent.replace(new RegExp('('+q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')+')','gi'), '<mark style="background:#FFE9B3;color:var(--ink);padding:0 2px;border-radius:2px;font-weight:600">$1</mark>');
        n.parentNode.replaceChild(span, n);
        if (!firstHit) firstHit = span;
      }
    }
    if (firstHit){
      setTimeout(() => firstHit.scrollIntoView({ behavior:'smooth', block:'center' }), 100);
    }
  }
})();