Просмотр исходного кода

feat: rebuild dashboard with router-based multi-page architecture

单体 App.jsx (914行) 拆分为 6 个路由页面 + 4 个共享组件 + 5 个 lib 模块。
react-force-graph-3d 替换为 echarts,新增 React Router 懒加载。

后端新增 5 个 API:
- /api/stats/chapter-trend (分页章节趋势聚合)
- /api/commits (story-system commit 历史)
- /api/contracts/summary (合同树概览)
- /api/env-status, /api/env-status/probe (RAG 环境诊断)

修复 review 发现的问题:
- useEffectEvent 替换为 useRef (React 19 稳定版兼容)
- 补回 .filter-group CSS 定义
- /api/commits 容错:跳过损坏文件而非 500
- SSE 恢复静默忽略非 JSON 消息
- datetime 统一使用 UTC 时区
- 实体加载闭包过期修复
- _ensure_scripts_dir_on_path 收拢到 create_app 统一调用

含 mock_demo.py 仿真数据脚本和完整测试覆盖。
lingfengQAQ 2 месяцев назад
Родитель
Сommit
a033f36d4f
54 измененных файлов с 9543 добавлено и 1787 удалено
  1. 356 0
      docs/architecture/dashboard-demo.html
  2. 977 0
      docs/architecture/dashboard-prototype.html
  3. 64 26
      docs/architecture/system-architecture-diagram.html
  4. 652 0
      docs/research/2026-04-14-ui-ux-pro-max-skill-architecture-research.md
  5. 388 0
      docs/superpowers/plans/2026-04-15-chain-integrity-fixes.md
  6. 1960 0
      docs/superpowers/plans/2026-04-15-story-system-final-convergence.md
  7. 34 26
      docs/superpowers/plans/2026-04-16-dashboard-frontend-rebuild.md
  8. 372 8
      webnovel-writer/dashboard/app.py
  9. 175 8
      webnovel-writer/dashboard/frontend/design.md
  10. 1 0
      webnovel-writer/dashboard/frontend/dist/assets/Badge-DcwOuuF4.js
  11. 0 0
      webnovel-writer/dashboard/frontend/dist/assets/CharactersPage-n6sZAtwm.js
  12. 0 0
      webnovel-writer/dashboard/frontend/dist/assets/ChartWrapper-7BPAgK6F.js
  13. 1 0
      webnovel-writer/dashboard/frontend/dist/assets/DataTable-DwExK2c6.js
  14. 0 0
      webnovel-writer/dashboard/frontend/dist/assets/FilesPage-Czd1SC2y.js
  15. 0 0
      webnovel-writer/dashboard/frontend/dist/assets/ForeshadowingPage-BeSmFV1Z.js
  16. 0 0
      webnovel-writer/dashboard/frontend/dist/assets/OverviewPage-vgewJiV1.js
  17. 0 0
      webnovel-writer/dashboard/frontend/dist/assets/PacingPage-Dsdrtxb_.js
  18. 1 0
      webnovel-writer/dashboard/frontend/dist/assets/Pager-FJRhLwfC.js
  19. 0 0
      webnovel-writer/dashboard/frontend/dist/assets/SystemPage-CRCMgVw-.js
  20. 13 0
      webnovel-writer/dashboard/frontend/dist/assets/echarts-vendor-DOu6vfXz.js
  21. 0 0
      webnovel-writer/dashboard/frontend/dist/assets/foreshadowing-BP4NrzVI.js
  22. 1 0
      webnovel-writer/dashboard/frontend/dist/assets/format-flB0hImb.js
  23. 0 16
      webnovel-writer/dashboard/frontend/dist/assets/index-BeHSak5z.js
  24. 1 0
      webnovel-writer/dashboard/frontend/dist/assets/index-Dyazi077.js
  25. 0 0
      webnovel-writer/dashboard/frontend/dist/assets/index-R26PxixS.css
  26. 0 0
      webnovel-writer/dashboard/frontend/dist/assets/index-qVwzETG1.css
  27. 8 0
      webnovel-writer/dashboard/frontend/dist/assets/react-vendor-CjpoAPrF.js
  28. 1 0
      webnovel-writer/dashboard/frontend/dist/assets/story-lVhSS0ka.js
  29. 3 2
      webnovel-writer/dashboard/frontend/dist/index.html
  30. 106 441
      webnovel-writer/dashboard/frontend/package-lock.json
  31. 3 1
      webnovel-writer/dashboard/frontend/package.json
  32. 60 872
      webnovel-writer/dashboard/frontend/src/App.jsx
  33. 81 31
      webnovel-writer/dashboard/frontend/src/api.js
  34. 18 0
      webnovel-writer/dashboard/frontend/src/components/Badge.jsx
  35. 30 0
      webnovel-writer/dashboard/frontend/src/components/ChartWrapper.jsx
  36. 92 0
      webnovel-writer/dashboard/frontend/src/components/DataTable.jsx
  37. 32 0
      webnovel-writer/dashboard/frontend/src/components/Pager.jsx
  38. 165 0
      webnovel-writer/dashboard/frontend/src/icons.jsx
  39. 432 354
      webnovel-writer/dashboard/frontend/src/index.css
  40. 123 0
      webnovel-writer/dashboard/frontend/src/lib/charts.js
  41. 19 0
      webnovel-writer/dashboard/frontend/src/lib/files.js
  42. 136 0
      webnovel-writer/dashboard/frontend/src/lib/foreshadowing.js
  43. 76 0
      webnovel-writer/dashboard/frontend/src/lib/format.js
  44. 69 0
      webnovel-writer/dashboard/frontend/src/lib/story.js
  45. 35 2
      webnovel-writer/dashboard/frontend/src/main.jsx
  46. 511 0
      webnovel-writer/dashboard/frontend/src/pages/CharactersPage.jsx
  47. 197 0
      webnovel-writer/dashboard/frontend/src/pages/FilesPage.jsx
  48. 271 0
      webnovel-writer/dashboard/frontend/src/pages/ForeshadowingPage.jsx
  49. 517 0
      webnovel-writer/dashboard/frontend/src/pages/OverviewPage.jsx
  50. 321 0
      webnovel-writer/dashboard/frontend/src/pages/PacingPage.jsx
  51. 294 0
      webnovel-writer/dashboard/frontend/src/pages/SystemPage.jsx
  52. 8 0
      webnovel-writer/dashboard/frontend/vite.config.js
  53. 558 0
      webnovel-writer/scripts/data_modules/tests/mock_demo.py
  54. 381 0
      webnovel-writer/scripts/data_modules/tests/test_dashboard_app.py

+ 356 - 0
docs/architecture/dashboard-demo.html

@@ -0,0 +1,356 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<title>PIXEL WRITER HUB - 500章 Demo</title>
+<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
+<style>
+:root{--bg-main:#fff7e8;--bg-panel:#fffdf6;--bg-card:#fffaf0;--bg-card-2:#fff3d5;--text-main:#2a220f;--text-sub:#5d5035;--text-mute:#8f7f5c;--accent-blue:#26a8ff;--accent-purple:#7f5af0;--accent-green:#2ec27e;--accent-amber:#f5a524;--accent-red:#d7263d;--accent-cyan:#00b8d4;--border-main:#2a220f;--border-soft:#8f7f5c;--shadow-main:6px 6px 0 #2a220f;--shadow-soft:3px 3px 0 #8f7f5c;--font-display:'Press Start 2P',monospace;--font-body:'Noto Sans SC','Microsoft YaHei',sans-serif}
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:var(--font-body);color:var(--text-main);background:var(--bg-main);background-image:linear-gradient(90deg,rgba(42,34,15,.05) 1px,transparent 1px),linear-gradient(rgba(42,34,15,.05) 1px,transparent 1px);background-size:14px 14px;height:100vh}
+.app{display:grid;grid-template-columns:240px 1fr;height:100vh}
+.sidebar{border-right:3px solid var(--border-main);background:linear-gradient(180deg,#ffe8b8,#ffe19f);display:flex;flex-direction:column}
+.sidebar-hd{padding:16px;border-bottom:3px solid var(--border-main)}
+.sidebar-hd h1{font-family:var(--font-display);font-size:11px;letter-spacing:.08em;line-height:1.45}
+.sidebar-hd .sub{margin-top:10px;font-size:14px;font-weight:500;color:var(--text-sub)}
+.nav{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px}
+.nav-btn{width:100%;border:2px solid var(--border-main);background:#fff9e8;color:var(--text-main);text-align:left;display:flex;align-items:center;gap:8px;padding:10px 12px;font-size:14px;font-weight:600;cursor:pointer;box-shadow:var(--shadow-soft);font-family:var(--font-body);transition:transform .08s}
+.nav-btn:hover{transform:translate(-1px,-1px)}
+.nav-btn.active{background:#dff3ff;border-color:var(--accent-blue)}
+.nav-btn .ico{width:22px;text-align:center}
+.live{border-top:3px solid var(--border-main);padding:10px 12px;font-size:13px;font-weight:500;display:flex;align-items:center;gap:8px}
+.live-dot{width:10px;height:10px;background:var(--accent-green);border:2px solid var(--border-main)}
+.main{overflow-y:auto;padding:22px}
+.page-hd{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap}
+.page-hd h2{font-size:22px;font-weight:700}
+.badge{border:2px solid var(--border-main);font-size:12px;font-weight:700;padding:3px 8px;display:inline-block}
+.b-blue{background:#dff3ff;color:#055d8b}.b-green{background:#dcfce7;color:#0f5132}.b-amber{background:#fff1cd;color:#8a5b00}.b-red{background:#ffe0e5;color:#8f1d30}.b-purple{background:#ece3ff;color:#4a2ea8}.b-cyan{background:#dcfafe;color:#155e75}
+.card{background:var(--bg-card);border:3px solid var(--border-main);box-shadow:var(--shadow-main);padding:16px;margin-bottom:16px}
+.card-hd{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:10px;flex-wrap:wrap}
+.card-title{font-size:17px;font-weight:700}
+.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin-bottom:14px}
+.stat .label{font-size:13px;font-weight:600;color:var(--text-mute)}
+.stat .val{font-size:28px;line-height:1.15;margin:6px 0 2px;color:var(--accent-blue);font-variant-numeric:tabular-nums}
+.stat .val.plain{color:var(--text-main)}
+.stat .sub{font-size:13px;font-weight:500;color:var(--text-sub)}
+.progress{margin-top:8px;height:12px;border:2px solid var(--border-main);background:#f8e3b8}
+.progress-bar{height:100%;background:linear-gradient(90deg,#26a8ff,#7f5af0)}
+.two-col{display:grid;grid-template-columns:1fr 1fr;gap:16px}
+.tbl-wrap{overflow-x:auto;border:2px solid var(--border-soft);background:var(--bg-panel)}
+table{width:100%;border-collapse:collapse;font-size:14px;font-variant-numeric:tabular-nums}
+th{text-align:left;padding:8px 10px;border-bottom:2px solid var(--border-soft);background:var(--bg-card-2);font-weight:700;white-space:nowrap}
+td{padding:8px 10px;border-bottom:1px solid #d8ccb2;font-weight:500}
+tr:hover td{background:#fff4d8}
+.page{display:none}.page.active{display:block}
+.legend-dot{display:inline-block;width:12px;height:12px;border:2px solid var(--border-main);margin-right:4px;vertical-align:-1px}
+
+/* === 翻页控制条 === */
+.pager{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}
+.pager-btn{border:2px solid var(--border-main);background:#fff8e6;color:var(--text-main);font-family:var(--font-body);font-size:13px;font-weight:600;padding:4px 10px;cursor:pointer;box-shadow:2px 2px 0 var(--border-main)}
+.pager-btn:hover:not(:disabled){background:#e6f7ff;border-color:var(--accent-blue)}
+.pager-btn:disabled{opacity:.4;cursor:not-allowed}
+.pager-info{font-size:13px;font-weight:600;color:var(--text-sub)}
+
+/* === 像素柱状图 === */
+.pixel-bars{display:flex;align-items:flex-end;gap:3px;height:120px;padding:14px 4px 0;overflow:hidden}
+.pixel-bar-col{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;min-width:0}
+.pixel-bar{width:100%;border:2px solid var(--border-main);min-width:4px;position:relative}
+.pixel-bar .bar-val{position:absolute;top:-16px;left:50%;transform:translateX(-50%);font-size:9px;font-weight:700;white-space:nowrap;display:none}
+.pixel-bar-col:hover .bar-val{display:block}
+.pixel-bar-label{font-size:9px;color:var(--text-mute);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
+.bar-green{background:var(--accent-green)}.bar-amber{background:var(--accent-amber)}.bar-red{background:var(--accent-red)}
+
+/* === 热力格按卷分组 === */
+.heatmap-vol{margin-bottom:10px}
+.heatmap-vol-title{font-size:13px;font-weight:700;color:var(--text-sub);margin-bottom:4px}
+.heatmap-row{display:flex;gap:2px;flex-wrap:wrap}
+.heat-cell{width:18px;height:18px;border:1px solid var(--border-soft);cursor:pointer;position:relative}
+.heat-cell:hover::after{content:attr(data-tip);position:absolute;bottom:22px;left:50%;transform:translateX(-50%);background:var(--text-main);color:#fff;font-size:11px;padding:2px 6px;white-space:nowrap;z-index:10;border:2px solid var(--border-main)}
+.h-high{background:#26a8ff}.h-mid{background:#a8d8ff}.h-low{background:#dff3ff}.h-vlow{background:#ffe0e5}.h-none{background:#f5f0e0}
+
+/* === 伏笔甘特 === */
+.gantt-row{display:flex;align-items:center;gap:8px;margin-bottom:5px;font-size:13px}
+.gantt-label{width:130px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600;flex-shrink:0}
+.gantt-track{flex:1;height:14px;border:2px solid var(--border-soft);background:var(--bg-panel);position:relative}
+.gantt-fill{height:100%;position:absolute}
+.g-active{background:var(--accent-amber)}.g-urgent{background:var(--accent-red)}.g-done{background:var(--accent-green)}
+.gantt-ch{font-size:11px;color:var(--text-mute);width:60px;text-align:right;flex-shrink:0}
+.gantt-now{position:absolute;top:0;bottom:0;width:2px;background:var(--accent-blue);z-index:2}
+
+/* === Strand === */
+.strand-bar{height:12px;border:2px solid var(--border-main);display:flex;margin-bottom:6px}
+.strand-bar .seg{height:100%}.sq{background:#26a8ff}.sf{background:#ff5c8a}.sc{background:#7f5af0}
+.strand-legend{display:flex;gap:14px;font-size:13px;color:var(--text-sub)}
+
+/* === 系统 === */
+.sys-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
+.status-row{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px dashed #d8ccb2;font-size:14px}
+.status-row:last-child{border:none}
+.status-label{font-weight:600;color:var(--text-sub)}
+
+.ch-card{border:2px solid var(--border-soft);background:var(--bg-panel);padding:10px 12px;margin-bottom:6px;display:flex;justify-content:space-between;align-items:center}
+.ch-card:hover{background:#fff4d8}
+.ch-title{font-weight:700;font-size:14px}.ch-meta{font-size:13px;color:var(--text-sub);margin-top:2px}
+</style>
+</head>
+<body>
+<div class="app">
+  <aside class="sidebar">
+    <div class="sidebar-hd"><h1>PIXEL WRITER HUB</h1><div class="sub">斗破苍穹</div></div>
+    <div class="nav">
+      <button class="nav-btn active" onclick="showPage('overview',this)"><span class="ico">📊</span><span>总览</span></button>
+      <button class="nav-btn" onclick="showPage('pacing',this)"><span class="ico">📈</span><span>节奏雷达</span></button>
+      <button class="nav-btn" onclick="showPage('foreshadow',this)"><span class="ico">🔖</span><span>伏笔追踪</span></button>
+      <button class="nav-btn" onclick="showPage('system',this)"><span class="ico">⚙️</span><span>系统状态</span></button>
+    </div>
+    <div class="live"><span class="live-dot"></span> 实时同步中</div>
+  </aside>
+  <main class="main">
+    <div id="p-overview" class="page active"></div>
+    <div id="p-pacing" class="page"></div>
+    <div id="p-foreshadow" class="page"></div>
+    <div id="p-system" class="page"></div>
+  </main>
+</div>
+<script>
+// ========== 模拟 500 章数据 ==========
+const TOTAL_CH = 487, TOTAL_VOL = 5;
+const VOL_RANGES = [[1,100],[101,200],[201,320],[321,420],[421,487]];
+const VOL_NAMES = ['第1卷 废材崛起','第2卷 迦南学院','第3卷 黑角域','第4卷 中州大陆','第5卷 星域之争'];
+
+function randInt(a,b){return Math.floor(Math.random()*(b-a+1))+a}
+function genChapters(){
+  const arr=[];
+  for(let i=1;i<=TOTAL_CH;i++){
+    const score=randInt(55,98);
+    const wc=randInt(1400,3200);
+    const hooks=['strong','medium','weak'];
+    const hook=hooks[score>82?0:score>68?1:2];
+    const strands=['quest','fire','constellation'];
+    arr.push({ch:i,score,wc,hook,strand:strands[randInt(0,2)]});
+  }
+  return arr;
+}
+const DATA=genChapters();
+
+function genForeshadowing(){
+  const names=['三年之约','药老身世','萧家复仇','青莲地心火','灵根置换术','墨蛟契约','星陨阁秘密','魂殿暗线','迦南学院内鬼','炎帝传承','古河谷主的遗嘱','美杜莎蛇族','远古龙族血脉','虚无吞炎','净莲妖火','陀舍古帝遗迹','异火榜终极','联盟暗棋','萧炎父亲下落','荒古遗迹入口'];
+  return names.map((n,i)=>{
+    const planted=randInt(1,Math.min(400,TOTAL_CH-20));
+    const target=planted+randInt(15,120);
+    const resolved=target<TOTAL_CH-30?Math.random()>.5:false;
+    const urgent=!resolved&&target-TOTAL_CH<10;
+    return{name:n,planted,target:Math.min(target,TOTAL_CH+60),status:resolved?'done':urgent?'urgent':'active'};
+  });
+}
+const FORESHADOWING=genForeshadowing();
+
+// ========== 柱状图 ==========
+let barPage=0;
+const BAR_SIZE=20;
+function renderBars(containerId){
+  const total=Math.ceil(TOTAL_CH/BAR_SIZE);
+  const start=barPage*BAR_SIZE+1;
+  const end=Math.min(start+BAR_SIZE-1,TOTAL_CH);
+  const slice=DATA.slice(start-1,end);
+  let html=`<div class="pager">
+    <button class="pager-btn" onclick="barPage=Math.max(0,barPage-1);renderBars('${containerId}')" ${barPage<=0?'disabled':''}>◀</button>
+    <span class="pager-info">第 ${start}-${end} 章 / 共 ${TOTAL_CH} 章</span>
+    <button class="pager-btn" onclick="barPage=Math.min(${total-1},barPage+1);renderBars('${containerId}')" ${barPage>=total-1?'disabled':''}>▶</button>
+    <button class="pager-btn" onclick="barPage=${total-1};renderBars('${containerId}')">最新 ▶▶</button>
+  </div><div class="pixel-bars">`;
+  slice.forEach(d=>{
+    const cls=d.score>=80?'bar-green':d.score>=70?'bar-amber':'bar-red';
+    html+=`<div class="pixel-bar-col"><div class="pixel-bar ${cls}" style="height:${d.score}%"><span class="bar-val">${d.score}</span></div><div class="pixel-bar-label">${d.ch}</div></div>`;
+  });
+  html+=`</div><div style="margin-top:6px;font-size:12px;color:var(--text-mute)">🟢 ≥80 🟡 70-79 🔴 &lt;70 鼠标悬停看分数</div>`;
+  document.getElementById(containerId).innerHTML=html;
+}
+
+// ========== 热力格按卷 ==========
+function renderHeatmap(containerId){
+  let html='';
+  VOL_RANGES.forEach(([s,e],vi)=>{
+    html+=`<div class="heatmap-vol"><div class="heatmap-vol-title">${VOL_NAMES[vi]}(${s}-${e}章)</div><div class="heatmap-row">`;
+    for(let i=s;i<=e;i++){
+      const d=DATA[i-1];
+      const cls=d.wc>=2500?'h-high':d.wc>=2000?'h-mid':d.wc>=1800?'h-low':d.wc>0?'h-vlow':'h-none';
+      html+=`<div class="heat-cell ${cls}" data-tip="第${d.ch}章 ${d.wc}字"></div>`;
+    }
+    html+=`</div></div>`;
+  });
+  html+=`<div style="margin-top:6px;display:flex;gap:10px;font-size:12px;color:var(--text-mute)">
+    <span><span class="legend-dot h-high" style="border-color:var(--border-main)"></span>≥2.5k</span>
+    <span><span class="legend-dot h-mid" style="border-color:var(--border-main)"></span>2.0-2.5k</span>
+    <span><span class="legend-dot h-low" style="border-color:var(--border-main)"></span>1.8-2.0k</span>
+    <span><span class="legend-dot h-vlow" style="border-color:var(--border-main)"></span>&lt;1.8k</span>
+  </div>`;
+  document.getElementById(containerId).innerHTML=html;
+}
+
+// ========== 伏笔甘特 ==========
+let foreshadowFilter='all';
+function renderGantt(){
+  const items=foreshadowFilter==='all'?FORESHADOWING:FORESHADOWING.filter(f=>f.status===foreshadowFilter);
+  const maxCh=Math.max(TOTAL_CH+60,...items.map(f=>f.target));
+  const nowPct=(TOTAL_CH/maxCh*100).toFixed(1);
+  const counts={done:FORESHADOWING.filter(f=>f.status==='done').length,active:FORESHADOWING.filter(f=>f.status==='active').length,urgent:FORESHADOWING.filter(f=>f.status==='urgent').length};
+
+  let statsHtml=`<div class="stats" style="grid-template-columns:repeat(4,1fr)">
+    <div class="card stat"><div class="label">总伏笔</div><div class="val plain">${FORESHADOWING.length}</div></div>
+    <div class="card stat"><div class="label">活跃</div><div class="val" style="color:var(--accent-amber)">${counts.active}</div></div>
+    <div class="card stat"><div class="label">已回收</div><div class="val" style="color:var(--accent-green)">${counts.done}</div></div>
+    <div class="card stat"><div class="label">紧急</div><div class="val" style="color:var(--accent-red)">${counts.urgent}</div></div>
+  </div>`;
+
+  let filterHtml=`<div class="pager" style="margin-bottom:10px">
+    <button class="pager-btn${foreshadowFilter==='all'?' active':''}" onclick="foreshadowFilter='all';renderGantt()" style="${foreshadowFilter==='all'?'background:#dff3ff;border-color:var(--accent-blue)':''}">全部</button>
+    <button class="pager-btn" onclick="foreshadowFilter='urgent';renderGantt()" style="${foreshadowFilter==='urgent'?'background:#ffe0e5;border-color:var(--accent-red)':''}">🔴 紧急</button>
+    <button class="pager-btn" onclick="foreshadowFilter='active';renderGantt()" style="${foreshadowFilter==='active'?'background:#fff1cd;border-color:var(--accent-amber)':''}">🟡 活跃</button>
+    <button class="pager-btn" onclick="foreshadowFilter='done';renderGantt()" style="${foreshadowFilter==='done'?'background:#dcfce7;border-color:var(--accent-green)':''}">🟢 已回收</button>
+  </div>`;
+
+  let ganttHtml='';
+  items.sort((a,b)=>a.status==='urgent'?-1:b.status==='urgent'?1:a.planted-b.planted).forEach(f=>{
+    const left=(f.planted/maxCh*100).toFixed(1);
+    const width=((f.target-f.planted)/maxCh*100).toFixed(1);
+    const cls=f.status==='done'?'g-done':f.status==='urgent'?'g-urgent':'g-active';
+    ganttHtml+=`<div class="gantt-row"><div class="gantt-label">${f.name}</div><div class="gantt-track"><div class="gantt-fill ${cls}" style="left:${left}%;width:${width}%"></div><div class="gantt-now" style="left:${nowPct}%"></div></div><div class="gantt-ch">${f.planted}→${f.target}</div></div>`;
+  });
+
+  document.getElementById('p-foreshadow').innerHTML=`
+    <div class="page-hd"><h2>🔖 伏笔追踪</h2><span class="badge b-cyan">当前第 ${TOTAL_CH} 章</span></div>
+    ${statsHtml}
+    ${filterHtml}
+    <div class="card"><div class="card-hd"><span class="card-title">伏笔时间线</span><span class="badge b-cyan">章节 1 — ${maxCh}</span></div>
+    ${ganttHtml}
+    <div style="margin-top:8px;display:flex;gap:14px;font-size:12px">
+      <span><span class="legend-dot" style="background:var(--accent-green)"></span>已回收</span>
+      <span><span class="legend-dot" style="background:var(--accent-amber)"></span>活跃</span>
+      <span><span class="legend-dot" style="background:var(--accent-red)"></span>紧急</span>
+      <span style="color:var(--accent-blue)">| 蓝线 = 当前章</span>
+    </div></div>`;
+}
+
+// ========== 总览页 ==========
+function renderOverview(){
+  const totalWords=DATA.reduce((s,d)=>s+d.wc,0);
+  const recent5=DATA.slice(-5);
+  const avgScore=(recent5.reduce((s,d)=>s+d.score,0)/5).toFixed(1);
+  const urgentCount=FORESHADOWING.filter(f=>f.status==='urgent').length;
+  const pct=(totalWords/1200000*100).toFixed(1);
+
+  document.getElementById('p-overview').innerHTML=`
+    <div class="page-hd"><h2>📊 总览</h2><span class="badge b-blue">玄幻修仙 · 500章级</span></div>
+    <div class="stats">
+      <div class="card stat"><div class="label">总字数</div><div class="val">${(totalWords/10000).toFixed(1)} 万</div><div class="sub">目标 120 万 · ${pct}%</div><div class="progress"><div class="progress-bar" style="width:${pct}%"></div></div></div>
+      <div class="card stat"><div class="label">当前章节</div><div class="val plain">第 ${TOTAL_CH} 章</div><div class="sub">卷 ${TOTAL_VOL} · ${VOL_NAMES[TOTAL_VOL-1].split(' ')[1]}</div></div>
+      <div class="card stat"><div class="label">Story Runtime</div><div class="val plain" style="font-size:18px"><span class="badge b-green">Mainline ✓</span></div><div class="sub">accepted · no fallback</div></div>
+      <div class="card stat"><div class="label">审查均分</div><div class="val" style="color:${avgScore>=80?'var(--accent-green)':avgScore>=70?'var(--accent-amber)':'var(--accent-red)'}">${avgScore}</div><div class="sub">最近 5 章均分</div></div>
+      <div class="card stat"><div class="label">紧急伏笔</div><div class="val" style="color:var(--accent-amber)">${urgentCount}</div><div class="sub">总计 ${FORESHADOWING.length} 条伏笔</div></div>
+    </div>
+    <div class="card"><div class="card-hd"><span class="card-title">📊 审查得分</span></div><div id="overview-bars"></div></div>
+    <div class="card"><div class="card-hd"><span class="card-title">📝 字数热力图(按卷)</span></div><div id="overview-heat"></div></div>
+    <div class="two-col">
+      <div class="card">
+        <div class="card-hd"><span class="card-title">⚠️ 紧急伏笔</span></div>
+        <div class="tbl-wrap"><table><thead><tr><th>内容</th><th>状态</th><th>埋设</th><th>目标</th></tr></thead><tbody>
+        ${FORESHADOWING.filter(f=>f.status==='urgent').slice(0,5).map(f=>`<tr><td>${f.name}</td><td><span class="badge b-red">紧急</span></td><td>${f.planted}</td><td>${f.target}</td></tr>`).join('')}
+        </tbody></table></div>
+      </div>
+      <div class="card">
+        <div class="card-hd"><span class="card-title">最近章节</span><span class="badge b-blue">LATEST</span></div>
+        ${DATA.slice(-3).reverse().map(d=>{
+          const cls=d.hook==='strong'?'b-green':d.hook==='medium'?'b-amber':'b-red';
+          return `<div class="ch-card"><div><div class="ch-title">📖 第${d.ch}章</div><div class="ch-meta">审查 ${d.score} · 字数 ${d.wc}</div></div><div style="text-align:right"><span class="badge ${cls}">${d.hook}</span></div></div>`;
+        }).join('')}
+      </div>
+    </div>`;
+  barPage=Math.ceil(TOTAL_CH/BAR_SIZE)-1;
+  renderBars('overview-bars');
+  renderHeatmap('overview-heat');
+}
+
+// ========== 节奏雷达页 ==========
+let pacingPage=0;
+const PACING_SIZE=20;
+function renderPacing(){
+  const total=Math.ceil(TOTAL_CH/PACING_SIZE);
+  const start=pacingPage*PACING_SIZE+1;
+  const end=Math.min(start+PACING_SIZE-1,TOTAL_CH);
+  const slice=DATA.slice(start-1,end);
+
+  let hookHtml=`<div class="pager">
+    <button class="pager-btn" onclick="pacingPage=Math.max(0,pacingPage-1);renderPacing()" ${pacingPage<=0?'disabled':''}>◀</button>
+    <span class="pager-info">第 ${start}-${end} 章</span>
+    <button class="pager-btn" onclick="pacingPage=Math.min(${total-1},pacingPage+1);renderPacing()" ${pacingPage>=total-1?'disabled':''}>▶</button>
+    <button class="pager-btn" onclick="pacingPage=${total-1};renderPacing()">最新 ▶▶</button>
+  </div>`;
+  slice.forEach(d=>{
+    const cls=d.hook==='strong'?'hb-strong':d.hook==='medium'?'hb-medium':'hb-weak';
+    const bcls=d.hook==='strong'?'b-green':d.hook==='medium'?'b-amber':'b-red';
+    hookHtml+=`<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px"><span style="width:50px;font-size:12px;color:var(--text-mute);text-align:right">${d.ch}</span><div style="flex:1;height:16px;border:2px solid var(--border-soft);background:var(--bg-panel)"><div style="height:100%;width:${d.hook==='strong'?100:d.hook==='medium'?66:33}%;background:${d.hook==='strong'?'var(--accent-green)':d.hook==='medium'?'var(--accent-amber)':'var(--accent-red)'}"></div></div><span style="width:65px;font-size:12px"><span class="badge ${bcls}">${d.hook}</span></span></div>`;
+  });
+
+  let strandHtml='';
+  slice.forEach(d=>{
+    const q=d.strand==='quest'?randInt(40,70):randInt(10,30);
+    const f=d.strand==='fire'?randInt(40,60):randInt(10,30);
+    const c=100-q-f;
+    strandHtml+=`<div style="display:flex;align-items:center;gap:6px"><span style="width:40px;font-size:11px;color:var(--text-mute);text-align:right">${d.ch}</span><div class="strand-bar" style="flex:1;margin:0;height:10px"><div class="seg sq" style="width:${q}%"></div><div class="seg sf" style="width:${f}%"></div><div class="seg sc" style="width:${c}%"></div></div></div>`;
+  });
+
+  document.getElementById('p-pacing').innerHTML=`
+    <div class="page-hd"><h2>📈 节奏雷达</h2><span class="badge b-purple">共 ${TOTAL_CH} 章</span></div>
+    <div class="card"><div class="card-hd"><span class="card-title">钩子强度</span></div>${hookHtml}</div>
+    <div class="two-col">
+      <div class="card"><div class="card-hd"><span class="card-title">Strand 分布</span></div>${strandHtml}
+        <div class="strand-legend" style="margin-top:8px"><span>🔵 Quest</span><span>🔴 Fire</span><span>🟣 Constellation</span></div>
+      </div>
+      <div class="card"><div class="card-hd"><span class="card-title">字数热力图</span></div><div id="pacing-heat"></div></div>
+    </div>`;
+  renderHeatmap('pacing-heat');
+}
+
+// ========== 系统页 ==========
+function renderSystem(){
+  document.getElementById('p-system').innerHTML=`
+    <div class="page-hd"><h2>⚙️ 系统状态</h2></div>
+    <div class="sys-grid">
+      <div class="card"><div class="card-hd"><span class="card-title">Story Runtime</span><span class="badge b-green">Mainline ✓</span></div>
+        <div class="status-row"><span class="status-label">Latest Commit</span><span><span class="badge b-green">accepted</span> 第${TOTAL_CH}章</span></div>
+        <div class="status-row"><span class="status-label">Fallback</span><span>none</span></div></div>
+      <div class="card"><div class="card-hd"><span class="card-title">合同树</span></div>
+        <div class="status-row"><span class="status-label">MASTER_SETTING</span><span><span class="badge b-green">✓</span> 玄幻修仙</span></div>
+        <div class="status-row"><span class="status-label">Volume</span><span>${TOTAL_VOL} 份</span></div>
+        <div class="status-row"><span class="status-label">Chapter</span><span>${TOTAL_CH} 份</span></div>
+        <div class="status-row"><span class="status-label">Review</span><span>${TOTAL_CH} 份</span></div></div>
+      <div class="card"><div class="card-hd"><span class="card-title">最近 Commit</span></div>
+        <div class="tbl-wrap"><table><thead><tr><th>章</th><th>状态</th><th>state</th><th>index</th><th>summ</th><th>mem</th><th>vec</th></tr></thead><tbody>
+        ${DATA.slice(-5).reverse().map(d=>`<tr><td>${d.ch}</td><td><span class="badge b-green">accepted</span></td><td>done</td><td>done</td><td>done</td><td>done</td><td>done</td></tr>`).join('')}
+        </tbody></table></div></div>
+      <div class="card"><div class="card-hd"><span class="card-title">RAG 环境</span></div>
+        <div class="status-row"><span class="status-label">Embed Key</span><span><span class="badge b-green">✓</span></span></div>
+        <div class="status-row"><span class="status-label">Rerank Key</span><span><span class="badge b-amber">未配</span></span></div>
+        <div class="status-row"><span class="status-label">Vector DB</span><span>24.3 MB</span></div>
+        <div class="status-row"><span class="status-label">RAG Mode</span><span><span class="badge b-blue">vector_only</span></span></div></div>
+    </div>`;
+}
+
+// ========== 页面切换 ==========
+function showPage(id,btn){
+  document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
+  document.getElementById('p-'+id).classList.add('active');
+  document.querySelectorAll('.nav-btn').forEach(b=>b.classList.remove('active'));
+  if(btn)btn.classList.add('active');
+  if(id==='overview')renderOverview();
+  if(id==='pacing')renderPacing();
+  if(id==='foreshadow')renderGantt();
+  if(id==='system')renderSystem();
+}
+
+// 初始渲染
+renderOverview();
+</script>
+</body>
+</html>

+ 977 - 0
docs/architecture/dashboard-prototype.html

@@ -0,0 +1,977 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>PIXEL WRITER HUB - Dashboard Prototype</title>
+<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
+<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
+<style>
+/* ===== PIXEL WRITER HUB DESIGN SYSTEM ===== */
+:root {
+  --bg-main: #fff7e8;
+  --bg-panel: #fffdf6;
+  --bg-card: #fffaf0;
+  --bg-card-2: #fff3d5;
+  --text-main: #2a220f;
+  --text-sub: #5d5035;
+  --text-mute: #8f7f5c;
+  --accent-blue: #26a8ff;
+  --accent-purple: #7f5af0;
+  --accent-green: #2ec27e;
+  --accent-amber: #f5a524;
+  --accent-red: #d7263d;
+  --accent-cyan: #00b8d4;
+  --border-main: #2a220f;
+  --border-soft: #8f7f5c;
+  --shadow-main: 6px 6px 0 #2a220f;
+  --shadow-soft: 3px 3px 0 #8f7f5c;
+  --font-display: 'Press Start 2P', monospace;
+  --font-body: 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
+}
+
+* { margin:0; padding:0; box-sizing:border-box; }
+html, body { height:100%; }
+
+body {
+  font-family: var(--font-body);
+  color: var(--text-main);
+  background: var(--bg-main);
+  background-image:
+    linear-gradient(90deg, rgba(42,34,15,.05) 1px, transparent 1px),
+    linear-gradient(rgba(42,34,15,.05) 1px, transparent 1px);
+  background-size: 14px 14px;
+}
+
+.app-layout {
+  display: grid;
+  grid-template-columns: 240px minmax(0,1fr);
+  height: 100vh;
+}
+
+/* ===== SIDEBAR ===== */
+.sidebar {
+  border-right: 3px solid var(--border-main);
+  background: linear-gradient(180deg, #ffe8b8, #ffe19f);
+  display: flex;
+  flex-direction: column;
+}
+.sidebar-header {
+  padding: 16px;
+  border-bottom: 3px solid var(--border-main);
+}
+.sidebar-header h1 {
+  font-family: var(--font-display);
+  font-size: 11px;
+  letter-spacing: .08em;
+  line-height: 1.45;
+}
+.sidebar-header .subtitle {
+  margin-top: 10px;
+  font-size: 14px;
+  font-weight: 500;
+  color: var(--text-sub);
+}
+.sidebar-nav {
+  flex: 1;
+  overflow-y: auto;
+  padding: 10px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.nav-item {
+  width: 100%;
+  border: 2px solid var(--border-main);
+  background: #fff9e8;
+  color: var(--text-main);
+  text-align: left;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 10px 12px;
+  font-size: 14px;
+  font-weight: 600;
+  cursor: pointer;
+  box-shadow: var(--shadow-soft);
+  transition: transform .08s;
+  font-family: var(--font-body);
+}
+.nav-item:hover { transform: translate(-1px,-1px); }
+.nav-item.active { background: #dff3ff; border-color: var(--accent-blue); }
+.nav-item .icon { width: 22px; text-align: center; }
+
+.live-indicator {
+  border-top: 3px solid var(--border-main);
+  padding: 10px 12px;
+  font-size: 13px;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.live-dot {
+  width: 10px; height: 10px;
+  background: var(--accent-green);
+  border: 2px solid var(--border-main);
+}
+
+/* ===== MAIN ===== */
+.main-content {
+  overflow-y: auto;
+  padding: 22px;
+}
+
+.page { display: none; }
+.page.active { display: block; }
+
+.page-header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 14px;
+}
+.page-header h2 { font-size: 22px; font-weight: 700; }
+
+/* ===== CARD ===== */
+.card {
+  background: var(--bg-card);
+  border: 3px solid var(--border-main);
+  box-shadow: var(--shadow-main);
+  padding: 16px;
+  margin-bottom: 16px;
+}
+.card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 10px;
+}
+.card-title { font-size: 17px; font-weight: 700; }
+
+.badge {
+  border: 2px solid var(--border-main);
+  font-size: 12px;
+  font-weight: 700;
+  padding: 3px 8px;
+  background: #fff;
+  display: inline-block;
+}
+.badge-blue { background: #dff3ff; color: #055d8b; }
+.badge-green { background: #dcfce7; color: #0f5132; }
+.badge-amber { background: #fff1cd; color: #8a5b00; }
+.badge-red { background: #ffe0e5; color: #8f1d30; }
+.badge-purple { background: #ece3ff; color: #4a2ea8; }
+.badge-cyan { background: #dcfafe; color: #155e75; }
+
+/* ===== STAT GRID ===== */
+.stat-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+  gap: 12px;
+  margin-bottom: 14px;
+}
+.stat-card .stat-label {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--text-mute);
+}
+.stat-card .stat-value {
+  font-size: 28px;
+  line-height: 1.15;
+  margin: 6px 0 2px;
+  color: var(--accent-blue);
+  font-variant-numeric: tabular-nums;
+}
+.stat-card .stat-value.plain { color: var(--text-main); }
+.stat-sub { font-size: 13px; font-weight: 500; color: var(--text-sub); }
+
+.progress-track {
+  margin-top: 8px;
+  height: 12px;
+  border: 2px solid var(--border-main);
+  background: #f8e3b8;
+}
+.progress-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #26a8ff, #7f5af0);
+}
+
+/* ===== CHART CONTAINER ===== */
+.chart-box {
+  width: 100%;
+  height: 320px;
+}
+.chart-box.tall { height: 420px; }
+
+/* ===== TABLE ===== */
+.table-wrap {
+  overflow-x: auto;
+  border: 2px solid var(--border-soft);
+  background: var(--bg-panel);
+}
+.data-table {
+  width: 100%;
+  min-width: 580px;
+  border-collapse: collapse;
+  font-size: 14px;
+  font-variant-numeric: tabular-nums;
+}
+.data-table th {
+  text-align: left;
+  padding: 8px 10px;
+  border-bottom: 2px solid var(--border-soft);
+  background: var(--bg-card-2);
+  white-space: nowrap;
+  font-weight: 700;
+}
+.data-table td {
+  padding: 8px 10px;
+  border-bottom: 1px solid #d8ccb2;
+  font-weight: 500;
+}
+.data-table tbody tr:hover td { background: #fff4d8; }
+
+/* ===== FILTER BUTTONS ===== */
+.filter-group {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 12px;
+}
+.filter-btn {
+  border: 2px solid var(--border-main);
+  background: #fff8e6;
+  color: var(--text-main);
+  font-family: var(--font-body);
+  font-size: 13px;
+  font-weight: 600;
+  padding: 5px 10px;
+  cursor: pointer;
+}
+.filter-btn.active { background: #e6f7ff; border-color: var(--accent-blue); }
+
+/* ===== PAGER ===== */
+.pager {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+  margin-top: 8px;
+}
+.page-btn {
+  border: 2px solid var(--border-main);
+  background: #fff8e6;
+  font-family: var(--font-body);
+  font-size: 13px;
+  font-weight: 600;
+  padding: 4px 10px;
+  cursor: pointer;
+}
+.page-btn:hover { background: #e6f7ff; border-color: var(--accent-blue); }
+.page-info { font-size: 13px; font-weight: 600; color: var(--text-sub); }
+
+/* ===== SPLIT LAYOUT ===== */
+.split-layout {
+  display: grid;
+  grid-template-columns: minmax(0,1fr) 340px;
+  gap: 14px;
+}
+
+/* ===== GANTT ===== */
+.chart-box.gantt { height: 380px; }
+
+/* ===== SECTION LABEL ===== */
+.section-label {
+  font-family: var(--font-display);
+  font-size: 9px;
+  letter-spacing: .1em;
+  color: var(--text-mute);
+  margin-bottom: 8px;
+  text-transform: uppercase;
+}
+
+/* ===== PAGE NOTE ===== */
+.proto-note {
+  background: #fff3d5;
+  border: 2px dashed var(--border-soft);
+  padding: 10px 14px;
+  font-size: 13px;
+  color: var(--text-sub);
+  margin-bottom: 14px;
+}
+</style>
+</head>
+<body>
+<div class="app-layout">
+  <!-- SIDEBAR -->
+  <aside class="sidebar">
+    <div class="sidebar-header">
+      <h1>PIXEL WRITER<br>HUB</h1>
+      <div class="subtitle">《仙道长青》</div>
+    </div>
+    <nav class="sidebar-nav" id="nav">
+      <button class="nav-item active" data-page="overview"><span class="icon">📊</span><span>总览</span></button>
+      <button class="nav-item" data-page="characters"><span class="icon">👤</span><span>角色图鉴</span></button>
+      <button class="nav-item" data-page="pacing"><span class="icon">📈</span><span>节奏雷达</span></button>
+      <button class="nav-item" data-page="foreshadowing"><span class="icon">🔖</span><span>伏笔追踪</span></button>
+      <button class="nav-item" data-page="files"><span class="icon">📁</span><span>文档浏览</span></button>
+      <button class="nav-item" data-page="system"><span class="icon">⚙️</span><span>系统状态</span></button>
+    </nav>
+    <div class="live-indicator">
+      <span class="live-dot"></span>
+      实时同步中
+    </div>
+  </aside>
+
+  <!-- MAIN -->
+  <main class="main-content">
+
+    <!-- ==================== PAGE 1: 总览 ==================== -->
+    <div class="page active" id="page-overview">
+      <div class="page-header">
+        <h2>📊 总览</h2>
+        <span class="badge badge-blue">仙侠</span>
+      </div>
+
+      <div class="stat-grid">
+        <div class="card stat-card">
+          <span class="stat-label">总字数</span>
+          <span class="stat-value">128.6 万</span>
+          <span class="stat-sub">目标 200 万字 · 64.3%</span>
+          <div class="progress-track"><div class="progress-fill" style="width:64.3%"></div></div>
+        </div>
+        <div class="card stat-card">
+          <span class="stat-label">当前章节</span>
+          <span class="stat-value">第 412 章</span>
+          <span class="stat-sub">目标 800 章 · 卷 5</span>
+        </div>
+        <div class="card stat-card">
+          <span class="stat-label">Story Runtime</span>
+          <span class="stat-value plain">Mainline</span>
+          <span class="stat-sub">accepted · projection OK</span>
+        </div>
+        <div class="card stat-card">
+          <span class="stat-label">审查均分</span>
+          <span class="stat-value">7.8</span>
+          <span class="stat-sub">最近 50 章平均</span>
+        </div>
+        <div class="card stat-card">
+          <span class="stat-label">紧急伏笔</span>
+          <span class="stat-value" style="color:var(--accent-amber)">4</span>
+          <span class="stat-sub">总计 37 条伏笔</span>
+        </div>
+      </div>
+
+      <!-- 审查得分折线图 -->
+      <div class="card">
+        <div class="card-header">
+          <span class="card-title">审查得分趋势</span>
+          <div>
+            <span class="badge badge-green">最近 50 章</span>
+            <button class="page-btn" style="margin-left:6px">← 前 50</button>
+            <button class="page-btn">跳到最新 →</button>
+          </div>
+        </div>
+        <div class="chart-box" id="chart-review-score"></div>
+      </div>
+
+      <!-- 字数分布柱状图 -->
+      <div class="card">
+        <div class="card-header">
+          <span class="card-title">字数分布(按卷)</span>
+          <span class="badge badge-purple">5 卷</span>
+        </div>
+        <div class="chart-box" id="chart-word-dist"></div>
+      </div>
+
+      <!-- Strand Weave -->
+      <div class="card">
+        <div class="card-header">
+          <span class="card-title">Strand Weave 整体分布</span>
+          <span class="badge badge-purple">constellation</span>
+        </div>
+        <div class="chart-box" id="chart-strand-overview" style="height:260px"></div>
+      </div>
+
+      <!-- 紧急伏笔 Top 5 -->
+      <div class="card">
+        <div class="card-header">
+          <span class="card-title">紧急伏笔 Top 5</span>
+        </div>
+        <div class="table-wrap">
+          <table class="data-table">
+            <thead><tr><th>内容</th><th>状态</th><th>埋设章</th><th>目标章</th><th>紧急度</th></tr></thead>
+            <tbody>
+              <tr><td>青元秘境的钥匙碎片下落</td><td><span class="badge badge-red">超期</span></td><td>285</td><td>350</td><td><span class="badge badge-red">critical</span></td></tr>
+              <tr><td>凤灵儿真实身份暗示</td><td><span class="badge badge-amber">紧急</span></td><td>312</td><td>420</td><td><span class="badge badge-amber">high</span></td></tr>
+              <tr><td>老道士临终遗言中的数字</td><td><span class="badge badge-amber">紧急</span></td><td>356</td><td>430</td><td><span class="badge badge-amber">high</span></td></tr>
+              <tr><td>黑市拍卖会幕后势力</td><td><span class="badge badge-amber">紧急</span></td><td>389</td><td>440</td><td><span class="badge badge-amber">medium</span></td></tr>
+              <tr><td>主角功法异变的真实原因</td><td><span class="badge badge-blue">活跃</span></td><td>401</td><td>500</td><td><span class="badge badge-blue">normal</span></td></tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+    </div>
+
+    <!-- ==================== PAGE 2: 角色图鉴 ==================== -->
+    <div class="page" id="page-characters">
+      <div class="page-header">
+        <h2>👤 角色图鉴</h2>
+        <span class="badge badge-green">48 / 127 个实体</span>
+      </div>
+
+      <div class="filter-group">
+        <button class="filter-btn active">全部</button>
+        <button class="filter-btn">角色</button>
+        <button class="filter-btn">势力</button>
+        <button class="filter-btn">地点</button>
+        <button class="filter-btn">法宝</button>
+      </div>
+
+      <div class="proto-note">Tab 1: 实体列表 + 详情面板(保留现有逻辑) | Tab 2: 关系图谱(下方预览)</div>
+
+      <!-- 关系图谱 -->
+      <div class="card">
+        <div class="card-header">
+          <span class="card-title">关系图谱</span>
+          <span class="badge badge-blue">ECharts graph · 力导向 · 时间轴</span>
+        </div>
+        <!-- 时间轴控制器 -->
+        <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap;">
+          <button class="page-btn" id="graph-play-btn" style="min-width:60px">▶ 播放</button>
+          <span style="font-size:13px;font-weight:600;color:var(--text-mute);white-space:nowrap">第 1 章</span>
+          <input type="range" id="graph-timeline" min="1" max="412" value="412"
+            style="flex:1;min-width:200px;height:12px;accent-color:#26a8ff;cursor:pointer">
+          <span style="font-size:13px;font-weight:600;color:var(--text-mute);white-space:nowrap">第 412 章</span>
+          <span id="graph-chapter-label" class="badge badge-blue" style="min-width:90px;text-align:center">第 412 章</span>
+          <span id="graph-node-count" class="badge badge-green" style="min-width:60px;text-align:center">8 人</span>
+        </div>
+        <div class="chart-box tall" id="chart-relation-graph"></div>
+      </div>
+    </div>
+
+    <!-- ==================== PAGE 3: 节奏雷达 ==================== -->
+    <div class="page" id="page-pacing">
+      <div class="page-header">
+        <h2>📈 节奏雷达</h2>
+        <span class="badge badge-amber">412 章数据</span>
+      </div>
+
+      <!-- 钩子强度面积图 -->
+      <div class="card">
+        <div class="card-header">
+          <span class="card-title">钩子强度走势</span>
+          <div>
+            <span class="badge badge-green">第 363-412 章</span>
+            <button class="page-btn" style="margin-left:6px">← 前 50</button>
+            <button class="page-btn">跳到最新 →</button>
+          </div>
+        </div>
+        <div class="chart-box" id="chart-hook-strength"></div>
+      </div>
+
+      <!-- Strand 堆叠柱状图 -->
+      <div class="card">
+        <div class="card-header">
+          <span class="card-title">Strand 分布(逐章)</span>
+          <span class="badge badge-purple">堆叠柱状图</span>
+        </div>
+        <div class="chart-box" id="chart-strand-stack"></div>
+      </div>
+
+      <!-- 字数分布 -->
+      <div class="card">
+        <div class="card-header">
+          <span class="card-title">章节字数分布</span>
+          <span class="badge badge-blue">按卷分组</span>
+        </div>
+        <div class="chart-box" id="chart-pacing-words"></div>
+      </div>
+    </div>
+
+    <!-- ==================== PAGE 4: 伏笔追踪 ==================== -->
+    <div class="page" id="page-foreshadowing">
+      <div class="page-header">
+        <h2>🔖 伏笔追踪</h2>
+      </div>
+
+      <div class="stat-grid">
+        <div class="card stat-card">
+          <span class="stat-label">总伏笔</span>
+          <span class="stat-value plain">37</span>
+        </div>
+        <div class="card stat-card">
+          <span class="stat-label">活跃</span>
+          <span class="stat-value" style="color:var(--accent-blue)">18</span>
+        </div>
+        <div class="card stat-card">
+          <span class="stat-label">已回收</span>
+          <span class="stat-value" style="color:var(--accent-green)">15</span>
+        </div>
+        <div class="card stat-card">
+          <span class="stat-label">紧急/超期</span>
+          <span class="stat-value" style="color:var(--accent-red)">4</span>
+        </div>
+      </div>
+
+      <div class="filter-group">
+        <button class="filter-btn active">全部</button>
+        <button class="filter-btn">紧急</button>
+        <button class="filter-btn">活跃</button>
+        <button class="filter-btn">已回收</button>
+      </div>
+
+      <!-- 甘特时间线 -->
+      <div class="card">
+        <div class="card-header">
+          <span class="card-title">伏笔时间线</span>
+          <span class="badge badge-cyan">ECharts 自定义 bar · 甘特</span>
+        </div>
+        <div class="chart-box gantt" id="chart-foreshadow-gantt"></div>
+      </div>
+
+      <!-- 伏笔完整表格 -->
+      <div class="card">
+        <div class="card-header">
+          <span class="card-title">完整伏笔列表</span>
+        </div>
+        <div class="table-wrap">
+          <table class="data-table">
+            <thead><tr><th>内容</th><th>状态</th><th>埋设章</th><th>目标章</th><th>紧急度</th></tr></thead>
+            <tbody>
+              <tr><td>青元秘境的钥匙碎片下落</td><td><span class="badge badge-red">超期</span></td><td>285</td><td>350</td><td><span class="badge badge-red">critical</span></td></tr>
+              <tr><td>凤灵儿真实身份暗示</td><td><span class="badge badge-amber">紧急</span></td><td>312</td><td>420</td><td><span class="badge badge-amber">high</span></td></tr>
+              <tr><td>天魔血脉觉醒征兆</td><td><span class="badge badge-blue">活跃</span></td><td>345</td><td>500</td><td><span class="badge badge-blue">normal</span></td></tr>
+              <tr><td>第一卷师门灭门线索</td><td><span class="badge badge-green">已回收</span></td><td>12</td><td>180</td><td>—</td></tr>
+            </tbody>
+          </table>
+        </div>
+        <div class="pager">
+          <button class="page-btn">上一页</button>
+          <span class="page-info">第 1 / 4 页 · 共 37 条</span>
+          <button class="page-btn">下一页</button>
+        </div>
+      </div>
+    </div>
+
+    <!-- ==================== PAGE 5: 文档浏览 ==================== -->
+    <div class="page" id="page-files">
+      <div class="page-header">
+        <h2>📁 文档浏览</h2>
+      </div>
+      <div class="proto-note">逻辑不变,从现有 App.jsx 迁移。左侧文件树 + 右侧内容预览。</div>
+      <div class="card" style="height:500px;display:flex;align-items:center;justify-content:center;">
+        <span style="font-size:40px;margin-right:12px">📂</span>
+        <span style="color:var(--text-mute);font-size:16px;font-weight:600">文件树 + 内容预览(直接迁移,无变化)</span>
+      </div>
+    </div>
+
+    <!-- ==================== PAGE 6: 系统状态 ==================== -->
+    <div class="page" id="page-system">
+      <div class="page-header">
+        <h2>⚙️ 系统状态</h2>
+      </div>
+
+      <div class="stat-grid">
+        <div class="card stat-card">
+          <span class="stat-label">Story Runtime</span>
+          <span class="stat-value plain">Mainline</span>
+          <span class="stat-sub">fallback: state.json, index.db</span>
+        </div>
+        <div class="card stat-card">
+          <span class="stat-label">Latest Commit</span>
+          <span class="stat-value plain">accepted</span>
+          <span class="stat-sub">第 412 章 · 5 路 projection OK</span>
+        </div>
+        <div class="card stat-card">
+          <span class="stat-label">RAG Mode</span>
+          <span class="stat-value" style="color:var(--accent-green)">full</span>
+          <span class="stat-sub">embed + rerank 就绪</span>
+        </div>
+        <div class="card stat-card">
+          <span class="stat-label">Vector DB</span>
+          <span class="stat-value plain">2,847</span>
+          <span class="stat-sub">条向量记录</span>
+        </div>
+      </div>
+
+      <!-- 合同树概览 -->
+      <div class="card">
+        <div class="card-header">
+          <span class="card-title">合同树概览</span>
+        </div>
+        <div class="table-wrap">
+          <table class="data-table">
+            <thead><tr><th>类型</th><th>数量</th><th>说明</th></tr></thead>
+            <tbody>
+              <tr><td>MASTER_SETTING</td><td><span class="badge badge-green">1</span></td><td>仙侠 · 沉稳厚重</td></tr>
+              <tr><td>VOLUME_BRIEF</td><td><span class="badge badge-blue">5</span></td><td>卷 1-5</td></tr>
+              <tr><td>CHAPTER_BRIEF</td><td><span class="badge badge-blue">412</span></td><td>全章</td></tr>
+              <tr><td>REVIEW_CONTRACT</td><td><span class="badge badge-purple">412</span></td><td>全章审查合同</td></tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <!-- 最近 Commit -->
+      <div class="card">
+        <div class="card-header">
+          <span class="card-title">最近 Commit 历史</span>
+        </div>
+        <div class="table-wrap">
+          <table class="data-table">
+            <thead><tr><th>章节</th><th>状态</th><th>state</th><th>index</th><th>summary</th><th>memory</th><th>dashboard</th></tr></thead>
+            <tbody>
+              <tr><td>第 412 章</td><td><span class="badge badge-green">accepted</span></td><td><span class="badge badge-green">OK</span></td><td><span class="badge badge-green">OK</span></td><td><span class="badge badge-green">OK</span></td><td><span class="badge badge-green">OK</span></td><td><span class="badge badge-green">OK</span></td></tr>
+              <tr><td>第 411 章</td><td><span class="badge badge-green">accepted</span></td><td><span class="badge badge-green">OK</span></td><td><span class="badge badge-green">OK</span></td><td><span class="badge badge-green">OK</span></td><td><span class="badge badge-green">OK</span></td><td><span class="badge badge-green">OK</span></td></tr>
+              <tr><td>第 410 章</td><td><span class="badge badge-red">rejected</span></td><td><span class="badge badge-amber">skip</span></td><td><span class="badge badge-amber">skip</span></td><td><span class="badge badge-amber">skip</span></td><td><span class="badge badge-amber">skip</span></td><td><span class="badge badge-amber">skip</span></td></tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+
+      <!-- RAG 诊断 -->
+      <div class="card">
+        <div class="card-header">
+          <span class="card-title">RAG 环境</span>
+          <button class="page-btn">运行诊断</button>
+        </div>
+        <div class="table-wrap">
+          <table class="data-table">
+            <thead><tr><th>组件</th><th>状态</th><th>详情</th></tr></thead>
+            <tbody>
+              <tr><td>Embedding Key</td><td><span class="badge badge-green">OK</span></td><td>VOYAGE_API_KEY 已配置</td></tr>
+              <tr><td>Rerank Key</td><td><span class="badge badge-green">OK</span></td><td>COHERE_API_KEY 已配置</td></tr>
+              <tr><td>Vector DB</td><td><span class="badge badge-green">OK</span></td><td>2,847 records · 128 MB</td></tr>
+              <tr><td>RAG Mode</td><td><span class="badge badge-green">full</span></td><td>embed + rerank</td></tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+    </div>
+
+  </main>
+</div>
+
+<script>
+// ===== NAV SWITCHING =====
+document.getElementById('nav').addEventListener('click', e => {
+  const btn = e.target.closest('.nav-item');
+  if (!btn) return;
+  document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
+  btn.classList.add('active');
+  document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
+  document.getElementById('page-' + btn.dataset.page).classList.add('active');
+  // re-render charts on page switch
+  setTimeout(() => window.dispatchEvent(new Event('resize')), 50);
+});
+
+// ===== ECHARTS PIXEL THEME =====
+const PIXEL_THEME = {
+  color: ['#26a8ff','#f5a524','#7f5af0','#2ec27e','#d7263d','#00b8d4','#ff5c8a'],
+  backgroundColor: 'transparent',
+  textStyle: { fontFamily: "'Noto Sans SC', sans-serif", color: '#2a220f' },
+  title: { textStyle: { fontFamily: "'Press Start 2P', monospace", fontSize: 11, color: '#2a220f' } },
+  legend: { textStyle: { fontSize: 13, fontWeight: 600, color: '#5d5035' } },
+  tooltip: {
+    backgroundColor: '#fffaf0',
+    borderColor: '#2a220f',
+    borderWidth: 2,
+    textStyle: { color: '#2a220f', fontSize: 13 },
+    extraCssText: 'border-radius:0;box-shadow:3px 3px 0 #2a220f;'
+  },
+  categoryAxis: {
+    axisLine: { lineStyle: { color: '#8f7f5c', width: 2 } },
+    axisTick: { lineStyle: { color: '#8f7f5c' } },
+    axisLabel: { color: '#8f7f5c', fontSize: 12 },
+    splitLine: { lineStyle: { color: '#e8dcc4', type: 'dashed' } }
+  },
+  valueAxis: {
+    axisLine: { lineStyle: { color: '#8f7f5c', width: 2 } },
+    axisTick: { lineStyle: { color: '#8f7f5c' } },
+    axisLabel: { color: '#8f7f5c', fontSize: 12 },
+    splitLine: { lineStyle: { color: '#e8dcc4', type: 'dashed' } }
+  },
+  grid: { left: 50, right: 20, top: 30, bottom: 40 }
+};
+echarts.registerTheme('pixel', PIXEL_THEME);
+
+function px(id, opt) {
+  const el = document.getElementById(id);
+  if (!el) return null;
+  const chart = echarts.init(el, 'pixel');
+  chart.setOption(opt);
+  window.addEventListener('resize', () => chart.resize());
+  return chart;
+}
+
+// ===== SAMPLE DATA =====
+const chapters50 = Array.from({length:50}, (_,i) => i+363);
+const reviewScores = [7.2,7.5,6.8,7.9,8.1,7.4,7.0,7.8,8.3,7.6,6.5,7.2,7.8,8.0,7.1,7.5,7.9,8.2,7.3,6.9,7.7,8.0,7.4,7.6,8.1,7.8,7.2,7.5,8.4,7.0,7.3,7.9,8.0,7.6,7.1,7.8,8.2,7.5,7.3,8.0,7.7,7.4,8.1,7.9,7.2,7.6,8.3,7.8,7.5,7.8];
+
+// 1. Review Score Line
+px('chart-review-score', {
+  xAxis: { type: 'category', data: chapters50.map(c => '第'+c+'章'), axisLabel: { interval: 9 } },
+  yAxis: { type: 'value', min: 5, max: 10 },
+  series: [{
+    type: 'line', data: reviewScores, smooth: false, step: false,
+    lineStyle: { width: 3, color: '#26a8ff' },
+    itemStyle: { color: '#26a8ff', borderColor: '#2a220f', borderWidth: 2 },
+    symbol: 'rect', symbolSize: 8,
+    markLine: { data: [{ type: 'average', label: { formatter: '均值 {c}' } }], lineStyle: { color: '#f5a524', width: 2, type: 'dashed' } },
+    areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: 'rgba(38,168,255,.2)' }, { offset: 1, color: 'rgba(38,168,255,0)' }] } }
+  }]
+});
+
+// 2. Word Distribution by Volume
+px('chart-word-dist', {
+  xAxis: { type: 'category', data: ['卷一\n求道篇','卷二\n筑基篇','卷三\n历练篇','卷四\n金丹篇','卷五\n元婴篇'] },
+  yAxis: { type: 'value', axisLabel: { formatter: v => (v/10000).toFixed(0)+'万' } },
+  series: [{
+    type: 'bar', data: [
+      { value: 280000, itemStyle: { color: '#26a8ff' } },
+      { value: 310000, itemStyle: { color: '#7f5af0' } },
+      { value: 265000, itemStyle: { color: '#2ec27e' } },
+      { value: 290000, itemStyle: { color: '#f5a524' } },
+      { value: 141000, itemStyle: { color: '#00b8d4' } }
+    ],
+    barWidth: '50%',
+    label: { show: true, position: 'top', formatter: p => (p.value/10000).toFixed(1)+'万', fontSize: 12, fontWeight: 700 },
+    itemStyle: { borderColor: '#2a220f', borderWidth: 2 }
+  }]
+});
+
+// 3. Strand Overview Pie
+px('chart-strand-overview', {
+  legend: { bottom: 0, data: ['Quest','Fire','Constellation'] },
+  series: [{
+    type: 'pie', radius: ['40%','70%'], center: ['50%','45%'],
+    data: [
+      { value: 145, name: 'Quest', itemStyle: { color: '#26a8ff', borderColor: '#2a220f', borderWidth: 2 } },
+      { value: 128, name: 'Fire', itemStyle: { color: '#ff5c8a', borderColor: '#2a220f', borderWidth: 2 } },
+      { value: 139, name: 'Constellation', itemStyle: { color: '#7f5af0', borderColor: '#2a220f', borderWidth: 2 } }
+    ],
+    label: { formatter: '{b}\n{d}%', fontSize: 13, fontWeight: 600 },
+    itemStyle: { borderColor: '#2a220f', borderWidth: 2 }
+  }]
+});
+
+// 4. Relation Graph with Timeline
+const graphNodes = [
+  { name: '林长青', category: 0, appear: 1,   symbolSize: [70,35], itemStyle: { color: '#f5a524' } },
+  { name: '老道士', category: 0, appear: 1 },
+  { name: '太虚宗', category: 1, appear: 5 },
+  { name: '凤灵儿', category: 0, appear: 28 },
+  { name: '初代掌门遗物', category: 2, appear: 45 },
+  { name: '青元秘境', category: 2, appear: 80 },
+  { name: '白玉京', category: 0, appear: 120 },
+  { name: '天魔教', category: 1, appear: 150 },
+  { name: '东海仙城', category: 2, appear: 220 },
+  { name: '龙脉封印', category: 2, appear: 260 },
+  { name: '黑市掮客', category: 0, appear: 310 },
+  { name: '剑灵', category: 0, appear: 380 }
+];
+const graphLinks = [
+  { source: '林长青', target: '老道士', name: '师徒', appear: 1 },
+  { source: '老道士', target: '太虚宗', name: '前长老', appear: 5 },
+  { source: '林长青', target: '太虚宗', name: '入门', appear: 12 },
+  { source: '林长青', target: '凤灵儿', name: '初识', appear: 28, changeTo: '师兄妹', changeAt: 60 },
+  { source: '林长青', target: '初代掌门遗物', name: '获得', appear: 50 },
+  { source: '太虚宗', target: '青元秘境', name: '管辖', appear: 80 },
+  { source: '林长青', target: '青元秘境', name: '历练', appear: 85 },
+  { source: '凤灵儿', target: '太虚宗', name: '弟子', appear: 35 },
+  { source: '林长青', target: '白玉京', name: '初遇', appear: 120, changeTo: '宿敌', changeAt: 200 },
+  { source: '白玉京', target: '天魔教', name: '加入', appear: 180, changeTo: '长老', changeAt: 300 },
+  { source: '太虚宗', target: '天魔教', name: '敌对', appear: 200 },
+  { source: '老道士', target: '东海仙城', name: '隐居', appear: 220 },
+  { source: '林长青', target: '东海仙城', name: '造访', appear: 235 },
+  { source: '林长青', target: '龙脉封印', name: '发现', appear: 260 },
+  { source: '黑市掮客', target: '天魔教', name: '线人', appear: 320 },
+  { source: '林长青', target: '黑市掮客', name: '交易', appear: 330 },
+  { source: '林长青', target: '剑灵', name: '契约', appear: 385 },
+  { source: '剑灵', target: '初代掌门遗物', name: '寄宿', appear: 390 }
+];
+const graphCategories = [
+  { name: '角色', itemStyle: { color: '#26a8ff' } },
+  { name: '势力', itemStyle: { color: '#7f5af0' } },
+  { name: '地点', itemStyle: { color: '#2ec27e' } }
+];
+
+function getGraphDataAtChapter(ch) {
+  const nodes = graphNodes
+    .filter(n => n.appear <= ch)
+    .map(n => ({
+      ...n,
+      symbol: 'rect',
+      symbolSize: n.symbolSize || [60, 30],
+      label: { show: true, fontSize: 12, fontWeight: 700, color: '#fff' },
+      itemStyle: { ...(n.itemStyle || {}), borderColor: '#2a220f', borderWidth: 2 }
+    }));
+  const nodeNames = new Set(nodes.map(n => n.name));
+  const links = graphLinks
+    .filter(l => l.appear <= ch && nodeNames.has(l.source) && nodeNames.has(l.target))
+    .map(l => ({
+      source: l.source,
+      target: l.target,
+      name: (l.changeTo && ch >= l.changeAt) ? l.changeTo : l.name
+    }));
+  return { nodes, links };
+}
+
+const graphEl = document.getElementById('chart-relation-graph');
+const graphChart = echarts.init(graphEl, 'pixel');
+const slider = document.getElementById('graph-timeline');
+const chLabel = document.getElementById('graph-chapter-label');
+const nodeCount = document.getElementById('graph-node-count');
+const playBtn = document.getElementById('graph-play-btn');
+let playing = false, playTimer = null;
+
+function renderGraph(ch) {
+  const { nodes, links } = getGraphDataAtChapter(ch);
+  chLabel.textContent = '第 ' + ch + ' 章';
+  nodeCount.textContent = nodes.length + ' 人';
+  graphChart.setOption({
+    animationDuration: 300,
+    animationEasingUpdate: 'cubicOut',
+    series: [{
+      type: 'graph', layout: 'force', roam: true,
+      symbol: 'rect',
+      edgeLabel: { show: true, fontSize: 11, formatter: p => p.data.name, color: '#5d5035' },
+      force: { repulsion: 350, edgeLength: [120, 200], gravity: 0.1 },
+      lineStyle: { color: '#8f7f5c', width: 2, curveness: 0.1 },
+      categories: graphCategories,
+      nodes: nodes,
+      links: links
+    }]
+  });
+}
+
+slider.addEventListener('input', () => {
+  renderGraph(parseInt(slider.value));
+});
+
+playBtn.addEventListener('click', () => {
+  if (playing) {
+    playing = false;
+    clearInterval(playTimer);
+    playBtn.textContent = '▶ 播放';
+  } else {
+    playing = true;
+    playBtn.textContent = '⏸ 暂停';
+    if (parseInt(slider.value) >= 412) slider.value = 1;
+    playTimer = setInterval(() => {
+      let v = parseInt(slider.value) + 5;
+      if (v > 412) { v = 412; playing = false; clearInterval(playTimer); playBtn.textContent = '▶ 播放'; }
+      slider.value = v;
+      renderGraph(v);
+    }, 120);
+  }
+});
+
+renderGraph(412);
+window.addEventListener('resize', () => graphChart.resize());
+
+// 5. Hook Strength Area
+const hookValues = [3,4,3,5,4,3,2,5,4,3,4,5,3,4,5,4,3,5,4,3,2,4,5,3,4,5,4,3,5,4,3,4,5,3,4,2,5,4,3,5,4,3,5,4,3,4,5,4,3,4];
+px('chart-hook-strength', {
+  xAxis: { type: 'category', data: chapters50.map(c => c+''), axisLabel: { interval: 9 } },
+  yAxis: { type: 'value', min: 0, max: 5, axisLabel: { formatter: v => ['','weak','','medium','','strong'][v] || '' } },
+  series: [{
+    type: 'line', data: hookValues, smooth: false,
+    lineStyle: { width: 3, color: '#f5a524' },
+    itemStyle: { color: '#f5a524', borderColor: '#2a220f', borderWidth: 2 },
+    symbol: 'rect', symbolSize: 6,
+    areaStyle: { color: { type: 'linear', x:0,y:0,x2:0,y2:1, colorStops: [{offset:0,color:'rgba(245,165,36,.3)'},{offset:1,color:'rgba(245,165,36,0)'}] } }
+  }]
+});
+
+// 6. Strand Stack Bar
+const strandChapters = chapters50.map(c => c+'');
+const questData = chapters50.map(() => Math.floor(Math.random()*3)+1);
+const fireData = chapters50.map(() => Math.floor(Math.random()*3)+1);
+const constData = chapters50.map(() => Math.floor(Math.random()*3)+1);
+px('chart-strand-stack', {
+  legend: { data: ['Quest','Fire','Constellation'], bottom: 0 },
+  xAxis: { type: 'category', data: strandChapters, axisLabel: { interval: 9 } },
+  yAxis: { type: 'value' },
+  series: [
+    { name: 'Quest', type: 'bar', stack: 'strand', data: questData, itemStyle: { color: '#26a8ff', borderColor: '#2a220f', borderWidth: 1 }, barWidth: '60%' },
+    { name: 'Fire', type: 'bar', stack: 'strand', data: fireData, itemStyle: { color: '#ff5c8a', borderColor: '#2a220f', borderWidth: 1 } },
+    { name: 'Constellation', type: 'bar', stack: 'strand', data: constData, itemStyle: { color: '#7f5af0', borderColor: '#2a220f', borderWidth: 1 } }
+  ]
+});
+
+// 7. Pacing Words by Volume
+const vol1 = Array.from({length:80}, () => 2800+Math.random()*1200);
+const vol2 = Array.from({length:90}, () => 3000+Math.random()*1500);
+const vol3 = Array.from({length:75}, () => 2500+Math.random()*1800);
+const vol4 = Array.from({length:85}, () => 3200+Math.random()*1200);
+const vol5 = Array.from({length:82}, () => 2600+Math.random()*1600);
+function volBoxData(arr) {
+  const s = [...arr].sort((a,b)=>a-b);
+  return [s[0], s[Math.floor(s.length*.25)], s[Math.floor(s.length*.5)], s[Math.floor(s.length*.75)], s[s.length-1]].map(Math.round);
+}
+px('chart-pacing-words', {
+  xAxis: { type: 'category', data: ['卷一','卷二','卷三','卷四','卷五'] },
+  yAxis: { type: 'value', axisLabel: { formatter: v => (v/1000).toFixed(0)+'k' } },
+  series: [{
+    type: 'boxplot',
+    data: [volBoxData(vol1), volBoxData(vol2), volBoxData(vol3), volBoxData(vol4), volBoxData(vol5)],
+    itemStyle: { color: '#fffaf0', borderColor: '#26a8ff', borderWidth: 2 }
+  }]
+});
+
+// 8. Foreshadowing Gantt
+const foreshadowData = [
+  { name: '青元秘境钥匙碎片', start: 285, end: 350, status: 'overdue' },
+  { name: '凤灵儿真实身份', start: 312, end: 420, status: 'urgent' },
+  { name: '老道士遗言数字', start: 356, end: 430, status: 'urgent' },
+  { name: '黑市幕后势力', start: 389, end: 440, status: 'urgent' },
+  { name: '功法异变原因', start: 401, end: 500, status: 'active' },
+  { name: '天魔血脉觉醒', start: 345, end: 500, status: 'active' },
+  { name: '仙城禁地秘密', start: 220, end: 480, status: 'active' },
+  { name: '师门灭门线索', start: 12, end: 180, status: 'resolved' },
+  { name: '初代掌门遗物', start: 45, end: 150, status: 'resolved' },
+  { name: '龙脉封印', start: 100, end: 260, status: 'resolved' }
+];
+const statusColor = { overdue: '#d7263d', urgent: '#f5a524', active: '#26a8ff', resolved: '#2ec27e' };
+const yLabels = foreshadowData.map(d => d.name);
+
+px('chart-foreshadow-gantt', {
+  grid: { left: 140, right: 30, top: 10, bottom: 40 },
+  xAxis: { type: 'value', min: 0, max: 520, axisLabel: { formatter: v => '第'+v+'章' }, splitLine: { lineStyle: { color: '#e8dcc4', type: 'dashed' } } },
+  yAxis: { type: 'category', data: yLabels, inverse: true, axisLabel: { fontSize: 12, fontWeight: 600 } },
+  series: [
+    {
+      type: 'custom',
+      renderItem: function(params, api) {
+        const catIdx = api.value(0);
+        const start = api.coord([api.value(1), catIdx]);
+        const end = api.coord([api.value(2), catIdx]);
+        const height = api.size([0, 1])[1] * 0.5;
+        return {
+          type: 'rect',
+          shape: { x: start[0], y: start[1] - height/2, width: end[0] - start[0], height: height },
+          style: { fill: api.value(3), stroke: '#2a220f', lineWidth: 2 }
+        };
+      },
+      encode: { x: [1, 2], y: 0 },
+      data: foreshadowData.map((d, i) => [i, d.start, d.end, statusColor[d.status]])
+    },
+    {
+      type: 'line', z: 10,
+      markLine: {
+        silent: true, symbol: 'none',
+        lineStyle: { color: '#26a8ff', width: 3, type: 'solid' },
+        data: [{ xAxis: 412 }],
+        label: { formatter: '当前 412章', position: 'end', fontSize: 11, fontWeight: 700, color: '#26a8ff' }
+      },
+      data: []
+    }
+  ]
+});
+</script>
+</body>
+</html>

+ 64 - 26
docs/architecture/system-architecture-diagram.md → docs/architecture/system-architecture-diagram.html

@@ -1,19 +1,55 @@
-# Webnovel Writer 系统架构总图
-
-> 生成日期:2026-04-15 | 覆盖范围:init → plan → write 全链路六层主链 + 读写映射
-
-<div style="width: 1280px; box-sizing: border-box; position: relative; background: #f5f3ff; padding: 20px; border-radius: 8px; border: 1px solid #c7c2ea;">
-  <style scoped>
-    .arch-wrapper { display: flex; gap: 12px; }.arch-sidebar { width: 175px; flex-shrink: 0; }.arch-main { flex: 1; min-width: 0; }.arch-title { text-align: center; font-size: 22px; font-weight: bold; color: #312e81; margin-bottom: 4px; }.arch-subtitle { text-align: center; font-size: 12px; color: #6366f1; margin-bottom: 14px; }
-    .arch-layer { margin: 7px 0; padding: 12px; border-radius: 6px; box-shadow: 0 2px 8px rgba(67, 56, 202, 0.06); }.arch-layer-title { font-size: 13px; font-weight: bold; margin-bottom: 8px; text-align: center; }
-    .arch-grid { display: grid; gap: 6px; }.arch-grid-2 { grid-template-columns: repeat(2, 1fr); }.arch-grid-3 { grid-template-columns: repeat(3, 1fr); }.arch-grid-4 { grid-template-columns: repeat(4, 1fr); }.arch-grid-5 { grid-template-columns: repeat(5, 1fr); }.arch-grid-6 { grid-template-columns: repeat(6, 1fr); }
-    .arch-box { border-radius: 5px; padding: 7px; text-align: center; font-size: 10.5px; font-weight: 600; line-height: 1.3; color: #312e81; background: rgba(255, 255, 255, 0.85); border: 1px solid #c7d2fe; }.arch-box.highlight { background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%); border: 2px solid #4f46e5; }.arch-box.tech { font-size: 10px; color: #4338ca; background: rgba(238, 242, 255, 0.8); }.arch-box.write { border-left: 3px solid #dc2626; }.arch-box.read { border-left: 3px solid #16a34a; }.arch-box.rw { border-left: 3px solid #d97706; }
-    .arch-layer.user { background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); border: 2px solid #3b82f6; }.arch-layer.user .arch-layer-title { color: #1e40af; }.arch-layer.application { background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%); border: 2px solid #4f46e5; }.arch-layer.application .arch-layer-title { color: #3730a3; }.arch-layer.ai { background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%); border: 2px solid #7c3aed; }.arch-layer.ai .arch-layer-title { color: #5b21b6; }.arch-layer.data { background: linear-gradient(135deg, #f3e8ff 0%, #e9d5ff 100%); border: 2px solid #9333ea; }.arch-layer.data .arch-layer-title { color: #7e22ce; }.arch-layer.infra { background: linear-gradient(135deg, #fae8ff 0%, #f5d0fe 100%); border: 2px solid #a855f7; }.arch-layer.infra .arch-layer-title { color: #86198f; }.arch-layer.external { background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); border: 2px dashed #94a3b8; }.arch-layer.external .arch-layer-title { color: #64748b; }
-    .arch-sidebar-panel { border-radius: 6px; padding: 10px; background: linear-gradient(135deg, #eef2ff 0%, #e0e7ff 100%); border: 2px solid #a5b4fc; margin-bottom: 7px; box-shadow: 0 1px 3px rgba(67, 56, 202, 0.04); }.arch-sidebar-title { font-size: 11px; font-weight: bold; text-align: center; color: #312e81; margin-bottom: 5px; }.arch-sidebar-item { font-size: 9.5px; text-align: center; color: #3730a3; background: rgba(255, 255, 255, 0.8); padding: 4px; border-radius: 4px; margin: 3px 0; border: 1px solid #c7d2fe; }.arch-sidebar-item.metric { background: #e0e7ff; border: 1px solid #6366f1; color: #3730a3; font-weight: 600; }
-    .arch-subgroup { display: flex; gap: 6px; margin-top: 6px; }.arch-subgroup-box { flex: 1; border-radius: 6px; padding: 7px; background: rgba(255, 255, 255, 0.5); border: 1px solid rgba(0, 0, 0, 0.08); }.arch-subgroup-title { font-size: 10px; font-weight: bold; color: #374151; text-align: center; margin-bottom: 5px; }
-    .arch-flow { display: flex; align-items: center; justify-content: center; gap: 8px; margin: 6px 0; font-size: 11px; color: #4f46e5; font-weight: 600; }.arch-arrow { font-size: 16px; color: #818cf8; }
-    .legend { display: flex; gap: 14px; justify-content: center; margin: 8px 0; font-size: 10px; color: #4b5563; }.legend-item { display: flex; align-items: center; gap: 4px; }.legend-bar { width: 14px; height: 10px; border-radius: 2px; }
-  </style>
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Webnovel Writer 系统架构</title>
+<style>
+body { margin: 20px; background: #f0eef6; font-family: -apple-system, "Microsoft YaHei", sans-serif; }
+.arch-wrapper { display: flex; gap: 12px; }
+.arch-sidebar { width: 175px; flex-shrink: 0; }
+.arch-main { flex: 1; min-width: 0; }
+.arch-title { text-align: center; font-size: 22px; font-weight: bold; color: #312e81; margin-bottom: 4px; }
+.arch-subtitle { text-align: center; font-size: 12px; color: #6366f1; margin-bottom: 14px; }
+.arch-layer { margin: 7px 0; padding: 12px; border-radius: 6px; box-shadow: 0 2px 8px rgba(67,56,202,0.06); }
+.arch-layer-title { font-size: 13px; font-weight: bold; margin-bottom: 8px; text-align: center; }
+.arch-grid { display: grid; gap: 6px; }
+.arch-grid-2 { grid-template-columns: repeat(2,1fr); }
+.arch-grid-3 { grid-template-columns: repeat(3,1fr); }
+.arch-grid-4 { grid-template-columns: repeat(4,1fr); }
+.arch-grid-5 { grid-template-columns: repeat(5,1fr); }
+.arch-box { border-radius: 5px; padding: 7px; text-align: center; font-size: 10.5px; font-weight: 600; line-height: 1.3; color: #312e81; background: rgba(255,255,255,0.85); border: 1px solid #c7d2fe; }
+.arch-box.highlight { background: linear-gradient(135deg,#e0e7ff,#c7d2fe); border: 2px solid #4f46e5; }
+.arch-box.tech { font-size: 10px; color: #4338ca; background: rgba(238,242,255,0.8); }
+.arch-box.write { border-left: 3px solid #dc2626; }
+.arch-box.read { border-left: 3px solid #16a34a; }
+.arch-box.rw { border-left: 3px solid #d97706; }
+.arch-layer.user { background: linear-gradient(135deg,#dbeafe,#bfdbfe); border: 2px solid #3b82f6; }
+.arch-layer.user .arch-layer-title { color: #1e40af; }
+.arch-layer.application { background: linear-gradient(135deg,#e0e7ff,#c7d2fe); border: 2px solid #4f46e5; }
+.arch-layer.application .arch-layer-title { color: #3730a3; }
+.arch-layer.ai { background: linear-gradient(135deg,#ede9fe,#ddd6fe); border: 2px solid #7c3aed; }
+.arch-layer.ai .arch-layer-title { color: #5b21b6; }
+.arch-layer.data { background: linear-gradient(135deg,#f3e8ff,#e9d5ff); border: 2px solid #9333ea; }
+.arch-layer.data .arch-layer-title { color: #7e22ce; }
+.arch-layer.infra { background: linear-gradient(135deg,#fae8ff,#f5d0fe); border: 2px solid #a855f7; }
+.arch-layer.infra .arch-layer-title { color: #86198f; }
+.arch-layer.external { background: linear-gradient(135deg,#f1f5f9,#e2e8f0); border: 2px dashed #94a3b8; }
+.arch-layer.external .arch-layer-title { color: #64748b; }
+.arch-sidebar-panel { border-radius: 6px; padding: 10px; background: linear-gradient(135deg,#eef2ff,#e0e7ff); border: 2px solid #a5b4fc; margin-bottom: 7px; box-shadow: 0 1px 3px rgba(67,56,202,0.04); }
+.arch-sidebar-title { font-size: 11px; font-weight: bold; text-align: center; color: #312e81; margin-bottom: 5px; }
+.arch-sidebar-item { font-size: 9.5px; text-align: center; color: #3730a3; background: rgba(255,255,255,0.8); padding: 4px; border-radius: 4px; margin: 3px 0; border: 1px solid #c7d2fe; }
+.arch-sidebar-item.metric { background: #e0e7ff; border: 1px solid #6366f1; color: #3730a3; font-weight: 600; }
+.arch-subgroup { display: flex; gap: 6px; margin-top: 6px; }
+.arch-subgroup-box { flex: 1; border-radius: 6px; padding: 7px; background: rgba(255,255,255,0.5); border: 1px solid rgba(0,0,0,0.08); }
+.arch-subgroup-title { font-size: 10px; font-weight: bold; color: #374151; text-align: center; margin-bottom: 5px; }
+.legend { display: flex; gap: 14px; justify-content: center; margin: 8px 0; font-size: 10px; color: #4b5563; }
+.legend-item { display: flex; align-items: center; gap: 4px; }
+.legend-bar { width: 14px; height: 10px; border-radius: 2px; }
+</style>
+</head>
+<body>
+<div style="width: 1280px; margin: 0 auto; background: #f5f3ff; padding: 20px; border-radius: 8px; border: 1px solid #c7c2ea;">
   <div class="arch-title">📖 Webnovel Writer 系统架构</div>
   <div class="arch-title">📖 Webnovel Writer 系统架构</div>
   <div class="arch-subtitle">init → plan → write 六层主链 | 全读写映射</div>
   <div class="arch-subtitle">init → plan → write 六层主链 | 全读写映射</div>
   <div class="legend">
   <div class="legend">
@@ -95,14 +131,14 @@
           <div class="arch-box tech">金手指与设定.csv<br><small>基础表(Base)</small></div>
           <div class="arch-box tech">金手指与设定.csv<br><small>基础表(Base)</small></div>
           <div class="arch-box tech">桥段套路.csv<br><small>动态表(Dynamic)</small></div>
           <div class="arch-box tech">桥段套路.csv<br><small>动态表(Dynamic)</small></div>
           <div class="arch-box tech">爽点与节奏.csv<br><small>动态表(Dynamic)</small></div>
           <div class="arch-box tech">爽点与节奏.csv<br><small>动态表(Dynamic)</small></div>
-          <div class="arch-box">CSV_CONFIG<br><small>per-table 注册<br>search_cols/output_cols<br>poison_col/role</small></div>
+          <div class="arch-box">CSV_CONFIG<br><small>per-table 注册<br>search_cols / output_cols<br>poison_col / role</small></div>
         </div>
         </div>
       </div>
       </div>
       <div class="arch-layer ai">
       <div class="arch-layer ai">
         <div class="arch-layer-title">🧠 Layer 2: 裁决层(Reasoning)— story_system_engine.py</div>
         <div class="arch-layer-title">🧠 Layer 2: 裁决层(Reasoning)— story_system_engine.py</div>
         <div class="arch-grid arch-grid-4">
         <div class="arch-grid arch-grid-4">
-          <div class="arch-box read">_route()<br><small>Read: 题材与调性推理.csv<br>匹配题材→推荐表</small></div>
-          <div class="arch-box read">_collect_tables()<br><small>Read: 基础表+动态表<br>BM25 检索</small></div>
+          <div class="arch-box read">_route()<br><small>Read: 题材与调性推理.csv<br>匹配题材  推荐表</small></div>
+          <div class="arch-box read">_collect_tables()<br><small>Read: 基础表 + 动态表<br>BM25 检索</small></div>
           <div class="arch-box highlight">_load_reasoning()<br><small>Read: 裁决规则.csv<br>别名匹配(修仙→东方仙侠)<br>fallback 到原始 genre</small></div>
           <div class="arch-box highlight">_load_reasoning()<br><small>Read: 裁决规则.csv<br>别名匹配(修仙→东方仙侠)<br>fallback 到原始 genre</small></div>
           <div class="arch-box highlight">_apply_reasoning()<br><small>冲突裁决排序<br>毒点加权<br>反模式注入</small></div>
           <div class="arch-box highlight">_apply_reasoning()<br><small>冲突裁决排序<br>毒点加权<br>反模式注入</small></div>
         </div>
         </div>
@@ -122,7 +158,7 @@
           <div class="arch-subgroup-box">
           <div class="arch-subgroup-box">
             <div class="arch-subgroup-title">写后真源(chapter-commit)</div>
             <div class="arch-subgroup-title">写后真源(chapter-commit)</div>
             <div class="arch-grid arch-grid-2">
             <div class="arch-grid arch-grid-2">
-              <div class="arch-box highlight write">CHAPTER_COMMIT<br><small>Write: accepted/rejected<br>events + deltas + summary</small></div>
+              <div class="arch-box highlight write">CHAPTER_COMMIT<br><small>Write: accepted / rejected<br>events + deltas + summary</small></div>
               <div class="arch-box">anti_patterns.json<br><small>Write: 题材毒点集合</small></div>
               <div class="arch-box">anti_patterns.json<br><small>Write: 题材毒点集合</small></div>
             </div>
             </div>
           </div>
           </div>
@@ -134,7 +170,7 @@
           <div class="arch-subgroup-box">
           <div class="arch-subgroup-box">
             <div class="arch-subgroup-title">context-agent(Step 1 子代理)</div>
             <div class="arch-subgroup-title">context-agent(Step 1 子代理)</div>
             <div class="arch-grid arch-grid-4">
             <div class="arch-grid arch-grid-4">
-              <div class="arch-box read">load-context<br><small>Read: contracts<br>summaries/protagonist<br>rules/loops/memory_pack<br>genre_profile_excerpt</small></div>
+              <div class="arch-box read">load-context<br><small>Read: contracts<br>summaries / protagonist<br>rules / loops / memory_pack<br>genre_profile_excerpt</small></div>
               <div class="arch-box read">Read 章纲原文<br><small>大纲/第X卷-详细大纲.md<br>(最高权重)</small></div>
               <div class="arch-box read">Read 章纲原文<br><small>大纲/第X卷-详细大纲.md<br>(最高权重)</small></div>
               <div class="arch-box read">按需深查<br><small>query-entity<br>query-rules<br>get-timeline<br>get-reader-signals</small></div>
               <div class="arch-box read">按需深查<br><small>query-entity<br>query-rules<br>get-timeline<br>get-reader-signals</small></div>
               <div class="arch-box highlight write">输出: 写作任务书<br><small>五段格式<br>消费 reasoning 裁决<br>内化 Anti-AI 铁律</small></div>
               <div class="arch-box highlight write">输出: 写作任务书<br><small>五段格式<br>消费 reasoning 裁决<br>内化 Anti-AI 铁律</small></div>
@@ -143,7 +179,7 @@
           <div class="arch-subgroup-box">
           <div class="arch-subgroup-box">
             <div class="arch-subgroup-title">辅助查询</div>
             <div class="arch-subgroup-title">辅助查询</div>
             <div class="arch-grid arch-grid-3">
             <div class="arch-grid arch-grid-3">
-              <div class="arch-box tech read">context_manager<br><small>纯 JSON 组装器<br>Read: contracts/state<br>runtime/guidance</small></div>
+              <div class="arch-box tech read">context_manager<br><small>纯 JSON 组装器<br>Read: contracts / state<br>runtime / guidance</small></div>
               <div class="arch-box tech read">knowledge_query<br><small>时序查询<br>entity_state_at_chapter<br>relationships_at_chapter</small></div>
               <div class="arch-box tech read">knowledge_query<br><small>时序查询<br>entity_state_at_chapter<br>relationships_at_chapter</small></div>
               <div class="arch-box tech read">reference_search<br><small>CSV BM25 检索<br>per-table search_cols</small></div>
               <div class="arch-box tech read">reference_search<br><small>CSV BM25 检索<br>per-table search_cols</small></div>
             </div>
             </div>
@@ -166,7 +202,7 @@
             <div class="arch-subgroup-title">chapter-commit CLI(Step 5.2)</div>
             <div class="arch-subgroup-title">chapter-commit CLI(Step 5.2)</div>
             <div class="arch-grid arch-grid-3">
             <div class="arch-grid arch-grid-3">
               <div class="arch-box read">Read 4份 artifacts<br><small>tmp/*.json</small></div>
               <div class="arch-box read">Read 4份 artifacts<br><small>tmp/*.json</small></div>
-              <div class="arch-box highlight">判定 accepted/rejected<br><small>blocking_count=0<br>missed_nodes=空<br>pending=空</small></div>
+              <div class="arch-box highlight">判定 accepted / rejected<br><small>blocking_count = 0<br>missed_nodes = 空<br>pending = 空</small></div>
               <div class="arch-box write">Write commit.json<br><small>→ 触发投影链</small></div>
               <div class="arch-box write">Write commit.json<br><small>→ 触发投影链</small></div>
             </div>
             </div>
           </div>
           </div>
@@ -175,11 +211,11 @@
       <div class="arch-layer infra">
       <div class="arch-layer infra">
         <div class="arch-layer-title">💿 Layer 6: 投影层(Projection)— EventProjectionRouter → 5 Writers</div>
         <div class="arch-layer-title">💿 Layer 6: 投影层(Projection)— EventProjectionRouter → 5 Writers</div>
         <div class="arch-grid arch-grid-5">
         <div class="arch-grid arch-grid-5">
-          <div class="arch-box write">state_projection<br><small>Write: state.json<br>entity_state<br>chapter_status<br>(committed/rejected)</small></div>
+          <div class="arch-box write">state_projection<br><small>Write: state.json<br>entity_state<br>chapter_status<br>(committed / rejected)</small></div>
           <div class="arch-box write">index_projection<br><small>Write: index.db<br>entity_deltas<br>relationships</small></div>
           <div class="arch-box write">index_projection<br><small>Write: index.db<br>entity_deltas<br>relationships</small></div>
           <div class="arch-box write">summary_projection<br><small>Write: summaries/<br>chNNNN.md<br>剧情摘要</small></div>
           <div class="arch-box write">summary_projection<br><small>Write: summaries/<br>chNNNN.md<br>剧情摘要</small></div>
           <div class="arch-box write">memory_projection<br><small>Write: memory_<br>scratchpad.json<br>长期记忆事实</small></div>
           <div class="arch-box write">memory_projection<br><small>Write: memory_<br>scratchpad.json<br>长期记忆事实</small></div>
-          <div class="arch-box write">vector_projection<br><small>Write: vector_db<br>event→text→embed<br>delta→text→embed</small></div>
+          <div class="arch-box write">vector_projection<br><small>Write: vector_db<br>event  text  embed<br>delta  text  embed</small></div>
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>
@@ -217,10 +253,12 @@
       <div class="arch-sidebar-panel">
       <div class="arch-sidebar-panel">
         <div class="arch-sidebar-title">🛡️ 写入保护</div>
         <div class="arch-sidebar-title">🛡️ 写入保护</div>
         <div class="arch-sidebar-item metric">唯一写后入口: COMMIT</div>
         <div class="arch-sidebar-item metric">唯一写后入口: COMMIT</div>
-        <div class="arch-sidebar-item">state/index/summary/memory<br>只由 projection 写入</div>
+        <div class="arch-sidebar-item">state / index / summary / memory<br>只由 projection 写入</div>
         <div class="arch-sidebar-item">skill 不直接 set-chapter-status</div>
         <div class="arch-sidebar-item">skill 不直接 set-chapter-status</div>
         <div class="arch-sidebar-item">data-agent 不直写存储</div>
         <div class="arch-sidebar-item">data-agent 不直写存储</div>
       </div>
       </div>
     </div>
     </div>
   </div>
   </div>
 </div>
 </div>
+</body>
+</html>

+ 652 - 0
docs/research/2026-04-14-ui-ux-pro-max-skill-architecture-research.md

@@ -0,0 +1,652 @@
+# UI/UX Pro Max Skill 架构调研报告
+
+> 文档状态:`draft`(2026-04-14)
+
+## 文档目标
+
+本文档调研 `ui-ux-pro-max` skill 的真实工程结构,重点回答四个问题:
+
+- 它不是一个单文件 prompt,那它到底是什么结构
+- 它为什么能稳定工作,而不是只靠文案堆砌
+- 其中哪些思想和架构值得 `webnovel-writer` 学习
+- 哪些做法可以借鉴,哪些不能直接照搬
+
+说明:
+
+- 本次调研基于本地源码目录  
+  `C:\Users\lcy\.gemini\tmp\webnovel-writer\ui-ux-pro-max-skill\src\ui-ux-pro-max`
+- 调研目标不是做逐文件复述,而是提炼其可迁移的系统设计
+- 结论将服务于后续 `references/csv` 与 `story-system` 的收束型 spec
+
+## 一句话结论
+
+`ui-ux-pro-max` 能工作的核心,不是”提示词写得长”,而是它把 skill 做成了一个 **外置知识库 + 通用检索内核 + 上层推理聚合器 + 持久化主文件/覆盖文件 + 平台分发适配** 的小型知识系统。
+
+我们真正缺的不是聚合器(`StorySystemEngine` 已经承担了这个角色),而是一套**显式、结构化、可审查的裁决层**——当前很多裁决逻辑仍散落在 engine 代码、CSV、context_manager 和 skill 文本里,还没有被收束成独立的配置层。
+
+对我们最值得学习的,不是 UI 数据本身,而是这五个架构动作:
+
+1. 把知识从 prompt 文本里拆到结构化表
+2. 用统一检索 primitive 查询不同知识域
+3. 用显式 reasoning 层把”查到什么”变成”最后该怎么裁决”
+4. 把运行时结果落成 `Master + Override` 层级真源
+5. 把平台差异收束到模板/元数据层,而不是污染主知识层
+
+但有一个关键区别必须前置:`ui-ux-pro-max` 本质上是一套”查询时聚合”的准静态知识系统,它的知识域(风格、配色、字体、技术栈 best practices)不会随运行时事件演进。而我们的故事系统会——角色状态、关系、设定、世界规则都随章节提交持续变化。这意味着即使完整复刻它的前四层,仍然还要额外解决 `Chapter Commit Layer` 与 `Projection Layer` 的运行时演进问题。
+
+## 调研范围
+
+本次重点查看了以下内容:
+
+- `scripts/core.py`
+- `scripts/design_system.py`
+- `scripts/search.py`
+- `data/*.csv`
+- `data/stacks/*.csv`
+- `data/_sync_all.py`
+- `templates/base/*.md`
+- `templates/platforms/*.json`
+
+## 它的真实系统分层
+
+从目录结构看,`ui-ux-pro-max` 至少有三层物理结构,加一条独立运行路径:
+
+```text
+ui-ux-pro-max/
+├── data/        # 结构化知识库
+├── scripts/     # 检索、推理、聚合、持久化
+└── templates/   # skill 内容模板、平台安装元数据
+```
+
+运行路径则是:
+
+```text
+query
+  -> search.py
+  -> core.py / design_system.py
+  -> 结构化输出或持久化产物
+```
+
+### 1. 数据层:不是一张大表,而是“主题表 + 推理表 + 技术栈表”
+
+它的数据层明显分了三类:
+
+#### 1.1 主题知识表
+
+例如:
+
+- `styles.csv`
+- `colors.csv`
+- `charts.csv`
+- `landing.csv`
+- `products.csv`
+- `ux-guidelines.csv`
+- `typography.csv`
+- `icons.csv`
+- `react-performance.csv`
+- `app-interface.csv`
+- `google-fonts.csv`
+
+这些表并不追求统一成一个超大表,而是按“知识域”拆分。  
+对应证据见:
+
+- `scripts/core.py:17`
+- `scripts/core.py:18`
+- `scripts/core.py:68`
+
+其关键特点是:
+
+- 每个 domain 有独立 `file`
+- 每个 domain 有独立 `search_cols`
+- 每个 domain 有独立 `output_cols`
+
+也就是说,它不是“查整张表”,而是为每类知识定义了**检索字段**和**展示字段**。
+
+#### 1.2 推理表
+
+它额外有一张 `ui-reasoning.csv`,不承担原始知识条目职责,而是承担:
+
+- 类别到模式的映射
+- 风格优先级
+- 关键 effect
+- 反模式
+- decision rules
+
+证据见:
+
+- `scripts/design_system.py:24`
+- `scripts/design_system.py:43`
+- `scripts/design_system.py:88`
+
+这很关键:  
+它把“检索结果”和“最终裁决”分开了。
+
+#### 1.3 技术栈表
+
+它还单独维护:
+
+- `data/stacks/react.csv`
+- `data/stacks/nextjs.csv`
+- `data/stacks/vue.csv`
+- `data/stacks/react-native.csv`
+- `data/stacks/threejs.csv`
+  等 16 张表
+
+这些不是产品知识,而是**实现层 best practices**。
+
+证据见:
+
+- `scripts/core.py:75`
+- `scripts/core.py:95`
+- `data/stacks/react.csv`
+
+这说明它的数据分层并不是按文件类型,而是按职责分层:
+
+- 产品/风格/颜色/字体等“设计知识”
+- reasoning“裁决知识”
+- stack“实现知识”
+
+## 运行时架构
+
+### 2. 通用检索内核:一个 BM25 primitive 服务全部 domain
+
+`core.py` 的核心并不复杂,但架构很干净:
+
+1. `CSV_CONFIG` 注册 domain
+2. `_load_csv()` 统一读表
+3. `_search_csv()` 统一走 BM25
+4. `search()` 做 domain 查询
+5. `search_stack()` 做 stack 查询
+
+证据见:
+
+- `scripts/core.py:17`
+- `scripts/core.py:166`
+- `scripts/core.py:221`
+- `scripts/core.py:243`
+
+这里最值得学习的不是 BM25 本身,而是:
+
+- 所有 domain 共用一个搜索 primitive
+- 变化点全部下沉到配置表
+- 脚本层只关心“读哪张表、查哪些列、吐哪些列”
+
+这让它的数据表可以持续增加,而不用每加一张表就重写一套逻辑。
+
+关键摘录如下:
+
+```python
+CSV_CONFIG = {
+    "style": {
+        "file": "styles.csv",
+        "search_cols": [...],
+        "output_cols": [...]
+    },
+    "color": {
+        "file": "colors.csv",
+        "search_cols": [...],
+        "output_cols": [...]
+    },
+}
+```
+
+这里也要顺手校准我们当前实现和它的真实差距。  
+`webnovel-writer/scripts/reference_search.py` 目前仍然是**全局硬编码字段**,而不是 per-domain 注册:
+
+```python
+_SEARCH_FIELD_WEIGHTS = {
+    "意图与同义词": 4,
+    "关键词": 3,
+    "核心摘要": 2,
+    "详细展开": 1,
+}
+
+_CONTENT_COLUMNS = [
+    "技法名称", "桥段名称", "人设类型", ...
+]
+```
+
+这意味着当前更准确的对应关系不是  
+`core.py -> reference_search.py`,而是:
+
+- `core.py -> reference_search.py + 尚未存在的 CSV_CONFIG 注册层`
+
+### 3. 自动域识别:先判“查哪类知识”
+
+`detect_domain()` 用关键词表先做 domain 猜测,再决定默认查什么。
+
+证据见:
+
+- `scripts/core.py:198`
+- `scripts/core.py:202`
+- `scripts/core.py:216`
+
+这一步虽然简单,但很有启发:
+
+- skill 不要求调用者总是显式指定表
+- 系统先把自然语言问题归类到知识域
+- 再进统一检索
+
+对我们来说,这对应的是:
+
+- 题材输入路由
+- 任务意图到知识表映射
+- 写前不同 step 的表选择
+
+### 4. 上层聚合器:不是“查完就返回”,而是“查完再推理再组装”
+
+`design_system.py` 才是这个 skill 真正的中枢。
+
+其逻辑顺序是:
+
+1. 先查 `product`
+2. 从 `product` 结果得到 category
+3. 用 `ui-reasoning.csv` 找对应 reasoning rule
+4. 带着 `style_priority` 做多 domain 检索
+5. 从 style / color / typography / landing 中挑最佳项
+6. 组装成统一 `design_system` 字典
+
+证据见:
+
+- `scripts/design_system.py:51`
+- `scripts/design_system.py:64`
+- `scripts/design_system.py:88`
+- `scripts/design_system.py:163`
+- `scripts/design_system.py:197`
+
+这比单纯 `reference_search` 高了一层,因为它已经不是“返回搜索命中项”,而是:
+
+- 有路由
+- 有裁决
+- 有优先级
+- 有最终统一输出对象
+
+这其实已经很接近一个轻量 contract 生成器。
+
+## 持久化架构
+
+### 5. Master + Overrides:把运行时结果落成层级真源
+
+它的另一个关键设计是:  
+运行时生成的设计系统可以被持久化为:
+
+- `design-system/<project>/MASTER.md`
+- `design-system/<project>/pages/<page>.md`
+
+证据见:
+
+- `scripts/search.py:13`
+- `scripts/design_system.py:561`
+- `scripts/design_system.py:589`
+- `scripts/design_system.py:612`
+- `scripts/design_system.py:886`
+
+这套模式的意义非常大:
+
+- `MASTER.md` 承担全局真源
+- `pages/*.md` 只记录局部偏离
+- 覆盖关系是显式的,不是隐式拼接
+
+这和我们现在的 Story System 其实是同构的:
+
+- `MASTER_SETTING` ≈ `MASTER.md`
+- `VOLUME / CHAPTER / REVIEW contract` ≈ `page override`
+
+也就是说,`ui-ux-pro-max` 的核心思想并不是 UI 专属,而是:
+
+- 先统一主真源
+- 再允许局部覆盖
+- 覆盖必须被显式表达
+
+## 分发与平台适配
+
+### 6. 平台元数据与 skill 内容是分开的
+
+`templates/platforms/*.json` 说明它不是只为一个 agent 平台准备的。
+
+例如:
+
+- `templates/platforms/claude.json`
+- `templates/platforms/codex.json`
+- `templates/platforms/gemini.json`
+
+这些 JSON 负责定义:
+
+- 安装根目录
+- skillPath
+- frontmatter
+- title / description
+- 是否附带 quickReference
+
+证据见:
+
+- `templates/platforms/claude.json`
+- `templates/platforms/codex.json`
+- `templates/platforms/gemini.json`
+
+这意味着它把三类东西彻底分开了:
+
+1. **知识内容**
+2. **运行时逻辑**
+3. **平台适配壳**
+
+这是一个非常值得抄的边界。
+
+## 数据维护策略
+
+### 7. 它允许工程脚本维护一致性,但不把脚本当 runtime 主逻辑
+
+`data/_sync_all.py` 的作用很明确:
+
+- 同步 `products.csv`、`colors.csv`、`ui-reasoning.csv`
+- 处理 rename / remove / add
+- 衍生一些默认配色与 reasoning 行
+
+证据见:
+
+- `data/_sync_all.py:1`
+- `data/_sync_all.py:63`
+- `data/_sync_all.py:136`
+
+这个脚本说明它有“离线数据维护流水线”,而不是在 runtime 临时 patch 数据。
+
+但这部分对我们要谨慎学习:
+
+- **可以学它的“离线校验/同步”思想**
+- **不能照抄它的“程序生成内容”方式**
+
+因为我们的硬约束是:
+
+- `md -> csv` 知识迁移必须人工完成
+- 禁止自动抽取、自动翻译、自动拆句入库
+
+所以对我们来说,应当保留:
+
+- schema 校验脚本
+- 编号唯一性校验
+- 别名覆盖校验
+- 路由表与规则表一致性校验
+
+但不能写:
+
+- 自动从 md 批量生成故事知识条目
+
+## 对我们最值得迁移的思想
+
+### 8. 我们应该学的,不是 UI 数据,而是把它映射进我们的六层主链
+
+为避免和 `story-system-evolution-spec.md` 的六层术语打架,后文统一按 `evolution-spec 6.1` 的六层来描述迁移:
+
+```text
+Knowledge Layer
+    -> Reasoning Layer
+        -> Contract Layer
+            -> Runtime Assembly Layer
+                -> Chapter Commit Layer
+                    -> Projection Layer
+```
+
+`ui-ux-pro-max` 主要覆盖的是前四层,加上一套 `MASTER.md + page override` 的持久化真源;  
+而我们的故事系统还必须额外补上 `Chapter Commit` 和 `Projection`,因为知识会随章节运行时演进。
+
+映射到我们项目里,更准确的对照应是:
+
+| `evolution-spec` 六层 | `ui-ux-pro-max` 参照物 | `webnovel-writer` 现状 / 目标 | 当前完成度 |
+|------|------|------|------|
+| `Knowledge Layer` | `products.csv`、`styles/colors/...`、stack tables | `references/csv` 基础表、动态表、路由基础表 | 已有基础骨架:7 张规则表、路由表、README schema 已在位 |
+| `Reasoning Layer` | `core.py` 的 `CSV_CONFIG + detect_domain()`,以及 `design_system.py` 的 reasoning rule | `题材与调性推理.csv`、`StorySystemEngine._route()`、未来显式 `CSV_CONFIG` 与 reasoning config | 半成品:已有 route 与 engine 裁决,但还没抽成显式配置层 |
+| `Contract Layer` | `design_system` 统一对象与 `MASTER.md/pages/*.md` | `MASTER_SETTING / VOLUME_BRIEF / CHAPTER_BRIEF / REVIEW_CONTRACT / anti_patterns` | 已接上主骨架:`engine.build()` 可产出 `MASTER/CHAPTER/ANTI`,`RuntimeContractBuilder` 可产出 `VOLUME/REVIEW` |
+| `Runtime Assembly Layer` | 生成页面时“先 page override,再 MASTER”的装配逻辑 | `context_manager(contract-first)` 与运行时上下文装配 | 半成品:`context_manager` 已读 runtime contracts,但整体仍是运行时装配器,尚未完全收束到 contract-first SSOT |
+| `Chapter Commit Layer` | 无完整对应;UI/UX skill 没有事件提交主链 | `CHAPTER_COMMIT` + `override ledger` | 已接线待治理:`ChapterCommitService` 已能生成 commit、写 event log、触发 amend proposal 与 projection writers,但 rejected/backlog 治理仍未完全闭合 |
+| `Projection Layer` | 无完整对应;没有状态投影链 | `state / index / summaries / memory / dashboard` | 已接线待降级:已有四类 projection writer,但旧的 state/index/memory 散写与双写链路仍未完全退居投影层 |
+
+如果只看搜索 primitive 和聚合器,对应关系要写得更严格一些:
+
+- `core.py` ≈ `reference_search.py + 尚未存在的 CSV_CONFIG 注册层`
+- `design_system.py` ≈ `StorySystemEngine + story_system.py + RuntimeContractBuilder`
+
+### 8.1 我们真正缺的不是”聚合器”,而是”显式可审查的裁决层”
+
+当前我们已经有:
+
+- `题材与调性推理.csv`
+- 7 张规则表
+- `reference_search.py`
+- `story_system_engine.py`
+
+而且当前系统已经不是“只有搜索,没有聚合”。  
+`story_system.py` 已经串起了 `build -> persist story seed -> build runtime contracts -> persist runtime contracts` 的主链:
+
+```python
+contract = engine.build(...)
+persist_story_seed(...)
+volume_brief, review_contract = RuntimeContractBuilder(project_root).build_for_chapter(...)
+persist_runtime_contracts(project_root, args.chapter, volume_brief, review_contract)
+```
+
+`StorySystemEngine.build()` 也已经直接产出 `MASTER_SETTING` / `CHAPTER_BRIEF` / `anti_patterns`:
+
+```python
+return {
+    "master_setting": {
+        "meta": {"contract_type": "MASTER_SETTING"},
+        ...
+    },
+    "chapter_brief": {
+        "meta": {"contract_type": "CHAPTER_BRIEF"},
+        ...
+    },
+    "anti_patterns": anti_patterns,
+}
+```
+
+这意味着当前的 `StorySystemEngine + RuntimeContractBuilder`,实际上已经共同承担了
+route / aggregate / persist 主链中的大部分职责。
+
+所以现状并不是“还缺一个像 `design_system.py` 那样的聚合器”,而是:
+
+- 已经有聚合器
+- 但聚合裁决逻辑仍散落在 engine 代码、CSV、`context_manager.py` 和 skill 文本里
+- 还没有像 `ui-reasoning.csv` 那样被提炼成一套显式、结构化、可审查、可测试的规则层
+
+目前很多裁决还散落在:
+
+- `story_system_engine.py`
+- `context_manager.py`
+- skill 文本
+- 经验性 prompt
+
+后续建议把这类规则显式收束为 Python 配置层,或落成一张独立 reasoning 表。
+
+不一定非要 CSV,但必须是**结构化、可审查、可测试**的。
+
+### 8.2 我们也应该继续强化”Master + Override”心智
+
+这一点其实我们已经部分做到,但还需要在 spec 层写得更硬:
+
+- `MASTER_SETTING` 是全局真源
+- `VOLUME_BRIEF` 是卷级偏移
+- `CHAPTER_BRIEF` / `REVIEW_CONTRACT` 是章级偏移
+- accepted `CHAPTER_COMMIT` 是写后事实真源
+
+这和 `ui-ux-pro-max` 的 `MASTER.md + page override` 是同一类思想。
+
+但这里必须补一条关键校准:  
+`ui-ux-pro-max` 实际上是**二层覆盖**:
+
+- `MASTER.md`
+- `pages/*.md`
+
+而我们已经是**四层合同覆盖 + 写后事实层**:
+
+- `MASTER_SETTING`
+- `VOLUME_BRIEF`
+- `CHAPTER_BRIEF`
+- `REVIEW_CONTRACT`
+- accepted `CHAPTER_COMMIT`
+
+这意味着同一个 field 可能在多个层级被覆盖,复杂度显著高于  
+`page override trumps master` 这种二层规则。  
+所以我们不能直接照搬它的覆盖判定逻辑,必须与 `evolution-spec 8.5` 的 `override ledger` 一起设计。
+
+### 8.3 我们要学它的”注册式配置”,不是学它的数据体量
+
+对我们真正重要的是建立一个统一注册表,明确:
+
+- 每张表的职责
+- 检索列
+- 输出列
+- 毒点列
+- 是否属于基础表 / 动态表 / 路由表
+- 是否允许进入 contract 主链
+
+也就是做出属于我们的 `CSV_CONFIG`。
+
+但这一步的起点应该是:承认当前 `reference_search.py` 只是一个**通用 BM25 primitive**,还不是注册式配置层。  
+它现在的检索列和展示列都是全局硬编码,不区分 domain:
+
+```python
+_SEARCH_FIELD_WEIGHTS = {
+    "意图与同义词": 4,
+    "关键词": 3,
+    "核心摘要": 2,
+    "详细展开": 1,
+}
+_CONTENT_COLUMNS = [...]
+```
+
+因此下一步不是重写搜索算法,而是在它上面补一层 per-table / per-domain 的元数据注册。
+
+### 8.4 我们要学它的”消费适配层隔离”
+
+后续我们自己的 CSV / contract 系统,不应该直接把表结构暴露给所有消费者。
+
+正确方向是:
+
+- `story-system` 负责产出统一 contract
+- `context-agent` / `webnovel-write` / `webnovel-query` / `dashboard` 只消费 contract
+- 平台/skill 差异只停留在消费入口层
+
+### 8.5 上下文窗口成本是一个被低估的差异
+
+`ui-ux-pro-max` 的 CSV 检索结果直接进 prompt,单次交互的上下文窗口足够消化。但我们的写作任务书需要织入前文摘要、长期记忆、RAG 线索、当前状态、追读信号等多源数据,context-agent 自身的 research 阶段就要消耗大量上下文。
+
+这意味着 reasoning 层不能照搬它"查完全部再推理"的模式,而是要考虑:
+
+- 哪些数据源在 research 阶段按需查询(而非全量灌入)
+- 最终任务书的信息密度要做取舍,不是"查到的都塞进去"
+- reasoning 规则本身也要轻量,不能再额外占用大段上下文
+
+这个差异直接影响"reasoning 层该做多重"的设计决策:我们的 reasoning 层应该是轻量配置 + 按需路由,而不是像 `design_system.py` 那样在单次调用中串联五六个 domain 的检索结果。
+
+## 不能直接照抄的地方
+
+### 9.1 不能照抄自动数据生成
+
+`ui-ux-pro-max` 的 `_sync_all.py` 有一定“程序生成衍生数据”的倾向。  
+这对 UI 配色数据可以接受,但对故事知识库不适合。
+
+我们的硬边界仍然要保持:
+
+- 知识条目内容必须人工整理
+- 脚本只能做校验、补空、对齐、去重、编号检查
+- 不能自动从 md 迁移内容
+
+### 9.2 不能把自动 domain detect 当成唯一真理
+
+它的 `detect_domain()` 主要靠关键词启发式。  
+这在 UI 场景可以用,但故事系统不能只靠这个。
+
+我们更适合的顺序是:
+
+1. 用户显式题材 / `.story-system` contract
+2. `题材与调性推理.csv`
+3. alias / fallback
+4. 最后才是启发式猜测
+
+注:这里说的是**题材路由判定顺序**,不同于 `evolution-spec 7.2` 的**运行时上下文装配优先级**。  
+后者是在合同已存在时,按 `chapter -> volume -> master -> 题材与调性推理.csv -> genre-profiles.md -> templates/genres/*.md` 组装输入。
+
+### 9.3 不能让平台模板反向主导知识结构
+
+平台适配层必须是壳,不应该反向决定 CSV 结构。
+
+## 对我们下一步 spec 的直接启发
+
+如果把这次调研落成一份可执行 spec,最应该写进 spec 的不是“继续加几百条 CSV”,而是以下结构要求:
+
+### 10.1 建立我们的 `CSV_CONFIG`
+
+这里的 `CSV_CONFIG` 不应取代 `references/csv/README.md`,两者应该明确分工:
+
+- `CSV_CONFIG`:Python 代码层注册字典,供 runtime 的检索、路由、contract 注入直接消费
+- `README.md`:人类可读的 schema / 录入规范 / 表边界说明
+- 校验脚本:保证 `CSV_CONFIG` 与 `README.md` 的列定义、表角色、前缀约定保持一致
+
+当前 `README.md` 已经承担了 schema 文档职责,例如:
+
+```md
+| `关键词` | 是 | 高权重触发词,多值字段,统一使用 `|` |
+| `核心摘要` | 是 | 供高权重召回与结果展示使用的简明摘要 |
+
+### 命名规则.csv
+| `命名对象` | 角色、书名、地点、势力、功法、道具等 |
+```
+
+因此更合理的落地方向不是”README 或 `CSV_CONFIG` 二选一”,而是:
+
+- README 讲人话
+- `CSV_CONFIG` 讲机器话
+- **硬约束**:必须有 CI 校验脚本保证两边对齐。两套 schema 定义如果没有自动化校验,很快就会漂移——这不是建议,是必须做的事
+
+至少明确:
+
+- 表名 / 文件名
+- 角色
+- 检索字段
+- 输出字段
+- 毒点字段
+- 是否基础表 / 动态表 / 路由表
+- contract 注入位置
+- 是否允许进入主链
+- 与 README 对应的 schema 章节
+
+### 10.2 明确”route -> reasoning -> rule tables -> contract”的流水线
+
+这一步是本次调研最关键的迁移结论。
+
+### 10.3 为 CSV 主线补”研究-录入-校验-验收”的闭环
+
+也就是:
+
+- 人工选题
+- 人工提炼
+- CSV 录入
+- schema / alias / route 校验
+- contract 抽样验证
+
+### 10.4 把 skills / agents / dashboard 都降级为消费层
+
+消费统一 contract,而不是各自重新拼知识。
+
+## 最终判断
+
+`ui-ux-pro-max` 的成功,本质上说明了一件事:
+
+> 一个强 skill 的核心,不是写一篇更长的说明书,而是把知识、检索、推理、持久化和消费边界做成明确分层。
+
+对 `webnovel-writer` 来说,最值得学习的最终不是:
+
+- 继续写更长的 skill 文本
+- 继续堆更多零散 md
+- 继续让每个入口自己决定查哪些 reference
+
+而是:
+
+- 用 `references/csv` 承担 `Knowledge Layer`(见 `evolution-spec 6.1`, `12.3`)
+- 用 `story_system + 显式 reasoning 配置` 承担 `Reasoning / Contract Layer`(见 `evolution-spec 6.1`, `7`)
+- 用 `.story-system` 承担主真源与覆盖层(见 `evolution-spec 7`, `8.5`)
+- 用 `CHAPTER_COMMIT + override ledger + canonical event log` 承担运行时演进主链(见 `evolution-spec 9`, `10`)
+- 让 skills / agents / dashboard 只消费统一输出与投影视图(见 `evolution-spec 9.4`, `12`)
+
+这才是它真正值得迁移过来的思想和架构。

+ 388 - 0
docs/superpowers/plans/2026-04-15-chain-integrity-fixes.md

@@ -0,0 +1,388 @@
+# Init→Plan→Write 链路完整性修复计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 修复 init→plan→write→plan→write 全链路中 7 个使用者视角的断裂问题,确保题材从 init 到裁决层的流通、story-system 在正确时机触发、plan 跨卷时能感知已写内容。
+
+**Architecture:** 围绕"题材是 init 写入 state.json 的唯一真源"这个核心决策,从数据层(CSV 别名补齐)到流程层(SKILL.md 规范化)逐步修复。
+
+**Tech Stack:** CSV (UTF-8 BOM), Python CLI, Markdown prompt files
+
+**已确认决策:**
+1. 题材在 init 阶段写入 state.json,后续所有环节从此读取,不再允许 free-text query 决定路由
+2. CSV 检索结果是创作参考,大纲/章纲是最高权重(仅次于用户意见)
+3. chapter_brief.json 只承载裁决/路由元数据,不伪装成章级内容合同
+4. rejected chapter 不阻断下一章,交由用户决断
+5. plan 写卷依据:大纲 + 用户意见 + 已有剧情状态 + CSV 检索
+
+---
+
+## Task 1: 裁决表补齐 init 题材别名
+
+**问题:** init 支持的题材名(修仙、系统流、高武、西幻等)与裁决表的题材名(东方仙侠、西方奇幻等)不匹配,导致裁决层对部分题材不生效。
+
+**Files:**
+- Modify: `webnovel-writer/references/csv/裁决规则.csv`
+- Modify: `webnovel-writer/references/csv/题材与调性推理.csv`
+
+- [ ] **Step 1: 建立 init 题材 → 裁决题材的映射**
+
+init SKILL.md 第 110-113 行列出的题材集合:
+
+```
+玄幻修仙类:修仙 | 系统流 | 高武 | 西幻 | 无限流 | 末世 | 科幻
+都市现代类:都市异能 | 都市日常 | 都市脑洞 | 现实题材 | 黑暗题材 | 电竞 | 直播文
+言情类:古言 | 宫斗宅斗 | 青春甜宠 | 豪门总裁 | ...
+特殊题材:规则怪谈 | 悬疑脑洞 | 悬疑灵异 | 历史古代 | 历史脑洞 | ...
+```
+
+映射到现有 7 个裁决题材:
+
+| init 题材名 | → 裁决题材 |
+|------------|-----------|
+| 修仙、仙侠 | 东方仙侠 |
+| 西幻、西方奇幻 | 西方奇幻 |
+| 末世、科幻 | 科幻末世 |
+| 都市异能、都市脑洞、现实题材 | 都市日常 |
+| 都市修真、现代修真 | 都市修真 |
+| 高武、都市异能(高武向) | 都市高武 |
+| 历史古代、历史脑洞 | 历史古代 |
+| 系统流、无限流 | 东方仙侠(fallback,多为修仙变体) |
+
+不在映射中的(言情类、规则怪谈、悬疑等)暂无裁决行,走空裁决 fallback。
+
+- [ ] **Step 2: 在裁决规则.csv 的关键词列补齐别名**
+
+每行的「关键词」列追加 init 题材名:
+
+| 裁决题材 | 当前关键词 | 追加 |
+|---------|-----------|------|
+| 西方奇幻 | `西方奇幻\|奇幻` | `\|西幻` |
+| 东方仙侠 | `东方仙侠\|仙侠` | `\|修仙\|系统流\|无限流` |
+| 科幻末世 | `科幻末世\|末世\|科幻` | (已覆盖) |
+| 都市日常 | `都市日常\|都市` | `\|都市脑洞\|现实题材\|黑暗题材` |
+| 都市修真 | `都市修真\|修真\|现代修真` | (已覆盖) |
+| 都市高武 | `都市高武\|高武\|都市异能` | (已覆盖) |
+| 历史古代 | `历史古代\|历史\|古代` | `\|历史脑洞` |
+
+编辑 CSV 文件,在对应行的「关键词」列追加。
+
+- [ ] **Step 3: 同步更新路由表的别名覆盖**
+
+路由表 `题材与调性推理.csv` 当前 8 行是流派(退婚流、规则怪谈等),不是题材大类。**不改现有行**。
+
+但路由表目前缺少"东方仙侠""西方奇幻""科幻末世"等大类的路由行。当用户 genre 是"修仙"时,路由表的 `_route()` 可能匹配不上任何行(因为现有行都是具体流派)。
+
+需要确认:路由表是否需要补"大类"行?检查 `_route()` 的 fallback 逻辑——如果匹配不上,用 `--genre` 参数做 `explicit_genre_fallback`,走默认推荐表。这个 fallback 够用。
+
+结论:**路由表不改**。裁决表补别名即可。路由匹配不上时 fallback 到默认推荐表 + 裁决表仍能通过 `_load_reasoning(genre)` 匹配到。
+
+- [ ] **Step 4: 运行测试确认**
+
+```bash
+cd "D:\wk\novel skill\webnovel-writer\webnovel-writer" && python -m pytest scripts/data_modules/tests/test_csv_config.py scripts/data_modules/tests/test_reasoning_engine.py -v --tb=short
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd "D:\wk\novel skill\webnovel-writer" && git add webnovel-writer/references/csv/裁决规则.csv && git commit -m "fix: expand reasoning table genre aliases to cover init genre names"
+```
+
+---
+
+## Task 2: 规范 story-system 的 genre 输入为 state.json 唯一真源
+
+**问题:** write SKILL.md 调 `story-system "{chapter_goal}"` 时 `{chapter_goal}` 来源不明,导致路由质量不可控。
+
+**决策:** genre 从 state.json 读取,作为唯一真源。query 参数改为章纲目标(用于 CSV 检索),genre 参数固定从 state.json 取。
+
+**Files:**
+- Modify: `webnovel-writer/skills/webnovel-write/SKILL.md`
+- Modify: `webnovel-writer/skills/webnovel-plan/SKILL.md`
+
+- [ ] **Step 1: 修改 write SKILL.md 准备阶段的 story-system 调用**
+
+当前(第 141-143 行):
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" \
+  story-system "{chapter_goal}" --chapter {chapter_num} --persist --emit-runtime-contracts --format both
+```
+
+改为:
+```bash
+# 从 state.json 读取题材(唯一真源)
+GENRE="$(python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" state get-field --field project.genre)"
+
+# 从章纲提取本章目标作为检索 query(若无章纲则用题材兜底)
+CHAPTER_GOAL="{从章纲提取的本章目标,如'韩立进入坊市试探消息真伪'}"
+
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" \
+  story-system "${CHAPTER_GOAL}" --genre "${GENRE}" --chapter {chapter_num} --persist --emit-runtime-contracts --format both
+```
+
+在这段命令前加说明:
+```markdown
+**genre 参数规范**:
+- `--genre` 必须从 `state.json` 的 `project.genre` 读取,不得手动填写
+- 第一个位置参数(query)填本章章纲的"目标"字段内容,用于 CSV 知识检索
+- 若章纲无明确目标,fallback 到 `"{题材} 第{chapter_num}章"`
+```
+
+- [ ] **Step 2: 修改 plan SKILL.md 的 story-system 调用**
+
+当前(第 59-61 行):
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${WORKSPACE_ROOT}" \
+  story-system "{chapter_goal}" --chapter {chapter_num} --persist --emit-runtime-contracts --format both
+```
+
+同样改为带 `--genre` 的规范形式。
+
+- [ ] **Step 3: 确认 `state get-field` CLI 命令存在**
+
+```bash
+grep -n "get-field" webnovel-writer/scripts/data_modules/webnovel.py
+```
+
+如果不存在,需要补一个简单的 state 子命令来读取任意 JSON path。或者用 jq/python 一行脚本替代:
+
+```bash
+GENRE="$(python -X utf8 -c "import json; s=json.load(open('${PROJECT_ROOT}/.webnovel/state.json')); print(s.get('project',{}).get('genre',''))")"
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add webnovel-writer/skills/webnovel-write/SKILL.md webnovel-writer/skills/webnovel-plan/SKILL.md
+git commit -m "fix: standardize story-system genre input from state.json as sole source"
+```
+
+---
+
+## Task 3: init 完成后触发 story-system 生成 MASTER_SETTING
+
+**问题:** init 完成后 `.story-system/` 目录不存在,plan 的卷级规划阶段缺少调性/禁忌参照。
+
+**Files:**
+- Modify: `webnovel-writer/skills/webnovel-init/SKILL.md`
+
+- [ ] **Step 1: 在 init SKILL.md 的"执行生成"段落末尾追加 story-system 触发**
+
+在"3) Patch 总纲"之后、"验证与交付"之前,新增:
+
+```markdown
+### 4) 生成写前合同树(Story System 初始化)
+
+init 完成后,立即生成 MASTER_SETTING,让后续 plan 有调性/禁忌参照:
+
+```bash
+GENRE="$(python -X utf8 -c "import json; s=json.load(open('{project_root}/.webnovel/state.json')); print(s.get('project',{}).get('genre',''))")"
+
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" \
+  story-system "${GENRE}" --genre "${GENRE}" --persist --format json
+```
+
+说明:
+- 此时不传 `--chapter`,只生成 `MASTER_SETTING.json` 和 `anti_patterns.json`
+- 不传 `--emit-runtime-contracts`(还没有卷/章级数据)
+- plan 阶段拆到具体章节时再生成 volume/chapter/review 合同
+```
+
+- [ ] **Step 2: 更新 init 的验证与交付段落**
+
+在验证检查中新增:
+```bash
+test -f "{project_root}/.story-system/MASTER_SETTING.json"
+```
+
+在成功标准中新增:
+- `.story-system/MASTER_SETTING.json` 存在且 `route.primary_genre` 非空
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add webnovel-writer/skills/webnovel-init/SKILL.md
+git commit -m "feat: init triggers story-system to generate MASTER_SETTING after project creation"
+```
+
+---
+
+## Task 4: 明确 chapter_brief 只承载裁决元数据
+
+**问题:** `chapter_brief.json` 的 `chapter_focus` 是从 CSV 检索结果凑的,跟实际章纲无关,误导 context-agent。
+
+**决策:** chapter_brief 只承载裁决/路由元数据。章纲是最高权重(仅次于用户意见),由 context-agent 直接读取。
+
+**Files:**
+- Modify: `webnovel-writer/agents/context-agent.md`
+- Modify: `webnovel-writer/skills/webnovel-write/SKILL.md`
+
+- [ ] **Step 1: 在 context-agent.md 的"Story System 主链"段落明确权重**
+
+在写前真源列表后新增权重说明:
+
+```markdown
+**数据权重(高→低)**:
+1. 用户明确要求
+2. 大纲/章纲原文(`大纲/第X卷-详细大纲.md` 中的本章内容)
+3. Story Contracts 中的 `MASTER_SETTING`(题材、调性、核心禁忌)
+4. `chapter_{NNN}.json` 的 `reasoning` 字段(裁决层的风格/节奏/毒点建议)
+5. accepted `CHAPTER_COMMIT`(写后事实)
+6. CSV 检索结果(创作参考,不覆盖大纲)
+
+`chapter_{NNN}.json` 的 `chapter_focus` 字段仅为 CSV 检索派生的参考,不代表本章实际目标。本章目标以章纲原文为准。
+```
+
+- [ ] **Step 2: 在 write SKILL.md 的"合同树必备文件"段落补充说明**
+
+在第 146-148 行的合同树说明后加:
+
+```markdown
+**注意**:`.story-system/chapters/chapter_{NNN}.json` 的 `chapter_focus` 是 CSV 检索派生的参考建议,不是本章的实际目标。本章目标以 `大纲/第X卷-详细大纲.md` 中的章纲原文为最高权重。`chapter_{NNN}.json` 的核心价值是 `reasoning` 字段中的裁决元数据(风格优先级、节奏策略、反模式)。
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add webnovel-writer/agents/context-agent.md webnovel-writer/skills/webnovel-write/SKILL.md
+git commit -m "docs: clarify chapter_brief carries reasoning metadata, outline is authority"
+```
+
+---
+
+## Task 5: plan 跨卷时读取已写内容
+
+**问题:** plan 第2卷时不读 commit 历史、summaries、实体状态,导致章纲可能与已写内容矛盾。
+
+**决策:** plan 步骤 1 加载大纲 + 用户意见 + 已有剧情状态 + CSV 检索。
+
+**Files:**
+- Modify: `webnovel-writer/skills/webnovel-plan/SKILL.md`
+
+- [ ] **Step 1: 扩展 plan Step 1 的数据加载**
+
+当前 Step 1(第 97-113 行)只读 state.json 和总纲。改为:
+
+```markdown
+### Step 1:加载项目数据并确认前置条件
+
+**必须加载**:
+
+```bash
+# 项目状态与题材
+cat "$PROJECT_ROOT/.webnovel/state.json"
+
+# 总纲(全局蓝图)
+cat "$PROJECT_ROOT/大纲/总纲.md"
+```
+
+**已有卷的剧情状态**(跨卷规划时必须加载):
+
+若已有已完成卷(`.webnovel/summaries/` 下有文件),加载以下数据感知已写内容:
+
+```bash
+# 最近 5 章摘要(了解剧情走向)
+for ch in $(seq $((START_CH - 5)) $((START_CH - 1))); do
+  cat "$PROJECT_ROOT/.webnovel/summaries/ch$(printf '%04d' $ch).md" 2>/dev/null
+done
+
+# 核心角色当前状态(知道主角走到哪了)
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" \
+  knowledge query-entity-state --entity "{protagonist_id}" --at-chapter {上一卷最后章}
+
+# 核心关系当前状态
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" \
+  knowledge query-relationships --entity "{protagonist_id}" --at-chapter {上一卷最后章}
+
+# 活跃伏笔(跨卷未回收的伏笔)
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" \
+  memory-contract get-open-loops
+```
+
+**CSV 创作参考**(卷级规划时按需检索):
+
+```bash
+# 本卷题材相关的节奏和桥段参考
+python -X utf8 "${SCRIPTS_DIR}/reference_search.py" --skill plan --table 爽点与节奏 --query "{卷级核心冲突}" --genre "${GENRE}"
+python -X utf8 "${SCRIPTS_DIR}/reference_search.py" --skill plan --table 桥段套路 --query "{卷级核心冲突}" --genre "${GENRE}"
+```
+```
+
+按需读取(保持不变):
+- `设定集/世界观.md`
+- `设定集/力量体系.md`
+- `设定集/主角卡.md`
+- `设定集/反派设计.md`
+- `.webnovel/idea_bank.json`
+
+- [ ] **Step 2: 在 plan Step 6(卷纲骨架)加入已写状态参照**
+
+在 Step 6 的"卷纲必须明确"列表后新增:
+
+```markdown
+跨卷一致性检查:
+- 上一卷未回收的伏笔必须出现在新卷的伏笔规划中(继续推进或标记回收)
+- 角色关系变化必须延续(不能当上一卷没发生过)
+- 主角能力/境界必须承接(不能回退也不能跳级,除非有剧情解释)
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add webnovel-writer/skills/webnovel-plan/SKILL.md
+git commit -m "feat: plan reads write history for cross-volume awareness"
+```
+
+---
+
+## Task 6: 运行测试 + 最终验证
+
+**Files:** (read-only verification)
+
+- [ ] **Step 1: 运行全量 prompt integrity 测试**
+
+```bash
+cd "D:\wk\novel skill\webnovel-writer\webnovel-writer" && python -m pytest scripts/data_modules/tests/test_prompt_integrity.py -v --tb=short
+```
+
+- [ ] **Step 2: 运行 CSV 配置对齐测试**
+
+```bash
+cd "D:\wk\novel skill\webnovel-writer\webnovel-writer" && python -m pytest scripts/data_modules/tests/test_csv_config.py -v --tb=short
+```
+
+- [ ] **Step 3: 验证裁决表别名覆盖**
+
+```bash
+python3 -c "
+import csv
+from pathlib import Path
+path = Path('webnovel-writer/references/csv/裁决规则.csv')
+with open(path, 'r', encoding='utf-8-sig') as f:
+    for row in csv.DictReader(f):
+        genre = row['题材']
+        keywords = row['关键词']
+        print(f'{genre}: {keywords}')
+"
+```
+
+确认"修仙""西幻""系统流""都市脑洞""历史脑洞"等 init 题材名都出现在对应行的关键词中。
+
+- [ ] **Step 4: 手动走一遍 init→write 关键路径**
+
+模拟检查:
+1. init 设 genre="修仙" → state.json.project.genre="修仙"
+2. init 触发 story-system "修仙" --genre "修仙" → MASTER_SETTING.json 生成
+3. write 读 state.json 的 genre → 传 --genre "修仙" 给 story-system
+4. story-system `_route()` 匹配"修仙" → 路由表 fallback(无精确匹配,但有 explicit_genre_fallback)
+5. story-system `_load_reasoning("修仙")` → 裁决表匹配到"东方仙侠"行(通过别名"修仙")
+6. 裁决层生效 → chapter_brief.reasoning 有值
+
+确认这条路径不断裂。
+
+- [ ] **Step 5: Commit final**
+
+```bash
+git add -A && git commit -m "chore: final verification for chain integrity fixes"
+```

+ 1960 - 0
docs/superpowers/plans/2026-04-15-story-system-final-convergence.md

@@ -0,0 +1,1960 @@
+# Story System 最终收束实施计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 把 Story System 从"半成品并存"收束到"六层主链 + 消费端同步"的最终可用状态,覆盖 CSV_CONFIG 注册、裁决表、engine 改造、context_manager 瘦身、旧散写清理、projection 收束、消费端同步和向量索引增强共 9 个 section。
+
+**Architecture:** 自底向上串行推进。先在 `reference_search.py` 引入 per-table `CSV_CONFIG`,然后统一 CSV 毒点列名、新建裁决表、改造 `story_system_engine.py` 接入裁决层,接着瘦身 `context_manager.py`、清理旧散写路径、收束 projection 层,最后同步所有消费端 prompt 并增强向量索引。
+
+**Tech Stack:** Python 3.11+, pytest, CSV (UTF-8 BOM), SQLite FTS5, RAG embedding
+
+**Spec:** `docs/superpowers/specs/2026-04-14-story-system-final-convergence-spec.md`
+
+---
+
+## 文件结构总览
+
+### 新建文件
+
+| 文件 | 职责 |
+|------|------|
+| `webnovel-writer/references/csv/裁决规则.csv` | reasoning 层,key=题材,裁决命中条目的优先级和注入位置 |
+| `webnovel-writer/scripts/data_modules/knowledge_query.py` | 时序查询接口,entity_state_at_chapter / relationships_at_chapter |
+| `webnovel-writer/scripts/data_modules/vector_projection_writer.py` | commit 后把事件/entity_delta 写入向量库 |
+| `webnovel-writer/scripts/data_modules/tests/test_csv_config.py` | CSV_CONFIG 与 CSV 表头对齐校验 |
+| `webnovel-writer/scripts/data_modules/tests/test_reasoning_engine.py` | 裁决层单元测试 |
+| `webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py` | 时序查询单元测试 |
+| `webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py` | 向量投影写入测试 |
+
+### 主要修改文件
+
+| 文件 | 改动摘要 |
+|------|---------|
+| `webnovel-writer/scripts/reference_search.py` | 引入 `CSV_CONFIG`,`search()` 按表使用不同 `search_cols` |
+| `webnovel-writer/scripts/data_modules/story_system_engine.py` | 接入裁决表,新增 `_load_reasoning` / `_apply_reasoning` / `_rank_anti_patterns` / `_assemble_contract` |
+| `webnovel-writer/scripts/data_modules/context_manager.py` | 删 snapshot 逻辑、删 `_compact_json_text` / text 渲染相关,压到 400 行以下 |
+| `webnovel-writer/scripts/extract_chapter_context.py` | `_render_text()` 改为纯 JSON 序列化,text 渲染不再由代码层负责(context-agent 按示例写任务书) |
+| `webnovel-writer/scripts/data_modules/event_projection_router.py` | 给 6 种事件加 `"vector"` 路由 |
+| `webnovel-writer/scripts/data_modules/chapter_commit_service.py` | `apply_projections` 接入 `VectorProjectionWriter` |
+| `webnovel-writer/scripts/data_modules/state_projection_writer.py` | 统一由 projection 推进 `chapter_status` |
+| `webnovel-writer/skills/webnovel-write/SKILL.md` | 删 Step 2/4 的 `set-chapter-status`、删 `core-constraints` / `anti-ai-guide` 直读 |
+| `webnovel-writer/agents/context-agent.md` | 确认工具段落、research 数据源路径与代码一致 |
+| `webnovel-writer/agents/data-agent.md` | 确认不直写 state/index/memory |
+| `webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py` | 新增散写检测断言 |
+| `webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py` | 补 vector 路由测试 |
+| `webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py` | 补裁决层测试 |
+| `webnovel-writer/scripts/tests/test_reference_search.py` | 补 per-table search_cols 测试 |
+| `webnovel-writer/references/csv/*.csv` | 毒点列统一 rename |
+
+### 删除文件
+
+| 文件 | 理由 |
+|------|------|
+| `webnovel-writer/scripts/data_modules/snapshot_manager.py` | snapshot 逻辑随 context_manager 瘦身一起删除 |
+
+---
+
+## Task 1: CSV_CONFIG 注册层
+
+**Files:**
+- Modify: `webnovel-writer/scripts/reference_search.py:90-191`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_csv_config.py`
+- Modify: `webnovel-writer/scripts/tests/test_reference_search.py`
+
+- [ ] **Step 1: 在 `reference_search.py` 新增 `CSV_CONFIG` dict**
+
+在 `_TOKEN_SPLIT_RE` 定义之前(约第 89 行),插入 `CSV_CONFIG`:
+
+```python
+# ---------------------------------------------------------------------------
+# Per-table configuration
+# ---------------------------------------------------------------------------
+
+CSV_CONFIG: Dict[str, Dict[str, Any]] = {
+    "命名规则": {
+        "file": "命名规则.csv",
+        "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
+        "output_cols": ["编号", "命名对象", "核心摘要", "大模型指令", "详细展开"],
+        "poison_col": "毒点",
+        "role": "base",
+    },
+    "场景写法": {
+        "file": "场景写法.csv",
+        "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
+        "output_cols": ["编号", "模式名称", "核心摘要", "大模型指令", "详细展开"],
+        "poison_col": "毒点",
+        "role": "base",
+    },
+    "写作技法": {
+        "file": "写作技法.csv",
+        "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
+        "output_cols": ["编号", "技法名称", "核心摘要", "大模型指令", "详细展开"],
+        "poison_col": "毒点",
+        "role": "base",
+    },
+    "桥段套路": {
+        "file": "桥段套路.csv",
+        "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
+        "output_cols": ["编号", "桥段名称", "核心摘要", "大模型指令", "详细展开"],
+        "poison_col": "毒点",
+        "role": "dynamic",
+    },
+    "爽点与节奏": {
+        "file": "爽点与节奏.csv",
+        "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
+        "output_cols": ["编号", "节奏类型", "核心摘要", "大模型指令", "详细展开"],
+        "poison_col": "毒点",
+        "role": "dynamic",
+    },
+    "人设与关系": {
+        "file": "人设与关系.csv",
+        "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
+        "output_cols": ["编号", "人设类型", "核心摘要", "大模型指令", "详细展开"],
+        "poison_col": "毒点",
+        "role": "base",
+    },
+    "金手指与设定": {
+        "file": "金手指与设定.csv",
+        "search_cols": {"关键词": 3, "意图与同义词": 4, "核心摘要": 2},
+        "output_cols": ["编号", "设定类型", "核心摘要", "大模型指令", "详细展开"],
+        "poison_col": "毒点",
+        "role": "base",
+    },
+    "题材与调性推理": {
+        "file": "题材与调性推理.csv",
+        "search_cols": {"关键词": 3, "意图与同义词": 4, "题材别名": 3},
+        "output_cols": ["编号", "题材/流派", "核心调性", "推荐基础检索表", "推荐动态检索表"],
+        "poison_col": "毒点",
+        "role": "route",
+    },
+    "裁决规则": {
+        "file": "裁决规则.csv",
+        "search_cols": {"题材": 4},
+        "output_cols": [
+            "题材", "风格优先级", "爽点优先级", "节奏默认策略",
+            "毒点权重", "冲突裁决", "contract注入层", "反模式",
+        ],
+        "poison_col": "",
+        "role": "reasoning",
+    },
+}
+```
+
+- [ ] **Step 2: 改造 `_build_doc_terms()` 使用 per-table `search_cols`**
+
+把旧的 `_SEARCH_FIELD_WEIGHTS` 全局 dict 替换为 per-table 参数:
+
+```python
+# 删除旧的全局常量
+# _SEARCH_FIELD_WEIGHTS = { ... }  # 删除
+
+# 保留作为默认 fallback
+_DEFAULT_SEARCH_WEIGHTS: Dict[str, int] = {
+    "意图与同义词": 4,
+    "关键词": 3,
+    "核心摘要": 2,
+    "详细展开": 1,
+}
+
+
+def _build_doc_terms(row: Dict[str, str], search_weights: Dict[str, int] | None = None) -> List[str]:
+    """Build weighted BM25 terms from the configured search fields."""
+    weights = search_weights or _DEFAULT_SEARCH_WEIGHTS
+    terms: List[str] = []
+    for field, weight in weights.items():
+        field_terms = _tokenize(row.get(field, ""))
+        if not field_terms:
+            continue
+        terms.extend(field_terms * weight)
+    return terms
+```
+
+- [ ] **Step 3: 改造 `search()` 从 `CSV_CONFIG` 读取配置**
+
+在 `search()` 函数里,根据 `table` 参数查 `CSV_CONFIG`:
+
+```python
+def search(
+    csv_dir: Path,
+    skill: str,
+    query: str,
+    table: Optional[str] = None,
+    genre: Optional[str] = None,
+    max_results: int = 5,
+) -> Dict[str, Any]:
+    # ... (error check 不变)
+
+    tables = load_tables(csv_dir, table=table)
+    if not tables:
+        # ... (不变)
+
+    # 按表查 search_cols
+    table_config = CSV_CONFIG.get(table) if table else None
+    search_weights = (
+        dict(table_config["search_cols"]) if table_config else None
+    )
+
+    # 1) Collect filtered rows
+    candidates: List[tuple] = []
+    for tbl_name, rows in tables.items():
+        for row in rows:
+            if _skill_matches(row, skill) and _genre_matches(row, genre):
+                candidates.append((tbl_name, row))
+
+    if not candidates:
+        # ... (不变)
+
+    # 2) Tokenize - 对每条用其所在表的 search_cols
+    query_terms = _tokenize(query)
+    doc_terms_list = []
+    for tbl_name, row in candidates:
+        tbl_cfg = CSV_CONFIG.get(tbl_name)
+        weights = dict(tbl_cfg["search_cols"]) if tbl_cfg else search_weights
+        doc_terms_list.append(_build_doc_terms(row, weights))
+
+    # 3-4) 不变 ...
+```
+
+- [ ] **Step 4: 删除 `_SEARCH_FIELD_WEIGHTS` 和 `_CONTENT_COLUMNS`**
+
+删除第 90-95 行的 `_SEARCH_FIELD_WEIGHTS` 和第 180-190 行的 `_CONTENT_COLUMNS`。
+
+`_build_summary()` 改为:如果有 `CSV_CONFIG` 里的 `output_cols`,就按那个顺序取字段;否则用原来的 fallback 逻辑。
+
+```python
+def _build_summary(row: Dict[str, str], table_name: str | None = None) -> str:
+    core_summary = row.get("核心摘要", "").strip()
+    if core_summary:
+        return core_summary
+
+    # 优先用 CSV_CONFIG 的 output_cols
+    if table_name and table_name in CSV_CONFIG:
+        cols = CSV_CONFIG[table_name]["output_cols"]
+    else:
+        cols = [
+            "技法名称", "桥段名称", "人设类型", "节奏类型", "设定类型",
+            "规则", "说明", "模式名称", "命名对象", "场景类型",
+        ]
+
+    parts: List[str] = []
+    for col in cols:
+        val = row.get(col, "").strip()
+        if val and col not in ("编号", "大模型指令", "详细展开", "核心摘要"):
+            parts.append(val)
+    if parts:
+        return ";".join(parts)
+    return row.get("详细展开", "").strip()
+```
+
+- [ ] **Step 5: 创建 CSV_CONFIG 对齐校验测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_csv_config.py
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""CSV_CONFIG 与实际 CSV 表头对齐校验。"""
+import csv
+from pathlib import Path
+
+import pytest
+
+# reference_search.py 在 scripts/ 下,需要加 sys.path
+import sys
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
+
+from reference_search import CSV_CONFIG
+
+CSV_DIR = Path(__file__).resolve().parent.parent.parent.parent / "references" / "csv"
+
+
+@pytest.mark.parametrize("table_name,config", list(CSV_CONFIG.items()))
+def test_csv_config_columns_exist_in_csv_header(table_name: str, config: dict):
+    """CSV_CONFIG 里声明的所有列名都必须在 CSV 文件头中找到。"""
+    csv_path = CSV_DIR / config["file"]
+    if not csv_path.exists():
+        pytest.skip(f"{config['file']} not yet created")
+
+    with open(csv_path, "r", encoding="utf-8-sig", newline="") as f:
+        reader = csv.DictReader(f)
+        headers = set(reader.fieldnames or [])
+
+    all_cols = set()
+    for col in config.get("search_cols", {}):
+        all_cols.add(col)
+    for col in config.get("output_cols", []):
+        all_cols.add(col)
+    poison = config.get("poison_col", "")
+    if poison:
+        all_cols.add(poison)
+
+    missing = all_cols - headers
+    assert not missing, f"表 {table_name} 缺少列: {missing}"
+
+
+def test_csv_config_file_field_matches_filename():
+    """CSV_CONFIG 的 file 字段必须与 key + '.csv' 对应。"""
+    for name, config in CSV_CONFIG.items():
+        assert config["file"] == f"{name}.csv", f"{name}: file 应为 '{name}.csv',实际为 '{config['file']}'"
+```
+
+- [ ] **Step 6: 运行测试验证**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_csv_config.py -v`
+
+预期:`裁决规则` 那条会 skip(文件还没创建),其余表全 pass。
+
+- [ ] **Step 7: 补充 per-table 检索测试**
+
+在 `webnovel-writer/scripts/tests/test_reference_search.py` 末尾新增:
+
+```python
+class TestPerTableSearchCols:
+    """CSV_CONFIG per-table search_cols 测试。"""
+
+    def test_different_tables_use_different_search_weights(self):
+        """确认不同表用不同的 search_cols 做检索。"""
+        # 命名规则和场景写法都应返回结果,但用各自表的 search_cols
+        out1 = run_search("--skill", "write", "--table", "命名规则", "--query", "角色命名")
+        out2 = run_search("--skill", "write", "--table", "场景写法", "--query", "战斗描写")
+        assert out1["status"] == "success"
+        assert out2["status"] == "success"
+        assert out1["data"]["total"] >= 1
+        assert out2["data"]["total"] >= 1
+```
+
+- [ ] **Step 8: 运行全量 reference_search 测试**
+
+Run: `cd webnovel-writer && python -m pytest scripts/tests/test_reference_search.py -v`
+
+预期:全部 PASS。
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add webnovel-writer/scripts/reference_search.py webnovel-writer/scripts/data_modules/tests/test_csv_config.py webnovel-writer/scripts/tests/test_reference_search.py
+git commit -m "feat: introduce per-table CSV_CONFIG in reference_search"
+```
+
+---
+
+## Task 2: CSV 毒点列统一
+
+**Files:**
+- Modify: `webnovel-writer/references/csv/场景写法.csv` (header rename)
+- Modify: `webnovel-writer/references/csv/写作技法.csv` (header rename)
+- Modify: `webnovel-writer/references/csv/爽点与节奏.csv` (header rename)
+- Modify: `webnovel-writer/references/csv/人设与关系.csv` (header rename)
+- Modify: `webnovel-writer/references/csv/桥段套路.csv` (header rename)
+- Modify: `webnovel-writer/references/csv/题材与调性推理.csv` (header rename)
+- Modify: `webnovel-writer/scripts/data_modules/story_system_engine.py:15-22`
+
+- [ ] **Step 1: 统计当前各表的毒点列名**
+
+当前列名映射:
+- `场景写法.csv` → `反面写法`
+- `写作技法.csv` → `常见误区`
+- `爽点与节奏.csv` → `常见崩盘误区`
+- `人设与关系.csv` → `忌讳写法`
+- `桥段套路.csv` → 有 `忌讳写法` 列
+- `题材与调性推理.csv` → `强制禁忌/毒点`
+- `命名规则.csv` → 无毒点列(header 里有 `反例`,保留不动,新增 `毒点` 列)
+- `金手指与设定.csv` → 无毒点列(新增 `毒点` 列)
+
+- [ ] **Step 2: 批量 rename CSV 列头**
+
+用脚本执行(一次性,不入库):
+
+```python
+# 在 bash 里直接执行
+python3 -c "
+import csv, sys
+from pathlib import Path
+
+csv_dir = Path('webnovel-writer/references/csv')
+
+renames = {
+    '场景写法.csv': {'反面写法': '毒点'},
+    '写作技法.csv': {'常见误区': '毒点'},
+    '爽点与节奏.csv': {'常见崩盘误区': '毒点'},
+    '人设与关系.csv': {'忌讳写法': '毒点'},
+    '桥段套路.csv': {'忌讳写法': '毒点'},
+    '题材与调性推理.csv': {'强制禁忌/毒点': '毒点'},
+}
+
+for filename, mapping in renames.items():
+    path = csv_dir / filename
+    with open(path, 'r', encoding='utf-8-sig', newline='') as f:
+        reader = csv.DictReader(f)
+        rows = list(reader)
+        old_fields = list(reader.fieldnames)
+
+    new_fields = [mapping.get(f, f) for f in old_fields]
+
+    new_rows = []
+    for row in rows:
+        new_row = {}
+        for old_f, new_f in zip(old_fields, new_fields):
+            new_row[new_f] = row.get(old_f, '')
+        new_rows.append(new_row)
+
+    with open(path, 'w', encoding='utf-8-sig', newline='') as f:
+        writer = csv.DictWriter(f, fieldnames=new_fields)
+        writer.writeheader()
+        writer.writerows(new_rows)
+
+print('Done')
+"
+```
+
+- [ ] **Step 3: 给 `命名规则.csv` 和 `金手指与设定.csv` 新增空 `毒点` 列**
+
+```python
+python3 -c "
+import csv
+from pathlib import Path
+
+csv_dir = Path('webnovel-writer/references/csv')
+
+for filename in ['命名规则.csv', '金手指与设定.csv']:
+    path = csv_dir / filename
+    with open(path, 'r', encoding='utf-8-sig', newline='') as f:
+        reader = csv.DictReader(f)
+        rows = list(reader)
+        fields = list(reader.fieldnames)
+
+    if '毒点' not in fields:
+        fields.append('毒点')
+        for row in rows:
+            row['毒点'] = ''
+
+    with open(path, 'w', encoding='utf-8-sig', newline='') as f:
+        writer = csv.DictWriter(f, fieldnames=fields)
+        writer.writeheader()
+        writer.writerows(rows)
+
+print('Done')
+"
+```
+
+- [ ] **Step 4: 更新 `story_system_engine.py` 的 `ANTI_PATTERN_SOURCE_FIELDS`**
+
+把第 15-22 行的旧映射:
+
+```python
+ANTI_PATTERN_SOURCE_FIELDS = {
+    "场景写法": ["反面写法"],
+    "写作技法": ["常见误区"],
+    "爽点与节奏": ["常见崩盘误区"],
+    "人设与关系": ["忌讳写法"],
+    "桥段套路": ["忌讳写法"],
+    "题材与调性推理": ["强制禁忌/毒点"],
+}
+```
+
+统一改为:
+
+```python
+ANTI_PATTERN_SOURCE_FIELDS = {
+    "场景写法": ["毒点"],
+    "写作技法": ["毒点"],
+    "爽点与节奏": ["毒点"],
+    "人设与关系": ["毒点"],
+    "桥段套路": ["毒点"],
+    "题材与调性推理": ["毒点"],
+    "命名规则": ["毒点"],
+    "金手指与设定": ["毒点"],
+}
+```
+
+- [ ] **Step 5: 运行测试**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_csv_config.py scripts/data_modules/tests/test_story_system_engine.py scripts/tests/test_reference_search.py -v`
+
+预期:test_story_system_engine 会因 fixture CSV 里用旧列名而失败。
+
+- [ ] **Step 6: 修复 `test_story_system_engine.py` fixture 列名**
+
+把 fixture CSV 里的 `忌讳写法` 和 `常见崩盘误区` 改为 `毒点`,`强制禁忌/毒点` 也改为 `毒点`。
+
+fixture 第 53 行的 `桥段套路.csv` headers 改为:
+```python
+["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "桥段名称", "毒点"],
+```
+对应行数据 key `忌讳写法` 改为 `毒点`。
+
+fixture 第 71 行的 `爽点与节奏.csv` headers 改为:
+```python
+["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "毒点", "节奏类型"],
+```
+对应行数据 key `常见崩盘误区` 改为 `毒点`。
+
+fixture 第 26 行的 `题材与调性推理.csv` headers 里 `强制禁忌/毒点` 改为 `毒点`,对应行数据 key 也改为 `毒点`。
+
+- [ ] **Step 7: 运行测试确认全 PASS**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_story_system_engine.py scripts/data_modules/tests/test_csv_config.py -v`
+
+预期:全部 PASS。
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add webnovel-writer/references/csv/*.csv webnovel-writer/scripts/data_modules/story_system_engine.py webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py
+git commit -m "refactor: unify poison column name to 毒点 across all CSV tables"
+```
+
+---
+
+## Task 3: 新建裁决规则表
+
+**Files:**
+- Create: `webnovel-writer/references/csv/裁决规则.csv`
+
+- [ ] **Step 1: 创建裁决规则 CSV 文件**
+
+```csv
+编号,适用技能,分类,层级,关键词,意图与同义词,适用题材,大模型指令,核心摘要,详细展开,题材,风格优先级,爽点优先级,节奏默认策略,毒点权重,冲突裁决,contract注入层,反模式
+RS-001,write|plan,裁决,推理层,西方奇幻|奇幻,西方奇幻怎么写,西方奇幻,按冲突裁决排序命中条目,西方奇幻裁决规则,,西方奇幻,史诗感 > 冷硬算计 > 日常轻松,实力碾压 > 逆境翻盘 > 智谋博弈,快推慢收 对峙段拉长 过渡段压短,圣母病 > 情绪标签化 > 逻辑断裂,爽点与节奏 > 场景写法 > 写作技法,CHAPTER_BRIEF.writing_guidance,情绪标签化|角色行为无逻辑|战斗无代价
+RS-002,write|plan,裁决,推理层,东方仙侠|仙侠,仙侠怎么写,东方仙侠,按冲突裁决排序命中条目,东方仙侠裁决规则,,东方仙侠,冷硬算计 > 超然物外 > 热血冲突,境界碾压 > 底牌揭晓 > 因果兑现,慢蓄快爆 修炼段精简 斗法段拉满,修炼水字数 > 圣母病 > 逻辑断裂,爽点与节奏 > 桥段套路 > 场景写法,CHAPTER_BRIEF.writing_guidance,修炼变流水账|境界突破无代价|感悟靠顿悟标签
+RS-003,write|plan,裁决,推理层,科幻末世|末世|科幻,科幻末世怎么写,科幻末世,按冲突裁决排序命中条目,科幻末世裁决规则,,科幻末世,高压克制 > 冷硬算计 > 绝境反击,绝境生存 > 资源碾压 > 智谋博弈,紧凑推进 危机不断 喘息极短,主角无敌 > 科技无代价 > 末世无压迫感,场景写法 > 爽点与节奏 > 写作技法,CHAPTER_BRIEF.writing_guidance,末世没有生存压力|科技万能|角色行为无逻辑
+RS-004,write|plan,裁决,推理层,都市日常|都市,都市日常怎么写,都市日常,按冲突裁决排序命中条目,都市日常裁决规则,,都市日常,日常轻松 > 温情治愈 > 微妙张力,情感共鸣 > 生活逆袭 > 社交碾压,慢节奏 情感铺垫长 冲突柔和,假大空说教 > 情绪标签化 > 逻辑断裂,写作技法 > 人设与关系 > 场景写法,CHAPTER_BRIEF.writing_guidance,情感靠标签|日常无冲突|角色千人一面
+RS-005,write|plan,裁决,推理层,都市修真|修真|现代修真,都市修真怎么写,都市修真,按冲突裁决排序命中条目,都市修真裁决规则,,都市修真,隐秘低调 > 冷硬算计 > 热血爆发,身份反差 > 境界碾压 > 底牌揭晓,快慢交替 日常短 修真爆发长,修真体系与现代割裂 > 圣母病 > 装逼无代价,爽点与节奏 > 场景写法 > 桥段套路,CHAPTER_BRIEF.writing_guidance,修真体系照搬古代|现代元素没有影响|身份暴露无后果
+RS-006,write|plan,裁决,推理层,都市高武|高武|都市异能,都市高武怎么写,都市高武,按冲突裁决排序命中条目,都市高武裁决规则,,都市高武,热血冲突 > 冷硬算计 > 力量美学,实力碾压 > 以弱胜强 > 排名跃升,快节奏 战斗密集 过渡极短,战力崩盘 > 圣母病 > 无脑开挂,爽点与节奏 > 场景写法 > 桥段套路,CHAPTER_BRIEF.writing_guidance,战力体系自相矛盾|升级无代价|打斗无策略
+RS-007,write|plan,裁决,推理层,历史古代|历史|古代,历史古代怎么写,历史古代,按冲突裁决排序命中条目,历史古代裁决规则,,历史古代,沉稳厚重 > 权谋算计 > 家国情怀,权谋碾压 > 历史转折 > 身份反转,慢铺快收 权谋段拉长 战争段紧凑,现代价值观强加古人 > 逻辑断裂 > 历史常识错误,写作技法 > 人设与关系 > 场景写法,CHAPTER_BRIEF.writing_guidance,用现代口语写古代|权谋无逻辑|历史事件随意篡改
+```
+
+- [ ] **Step 2: 运行 CSV_CONFIG 校验确认新表列头对齐**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_csv_config.py -v`
+
+预期:`裁决规则` 现在有文件了,应该 PASS。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add webnovel-writer/references/csv/裁决规则.csv
+git commit -m "feat: add 裁决规则.csv reasoning table for 7 genres"
+```
+
+---
+
+## Task 4: engine 接入裁决表
+
+**Files:**
+- Modify: `webnovel-writer/scripts/data_modules/story_system_engine.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_reasoning_engine.py`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_story_system_engine.py`
+
+- [ ] **Step 1: 写裁决层测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_reasoning_engine.py
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""裁决层集成测试。"""
+import csv
+
+from data_modules.story_system_engine import StorySystemEngine
+
+
+def _write_csv(path, headers, rows):
+    with open(path, "w", encoding="utf-8-sig", newline="") as f:
+        writer = csv.DictWriter(f, fieldnames=headers)
+        writer.writeheader()
+        writer.writerows(rows)
+
+
+ROUTE_HEADERS = [
+    "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
+    "大模型指令", "核心摘要", "详细展开", "题材/流派", "题材别名", "核心调性",
+    "节奏策略", "毒点", "推荐基础检索表", "推荐动态检索表", "默认查询词",
+]
+
+REASONING_HEADERS = [
+    "编号", "适用技能", "分类", "层级", "关键词", "意图与同义词", "适用题材",
+    "大模型指令", "核心摘要", "详细展开",
+    "题材", "风格优先级", "爽点优先级", "节奏默认策略",
+    "毒点权重", "冲突裁决", "contract注入层", "反模式",
+]
+
+
+def _setup_csvs(csv_dir):
+    _write_csv(csv_dir / "题材与调性推理.csv", ROUTE_HEADERS, [{
+        "编号": "GR-001", "适用技能": "write|plan", "分类": "题材路由",
+        "层级": "知识补充", "关键词": "玄幻", "意图与同义词": "玄幻|仙侠",
+        "适用题材": "玄幻", "大模型指令": "", "核心摘要": "", "详细展开": "",
+        "题材/流派": "玄幻", "题材别名": "玄幻", "核心调性": "热血冲突",
+        "节奏策略": "快推慢收", "毒点": "圣母病",
+        "推荐基础检索表": "命名规则|人设与关系",
+        "推荐动态检索表": "桥段套路|爽点与节奏",
+        "默认查询词": "玄幻",
+    }])
+
+    _write_csv(csv_dir / "裁决规则.csv", REASONING_HEADERS, [{
+        "编号": "RS-001", "适用技能": "write|plan", "分类": "裁决",
+        "层级": "推理层", "关键词": "玄幻", "意图与同义词": "玄幻",
+        "适用题材": "玄幻", "大模型指令": "", "核心摘要": "", "详细展开": "",
+        "题材": "玄幻",
+        "风格优先级": "热血冲突 > 冷硬算计",
+        "爽点优先级": "实力碾压 > 逆境翻盘",
+        "节奏默认策略": "快推慢收",
+        "毒点权重": "圣母病 > 情绪标签化",
+        "冲突裁决": "爽点与节奏 > 场景写法 > 写作技法",
+        "contract注入层": "CHAPTER_BRIEF.writing_guidance",
+        "反模式": "情绪标签化|战斗无代价",
+    }])
+
+    _write_csv(csv_dir / "桥段套路.csv",
+        ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "桥段名称", "毒点"],
+        [{"编号": "TR-001", "适用技能": "write", "分类": "桥段", "层级": "知识补充",
+          "关键词": "退婚", "适用题材": "玄幻", "核心摘要": "退婚反击",
+          "桥段名称": "退婚反击", "毒点": "配角代打"}])
+
+    _write_csv(csv_dir / "爽点与节奏.csv",
+        ["编号", "适用技能", "分类", "层级", "关键词", "适用题材", "核心摘要", "毒点", "节奏类型"],
+        [{"编号": "PA-001", "适用技能": "write", "分类": "节奏", "层级": "知识补充",
+          "关键词": "打脸", "适用题材": "玄幻", "核心摘要": "兑现必须补刀",
+          "毒点": "打脸软收尾", "节奏类型": "爆发期"}])
+
+
+def test_build_with_reasoning_includes_reasoning_rule_in_source_trace(tmp_path):
+    csv_dir = tmp_path / "csv"
+    csv_dir.mkdir()
+    _setup_csvs(csv_dir)
+
+    engine = StorySystemEngine(csv_dir=csv_dir)
+    contract = engine.build(query="玄幻", genre=None, chapter=5)
+
+    traces = contract["master_setting"]["source_trace"]
+    reasoning_traces = [t for t in traces if t.get("reasoning_rule")]
+    assert len(reasoning_traces) >= 1
+    assert reasoning_traces[0]["reasoning_rule"] == "玄幻"
+
+
+def test_reasoning_anti_patterns_sorted_by_weight(tmp_path):
+    csv_dir = tmp_path / "csv"
+    csv_dir.mkdir()
+    _setup_csvs(csv_dir)
+
+    engine = StorySystemEngine(csv_dir=csv_dir)
+    contract = engine.build(query="玄幻", genre=None, chapter=5)
+
+    anti = contract["anti_patterns"]
+    assert len(anti) >= 1
+
+
+def test_reasoning_not_found_falls_back_gracefully(tmp_path):
+    csv_dir = tmp_path / "csv"
+    csv_dir.mkdir()
+    _setup_csvs(csv_dir)
+
+    engine = StorySystemEngine(csv_dir=csv_dir)
+    contract = engine.build(query="末日生存", genre="末日", chapter=1)
+
+    # 没有裁决规则也不应报错
+    assert "master_setting" in contract
+    assert "anti_patterns" in contract
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_reasoning_engine.py -v`
+
+预期:FAIL,因为 `_load_reasoning` 等方法还不存在。
+
+- [ ] **Step 3: 在 `story_system_engine.py` 新增裁决方法**
+
+在 `StorySystemEngine` 类末尾新增:
+
+```python
+def _load_reasoning(self, genre: str) -> Dict[str, Any]:
+    """从裁决表按题材查一行,返回裁决规则 dict。"""
+    rows = self._load_csv_rows("裁决规则")
+    genre_text = self._normalize_text(genre)
+    for row in rows:
+        row_genre = self._normalize_text(row.get("题材", ""))
+        if row_genre == genre_text:
+            return row
+        aliases = self._split_multi_value(row.get("关键词")) + self._split_multi_value(row.get("意图与同义词"))
+        if any(self._normalize_text(a) == genre_text for a in aliases):
+            return row
+    return {}
+
+def _apply_reasoning(
+    self,
+    reasoning: Dict[str, Any],
+    base_context: List[Dict[str, Any]],
+    dynamic_context: List[Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+    """用冲突裁决字段对命中条目做优先级排序。"""
+    if not reasoning:
+        return base_context + dynamic_context
+
+    priority_order = [
+        t.strip() for t in str(reasoning.get("冲突裁决", "")).split(">") if t.strip()
+    ]
+    priority_map = {name: idx for idx, name in enumerate(priority_order)}
+
+    all_rows = base_context + dynamic_context
+    for row in all_rows:
+        table_name = str(row.get("_table", "")).strip()
+        row["_priority_rank"] = priority_map.get(table_name, len(priority_order))
+        row["_reasoning_rule"] = str(reasoning.get("题材", "")).strip()
+
+    all_rows.sort(key=lambda r: r.get("_priority_rank", 999))
+    return all_rows
+
+def _rank_anti_patterns(
+    self,
+    reasoning: Dict[str, Any],
+    anti_patterns: List[Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+    """用毒点权重字段对毒点排序。"""
+    if not reasoning:
+        return anti_patterns
+
+    weight_order = [
+        t.strip() for t in str(reasoning.get("毒点权重", "")).split(">") if t.strip()
+    ]
+
+    def sort_key(item):
+        text = str(item.get("text", "")).strip()
+        for idx, keyword in enumerate(weight_order):
+            if keyword in text:
+                return idx
+        return len(weight_order)
+
+    anti_patterns.sort(key=sort_key)
+
+    # 追加裁决表自带的反模式
+    for text in self._split_multi_value(reasoning.get("反模式")):
+        anti_patterns.append({
+            "text": text,
+            "source_table": "裁决规则",
+            "source_id": reasoning.get("编号", ""),
+        })
+
+    return anti_patterns
+```
+
+- [ ] **Step 4: 改造 `build()` 方法接入裁决层**
+
+把 `build()` 方法(第 29-90 行)改为:
+
+```python
+def build(self, query: str, genre: Optional[str], chapter: Optional[int]) -> Dict[str, Any]:
+    route = self._route(query=query, genre=genre)
+    search_query = self._expand_query(query, route.get("default_query", ""))
+    base_context = self._collect_tables(
+        search_query,
+        route["recommended_base_tables"],
+        genre=route["genre_filter"],
+        top_k=1,
+    )
+    dynamic_context = self._collect_tables(
+        search_query,
+        route["recommended_dynamic_tables"],
+        genre=route["genre_filter"],
+        top_k=2,
+    )
+
+    # --- 裁决层 ---
+    primary_genre = str(
+        route.get("meta", {}).get("primary_genre", "") or genre or ""
+    ).strip()
+    reasoning = self._load_reasoning(primary_genre)
+    ranked = self._apply_reasoning(reasoning, base_context, dynamic_context)
+
+    source_trace = route["source_trace"] + self._build_source_trace_with_reasoning(ranked, reasoning)
+
+    raw_anti = merge_anti_patterns(
+        route["route_anti_patterns"],
+        self._extract_anti_patterns(base_context),
+        self._extract_anti_patterns(dynamic_context),
+    )
+    anti_patterns = self._rank_anti_patterns(reasoning, raw_anti)
+
+    return {
+        "meta": {"query": query, "chapter": chapter, "explicit_genre": genre or ""},
+        "master_setting": {
+            "meta": {
+                "schema_version": "story-system/v1",
+                "contract_type": "MASTER_SETTING",
+                "generator_version": "phase1",
+                "query": query,
+            },
+            "route": route["meta"],
+            "master_constraints": {
+                "core_tone": route["core_tone"],
+                "pacing_strategy": route["pacing_strategy"],
+            },
+            "base_context": [r for r in ranked if r.get("_priority_rank", 999) < 999],
+            "source_trace": source_trace,
+            "override_policy": {
+                "locked": ["route.primary_genre", "master_constraints.core_tone"],
+                "append_only": ["anti_patterns"],
+                "override_allowed": [],
+            },
+        },
+        "chapter_brief": (
+            {
+                "meta": {
+                    "schema_version": "story-system/v1",
+                    "contract_type": "CHAPTER_BRIEF",
+                    "generator_version": "phase1",
+                    "chapter": chapter,
+                },
+                "override_allowed": {
+                    "chapter_focus": self._suggest_chapter_focus(query, dynamic_context),
+                },
+                "dynamic_context": ranked,
+                "source_trace": source_trace,
+                "reasoning": {
+                    "genre": reasoning.get("题材", ""),
+                    "inject_target": reasoning.get("contract注入层", ""),
+                    "style_priority": reasoning.get("风格优先级", ""),
+                    "pacing_strategy": reasoning.get("节奏默认策略", ""),
+                } if reasoning else {},
+            }
+            if chapter is not None
+            else None
+        ),
+        "anti_patterns": anti_patterns,
+    }
+```
+
+- [ ] **Step 5: 新增 `_build_source_trace_with_reasoning` 方法**
+
+```python
+def _build_source_trace_with_reasoning(
+    self, ranked: List[Dict[str, Any]], reasoning: Dict[str, Any]
+) -> List[Dict[str, Any]]:
+    trace: List[Dict[str, Any]] = []
+    reasoning_rule = str(reasoning.get("题材", "")).strip() if reasoning else ""
+    for row in ranked:
+        trace.append({
+            "table": row.get("_table", ""),
+            "id": row.get("编号", ""),
+            "summary": row.get("核心摘要", ""),
+            "reasoning_rule": row.get("_reasoning_rule", reasoning_rule),
+            "priority_rank": row.get("_priority_rank", 999),
+            "inject_target": str(reasoning.get("contract注入层", "")).strip() if reasoning else "",
+        })
+    return trace
+```
+
+- [ ] **Step 6: 运行裁决层测试**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_reasoning_engine.py -v`
+
+预期:全部 PASS。
+
+- [ ] **Step 7: 运行现有 engine 测试确认不破坏**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_story_system_engine.py -v`
+
+预期:全部 PASS(无裁决表时 graceful fallback)。
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add webnovel-writer/scripts/data_modules/story_system_engine.py webnovel-writer/scripts/data_modules/tests/test_reasoning_engine.py
+git commit -m "feat: integrate reasoning table into story_system_engine build pipeline"
+```
+
+---
+
+## Task 5: context_manager 瘦身
+
+**Files:**
+- Modify: `webnovel-writer/scripts/data_modules/context_manager.py`
+- Delete: `webnovel-writer/scripts/data_modules/snapshot_manager.py`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_context_manager.py`
+
+- [ ] **Step 1: 从 `context_manager.py` 删除 snapshot 相关代码**
+
+1. 删除 import:`from .snapshot_manager import SnapshotManager, SnapshotVersionMismatch`(第 33 行)
+2. 删除 `__init__` 中 `self.snapshot_manager` 赋值(第 101 行)
+3. 删除 `_is_snapshot_compatible` 方法(第 105-146 行)
+4. 删除 `build_context` 中 snapshot 加载和保存逻辑(第 162-169 行和第 176-181 行)
+5. 删除 `_story_contract_signature` 方法(第 794-817 行)
+6. 删除 `_payload_signature` 方法(第 819-823 行)
+7. 删除 `build_context` 的 `use_snapshot` 和 `save_snapshot` 参数
+
+- [ ] **Step 2: 简化 `build_context` 为纯 JSON 返回**
+
+改造后的 `build_context`:
+
+```python
+def build_context(
+    self,
+    chapter: int,
+    template: str | None = None,
+    max_chars: Optional[int] = None,
+) -> Dict[str, Any]:
+    template = template or self.DEFAULT_TEMPLATE
+    self._active_template = template
+    if template not in self.TEMPLATE_WEIGHTS:
+        template = self.DEFAULT_TEMPLATE
+        self._active_template = template
+
+    pack = self._build_pack(chapter)
+    if getattr(self.config, "context_ranker_enabled", True):
+        pack = self.context_ranker.rank_pack(pack, chapter)
+
+    return self._assemble_json_payload(pack, template=template, max_chars=max_chars)
+```
+
+- [ ] **Step 3: 把 `assemble_context` 重写为 `_assemble_json_payload`**
+
+直接返回 dict,不做 text 渲染:
+
+```python
+def _assemble_json_payload(
+    self,
+    pack: Dict[str, Any],
+    template: str = DEFAULT_TEMPLATE,
+    max_chars: Optional[int] = None,
+) -> Dict[str, Any]:
+    chapter = int((pack.get("meta") or {}).get("chapter") or 0)
+    weights = self._resolve_template_weights(template=template, chapter=chapter)
+
+    payload: Dict[str, Any] = {
+        "meta": {
+            **(pack.get("meta") or {}),
+            "context_contract_version": "v3",
+        },
+    }
+
+    for section_name in self.SECTION_ORDER:
+        if section_name in pack and section_name != "global":
+            content = pack[section_name]
+            weight = weights.get(section_name, 0.0)
+            if weight > 0 or section_name in self.EXTRA_SECTIONS:
+                payload[section_name] = content
+
+    if chapter > 0:
+        payload["meta"]["context_weight_stage"] = self._resolve_context_stage(chapter)
+
+    return payload
+```
+
+- [ ] **Step 4: 删除 `_compact_json_text` 方法**
+
+删除第 749-764 行。
+
+- [ ] **Step 5: 删除 `assemble_context` 旧方法**
+
+删除第 185-217 行的 `assemble_context`。
+
+- [ ] **Step 6: 更新 `__init__` 签名**
+
+```python
+def __init__(self, config=None):
+    self.config = config or get_config()
+    self.index_manager = IndexManager(self.config)
+    self.context_ranker = ContextRanker(self.config)
+```
+
+- [ ] **Step 7: 删除 `snapshot_manager.py`**
+
+```bash
+git rm webnovel-writer/scripts/data_modules/snapshot_manager.py
+```
+
+- [ ] **Step 8: 更新 `extract_chapter_context.py` 的 `_load_contract_context`**
+
+`_load_contract_context`(第 294-325 行)改为:
+
+```python
+def _load_contract_context(project_root: Path, chapter_num: int) -> Dict[str, Any]:
+    """Build context via ContextManager and return payload directly."""
+    _ensure_scripts_path()
+    from data_modules.config import DataModulesConfig
+    from data_modules.context_manager import ContextManager
+
+    config = DataModulesConfig.from_project_root(project_root)
+    manager = ContextManager(config)
+    payload = manager.build_context(chapter=chapter_num, template="plot")
+
+    return {
+        "context_contract_version": (payload.get("meta") or {}).get("context_contract_version"),
+        "context_weight_stage": (payload.get("meta") or {}).get("context_weight_stage"),
+        "story_contract": payload.get("story_contract", {}),
+        "runtime_status": payload.get("runtime_status", {}),
+        "latest_commit": payload.get("latest_commit", {}),
+        "prewrite_validation": payload.get("prewrite_validation", {}),
+        "reader_signal": payload.get("reader_signal", {}),
+        "genre_profile": payload.get("genre_profile", {}),
+        "writing_guidance": payload.get("writing_guidance", {}),
+        "plot_structure": payload.get("plot_structure", {}),
+        "long_term_memory": payload.get("long_term_memory", {}),
+        "scene": payload.get("scene", {}),
+        "core": payload.get("core", {}),
+    }
+```
+
+- [ ] **Step 9: 把 `_render_text()` 改为纯 JSON 序列化**
+
+当前 `_render_text()`(第 364-601 行)是一个 240 行的审计式文本渲染函数。按 spec 终局,text 渲染不再由代码层负责——context-agent 拿 JSON payload 按示例写任务书。
+
+把整个 `_render_text()` 替换为:
+
+```python
+def _render_text(payload: Dict[str, Any]) -> str:
+    """JSON 序列化输出,text 渲染由 context-agent 负责。"""
+    return json.dumps(payload, ensure_ascii=False, indent=2)
+```
+
+这意味着 `--format text` 和 `--format json` 现在输出相同内容。如果后续要区分,可以在 context-agent 侧处理,但代码层不再做 markdown 拼接。
+
+- [ ] **Step 10: 修复受影响的测试**
+
+在 `test_context_manager.py` 中:
+- 删除所有 `snapshot_manager` 相关的 mock 和 fixture
+- 删除 snapshot 相关的测试用例
+- 更新 `build_context` 调用移除 `use_snapshot` / `save_snapshot` 参数
+- 更新断言适配新的 payload 结构(直接 `payload["story_contract"]` 而不是 `payload["sections"]["story_contract"]["content"]`)
+
+在 `test_extract_chapter_context.py` 中:
+- 更新任何依赖旧 markdown 渲染输出的断言(如 `"## 本章大纲"` 等 markdown 标题检查改为 JSON key 检查)
+
+- [ ] **Step 11: 运行测试**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_context_manager.py scripts/data_modules/tests/test_extract_chapter_context.py -v`
+
+预期:全部 PASS。
+
+- [ ] **Step 12: 确认行数**
+
+Run: `wc -l webnovel-writer/scripts/data_modules/context_manager.py`
+
+预期:400 行以下。
+
+- [ ] **Step 13: Commit**
+
+```bash
+git add webnovel-writer/scripts/data_modules/context_manager.py webnovel-writer/scripts/extract_chapter_context.py webnovel-writer/scripts/data_modules/tests/test_context_manager.py
+git rm webnovel-writer/scripts/data_modules/snapshot_manager.py
+git commit -m "refactor: slim context_manager to pure JSON assembler, remove snapshot"
+```
+
+---
+
+## Task 6: 旧散写路径清理
+
+**Files:**
+- Modify: `webnovel-writer/skills/webnovel-write/SKILL.md:184,254,323`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py`
+
+- [ ] **Step 1: 删除 SKILL.md 中 Step 2 的 `set-chapter-status`**
+
+删除第 182-184 行:
+
+```markdown
+状态推进:
+
+```bash
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" state set-chapter-status --chapter {chapter_num} --status chapter_drafted
+```
+```
+
+- [ ] **Step 2: 删除 SKILL.md 中 Step 4 的 `set-chapter-status`**
+
+删除第 250-254 行的 `状态推进(--minimal 除外):` 段和对应的 bash 块。
+
+- [ ] **Step 3: 删除 SKILL.md 中 Step 5 末尾的 `set-chapter-status`**
+
+删除第 320-323 行的状态推进 bash 块。Step 5 的状态推进现在由 `state_projection_writer.py` 在 commit accepted 时自动完成。
+
+在 Step 5.3 验证投影状态段落中补充说明:
+
+```markdown
+**chapter_status 推进**:
+- accepted commit → `state_projection_writer` 自动推进到 `chapter_committed`
+- rejected commit → `state_projection_writer` 自动推进到 `chapter_rejected`
+- 不再由 skill 手动调用 `set-chapter-status`
+```
+
+- [ ] **Step 4: 更新充分性闸门**
+
+把第 338-346 行的闸门条件中:
+- 删除 "2. `chapter_status` 已推进到 `chapter_drafted`(Step 2 完成)"
+- 把 "5. ... `chapter_status` 已推进到 `chapter_reviewed`" 中状态检查改为仅由投影确认
+- 把 "6. ... `chapter_status` 已推进到 `chapter_committed`" 改为 "6. ... projection_status 四项全部 done/skipped"
+
+改为:
+
+```markdown
+## 充分性闸门
+
+未满足以下条件前,不得结束流程:
+
+1. 章节正文文件存在且非空。
+2. Step 3 已产出审查结果并落库(`--minimal` 除外)。
+3. 若存在 `blocking=true` 的 issue,流程必须停在 Step 3。
+4. Step 4 的 `anti_ai_force_check=pass`(`--minimal` 除外)。
+5. Step 5 已生成 accepted `CHAPTER_COMMIT`,`projection_status` 四项全部为 `done` 或 `skipped`。
+6. `chapter_status` 为 `chapter_committed`(由 projection writer 自动推进,不手动写入)。
+7. 若启用观测,已读取最新 timing 记录并给出结论。
+```
+
+- [ ] **Step 5: 新增 prompt integrity 测试断言**
+
+在 `test_prompt_integrity.py` 末尾新增:
+
+```python
+def test_no_direct_state_writes_in_write_skill():
+    """webnovel-write SKILL.md 中不应有 set-chapter-status 调用(由 projection writer 统一推进)。"""
+    text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
+    assert "state set-chapter-status" not in text, (
+        "webnovel-write 中不应直接调用 state set-chapter-status,"
+        "chapter_status 由 state_projection_writer 在 commit 时自动推进"
+    )
+
+
+def test_no_direct_state_writes_in_agents():
+    """agents 目录中不应有直接写 state/index 的指令。"""
+    for agent_file in AGENT_FILES:
+        text = _read_text(agent_file)
+        assert "state set-chapter-status" not in text, (
+            f"{agent_file.name}: 不应直接调用 state set-chapter-status"
+        )
+```
+
+- [ ] **Step 6: 运行测试**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_prompt_integrity.py -v`
+
+预期:全部 PASS。
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add webnovel-writer/skills/webnovel-write/SKILL.md webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py
+git commit -m "refactor: remove direct set-chapter-status calls from write skill"
+```
+
+---
+
+## Task 7: projection 层收束
+
+**Files:**
+- Modify: `webnovel-writer/scripts/data_modules/event_projection_router.py`
+- Modify: `webnovel-writer/scripts/data_modules/state_projection_writer.py`
+- Modify: `webnovel-writer/scripts/data_modules/chapter_commit_service.py`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py`
+
+- [ ] **Step 1: 写 router vector 路由测试**
+
+在 `test_event_projection_router.py` 末尾新增:
+
+```python
+def test_router_maps_power_breakthrough_to_state_memory_vector():
+    router = EventProjectionRouter()
+    targets = router.route(
+        {"event_type": "power_breakthrough", "subject": "xiaoyan", "payload": {}}
+    )
+    assert "vector" in targets
+    assert "state" in targets
+    assert "memory" in targets
+
+
+def test_router_maps_relationship_changed_to_index_and_vector():
+    router = EventProjectionRouter()
+    targets = router.route(
+        {"event_type": "relationship_changed", "subject": "xiaoyan", "payload": {}}
+    )
+    assert "index" in targets
+    assert "vector" in targets
+
+
+def test_required_writers_includes_vector_for_key_events():
+    router = EventProjectionRouter()
+    payload = {
+        "meta": {"status": "accepted", "chapter": 5},
+        "accepted_events": [
+            {"event_type": "power_breakthrough", "subject": "xiaoyan", "payload": {}},
+        ],
+        "entity_deltas": [],
+        "summary_text": "摘要",
+    }
+    writers = router.required_writers(payload)
+    assert "vector" in writers
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_event_projection_router.py -v`
+
+预期:新增的 3 个测试 FAIL。
+
+- [ ] **Step 3: 更新 `EventProjectionRouter.TABLE`**
+
+```python
+class EventProjectionRouter:
+    TABLE = {
+        "character_state_changed": ["state", "memory", "vector"],
+        "power_breakthrough": ["state", "memory", "vector"],
+        "relationship_changed": ["index", "vector"],
+        "world_rule_revealed": ["memory", "vector"],
+        "world_rule_broken": ["memory", "vector"],
+        "open_loop_created": ["memory"],
+        "open_loop_closed": ["memory"],
+        "promise_created": ["memory"],
+        "promise_paid_off": ["memory"],
+        "artifact_obtained": ["index", "vector"],
+    }
+```
+
+- [ ] **Step 4: 确认 `state_projection_writer` 已处理 `chapter_status`**
+
+当前 `state_projection_writer.py:34-35` 已有:
+
+```python
+if chapter > 0:
+    chapter_status[str(chapter)] = "chapter_committed"
+```
+
+这已经满足 Section 6/7 的要求(accepted commit 时自动推进到 `chapter_committed`)。
+
+确认 rejected commit 时不推进——当前第 15 行检查了 `status != "accepted"` 直接返回,不写状态。这是正确的。
+
+但 spec 要求 rejected 推进到 `chapter_rejected`。在 `apply` 方法开头加 rejected 处理:
+
+```python
+def apply(self, commit_payload: dict) -> dict:
+    chapter = int(commit_payload.get("meta", {}).get("chapter") or 0)
+    status = commit_payload["meta"]["status"]
+
+    if status == "rejected":
+        if chapter > 0:
+            state_path = self.project_root / ".webnovel" / "state.json"
+            state = read_json_if_exists(state_path) or {}
+            progress = state.setdefault("progress", {})
+            chapter_status = progress.setdefault("chapter_status", {})
+            chapter_status[str(chapter)] = "chapter_rejected"
+            write_json(state_path, state)
+        return {"applied": True, "writer": "state", "reason": "commit_rejected_status_updated"}
+
+    # ... rest of accepted logic
+```
+
+- [ ] **Step 5: 确认 `chapter_commit_service.apply_projections` 的失败隔离**
+
+当前第 115-119 行已有 try/except 隔离:
+
+```python
+try:
+    result = writer.apply(payload)
+    payload["projection_status"][name] = "done" if result.get("applied") else "skipped"
+except Exception as exc:
+    payload["projection_status"][name] = f"failed:{exc}"
+```
+
+并且第 120 行 `self.persist_commit(payload)` 在所有 writer 执行完后才写入——确保 `projection_status` 已更新。
+
+这已满足 spec 要求。不需要额外改动。
+
+- [ ] **Step 6: 运行测试**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_event_projection_router.py -v`
+
+预期:全部 PASS。
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add webnovel-writer/scripts/data_modules/event_projection_router.py webnovel-writer/scripts/data_modules/state_projection_writer.py webnovel-writer/scripts/data_modules/tests/test_event_projection_router.py
+git commit -m "feat: add vector route to projection router, handle rejected status"
+```
+
+---
+
+## Task 8: 消费端同步
+
+**Files:**
+- Modify: `webnovel-writer/agents/context-agent.md`
+- Modify: `webnovel-writer/agents/data-agent.md`
+- Modify: `webnovel-writer/skills/webnovel-write/SKILL.md`
+- Modify: `webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py`
+
+- [ ] **Step 1: 更新 `context-agent.md` 删除旧引用**
+
+1. Section 2 工具段落中,确认 `extract-context` 命令标注为"备选"(已是,不动)
+2. 删除对 snapshot 的引用(grep 确认是否有)
+3. 确认 Section 8 的输出格式中有写作任务书示例(已在上一次改造中完成)
+
+- [ ] **Step 2: 确认 `data-agent.md` 不直写**
+
+当前 `data-agent.md` 已明确标注:
+- "你不直接写入这些文件" (第 111 行)
+- "不直接写入 `index.db` 和 `state.json`" (第 146 行)
+
+确认不需要改动。
+
+- [ ] **Step 3: 更新 `SKILL.md` 中 Step 5 简化描述**
+
+把 Step 5.4 的失败隔离表格中增加 vector 相关条目(如果还没有的话)。
+
+确认 Step 1 的写作任务书流程描述和当前代码对齐(已在上一次改造中完成)。
+
+- [ ] **Step 4: 在 `test_prompt_integrity.py` 中更新 `KNOWN_DELETED_FILES`**
+
+新增 `snapshot_manager.py` 到已删文件列表:
+
+```python
+KNOWN_DELETED_FILES = [
+    "step-1.5-contract.md",
+    "step-3-review-gate.md",
+    "step-5-debt-switch.md",
+    "workflow-details.md",
+    "checker-output-schema.md",
+    "workflow_manager.py",
+    "webnovel-resume",
+    "golden_three_checker.py",
+    "snapshot_manager.py",
+]
+```
+
+- [ ] **Step 5: 运行全量 prompt integrity 测试**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_prompt_integrity.py -v`
+
+预期:全部 PASS。
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add webnovel-writer/agents/context-agent.md webnovel-writer/agents/data-agent.md webnovel-writer/skills/webnovel-write/SKILL.md webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py
+git commit -m "refactor: sync consumer prompts with new mainline"
+```
+
+---
+
+## Task 9: 向量投影 Writer
+
+**Files:**
+- Create: `webnovel-writer/scripts/data_modules/vector_projection_writer.py`
+- Modify: `webnovel-writer/scripts/data_modules/chapter_commit_service.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py`
+
+- [ ] **Step 1: 写测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""VectorProjectionWriter 单元测试。"""
+from data_modules.vector_projection_writer import VectorProjectionWriter
+
+
+def test_event_to_text_formats_power_breakthrough():
+    writer = VectorProjectionWriter.__new__(VectorProjectionWriter)
+    event = {
+        "event_type": "power_breakthrough",
+        "chapter": 47,
+        "subject": "韩立",
+        "payload": {"field": "realm", "new": "筑基初期"},
+    }
+    text = writer._event_to_text(event)
+    assert "第47章" in text
+    assert "韩立" in text
+    assert "筑基初期" in text
+
+
+def test_delta_to_text_formats_relationship():
+    writer = VectorProjectionWriter.__new__(VectorProjectionWriter)
+    delta = {
+        "from_entity": "韩立",
+        "to_entity": "陈巧倩",
+        "relationship_type": "合作",
+        "chapter": 47,
+    }
+    text = writer._delta_to_text(delta)
+    assert "第47章" in text
+    assert "韩立" in text
+    assert "陈巧倩" in text
+    assert "合作" in text
+
+
+def test_collect_chunks_from_commit():
+    writer = VectorProjectionWriter.__new__(VectorProjectionWriter)
+    payload = {
+        "meta": {"chapter": 47, "status": "accepted"},
+        "accepted_events": [
+            {
+                "event_type": "power_breakthrough",
+                "chapter": 47,
+                "subject": "韩立",
+                "payload": {"field": "realm", "new": "筑基初期"},
+            },
+        ],
+        "entity_deltas": [
+            {
+                "from_entity": "韩立",
+                "to_entity": "陈巧倩",
+                "relationship_type": "合作",
+                "chapter": 47,
+            },
+        ],
+    }
+    chunks = writer._collect_chunks(payload)
+    assert len(chunks) == 2
+    assert chunks[0]["chunk_type"] == "event"
+    assert chunks[1]["chunk_type"] == "entity_delta"
+
+
+def test_rejected_commit_returns_not_applied():
+    writer = VectorProjectionWriter.__new__(VectorProjectionWriter)
+    writer.project_root = None  # won't be used
+    result = writer.apply({"meta": {"status": "rejected", "chapter": 1}})
+    assert result["applied"] is False
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_vector_projection_writer.py -v`
+
+预期:FAIL(模块不存在)。
+
+- [ ] **Step 3: 实现 `vector_projection_writer.py`**
+
+```python
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import asyncio
+import logging
+from pathlib import Path
+from typing import Any, Dict, List
+
+logger = logging.getLogger(__name__)
+
+
+class VectorProjectionWriter:
+    def __init__(self, project_root: Path):
+        self.project_root = Path(project_root)
+
+    def apply(self, commit_payload: dict) -> dict:
+        if commit_payload["meta"]["status"] != "accepted":
+            return {"applied": False, "writer": "vector", "reason": "commit_rejected"}
+
+        chunks = self._collect_chunks(commit_payload)
+        if not chunks:
+            return {"applied": False, "writer": "vector", "reason": "no_chunks"}
+
+        try:
+            stored = self._store_chunks(chunks)
+            return {"applied": stored > 0, "writer": "vector", "stored": stored}
+        except Exception as exc:
+            logger.warning("vector_projection_failed: %s", exc)
+            return {"applied": False, "writer": "vector", "reason": f"error:{exc}"}
+
+    def _collect_chunks(self, commit_payload: dict) -> List[Dict[str, Any]]:
+        chunks: List[Dict[str, Any]] = []
+        chapter = int(commit_payload.get("meta", {}).get("chapter") or 0)
+
+        for event in commit_payload.get("accepted_events") or []:
+            if not isinstance(event, dict):
+                continue
+            text = self._event_to_text(event)
+            if text:
+                evt_chapter = int(event.get("chapter") or chapter)
+                chunks.append({
+                    "chapter": evt_chapter,
+                    "scene_index": 0,
+                    "content": text,
+                    "chunk_type": "event",
+                    "parent_chunk_id": f"ch{evt_chapter:04d}_summary",
+                    "source_file": f"commit:chapter_{evt_chapter:03d}",
+                })
+
+        for delta in commit_payload.get("entity_deltas") or []:
+            if not isinstance(delta, dict):
+                continue
+            text = self._delta_to_text(delta)
+            if text:
+                d_chapter = int(delta.get("chapter") or chapter)
+                chunks.append({
+                    "chapter": d_chapter,
+                    "scene_index": 0,
+                    "content": text,
+                    "chunk_type": "entity_delta",
+                    "parent_chunk_id": f"ch{d_chapter:04d}_summary",
+                    "source_file": f"commit:chapter_{d_chapter:03d}",
+                })
+
+        return chunks
+
+    def _event_to_text(self, event: dict) -> str:
+        chapter = int(event.get("chapter") or 0)
+        subject = str(event.get("subject") or "").strip()
+        event_type = str(event.get("event_type") or "").strip()
+        payload = event.get("payload") or {}
+
+        if event_type == "power_breakthrough":
+            new_val = str(payload.get("new") or payload.get("to") or "").strip()
+            return f"第{chapter}章:{subject}突破至{new_val}" if new_val else ""
+        elif event_type == "character_state_changed":
+            field = str(payload.get("field") or "").strip()
+            new_val = str(payload.get("new") or payload.get("to") or "").strip()
+            return f"第{chapter}章:{subject}的{field}变为{new_val}" if field and new_val else ""
+        elif event_type == "relationship_changed":
+            to_entity = str(payload.get("to_entity") or payload.get("to") or "").strip()
+            rel_type = str(
+                payload.get("relationship_type") or payload.get("type") or ""
+            ).strip()
+            return f"第{chapter}章:{subject}与{to_entity}关系变为{rel_type}" if to_entity else ""
+        elif event_type in ("world_rule_revealed", "world_rule_broken"):
+            desc = str(payload.get("description") or payload.get("rule") or "").strip()
+            action = "揭示" if "revealed" in event_type else "打破"
+            return f"第{chapter}章:{action}世界规则——{desc}" if desc else ""
+        elif event_type == "artifact_obtained":
+            name = str(payload.get("name") or subject or "").strip()
+            owner = str(payload.get("owner") or payload.get("holder") or "").strip()
+            return f"第{chapter}章:{owner}获得{name}" if owner else f"第{chapter}章:获得{name}"
+        return ""
+
+    def _delta_to_text(self, delta: dict) -> str:
+        chapter = int(delta.get("chapter") or 0)
+        from_e = str(delta.get("from_entity") or "").strip()
+        to_e = str(delta.get("to_entity") or "").strip()
+        rel = str(delta.get("relationship_type") or "").strip()
+
+        if from_e and to_e and rel:
+            return f"第{chapter}章:{from_e}与{to_e}关系变为{rel}"
+
+        entity_id = str(delta.get("entity_id") or "").strip()
+        canonical = str(delta.get("canonical_name") or entity_id).strip()
+        if entity_id:
+            return f"第{chapter}章:实体变更——{canonical}"
+        return ""
+
+    def _store_chunks(self, chunks: List[Dict[str, Any]]) -> int:
+        from .config import DataModulesConfig
+        from .rag_adapter import RAGAdapter
+
+        config = DataModulesConfig.from_project_root(self.project_root)
+        adapter = RAGAdapter(config)
+        try:
+            stored = asyncio.run(adapter.store_chunks(chunks))
+            return stored
+        except Exception as exc:
+            logger.warning("vector_store_failed: %s", exc)
+            return 0
+```
+
+- [ ] **Step 4: 在 `chapter_commit_service.py` 注册 vector writer**
+
+在 `apply_projections` 方法中(第 104-109 行),加入 vector writer:
+
+```python
+from .vector_projection_writer import VectorProjectionWriter
+
+writers = {
+    "state": StateProjectionWriter(self.project_root),
+    "index": IndexProjectionWriter(self.project_root),
+    "summary": SummaryProjectionWriter(self.project_root),
+    "memory": MemoryProjectionWriter(self.project_root),
+    "vector": VectorProjectionWriter(self.project_root),
+}
+```
+
+同时在 `build_commit` 的 `projection_status` 中加 `"vector": "pending"`:
+
+```python
+"projection_status": {
+    "state": "pending",
+    "index": "pending",
+    "summary": "pending",
+    "memory": "pending",
+    "vector": "pending",
+},
+```
+
+- [ ] **Step 5: 运行测试**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_vector_projection_writer.py -v`
+
+预期:全部 PASS。
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add webnovel-writer/scripts/data_modules/vector_projection_writer.py webnovel-writer/scripts/data_modules/chapter_commit_service.py webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py
+git commit -m "feat: add vector_projection_writer for event/entity embedding"
+```
+
+---
+
+## Task 10: 时序查询接口
+
+**Files:**
+- Create: `webnovel-writer/scripts/data_modules/knowledge_query.py`
+- Create: `webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py`
+- Modify: `webnovel-writer/scripts/data_modules/webnovel.py` (register CLI subcommand)
+
+- [ ] **Step 1: 写测试**
+
+```python
+# webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""KnowledgeQuery 时序查询测试。"""
+import json
+import sqlite3
+from pathlib import Path
+
+import pytest
+
+from data_modules.knowledge_query import KnowledgeQuery
+
+
+@pytest.fixture
+def setup_db(tmp_path):
+    """创建带 state_changes 和 relationship_events 表的测试 DB。"""
+    db_path = tmp_path / ".webnovel" / "index.db"
+    db_path.parent.mkdir(parents=True)
+
+    conn = sqlite3.connect(str(db_path))
+    conn.execute("""
+        CREATE TABLE IF NOT EXISTS entities (
+            id TEXT PRIMARY KEY,
+            canonical_name TEXT,
+            type TEXT DEFAULT '角色',
+            current_json TEXT DEFAULT '{}',
+            created_at TEXT,
+            updated_at TEXT
+        )
+    """)
+    conn.execute("""
+        CREATE TABLE IF NOT EXISTS state_changes (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            entity_id TEXT,
+            field TEXT,
+            old_value TEXT,
+            new_value TEXT,
+            chapter INTEGER,
+            created_at TEXT
+        )
+    """)
+    conn.execute("""
+        CREATE TABLE IF NOT EXISTS relationship_events (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            from_entity TEXT,
+            to_entity TEXT,
+            relationship_type TEXT,
+            description TEXT,
+            chapter INTEGER,
+            created_at TEXT
+        )
+    """)
+
+    # 插入测试数据
+    conn.execute(
+        "INSERT INTO entities (id, canonical_name, current_json) VALUES (?, ?, ?)",
+        ("hanli", "韩立", json.dumps({"realm": "筑基中期", "location": "乱星海"})),
+    )
+    conn.execute(
+        "INSERT INTO state_changes (entity_id, field, old_value, new_value, chapter) VALUES (?, ?, ?, ?, ?)",
+        ("hanli", "realm", "练气圆满", "筑基初期", 30),
+    )
+    conn.execute(
+        "INSERT INTO state_changes (entity_id, field, old_value, new_value, chapter) VALUES (?, ?, ?, ?, ?)",
+        ("hanli", "realm", "筑基初期", "筑基中期", 50),
+    )
+    conn.execute(
+        "INSERT INTO relationship_events (from_entity, to_entity, relationship_type, chapter) VALUES (?, ?, ?, ?)",
+        ("hanli", "陈巧倩", "同门", 20),
+    )
+    conn.execute(
+        "INSERT INTO relationship_events (from_entity, to_entity, relationship_type, chapter) VALUES (?, ?, ?, ?)",
+        ("hanli", "陈巧倩", "合作", 45),
+    )
+    conn.commit()
+    conn.close()
+
+    return tmp_path
+
+
+def test_entity_state_at_chapter_before_first_change(setup_db):
+    kq = KnowledgeQuery(setup_db)
+    result = kq.entity_state_at_chapter("hanli", 10)
+    # 第10章在第一次 state_change 之前,应返回空变更
+    assert result["entity_id"] == "hanli"
+    assert result["state_at_chapter"] == {}
+
+
+def test_entity_state_at_chapter_after_first_breakthrough(setup_db):
+    kq = KnowledgeQuery(setup_db)
+    result = kq.entity_state_at_chapter("hanli", 35)
+    assert result["state_at_chapter"]["realm"] == "筑基初期"
+
+
+def test_entity_state_at_chapter_after_second_breakthrough(setup_db):
+    kq = KnowledgeQuery(setup_db)
+    result = kq.entity_state_at_chapter("hanli", 60)
+    assert result["state_at_chapter"]["realm"] == "筑基中期"
+
+
+def test_relationships_at_chapter_before_any(setup_db):
+    kq = KnowledgeQuery(setup_db)
+    result = kq.entity_relationships_at_chapter("hanli", 10)
+    assert result["relationships"] == []
+
+
+def test_relationships_at_chapter_after_first(setup_db):
+    kq = KnowledgeQuery(setup_db)
+    result = kq.entity_relationships_at_chapter("hanli", 25)
+    assert len(result["relationships"]) == 1
+    assert result["relationships"][0]["to_entity"] == "陈巧倩"
+    assert result["relationships"][0]["relationship_type"] == "同门"
+
+
+def test_relationships_at_chapter_after_update(setup_db):
+    kq = KnowledgeQuery(setup_db)
+    result = kq.entity_relationships_at_chapter("hanli", 50)
+    rels = result["relationships"]
+    assert len(rels) == 1
+    assert rels[0]["relationship_type"] == "合作"
+```
+
+- [ ] **Step 2: 运行测试确认失败**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_knowledge_query.py -v`
+
+预期:FAIL。
+
+- [ ] **Step 3: 实现 `knowledge_query.py`**
+
+```python
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import sqlite3
+from pathlib import Path
+from typing import Any, Dict, List
+
+
+class KnowledgeQuery:
+    def __init__(self, project_root: Path):
+        self.project_root = Path(project_root)
+        self._db_path = self.project_root / ".webnovel" / "index.db"
+
+    def entity_state_at_chapter(self, entity_id: str, chapter: int) -> Dict[str, Any]:
+        """查询实体在指定章节时的状态(从 state_changes 反推)。"""
+        conn = sqlite3.connect(str(self._db_path))
+        conn.row_factory = sqlite3.Row
+        try:
+            rows = conn.execute(
+                """
+                SELECT field, new_value
+                FROM state_changes
+                WHERE entity_id = ? AND chapter <= ?
+                ORDER BY chapter ASC, id ASC
+                """,
+                (entity_id, chapter),
+            ).fetchall()
+
+            state: Dict[str, str] = {}
+            for row in rows:
+                field = str(row["field"] or "").strip()
+                if field:
+                    state[field] = str(row["new_value"] or "").strip()
+
+            return {
+                "entity_id": entity_id,
+                "at_chapter": chapter,
+                "state_at_chapter": state,
+            }
+        finally:
+            conn.close()
+
+    def entity_relationships_at_chapter(self, entity_id: str, chapter: int) -> Dict[str, Any]:
+        """查询实体在指定章节时的所有关系(从 relationship_events 计算快照)。"""
+        conn = sqlite3.connect(str(self._db_path))
+        conn.row_factory = sqlite3.Row
+        try:
+            rows = conn.execute(
+                """
+                SELECT from_entity, to_entity, relationship_type, description, chapter
+                FROM relationship_events
+                WHERE (from_entity = ? OR to_entity = ?) AND chapter <= ?
+                ORDER BY chapter ASC, id ASC
+                """,
+                (entity_id, entity_id, chapter),
+            ).fetchall()
+
+            # 用最新的关系覆盖旧关系(按 pair 去重,保留最新)
+            latest: Dict[str, Dict[str, Any]] = {}
+            for row in rows:
+                from_e = str(row["from_entity"] or "").strip()
+                to_e = str(row["to_entity"] or "").strip()
+                pair_key = tuple(sorted([from_e, to_e]))
+                latest[str(pair_key)] = {
+                    "from_entity": from_e,
+                    "to_entity": to_e,
+                    "relationship_type": str(row["relationship_type"] or "").strip(),
+                    "description": str(row["description"] or "").strip(),
+                    "since_chapter": int(row["chapter"] or 0),
+                }
+
+            return {
+                "entity_id": entity_id,
+                "at_chapter": chapter,
+                "relationships": list(latest.values()),
+            }
+        finally:
+            conn.close()
+```
+
+- [ ] **Step 4: 运行测试**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_knowledge_query.py -v`
+
+预期:全部 PASS。
+
+- [ ] **Step 5: 注册 `knowledge` CLI 子命令**
+
+在 `webnovel-writer/scripts/data_modules/webnovel.py` 中注册 `knowledge` 子命令。找到 subparser 注册区域(grep `add_parser`),新增:
+
+```python
+# knowledge 子命令
+knowledge_parser = subparsers.add_parser("knowledge", help="时序知识查询")
+knowledge_sub = knowledge_parser.add_subparsers(dest="knowledge_action")
+
+qs_parser = knowledge_sub.add_parser("query-entity-state", help="查询实体在指定章节的状态")
+qs_parser.add_argument("--entity", required=True, help="实体 ID")
+qs_parser.add_argument("--at-chapter", type=int, required=True, help="目标章节号")
+
+qr_parser = knowledge_sub.add_parser("query-relationships", help="查询实体在指定章节的关系")
+qr_parser.add_argument("--entity", required=True, help="实体 ID")
+qr_parser.add_argument("--at-chapter", type=int, required=True, help="目标章节号")
+```
+
+在命令分发区域新增 handler:
+
+```python
+if args.command == "knowledge":
+    from .knowledge_query import KnowledgeQuery
+    kq = KnowledgeQuery(project_root)
+    if args.knowledge_action == "query-entity-state":
+        result = kq.entity_state_at_chapter(args.entity, args.at_chapter)
+        print_success(result, message="entity_state_at_chapter")
+    elif args.knowledge_action == "query-relationships":
+        result = kq.entity_relationships_at_chapter(args.entity, args.at_chapter)
+        print_success(result, message="entity_relationships_at_chapter")
+```
+
+- [ ] **Step 6: 同步 `REGISTERED_CLI_SUBCOMMANDS` 和 `context-agent.md`**
+
+在 `test_prompt_integrity.py` 的 `REGISTERED_CLI_SUBCOMMANDS`(第 32-38 行)中新增 `"knowledge"`:
+
+```python
+REGISTERED_CLI_SUBCOMMANDS = {
+    "where", "preflight", "use",
+    "index", "state", "rag", "style", "entity", "context", "memory",
+    "migrate", "status", "update-state", "backup", "archive",
+    "init", "extract-context", "memory-contract", "review-pipeline",
+    "story-system", "chapter-commit", "story-events",
+    "knowledge",
+}
+```
+
+在 `context-agent.md` Section 2 的"补充命令"段落中新增:
+
+```bash
+# 时序知识查询(查询某实体在指定章节时的状态和关系)
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" knowledge query-entity-state --entity "{entity_id}" --at-chapter {N}
+python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "{project_root}" knowledge query-relationships --entity "{entity_id}" --at-chapter {N}
+```
+
+- [ ] **Step 7: 运行全量测试**
+
+Run: `cd webnovel-writer && python -m pytest scripts/data_modules/tests/ scripts/tests/ -v --timeout=60`
+
+预期:全部 PASS。
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add webnovel-writer/scripts/data_modules/knowledge_query.py webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py webnovel-writer/scripts/data_modules/webnovel.py webnovel-writer/agents/context-agent.md webnovel-writer/scripts/data_modules/tests/test_prompt_integrity.py
+git commit -m "feat: add knowledge_query temporal API with CLI and prompt sync"
+```
+
+---
+
+## Task 11: 最终集成验证
+
+**Files:** (read-only verification)
+
+- [ ] **Step 1: 运行全量测试套件**
+
+```bash
+cd webnovel-writer && python -m pytest scripts/data_modules/tests/ scripts/tests/ -v --timeout=120
+```
+
+预期:全部 PASS,0 FAIL。
+
+- [ ] **Step 2: grep 确认无残留散写**
+
+```bash
+grep -rn "state set-chapter-status" webnovel-writer/skills/ webnovel-writer/agents/ || echo "CLEAN"
+grep -rn "index process-chapter" webnovel-writer/skills/ webnovel-writer/agents/ || echo "CLEAN"
+```
+
+预期:两条都输出 `CLEAN`。
+
+- [ ] **Step 3: 确认 context_manager.py 行数**
+
+```bash
+wc -l webnovel-writer/scripts/data_modules/context_manager.py
+```
+
+预期:< 400 行。
+
+- [ ] **Step 4: 确认 snapshot_manager.py 已删除**
+
+```bash
+test -f webnovel-writer/scripts/data_modules/snapshot_manager.py && echo "STILL EXISTS" || echo "DELETED"
+```
+
+预期:`DELETED`。
+
+- [ ] **Step 5: 确认裁决表覆盖 7 个题材**
+
+```bash
+python3 -c "
+import csv
+from pathlib import Path
+path = Path('webnovel-writer/references/csv/裁决规则.csv')
+with open(path, 'r', encoding='utf-8-sig') as f:
+    rows = list(csv.DictReader(f))
+genres = [r['题材'] for r in rows]
+print(f'题材数: {len(genres)}')
+print(f'题材: {genres}')
+assert len(genres) == 7
+"
+```
+
+预期:输出 7 个题材。
+
+- [ ] **Step 6: 确认 CSV_CONFIG 对齐**
+
+```bash
+cd webnovel-writer && python -m pytest scripts/data_modules/tests/test_csv_config.py -v
+```
+
+预期:全部 PASS。
+
+- [ ] **Step 7: Commit final**
+
+如果有任何 fix,commit:
+
+```bash
+git add -A
+git commit -m "chore: final integration fixes for story system convergence"
+```

+ 34 - 26
docs/superpowers/plans/2026-04-16-dashboard-frontend-rebuild.md

@@ -6,7 +6,7 @@
 
 
 **Architecture:** React 19 + Vite,新增图表库和路由。后端补聚合 API。
 **Architecture:** React 19 + Vite,新增图表库和路由。后端补聚合 API。
 
 
-**Tech Stack:** React 19, Vite, react-router-dom v7, 图表库待定, 关系图库待定
+**Tech Stack:** React 19, Vite, react-router-dom v7, Apache ECharts (echarts + echarts-for-react), Pixelarticons (pixelarticons)
 
 
 **Design:** 视觉风格和具体图表形式待确认,见 `dashboard/frontend/design.md`(待更新)
 **Design:** 视觉风格和具体图表形式待确认,见 `dashboard/frontend/design.md`(待更新)
 
 
@@ -82,9 +82,11 @@ src/
 - Modify: `dashboard/frontend/src/api.js`
 - Modify: `dashboard/frontend/src/api.js`
 - Create: `dashboard/frontend/src/components/*.jsx`
 - Create: `dashboard/frontend/src/components/*.jsx`
 
 
-- [ ] **Step 1: 安装依赖(路由 + 图表库 + 关系图库)**
+- [ ] **Step 1: 安装依赖**
 
 
-具体图表库待设计确认后决定。
+```bash
+npm install echarts echarts-for-react react-router-dom pixelarticons
+```
 
 
 - [ ] **Step 2: 提取公共组件**
 - [ ] **Step 2: 提取公共组件**
 
 
@@ -117,8 +119,8 @@ Pager 组件要点:
 
 
 **功能:**
 **功能:**
 - 统计卡:总字数/进度、当前章节/卷、Story Runtime 状态、审查均分、紧急伏笔数
 - 统计卡:总字数/进度、当前章节/卷、Story Runtime 状态、审查均分、紧急伏笔数
-- 审查得分可视化:支持翻页浏览(默认最近 N 章)——具体图表形式待定
-- 字数分布可视化:按卷分组——具体图表形式待定
+- 审查得分可视化:ECharts 折线图,支持翻页浏览(默认最近 N 章)
+- 字数分布可视化:ECharts 柱状图,按卷分组
 - Strand Weave 整体分布
 - Strand Weave 整体分布
 - 紧急伏笔 Top 5 表格
 - 紧急伏笔 Top 5 表格
 - 最近 3 章概要卡片
 - 最近 3 章概要卡片
@@ -142,7 +144,13 @@ Pager 组件要点:
 
 
 **功能:**
 **功能:**
 - Tab 1 列表视图:实体列表 + 筛选 + 详情面板 + 状态变化历史(保留现有逻辑)
 - Tab 1 列表视图:实体列表 + 筛选 + 详情面板 + 状态变化历史(保留现有逻辑)
-- Tab 2 关系图谱:替换 3D 为 2D 关系图
+- Tab 2 关系图谱:ECharts graph series(力导向布局)+ 章节时间轴滑块
+
+关系图谱时间轴要点:
+- 滑块控制当前章节,节点/边按 `first_appearance` / `chapter` 过滤
+- 关系标签支持随章节变化(如"初识"→"宿敌")
+- 播放/暂停按钮,自动推进章节
+- 显示当前章节号 + 当前可见节点数
 
 
 - [ ] **Step 1: 实现 + Commit**
 - [ ] **Step 1: 实现 + Commit**
 
 
@@ -154,9 +162,9 @@ Pager 组件要点:
 - Create: `dashboard/frontend/src/pages/PacingPage.jsx`
 - Create: `dashboard/frontend/src/pages/PacingPage.jsx`
 
 
 **功能:**
 **功能:**
-- 钩子强度走势:支持翻页,每页 N 章——具体可视化形式待定
-- Strand 分布:逐章 Strand 分布——具体可视化形式待定
-- 字数分布:按卷分组——具体可视化形式待定
+- 钩子强度走势:ECharts 面积折线图,支持翻页,每页 N 章
+- Strand 分布:ECharts 堆叠柱状图,逐章 Strand 分布
+- 字数分布:ECharts 柱状图,按卷分组
 
 
 **大数据量适配:**
 **大数据量适配:**
 - 所有组件支持翻页 + "跳到最新"
 - 所有组件支持翻页 + "跳到最新"
@@ -173,7 +181,7 @@ Pager 组件要点:
 **功能:**
 **功能:**
 - 统计卡:总伏笔、活跃、已回收、紧急/超期
 - 统计卡:总伏笔、活跃、已回收、紧急/超期
 - 状态筛选按钮:全部 / 紧急 / 活跃 / 已回收
 - 状态筛选按钮:全部 / 紧急 / 活跃 / 已回收
-- 伏笔时间线:横向甘特图,埋设章→目标章,颜色按状态,蓝线标当前章
+- 伏笔时间线:ECharts 自定义 bar series(横向甘特),埋设章→目标章,颜色按状态,蓝线标当前章
 - 完整伏笔表格:内容、状态、埋设章、目标章、紧急度
 - 完整伏笔表格:内容、状态、埋设章、目标章、紧急度
 
 
 **大数据量适配:**
 **大数据量适配:**
@@ -213,15 +221,15 @@ Pager 组件要点:
 
 
 ## 导航变更
 ## 导航变更
 
 
-| 旧导航 | 新导航 | 变化 |
-|--------|--------|------|
-| 📊 数据总览 | 📊 总览 | 删全量视图,加可视化 |
-| 👤 设定词典 | 👤 角色图鉴 | 合并关系图谱,2D 替 3D |
-| 🕸️ 关系图谱 | _(合并到角色)_ | 删除独立页面 |
-| 📝 章节一览 | 📈 节奏雷达 | 新页面 |
-| 📁 文档浏览 | 📁 文档浏览 | 不变 |
-| 🔥 追读力 | 🔖 伏笔追踪 | 新页面 |
-| _(无)_ | ⚙️ 系统状态 | 新页面 |
+| 旧导航 | 新导航 | 图标 (Pixelarticons) | 变化 |
+|--------|--------|---------------------|------|
+| 📊 数据总览 | 总览 | `chart-bar` | 删全量视图,加可视化 |
+| 👤 设定词典 | 角色图鉴 | `users` | 合并关系图谱,2D 替 3D,加时间轴 |
+| 🕸️ 关系图谱 | _(合并到角色)_ | — | 删除独立页面 |
+| 📝 章节一览 | 节奏雷达 | `trending-up` | 新页面 |
+| 📁 文档浏览 | 文档浏览 | `folder` | 不变 |
+| 🔥 追读力 | 伏笔追踪 | `bookmark` | 新页面 |
+| _(无)_ | 系统状态 | `sliders` | 新页面 |
 
 
 ## 删除的数据
 ## 删除的数据
 
 
@@ -230,10 +238,10 @@ Pager 组件要点:
 
 
 ## 待确认项(设计讨论后更新)
 ## 待确认项(设计讨论后更新)
 
 
-- [ ] 视觉风格确认(保留像素风 / 调整)
-- [ ] 审查得分可视化形式
-- [ ] 字数分布可视化形式
-- [ ] 钩子强度可视化形式
-- [ ] Strand 分布可视化形式
-- [ ] 图表库选型(Recharts / 其他 / 纯 CSS)
-- [ ] 关系图库选型(react-force-graph-2d / @antv/G6 / 其他)
+- [x] ~~视觉风格确认~~ → 保留像素风
+- [x] ~~图表库选型~~ → Apache ECharts(echarts + echarts-for-react)
+- [x] ~~关系图库选型~~ → ECharts graph series(力导向布局)
+- [x] ~~审查得分可视化形式~~ → 折线图
+- [x] ~~字数分布可视化形式~~ → 柱状图(按卷分组)
+- [x] ~~钩子强度可视化形式~~ → 面积折线图
+- [x] ~~Strand 分布可视化形式~~ → 堆叠柱状图

+ 372 - 8
webnovel-writer/dashboard/app.py

@@ -8,6 +8,7 @@ import asyncio
 import json
 import json
 import sqlite3
 import sqlite3
 import sys
 import sys
+from datetime import datetime, timezone
 from contextlib import asynccontextmanager, closing
 from contextlib import asynccontextmanager, closing
 from pathlib import Path
 from pathlib import Path
 from typing import Optional
 from typing import Optional
@@ -44,14 +45,176 @@ def _story_system_dir() -> Path:
 
 
 
 
 def _build_story_runtime_health_report(project_root: Path) -> dict:
 def _build_story_runtime_health_report(project_root: Path) -> dict:
+    from data_modules.story_runtime_health import build_story_runtime_health
+
+    return build_story_runtime_health(project_root)
+
+
+def _ensure_scripts_dir_on_path() -> None:
     scripts_dir = Path(__file__).resolve().parents[1] / "scripts"
     scripts_dir = Path(__file__).resolve().parents[1] / "scripts"
     scripts_entry = str(scripts_dir)
     scripts_entry = str(scripts_dir)
     if scripts_entry not in sys.path:
     if scripts_entry not in sys.path:
         sys.path.insert(0, scripts_entry)
         sys.path.insert(0, scripts_entry)
 
 
-    from data_modules.story_runtime_health import build_story_runtime_health
 
 
-    return build_story_runtime_health(project_root)
+def _load_state_payload(*, required: bool = False) -> dict:
+    state_path = _webnovel_dir() / "state.json"
+    if not state_path.is_file():
+        if required:
+            raise HTTPException(404, "state.json 不存在")
+        return {}
+
+    try:
+        payload = json.loads(state_path.read_text(encoding="utf-8"))
+    except (OSError, json.JSONDecodeError) as exc:
+        raise HTTPException(status_code=500, detail=f"state.json 读取失败: {exc}") from exc
+
+    return payload if isinstance(payload, dict) else {}
+
+
+def _parse_json_value(raw: object, default):
+    if raw is None:
+        return default
+    if isinstance(raw, (dict, list)):
+        return raw
+    if not isinstance(raw, str):
+        return default
+    try:
+        return json.loads(raw)
+    except json.JSONDecodeError:
+        return default
+
+
+def _resolve_volume_for_chapter(state: dict, chapter: int) -> int | None:
+    progress = state.get("progress") if isinstance(state, dict) else {}
+    if not isinstance(progress, dict):
+        return None
+    volumes_planned = progress.get("volumes_planned")
+    if not isinstance(volumes_planned, list):
+        return None
+
+    best: tuple[int, int] | None = None
+    for item in volumes_planned:
+        if not isinstance(item, dict):
+            continue
+        volume = item.get("volume")
+        if not isinstance(volume, int) or volume <= 0:
+            continue
+        chapter_range = str(item.get("chapters_range") or "").strip()
+        if "-" not in chapter_range:
+            continue
+        left, _, right = chapter_range.partition("-")
+        try:
+            start = int(left.strip())
+            end = int(right.strip())
+        except ValueError:
+            continue
+        if start <= 0 or end <= 0 or start > end:
+            continue
+        if start <= chapter <= end:
+            candidate = (start, volume)
+            if best is None or candidate[0] > best[0] or (
+                candidate[0] == best[0] and candidate[1] < best[1]
+            ):
+                best = candidate
+    return best[1] if best else None
+
+
+def _build_strand_map(state: dict) -> dict[int, str]:
+    tracker = state.get("strand_tracker") if isinstance(state, dict) else {}
+    history = tracker.get("history") if isinstance(tracker, dict) else []
+    if not isinstance(history, list):
+        return {}
+
+    strand_map: dict[int, str] = {}
+    for index, entry in enumerate(history, start=1):
+        if not isinstance(entry, dict):
+            continue
+        chapter_value = entry.get("chapter", index)
+        try:
+            chapter = int(chapter_value)
+        except (TypeError, ValueError):
+            chapter = index
+        strand = str(entry.get("strand") or entry.get("dominant") or "").strip().lower()
+        if chapter > 0 and strand:
+            strand_map[chapter] = strand
+    return strand_map
+
+
+def _extract_story_chapter(path: Path) -> int:
+    stem = path.stem
+    if "_" not in stem:
+        return 0
+    _, _, tail = stem.partition("_")
+    try:
+        return int(tail.split(".")[0])
+    except ValueError:
+        return 0
+
+
+def _inspect_vector_db(project_root: Path) -> dict:
+    from data_modules.config import DataModulesConfig
+
+    cfg = DataModulesConfig.from_project_root(project_root)
+    vector_db = cfg.vector_db
+    exists = vector_db.is_file()
+    size_bytes = vector_db.stat().st_size if exists else 0
+    record_count = 0
+    error = ""
+
+    if exists and size_bytes > 0:
+        try:
+            with sqlite3.connect(str(vector_db)) as conn:
+                cursor = conn.cursor()
+                table_exists = cursor.execute(
+                    "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'vectors'"
+                ).fetchone()
+                if table_exists:
+                    row = cursor.execute("SELECT COUNT(*) FROM vectors").fetchone()
+                    record_count = int(row[0] or 0) if row else 0
+        except sqlite3.Error as exc:
+            error = str(exc)
+
+    return {
+        "path": str(vector_db),
+        "exists": exists,
+        "size_bytes": size_bytes,
+        "record_count": record_count,
+        "error": error,
+    }
+
+
+def _build_env_status(project_root: Path) -> dict:
+    from data_modules.config import DataModulesConfig
+
+    cfg = DataModulesConfig.from_project_root(project_root)
+    vector_info = _inspect_vector_db(project_root)
+
+    embed_ready = bool(str(cfg.embed_api_key or "").strip())
+    rerank_ready = bool(str(cfg.rerank_api_key or "").strip())
+    vector_ready = bool(vector_info["exists"] and vector_info["size_bytes"] > 0)
+
+    if vector_ready and embed_ready and rerank_ready:
+        rag_mode = "full"
+    elif vector_ready and embed_ready:
+        rag_mode = "embed_only"
+    else:
+        rag_mode = "bm25_only"
+
+    return {
+        "embed": {
+            "base_url": cfg.embed_base_url,
+            "model": cfg.embed_model,
+            "api_key_present": embed_ready,
+        },
+        "rerank": {
+            "base_url": cfg.rerank_base_url,
+            "model": cfg.rerank_model,
+            "api_key_present": rerank_ready,
+        },
+        "vector_db": vector_info,
+        "rag_mode": rag_mode,
+    }
 
 
 
 
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
@@ -64,6 +227,8 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
     if project_root:
     if project_root:
         _project_root = Path(project_root).resolve()
         _project_root = Path(project_root).resolve()
 
 
+    _ensure_scripts_dir_on_path()
+
     @asynccontextmanager
     @asynccontextmanager
     async def _lifespan(_: FastAPI):
     async def _lifespan(_: FastAPI):
         webnovel = _webnovel_dir()
         webnovel = _webnovel_dir()
@@ -95,10 +260,7 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
     @app.get("/api/project/info")
     @app.get("/api/project/info")
     def project_info():
     def project_info():
         """返回 state.json 完整内容(只读)。"""
         """返回 state.json 完整内容(只读)。"""
-        state_path = _webnovel_dir() / "state.json"
-        if not state_path.is_file():
-            raise HTTPException(404, "state.json 不存在")
-        return json.loads(state_path.read_text(encoding="utf-8"))
+        return _load_state_payload(required=True)
 
 
     @app.get("/api/story-runtime/health")
     @app.get("/api/story-runtime/health")
     def story_runtime_health():
     def story_runtime_health():
@@ -201,7 +363,12 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
     def list_chapters():
     def list_chapters():
         with closing(_get_db()) as conn:
         with closing(_get_db()) as conn:
             rows = conn.execute("SELECT * FROM chapters ORDER BY chapter ASC").fetchall()
             rows = conn.execute("SELECT * FROM chapters ORDER BY chapter ASC").fetchall()
-            return [dict(r) for r in rows]
+            normalized = []
+            for row in rows:
+                item = dict(row)
+                item["characters"] = _parse_json_value(item.get("characters"), [])
+                normalized.append(item)
+            return normalized
 
 
     @app.get("/api/scenes")
     @app.get("/api/scenes")
     def list_scenes(chapter: Optional[int] = None, limit: int = 500):
     def list_scenes(chapter: Optional[int] = None, limit: int = 500):
@@ -230,7 +397,202 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
             rows = conn.execute(
             rows = conn.execute(
                 "SELECT * FROM review_metrics ORDER BY end_chapter DESC LIMIT ?", (limit,)
                 "SELECT * FROM review_metrics ORDER BY end_chapter DESC LIMIT ?", (limit,)
             ).fetchall()
             ).fetchall()
-            return [dict(r) for r in rows]
+            normalized = []
+            for row in rows:
+                item = dict(row)
+                item["dimension_scores"] = _parse_json_value(item.get("dimension_scores"), {})
+                item["severity_counts"] = _parse_json_value(item.get("severity_counts"), {})
+                item["critical_issues"] = _parse_json_value(item.get("critical_issues"), [])
+                normalized.append(item)
+            return normalized
+
+    @app.get("/api/stats/chapter-trend")
+    def chapter_trend(limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0)):
+        state = _load_state_payload()
+        strand_map = _build_strand_map(state)
+
+        with closing(_get_db()) as conn:
+            total_rows = _fetchall_safe(conn, "SELECT COUNT(*) AS count FROM chapters")
+            latest_rows = _fetchall_safe(conn, "SELECT MAX(chapter) AS chapter FROM chapters")
+            rows = _fetchall_safe(
+                conn,
+                """
+                WITH selected_chapters AS (
+                    SELECT chapter, title, location, word_count, characters, summary
+                    FROM chapters
+                    ORDER BY chapter DESC
+                    LIMIT ? OFFSET ?
+                )
+                SELECT
+                    c.chapter,
+                    c.title,
+                    c.location,
+                    c.word_count,
+                    c.characters,
+                    c.summary,
+                    rp.hook_type,
+                    rp.hook_strength,
+                    rp.is_transition,
+                    rp.override_count,
+                    rp.debt_balance,
+                    rm.overall_score AS review_score,
+                    rm.severity_counts
+                FROM selected_chapters c
+                LEFT JOIN chapter_reading_power rp ON rp.chapter = c.chapter
+                LEFT JOIN review_metrics rm ON rm.end_chapter = c.chapter
+                ORDER BY c.chapter ASC
+                """,
+                (limit, offset),
+            )
+
+        hook_strength_value = {"weak": 1, "medium": 3, "strong": 5}
+        items = []
+        for row in rows:
+            chapter = int(row.get("chapter") or 0)
+            hook_strength = str(row.get("hook_strength") or "").strip().lower()
+            items.append(
+                {
+                    "chapter": chapter,
+                    "title": row.get("title") or "",
+                    "location": row.get("location") or "",
+                    "word_count": int(row.get("word_count") or 0),
+                    "characters": _parse_json_value(row.get("characters"), []),
+                    "summary": row.get("summary") or "",
+                    "review_score": row.get("review_score"),
+                    "review_severity_counts": _parse_json_value(row.get("severity_counts"), {}),
+                    "hook_type": row.get("hook_type") or "",
+                    "hook_strength": hook_strength,
+                    "hook_strength_value": hook_strength_value.get(hook_strength, 0),
+                    "is_transition": bool(row.get("is_transition")),
+                    "override_count": int(row.get("override_count") or 0),
+                    "debt_balance": float(row.get("debt_balance") or 0.0),
+                    "strand": strand_map.get(chapter, ""),
+                    "volume": _resolve_volume_for_chapter(state, chapter),
+                }
+            )
+
+        return {
+            "items": items,
+            "total": int(total_rows[0]["count"] or 0) if total_rows else 0,
+            "latest_chapter": int(latest_rows[0]["chapter"] or 0) if latest_rows else 0,
+            "limit": limit,
+            "offset": offset,
+        }
+
+    @app.get("/api/commits")
+    def list_commits(limit: int = Query(20, ge=1, le=200)):
+        commits_dir = _story_system_dir() / "commits"
+        if not commits_dir.is_dir():
+            return {"items": [], "total": 0, "limit": limit}
+
+        items = []
+        for path in commits_dir.glob("chapter_*.commit.json"):
+            try:
+                payload = json.loads(path.read_text(encoding="utf-8"))
+            except (OSError, json.JSONDecodeError):
+                continue
+
+            meta = payload.get("meta") if isinstance(payload, dict) else {}
+            provenance = payload.get("provenance") if isinstance(payload, dict) else {}
+            chapter = int((meta or {}).get("chapter") or _extract_story_chapter(path))
+            items.append(
+                {
+                    "chapter": chapter,
+                    "status": str((meta or {}).get("status") or "missing"),
+                    "projection_status": payload.get("projection_status") or {},
+                    "write_fact_role": str((provenance or {}).get("write_fact_role") or ""),
+                    "contract_refs": payload.get("contract_refs") or {},
+                    "path": path.name,
+                    "updated_at": datetime.fromtimestamp(
+                        path.stat().st_mtime, tz=timezone.utc
+                    ).isoformat(),
+                }
+            )
+
+        items.sort(key=lambda item: item["chapter"], reverse=True)
+        return {"items": items[:limit], "total": len(items), "limit": limit}
+
+    @app.get("/api/contracts/summary")
+    def contracts_summary():
+        from data_modules.story_contracts import StoryContractPaths, read_json_if_exists
+
+        project_root = _get_project_root()
+        state = _load_state_payload()
+        runtime = _build_story_runtime_health_report(project_root)
+        chapter = int(runtime.get("chapter") or ((state.get("progress") or {}).get("current_chapter") or 0))
+        current_volume = _resolve_volume_for_chapter(state, chapter) or int(
+            ((state.get("progress") or {}).get("current_volume") or 1)
+        )
+
+        paths = StoryContractPaths.from_project_root(project_root)
+        master_payload = read_json_if_exists(paths.master_json) or {}
+
+        return {
+            "chapter": chapter,
+            "current_volume": current_volume,
+            "master": {
+                "exists": bool(master_payload),
+                "primary_genre": str(((master_payload.get("route") or {}).get("primary_genre") or "")),
+                "core_tone": str(
+                    ((master_payload.get("master_constraints") or {}).get("core_tone") or "")
+                ),
+            },
+            "counts": {
+                "volumes": len(list(paths.volumes_dir.glob("volume_*.json"))) if paths.volumes_dir.is_dir() else 0,
+                "chapters": len(list(paths.chapters_dir.glob("chapter_*.json"))) if paths.chapters_dir.is_dir() else 0,
+                "reviews": len(list(paths.reviews_dir.glob("chapter_*.review.json"))) if paths.reviews_dir.is_dir() else 0,
+                "commits": len(list(paths.commits_dir.glob("chapter_*.commit.json"))) if paths.commits_dir.is_dir() else 0,
+            },
+            "current_contracts": {
+                "volume": paths.volume_json(current_volume).is_file(),
+                "chapter": paths.chapter_json(chapter).is_file() if chapter > 0 else False,
+                "review": paths.review_json(chapter).is_file() if chapter > 0 else False,
+                "commit": paths.commit_json(chapter).is_file() if chapter > 0 else False,
+            },
+        }
+
+    @app.get("/api/env-status")
+    def env_status():
+        return _build_env_status(_get_project_root())
+
+    @app.get("/api/env-status/probe")
+    def env_status_probe():
+        status = _build_env_status(_get_project_root())
+        runtime = _build_story_runtime_health_report(_get_project_root())
+        vector_db = status["vector_db"]
+        checks = [
+            {
+                "name": "embed_api_key",
+                "ok": bool(status["embed"]["api_key_present"]),
+                "detail": "已配置" if status["embed"]["api_key_present"] else "未配置",
+            },
+            {
+                "name": "rerank_api_key",
+                "ok": bool(status["rerank"]["api_key_present"]),
+                "detail": "已配置" if status["rerank"]["api_key_present"] else "未配置",
+            },
+            {
+                "name": "vector_db",
+                "ok": bool(vector_db["exists"] and not vector_db["error"]),
+                "detail": vector_db["error"]
+                or f"{vector_db['record_count']} records · {vector_db['size_bytes']} bytes",
+            },
+            {
+                "name": "story_runtime",
+                "ok": bool(runtime.get("mainline_ready")),
+                "detail": (
+                    f"chapter={runtime.get('chapter')} "
+                    f"status={runtime.get('latest_commit_status')} "
+                    f"fallback={','.join(runtime.get('fallback_sources') or []) or 'none'}"
+                ),
+            },
+        ]
+        return {
+            "ok": all(bool(item["ok"]) for item in checks),
+            "rag_mode": status["rag_mode"],
+            "checks": checks,
+            "checked_at": datetime.now(timezone.utc).isoformat(),
+        }
 
 
     @app.get("/api/state-changes")
     @app.get("/api/state-changes")
     def list_state_changes(entity: Optional[str] = None, limit: int = 100):
     def list_state_changes(entity: Optional[str] = None, limit: int = 100):
@@ -488,6 +850,8 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
         @app.get("/{full_path:path}")
         @app.get("/{full_path:path}")
         def serve_spa(full_path: str):
         def serve_spa(full_path: str):
             """SPA fallback:任何非 /api 路径都返回 index.html。"""
             """SPA fallback:任何非 /api 路径都返回 index.html。"""
+            if full_path.startswith("api/"):
+                raise HTTPException(404, "API 路径不存在")
             index = STATIC_DIR / "index.html"
             index = STATIC_DIR / "index.html"
             if index.is_file():
             if index.is_file():
                 return FileResponse(str(index))
                 return FileResponse(str(index))

+ 175 - 8
webnovel-writer/dashboard/frontend/design.md

@@ -1,6 +1,7 @@
 # PIXEL WRITER HUB 设计规范
 # PIXEL WRITER HUB 设计规范
 
 
 > Dashboard 前端设计规范,所有页面必须遵守。
 > Dashboard 前端设计规范,所有页面必须遵守。
+> 原型预览:`docs/architecture/dashboard-prototype.html`
 
 
 ## 视觉风格:复古像素 / 8-bit 游戏
 ## 视觉风格:复古像素 / 8-bit 游戏
 
 
@@ -11,6 +12,7 @@
 | 变量 | 色值 | 用途 |
 | 变量 | 色值 | 用途 |
 |------|------|------|
 |------|------|------|
 | `--bg-main` | `#fff7e8` | 页面背景(带 14px 网格线) |
 | `--bg-main` | `#fff7e8` | 页面背景(带 14px 网格线) |
+| `--bg-panel` | `#fffdf6` | 表格/面板内背景 |
 | `--bg-card` | `#fffaf0` | 卡片背景 |
 | `--bg-card` | `#fffaf0` | 卡片背景 |
 | `--bg-card-2` | `#fff3d5` | 表头、次级卡片 |
 | `--bg-card-2` | `#fff3d5` | 表头、次级卡片 |
 | `--text-main` | `#2a220f` | 主文字 |
 | `--text-main` | `#2a220f` | 主文字 |
@@ -22,12 +24,38 @@
 | `--accent-amber` | `#f5a524` | 警告(紧急伏笔、中等分数) |
 | `--accent-amber` | `#f5a524` | 警告(紧急伏笔、中等分数) |
 | `--accent-red` | `#d7263d` | 危险(blocking、超期) |
 | `--accent-red` | `#d7263d` | 危险(blocking、超期) |
 | `--accent-cyan` | `#00b8d4` | 信息(badge) |
 | `--accent-cyan` | `#00b8d4` | 信息(badge) |
+| `--border-main` | `#2a220f` | 主边框 |
+| `--border-soft` | `#8f7f5c` | 次级边框 |
+
+### ECharts 系列色序
+
+```
+['#26a8ff', '#f5a524', '#7f5af0', '#2ec27e', '#d7263d', '#00b8d4', '#ff5c8a']
+```
+
+### Strand 专用色
+
+| Strand | 色值 | CSS 类 |
+|--------|------|--------|
+| Quest | `#26a8ff` | `.strand-quest` |
+| Fire | `#ff5c8a` | `.strand-fire` |
+| Constellation | `#7f5af0` | `.strand-constellation` |
+
+### 伏笔状态色
+
+| 状态 | 色值 | Badge |
+|------|------|-------|
+| 超期 (overdue) | `#d7263d` | `.badge-red` |
+| 紧急 (urgent) | `#f5a524` | `.badge-amber` |
+| 活跃 (active) | `#26a8ff` | `.badge-blue` |
+| 已回收 (resolved) | `#2ec27e` | `.badge-green` |
 
 
 ## 字体
 ## 字体
 
 
 - **标题/Logo**:`Press Start 2P`,11px,字间距 0.08em
 - **标题/Logo**:`Press Start 2P`,11px,字间距 0.08em
 - **正文/数据**:`Noto Sans SC`,14px,font-weight 500-700
 - **正文/数据**:`Noto Sans SC`,14px,font-weight 500-700
 - **数字**:tabular-nums(等宽数字)
 - **数字**:tabular-nums(等宽数字)
+- **图例/小标签**:`Noto Sans SC` 13px,font-weight 600
 
 
 ## 边框与阴影
 ## 边框与阴影
 
 
@@ -40,23 +68,161 @@
 
 
 **Badge**:`2px solid #2a220f`,padding `3px 8px`,配色见 `.badge-*` 类。
 **Badge**:`2px solid #2a220f`,padding `3px 8px`,配色见 `.badge-*` 类。
 
 
-**表格**:`.table-wrap` 包裹,表头 `--bg-card-2` 底色,行 hover `#fff4d8`。
+**表格**:`.table-wrap` 包裹,表头 `--bg-card-2` 底色,行 hover `#fff4d8`,支持分页
 
 
 **进度条**:`12px` 高,`2px` 硬边框,填充渐变 `#26a8ff → #7f5af0`。
 **进度条**:`12px` 高,`2px` 硬边框,填充渐变 `#26a8ff → #7f5af0`。
 
 
 **按钮/导航**:`2px solid` 边框,hover 时微移 `-1px, -1px`。active 态蓝底。
 **按钮/导航**:`2px solid` 边框,hover 时微移 `-1px, -1px`。active 态蓝底。
 
 
-**图表**(新增 Recharts):
-- 主线用 `--accent-blue`,次线用 `--accent-amber`
-- 网格线 `#e8dcc4`(极淡)
-- 无圆角 tooltip,用 `2px solid #2a220f` 硬边框
-- 坐标轴标签 `--text-mute` 色,12px
+**统计卡**:`.stat-card` 内含 `.stat-label`(mute 色 13px)、`.stat-value`(accent-blue 28px)、`.stat-sub`(sub 色 13px)。
+
+**翻页器 (Pager)**:`← 前 N` / `页码信息` / `下一页 →` / `跳到最新 →`,按钮用 `.page-btn` 样式。
+
+**筛选按钮组**:`.filter-group` flex 排列,`.filter-btn` 2px 边框,active 态蓝底蓝边。
+
+## ECharts 像素风主题
+
+注册为 `pixel` 主题,所有图表统一使用 `echarts.init(el, 'pixel')`。
+
+### 主题配置要点
+
+```js
+{
+  color: ['#26a8ff','#f5a524','#7f5af0','#2ec27e','#d7263d','#00b8d4','#ff5c8a'],
+  backgroundColor: 'transparent',
+  tooltip: {
+    backgroundColor: '#fffaf0',
+    borderColor: '#2a220f',
+    borderWidth: 2,
+    extraCssText: 'border-radius:0;box-shadow:3px 3px 0 #2a220f;'
+  },
+  // 坐标轴
+  axisLine:  { lineStyle: { color: '#8f7f5c', width: 2 } },
+  axisLabel: { color: '#8f7f5c', fontSize: 12 },
+  splitLine: { lineStyle: { color: '#e8dcc4', type: 'dashed' } }
+}
+```
+
+### 图表通用规则
+
+| 规则 | 说明 |
+|------|------|
+| 无圆角 | tooltip、bar、节点均不用 borderRadius |
+| 硬描边 | 所有 item `borderColor: '#2a220f', borderWidth: 2` |
+| 方形符号 | 折线图数据点 `symbol: 'rect', symbolSize: 8` |
+| 线宽 3px | 折线图 `lineStyle.width: 3`,不用 smooth |
+| bar 无圆角 | 柱状图默认方形 |
+| 面积填充 | 用 20% 透明度线性渐变到透明 |
+| markLine | 均值线/当前章节线用 `type: 'dashed'` 或 `solid`,颜色匹配语义 |
+
+## 各页图表规格
+
+### 总览页 (OverviewPage)
+
+| 图表 | 类型 | 数据 | 交互 |
+|------|------|------|------|
+| 审查得分趋势 | 折线图 (line) | 每章 overall_score | 翻页(每页 50 章)+ 均值 markLine |
+| 字数分布 | 柱状图 (bar) | 按卷汇总字数 | 标签显示万字 |
+| Strand 整体分布 | 环形图 (pie) | quest/fire/constellation 计数 | 百分比标签 |
+| 紧急伏笔 Top 5 | 表格 | 内容、状态、埋设章、目标章、紧急度 | — |
+
+### 角色图鉴页 (CharactersPage)
+
+| 图表 | 类型 | 数据 | 交互 |
+|------|------|------|------|
+| 关系图谱 | graph (力导向) | entities + relationships | **章节时间轴滑块** + 播放/暂停 |
+
+**关系图时间轴规格:**
+- 滑块 (`<input type="range">`) 控制当前章节,范围 1 ~ 最新章
+- 节点按 `first_appearance <= 当前章` 过滤显示
+- 边按 `chapter <= 当前章` 过滤,标签支持随章节演化(如"初识"→"宿敌")
+- 播放按钮:每 120ms 步进 5 章,自动推进
+- 右侧 badge 实时显示当前章节号 + 可见节点数
+- 节点:方形 (`symbol: 'rect'`),主角加大 + 金色 (`#f5a524`)
+- 边:直线 + 标签,`curveness: 0.1`
+- 类别色:角色 `#26a8ff`、势力 `#7f5af0`、地点 `#2ec27e`
+
+### 节奏雷达页 (PacingPage)
+
+| 图表 | 类型 | 数据 | 交互 |
+|------|------|------|------|
+| 钩子强度走势 | 面积折线图 (line+areaStyle) | 每章 hook_strength | 翻页(每页 50 章) |
+| Strand 分布 | 堆叠柱状图 (bar, stack) | 逐章 strand 分配 | 翻页 |
+| 字数分布 | 箱线图 (boxplot) | 按卷分组 | — |
+
+### 伏笔追踪页 (ForeshadowingPage)
+
+| 图表 | 类型 | 数据 | 交互 |
+|------|------|------|------|
+| 伏笔时间线 | 自定义 bar (custom series) | 埋设章→目标章 | 当前章蓝线 (`z:10` 置顶),按状态着色 |
+
+**甘特图规格:**
+- Y 轴:伏笔名称(反转,紧急在上)
+- X 轴:章节范围,`axisLabel: '第N章'`
+- Bar 颜色:overdue `#d7263d`、urgent `#f5a524`、active `#26a8ff`、resolved `#2ec27e`
+- 当前章节竖线:`markLine` + `z: 10`(确保在 bar 上层),`label.position: 'end'`
+- 默认只显示 活跃+紧急,已回收折叠
+- 横轴范围自动适配(不铺满 1-最大章)
+
+### 系统状态页 (SystemPage)
+
+纯统计卡 + 表格,无图表。
 
 
 ## 布局
 ## 布局
 
 
 - 侧边栏 240px(金色渐变 `#ffe8b8 → #ffe19f`),`3px` 右边框
 - 侧边栏 240px(金色渐变 `#ffe8b8 → #ffe19f`),`3px` 右边框
 - 主区域可滚动,padding 22px
 - 主区域可滚动,padding 22px
-- 卡片网格 `repeat(auto-fill, minmax(220px, 1fr))`
+- 统计卡网格 `repeat(auto-fill, minmax(220px, 1fr))`
+- 图表卡片全宽,高度 320px(默认)/ 420px(关系图等 `.tall`)/ 380px(甘特 `.gantt`)
+
+## 导航
+
+| 图标 | 标签 | 路由 |
+|------|------|------|
+| 📊 | 总览 | `/` |
+| 👤 | 角色图鉴 | `/characters` |
+| 📈 | 节奏雷达 | `/pacing` |
+| 🔖 | 伏笔追踪 | `/foreshadowing` |
+| 📁 | 文档浏览 | `/files` |
+| ⚙️ | 系统状态 | `/system` |
+
+## 大数据量适配
+
+- 所有时序图表默认显示最近 50 章窗口,支持翻页 + "跳到最新"
+- 字数按卷分组,不一次性铺开所有章节
+- 甘特图默认只显示活跃+紧急,已回收可展开
+- 关系图时间轴播放时步进 5 章/120ms,不逐章渲染
+
+## 图标
+
+使用 [Pixelarticons](https://pixelarticons.com/)(`pixelarticons` npm 包)。
+
+- 24×24 网格,无抗锯齿,纯像素风
+- SVG `fill="currentColor"`,颜色继承父元素,天然适配色板
+- React 组件导入,tree-shakeable
+- 尺寸用 24px 的整数倍(24/48)保持像素对齐
+
+### 导航图标映射
+
+| 页面 | 图标名 | 组件 |
+|------|--------|------|
+| 总览 | `chart-bar` | `<ChartBar />` |
+| 角色图鉴 | `users` | `<Users />` |
+| 节奏雷达 | `trending-up` | `<TrendingUp />` |
+| 伏笔追踪 | `bookmark` | `<Bookmark />` |
+| 文档浏览 | `folder` | `<Folder />` |
+| 系统状态 | `sliders` | `<Sliders />` |
+
+### 其他常用图标
+
+| 用途 | 图标名 |
+|------|--------|
+| 播放/暂停 | `play` / `pause` |
+| 翻页 | `chevron-left` / `chevron-right` |
+| 跳到最新 | `chevrons-right` |
+| 刷新/诊断 | `reload` |
+| 连接状态 | `wifi` / `wifi-off` |
+| 搜索/筛选 | `search` / `filter` |
 
 
 ## 不做的事
 ## 不做的事
 
 
@@ -64,4 +230,5 @@
 - 不用渐变背景(进度条除外)
 - 不用渐变背景(进度条除外)
 - 不用 soft shadow
 - 不用 soft shadow
 - 不用 glassmorphism / neumorphism
 - 不用 glassmorphism / neumorphism
-- 不用 SVG icon 库——用 emoji
+- 不用 emoji 做图标——用 Pixelarticons
+- 不用 3D 图表(原 react-force-graph-3d 替换为 ECharts 2D graph)

+ 1 - 0
webnovel-writer/dashboard/frontend/dist/assets/Badge-DcwOuuF4.js

@@ -0,0 +1 @@
+import{j as s}from"./index-Dyazi077.js";const e={blue:"badge-blue",green:"badge-green",amber:"badge-amber",red:"badge-red",purple:"badge-purple",cyan:"badge-cyan",neutral:"badge-neutral"};function g({tone:a="neutral",className:r="",title:n="",children:t}){const b=e[a]||e.neutral;return s.jsx("span",{className:`badge ${b} ${r}`.trim(),title:n,children:t})}export{g as B};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/CharactersPage-n6sZAtwm.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/ChartWrapper-7BPAgK6F.js


+ 1 - 0
webnovel-writer/dashboard/frontend/dist/assets/DataTable-DwExK2c6.js

@@ -0,0 +1 @@
+import{j as t}from"./index-Dyazi077.js";import{b as p}from"./react-vendor-CjpoAPrF.js";import{h as g}from"./format-flB0hImb.js";function u(s,e,i){return typeof e=="function"?e(s,i):typeof e=="string"&&(s==null?void 0:s[e])!==void 0?s[e]:i}function k({columns:s,rows:e,rowKey:i="id",pageSize:c=8,emptyText:m="暂无数据",minWidth:x=640}){const[b,h]=p.useState(1);if(p.useEffect(()=>{h(1)},[e,c]),!(e!=null&&e.length))return t.jsx("div",{className:"empty-state compact",children:t.jsx("p",{children:m})});const l=Math.max(1,Math.ceil(e.length/c)),d=Math.min(b,l),r=(d-1)*c,j=e.slice(r,r+c);return t.jsxs(t.Fragment,{children:[t.jsx("div",{className:"table-wrap",children:t.jsxs("table",{className:"data-table",style:{minWidth:x},children:[t.jsx("thead",{children:t.jsx("tr",{children:s.map(a=>t.jsx("th",{children:a.label},a.key))})}),t.jsx("tbody",{children:j.map((a,f)=>t.jsx("tr",{children:s.map(n=>t.jsx("td",{className:n.className||"",style:n.style||void 0,children:n.render?n.render(a):g(a==null?void 0:a[n.key])},n.key))},u(a,i,f)))})]})}),l>1?t.jsxs("div",{className:"table-pagination",children:[t.jsx("button",{className:"page-btn",type:"button",onClick:()=>h(a=>Math.max(1,a-1)),disabled:d<=1,children:"上一页"}),t.jsxs("span",{className:"page-info",children:["第 ",d,"/",l," 页 · 共 ",e.length," 条"]}),t.jsx("button",{className:"page-btn",type:"button",onClick:()=>h(a=>Math.min(l,a+1)),disabled:d>=l,children:"下一页"})]}):null]})}export{k as D};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/FilesPage-Czd1SC2y.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/ForeshadowingPage-BeSmFV1Z.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/OverviewPage-vgewJiV1.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/PacingPage-Dsdrtxb_.js


+ 1 - 0
webnovel-writer/dashboard/frontend/dist/assets/Pager-FJRhLwfC.js

@@ -0,0 +1 @@
+import{j as s}from"./index-Dyazi077.js";function b({page:n,totalPages:e,currentStart:t,currentEnd:i,totalItems:a,onPrevious:l,onNext:c,onLatest:r,stepLabel:d="50"}){return a<=0?null:s.jsxs("div",{className:"pager",children:[s.jsxs("button",{className:"page-btn",type:"button",onClick:l,disabled:n<=1,children:["← 前 ",d]}),s.jsxs("span",{className:"page-info",children:["第 ",t,"-",i," 章 · 第 ",n,"/",e," 页"]}),s.jsxs("div",{className:"pager-actions",children:[s.jsx("button",{className:"page-btn",type:"button",onClick:c,disabled:n>=e,children:"下一页 →"}),s.jsx("button",{className:"page-btn",type:"button",onClick:r,disabled:n>=e,children:"跳到最新 →"})]})]})}export{b as P};

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/SystemPage-CRCMgVw-.js


Разница между файлами не показана из-за своего большого размера
+ 13 - 0
webnovel-writer/dashboard/frontend/dist/assets/echarts-vendor-DOu6vfXz.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/foreshadowing-BP4NrzVI.js


+ 1 - 0
webnovel-writer/dashboard/frontend/dist/assets/format-flB0hImb.js

@@ -0,0 +1 @@
+function e(r){const t=Number(r||0);return Number.isFinite(t)?Math.abs(t)>=1e4?`${new Intl.NumberFormat("zh-CN",{maximumFractionDigits:1}).format(t/1e4)} 万`:new Intl.NumberFormat("zh-CN").format(t):"—"}function u(r){const t=Number(r||0);return Number.isFinite(t)?new Intl.NumberFormat("zh-CN",{maximumFractionDigits:1}).format(t):"—"}function o(r,t=1){const n=Number(r);return Number.isFinite(n)?`${n.toFixed(t)}%`:"—"}function f(r){const t=Number(r||0);return!Number.isFinite(t)||t<=0?"—":`第 ${t} 章`}function m(r){if(!r)return"—";const t=new Date(r);return Number.isNaN(t.getTime())?String(r):t.toLocaleString("zh-CN",{hour12:!1,month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit"})}function s(r){if(r==null||r==="")return"";if(typeof r=="object")return JSON.stringify(r,null,2);if(typeof r!="string")return String(r);try{return JSON.stringify(JSON.parse(r),null,2)}catch{return r}}function a(r){const t=r.map(n=>Number(n)).filter(n=>Number.isFinite(n));return t.length?t.reduce((n,i)=>n+i,0)/t.length:null}function b(r){return r==null||r===""?"—":Array.isArray(r)?r.length?r.join("、"):"—":typeof r=="object"?JSON.stringify(r,null,2):typeof r=="boolean"?r?"是":"否":String(r)}export{a,o as b,f as c,u as d,s as e,e as f,m as g,b as h};

Разница между файлами не показана из-за своего большого размера
+ 0 - 16
webnovel-writer/dashboard/frontend/dist/assets/index-BeHSak5z.js


Разница между файлами не показана из-за своего большого размера
+ 1 - 0
webnovel-writer/dashboard/frontend/dist/assets/index-Dyazi077.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/index-R26PxixS.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/index-qVwzETG1.css


Разница между файлами не показана из-за своего большого размера
+ 8 - 0
webnovel-writer/dashboard/frontend/dist/assets/react-vendor-CjpoAPrF.js


+ 1 - 0
webnovel-writer/dashboard/frontend/dist/assets/story-lVhSS0ka.js

@@ -0,0 +1 @@
+function a(r){const e=Number(r);return Number.isFinite(e)&&e>0?e:null}function i(r){const e=String(r||"").trim();if(!e.includes("-"))return null;const[s,u]=e.split("-",2),n=a(s),t=a(u);return!n||!t||n>t?null:{start:n,end:t}}function m(r){var e;return a((e=r==null?void 0:r.progress)==null?void 0:e.current_chapter)||1}function c(r,e){var n;const s=a(e);if(!s)return null;const u=Array.isArray((n=r==null?void 0:r.progress)==null?void 0:n.volumes_planned)?r.progress.volumes_planned:[];for(const t of u){const o=a(t==null?void 0:t.volume),l=i(t==null?void 0:t.chapters_range);if(o&&l&&l.start<=s&&s<=l.end)return o}return null}function g(r,e){const s=new Map;for(const u of r||[]){const n=c(e,u==null?void 0:u.chapter),t=n||0;s.has(t)||s.set(t,{volume:n,label:n?`卷 ${n}`:"未分卷",totalWords:0,chapterCount:0,values:[]});const o=s.get(t),l=Number((u==null?void 0:u.word_count)||0);o.totalWords+=Number.isFinite(l)?l:0,o.chapterCount+=1,Number.isFinite(l)&&l>0&&o.values.push(l)}return[...s.values()].sort((u,n)=>u.volume===null?1:n.volume===null?-1:(u.volume||0)-(n.volume||0))}export{m as a,g};

+ 3 - 2
webnovel-writer/dashboard/frontend/dist/index.html

@@ -8,8 +8,9 @@
     <link rel="preconnect" href="https://fonts.googleapis.com" />
     <link rel="preconnect" href="https://fonts.googleapis.com" />
     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
     <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
     <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
-    <script type="module" crossorigin src="/assets/index-BeHSak5z.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-qVwzETG1.css">
+    <script type="module" crossorigin src="/assets/index-Dyazi077.js"></script>
+    <link rel="modulepreload" crossorigin href="/assets/react-vendor-CjpoAPrF.js">
+    <link rel="stylesheet" crossorigin href="/assets/index-R26PxixS.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>

     <div id="root"></div>


+ 106 - 441
webnovel-writer/dashboard/frontend/package-lock.json

@@ -8,9 +8,11 @@
       "name": "webnovel-dashboard",
       "name": "webnovel-dashboard",
       "version": "0.1.0",
       "version": "0.1.0",
       "dependencies": {
       "dependencies": {
+        "echarts": "^5.6.0",
+        "echarts-for-react": "^3.0.2",
         "react": "^19.0.0",
         "react": "^19.0.0",
         "react-dom": "^19.0.0",
         "react-dom": "^19.0.0",
-        "react-force-graph-3d": "^1.29.1"
+        "react-router-dom": "^7.0.0"
       },
       },
       "devDependencies": {
       "devDependencies": {
         "@types/react": "^19.0.0",
         "@types/react": "^19.0.0",
@@ -253,15 +255,6 @@
         "@babel/core": "^7.0.0-0"
         "@babel/core": "^7.0.0-0"
       }
       }
     },
     },
-    "node_modules/@babel/runtime": {
-      "version": "7.28.6",
-      "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.6.tgz",
-      "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=6.9.0"
-      }
-    },
     "node_modules/@babel/template": {
     "node_modules/@babel/template": {
       "version": "7.28.6",
       "version": "7.28.6",
       "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
       "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
@@ -1159,12 +1152,6 @@
         "win32"
         "win32"
       ]
       ]
     },
     },
-    "node_modules/@tweenjs/tween.js": {
-      "version": "25.0.0",
-      "resolved": "https://registry.npmmirror.com/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
-      "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
-      "license": "MIT"
-    },
     "node_modules/@types/babel__core": {
     "node_modules/@types/babel__core": {
       "version": "7.20.5",
       "version": "7.20.5",
       "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
       "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1258,31 +1245,6 @@
         "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
         "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
       }
       }
     },
     },
-    "node_modules/3d-force-graph": {
-      "version": "1.79.1",
-      "resolved": "https://registry.npmmirror.com/3d-force-graph/-/3d-force-graph-1.79.1.tgz",
-      "integrity": "sha512-iscIVt4jWjJ11KEEswgOIOWk8Ew4EFKHRyERJXJ0ouycqzHCtWwb9E5imnxS5rYF1f1IESkFNAfB+h3EkU0Irw==",
-      "license": "MIT",
-      "dependencies": {
-        "accessor-fn": "1",
-        "kapsule": "^1.16",
-        "three": ">=0.118 <1",
-        "three-forcegraph": "1",
-        "three-render-objects": "^1.35"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/accessor-fn": {
-      "version": "1.5.3",
-      "resolved": "https://registry.npmmirror.com/accessor-fn/-/accessor-fn-1.5.3.tgz",
-      "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=12"
-      }
-    },
     "node_modules/baseline-browser-mapping": {
     "node_modules/baseline-browser-mapping": {
       "version": "2.10.0",
       "version": "2.10.0",
       "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
       "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -1358,184 +1320,26 @@
       "dev": true,
       "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
-    "node_modules/csstype": {
-      "version": "3.2.3",
-      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
-      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
-      "dev": true,
-      "license": "MIT"
-    },
-    "node_modules/d3-array": {
-      "version": "3.2.4",
-      "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz",
-      "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
-      "license": "ISC",
-      "dependencies": {
-        "internmap": "1 - 2"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-binarytree": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmmirror.com/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
-      "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
-      "license": "MIT"
-    },
-    "node_modules/d3-color": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz",
-      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-dispatch": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
-      "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-force-3d": {
-      "version": "3.0.6",
-      "resolved": "https://registry.npmmirror.com/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
-      "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
+    "node_modules/cookie": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+      "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
       "license": "MIT",
       "license": "MIT",
-      "dependencies": {
-        "d3-binarytree": "1",
-        "d3-dispatch": "1 - 3",
-        "d3-octree": "1",
-        "d3-quadtree": "1 - 3",
-        "d3-timer": "1 - 3"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-format": {
-      "version": "3.1.2",
-      "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.2.tgz",
-      "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
-      "license": "ISC",
       "engines": {
       "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-interpolate": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
-      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-color": "1 - 3"
+        "node": ">=18"
       },
       },
-      "engines": {
-        "node": ">=12"
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
       }
       }
     },
     },
-    "node_modules/d3-octree": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmmirror.com/d3-octree/-/d3-octree-1.1.0.tgz",
-      "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
-    "node_modules/d3-quadtree": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
-      "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-scale": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz",
-      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-array": "2.10.0 - 3",
-        "d3-format": "1 - 3",
-        "d3-interpolate": "1.2.0 - 3",
-        "d3-time": "2.1.1 - 3",
-        "d3-time-format": "2 - 4"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-scale-chromatic": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
-      "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-color": "1 - 3",
-        "d3-interpolate": "1 - 3"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-selection": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz",
-      "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-time": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz",
-      "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-array": "2 - 3"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-time-format": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz",
-      "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
-      "license": "ISC",
-      "dependencies": {
-        "d3-time": "1 - 3"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/d3-timer": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz",
-      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/data-bind-mapper": {
-      "version": "1.0.3",
-      "resolved": "https://registry.npmmirror.com/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz",
-      "integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==",
-      "license": "MIT",
-      "dependencies": {
-        "accessor-fn": "1"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
     "node_modules/debug": {
     "node_modules/debug": {
       "version": "4.4.3",
       "version": "4.4.3",
       "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
       "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
@@ -1554,6 +1358,30 @@
         }
         }
       }
       }
     },
     },
+    "node_modules/echarts": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz",
+      "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "5.6.1"
+      }
+    },
+    "node_modules/echarts-for-react": {
+      "version": "3.0.6",
+      "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
+      "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
+      "license": "MIT",
+      "dependencies": {
+        "fast-deep-equal": "^3.1.3",
+        "size-sensor": "^1.0.1"
+      },
+      "peerDependencies": {
+        "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
+        "react": "^15.0.0 || >=16.0.0"
+      }
+    },
     "node_modules/electron-to-chromium": {
     "node_modules/electron-to-chromium": {
       "version": "1.5.302",
       "version": "1.5.302",
       "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
       "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
@@ -1613,6 +1441,12 @@
         "node": ">=6"
         "node": ">=6"
       }
       }
     },
     },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "license": "MIT"
+    },
     "node_modules/fdir": {
     "node_modules/fdir": {
       "version": "6.5.0",
       "version": "6.5.0",
       "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
       "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
@@ -1631,20 +1465,6 @@
         }
         }
       }
       }
     },
     },
-    "node_modules/float-tooltip": {
-      "version": "1.7.5",
-      "resolved": "https://registry.npmmirror.com/float-tooltip/-/float-tooltip-1.7.5.tgz",
-      "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
-      "license": "MIT",
-      "dependencies": {
-        "d3-selection": "2 - 3",
-        "kapsule": "^1.16",
-        "preact": "10"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
     "node_modules/fsevents": {
     "node_modules/fsevents": {
       "version": "2.3.3",
       "version": "2.3.3",
       "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
       "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@@ -1670,28 +1490,11 @@
         "node": ">=6.9.0"
         "node": ">=6.9.0"
       }
       }
     },
     },
-    "node_modules/internmap": {
-      "version": "2.0.3",
-      "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
-      "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
-      "license": "ISC",
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/jerrypick": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmmirror.com/jerrypick/-/jerrypick-1.1.2.tgz",
-      "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=12"
-      }
-    },
     "node_modules/js-tokens": {
     "node_modules/js-tokens": {
       "version": "4.0.0",
       "version": "4.0.0",
       "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
       "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/jsesc": {
     "node_modules/jsesc": {
@@ -1720,36 +1523,6 @@
         "node": ">=6"
         "node": ">=6"
       }
       }
     },
     },
-    "node_modules/kapsule": {
-      "version": "1.16.3",
-      "resolved": "https://registry.npmmirror.com/kapsule/-/kapsule-1.16.3.tgz",
-      "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
-      "license": "MIT",
-      "dependencies": {
-        "lodash-es": "4"
-      },
-      "engines": {
-        "node": ">=12"
-      }
-    },
-    "node_modules/lodash-es": {
-      "version": "4.17.23",
-      "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz",
-      "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
-      "license": "MIT"
-    },
-    "node_modules/loose-envify": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
-      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
-      "license": "MIT",
-      "dependencies": {
-        "js-tokens": "^3.0.0 || ^4.0.0"
-      },
-      "bin": {
-        "loose-envify": "cli.js"
-      }
-    },
     "node_modules/lru-cache": {
     "node_modules/lru-cache": {
       "version": "5.1.1",
       "version": "5.1.1",
       "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
       "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -1786,44 +1559,6 @@
         "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
         "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
       }
       }
     },
     },
-    "node_modules/ngraph.events": {
-      "version": "1.4.0",
-      "resolved": "https://registry.npmmirror.com/ngraph.events/-/ngraph.events-1.4.0.tgz",
-      "integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/ngraph.forcelayout": {
-      "version": "3.3.1",
-      "resolved": "https://registry.npmmirror.com/ngraph.forcelayout/-/ngraph.forcelayout-3.3.1.tgz",
-      "integrity": "sha512-MKBuEh1wujyQHFTW57y5vd/uuEOK0XfXYxm3lC7kktjJLRdt/KEKEknyOlc6tjXflqBKEuYBBcu7Ax5VY+S6aw==",
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "ngraph.events": "^1.0.0",
-        "ngraph.merge": "^1.0.0",
-        "ngraph.random": "^1.0.0"
-      }
-    },
-    "node_modules/ngraph.graph": {
-      "version": "20.1.2",
-      "resolved": "https://registry.npmmirror.com/ngraph.graph/-/ngraph.graph-20.1.2.tgz",
-      "integrity": "sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==",
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "ngraph.events": "^1.4.0"
-      }
-    },
-    "node_modules/ngraph.merge": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmmirror.com/ngraph.merge/-/ngraph.merge-1.0.0.tgz",
-      "integrity": "sha512-5J8YjGITUJeapsomtTALYsw7rFveYkM+lBj3QiYZ79EymQcuri65Nw3knQtFxQBU1r5iOaVRXrSwMENUPK62Vg==",
-      "license": "MIT"
-    },
-    "node_modules/ngraph.random": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmmirror.com/ngraph.random/-/ngraph.random-1.2.0.tgz",
-      "integrity": "sha512-4EUeAGbB2HWX9njd6bP6tciN6ByJfoaAvmVL9QTaZSeXrW46eNGA9GajiXiPBbvFqxUWFkEbyo6x5qsACUuVfA==",
-      "license": "BSD-3-Clause"
-    },
     "node_modules/node-releases": {
     "node_modules/node-releases": {
       "version": "2.0.27",
       "version": "2.0.27",
       "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz",
       "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz",
@@ -1831,15 +1566,6 @@
       "dev": true,
       "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
-    "node_modules/object-assign": {
-      "version": "4.1.1",
-      "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
-      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
     "node_modules/picocolors": {
     "node_modules/picocolors": {
       "version": "1.1.1",
       "version": "1.1.1",
       "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
       "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -1860,18 +1586,6 @@
         "url": "https://github.com/sponsors/jonschlinkert"
         "url": "https://github.com/sponsors/jonschlinkert"
       }
       }
     },
     },
-    "node_modules/polished": {
-      "version": "4.3.1",
-      "resolved": "https://registry.npmmirror.com/polished/-/polished-4.3.1.tgz",
-      "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==",
-      "license": "MIT",
-      "dependencies": {
-        "@babel/runtime": "^7.17.8"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
     "node_modules/postcss": {
     "node_modules/postcss": {
       "version": "8.5.8",
       "version": "8.5.8",
       "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz",
       "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz",
@@ -1901,27 +1615,6 @@
         "node": "^10 || ^12 || >=14"
         "node": "^10 || ^12 || >=14"
       }
       }
     },
     },
-    "node_modules/preact": {
-      "version": "10.28.4",
-      "resolved": "https://registry.npmmirror.com/preact/-/preact-10.28.4.tgz",
-      "integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==",
-      "license": "MIT",
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/preact"
-      }
-    },
-    "node_modules/prop-types": {
-      "version": "15.8.1",
-      "resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
-      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
-      "license": "MIT",
-      "dependencies": {
-        "loose-envify": "^1.4.0",
-        "object-assign": "^4.1.1",
-        "react-is": "^16.13.1"
-      }
-    },
     "node_modules/react": {
     "node_modules/react": {
       "version": "19.2.4",
       "version": "19.2.4",
       "resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz",
       "resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz",
@@ -1943,52 +1636,52 @@
         "react": "^19.2.4"
         "react": "^19.2.4"
       }
       }
     },
     },
-    "node_modules/react-force-graph-3d": {
-      "version": "1.29.1",
-      "resolved": "https://registry.npmmirror.com/react-force-graph-3d/-/react-force-graph-3d-1.29.1.tgz",
-      "integrity": "sha512-5Vp+PGpYnO+zLwgK2NvNqdXHvsWLrFzpDfJW1vUA1twjo9SPvXqfUYQrnRmAbD+K2tOxkZw1BkbH31l5b4TWHg==",
+    "node_modules/react-refresh": {
+      "version": "0.17.0",
+      "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz",
+      "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+      "dev": true,
       "license": "MIT",
       "license": "MIT",
-      "dependencies": {
-        "3d-force-graph": "^1.79",
-        "prop-types": "15",
-        "react-kapsule": "^2.5"
-      },
       "engines": {
       "engines": {
-        "node": ">=12"
-      },
-      "peerDependencies": {
-        "react": "*"
+        "node": ">=0.10.0"
       }
       }
     },
     },
-    "node_modules/react-is": {
-      "version": "16.13.1",
-      "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
-      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
-      "license": "MIT"
-    },
-    "node_modules/react-kapsule": {
-      "version": "2.5.7",
-      "resolved": "https://registry.npmmirror.com/react-kapsule/-/react-kapsule-2.5.7.tgz",
-      "integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==",
+    "node_modules/react-router": {
+      "version": "7.14.1",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz",
+      "integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "jerrypick": "^1.1.1"
+        "cookie": "^1.0.1",
+        "set-cookie-parser": "^2.6.0"
       },
       },
       "engines": {
       "engines": {
-        "node": ">=12"
+        "node": ">=20.0.0"
       },
       },
       "peerDependencies": {
       "peerDependencies": {
-        "react": ">=16.13.1"
+        "react": ">=18",
+        "react-dom": ">=18"
+      },
+      "peerDependenciesMeta": {
+        "react-dom": {
+          "optional": true
+        }
       }
       }
     },
     },
-    "node_modules/react-refresh": {
-      "version": "0.17.0",
-      "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz",
-      "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
-      "dev": true,
+    "node_modules/react-router-dom": {
+      "version": "7.14.1",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz",
+      "integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==",
       "license": "MIT",
       "license": "MIT",
+      "dependencies": {
+        "react-router": "7.14.1"
+      },
       "engines": {
       "engines": {
-        "node": ">=0.10.0"
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
       }
       }
     },
     },
     "node_modules/rollup": {
     "node_modules/rollup": {
@@ -2052,6 +1745,18 @@
         "semver": "bin/semver.js"
         "semver": "bin/semver.js"
       }
       }
     },
     },
+    "node_modules/set-cookie-parser": {
+      "version": "2.7.2",
+      "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+      "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+      "license": "MIT"
+    },
+    "node_modules/size-sensor": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
+      "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
+      "license": "ISC"
+    },
     "node_modules/source-map-js": {
     "node_modules/source-map-js": {
       "version": "1.2.1",
       "version": "1.2.1",
       "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
       "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2062,61 +1767,6 @@
         "node": ">=0.10.0"
         "node": ">=0.10.0"
       }
       }
     },
     },
-    "node_modules/three": {
-      "version": "0.183.2",
-      "resolved": "https://registry.npmmirror.com/three/-/three-0.183.2.tgz",
-      "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
-      "license": "MIT"
-    },
-    "node_modules/three-forcegraph": {
-      "version": "1.43.0",
-      "resolved": "https://registry.npmmirror.com/three-forcegraph/-/three-forcegraph-1.43.0.tgz",
-      "integrity": "sha512-1AqLmTCjjjwcuccObG96fCxiRnNJjCLdA5Mozl7XK+ROwTJ6QEJPo2XJ6uxWeuAmPE7ukMhgv4lj28oZSfE4wg==",
-      "license": "MIT",
-      "dependencies": {
-        "accessor-fn": "1",
-        "d3-array": "1 - 3",
-        "d3-force-3d": "2 - 3",
-        "d3-scale": "1 - 4",
-        "d3-scale-chromatic": "1 - 3",
-        "data-bind-mapper": "1",
-        "kapsule": "^1.16",
-        "ngraph.forcelayout": "3",
-        "ngraph.graph": "20",
-        "tinycolor2": "1"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "peerDependencies": {
-        "three": ">=0.118.3"
-      }
-    },
-    "node_modules/three-render-objects": {
-      "version": "1.40.4",
-      "resolved": "https://registry.npmmirror.com/three-render-objects/-/three-render-objects-1.40.4.tgz",
-      "integrity": "sha512-Ukpu1pei3L5r809izvjsZxwuRcYLiyn6Uvy3lZ9bpMTdvj3i6PeX6w++/hs2ZS3KnEzGjb6YvTvh4UQuwHTDJg==",
-      "license": "MIT",
-      "dependencies": {
-        "@tweenjs/tween.js": "18 - 25",
-        "accessor-fn": "1",
-        "float-tooltip": "^1.7",
-        "kapsule": "^1.16",
-        "polished": "4"
-      },
-      "engines": {
-        "node": ">=12"
-      },
-      "peerDependencies": {
-        "three": ">=0.168"
-      }
-    },
-    "node_modules/tinycolor2": {
-      "version": "1.6.0",
-      "resolved": "https://registry.npmmirror.com/tinycolor2/-/tinycolor2-1.6.0.tgz",
-      "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
-      "license": "MIT"
-    },
     "node_modules/tinyglobby": {
     "node_modules/tinyglobby": {
       "version": "0.2.15",
       "version": "0.2.15",
       "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
       "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2134,6 +1784,12 @@
         "url": "https://github.com/sponsors/SuperchupuDev"
         "url": "https://github.com/sponsors/SuperchupuDev"
       }
       }
     },
     },
+    "node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
+    },
     "node_modules/update-browserslist-db": {
     "node_modules/update-browserslist-db": {
       "version": "1.2.3",
       "version": "1.2.3",
       "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
       "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -2246,6 +1902,15 @@
       "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
       "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
       "dev": true,
       "dev": true,
       "license": "ISC"
       "license": "ISC"
+    },
+    "node_modules/zrender": {
+      "version": "5.6.1",
+      "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
+      "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
     }
     }
   }
   }
 }
 }

+ 3 - 1
webnovel-writer/dashboard/frontend/package.json

@@ -9,9 +9,11 @@
     "preview": "vite preview"
     "preview": "vite preview"
   },
   },
   "dependencies": {
   "dependencies": {
+    "echarts": "^5.6.0",
+    "echarts-for-react": "^3.0.2",
     "react": "^19.0.0",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
     "react-dom": "^19.0.0",
-    "react-force-graph-3d": "^1.29.1"
+    "react-router-dom": "^7.0.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@types/react": "^19.0.0",
     "@types/react": "^19.0.0",

+ 60 - 872
webnovel-writer/dashboard/frontend/src/App.jsx

@@ -1,914 +1,102 @@
-import { useState, useEffect, useCallback } from 'react'
-import { fetchJSON, fetchStoryRuntimeHealth, subscribeSSE } from './api.js'
-import ForceGraph3D from 'react-force-graph-3d'
+import { startTransition, useCallback, useEffect, useState } from 'react'
+import { NavLink, Outlet, useOutletContext } from 'react-router-dom'
+import { fetchProjectInfo, subscribeSSE } from './api.js'
+import {
+    BookmarkIcon,
+    ChartBarIcon,
+    FolderIcon,
+    SlidersIcon,
+    TrendingUpIcon,
+    UsersIcon,
+    WifiIcon,
+    WifiOffIcon,
+} from './icons.jsx'
 
 
-// ====================================================================
-// 主应用
-// ====================================================================
+const NAV_ITEMS = [
+    { to: '/', label: '总览', icon: ChartBarIcon, end: true },
+    { to: '/characters', label: '角色图鉴', icon: UsersIcon },
+    { to: '/pacing', label: '节奏雷达', icon: TrendingUpIcon },
+    { to: '/foreshadowing', label: '伏笔追踪', icon: BookmarkIcon },
+    { to: '/files', label: '文档浏览', icon: FolderIcon },
+    { to: '/system', label: '系统状态', icon: SlidersIcon },
+]
 
 
 export default function App() {
 export default function App() {
-    const [page, setPage] = useState('dashboard')
     const [projectInfo, setProjectInfo] = useState(null)
     const [projectInfo, setProjectInfo] = useState(null)
-    const [refreshKey, setRefreshKey] = useState(0)
+    const [refreshToken, setRefreshToken] = useState(0)
     const [connected, setConnected] = useState(false)
     const [connected, setConnected] = useState(false)
 
 
     const loadProjectInfo = useCallback(() => {
     const loadProjectInfo = useCallback(() => {
-        fetchJSON('/api/project/info')
+        fetchProjectInfo()
             .then(setProjectInfo)
             .then(setProjectInfo)
             .catch(() => setProjectInfo(null))
             .catch(() => setProjectInfo(null))
     }, [])
     }, [])
 
 
-    useEffect(() => { loadProjectInfo() }, [loadProjectInfo, refreshKey])
+    useEffect(() => {
+        loadProjectInfo()
+    }, [loadProjectInfo, refreshToken])
 
 
-    // SSE 订阅
     useEffect(() => {
     useEffect(() => {
-        const unsub = subscribeSSE(
+        const unsubscribe = subscribeSSE(
             () => {
             () => {
-                setRefreshKey(k => k + 1)
+                startTransition(() => {
+                    setRefreshToken(current => current + 1)
+                })
             },
             },
             {
             {
                 onOpen: () => setConnected(true),
                 onOpen: () => setConnected(true),
                 onError: () => setConnected(false),
                 onError: () => setConnected(false),
             },
             },
         )
         )
-        return () => { unsub(); setConnected(false) }
+
+        return () => {
+            unsubscribe()
+            setConnected(false)
+        }
     }, [])
     }, [])
 
 
-    const title = projectInfo?.project_info?.title || '未加载'
+    const title = projectInfo?.project_info?.title || '未加载项目'
 
 
     return (
     return (
         <div className="app-layout">
         <div className="app-layout">
             <aside className="sidebar">
             <aside className="sidebar">
                 <div className="sidebar-header">
                 <div className="sidebar-header">
                     <h1>PIXEL WRITER HUB</h1>
                     <h1>PIXEL WRITER HUB</h1>
-                    <div className="subtitle">{title}</div>
+                    <div className="subtitle" title={title}>{title}</div>
                 </div>
                 </div>
                 <nav className="sidebar-nav">
                 <nav className="sidebar-nav">
-                    {NAV_ITEMS.map(item => (
-                        <button
-                            key={item.id}
-                            className={`nav-item ${page === item.id ? 'active' : ''}`}
-                            onClick={() => setPage(item.id)}
-                        >
-                            <span className="icon">{item.icon}</span>
-                            <span>{item.label}</span>
-                        </button>
-                    ))}
+                    {NAV_ITEMS.map(item => {
+                        const Icon = item.icon
+                        return (
+                            <NavLink
+                                key={item.to}
+                                to={item.to}
+                                end={item.end}
+                                className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`.trim()}
+                            >
+                                <span className="icon">
+                                    <Icon />
+                                </span>
+                                <span>{item.label}</span>
+                            </NavLink>
+                        )
+                    })}
                 </nav>
                 </nav>
                 <div className="live-indicator">
                 <div className="live-indicator">
-                    <span className={`live-dot ${connected ? '' : 'disconnected'}`} />
-                    {connected ? '实时同步中' : '未连接'}
+                    <span className="icon">
+                        {connected ? <WifiIcon /> : <WifiOffIcon />}
+                    </span>
+                    {connected ? '实时同步中' : '实时连接断开'}
                 </div>
                 </div>
             </aside>
             </aside>
 
 
             <main className="main-content">
             <main className="main-content">
-                {page === 'dashboard' && <DashboardPage data={projectInfo} key={refreshKey} />}
-                {page === 'entities' && <EntitiesPage key={refreshKey} />}
-                {page === 'graph' && <GraphPage key={refreshKey} />}
-                {page === 'chapters' && <ChaptersPage key={refreshKey} />}
-                {page === 'files' && <FilesPage />}
-                {page === 'reading' && <ReadingPowerPage key={refreshKey} />}
+                <Outlet context={{ projectInfo, refreshToken, connected, reloadProjectInfo: loadProjectInfo }} />
             </main>
             </main>
         </div>
         </div>
     )
     )
 }
 }
 
 
-const NAV_ITEMS = [
-    { id: 'dashboard', icon: '📊', label: '数据总览' },
-    { id: 'entities', icon: '👤', label: '设定词典' },
-    { id: 'graph', icon: '🕸️', label: '关系图谱' },
-    { id: 'chapters', icon: '📝', label: '章节一览' },
-    { id: 'files', icon: '📁', label: '文档浏览' },
-    { id: 'reading', icon: '🔥', label: '追读力' },
-]
-
-const FULL_DATA_GROUPS = [
-    { key: 'entities', title: '实体', columns: ['id', 'canonical_name', 'type', 'tier', 'first_appearance', 'last_appearance'], domain: 'core' },
-    { key: 'chapters', title: '章节', columns: ['chapter', 'title', 'word_count', 'location', 'characters'], domain: 'core' },
-    { key: 'scenes', title: '场景', columns: ['chapter', 'scene_index', 'location', 'time', 'summary'], domain: 'core' },
-    { key: 'aliases', title: '别名', columns: ['alias', 'entity_id', 'entity_type'], domain: 'core' },
-    { key: 'stateChanges', title: '状态变化', columns: ['entity_id', 'field', 'old_value', 'new_value', 'chapter'], domain: 'core' },
-    { key: 'relationships', title: '关系', columns: ['from_entity', 'to_entity', 'type', 'chapter', 'description'], domain: 'network' },
-    { key: 'relationshipEvents', title: '关系事件', columns: ['from_entity', 'to_entity', 'type', 'chapter', 'event_type', 'description'], domain: 'network' },
-    { key: 'readingPower', title: '追读力', columns: ['chapter', 'hook_type', 'hook_strength', 'is_transition', 'override_count', 'debt_balance'], domain: 'network' },
-    { key: 'overrides', title: 'Override 合约', columns: ['chapter', 'constraint_type', 'constraint_id', 'due_chapter', 'status'], domain: 'network' },
-    { key: 'debts', title: '追读债务', columns: ['id', 'debt_type', 'current_amount', 'interest_rate', 'due_chapter', 'status'], domain: 'network' },
-    { key: 'debtEvents', title: '债务事件', columns: ['debt_id', 'event_type', 'amount', 'chapter', 'note'], domain: 'network' },
-    { key: 'reviewMetrics', title: '审查指标', columns: ['start_chapter', 'end_chapter', 'overall_score', 'severity_counts', 'created_at'], domain: 'quality' },
-    { key: 'invalidFacts', title: '无效事实', columns: ['source_type', 'source_id', 'reason', 'status', 'chapter_discovered'], domain: 'quality' },
-    { key: 'checklistScores', title: '写作清单评分', columns: ['chapter', 'template', 'score', 'completion_rate', 'completed_items', 'total_items'], domain: 'quality' },
-    { key: 'ragQueries', title: 'RAG 查询日志', columns: ['query_type', 'query', 'results_count', 'latency_ms', 'chapter', 'created_at'], domain: 'ops' },
-    { key: 'toolStats', title: '工具调用统计', columns: ['tool_name', 'success', 'retry_count', 'error_code', 'chapter', 'created_at'], domain: 'ops' },
-]
-
-const FULL_DATA_DOMAINS = [
-    { id: 'overview', label: '总览' },
-    { id: 'core', label: '基础档案' },
-    { id: 'network', label: '关系与剧情' },
-    { id: 'quality', label: '质量审查' },
-    { id: 'ops', label: 'RAG 与工具' },
-]
-
-
-// ====================================================================
-// 页面 1:数据总览
-// ====================================================================
-
-function DashboardPage({ data }) {
-    const [runtimeHealth, setRuntimeHealth] = useState(null)
-
-    useEffect(() => {
-        fetchStoryRuntimeHealth()
-            .then(setRuntimeHealth)
-            .catch(() => setRuntimeHealth(null))
-    }, [])
-
-    if (!data) return <div className="loading">加载中…</div>
-
-    const info = data.project_info || {}
-    const progress = data.progress || {}
-    const protagonist = data.protagonist_state || {}
-    const strand = data.strand_tracker || {}
-    const foreshadowing = data.plot_threads?.foreshadowing || []
-
-    const totalWords = progress.total_words || 0
-    const targetWords = info.target_words || 2000000
-    const pct = targetWords > 0 ? Math.min(100, (totalWords / targetWords * 100)).toFixed(1) : 0
-
-    const unresolvedForeshadow = foreshadowing.filter(f => {
-        const s = (f.status || '').toLowerCase()
-        return s !== '已回收' && s !== '已兑现' && s !== 'resolved'
-    })
-
-    // Strand 历史统计
-    const history = strand.history || []
-    const strandCounts = { quest: 0, fire: 0, constellation: 0 }
-    history.forEach(h => { if (strandCounts[h.strand] !== undefined) strandCounts[h.strand]++ })
-    const total = history.length || 1
-
-    return (
-        <>
-            <div className="page-header">
-                <h2>📊 数据总览</h2>
-                <span className="card-badge badge-blue">{info.genre || '未知题材'}</span>
-            </div>
-
-            <div className="dashboard-grid">
-                <div className="card stat-card">
-                    <span className="stat-label">总字数</span>
-                    <span className="stat-value">{formatNumber(totalWords)}</span>
-                    <span className="stat-sub">目标 {formatNumber(targetWords)} 字 · {pct}%</span>
-                    <div className="progress-track">
-                        <div className="progress-fill" style={{ width: `${pct}%` }} />
-                    </div>
-                </div>
-
-                <div className="card stat-card">
-                    <span className="stat-label">当前章节</span>
-                    <span className="stat-value">第 {progress.current_chapter || 0} 章</span>
-                    <span className="stat-sub">目标 {info.target_chapters || '?'} 章 · 卷 {progress.current_volume || 1}</span>
-                </div>
-
-                {runtimeHealth ? (
-                    <div className="card stat-card">
-                        <span className="stat-label">Story Runtime</span>
-                        <span className="stat-value plain">
-                            {runtimeHealth.mainline_ready ? 'Mainline' : 'Fallback'}
-                        </span>
-                        <span className="stat-sub">
-                            {runtimeHealth.latest_commit_status || 'missing'} · {renderFallbackSources(runtimeHealth.fallback_sources)}
-                        </span>
-                    </div>
-                ) : null}
-
-                <div className="card stat-card">
-                    <span className="stat-label">主角状态</span>
-                    <span className="stat-value plain">{protagonist.name || '未设定'}</span>
-                    <span className="stat-sub">
-                        {protagonist.power?.realm || '未知境界'}
-                        {protagonist.location?.current ? ` · ${protagonist.location.current}` : ''}
-                    </span>
-                </div>
-
-                <div className="card stat-card">
-                    <span className="stat-label">未回收伏笔</span>
-                    <span className="stat-value" style={{ color: unresolvedForeshadow.length > 10 ? 'var(--accent-red)' : 'var(--accent-amber)' }}>
-                        {unresolvedForeshadow.length}
-                    </span>
-                    <span className="stat-sub">总计 {foreshadowing.length} 条伏笔</span>
-                </div>
-            </div>
-
-            {/* Strand Weave 比例 */}
-            <div className="card dashboard-section-card">
-                <div className="card-header">
-                    <span className="card-title">Strand Weave 节奏分布</span>
-                    <span className="card-badge badge-purple">{strand.current_dominant || '?'}</span>
-                </div>
-                <div className="strand-bar">
-                    <div className="segment strand-quest" style={{ width: `${(strandCounts.quest / total * 100).toFixed(1)}%` }} />
-                    <div className="segment strand-fire" style={{ width: `${(strandCounts.fire / total * 100).toFixed(1)}%` }} />
-                    <div className="segment strand-constellation" style={{ width: `${(strandCounts.constellation / total * 100).toFixed(1)}%` }} />
-                </div>
-                <div className="strand-legend">
-                    <span>🔵 Quest {(strandCounts.quest / total * 100).toFixed(0)}%</span>
-                    <span>🔴 Fire {(strandCounts.fire / total * 100).toFixed(0)}%</span>
-                    <span>🟣 Constellation {(strandCounts.constellation / total * 100).toFixed(0)}%</span>
-                </div>
-            </div>
-
-            {/* 伏笔列表 */}
-            {unresolvedForeshadow.length > 0 ? (
-                <div className="card dashboard-section-card">
-                    <div className="card-header">
-                        <span className="card-title">⚠️ 待回收伏笔 (Top 20)</span>
-                    </div>
-                    <div className="table-wrap">
-                        <table className="data-table">
-                            <thead><tr><th>内容</th><th>状态</th><th>埋设章</th></tr></thead>
-                            <tbody>
-                                {unresolvedForeshadow.slice(0, 20).map((f, i) => (
-                                    <tr key={i}>
-                                        <td className="truncate" style={{ maxWidth: 400 }}>{f.content || f.description || '—'}</td>
-                                        <td><span className="card-badge badge-amber">{f.status || '未知'}</span></td>
-                                        <td>{f.chapter || f.planted_chapter || '—'}</td>
-                                    </tr>
-                                ))}
-                            </tbody>
-                        </table>
-                    </div>
-                </div>
-            ) : null}
-
-            <MergedDataView />
-        </>
-    )
-}
-
-
-// ====================================================================
-// 页面 2:设定词典
-// ====================================================================
-
-function EntitiesPage() {
-    const [entities, setEntities] = useState([])
-    const [typeFilter, setTypeFilter] = useState('')
-    const [selected, setSelected] = useState(null)
-    const [changes, setChanges] = useState([])
-
-    useEffect(() => {
-        fetchJSON('/api/entities').then(setEntities).catch(() => { })
-    }, [])
-
-    useEffect(() => {
-        if (selected) {
-            fetchJSON('/api/state-changes', { entity: selected.id, limit: 30 }).then(setChanges).catch(() => setChanges([]))
-        }
-    }, [selected])
-
-    const types = [...new Set(entities.map(e => e.type))].sort()
-    const filteredEntities = typeFilter ? entities.filter(e => e.type === typeFilter) : entities
-
-    return (
-        <>
-            <div className="page-header">
-                <h2>👤 设定词典</h2>
-                <span className="card-badge badge-green">{filteredEntities.length} / {entities.length} 个实体</span>
-            </div>
-
-            <div className="filter-group">
-                <button className={`filter-btn ${typeFilter === '' ? 'active' : ''}`} onClick={() => setTypeFilter('')}>全部</button>
-                {types.map(t => (
-                    <button key={t} className={`filter-btn ${typeFilter === t ? 'active' : ''}`} onClick={() => setTypeFilter(t)}>{t}</button>
-                ))}
-            </div>
-
-            <div className="split-layout">
-                <div className="split-main">
-                    <div className="card">
-                        <div className="table-wrap">
-                            <table className="data-table">
-                                <thead><tr><th>名称</th><th>类型</th><th>层级</th><th>首现</th><th>末现</th></tr></thead>
-                                <tbody>
-                                    {filteredEntities.map(e => (
-                                        <tr
-                                            key={e.id}
-                                            role="button"
-                                            tabIndex={0}
-                                            className={`entity-row ${selected?.id === e.id ? 'selected' : ''}`}
-                                            onKeyDown={evt => (evt.key === 'Enter' || evt.key === ' ') && (evt.preventDefault(), setSelected(e))}
-                                            onClick={() => setSelected(e)}
-                                        >
-                                            <td className={e.is_protagonist ? 'entity-name protagonist' : 'entity-name'}>
-                                                {e.canonical_name} {e.is_protagonist ? '⭐' : ''}
-                                            </td>
-                                            <td><span className="card-badge badge-blue">{e.type}</span></td>
-                                            <td>{e.tier}</td>
-                                            <td>{e.first_appearance || '—'}</td>
-                                            <td>{e.last_appearance || '—'}</td>
-                                        </tr>
-                                    ))}
-                                </tbody>
-                            </table>
-                        </div>
-                    </div>
-                </div>
-
-                {selected && (
-                    <div className="split-side">
-                        <div className="card">
-                            <div className="card-header">
-                                <span className="card-title">{selected.canonical_name}</span>
-                                <span className="card-badge badge-purple">{selected.tier}</span>
-                            </div>
-                            <div className="entity-detail">
-                                <p><strong>类型:</strong>{selected.type}</p>
-                                <p><strong>ID:</strong><code>{selected.id}</code></p>
-                                {selected.desc && <p className="entity-desc">{selected.desc}</p>}
-                                {selected.current_json && (
-                                    <div className="entity-current-block">
-                                        <strong>当前状态:</strong>
-                                        <pre className="entity-json">
-                                            {formatJSON(selected.current_json)}
-                                        </pre>
-                                    </div>
-                                )}
-                            </div>
-                            {changes.length > 0 ? (
-                                <div className="entity-history">
-                                    <div className="card-title">状态变化历史</div>
-                                    <div className="table-wrap">
-                                        <table className="data-table">
-                                            <thead><tr><th>章</th><th>字段</th><th>变化</th></tr></thead>
-                                            <tbody>
-                                                {changes.map((c, i) => (
-                                                    <tr key={i}>
-                                                        <td>{c.chapter}</td>
-                                                        <td>{c.field}</td>
-                                                        <td>{c.old_value} → {c.new_value}</td>
-                                                    </tr>
-                                                ))}
-                                            </tbody>
-                                        </table>
-                                    </div>
-                                </div>
-                            ) : null}
-                        </div>
-                    </div>
-                )}
-            </div>
-        </>
-    )
-}
-
-
-// ====================================================================
-// 页面 3:3D 宇宙关系图谱
-// ====================================================================
-
-function GraphPage() {
-    const [relationships, setRelationships] = useState([])
-    const [graphData, setGraphData] = useState({ nodes: [], links: [] })
-
-    useEffect(() => {
-        Promise.all([
-            fetchJSON('/api/relationships', { limit: 1000 }),
-            fetchJSON('/api/entities'),
-        ]).then(([rels, ents]) => {
-            setRelationships(rels)
-            const typeColors = {
-                '角色': '#4f8ff7', '地点': '#34d399', '星球': '#22d3ee', '神仙': '#f59e0b',
-                '势力': '#8b5cf6', '招式': '#ef4444', '法宝': '#ec4899'
-            }
-            const relatedIds = new Set()
-            rels.forEach(r => { relatedIds.add(r.from_entity); relatedIds.add(r.to_entity) })
-            const entityMap = {}
-            ents.forEach(e => { entityMap[e.id] = e })
-
-            const nodes = [...relatedIds].map(id => ({
-                id,
-                name: entityMap[id]?.canonical_name || id,
-                val: (entityMap[id]?.tier === 'S' ? 8 : entityMap[id]?.tier === 'A' ? 5 : 2),
-                color: typeColors[entityMap[id]?.type] || '#5c6078'
-            }))
-            const links = rels.map(r => ({
-                source: r.from_entity,
-                target: r.to_entity,
-                name: r.type
-            }))
-            setGraphData({ nodes, links })
-        }).catch(() => { })
-    }, [])
-
-    return (
-        <>
-            <div className="page-header">
-                <h2>🕸️ 关系图谱</h2>
-                <span className="card-badge badge-blue">{relationships.length} 条引力链接</span>
-            </div>
-            <div className="card graph-shell">
-                <ForceGraph3D
-                    graphData={graphData}
-                    nodeLabel="name"
-                    nodeColor="color"
-                    nodeRelSize={6}
-                    linkColor={() => 'rgba(127, 90, 240, 0.35)'}
-                    linkWidth={1}
-                    linkDirectionalParticles={2}
-                    linkDirectionalParticleWidth={1.5}
-                    linkDirectionalParticleSpeed={d => 0.005 + Math.random() * 0.005}
-                    backgroundColor="#fffaf0"
-                    showNavInfo={false}
-                />
-            </div>
-        </>
-    )
-}
-
-
-
-// ====================================================================
-// 页面 4:章节一览
-// ====================================================================
-
-function ChaptersPage() {
-    const [chapters, setChapters] = useState([])
-
-    useEffect(() => {
-        fetchJSON('/api/chapters').then(setChapters).catch(() => { })
-    }, [])
-
-    const totalWords = chapters.reduce((s, c) => s + (c.word_count || 0), 0)
-
-    return (
-        <>
-            <div className="page-header">
-                <h2>📝 章节一览</h2>
-                <span className="card-badge badge-green">{chapters.length} 章 · {formatNumber(totalWords)} 字</span>
-            </div>
-            <div className="card">
-                <div className="table-wrap">
-                    <table className="data-table">
-                        <thead><tr><th>章节</th><th>标题</th><th>字数</th><th>地点</th><th>角色</th></tr></thead>
-                        <tbody>
-                            {chapters.map(c => (
-                                <tr key={c.chapter}>
-                                    <td className="chapter-no">第 {c.chapter} 章</td>
-                                    <td>{c.title || '—'}</td>
-                                    <td>{formatNumber(c.word_count || 0)}</td>
-                                    <td>{c.location || '—'}</td>
-                                    <td className="truncate chapter-characters">{c.characters || '—'}</td>
-                                </tr>
-                            ))}
-                        </tbody>
-                    </table>
-                </div>
-                {chapters.length === 0 ? <div className="empty-state"><div className="empty-icon">📭</div><p>暂无章节数据</p></div> : null}
-            </div>
-        </>
-    )
-}
-
-
-// ====================================================================
-// 页面 5:文档浏览
-// ====================================================================
-
-function FilesPage() {
-    const [tree, setTree] = useState({})
-    const [selectedPath, setSelectedPath] = useState(null)
-    const [content, setContent] = useState('')
-
-    useEffect(() => {
-        fetchJSON('/api/files/tree').then(setTree).catch(() => { })
-    }, [])
-
-    useEffect(() => {
-        if (selectedPath) {
-            fetchJSON('/api/files/read', { path: selectedPath })
-                .then(d => setContent(d.content))
-                .catch(() => setContent('[读取失败]'))
-        }
-    }, [selectedPath])
-
-    useEffect(() => {
-        if (selectedPath) return
-        const first = findFirstFilePath(tree)
-        if (first) setSelectedPath(first)
-    }, [tree, selectedPath])
-
-    return (
-        <>
-            <div className="page-header">
-                <h2>📁 文档浏览</h2>
-            </div>
-            <div className="file-layout">
-                <div className="file-tree-pane">
-                    {Object.entries(tree).map(([folder, items]) => (
-                        <div key={folder} className="folder-block">
-                            <div className="folder-title">📂 {folder}</div>
-                            <ul className="file-tree">
-                                <TreeNodes items={items} selected={selectedPath} onSelect={setSelectedPath} />
-                            </ul>
-                        </div>
-                    ))}
-                </div>
-                <div className="file-content-pane">
-                    {selectedPath ? (
-                        <div>
-                            <div className="selected-path">{selectedPath}</div>
-                            <div className="file-preview">{content}</div>
-                        </div>
-                    ) : (
-                        <div className="empty-state"><div className="empty-icon">📄</div><p>选择左侧文件以预览内容</p></div>
-                    )}
-                </div>
-            </div>
-        </>
-    )
-}
-
-
-// ====================================================================
-// 页面 6:追读力
-// ====================================================================
-
-function ReadingPowerPage() {
-    const [data, setData] = useState([])
-
-    useEffect(() => {
-        fetchJSON('/api/reading-power', { limit: 50 }).then(setData).catch(() => { })
-    }, [])
-
-    return (
-        <>
-            <div className="page-header">
-                <h2>🔥 追读力分析</h2>
-                <span className="card-badge badge-amber">{data.length} 章数据</span>
-            </div>
-            <div className="card">
-                <div className="table-wrap">
-                    <table className="data-table">
-                        <thead><tr><th>章节</th><th>钩子类型</th><th>钩子强度</th><th>过渡章</th><th>Override</th><th>债务余额</th></tr></thead>
-                        <tbody>
-                            {data.map(r => (
-                                <tr key={r.chapter}>
-                                    <td className="chapter-no">第 {r.chapter} 章</td>
-                                    <td>{r.hook_type || '—'}</td>
-                                    <td>
-                                        <span className={`card-badge ${r.hook_strength === 'strong' ? 'badge-green' : r.hook_strength === 'weak' ? 'badge-red' : 'badge-amber'}`}>
-                                            {r.hook_strength || '—'}
-                                        </span>
-                                    </td>
-                                    <td>{r.is_transition ? '✅' : '—'}</td>
-                                    <td>{r.override_count || 0}</td>
-                                    <td className={r.debt_balance > 0 ? 'debt-positive' : 'debt-normal'}>{(r.debt_balance || 0).toFixed(2)}</td>
-                                </tr>
-                            ))}
-                        </tbody>
-                    </table>
-                </div>
-                {data.length === 0 ? <div className="empty-state"><div className="empty-icon">🔥</div><p>暂无追读力数据</p></div> : null}
-            </div>
-        </>
-    )
-}
-
-function findFirstFilePath(tree) {
-    const roots = Object.values(tree || {})
-    for (const items of roots) {
-        const p = walkFirstFile(items)
-        if (p) return p
-    }
-    return null
-}
-
-function walkFirstFile(items) {
-    if (!Array.isArray(items)) return null
-    for (const item of items) {
-        if (item?.type === 'file' && item?.path) return item.path
-        if (item?.type === 'dir' && Array.isArray(item.children)) {
-            const p = walkFirstFile(item.children)
-            if (p) return p
-        }
-    }
-    return null
-}
-
-
-// ====================================================================
-// 数据总览内嵌:全量数据视图
-// ====================================================================
-
-function MergedDataView() {
-    const [loading, setLoading] = useState(true)
-    const [payload, setPayload] = useState({})
-    const [domain, setDomain] = useState('overview')
-
-    useEffect(() => {
-        let disposed = false
-
-        async function loadAll() {
-            setLoading(true)
-            const requests = [
-                ['entities', fetchJSON('/api/entities')],
-                ['chapters', fetchJSON('/api/chapters')],
-                ['scenes', fetchJSON('/api/scenes', { limit: 200 })],
-                ['relationships', fetchJSON('/api/relationships', { limit: 300 })],
-                ['relationshipEvents', fetchJSON('/api/relationship-events', { limit: 200 })],
-                ['readingPower', fetchJSON('/api/reading-power', { limit: 100 })],
-                ['reviewMetrics', fetchJSON('/api/review-metrics', { limit: 50 })],
-                ['stateChanges', fetchJSON('/api/state-changes', { limit: 120 })],
-                ['aliases', fetchJSON('/api/aliases')],
-                ['overrides', fetchJSON('/api/overrides', { limit: 120 })],
-                ['debts', fetchJSON('/api/debts', { limit: 120 })],
-                ['debtEvents', fetchJSON('/api/debt-events', { limit: 150 })],
-                ['invalidFacts', fetchJSON('/api/invalid-facts', { limit: 120 })],
-                ['ragQueries', fetchJSON('/api/rag-queries', { limit: 150 })],
-                ['toolStats', fetchJSON('/api/tool-stats', { limit: 200 })],
-                ['checklistScores', fetchJSON('/api/checklist-scores', { limit: 120 })],
-            ]
-
-            const entries = await Promise.all(
-                requests.map(async ([key, p]) => {
-                    try {
-                        const val = await p
-                        return [key, val]
-                    } catch {
-                        return [key, []]
-                    }
-                }),
-            )
-            if (!disposed) {
-                setPayload(Object.fromEntries(entries))
-                setLoading(false)
-            }
-        }
-
-        loadAll()
-        return () => { disposed = true }
-    }, [])
-
-    if (loading) return <div className="loading">加载全量数据中…</div>
-
-    const groups = domain === 'overview'
-        ? FULL_DATA_GROUPS
-        : FULL_DATA_GROUPS.filter(g => g.domain === domain)
-    const totalRows = FULL_DATA_GROUPS.reduce((sum, g) => sum + (payload[g.key] || []).length, 0)
-    const nonEmptyGroups = FULL_DATA_GROUPS.filter(g => (payload[g.key] || []).length > 0).length
-    const maxChapter = FULL_DATA_GROUPS.reduce((max, g) => {
-        const rows = payload[g.key] || []
-        rows.slice(0, 120).forEach(r => {
-            const c = extractChapter(r)
-            if (c > max) max = c
-        })
-        return max
-    }, 0)
-    const domainStats = FULL_DATA_DOMAINS.filter(d => d.id !== 'overview').map(d => {
-        const ds = FULL_DATA_GROUPS.filter(g => g.domain === d.id)
-        const rowCount = ds.reduce((sum, g) => sum + (payload[g.key] || []).length, 0)
-        const filled = ds.filter(g => (payload[g.key] || []).length > 0).length
-        return { ...d, rowCount, filled, total: ds.length }
-    })
-
-    return (
-        <>
-            <div className="page-header section-page-header">
-                <h2>🧪 全量数据视图</h2>
-                <span className="card-badge badge-cyan">{FULL_DATA_GROUPS.length} 类数据源</span>
-            </div>
-
-            <div className="demo-summary-grid">
-                <div className="card stat-card">
-                    <span className="stat-label">总记录数</span>
-                    <span className="stat-value">{formatNumber(totalRows)}</span>
-                    <span className="stat-sub">当前返回的全部数据行</span>
-                </div>
-                <div className="card stat-card">
-                    <span className="stat-label">已覆盖数据源</span>
-                    <span className="stat-value plain">{nonEmptyGroups}/{FULL_DATA_GROUPS.length}</span>
-                    <span className="stat-sub">有数据的表 / 总表数</span>
-                </div>
-                <div className="card stat-card">
-                    <span className="stat-label">最新章节触达</span>
-                    <span className="stat-value plain">{maxChapter > 0 ? `第 ${maxChapter} 章` : '—'}</span>
-                    <span className="stat-sub">按可识别 chapter 字段估算</span>
-                </div>
-                <div className="card stat-card">
-                    <span className="stat-label">当前视图</span>
-                    <span className="stat-value plain">{FULL_DATA_DOMAINS.find(d => d.id === domain)?.label}</span>
-                    <span className="stat-sub">{groups.length} 个数据分组</span>
-                </div>
-            </div>
-
-            <div className="demo-domain-tabs">
-                {FULL_DATA_DOMAINS.map(item => (
-                    <button
-                        key={item.id}
-                        className={`demo-domain-tab ${domain === item.id ? 'active' : ''}`}
-                        onClick={() => setDomain(item.id)}
-                    >
-                        {item.label}
-                    </button>
-                ))}
-            </div>
-
-            {domain === 'overview' ? (
-                <div className="demo-domain-grid">
-                    {domainStats.map(ds => (
-                        <div className="card" key={ds.id}>
-                            <div className="card-header">
-                                <span className="card-title">{ds.label}</span>
-                                <span className="card-badge badge-purple">{ds.filled}/{ds.total}</span>
-                            </div>
-                            <div className="domain-stat-number">{formatNumber(ds.rowCount)}</div>
-                            <div className="stat-sub">该数据域总记录数</div>
-                        </div>
-                    ))}
-                </div>
-            ) : null}
-
-            {groups.map(g => {
-                const count = (payload[g.key] || []).length
-                return (
-                    <div className="card demo-group-card" key={g.key}>
-                        <div className="card-header">
-                            <span className="card-title">{g.title}</span>
-                            <span className={`card-badge ${count > 0 ? 'badge-blue' : 'badge-amber'}`}>{count} 条</span>
-                        </div>
-                        <MiniTable
-                            rows={payload[g.key] || []}
-                            columns={g.columns}
-                            pageSize={12}
-                        />
-                    </div>
-                )
-            })}
-        </>
-    )
-}
-
-function MiniTable({ rows, columns, pageSize = 12 }) {
-    const [page, setPage] = useState(1)
-
-    useEffect(() => {
-        setPage(1)
-    }, [rows, columns, pageSize])
-
-    if (!rows || rows.length === 0) {
-        return <div className="empty-state compact"><p>暂无数据</p></div>
-    }
-
-    const totalPages = Math.max(1, Math.ceil(rows.length / pageSize))
-    const safePage = Math.min(page, totalPages)
-    const start = (safePage - 1) * pageSize
-    const list = rows.slice(start, start + pageSize)
-
-    return (
-        <>
-            <div className="table-wrap">
-                <table className="data-table">
-                    <thead>
-                        <tr>{columns.map(c => <th key={c}>{c}</th>)}</tr>
-                    </thead>
-                    <tbody>
-                        {list.map((row, i) => (
-                            <tr key={i}>
-                                {columns.map(c => (
-                                    <td key={c} className="truncate" style={{ maxWidth: 240 }}>
-                                        {formatCell(row?.[c])}
-                                    </td>
-                                ))}
-                            </tr>
-                        ))}
-                    </tbody>
-                </table>
-            </div>
-            <div className="table-pagination">
-                <button
-                    className="page-btn"
-                    type="button"
-                    onClick={() => setPage(p => Math.max(1, p - 1))}
-                    disabled={safePage <= 1}
-                >
-                    上一页
-                </button>
-                <span className="page-info">
-                    第 {safePage} / {totalPages} 页 · 共 {rows.length} 条
-                </span>
-                <button
-                    className="page-btn"
-                    type="button"
-                    onClick={() => setPage(p => Math.min(totalPages, p + 1))}
-                    disabled={safePage >= totalPages}
-                >
-                    下一页
-                </button>
-            </div>
-        </>
-    )
-}
-
-function extractChapter(row) {
-    if (!row || typeof row !== 'object') return 0
-    const candidates = [
-        row.chapter,
-        row.start_chapter,
-        row.end_chapter,
-        row.chapter_discovered,
-        row.first_appearance,
-        row.last_appearance,
-    ]
-    for (const c of candidates) {
-        const n = Number(c)
-        if (Number.isFinite(n) && n > 0) return n
-    }
-    return 0
-}
-
-
-// ====================================================================
-// 子组件:文件树递归
-// ====================================================================
-
-function TreeNodes({ items, selected, onSelect, depth = 0 }) {
-    const [expanded, setExpanded] = useState({})
-    if (!items || items.length === 0) return null
-
-    return items.map((item, i) => {
-        const key = item.path || `${depth}-${i}`
-        if (item.type === 'dir') {
-            const isOpen = expanded[key]
-            return (
-                <li key={key}>
-                    <div
-                        className="tree-item"
-                        role="button"
-                        tabIndex={0}
-                        onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), setExpanded(prev => ({ ...prev, [key]: !prev[key] })))}
-                        onClick={() => setExpanded(prev => ({ ...prev, [key]: !prev[key] }))}
-                    >
-                        <span className="tree-icon">{isOpen ? '📂' : '📁'}</span>
-                        <span>{item.name}</span>
-                    </div>
-                    {isOpen && item.children && (
-                        <ul className="tree-children">
-                            <TreeNodes items={item.children} selected={selected} onSelect={onSelect} depth={depth + 1} />
-                        </ul>
-                    )}
-                </li>
-            )
-        }
-        return (
-            <li key={key}>
-                <div
-                    className={`tree-item ${selected === item.path ? 'active' : ''}`}
-                    role="button"
-                    tabIndex={0}
-                    onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onSelect(item.path))}
-                    onClick={() => onSelect(item.path)}
-                >
-                    <span className="tree-icon">📄</span>
-                    <span>{item.name}</span>
-                </div>
-            </li>
-        )
-    })
-}
-
-
-// ====================================================================
-// 辅助:数字格式化
-// ====================================================================
-
-function formatNumber(n) {
-    if (n >= 10000) return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(n / 10000) + ' 万'
-    return new Intl.NumberFormat('zh-CN').format(n)
-}
-
-function formatJSON(str) {
-    try {
-        return JSON.stringify(JSON.parse(str), null, 2)
-    } catch {
-        return str
-    }
-}
-
-function formatCell(v) {
-    if (v === null || v === undefined) return '—'
-    if (typeof v === 'boolean') return v ? 'true' : 'false'
-    if (typeof v === 'object') {
-        try {
-            return JSON.stringify(v)
-        } catch {
-            return String(v)
-        }
-    }
-    const s = String(v)
-    return s.length > 180 ? `${s.slice(0, 180)}...` : s
-}
-
-function renderFallbackSources(items) {
-    if (!Array.isArray(items) || items.length === 0) return 'no fallback'
-    return items.join(', ')
+export function useDashboardContext() {
+    return useOutletContext()
 }
 }

+ 81 - 31
webnovel-writer/dashboard/frontend/src/api.js

@@ -1,43 +1,93 @@
-/**
- * API 请求工具函数
- */
-
-const BASE = '';  // 开发时由 vite proxy 代理到 FastAPI
+const BASE = ''
 
 
 export async function fetchJSON(path, params = {}) {
 export async function fetchJSON(path, params = {}) {
-    const url = new URL(path, window.location.origin);
-    Object.entries(params).forEach(([k, v]) => {
-        if (v !== undefined && v !== null) url.searchParams.set(k, v);
-    });
-    const res = await fetch(url.toString());
-    if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
-    return res.json();
+    const url = new URL(`${BASE}${path}`, window.location.origin)
+    for (const [key, value] of Object.entries(params)) {
+        if (value !== undefined && value !== null && value !== '') {
+            url.searchParams.set(key, value)
+        }
+    }
+
+    const response = await fetch(url.toString())
+    if (!response.ok) {
+        throw new Error(`${response.status} ${response.statusText}`)
+    }
+    return response.json()
+}
+
+export function fetchProjectInfo() {
+    return fetchJSON('/api/project/info')
 }
 }
 
 
 export function fetchStoryRuntimeHealth() {
 export function fetchStoryRuntimeHealth() {
-    return fetchJSON('/api/story-runtime/health');
+    return fetchJSON('/api/story-runtime/health')
+}
+
+export function fetchChapterTrend(params = {}) {
+    return fetchJSON('/api/stats/chapter-trend', params)
+}
+
+export function fetchChapters() {
+    return fetchJSON('/api/chapters')
+}
+
+export function fetchEntities(params = {}) {
+    return fetchJSON('/api/entities', params)
+}
+
+export function fetchStateChanges(params = {}) {
+    return fetchJSON('/api/state-changes', params)
+}
+
+export function fetchRelationships(params = {}) {
+    return fetchJSON('/api/relationships', params)
+}
+
+export function fetchRelationshipEvents(params = {}) {
+    return fetchJSON('/api/relationship-events', params)
+}
+
+export function fetchCommits(params = {}) {
+    return fetchJSON('/api/commits', params)
+}
+
+export function fetchContractsSummary() {
+    return fetchJSON('/api/contracts/summary')
+}
+
+export function fetchEnvStatus() {
+    return fetchJSON('/api/env-status')
+}
+
+export function probeEnvStatus() {
+    return fetchJSON('/api/env-status/probe')
+}
+
+export function fetchFilesTree() {
+    return fetchJSON('/api/files/tree')
+}
+
+export function fetchFileContent(path) {
+    return fetchJSON('/api/files/read', { path })
 }
 }
 
 
-/**
- * 订阅 SSE 实时事件流
- * @param {function} onMessage  收到 data 时回调
- * @param {{onOpen?: function, onError?: function}} handlers 连接状态回调
- * @returns {function} 取消订阅函数
- */
 export function subscribeSSE(onMessage, handlers = {}) {
 export function subscribeSSE(onMessage, handlers = {}) {
     const { onOpen, onError } = handlers
     const { onOpen, onError } = handlers
-    const es = new EventSource(`${BASE}/api/events`);
-    es.onopen = () => {
+    const eventSource = new EventSource(`${BASE}/api/events`)
+
+    eventSource.onopen = () => {
         if (onOpen) onOpen()
         if (onOpen) onOpen()
-    };
-    es.onmessage = (e) => {
+    }
+
+    eventSource.onmessage = event => {
         try {
         try {
-            onMessage(JSON.parse(e.data));
-        } catch { /* ignore parse errors */ }
-    };
-    es.onerror = (e) => {
-        // EventSource 会自动重连,这里只更新连接状态
-        if (onError) onError(e)
-    };
-    return () => es.close();
+            onMessage(JSON.parse(event.data))
+        } catch { /* ignore non-JSON messages */ }
+    }
+
+    eventSource.onerror = error => {
+        if (onError) onError(error)
+    }
+
+    return () => eventSource.close()
 }
 }

+ 18 - 0
webnovel-writer/dashboard/frontend/src/components/Badge.jsx

@@ -0,0 +1,18 @@
+const TONE_CLASS = {
+    blue: 'badge-blue',
+    green: 'badge-green',
+    amber: 'badge-amber',
+    red: 'badge-red',
+    purple: 'badge-purple',
+    cyan: 'badge-cyan',
+    neutral: 'badge-neutral',
+}
+
+export default function Badge({ tone = 'neutral', className = '', title = '', children }) {
+    const toneClass = TONE_CLASS[tone] || TONE_CLASS.neutral
+    return (
+        <span className={`badge ${toneClass} ${className}`.trim()} title={title}>
+            {children}
+        </span>
+    )
+}

+ 30 - 0
webnovel-writer/dashboard/frontend/src/components/ChartWrapper.jsx

@@ -0,0 +1,30 @@
+import { useEffect } from 'react'
+import ReactEChartsCore from 'echarts-for-react/lib/core'
+import { echarts, ensurePixelTheme } from '../lib/charts.js'
+
+export default function ChartWrapper({
+    option,
+    className = '',
+    height = 320,
+    loading = false,
+    onChartReady,
+}) {
+    useEffect(() => {
+        ensurePixelTheme()
+    }, [])
+
+    return (
+        <ReactEChartsCore
+            className={`chart-box ${className}`.trim()}
+            echarts={echarts}
+            theme="pixel"
+            option={option}
+            style={{ height }}
+            showLoading={loading}
+            notMerge
+            lazyUpdate
+            opts={{ renderer: 'canvas' }}
+            onChartReady={onChartReady}
+        />
+    )
+}

+ 92 - 0
webnovel-writer/dashboard/frontend/src/components/DataTable.jsx

@@ -0,0 +1,92 @@
+import { useEffect, useState } from 'react'
+import { formatTableValue } from '../lib/format.js'
+
+function resolveRowKey(row, rowKey, index) {
+    if (typeof rowKey === 'function') return rowKey(row, index)
+    if (typeof rowKey === 'string' && row?.[rowKey] !== undefined) return row[rowKey]
+    return index
+}
+
+export default function DataTable({
+    columns,
+    rows,
+    rowKey = 'id',
+    pageSize = 8,
+    emptyText = '暂无数据',
+    minWidth = 640,
+}) {
+    const [page, setPage] = useState(1)
+
+    useEffect(() => {
+        setPage(1)
+    }, [rows, pageSize])
+
+    if (!rows?.length) {
+        return (
+            <div className="empty-state compact">
+                <p>{emptyText}</p>
+            </div>
+        )
+    }
+
+    const totalPages = Math.max(1, Math.ceil(rows.length / pageSize))
+    const safePage = Math.min(page, totalPages)
+    const start = (safePage - 1) * pageSize
+    const pageRows = rows.slice(start, start + pageSize)
+
+    return (
+        <>
+            <div className="table-wrap">
+                <table className="data-table" style={{ minWidth }}>
+                    <thead>
+                        <tr>
+                            {columns.map(column => (
+                                <th key={column.key}>{column.label}</th>
+                            ))}
+                        </tr>
+                    </thead>
+                    <tbody>
+                        {pageRows.map((row, index) => (
+                            <tr key={resolveRowKey(row, rowKey, index)}>
+                                {columns.map(column => (
+                                    <td
+                                        key={column.key}
+                                        className={column.className || ''}
+                                        style={column.style || undefined}
+                                    >
+                                        {column.render
+                                            ? column.render(row)
+                                            : formatTableValue(row?.[column.key])}
+                                    </td>
+                                ))}
+                            </tr>
+                        ))}
+                    </tbody>
+                </table>
+            </div>
+            {totalPages > 1 ? (
+                <div className="table-pagination">
+                    <button
+                        className="page-btn"
+                        type="button"
+                        onClick={() => setPage(current => Math.max(1, current - 1))}
+                        disabled={safePage <= 1}
+                    >
+                        上一页
+                    </button>
+                    <span className="page-info">
+                        第 {safePage}/{totalPages} 页 · 共 {rows.length} 条
+                    </span>
+                    <button
+                        className="page-btn"
+                        type="button"
+                        onClick={() => setPage(current => Math.min(totalPages, current + 1))}
+                        disabled={safePage >= totalPages}
+                    >
+                        下一页
+                    </button>
+                </div>
+            ) : null}
+        </>
+    )
+}

+ 32 - 0
webnovel-writer/dashboard/frontend/src/components/Pager.jsx

@@ -0,0 +1,32 @@
+export default function Pager({
+    page,
+    totalPages,
+    currentStart,
+    currentEnd,
+    totalItems,
+    onPrevious,
+    onNext,
+    onLatest,
+    stepLabel = '50',
+}) {
+    if (totalItems <= 0) return null
+
+    return (
+        <div className="pager">
+            <button className="page-btn" type="button" onClick={onPrevious} disabled={page <= 1}>
+                ← 前 {stepLabel}
+            </button>
+            <span className="page-info">
+                第 {currentStart}-{currentEnd} 章 · 第 {page}/{totalPages} 页
+            </span>
+            <div className="pager-actions">
+                <button className="page-btn" type="button" onClick={onNext} disabled={page >= totalPages}>
+                    下一页 →
+                </button>
+                <button className="page-btn" type="button" onClick={onLatest} disabled={page >= totalPages}>
+                    跳到最新 →
+                </button>
+            </div>
+        </div>
+    )
+}

+ 165 - 0
webnovel-writer/dashboard/frontend/src/icons.jsx

@@ -0,0 +1,165 @@
+function IconBase({ children, className = '' }) {
+    return (
+        <svg
+            viewBox="0 0 24 24"
+            aria-hidden="true"
+            className={`pixel-icon ${className}`.trim()}
+            fill="currentColor"
+            shapeRendering="crispEdges"
+        >
+            {children}
+        </svg>
+    )
+}
+
+export function ChartBarIcon(props) {
+    return (
+        <IconBase {...props}>
+            <rect x="3" y="13" width="4" height="8" />
+            <rect x="10" y="9" width="4" height="12" />
+            <rect x="17" y="5" width="4" height="16" />
+            <rect x="3" y="3" width="18" height="2" />
+        </IconBase>
+    )
+}
+
+export function UsersIcon(props) {
+    return (
+        <IconBase {...props}>
+            <rect x="5" y="5" width="5" height="5" />
+            <rect x="14" y="6" width="5" height="4" />
+            <rect x="4" y="13" width="7" height="6" />
+            <rect x="13" y="13" width="7" height="6" />
+        </IconBase>
+    )
+}
+
+export function TrendingUpIcon(props) {
+    return (
+        <IconBase {...props}>
+            <rect x="3" y="17" width="4" height="4" />
+            <rect x="8" y="13" width="4" height="4" />
+            <rect x="13" y="9" width="4" height="4" />
+            <rect x="18" y="4" width="3" height="3" />
+            <rect x="17" y="4" width="2" height="11" transform="rotate(45 18 9.5)" />
+        </IconBase>
+    )
+}
+
+export function BookmarkIcon(props) {
+    return (
+        <IconBase {...props}>
+            <rect x="5" y="3" width="14" height="18" />
+            <rect x="8" y="14" width="8" height="4" transform="rotate(45 12 16)" />
+        </IconBase>
+    )
+}
+
+export function FolderIcon(props) {
+    return (
+        <IconBase {...props}>
+            <rect x="2" y="7" width="20" height="12" />
+            <rect x="2" y="5" width="8" height="3" />
+        </IconBase>
+    )
+}
+
+export function SlidersIcon(props) {
+    return (
+        <IconBase {...props}>
+            <rect x="4" y="4" width="2" height="16" />
+            <rect x="11" y="4" width="2" height="16" />
+            <rect x="18" y="4" width="2" height="16" />
+            <rect x="2" y="8" width="6" height="3" />
+            <rect x="9" y="14" width="6" height="3" />
+            <rect x="16" y="9" width="6" height="3" />
+        </IconBase>
+    )
+}
+
+export function PlayIcon(props) {
+    return (
+        <IconBase {...props}>
+            <polygon points="7,5 19,12 7,19" />
+        </IconBase>
+    )
+}
+
+export function PauseIcon(props) {
+    return (
+        <IconBase {...props}>
+            <rect x="6" y="5" width="4" height="14" />
+            <rect x="14" y="5" width="4" height="14" />
+        </IconBase>
+    )
+}
+
+export function ChevronLeftIcon(props) {
+    return (
+        <IconBase {...props}>
+            <polygon points="15,5 7,12 15,19 15,15 11,12 15,9" />
+        </IconBase>
+    )
+}
+
+export function ChevronRightIcon(props) {
+    return (
+        <IconBase {...props}>
+            <polygon points="9,5 17,12 9,19 9,15 13,12 9,9" />
+        </IconBase>
+    )
+}
+
+export function ChevronsRightIcon(props) {
+    return (
+        <IconBase {...props}>
+            <polygon points="5,5 13,12 5,19 5,15 9,12 5,9" />
+            <polygon points="11,5 19,12 11,19 11,15 15,12 11,9" />
+        </IconBase>
+    )
+}
+
+export function ReloadIcon(props) {
+    return (
+        <IconBase {...props}>
+            <rect x="5" y="5" width="10" height="2" />
+            <rect x="5" y="5" width="2" height="10" />
+            <rect x="9" y="17" width="10" height="2" />
+            <rect x="17" y="9" width="2" height="10" />
+            <polygon points="15,3 21,6 15,9" />
+            <polygon points="9,15 3,18 9,21" />
+        </IconBase>
+    )
+}
+
+export function WifiIcon(props) {
+    return (
+        <IconBase {...props}>
+            <rect x="11" y="17" width="2" height="2" />
+            <rect x="8" y="14" width="8" height="2" />
+            <rect x="5" y="11" width="14" height="2" />
+            <rect x="2" y="8" width="20" height="2" />
+        </IconBase>
+    )
+}
+
+export function WifiOffIcon(props) {
+    return (
+        <IconBase {...props}>
+            <rect x="11" y="17" width="2" height="2" />
+            <rect x="8" y="14" width="8" height="2" />
+            <rect x="5" y="11" width="14" height="2" />
+            <rect x="2" y="8" width="20" height="2" />
+            <rect x="4" y="4" width="2" height="16" transform="rotate(-45 5 12)" />
+        </IconBase>
+    )
+}
+
+export function SearchIcon(props) {
+    return (
+        <IconBase {...props}>
+            <rect x="5" y="5" width="9" height="9" />
+            <rect x="13" y="13" width="7" height="2" transform="rotate(45 16.5 14)" />
+        </IconBase>
+    )
+}

+ 432 - 354
webnovel-writer/dashboard/frontend/src/index.css

@@ -23,9 +23,9 @@
 }
 }
 
 
 * {
 * {
+  box-sizing: border-box;
   margin: 0;
   margin: 0;
   padding: 0;
   padding: 0;
-  box-sizing: border-box;
 }
 }
 
 
 *:focus-visible {
 *:focus-visible {
@@ -47,20 +47,34 @@ body {
     linear-gradient(90deg, rgba(42, 34, 15, 0.05) 1px, transparent 1px),
     linear-gradient(90deg, rgba(42, 34, 15, 0.05) 1px, transparent 1px),
     linear-gradient(rgba(42, 34, 15, 0.05) 1px, transparent 1px);
     linear-gradient(rgba(42, 34, 15, 0.05) 1px, transparent 1px);
   background-size: 14px 14px;
   background-size: 14px 14px;
+  font-size: 14px;
+  line-height: 1.6;
+}
+
+button,
+input,
+textarea,
+select {
+  font: inherit;
+}
+
+code,
+pre {
+  font-family: 'Cascadia Mono', 'Consolas', monospace;
 }
 }
 
 
 .app-layout {
 .app-layout {
   display: grid;
   display: grid;
   grid-template-columns: 240px minmax(0, 1fr);
   grid-template-columns: 240px minmax(0, 1fr);
-  height: 100vh;
+  min-height: 100vh;
 }
 }
 
 
 .sidebar {
 .sidebar {
-  border-right: 3px solid var(--border-main);
-  background: linear-gradient(180deg, #ffe8b8 0%, #ffe19f 100%);
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   min-height: 0;
   min-height: 0;
+  border-right: 3px solid var(--border-main);
+  background: linear-gradient(180deg, #ffe8b8 0%, #ffe19f 100%);
 }
 }
 
 
 .sidebar-header {
 .sidebar-header {
@@ -77,38 +91,35 @@ body {
 
 
 .sidebar-header .subtitle {
 .sidebar-header .subtitle {
   margin-top: 10px;
   margin-top: 10px;
-  font-size: 14px;
-  font-weight: 500;
-  color: var(--text-sub);
-  white-space: nowrap;
   overflow: hidden;
   overflow: hidden;
+  color: var(--text-sub);
   text-overflow: ellipsis;
   text-overflow: ellipsis;
+  white-space: nowrap;
 }
 }
 
 
 .sidebar-nav {
 .sidebar-nav {
-  flex: 1;
-  overflow-y: auto;
-  padding: 10px;
   display: flex;
   display: flex;
+  flex: 1;
   flex-direction: column;
   flex-direction: column;
   gap: 8px;
   gap: 8px;
+  overflow-y: auto;
+  padding: 10px;
 }
 }
 
 
 .nav-item {
 .nav-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
   width: 100%;
   width: 100%;
   border: 2px solid var(--border-main);
   border: 2px solid var(--border-main);
   background: #fff9e8;
   background: #fff9e8;
   color: var(--text-main);
   color: var(--text-main);
-  text-align: left;
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  padding: 10px 12px;
-  font-size: 14px;
-  font-weight: 600;
-  cursor: pointer;
   box-shadow: var(--shadow-soft);
   box-shadow: var(--shadow-soft);
-  transition: transform 0.08s ease;
+  cursor: pointer;
+  font-weight: 700;
+  padding: 10px 12px;
+  text-decoration: none;
+  transition: transform 0.08s ease, background-color 0.08s ease, border-color 0.08s ease;
 }
 }
 
 
 .nav-item:hover {
 .nav-item:hover {
@@ -121,119 +132,184 @@ body {
 }
 }
 
 
 .nav-item .icon {
 .nav-item .icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
   width: 22px;
   width: 22px;
-  text-align: center;
+}
+
+.pixel-icon {
+  width: 20px;
+  height: 20px;
 }
 }
 
 
 .live-indicator {
 .live-indicator {
-  border-top: 3px solid var(--border-main);
-  padding: 10px 12px;
-  font-size: 13px;
-  font-weight: 500;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   gap: 8px;
   gap: 8px;
-}
-
-.live-dot {
-  width: 10px;
-  height: 10px;
-  background: var(--accent-green);
-  border: 2px solid var(--border-main);
-}
-
-.live-dot.disconnected {
-  background: var(--accent-red);
+  border-top: 3px solid var(--border-main);
+  padding: 10px 12px;
+  font-size: 13px;
+  font-weight: 600;
 }
 }
 
 
 .main-content {
 .main-content {
-  overflow-y: auto;
   min-width: 0;
   min-width: 0;
+  overflow-y: auto;
   padding: 22px;
   padding: 22px;
 }
 }
 
 
+.dashboard-page {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
 .page-header {
 .page-header {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
-  gap: 12px;
   flex-wrap: wrap;
   flex-wrap: wrap;
-  margin-bottom: 14px;
+  gap: 12px;
 }
 }
 
 
 .page-header h2 {
 .page-header h2 {
   font-size: 22px;
   font-size: 22px;
-  line-height: 1.2;
   font-weight: 700;
   font-weight: 700;
+  line-height: 1.2;
 }
 }
 
 
-.section-page-header {
-  margin-top: 18px;
+.loading-screen {
+  display: grid;
+  place-items: center;
+  min-height: 100vh;
+  padding: 24px;
 }
 }
 
 
+.loading-card,
 .card {
 .card {
-  background: var(--bg-card);
   border: 3px solid var(--border-main);
   border: 3px solid var(--border-main);
+  background: var(--bg-card);
   box-shadow: var(--shadow-main);
   box-shadow: var(--shadow-main);
+}
+
+.loading-card {
+  min-width: min(420px, 100%);
+  padding: 18px;
+}
+
+.card {
   padding: 16px;
   padding: 16px;
-  margin-bottom: 16px;
 }
 }
 
 
 .card-header {
 .card-header {
   display: flex;
   display: flex;
-  align-items: center;
+  align-items: flex-start;
   justify-content: space-between;
   justify-content: space-between;
   gap: 12px;
   gap: 12px;
-  margin-bottom: 10px;
+  margin-bottom: 12px;
+}
+
+.compact-header {
+  margin-top: 10px;
 }
 }
 
 
 .card-title {
 .card-title {
   font-size: 17px;
   font-size: 17px;
   font-weight: 700;
   font-weight: 700;
+  line-height: 1.25;
 }
 }
 
 
-.card-badge {
+.section-label,
+.mini-label {
+  margin-bottom: 8px;
+  color: var(--text-mute);
+  font-family: var(--font-display);
+  font-size: 9px;
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
+}
+
+.mini-label {
+  margin-bottom: 6px;
+  font-size: 8px;
+}
+
+.badge {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 28px;
   border: 2px solid var(--border-main);
   border: 2px solid var(--border-main);
+  padding: 3px 8px;
   font-size: 12px;
   font-size: 12px;
   font-weight: 700;
   font-weight: 700;
-  padding: 3px 8px;
   background: #fff;
   background: #fff;
+  white-space: nowrap;
+}
+
+.badge-neutral {
+  color: var(--text-main);
+  background: #fffef8;
+}
+
+.badge-blue {
+  color: #055d8b;
+  background: #dff3ff;
 }
 }
 
 
-.badge-blue { background: #dff3ff; color: #055d8b; }
-.badge-green { background: #dcfce7; color: #0f5132; }
-.badge-amber { background: #fff1cd; color: #8a5b00; }
-.badge-red { background: #ffe0e5; color: #8f1d30; }
-.badge-purple { background: #ece3ff; color: #4a2ea8; }
-.badge-cyan { background: #dcfafe; color: #155e75; }
+.badge-green {
+  color: #0f5132;
+  background: #dcfce7;
+}
+
+.badge-amber {
+  color: #8a5b00;
+  background: #fff1cd;
+}
+
+.badge-red {
+  color: #8f1d30;
+  background: #ffe0e5;
+}
+
+.badge-purple {
+  color: #4a2ea8;
+  background: #ece3ff;
+}
+
+.badge-cyan {
+  color: #155e75;
+  background: #dcfafe;
+}
 
 
-.dashboard-grid {
+.stat-grid {
   display: grid;
   display: grid;
   grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
   grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
   gap: 12px;
   gap: 12px;
-  margin-bottom: 14px;
 }
 }
 
 
 .stat-card .stat-label {
 .stat-card .stat-label {
+  color: var(--text-mute);
   font-size: 13px;
   font-size: 13px;
   font-weight: 600;
   font-weight: 600;
-  color: var(--text-mute);
 }
 }
 
 
 .stat-card .stat-value {
 .stat-card .stat-value {
-  font-size: 28px;
-  line-height: 1.15;
   margin: 6px 0 2px;
   margin: 6px 0 2px;
   color: var(--accent-blue);
   color: var(--accent-blue);
+  font-size: 28px;
+  font-variant-numeric: tabular-nums;
+  line-height: 1.12;
 }
 }
 
 
 .stat-card .stat-value.plain {
 .stat-card .stat-value.plain {
   color: var(--text-main);
   color: var(--text-main);
 }
 }
 
 
-.stat-sub {
+.stat-card .stat-sub {
+  color: var(--text-sub);
   font-size: 13px;
   font-size: 13px;
   font-weight: 500;
   font-weight: 500;
-  color: var(--text-sub);
 }
 }
 
 
 .progress-track {
 .progress-track {
@@ -248,75 +324,26 @@ body {
   background: linear-gradient(90deg, #26a8ff, #7f5af0);
   background: linear-gradient(90deg, #26a8ff, #7f5af0);
 }
 }
 
 
-.dashboard-section-card {
-  margin-bottom: 16px;
-}
-
-.strand-bar {
-  height: 12px;
-  border: 2px solid var(--border-main);
-  display: flex;
-  margin-bottom: 10px;
-}
-
-.strand-bar .segment { height: 100%; }
-.strand-quest { background: #26a8ff; }
-.strand-fire { background: #ff5c8a; }
-.strand-constellation { background: #7f5af0; }
-
-.strand-legend {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 12px;
-  font-size: 13px;
-  color: var(--text-sub);
-}
-
-.demo-summary-grid {
+.content-grid {
   display: grid;
   display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
-  gap: 12px;
-  margin-bottom: 12px;
-}
-
-.demo-domain-tabs {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 8px;
-  margin-bottom: 12px;
+  gap: 16px;
 }
 }
 
 
-.demo-domain-tab {
-  border: 2px solid var(--border-main);
-  background: #fff8e6;
-  color: var(--text-main);
-  padding: 6px 10px;
-  font-family: var(--font-body);
-  font-size: 13px;
-  font-weight: 600;
-  cursor: pointer;
+.content-grid.two-columns {
+  grid-template-columns: repeat(2, minmax(0, 1fr));
 }
 }
 
 
-.demo-domain-tab.active {
-  background: #dff3ff;
-  border-color: var(--accent-blue);
-}
-
-.demo-domain-grid {
-  display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
-  gap: 12px;
-  margin-bottom: 12px;
+.chart-box {
+  width: 100%;
+  min-height: 320px;
 }
 }
 
 
-.domain-stat-number {
-  font-size: 30px;
-  color: var(--accent-purple);
-  margin-bottom: 4px;
+.chart-box.tall {
+  min-height: 420px;
 }
 }
 
 
-.demo-group-card {
-  margin-bottom: 12px;
+.chart-box.gantt {
+  min-height: 380px;
 }
 }
 
 
 .table-wrap {
 .table-wrap {
@@ -329,18 +356,16 @@ body {
   width: 100%;
   width: 100%;
   min-width: 580px;
   min-width: 580px;
   border-collapse: collapse;
   border-collapse: collapse;
-  font-size: 14px;
-  font-family: var(--font-body);
   font-variant-numeric: tabular-nums;
   font-variant-numeric: tabular-nums;
 }
 }
 
 
 .data-table th {
 .data-table th {
-  text-align: left;
   padding: 8px 10px;
   padding: 8px 10px;
   border-bottom: 2px solid var(--border-soft);
   border-bottom: 2px solid var(--border-soft);
   background: var(--bg-card-2);
   background: var(--bg-card-2);
-  white-space: nowrap;
   font-weight: 700;
   font-weight: 700;
+  text-align: left;
+  white-space: nowrap;
 }
 }
 
 
 .data-table td {
 .data-table td {
@@ -348,396 +373,449 @@ body {
   border-bottom: 1px solid #d8ccb2;
   border-bottom: 1px solid #d8ccb2;
   color: var(--text-main);
   color: var(--text-main);
   font-weight: 500;
   font-weight: 500;
+  vertical-align: top;
 }
 }
 
 
 .data-table tbody tr:hover td {
 .data-table tbody tr:hover td {
   background: #fff4d8;
   background: #fff4d8;
 }
 }
 
 
-.table-foot-note {
-  margin-top: 8px;
-}
-
-.table-pagination {
+.table-pagination,
+.pager {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: space-between;
   justify-content: space-between;
   gap: 10px;
   gap: 10px;
-  margin-top: 8px;
   flex-wrap: wrap;
   flex-wrap: wrap;
+  margin-top: 10px;
+}
+
+.pager-actions,
+.header-badges {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
 }
 }
 
 
-.page-btn {
+.filter-group {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 12px;
+}
+
+.page-btn,
+.tab-btn,
+.filter-btn,
+.tree-item {
   border: 2px solid var(--border-main);
   border: 2px solid var(--border-main);
   background: #fff8e6;
   background: #fff8e6;
   color: var(--text-main);
   color: var(--text-main);
-  font-family: var(--font-body);
-  font-size: 13px;
-  font-weight: 600;
-  padding: 4px 10px;
   cursor: pointer;
   cursor: pointer;
+  font-weight: 700;
+  transition: transform 0.08s ease, background-color 0.08s ease, border-color 0.08s ease;
+}
+
+.page-btn,
+.tab-btn,
+.filter-btn {
+  min-height: 36px;
+  padding: 5px 10px;
 }
 }
 
 
-.page-btn:hover:not(:disabled) {
+.page-btn:hover:not(:disabled),
+.tab-btn:hover,
+.filter-btn:hover,
+.tree-item:hover {
+  transform: translate(-1px, -1px);
+}
+
+.page-btn:hover:not(:disabled),
+.filter-btn:hover,
+.tab-btn.active,
+.filter-btn.active,
+.tree-item.active {
   background: #e6f7ff;
   background: #e6f7ff;
   border-color: var(--accent-blue);
   border-color: var(--accent-blue);
 }
 }
 
 
 .page-btn:disabled {
 .page-btn:disabled {
-  opacity: 0.5;
   cursor: not-allowed;
   cursor: not-allowed;
+  opacity: 0.5;
 }
 }
 
 
 .page-info {
 .page-info {
+  color: var(--text-sub);
   font-size: 13px;
   font-size: 13px;
   font-weight: 600;
   font-weight: 600;
+}
+
+.empty-state {
+  display: grid;
+  place-items: center;
+  min-height: 160px;
+  border: 2px dashed var(--border-soft);
+  background: #fff6df;
   color: var(--text-sub);
   color: var(--text-sub);
+  text-align: center;
+  padding: 18px;
 }
 }
 
 
-.split-layout {
+.empty-state.compact {
+  min-height: 90px;
+}
+
+.summary-card-list {
   display: grid;
   display: grid;
-  grid-template-columns: minmax(0, 1fr) 340px;
-  gap: 14px;
+  gap: 10px;
 }
 }
 
 
-.split-main,
-.split-side {
-  min-width: 0;
+.summary-card {
+  border: 2px solid var(--border-soft);
+  background: var(--bg-panel);
+  box-shadow: var(--shadow-soft);
+  padding: 12px;
 }
 }
 
 
-.entity-row {
-  cursor: pointer;
+.summary-card h3 {
+  font-size: 16px;
+  line-height: 1.3;
+  margin-bottom: 6px;
 }
 }
 
 
-.entity-row.selected td {
-  background: #e6f7ff;
+.summary-card p {
+  color: var(--text-sub);
 }
 }
 
 
-.entity-name {
-  font-weight: 700;
+.summary-card-header,
+.summary-meta,
+.graph-toolbar,
+.folder-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  gap: 8px;
 }
 }
 
 
-.entity-name.protagonist {
-  color: #b86a00;
+.summary-chapter {
+  font-weight: 700;
 }
 }
 
 
-.entity-detail {
-  font-size: 14px;
+.summary-badges,
+.summary-meta {
   color: var(--text-sub);
   color: var(--text-sub);
-  line-height: 1.7;
-  font-family: var(--font-body);
+  font-size: 13px;
 }
 }
 
 
-.entity-detail code {
-  border: 1px solid var(--border-soft);
-  padding: 1px 4px;
-  background: #fff;
+.summary-meta span::before {
+  content: '■';
+  margin-right: 6px;
+  color: var(--text-mute);
 }
 }
 
 
-.entity-desc {
-  margin-top: 8px;
+.summary-meta span:first-child::before {
+  content: '';
+  margin-right: 0;
 }
 }
 
 
-.entity-current-block {
-  margin-top: 10px;
+.split-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1.2fr) minmax(300px, 0.8fr);
+  gap: 16px;
+  align-items: start;
 }
 }
 
 
-.entity-json {
-  margin-top: 6px;
-  border: 2px solid var(--border-soft);
-  background: #fff;
-  padding: 8px;
-  max-height: 190px;
-  overflow: auto;
-  font-size: 12px;
-  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
+.sticky-card {
+  position: sticky;
+  top: 22px;
 }
 }
 
 
-.entity-history {
-  margin-top: 12px;
+.tab-strip {
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
 }
 }
 
 
-.graph-shell {
-  padding: 0;
-  overflow: hidden;
-  height: calc(100vh - 120px);
-  min-height: 520px;
+.entity-row {
+  cursor: pointer;
+}
+
+.entity-row.selected td {
+  background: #e6f7ff;
 }
 }
 
 
-.chapter-no {
+.entity-name {
   font-weight: 700;
   font-weight: 700;
-  white-space: nowrap;
 }
 }
 
 
-.chapter-characters {
-  max-width: 220px;
+.entity-name.protagonist::after {
+  content: ' ★';
+  color: var(--accent-amber);
 }
 }
 
 
-.file-layout {
+.entity-detail {
   display: grid;
   display: grid;
-  grid-template-columns: 300px minmax(0, 1fr);
-  gap: 12px;
-  height: calc(100vh - 130px);
-  min-height: 560px;
+  gap: 6px;
 }
 }
 
 
-.file-tree-pane {
-  height: 100%;
-  overflow-y: auto;
-  border: 3px solid var(--border-main);
-  box-shadow: var(--shadow-soft);
-  background: #fffcf5;
-  padding: 10px;
+.entity-desc {
+  color: var(--text-sub);
 }
 }
 
 
-.file-content-pane {
-  min-width: 0;
-  height: 100%;
-  border: 3px solid var(--border-main);
-  box-shadow: var(--shadow-soft);
-  background: #fffcf5;
-  padding: 10px;
-  overflow: hidden;
+.entity-current-block {
+  margin-top: 6px;
 }
 }
 
 
-.file-content-pane > div {
-  height: 100%;
-  min-height: 0;
-  display: flex;
-  flex-direction: column;
+.detail-divider {
+  height: 2px;
+  background: var(--border-soft);
+  margin: 14px 0;
 }
 }
 
 
-.folder-block {
+.code-block,
+.file-preview {
+  overflow: auto;
+  border: 2px solid var(--border-soft);
+  background: #fffef8;
+  color: var(--text-main);
+  padding: 12px;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+.code-block {
+  max-height: 240px;
+}
+
+.graph-toolbar {
   margin-bottom: 12px;
   margin-bottom: 12px;
 }
 }
 
 
-.folder-title {
-  font-size: 15px;
-  font-weight: 700;
-  margin-bottom: 6px;
+.timeline-slider {
+  flex: 1;
+  min-width: 220px;
+  cursor: pointer;
+  accent-color: var(--accent-blue);
 }
 }
 
 
-.selected-path {
-  margin-bottom: 8px;
-  font-size: 13px;
+.range-label {
   color: var(--text-mute);
   color: var(--text-mute);
+  font-size: 13px;
   font-weight: 600;
   font-weight: 600;
-  word-break: break-all;
 }
 }
 
 
-.file-tree {
-  list-style: none;
-  font-size: 13px;
-  font-family: var(--font-body);
+.files-layout {
+  grid-template-columns: minmax(280px, 320px) minmax(0, 1fr);
+  align-items: stretch;
 }
 }
 
 
-.tree-item {
-  border: 2px solid transparent;
-  padding: 6px 8px;
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  cursor: pointer;
+.files-tree-card,
+.files-preview-card {
+  min-height: 640px;
 }
 }
 
 
-.tree-item:hover {
-  background: #fff4d8;
-  border-color: #e0c98d;
+.folder-group-list {
+  display: grid;
+  gap: 12px;
 }
 }
 
 
-.tree-item.active {
-  background: #e6f7ff;
-  border-color: var(--accent-blue);
+.folder-block {
+  border: 2px solid var(--border-soft);
+  background: var(--bg-panel);
+  padding: 10px;
 }
 }
 
 
-.tree-icon {
-  width: 18px;
-  text-align: center;
+.folder-title {
+  margin-bottom: 10px;
+  font-weight: 700;
 }
 }
 
 
+.file-tree,
 .tree-children {
 .tree-children {
   list-style: none;
   list-style: none;
-  margin-left: 12px;
-  padding-left: 8px;
-  border-left: 2px dashed #d8ccb2;
 }
 }
 
 
-.file-preview {
-  border: 2px solid var(--border-soft);
-  background: #fff;
-  padding: 12px;
-  flex: 1;
-  min-height: 0;
-  overflow: auto;
-  white-space: pre-wrap;
-  line-height: 1.75;
-  word-break: break-word;
-  font-size: 14px;
-  font-family: var(--font-body);
-}
-
-.debt-positive { color: var(--accent-red); font-weight: 700; }
-.debt-normal { color: var(--text-sub); }
-
-.loading {
-  border: 3px solid var(--border-main);
-  background: #fff9e8;
-  padding: 20px;
-  box-shadow: var(--shadow-main);
-  font-size: 14px;
-  font-weight: 600;
+.tree-children {
+  margin-left: 14px;
+  margin-top: 6px;
+  display: grid;
+  gap: 6px;
 }
 }
 
 
-.empty-state {
-  text-align: center;
-  padding: 32px 14px;
-  color: var(--text-sub);
-  font-family: var(--font-body);
+.file-tree {
+  display: grid;
+  gap: 6px;
 }
 }
 
 
-.file-content-pane .empty-state {
-  height: 100%;
+.tree-item {
   display: flex;
   display: flex;
-  flex-direction: column;
   align-items: center;
   align-items: center;
-  justify-content: center;
+  gap: 8px;
+  width: 100%;
+  padding: 6px 8px;
+  text-align: left;
 }
 }
 
 
-.empty-state.compact {
-  padding: 20px 10px;
+.tree-dir.open {
+  background: #fff0cf;
 }
 }
 
 
-.empty-state .empty-icon {
-  font-size: 40px;
-  margin-bottom: 10px;
+.tree-glyph {
+  position: relative;
+  width: 14px;
+  height: 14px;
+  flex: 0 0 14px;
 }
 }
 
 
-.filter-group {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 8px;
-  margin-bottom: 12px;
+.tree-glyph::before,
+.tree-glyph::after {
+  content: '';
+  position: absolute;
+  inset: 0;
+  border: 2px solid var(--border-main);
+  background: #ffe8b8;
 }
 }
 
 
-.filter-btn {
-  border: 2px solid var(--border-main);
-  background: #fff8e6;
-  color: var(--text-main);
-  font-family: var(--font-body);
-  font-size: 13px;
-  font-weight: 600;
-  padding: 5px 10px;
-  cursor: pointer;
+.tree-glyph.file::before {
+  background: #dff3ff;
 }
 }
 
 
-.filter-btn.active {
-  background: #e6f7ff;
-  border-color: var(--accent-blue);
+.tree-glyph.file::after {
+  inset: 2px;
+  border-width: 0 0 2px 2px;
+  background: transparent;
 }
 }
 
 
-.truncate {
+.tree-name {
+  min-width: 0;
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
   white-space: nowrap;
   white-space: nowrap;
 }
 }
 
 
-@media (max-width: 1280px) {
-  .split-layout {
-    grid-template-columns: 1fr;
-  }
+.selected-path {
+  margin-bottom: 10px;
+  border: 2px solid var(--border-soft);
+  background: var(--bg-card-2);
+  padding: 8px 10px;
+  font-size: 13px;
+  font-weight: 700;
+  word-break: break-all;
+}
 
 
-  .graph-shell {
-    height: calc(100vh - 128px);
-    min-height: 460px;
+.file-preview {
+  min-height: 520px;
+}
+
+.file-preview.loading {
+  color: var(--text-mute);
+}
+
+.diagnosis-meta {
+  margin-bottom: 10px;
+  color: var(--text-sub);
+  font-size: 13px;
+  font-weight: 600;
+}
+
+@media (max-width: 1180px) {
+  .content-grid.two-columns,
+  .split-layout,
+  .files-layout {
+    grid-template-columns: 1fr;
   }
   }
 
 
-  .file-layout {
-    grid-template-columns: 260px minmax(0, 1fr);
-    min-height: 500px;
+  .sticky-card {
+    position: static;
   }
   }
 }
 }
 
 
-@media (max-width: 960px) {
+@media (max-width: 820px) {
   .app-layout {
   .app-layout {
-    grid-template-columns: 84px minmax(0, 1fr);
+    grid-template-columns: 1fr;
   }
   }
 
 
-  .sidebar-header h1,
-  .sidebar-header .subtitle,
-  .nav-item span:not(.icon),
-  .live-indicator {
-    display: none;
+  .sidebar {
+    border-right: 0;
+    border-bottom: 3px solid var(--border-main);
   }
   }
 
 
-  .sidebar {
-    border-right-width: 2px;
+  .sidebar-nav {
+    flex-direction: row;
+    overflow-x: auto;
   }
   }
 
 
   .nav-item {
   .nav-item {
-    justify-content: center;
-    padding: 12px 8px;
+    min-width: 128px;
   }
   }
 
 
   .main-content {
   .main-content {
-    padding: 14px;
+    padding: 16px;
   }
   }
 
 
-  .graph-shell {
-    height: calc(100vh - 108px);
-    min-height: 380px;
+  .page-header h2 {
+    font-size: 20px;
   }
   }
 
 
-  .file-layout {
+  .stat-grid {
     grid-template-columns: 1fr;
     grid-template-columns: 1fr;
-    height: auto;
-    min-height: 0;
   }
   }
 
 
-  .file-tree-pane {
-    height: 260px;
-    border: 2px solid var(--border-soft);
-    padding: 8px;
-    background: #fff;
+  .chart-box,
+  .chart-box.tall,
+  .chart-box.gantt {
+    min-height: 300px;
+  }
+
+  .files-tree-card,
+  .files-preview-card {
+    min-height: 0;
   }
   }
 
 
-  .file-content-pane {
-    height: calc(100vh - 430px);
-    min-height: 320px;
+  .file-preview {
+    min-height: 360px;
   }
   }
 }
 }
 
 
-@media (max-width: 720px) {
-  .page-header h2 {
-    font-size: 18px;
+@media (max-width: 560px) {
+  .sidebar-header h1 {
+    font-size: 10px;
   }
   }
 
 
-  .dashboard-grid,
-  .demo-summary-grid,
-  .demo-domain-grid {
-    grid-template-columns: 1fr;
+  .card {
+    padding: 14px;
   }
   }
 
 
-  .demo-domain-tabs {
-    overflow-x: auto;
-    flex-wrap: nowrap;
-    padding-bottom: 4px;
+  .page-header {
+    align-items: flex-start;
   }
   }
 
 
-  .demo-domain-tab {
-    white-space: nowrap;
+  .graph-toolbar {
+    align-items: stretch;
   }
   }
 
 
-  .card {
-    padding: 12px;
+  .timeline-slider {
+    min-width: 100%;
   }
   }
 
 
-  .graph-shell {
-    height: 58vh;
-    min-height: 320px;
+  .table-pagination,
+  .pager {
+    align-items: stretch;
+  }
+
+  .page-btn,
+  .tab-btn,
+  .filter-btn {
+    width: 100%;
+    justify-content: center;
   }
   }
 
 
-  .file-content-pane {
-    height: 58vh;
-    min-height: 280px;
+  .folder-title,
+  .summary-card-header,
+  .summary-meta {
+    flex-direction: column;
+    align-items: flex-start;
   }
   }
 }
 }

+ 123 - 0
webnovel-writer/dashboard/frontend/src/lib/charts.js

@@ -0,0 +1,123 @@
+import { BarChart, BoxplotChart, CustomChart, GraphChart, LineChart, PieChart } from 'echarts/charts'
+import {
+    GridComponent,
+    LegendComponent,
+    MarkLineComponent,
+    TooltipComponent,
+} from 'echarts/components'
+import { CanvasRenderer } from 'echarts/renderers'
+import * as echarts from 'echarts/core'
+
+echarts.use([
+    LineChart,
+    BarChart,
+    PieChart,
+    BoxplotChart,
+    GraphChart,
+    CustomChart,
+    GridComponent,
+    TooltipComponent,
+    LegendComponent,
+    MarkLineComponent,
+    CanvasRenderer,
+])
+
+export { echarts }
+
+const PIXEL_THEME_NAME = 'pixel'
+
+const PIXEL_THEME = {
+    color: ['#26a8ff', '#f5a524', '#7f5af0', '#2ec27e', '#d7263d', '#00b8d4', '#ff5c8a'],
+    backgroundColor: 'transparent',
+    textStyle: {
+        fontFamily: "'Noto Sans SC', 'Microsoft YaHei', sans-serif",
+        color: '#2a220f',
+    },
+    title: {
+        textStyle: {
+            fontFamily: "'Press Start 2P', monospace",
+            fontSize: 11,
+            color: '#2a220f',
+        },
+    },
+    legend: {
+        textStyle: {
+            color: '#5d5035',
+            fontSize: 13,
+            fontWeight: 600,
+        },
+    },
+    tooltip: {
+        backgroundColor: '#fffaf0',
+        borderColor: '#2a220f',
+        borderWidth: 2,
+        textStyle: {
+            color: '#2a220f',
+            fontSize: 13,
+        },
+        extraCssText: 'border-radius:0;box-shadow:3px 3px 0 #2a220f;',
+    },
+    categoryAxis: {
+        axisLine: { lineStyle: { color: '#8f7f5c', width: 2 } },
+        axisTick: { lineStyle: { color: '#8f7f5c' } },
+        axisLabel: { color: '#8f7f5c', fontSize: 12 },
+        splitLine: { lineStyle: { color: '#e8dcc4', type: 'dashed' } },
+    },
+    valueAxis: {
+        axisLine: { lineStyle: { color: '#8f7f5c', width: 2 } },
+        axisTick: { lineStyle: { color: '#8f7f5c' } },
+        axisLabel: { color: '#8f7f5c', fontSize: 12 },
+        splitLine: { lineStyle: { color: '#e8dcc4', type: 'dashed' } },
+    },
+    grid: { left: 50, right: 24, top: 30, bottom: 48 },
+}
+
+let themeRegistered = false
+
+export const STRAND_COLORS = {
+    quest: '#26a8ff',
+    fire: '#ff5c8a',
+    constellation: '#7f5af0',
+}
+
+export const FORESHADOWING_COLORS = {
+    overdue: '#d7263d',
+    urgent: '#f5a524',
+    active: '#26a8ff',
+    resolved: '#2ec27e',
+}
+
+export function ensurePixelTheme() {
+    if (themeRegistered) return
+    echarts.registerTheme(PIXEL_THEME_NAME, PIXEL_THEME)
+    themeRegistered = true
+}
+
+function quantile(sortedValues, ratio) {
+    if (!sortedValues.length) return 0
+    const index = (sortedValues.length - 1) * ratio
+    const lower = Math.floor(index)
+    const upper = Math.ceil(index)
+    if (lower === upper) return sortedValues[lower]
+    const weight = index - lower
+    return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight
+}
+
+export function buildBoxplotData(groups) {
+    return groups.map(group => {
+        const values = [...group.values]
+            .map(item => Number(item))
+            .filter(item => Number.isFinite(item))
+            .sort((left, right) => left - right)
+
+        if (!values.length) return [0, 0, 0, 0, 0]
+
+        return [
+            values[0],
+            quantile(values, 0.25),
+            quantile(values, 0.5),
+            quantile(values, 0.75),
+            values[values.length - 1],
+        ].map(item => Math.round(item))
+    })
+}

+ 19 - 0
webnovel-writer/dashboard/frontend/src/lib/files.js

@@ -0,0 +1,19 @@
+export function findFirstFilePath(tree) {
+    for (const items of Object.values(tree || {})) {
+        const path = walkFirstFile(items)
+        if (path) return path
+    }
+    return null
+}
+
+function walkFirstFile(items) {
+    if (!Array.isArray(items)) return null
+    for (const item of items) {
+        if (item?.type === 'file' && item?.path) return item.path
+        if (item?.type === 'dir' && Array.isArray(item.children)) {
+            const nested = walkFirstFile(item.children)
+            if (nested) return nested
+        }
+    }
+    return null
+}

+ 136 - 0
webnovel-writer/dashboard/frontend/src/lib/foreshadowing.js

@@ -0,0 +1,136 @@
+const RESOLVED_STATUS = new Set(['已回收', '已完成', '已解决', 'resolved', 'done', 'complete'])
+const CORE_TIER = new Set(['核心', 'core', 'main'])
+const DECOR_TIER = new Set(['装饰', 'decor', 'decoration'])
+
+function toPositiveInt(value) {
+    if (value === null || value === undefined || value === '' || value === false) return null
+    const number = Number.parseInt(String(value), 10)
+    return Number.isFinite(number) && number > 0 ? number : null
+}
+
+function resolveChapterField(item, keys) {
+    for (const key of keys) {
+        const chapter = toPositiveInt(item?.[key])
+        if (chapter) return chapter
+    }
+    return null
+}
+
+function isResolvedStatus(status) {
+    const text = String(status || '').trim()
+    return RESOLVED_STATUS.has(text) || RESOLVED_STATUS.has(text.toLowerCase())
+}
+
+function normalizeTier(rawTier) {
+    const text = String(rawTier || '').trim()
+    const lowered = text.toLowerCase()
+    if (CORE_TIER.has(text) || CORE_TIER.has(lowered)) return '核心'
+    if (DECOR_TIER.has(text) || DECOR_TIER.has(lowered)) return '装饰'
+    return '支线'
+}
+
+function tierWeight(tier) {
+    if (tier === '核心') return 3
+    if (tier === '装饰') return 1
+    return 2
+}
+
+function urgencyLevel(score, remaining, resolved) {
+    if (resolved) return 'resolved'
+    if (remaining !== null && remaining < 0) return 'overdue'
+    if ((score !== null && score >= 2) || (remaining !== null && remaining <= 5)) return 'urgent'
+    return 'active'
+}
+
+function urgencyText(level) {
+    if (level === 'overdue') return '超期'
+    if (level === 'urgent') return '紧急'
+    if (level === 'resolved') return '已回收'
+    return '活跃'
+}
+
+function urgencyBadge(score, level) {
+    if (level === 'overdue') return 'critical'
+    if (score !== null && score >= 3) return 'high'
+    if (score !== null && score >= 2) return 'medium'
+    if (level === 'resolved') return 'resolved'
+    return 'normal'
+}
+
+export function buildForeshadowingRecords(projectInfo) {
+    const currentChapter = Number(projectInfo?.progress?.current_chapter || 0)
+    const rows = Array.isArray(projectInfo?.plot_threads?.foreshadowing)
+        ? projectInfo.plot_threads.foreshadowing
+        : []
+
+    return rows.map((item, index) => {
+        const resolved = isResolvedStatus(item?.status)
+        const tier = normalizeTier(item?.tier)
+        const weight = tierWeight(tier)
+        const plantedChapter = resolveChapterField(item, [
+            'planted_chapter',
+            'added_chapter',
+            'source_chapter',
+            'start_chapter',
+            'chapter',
+        ])
+        const targetChapter = resolveChapterField(item, [
+            'target_chapter',
+            'due_chapter',
+            'deadline_chapter',
+            'resolve_by_chapter',
+            'target',
+        ])
+        const resolvedChapter = resolveChapterField(item, ['resolved_chapter', 'resolved'])
+
+        const elapsed = plantedChapter && currentChapter
+            ? Math.max(0, currentChapter - plantedChapter)
+            : null
+        const remaining = targetChapter && currentChapter
+            ? targetChapter - currentChapter
+            : null
+
+        let urgencyScore = null
+        if (plantedChapter && targetChapter && targetChapter > plantedChapter && elapsed !== null) {
+            urgencyScore = Number(((elapsed / (targetChapter - plantedChapter)) * weight).toFixed(2))
+        } else if (plantedChapter && targetChapter && targetChapter <= plantedChapter && elapsed !== null) {
+            urgencyScore = weight * 2
+        }
+
+        const level = urgencyLevel(urgencyScore, remaining, resolved)
+
+        return {
+            id: `${item?.content || 'foreshadowing'}-${index}`,
+            content: String(item?.content || item?.description || '未命名伏笔'),
+            tier,
+            plantedChapter,
+            targetChapter,
+            resolvedChapter,
+            elapsed,
+            remaining,
+            urgencyScore,
+            level,
+            statusText: urgencyText(level),
+            urgencyText: urgencyBadge(urgencyScore, level),
+            rawStatus: String(item?.status || ''),
+        }
+    })
+}
+
+export function summarizeForeshadowing(records) {
+    return records.reduce(
+        (summary, record) => {
+            summary.total += 1
+            if (record.level === 'resolved') {
+                summary.resolved += 1
+            } else {
+                summary.active += 1
+            }
+            if (record.level === 'urgent' || record.level === 'overdue') {
+                summary.attention += 1
+            }
+            return summary
+        },
+        { total: 0, active: 0, resolved: 0, attention: 0 },
+    )
+}

+ 76 - 0
webnovel-writer/dashboard/frontend/src/lib/format.js

@@ -0,0 +1,76 @@
+export function formatNumber(value) {
+    const number = Number(value || 0)
+    if (!Number.isFinite(number)) return '—'
+    if (Math.abs(number) >= 10000) {
+        return `${new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(number / 10000)} 万`
+    }
+    return new Intl.NumberFormat('zh-CN').format(number)
+}
+
+export function formatShortNumber(value) {
+    const number = Number(value || 0)
+    if (!Number.isFinite(number)) return '—'
+    return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(number)
+}
+
+export function formatPercent(value, fractionDigits = 1) {
+    const number = Number(value)
+    if (!Number.isFinite(number)) return '—'
+    return `${number.toFixed(fractionDigits)}%`
+}
+
+export function formatChapterLabel(chapter) {
+    const number = Number(chapter || 0)
+    if (!Number.isFinite(number) || number <= 0) return '—'
+    return `第 ${number} 章`
+}
+
+export function formatDateTime(value) {
+    if (!value) return '—'
+    const date = new Date(value)
+    if (Number.isNaN(date.getTime())) return String(value)
+    return date.toLocaleString('zh-CN', {
+        hour12: false,
+        month: '2-digit',
+        day: '2-digit',
+        hour: '2-digit',
+        minute: '2-digit',
+    })
+}
+
+export function formatJSONText(value) {
+    if (value === null || value === undefined || value === '') return ''
+    if (typeof value === 'object') {
+        return JSON.stringify(value, null, 2)
+    }
+    if (typeof value !== 'string') {
+        return String(value)
+    }
+    try {
+        return JSON.stringify(JSON.parse(value), null, 2)
+    } catch {
+        return value
+    }
+}
+
+export function average(values) {
+    const valid = values
+        .map(item => Number(item))
+        .filter(item => Number.isFinite(item))
+    if (!valid.length) return null
+    return valid.reduce((sum, item) => sum + item, 0) / valid.length
+}
+
+export function formatTableValue(value) {
+    if (value === null || value === undefined || value === '') return '—'
+    if (Array.isArray(value)) {
+        return value.length ? value.join('、') : '—'
+    }
+    if (typeof value === 'object') {
+        return JSON.stringify(value, null, 2)
+    }
+    if (typeof value === 'boolean') {
+        return value ? '是' : '否'
+    }
+    return String(value)
+}

+ 69 - 0
webnovel-writer/dashboard/frontend/src/lib/story.js

@@ -0,0 +1,69 @@
+function parseChapter(value) {
+    const number = Number(value)
+    return Number.isFinite(number) && number > 0 ? number : null
+}
+
+function parseRange(value) {
+    const text = String(value || '').trim()
+    if (!text.includes('-')) return null
+    const [left, right] = text.split('-', 2)
+    const start = parseChapter(left)
+    const end = parseChapter(right)
+    if (!start || !end || start > end) return null
+    return { start, end }
+}
+
+export function getLatestChapter(projectInfo) {
+    return parseChapter(projectInfo?.progress?.current_chapter) || 1
+}
+
+export function resolveVolumeForChapter(projectInfo, chapter) {
+    const target = parseChapter(chapter)
+    if (!target) return null
+
+    const planned = Array.isArray(projectInfo?.progress?.volumes_planned)
+        ? projectInfo.progress.volumes_planned
+        : []
+
+    for (const item of planned) {
+        const volume = parseChapter(item?.volume)
+        const range = parseRange(item?.chapters_range)
+        if (volume && range && range.start <= target && target <= range.end) {
+            return volume
+        }
+    }
+
+    return null
+}
+
+export function groupChaptersByVolume(chapters, projectInfo) {
+    const groups = new Map()
+
+    for (const chapter of chapters || []) {
+        const volume = resolveVolumeForChapter(projectInfo, chapter?.chapter)
+        const key = volume || 0
+        if (!groups.has(key)) {
+            groups.set(key, {
+                volume,
+                label: volume ? `卷 ${volume}` : '未分卷',
+                totalWords: 0,
+                chapterCount: 0,
+                values: [],
+            })
+        }
+
+        const bucket = groups.get(key)
+        const wordCount = Number(chapter?.word_count || 0)
+        bucket.totalWords += Number.isFinite(wordCount) ? wordCount : 0
+        bucket.chapterCount += 1
+        if (Number.isFinite(wordCount) && wordCount > 0) {
+            bucket.values.push(wordCount)
+        }
+    }
+
+    return [...groups.values()].sort((left, right) => {
+        if (left.volume === null) return 1
+        if (right.volume === null) return -1
+        return (left.volume || 0) - (right.volume || 0)
+    })
+}

+ 35 - 2
webnovel-writer/dashboard/frontend/src/main.jsx

@@ -1,10 +1,43 @@
-import React from 'react'
+import React, { Suspense, lazy } from 'react'
 import ReactDOM from 'react-dom/client'
 import ReactDOM from 'react-dom/client'
+import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
 import App from './App.jsx'
 import App from './App.jsx'
 import './index.css'
 import './index.css'
 
 
+const OverviewPage = lazy(() => import('./pages/OverviewPage.jsx'))
+const CharactersPage = lazy(() => import('./pages/CharactersPage.jsx'))
+const PacingPage = lazy(() => import('./pages/PacingPage.jsx'))
+const ForeshadowingPage = lazy(() => import('./pages/ForeshadowingPage.jsx'))
+const FilesPage = lazy(() => import('./pages/FilesPage.jsx'))
+const SystemPage = lazy(() => import('./pages/SystemPage.jsx'))
+
+function LoadingScreen() {
+    return (
+        <div className="loading-screen">
+            <div className="loading-card">
+                <div className="section-label">LOADING</div>
+                <p>正在加载 Dashboard…</p>
+            </div>
+        </div>
+    )
+}
+
 ReactDOM.createRoot(document.getElementById('root')).render(
 ReactDOM.createRoot(document.getElementById('root')).render(
     <React.StrictMode>
     <React.StrictMode>
-        <App />
+        <BrowserRouter>
+            <Suspense fallback={<LoadingScreen />}>
+                <Routes>
+                    <Route path="/" element={<App />}>
+                        <Route index element={<OverviewPage />} />
+                        <Route path="characters" element={<CharactersPage />} />
+                        <Route path="pacing" element={<PacingPage />} />
+                        <Route path="foreshadowing" element={<ForeshadowingPage />} />
+                        <Route path="files" element={<FilesPage />} />
+                        <Route path="system" element={<SystemPage />} />
+                        <Route path="*" element={<Navigate to="/" replace />} />
+                    </Route>
+                </Routes>
+            </Suspense>
+        </BrowserRouter>
     </React.StrictMode>,
     </React.StrictMode>,
 )
 )

+ 511 - 0
webnovel-writer/dashboard/frontend/src/pages/CharactersPage.jsx

@@ -0,0 +1,511 @@
+import { startTransition, useEffect, useMemo, useRef, useState } from 'react'
+import { useDashboardContext } from '../App.jsx'
+import {
+    fetchEntities,
+    fetchRelationships,
+    fetchRelationshipEvents,
+    fetchStateChanges,
+} from '../api.js'
+import Badge from '../components/Badge.jsx'
+import ChartWrapper from '../components/ChartWrapper.jsx'
+import DataTable from '../components/DataTable.jsx'
+import { formatChapterLabel, formatJSONText } from '../lib/format.js'
+import { getLatestChapter } from '../lib/story.js'
+
+const TYPE_COLORS = {
+    角色: '#26a8ff',
+    势力: '#7f5af0',
+    地点: '#2ec27e',
+    法宝: '#f5a524',
+}
+
+const CATEGORY_NAMES = ['角色', '势力', '地点', '其他']
+
+function parsePositiveChapter(value) {
+    const number = Number(value)
+    return Number.isFinite(number) && number > 0 ? number : null
+}
+
+function resolveCategory(type) {
+    if (type === '角色') return '角色'
+    if (type === '势力') return '势力'
+    if (type === '地点') return '地点'
+    return '其他'
+}
+
+function buildGraphData(entities, relationships, events, currentChapter) {
+    const eventRows = [...events]
+        .filter(row => (parsePositiveChapter(row.chapter) || 0) <= currentChapter)
+        .sort((left, right) => (parsePositiveChapter(left.chapter) || 0) - (parsePositiveChapter(right.chapter) || 0))
+
+    const latestEventByPair = new Map()
+    for (const row of eventRows) {
+        if (!row?.from_entity || !row?.to_entity) continue
+        latestEventByPair.set(`${row.from_entity}|${row.to_entity}`, row)
+    }
+
+    const baseRelationships = [...relationships]
+        .filter(row => (parsePositiveChapter(row.chapter) || 0) <= currentChapter)
+        .sort((left, right) => (parsePositiveChapter(left.chapter) || 0) - (parsePositiveChapter(right.chapter) || 0))
+
+    const linkMap = new Map()
+    for (const row of baseRelationships) {
+        if (!row?.from_entity || !row?.to_entity) continue
+        const key = `${row.from_entity}|${row.to_entity}`
+        linkMap.set(key, row)
+    }
+    for (const [key, row] of latestEventByPair.entries()) {
+        linkMap.set(key, row)
+    }
+
+    const visibleEntityMap = new Map()
+    for (const entity of entities) {
+        const firstAppearance = parsePositiveChapter(entity?.first_appearance)
+        if (!firstAppearance || firstAppearance <= currentChapter) {
+            visibleEntityMap.set(entity.id, entity)
+        }
+    }
+
+    for (const row of linkMap.values()) {
+        const fromEntity = entities.find(entity => entity.id === row.from_entity)
+        const toEntity = entities.find(entity => entity.id === row.to_entity)
+        if (fromEntity) visibleEntityMap.set(fromEntity.id, fromEntity)
+        if (toEntity) visibleEntityMap.set(toEntity.id, toEntity)
+    }
+
+    const nodes = [...visibleEntityMap.values()].map(entity => {
+        const category = resolveCategory(entity.type)
+        return {
+            id: entity.id,
+            name: entity.canonical_name || entity.id,
+            value: entity.tier || '',
+            category: CATEGORY_NAMES.indexOf(category),
+            symbolSize: entity.is_protagonist ? 34 : entity.tier === 'S' ? 30 : entity.tier === 'A' ? 26 : 22,
+            itemStyle: {
+                color: entity.is_protagonist ? '#f5a524' : TYPE_COLORS[category] || '#00b8d4',
+                borderColor: '#2a220f',
+                borderWidth: 2,
+            },
+            label: {
+                show: true,
+                color: '#2a220f',
+                fontSize: 11,
+                fontWeight: 600,
+            },
+            type: entity.type,
+            firstAppearance: entity.first_appearance,
+        }
+    })
+
+    const links = [...linkMap.values()]
+        .filter(row => visibleEntityMap.has(row.from_entity) && visibleEntityMap.has(row.to_entity))
+        .map(row => ({
+            source: row.from_entity,
+            target: row.to_entity,
+            name: row.description || row.event_type || row.type || '关联',
+            lineStyle: {
+                color: '#8f7f5c',
+                width: 2,
+                curveness: 0.1,
+            },
+            label: {
+                show: true,
+                color: '#5d5035',
+                fontSize: 11,
+            },
+        }))
+
+    return { nodes, links }
+}
+
+function buildGraphOption(data) {
+    return {
+        tooltip: {
+            formatter: params => {
+                if (params.dataType === 'edge') {
+                    return params.data?.name || '关系'
+                }
+                return `${params.data?.name || '实体'}<br/>${params.data?.type || '未知类型'}`
+            },
+        },
+        legend: {
+            bottom: 0,
+            data: CATEGORY_NAMES,
+        },
+        series: [
+            {
+                type: 'graph',
+                layout: 'force',
+                roam: true,
+                symbol: 'rect',
+                animationDuration: 300,
+                animationEasingUpdate: 'cubicOut',
+                categories: CATEGORY_NAMES.map(name => ({
+                    name,
+                    itemStyle: { color: TYPE_COLORS[name] || '#00b8d4' },
+                })),
+                force: {
+                    repulsion: 360,
+                    edgeLength: [120, 200],
+                    gravity: 0.08,
+                },
+                lineStyle: {
+                    color: '#8f7f5c',
+                    width: 2,
+                    curveness: 0.1,
+                },
+                edgeLabel: {
+                    show: true,
+                    formatter: params => params.data?.name || '',
+                    color: '#5d5035',
+                    fontSize: 11,
+                },
+                emphasis: {
+                    focus: 'adjacency',
+                    label: { show: true },
+                },
+                data: data.nodes,
+                links: data.links,
+            },
+        ],
+    }
+}
+
+function TypeFilter({ types, value, onChange }) {
+    return (
+        <div className="filter-group">
+            <button
+                type="button"
+                className={`filter-btn ${value === '' ? 'active' : ''}`.trim()}
+                onClick={() => onChange('')}
+            >
+                全部
+            </button>
+            {types.map(type => (
+                <button
+                    key={type}
+                    type="button"
+                    className={`filter-btn ${value === type ? 'active' : ''}`.trim()}
+                    onClick={() => onChange(type)}
+                >
+                    {type}
+                </button>
+            ))}
+        </div>
+    )
+}
+
+function EntityListTable({ rows, selectedId, onSelect }) {
+    if (!rows.length) {
+        return (
+            <div className="empty-state">
+                <p>暂无实体数据</p>
+            </div>
+        )
+    }
+
+    return (
+        <div className="table-wrap">
+            <table className="data-table entity-table">
+                <thead>
+                    <tr>
+                        <th>名称</th>
+                        <th>类型</th>
+                        <th>层级</th>
+                        <th>首现</th>
+                        <th>末现</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    {rows.map(entity => (
+                        <tr
+                            key={entity.id}
+                            className={`entity-row ${selectedId === entity.id ? 'selected' : ''}`.trim()}
+                            onClick={() => onSelect(entity)}
+                        >
+                            <td className={`entity-name ${entity.is_protagonist ? 'protagonist' : ''}`.trim()}>
+                                {entity.canonical_name}
+                            </td>
+                            <td>
+                                <Badge tone="blue">{entity.type || '未知'}</Badge>
+                            </td>
+                            <td>{entity.tier || '—'}</td>
+                            <td>{formatChapterLabel(entity.first_appearance)}</td>
+                            <td>{formatChapterLabel(entity.last_appearance)}</td>
+                        </tr>
+                    ))}
+                </tbody>
+            </table>
+        </div>
+    )
+}
+
+export default function CharactersPage() {
+    const { projectInfo, refreshToken } = useDashboardContext()
+    const [tab, setTab] = useState('list')
+    const [entities, setEntities] = useState([])
+    const [relationships, setRelationships] = useState([])
+    const [relationshipEvents, setRelationshipEvents] = useState([])
+    const [typeFilter, setTypeFilter] = useState('')
+    const [selected, setSelected] = useState(null)
+    const [changes, setChanges] = useState([])
+    const [playing, setPlaying] = useState(false)
+    const latestChapter = getLatestChapter(projectInfo)
+    const [graphChapter, setGraphChapter] = useState(latestChapter)
+
+    useEffect(() => {
+        setGraphChapter(latestChapter)
+    }, [latestChapter, refreshToken])
+
+    useEffect(() => {
+        let cancelled = false
+
+        Promise.allSettled([
+            fetchEntities(),
+            fetchRelationships({ limit: 1000 }),
+            fetchRelationshipEvents({ limit: 5000 }),
+        ]).then(results => {
+            if (cancelled) return
+
+            const entityRows = results[0].status === 'fulfilled' ? results[0].value : []
+            setEntities(entityRows)
+            setRelationships(results[1].status === 'fulfilled' ? results[1].value : [])
+            setRelationshipEvents(results[2].status === 'fulfilled' ? results[2].value : [])
+
+            if (entityRows.length) {
+                setSelected(current => current || entityRows[0])
+            }
+        })
+
+        return () => {
+            cancelled = true
+        }
+    }, [refreshToken])
+
+    useEffect(() => {
+        if (!selected?.id) {
+            setChanges([])
+            return
+        }
+
+        let cancelled = false
+        fetchStateChanges({ entity: selected.id, limit: 30 })
+            .then(payload => {
+                if (!cancelled) {
+                    setChanges(payload)
+                }
+            })
+            .catch(() => {
+                if (!cancelled) {
+                    setChanges([])
+                }
+            })
+
+        return () => {
+            cancelled = true
+        }
+    }, [selected])
+
+    const advanceGraphRef = useRef(null)
+    advanceGraphRef.current = () => {
+        setGraphChapter(current => {
+            if (current >= latestChapter) {
+                setPlaying(false)
+                return latestChapter
+            }
+            return Math.min(latestChapter, current + 5)
+        })
+    }
+
+    useEffect(() => {
+        if (!playing) return undefined
+        const timer = window.setInterval(() => advanceGraphRef.current(), 120)
+        return () => window.clearInterval(timer)
+    }, [playing])
+
+    const types = useMemo(() => {
+        return [...new Set(entities.map(entity => entity.type).filter(Boolean))].sort()
+    }, [entities])
+
+    const filteredEntities = useMemo(() => {
+        return typeFilter ? entities.filter(entity => entity.type === typeFilter) : entities
+    }, [entities, typeFilter])
+
+    useEffect(() => {
+        if (selected && filteredEntities.some(entity => entity.id === selected.id)) return
+        setSelected(filteredEntities[0] || null)
+    }, [filteredEntities, selected])
+
+    const graphData = useMemo(() => {
+        return buildGraphData(entities, relationships, relationshipEvents, graphChapter)
+    }, [entities, graphChapter, relationshipEvents, relationships])
+
+    return (
+        <section className="dashboard-page">
+            <header className="page-header">
+                <h2>角色图鉴</h2>
+                <Badge tone="green">{filteredEntities.length} / {entities.length} 个实体</Badge>
+            </header>
+
+            <TypeFilter types={types} value={typeFilter} onChange={setTypeFilter} />
+
+            <div className="tab-strip">
+                <button
+                    type="button"
+                    className={`tab-btn ${tab === 'list' ? 'active' : ''}`.trim()}
+                    onClick={() => setTab('list')}
+                >
+                    实体列表
+                </button>
+                <button
+                    type="button"
+                    className={`tab-btn ${tab === 'graph' ? 'active' : ''}`.trim()}
+                    onClick={() => setTab('graph')}
+                >
+                    关系图谱
+                </button>
+            </div>
+
+            {tab === 'list' ? (
+                <div className="split-layout">
+                    <div className="split-main">
+                        <article className="card">
+                            <div className="card-header">
+                                <div>
+                                    <div className="section-label">ENTITY INDEX</div>
+                                    <div className="card-title">实体列表</div>
+                                </div>
+                                <Badge tone="cyan">{typeFilter || '全部类型'}</Badge>
+                            </div>
+                            <EntityListTable
+                                rows={filteredEntities}
+                                selectedId={selected?.id}
+                                onSelect={setSelected}
+                            />
+                        </article>
+                    </div>
+
+                    <div className="split-side">
+                        <article className="card sticky-card">
+                            <div className="card-header">
+                                <div>
+                                    <div className="section-label">ENTITY DETAIL</div>
+                                    <div className="card-title">{selected?.canonical_name || '未选择实体'}</div>
+                                </div>
+                                {selected?.tier ? <Badge tone="purple">{selected.tier}</Badge> : null}
+                            </div>
+                            {selected ? (
+                                <div className="entity-detail">
+                                    <p><strong>类型:</strong>{selected.type || '未知'}</p>
+                                    <p><strong>ID:</strong><code>{selected.id}</code></p>
+                                    <p><strong>首现:</strong>{formatChapterLabel(selected.first_appearance)}</p>
+                                    <p><strong>末现:</strong>{formatChapterLabel(selected.last_appearance)}</p>
+                                    {selected.desc ? <p className="entity-desc">{selected.desc}</p> : null}
+                                    {selected.current_json ? (
+                                        <div className="entity-current-block">
+                                            <div className="mini-label">当前状态</div>
+                                            <pre className="code-block">{formatJSONText(selected.current_json)}</pre>
+                                        </div>
+                                    ) : null}
+                                </div>
+                            ) : (
+                                <div className="empty-state compact">
+                                    <p>从左侧选择一个实体查看详情</p>
+                                </div>
+                            )}
+
+                            <div className="detail-divider" />
+
+                            <div className="card-header compact-header">
+                                <div>
+                                    <div className="section-label">STATE CHANGES</div>
+                                    <div className="card-title">状态变化历史</div>
+                                </div>
+                                <Badge tone="amber">{changes.length} 条</Badge>
+                            </div>
+                            <DataTable
+                                columns={[
+                                    {
+                                        key: 'chapter',
+                                        label: '章',
+                                        render: row => formatChapterLabel(row.chapter),
+                                    },
+                                    { key: 'field', label: '字段' },
+                                    {
+                                        key: 'change',
+                                        label: '变化',
+                                        render: row => `${row.old_value ?? '—'} → ${row.new_value ?? '—'}`,
+                                    },
+                                ]}
+                                rows={changes}
+                                rowKey={(row, index) => `${row.entity_id || 'entity'}-${row.chapter || 0}-${index}`}
+                                pageSize={6}
+                                emptyText="暂无状态变化记录"
+                                minWidth={420}
+                            />
+                        </article>
+                    </div>
+                </div>
+            ) : (
+                <article className="card">
+                    <div className="card-header">
+                        <div>
+                            <div className="section-label">RELATION GRAPH</div>
+                            <div className="card-title">关系图谱</div>
+                        </div>
+                        <Badge tone="blue">ECharts graph · 力导向 · 时间轴</Badge>
+                    </div>
+
+                    <div className="graph-toolbar">
+                        <button
+                            type="button"
+                            className="page-btn icon-btn"
+                            onClick={() => {
+                                if (playing) {
+                                    setPlaying(false)
+                                    return
+                                }
+                                if (graphChapter >= latestChapter) {
+                                    setGraphChapter(1)
+                                }
+                                setPlaying(true)
+                            }}
+                        >
+                            {playing ? '暂停' : '播放'}
+                        </button>
+                        <span className="range-label">第 1 章</span>
+                        <input
+                            className="timeline-slider"
+                            type="range"
+                            min="1"
+                            max={String(latestChapter)}
+                            value={graphChapter}
+                            onChange={event => {
+                                const nextChapter = Number(event.target.value)
+                                startTransition(() => {
+                                    setGraphChapter(nextChapter)
+                                })
+                            }}
+                        />
+                        <span className="range-label">{formatChapterLabel(latestChapter)}</span>
+                        <Badge tone="blue">{formatChapterLabel(graphChapter)}</Badge>
+                        <Badge tone="green">{graphData.nodes.length} 节点</Badge>
+                        <Badge tone="purple">{graphData.links.length} 关系</Badge>
+                    </div>
+
+                    {graphData.nodes.length ? (
+                        <ChartWrapper
+                            className="tall"
+                            height={420}
+                            option={buildGraphOption(graphData)}
+                        />
+                    ) : (
+                        <div className="empty-state">
+                            <p>当前章节窗口没有可视化关系</p>
+                        </div>
+                    )}
+                </article>
+            )}
+        </section>
+    )
+}

+ 197 - 0
webnovel-writer/dashboard/frontend/src/pages/FilesPage.jsx

@@ -0,0 +1,197 @@
+import { startTransition, useEffect, useMemo, useState } from 'react'
+import { useDashboardContext } from '../App.jsx'
+import Badge from '../components/Badge.jsx'
+import { fetchFileContent, fetchFilesTree } from '../api.js'
+import { findFirstFilePath } from '../lib/files.js'
+
+function countTreeItems(items) {
+    return (items || []).reduce(
+        (count, item) => count + (item.type === 'file' ? 1 : countTreeItems(item.children)),
+        0,
+    )
+}
+
+function TreeNodes({ items, expanded, selectedPath, onToggle, onSelect, depth = 0 }) {
+    if (!Array.isArray(items) || !items.length) return null
+
+    return items.map(item => {
+        const key = item.path || `${depth}-${item.name}`
+        if (item.type === 'dir') {
+            const isOpen = expanded[key] ?? depth < 1
+            return (
+                <li key={key}>
+                    <button
+                        type="button"
+                        className={`tree-item tree-dir ${isOpen ? 'open' : ''}`.trim()}
+                        onClick={() => onToggle(key)}
+                    >
+                        <span className="tree-glyph" />
+                        <span className="tree-name">{item.name}</span>
+                    </button>
+                    {isOpen ? (
+                        <ul className="tree-children">
+                            <TreeNodes
+                                items={item.children}
+                                expanded={expanded}
+                                selectedPath={selectedPath}
+                                onToggle={onToggle}
+                                onSelect={onSelect}
+                                depth={depth + 1}
+                            />
+                        </ul>
+                    ) : null}
+                </li>
+            )
+        }
+
+        return (
+            <li key={key}>
+                <button
+                    type="button"
+                    className={`tree-item tree-file ${selectedPath === item.path ? 'active' : ''}`.trim()}
+                    onClick={() => onSelect(item.path)}
+                >
+                    <span className="tree-glyph file" />
+                    <span className="tree-name">{item.name}</span>
+                </button>
+            </li>
+        )
+    })
+}
+
+export default function FilesPage() {
+    const { refreshToken } = useDashboardContext()
+    const [tree, setTree] = useState({})
+    const [expanded, setExpanded] = useState({})
+    const [selectedPath, setSelectedPath] = useState(null)
+    const [content, setContent] = useState('')
+    const [loadingContent, setLoadingContent] = useState(false)
+
+    useEffect(() => {
+        let cancelled = false
+        fetchFilesTree()
+            .then(payload => {
+                if (!cancelled) {
+                    setTree(payload)
+                    const initialPath = findFirstFilePath(payload)
+                    if (initialPath) {
+                        setSelectedPath(current => current || initialPath)
+                    }
+                }
+            })
+            .catch(() => {
+                if (!cancelled) {
+                    setTree({})
+                }
+            })
+
+        return () => {
+            cancelled = true
+        }
+    }, [refreshToken])
+
+    useEffect(() => {
+        if (!selectedPath) return undefined
+
+        let cancelled = false
+        setLoadingContent(true)
+        fetchFileContent(selectedPath)
+            .then(payload => {
+                if (!cancelled) {
+                    setContent(payload.content || '')
+                }
+            })
+            .catch(() => {
+                if (!cancelled) {
+                    setContent('[读取失败]')
+                }
+            })
+            .finally(() => {
+                if (!cancelled) {
+                    setLoadingContent(false)
+                }
+            })
+
+        return () => {
+            cancelled = true
+        }
+    }, [selectedPath])
+
+    const totalFiles = useMemo(() => {
+        return Object.values(tree).reduce((count, items) => count + countTreeItems(items), 0)
+    }, [tree])
+    const lineCount = content ? content.split(/\r?\n/).length : 0
+
+    return (
+        <section className="dashboard-page">
+            <header className="page-header">
+                <h2>文档浏览</h2>
+                <Badge tone="blue">{totalFiles} 个文件</Badge>
+            </header>
+
+            <div className="content-grid files-layout">
+                <article className="card files-tree-card">
+                    <div className="card-header">
+                        <div>
+                            <div className="section-label">FILE TREE</div>
+                            <div className="card-title">目录树</div>
+                        </div>
+                        <Badge tone="cyan">正文 / 大纲 / 设定集</Badge>
+                    </div>
+
+                    <div className="folder-group-list">
+                        {Object.entries(tree).map(([folder, items]) => (
+                            <section key={folder} className="folder-block">
+                                <div className="folder-title">
+                                    <span>{folder}</span>
+                                    <Badge tone="purple">{countTreeItems(items)}</Badge>
+                                </div>
+                                <ul className="file-tree">
+                                    <TreeNodes
+                                        items={items}
+                                        expanded={expanded}
+                                        selectedPath={selectedPath}
+                                        onToggle={path => {
+                                            startTransition(() => {
+                                                setExpanded(current => ({ ...current, [path]: !current[path] }))
+                                            })
+                                        }}
+                                        onSelect={setSelectedPath}
+                                    />
+                                </ul>
+                            </section>
+                        ))}
+                    </div>
+                </article>
+
+                <article className="card files-preview-card">
+                    <div className="card-header">
+                        <div>
+                            <div className="section-label">FILE PREVIEW</div>
+                            <div className="card-title">内容预览</div>
+                        </div>
+                        {selectedPath ? (
+                            <div className="header-badges">
+                                <Badge tone="amber">{lineCount} 行</Badge>
+                                <Badge tone="green">{content.length} 字符</Badge>
+                            </div>
+                        ) : null}
+                    </div>
+
+                    {selectedPath ? (
+                        <>
+                            <div className="selected-path">{selectedPath}</div>
+                            <pre className={`file-preview ${loadingContent ? 'loading' : ''}`.trim()}>
+                                {loadingContent ? '读取中…' : content}
+                            </pre>
+                        </>
+                    ) : (
+                        <div className="empty-state">
+                            <p>选择左侧文件以预览内容</p>
+                        </div>
+                    )}
+                </article>
+            </div>
+        </section>
+    )
+}

+ 271 - 0
webnovel-writer/dashboard/frontend/src/pages/ForeshadowingPage.jsx

@@ -0,0 +1,271 @@
+import { useMemo, useState } from 'react'
+import { useDashboardContext } from '../App.jsx'
+import Badge from '../components/Badge.jsx'
+import ChartWrapper from '../components/ChartWrapper.jsx'
+import DataTable from '../components/DataTable.jsx'
+import { FORESHADOWING_COLORS } from '../lib/charts.js'
+import { buildForeshadowingRecords, summarizeForeshadowing } from '../lib/foreshadowing.js'
+import { formatChapterLabel, formatShortNumber } from '../lib/format.js'
+
+const LEVEL_ORDER = {
+    overdue: 4,
+    urgent: 3,
+    active: 2,
+    resolved: 1,
+}
+
+function statusTone(level) {
+    if (level === 'overdue') return 'red'
+    if (level === 'urgent') return 'amber'
+    if (level === 'resolved') return 'green'
+    return 'blue'
+}
+
+function urgencyTone(level) {
+    if (level === 'critical') return 'red'
+    if (level === 'high') return 'amber'
+    if (level === 'medium') return 'amber'
+    if (level === 'resolved') return 'green'
+    return 'blue'
+}
+
+function sortForeshadowing(left, right) {
+    const order = (LEVEL_ORDER[right.level] || 0) - (LEVEL_ORDER[left.level] || 0)
+    if (order !== 0) return order
+    if ((left.targetChapter || 0) !== (right.targetChapter || 0)) {
+        return (left.targetChapter || Number.MAX_SAFE_INTEGER) - (right.targetChapter || Number.MAX_SAFE_INTEGER)
+    }
+    return (right.urgencyScore || 0) - (left.urgencyScore || 0)
+}
+
+function buildGanttOption(rows, currentChapter) {
+    const chapters = rows.flatMap(row => [
+        row.plantedChapter || currentChapter,
+        row.targetChapter || row.resolvedChapter || currentChapter,
+        currentChapter,
+    ])
+    const minChapter = Math.max(1, Math.min(...chapters) - 5)
+    const maxChapter = Math.max(...chapters) + 5
+
+    return {
+        tooltip: {
+            formatter: params => {
+                const rowIndex = Array.isArray(params.value) ? params.value[0] : params.dataIndex
+                const row = rows[rowIndex]
+                if (!row) return '伏笔'
+                return `${row.content}<br/>${row.statusText} · ${formatChapterLabel(row.plantedChapter)} → ${formatChapterLabel(row.targetChapter || row.resolvedChapter)}`
+            },
+        },
+        grid: { left: 160, right: 28, top: 12, bottom: 44 },
+        xAxis: {
+            type: 'value',
+            min: minChapter,
+            max: maxChapter,
+            axisLabel: {
+                formatter: value => `第${value}章`,
+            },
+        },
+        yAxis: {
+            type: 'category',
+            inverse: true,
+            data: rows.map(row => row.content),
+            axisLabel: {
+                fontSize: 12,
+                fontWeight: 600,
+                color: '#5d5035',
+            },
+        },
+        series: [
+            {
+                type: 'custom',
+                renderItem(params, api) {
+                    const categoryIndex = api.value(0)
+                    const start = api.coord([api.value(1), categoryIndex])
+                    const end = api.coord([api.value(2), categoryIndex])
+                    const height = api.size([0, 1])[1] * 0.55
+                    return {
+                        type: 'rect',
+                        shape: {
+                            x: start[0],
+                            y: start[1] - height / 2,
+                            width: end[0] - start[0],
+                            height,
+                        },
+                        style: {
+                            fill: api.value(3),
+                            stroke: '#2a220f',
+                            lineWidth: 2,
+                        },
+                    }
+                },
+                encode: { x: [1, 2], y: 0 },
+                data: rows.map((row, index) => [
+                    index,
+                    row.plantedChapter || currentChapter,
+                    row.targetChapter || row.resolvedChapter || currentChapter,
+                    FORESHADOWING_COLORS[row.level] || '#26a8ff',
+                ]),
+            },
+            {
+                type: 'line',
+                data: [],
+                markLine: {
+                    silent: true,
+                    symbol: 'none',
+                    lineStyle: {
+                        color: '#26a8ff',
+                        width: 3,
+                    },
+                    label: {
+                        formatter: `当前 ${currentChapter} 章`,
+                        position: 'end',
+                        color: '#26a8ff',
+                        fontSize: 11,
+                        fontWeight: 700,
+                    },
+                    data: [{ xAxis: currentChapter }],
+                },
+            },
+        ],
+    }
+}
+
+function StatCard({ label, value, tone = 'plain' }) {
+    return (
+        <article className="card stat-card">
+            <span className="stat-label">{label}</span>
+            <span className={`stat-value ${tone === 'plain' ? 'plain' : ''}`.trim()}>{value}</span>
+        </article>
+    )
+}
+
+export default function ForeshadowingPage() {
+    const { projectInfo } = useDashboardContext()
+    const [filter, setFilter] = useState('all')
+
+    const currentChapter = Number(projectInfo?.progress?.current_chapter || 0)
+    const records = useMemo(() => {
+        return buildForeshadowingRecords(projectInfo).sort(sortForeshadowing)
+    }, [projectInfo])
+
+    const summary = useMemo(() => summarizeForeshadowing(records), [records])
+
+    const chartRows = useMemo(() => {
+        if (filter === 'attention') {
+            return records.filter(row => row.level === 'urgent' || row.level === 'overdue')
+        }
+        if (filter === 'active') {
+            return records.filter(row => row.level === 'active')
+        }
+        if (filter === 'resolved') {
+            return records.filter(row => row.level === 'resolved')
+        }
+        return records.filter(row => row.level !== 'resolved')
+    }, [filter, records])
+
+    const tableRows = useMemo(() => {
+        if (filter === 'attention') {
+            return records.filter(row => row.level === 'urgent' || row.level === 'overdue')
+        }
+        if (filter === 'active') {
+            return records.filter(row => row.level === 'active')
+        }
+        if (filter === 'resolved') {
+            return records.filter(row => row.level === 'resolved')
+        }
+        return records
+    }, [filter, records])
+
+    return (
+        <section className="dashboard-page">
+            <header className="page-header">
+                <h2>伏笔追踪</h2>
+                <Badge tone="cyan">{formatChapterLabel(currentChapter)}</Badge>
+            </header>
+
+            <div className="stat-grid">
+                <StatCard label="总伏笔" value={String(summary.total)} />
+                <StatCard label="活跃" value={String(summary.active)} tone="accent" />
+                <StatCard label="已回收" value={String(summary.resolved)} tone="accent" />
+                <StatCard label="紧急 / 超期" value={String(summary.attention)} tone="accent" />
+            </div>
+
+            <div className="filter-group">
+                <button type="button" className={`filter-btn ${filter === 'all' ? 'active' : ''}`.trim()} onClick={() => setFilter('all')}>全部</button>
+                <button type="button" className={`filter-btn ${filter === 'attention' ? 'active' : ''}`.trim()} onClick={() => setFilter('attention')}>紧急</button>
+                <button type="button" className={`filter-btn ${filter === 'active' ? 'active' : ''}`.trim()} onClick={() => setFilter('active')}>活跃</button>
+                <button type="button" className={`filter-btn ${filter === 'resolved' ? 'active' : ''}`.trim()} onClick={() => setFilter('resolved')}>已回收</button>
+            </div>
+
+            <article className="card">
+                <div className="card-header">
+                    <div>
+                        <div className="section-label">FORESHADOW GANTT</div>
+                        <div className="card-title">伏笔时间线</div>
+                    </div>
+                    {filter === 'all' ? <Badge tone="purple">默认折叠已回收</Badge> : <Badge tone="blue">按筛选展示</Badge>}
+                </div>
+                {chartRows.length ? (
+                    <ChartWrapper
+                        className="gantt"
+                        height={380}
+                        option={buildGanttOption(chartRows, currentChapter)}
+                    />
+                ) : (
+                    <div className="empty-state">
+                        <p>当前筛选条件下没有伏笔记录</p>
+                    </div>
+                )}
+            </article>
+
+            <article className="card">
+                <div className="card-header">
+                    <div>
+                        <div className="section-label">FORESHADOW LIST</div>
+                        <div className="card-title">完整伏笔列表</div>
+                    </div>
+                    <Badge tone="amber">{tableRows.length} 条</Badge>
+                </div>
+                <DataTable
+                    columns={[
+                        { key: 'content', label: '内容' },
+                        {
+                            key: 'statusText',
+                            label: '状态',
+                            render: row => <Badge tone={statusTone(row.level)}>{row.statusText}</Badge>,
+                        },
+                        {
+                            key: 'plantedChapter',
+                            label: '埋设章',
+                            render: row => formatChapterLabel(row.plantedChapter),
+                        },
+                        {
+                            key: 'targetChapter',
+                            label: '目标章',
+                            render: row => formatChapterLabel(row.targetChapter || row.resolvedChapter),
+                        },
+                        {
+                            key: 'urgencyText',
+                            label: '紧急度',
+                            render: row => (
+                                row.urgencyText === 'resolved'
+                                    ? '—'
+                                    : <Badge tone={urgencyTone(row.urgencyText)}>{row.urgencyText}</Badge>
+                            ),
+                        },
+                        {
+                            key: 'urgencyScore',
+                            label: '分值',
+                            render: row => row.urgencyScore ? formatShortNumber(row.urgencyScore) : '—',
+                        },
+                    ]}
+                    rows={tableRows}
+                    rowKey="id"
+                    pageSize={10}
+                    emptyText="暂无伏笔数据"
+                    minWidth={760}
+                />
+            </article>
+        </section>
+    )
+}

+ 517 - 0
webnovel-writer/dashboard/frontend/src/pages/OverviewPage.jsx

@@ -0,0 +1,517 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useDashboardContext } from '../App.jsx'
+import { fetchChapterTrend, fetchChapters, fetchStoryRuntimeHealth } from '../api.js'
+import Badge from '../components/Badge.jsx'
+import ChartWrapper from '../components/ChartWrapper.jsx'
+import DataTable from '../components/DataTable.jsx'
+import Pager from '../components/Pager.jsx'
+import { STRAND_COLORS } from '../lib/charts.js'
+import { buildForeshadowingRecords, summarizeForeshadowing } from '../lib/foreshadowing.js'
+import {
+    average,
+    formatChapterLabel,
+    formatNumber,
+    formatPercent,
+    formatShortNumber,
+} from '../lib/format.js'
+import { groupChaptersByVolume } from '../lib/story.js'
+
+const WINDOW_SIZE = 50
+
+const FORESHADOW_PRIORITY = {
+    overdue: 4,
+    urgent: 3,
+    active: 2,
+    resolved: 1,
+}
+
+function toneForForeshadowing(level) {
+    if (level === 'overdue') return 'red'
+    if (level === 'urgent') return 'amber'
+    if (level === 'resolved') return 'green'
+    return 'blue'
+}
+
+function toneForHookStrength(strength) {
+    if (strength === 'strong') return 'green'
+    if (strength === 'medium') return 'amber'
+    if (strength === 'weak') return 'red'
+    return 'blue'
+}
+
+function toneForUrgencyBadge(level) {
+    if (level === 'critical') return 'red'
+    if (level === 'high') return 'amber'
+    if (level === 'medium') return 'amber'
+    if (level === 'resolved') return 'green'
+    return 'blue'
+}
+
+function formatRuntimeText(runtimeHealth) {
+    if (!runtimeHealth) return '运行态未加载'
+    const fallback = Array.isArray(runtimeHealth.fallback_sources) && runtimeHealth.fallback_sources.length
+        ? runtimeHealth.fallback_sources.join(' / ')
+        : 'none'
+    return `${runtimeHealth.latest_commit_status || 'missing'} · fallback ${fallback}`
+}
+
+function buildReviewOption(items) {
+    const scores = items
+        .map(item => Number(item.review_score))
+        .filter(score => Number.isFinite(score))
+    const averageScore = average(scores)
+
+    return {
+        tooltip: { trigger: 'axis' },
+        grid: { left: 52, right: 24, top: 36, bottom: 46 },
+        xAxis: {
+            type: 'category',
+            data: items.map(item => item.chapter),
+            axisLabel: { interval: 9, formatter: value => `${value}` },
+        },
+        yAxis: {
+            type: 'value',
+            min: 0,
+            max: Math.max(100, ...scores, 0),
+        },
+        series: [
+            {
+                type: 'line',
+                name: '审查得分',
+                data: items.map(item => item.review_score ?? null),
+                symbol: 'rect',
+                symbolSize: 8,
+                lineStyle: { width: 3, color: '#26a8ff' },
+                itemStyle: {
+                    color: '#26a8ff',
+                    borderColor: '#2a220f',
+                    borderWidth: 2,
+                },
+                connectNulls: false,
+                markLine: averageScore
+                    ? {
+                        symbol: 'none',
+                        lineStyle: { color: '#f5a524', width: 2, type: 'dashed' },
+                        label: { formatter: `均值 ${formatShortNumber(averageScore)}` },
+                        data: [{ yAxis: Number(averageScore.toFixed(2)) }],
+                    }
+                    : undefined,
+            },
+        ],
+    }
+}
+
+function buildVolumeOption(groups) {
+    return {
+        tooltip: { trigger: 'axis' },
+        xAxis: {
+            type: 'category',
+            data: groups.map(group => group.label),
+        },
+        yAxis: {
+            type: 'value',
+            axisLabel: {
+                formatter: value => `${formatShortNumber(Number(value) / 10000)}万`,
+            },
+        },
+        series: [
+            {
+                type: 'bar',
+                data: groups.map(group => group.totalWords),
+                barWidth: '56%',
+                itemStyle: {
+                    color: '#7f5af0',
+                    borderColor: '#2a220f',
+                    borderWidth: 2,
+                },
+                label: {
+                    show: true,
+                    position: 'top',
+                    formatter: params => formatNumber(params.value),
+                    color: '#5d5035',
+                    fontSize: 12,
+                },
+            },
+        ],
+    }
+}
+
+function buildStrandOption(entries) {
+    return {
+        tooltip: { trigger: 'item' },
+        legend: { bottom: 0 },
+        series: [
+            {
+                type: 'pie',
+                radius: ['42%', '68%'],
+                avoidLabelOverlap: false,
+                itemStyle: {
+                    borderColor: '#2a220f',
+                    borderWidth: 2,
+                },
+                label: {
+                    show: true,
+                    formatter: '{b}\n{d}%',
+                    color: '#5d5035',
+                    fontSize: 12,
+                    fontWeight: 600,
+                },
+                data: entries,
+            },
+        ],
+    }
+}
+
+function StatCard({ label, value, sub, tone = 'accent', progress }) {
+    return (
+        <article className="card stat-card">
+            <span className="stat-label">{label}</span>
+            <span className={`stat-value ${tone === 'plain' ? 'plain' : ''}`.trim()}>{value}</span>
+            <span className="stat-sub">{sub}</span>
+            {progress !== undefined ? (
+                <div className="progress-track">
+                    <div className="progress-fill" style={{ width: `${Math.max(0, Math.min(100, progress))}%` }} />
+                </div>
+            ) : null}
+        </article>
+    )
+}
+
+function RecentSummaryCard({ item }) {
+    return (
+        <article className="summary-card">
+            <div className="summary-card-header">
+                <span className="summary-chapter">{formatChapterLabel(item.chapter)}</span>
+                <div className="summary-badges">
+                    <Badge tone={toneForHookStrength(item.hook_strength)}>
+                        {item.hook_strength || '无钩子'}
+                    </Badge>
+                    {item.review_score ? <Badge tone="purple">{item.review_score} 分</Badge> : null}
+                </div>
+            </div>
+            <h3>{item.title || '未命名章节'}</h3>
+            <p>{item.summary || '暂无章节概要。'}</p>
+            <div className="summary-meta">
+                <span>{item.location || '未记录地点'}</span>
+                <span>{formatNumber(item.word_count)} 字</span>
+                <span>{(item.characters || []).join('、') || '未记录角色'}</span>
+            </div>
+        </article>
+    )
+}
+
+export default function OverviewPage() {
+    const { projectInfo, refreshToken } = useDashboardContext()
+    const [runtimeHealth, setRuntimeHealth] = useState(null)
+    const [allChapters, setAllChapters] = useState([])
+    const [trendWindow, setTrendWindow] = useState({ items: [], total: 0, latest_chapter: 0 })
+    const [latestWindow, setLatestWindow] = useState({ items: [], total: 0, latest_chapter: 0 })
+    const [windowIndex, setWindowIndex] = useState(0)
+    const [loadingTrend, setLoadingTrend] = useState(true)
+
+    useEffect(() => {
+        setWindowIndex(0)
+    }, [refreshToken])
+
+    useEffect(() => {
+        let cancelled = false
+
+        Promise.allSettled([
+            fetchStoryRuntimeHealth(),
+            fetchChapters(),
+            fetchChapterTrend({ limit: WINDOW_SIZE, offset: 0 }),
+        ]).then(results => {
+            if (cancelled) return
+
+            setRuntimeHealth(results[0].status === 'fulfilled' ? results[0].value : null)
+            setAllChapters(results[1].status === 'fulfilled' ? results[1].value : [])
+
+            const latest = results[2].status === 'fulfilled'
+                ? results[2].value
+                : { items: [], total: 0, latest_chapter: 0 }
+            setLatestWindow(latest)
+            setTrendWindow(latest)
+            setLoadingTrend(false)
+        })
+
+        return () => {
+            cancelled = true
+        }
+    }, [refreshToken])
+
+    useEffect(() => {
+        if (windowIndex === 0) {
+            setTrendWindow(latestWindow)
+            return
+        }
+
+        let cancelled = false
+        setLoadingTrend(true)
+
+        fetchChapterTrend({ limit: WINDOW_SIZE, offset: windowIndex * WINDOW_SIZE })
+            .then(payload => {
+                if (!cancelled) {
+                    setTrendWindow(payload)
+                }
+            })
+            .catch(() => {
+                if (!cancelled) {
+                    setTrendWindow({ items: [], total: latestWindow.total || 0, latest_chapter: latestWindow.latest_chapter || 0 })
+                }
+            })
+            .finally(() => {
+                if (!cancelled) {
+                    setLoadingTrend(false)
+                }
+            })
+
+        return () => {
+            cancelled = true
+        }
+    }, [latestWindow, refreshToken, windowIndex])
+
+    const info = projectInfo?.project_info || {}
+    const progress = projectInfo?.progress || {}
+
+    const windowItems = trendWindow.items || []
+    const totalPages = Math.max(1, Math.ceil((trendWindow.total || latestWindow.total || 0) / WINDOW_SIZE))
+    const displayPage = Math.max(1, totalPages - windowIndex)
+    const currentStart = windowItems[0]?.chapter || 0
+    const currentEnd = windowItems[windowItems.length - 1]?.chapter || 0
+
+    const volumeGroups = useMemo(
+        () => groupChaptersByVolume(allChapters, projectInfo),
+        [allChapters, projectInfo],
+    )
+
+    const foreshadowRecords = useMemo(
+        () => buildForeshadowingRecords(projectInfo),
+        [projectInfo],
+    )
+    const foreshadowSummary = useMemo(
+        () => summarizeForeshadowing(foreshadowRecords),
+        [foreshadowRecords],
+    )
+
+    const urgentRows = useMemo(() => {
+        return [...foreshadowRecords]
+            .sort((left, right) => {
+                const priority = (FORESHADOW_PRIORITY[right.level] || 0) - (FORESHADOW_PRIORITY[left.level] || 0)
+                if (priority !== 0) return priority
+                return (right.urgencyScore || 0) - (left.urgencyScore || 0)
+            })
+            .slice(0, 5)
+    }, [foreshadowRecords])
+
+    const latestReviewAverage = useMemo(() => {
+        return average((latestWindow.items || []).map(item => item.review_score))
+    }, [latestWindow.items])
+
+    const recentSummaries = useMemo(() => {
+        return [...(latestWindow.items || [])].slice(-3).reverse()
+    }, [latestWindow.items])
+
+    const strandEntries = useMemo(() => {
+        const history = Array.isArray(projectInfo?.strand_tracker?.history)
+            ? projectInfo.strand_tracker.history
+            : []
+        const counts = new Map([
+            ['quest', 0],
+            ['fire', 0],
+            ['constellation', 0],
+        ])
+
+        history.forEach(item => {
+            const key = String(item?.strand || item?.dominant || '').toLowerCase()
+            if (counts.has(key)) {
+                counts.set(key, counts.get(key) + 1)
+            }
+        })
+
+        return [...counts.entries()]
+            .filter(([, value]) => value > 0)
+            .map(([key, value]) => ({
+                name: key,
+                value,
+                itemStyle: {
+                    color: STRAND_COLORS[key] || '#00b8d4',
+                    borderColor: '#2a220f',
+                    borderWidth: 2,
+                },
+            }))
+    }, [projectInfo?.strand_tracker?.history])
+
+    const totalWords = Number(progress.total_words || 0)
+    const targetWords = Number(info.target_words || 0)
+    const progressPercent = targetWords > 0 ? (totalWords / targetWords) * 100 : 0
+
+    return (
+        <section className="dashboard-page">
+            <header className="page-header">
+                <h2>总览</h2>
+                <Badge tone="blue">{info.genre || '未知题材'}</Badge>
+            </header>
+
+            <div className="stat-grid">
+                <StatCard
+                    label="总字数"
+                    value={formatNumber(totalWords)}
+                    sub={`目标 ${formatNumber(targetWords || 0)} 字 · ${formatPercent(progressPercent)}`}
+                    progress={progressPercent}
+                />
+                <StatCard
+                    label="当前章节"
+                    value={formatChapterLabel(progress.current_chapter)}
+                    sub={`目标 ${info.target_chapters || '—'} 章 · 卷 ${progress.current_volume || '—'}`}
+                />
+                <StatCard
+                    label="Story Runtime"
+                    value={runtimeHealth?.mainline_ready ? 'Mainline' : 'Fallback'}
+                    sub={formatRuntimeText(runtimeHealth)}
+                    tone="plain"
+                />
+                <StatCard
+                    label="审查均分"
+                    value={latestReviewAverage ? formatShortNumber(latestReviewAverage) : '—'}
+                    sub="最近 50 章平均"
+                />
+                <StatCard
+                    label="紧急伏笔"
+                    value={String(foreshadowSummary.attention)}
+                    sub={`总计 ${foreshadowSummary.total} 条伏笔`}
+                />
+            </div>
+
+            <article className="card">
+                <div className="card-header">
+                    <div>
+                        <div className="section-label">REVIEW TREND</div>
+                        <div className="card-title">审查得分趋势</div>
+                    </div>
+                    <Badge tone="green">
+                        {currentStart && currentEnd ? `${formatChapterLabel(currentStart)} - ${formatChapterLabel(currentEnd)}` : '最近窗口'}
+                    </Badge>
+                </div>
+                {windowItems.length ? (
+                    <>
+                        <ChartWrapper option={buildReviewOption(windowItems)} loading={loadingTrend} />
+                        <Pager
+                            page={displayPage}
+                            totalPages={totalPages}
+                            currentStart={currentStart}
+                            currentEnd={currentEnd}
+                            totalItems={trendWindow.total || latestWindow.total || 0}
+                            onPrevious={() => setWindowIndex(current => Math.min(totalPages - 1, current + 1))}
+                            onNext={() => setWindowIndex(current => Math.max(0, current - 1))}
+                            onLatest={() => setWindowIndex(0)}
+                            stepLabel={String(WINDOW_SIZE)}
+                        />
+                    </>
+                ) : (
+                    <div className="empty-state">
+                        <p>暂无章节趋势数据</p>
+                    </div>
+                )}
+            </article>
+
+            <div className="content-grid two-columns">
+                <article className="card">
+                    <div className="card-header">
+                        <div>
+                            <div className="section-label">WORD DISTRIBUTION</div>
+                            <div className="card-title">字数分布(按卷)</div>
+                        </div>
+                        <Badge tone="purple">{volumeGroups.length} 卷</Badge>
+                    </div>
+                    {volumeGroups.length ? (
+                        <ChartWrapper option={buildVolumeOption(volumeGroups)} />
+                    ) : (
+                        <div className="empty-state">
+                            <p>暂无章节字数数据</p>
+                        </div>
+                    )}
+                </article>
+
+                <article className="card">
+                    <div className="card-header">
+                        <div>
+                            <div className="section-label">STRAND OVERVIEW</div>
+                            <div className="card-title">Strand Weave 整体分布</div>
+                        </div>
+                        <Badge tone="purple">{projectInfo?.strand_tracker?.current_dominant || 'unknown'}</Badge>
+                    </div>
+                    {strandEntries.length ? (
+                        <ChartWrapper option={buildStrandOption(strandEntries)} height={260} />
+                    ) : (
+                        <div className="empty-state">
+                            <p>暂无 Strand 历史</p>
+                        </div>
+                    )}
+                </article>
+            </div>
+
+            <div className="content-grid two-columns">
+                <article className="card">
+                    <div className="card-header">
+                        <div>
+                            <div className="section-label">FORESHADOWING</div>
+                            <div className="card-title">紧急伏笔 Top 5</div>
+                        </div>
+                        <Badge tone="amber">按紧急度排序</Badge>
+                    </div>
+                    <DataTable
+                        columns={[
+                            { key: 'content', label: '内容' },
+                            {
+                                key: 'statusText',
+                                label: '状态',
+                                render: row => <Badge tone={toneForForeshadowing(row.level)}>{row.statusText}</Badge>,
+                            },
+                            {
+                                key: 'plantedChapter',
+                                label: '埋设章',
+                                render: row => formatChapterLabel(row.plantedChapter),
+                            },
+                            {
+                                key: 'targetChapter',
+                                label: '目标章',
+                                render: row => formatChapterLabel(row.targetChapter),
+                            },
+                            {
+                                key: 'urgencyText',
+                                label: '紧急度',
+                                render: row => <Badge tone={toneForUrgencyBadge(row.urgencyText)}>{row.urgencyText}</Badge>,
+                            },
+                        ]}
+                        rows={urgentRows}
+                        rowKey="id"
+                        pageSize={5}
+                        emptyText="暂无伏笔数据"
+                        minWidth={680}
+                    />
+                </article>
+
+                <article className="card">
+                    <div className="card-header">
+                        <div>
+                            <div className="section-label">LATEST CHAPTERS</div>
+                            <div className="card-title">最近 3 章概要</div>
+                        </div>
+                        <Badge tone="cyan">{recentSummaries.length} 条</Badge>
+                    </div>
+                    {recentSummaries.length ? (
+                        <div className="summary-card-list">
+                            {recentSummaries.map(item => (
+                                <RecentSummaryCard key={item.chapter} item={item} />
+                            ))}
+                        </div>
+                    ) : (
+                        <div className="empty-state">
+                            <p>暂无最新章节概要</p>
+                        </div>
+                    )}
+                </article>
+            </div>
+        </section>
+    )
+}

+ 321 - 0
webnovel-writer/dashboard/frontend/src/pages/PacingPage.jsx

@@ -0,0 +1,321 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useDashboardContext } from '../App.jsx'
+import { fetchChapterTrend, fetchChapters } from '../api.js'
+import Badge from '../components/Badge.jsx'
+import ChartWrapper from '../components/ChartWrapper.jsx'
+import Pager from '../components/Pager.jsx'
+import { STRAND_COLORS, buildBoxplotData } from '../lib/charts.js'
+import { average, formatChapterLabel, formatNumber, formatShortNumber } from '../lib/format.js'
+import { groupChaptersByVolume } from '../lib/story.js'
+
+const WINDOW_SIZE = 50
+
+function buildHookOption(items) {
+    return {
+        tooltip: { trigger: 'axis' },
+        xAxis: {
+            type: 'category',
+            data: items.map(item => item.chapter),
+            axisLabel: { interval: 9, formatter: value => `${value}` },
+        },
+        yAxis: {
+            type: 'value',
+            min: 0,
+            max: 5,
+            axisLabel: {
+                formatter: value => ['', 'weak', '', 'medium', '', 'strong'][value] || '',
+            },
+        },
+        series: [
+            {
+                type: 'line',
+                data: items.map(item => item.hook_strength_value || 0),
+                symbol: 'rect',
+                symbolSize: 8,
+                lineStyle: { width: 3, color: '#f5a524' },
+                itemStyle: {
+                    color: '#f5a524',
+                    borderColor: '#2a220f',
+                    borderWidth: 2,
+                },
+                areaStyle: {
+                    color: {
+                        type: 'linear',
+                        x: 0,
+                        y: 0,
+                        x2: 0,
+                        y2: 1,
+                        colorStops: [
+                            { offset: 0, color: 'rgba(245, 165, 36, 0.32)' },
+                            { offset: 1, color: 'rgba(245, 165, 36, 0)' },
+                        ],
+                    },
+                },
+            },
+        ],
+    }
+}
+
+function buildStrandStackOption(items) {
+    const chapters = items.map(item => item.chapter)
+    return {
+        tooltip: { trigger: 'axis' },
+        legend: { bottom: 0, data: ['Quest', 'Fire', 'Constellation'] },
+        xAxis: {
+            type: 'category',
+            data: chapters,
+            axisLabel: { interval: 9, formatter: value => `${value}` },
+        },
+        yAxis: {
+            type: 'value',
+            max: 1,
+            splitNumber: 1,
+        },
+        series: [
+            {
+                name: 'Quest',
+                type: 'bar',
+                stack: 'strand',
+                data: items.map(item => (item.strand === 'quest' ? 1 : 0)),
+                barWidth: '64%',
+                itemStyle: {
+                    color: STRAND_COLORS.quest,
+                    borderColor: '#2a220f',
+                    borderWidth: 1,
+                },
+            },
+            {
+                name: 'Fire',
+                type: 'bar',
+                stack: 'strand',
+                data: items.map(item => (item.strand === 'fire' ? 1 : 0)),
+                itemStyle: {
+                    color: STRAND_COLORS.fire,
+                    borderColor: '#2a220f',
+                    borderWidth: 1,
+                },
+            },
+            {
+                name: 'Constellation',
+                type: 'bar',
+                stack: 'strand',
+                data: items.map(item => (item.strand === 'constellation' ? 1 : 0)),
+                itemStyle: {
+                    color: STRAND_COLORS.constellation,
+                    borderColor: '#2a220f',
+                    borderWidth: 1,
+                },
+            },
+        ],
+    }
+}
+
+function buildWordBoxOption(groups) {
+    return {
+        tooltip: { trigger: 'item' },
+        xAxis: {
+            type: 'category',
+            data: groups.map(group => group.label),
+        },
+        yAxis: {
+            type: 'value',
+            axisLabel: {
+                formatter: value => `${formatShortNumber(Number(value) / 1000)}k`,
+            },
+        },
+        series: [
+            {
+                type: 'boxplot',
+                data: buildBoxplotData(groups),
+                itemStyle: {
+                    color: '#fffaf0',
+                    borderColor: '#26a8ff',
+                    borderWidth: 2,
+                },
+            },
+        ],
+    }
+}
+
+function StatCard({ label, value, sub }) {
+    return (
+        <article className="card stat-card">
+            <span className="stat-label">{label}</span>
+            <span className="stat-value">{value}</span>
+            <span className="stat-sub">{sub}</span>
+        </article>
+    )
+}
+
+export default function PacingPage() {
+    const { projectInfo, refreshToken } = useDashboardContext()
+    const [allChapters, setAllChapters] = useState([])
+    const [windowIndex, setWindowIndex] = useState(0)
+    const [windowPayload, setWindowPayload] = useState({ items: [], total: 0, latest_chapter: 0 })
+    const [loading, setLoading] = useState(true)
+
+    useEffect(() => {
+        setWindowIndex(0)
+    }, [refreshToken])
+
+    useEffect(() => {
+        let cancelled = false
+
+        fetchChapters()
+            .then(payload => {
+                if (!cancelled) {
+                    setAllChapters(payload)
+                }
+            })
+            .catch(() => {
+                if (!cancelled) {
+                    setAllChapters([])
+                }
+            })
+
+        return () => {
+            cancelled = true
+        }
+    }, [refreshToken])
+
+    useEffect(() => {
+        let cancelled = false
+        setLoading(true)
+
+        fetchChapterTrend({ limit: WINDOW_SIZE, offset: windowIndex * WINDOW_SIZE })
+            .then(payload => {
+                if (!cancelled) {
+                    setWindowPayload(payload)
+                }
+            })
+            .catch(() => {
+                if (!cancelled) {
+                    setWindowPayload({ items: [], total: 0, latest_chapter: 0 })
+                }
+            })
+            .finally(() => {
+                if (!cancelled) {
+                    setLoading(false)
+                }
+            })
+
+        return () => {
+            cancelled = true
+        }
+    }, [refreshToken, windowIndex])
+
+    const totalPages = Math.max(1, Math.ceil((windowPayload.total || 0) / WINDOW_SIZE))
+    const displayPage = Math.max(1, totalPages - windowIndex)
+    const groups = useMemo(
+        () => groupChaptersByVolume(allChapters, projectInfo),
+        [allChapters, projectInfo],
+    )
+    const items = windowPayload.items || []
+    const hookAverage = useMemo(() => average(items.map(item => item.hook_strength_value)), [items])
+    const transitionCount = useMemo(
+        () => items.filter(item => item.is_transition).length,
+        [items],
+    )
+    const currentStart = items[0]?.chapter || 0
+    const currentEnd = items[items.length - 1]?.chapter || 0
+
+    return (
+        <section className="dashboard-page">
+            <header className="page-header">
+                <h2>节奏雷达</h2>
+                <Badge tone="amber">{windowPayload.total || 0} 章数据</Badge>
+            </header>
+
+            <div className="stat-grid">
+                <StatCard
+                    label="当前窗口"
+                    value={currentStart && currentEnd ? `${currentStart} - ${currentEnd}` : '—'}
+                    sub="默认最近 50 章"
+                />
+                <StatCard
+                    label="平均钩子强度"
+                    value={hookAverage ? formatShortNumber(hookAverage) : '—'}
+                    sub="映射 weak=1 / medium=3 / strong=5"
+                />
+                <StatCard
+                    label="过渡章"
+                    value={String(transitionCount)}
+                    sub="当前窗口内标记为 transition 的章节"
+                />
+                <StatCard
+                    label="总字数"
+                    value={formatNumber(allChapters.reduce((sum, item) => sum + Number(item.word_count || 0), 0))}
+                    sub={`最新章节 ${formatChapterLabel(windowPayload.latest_chapter)}`}
+                />
+            </div>
+
+            <article className="card">
+                <div className="card-header">
+                    <div>
+                        <div className="section-label">HOOK TREND</div>
+                        <div className="card-title">钩子强度走势</div>
+                    </div>
+                    <Badge tone="green">
+                        {currentStart && currentEnd ? `${formatChapterLabel(currentStart)} - ${formatChapterLabel(currentEnd)}` : '最近窗口'}
+                    </Badge>
+                </div>
+                {items.length ? (
+                    <>
+                        <ChartWrapper option={buildHookOption(items)} loading={loading} />
+                        <Pager
+                            page={displayPage}
+                            totalPages={totalPages}
+                            currentStart={currentStart}
+                            currentEnd={currentEnd}
+                            totalItems={windowPayload.total || 0}
+                            onPrevious={() => setWindowIndex(current => Math.min(totalPages - 1, current + 1))}
+                            onNext={() => setWindowIndex(current => Math.max(0, current - 1))}
+                            onLatest={() => setWindowIndex(0)}
+                            stepLabel={String(WINDOW_SIZE)}
+                        />
+                    </>
+                ) : (
+                    <div className="empty-state">
+                        <p>暂无钩子强度数据</p>
+                    </div>
+                )}
+            </article>
+
+            <div className="content-grid two-columns">
+                <article className="card">
+                    <div className="card-header">
+                        <div>
+                            <div className="section-label">STRAND STACK</div>
+                            <div className="card-title">Strand 分布(逐章)</div>
+                        </div>
+                        <Badge tone="purple">堆叠柱状图</Badge>
+                    </div>
+                    {items.length ? (
+                        <ChartWrapper option={buildStrandStackOption(items)} />
+                    ) : (
+                        <div className="empty-state">
+                            <p>暂无 Strand 数据</p>
+                        </div>
+                    )}
+                </article>
+
+                <article className="card">
+                    <div className="card-header">
+                        <div>
+                            <div className="section-label">WORD BOXPLOT</div>
+                            <div className="card-title">章节字数分布</div>
+                        </div>
+                        <Badge tone="blue">按卷分组</Badge>
+                    </div>
+                    {groups.length ? (
+                        <ChartWrapper option={buildWordBoxOption(groups)} />
+                    ) : (
+                        <div className="empty-state">
+                            <p>暂无章节字数数据</p>
+                        </div>
+                    )}
+                </article>
+            </div>
+        </section>
+    )
+}

+ 294 - 0
webnovel-writer/dashboard/frontend/src/pages/SystemPage.jsx

@@ -0,0 +1,294 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useDashboardContext } from '../App.jsx'
+import Badge from '../components/Badge.jsx'
+import DataTable from '../components/DataTable.jsx'
+import {
+    fetchCommits,
+    fetchContractsSummary,
+    fetchEnvStatus,
+    fetchStoryRuntimeHealth,
+    probeEnvStatus,
+} from '../api.js'
+import { formatChapterLabel, formatDateTime, formatNumber } from '../lib/format.js'
+
+function statusTone(status) {
+    const text = String(status || '').toLowerCase()
+    if (text === 'accepted' || text === 'done' || text === 'ok' || text === 'full') return 'green'
+    if (text === 'rejected' || text === 'failed' || text === 'error') return 'red'
+    if (text === 'skipped' || text === 'missing' || text === 'bm25_only') return 'amber'
+    return 'blue'
+}
+
+function projectionSummary(projectionStatus) {
+    const values = Object.values(projectionStatus || {})
+    if (!values.length) return '无投影'
+    if (values.every(value => value === 'done')) return '5 路 projection OK'
+    return values.join(' / ')
+}
+
+function StatCard({ label, value, sub, tone = 'plain' }) {
+    return (
+        <article className="card stat-card">
+            <span className="stat-label">{label}</span>
+            <span className={`stat-value ${tone === 'plain' ? 'plain' : ''}`.trim()}>{value}</span>
+            <span className="stat-sub">{sub}</span>
+        </article>
+    )
+}
+
+export default function SystemPage() {
+    const { refreshToken } = useDashboardContext()
+    const [runtimeHealth, setRuntimeHealth] = useState(null)
+    const [contractsSummary, setContractsSummary] = useState(null)
+    const [commits, setCommits] = useState([])
+    const [envStatus, setEnvStatus] = useState(null)
+    const [probeResult, setProbeResult] = useState(null)
+    const [probing, setProbing] = useState(false)
+
+    useEffect(() => {
+        let cancelled = false
+
+        Promise.allSettled([
+            fetchStoryRuntimeHealth(),
+            fetchContractsSummary(),
+            fetchCommits({ limit: 12 }),
+            fetchEnvStatus(),
+        ]).then(results => {
+            if (cancelled) return
+
+            setRuntimeHealth(results[0].status === 'fulfilled' ? results[0].value : null)
+            setContractsSummary(results[1].status === 'fulfilled' ? results[1].value : null)
+            setCommits(results[2].status === 'fulfilled' ? (results[2].value.items || []) : [])
+            setEnvStatus(results[3].status === 'fulfilled' ? results[3].value : null)
+        })
+
+        return () => {
+            cancelled = true
+        }
+    }, [refreshToken])
+
+    const latestCommit = commits[0] || null
+    const contractRows = useMemo(() => {
+        if (!contractsSummary) return []
+        return [
+            {
+                type: 'MASTER_SETTING',
+                count: contractsSummary.master?.exists ? 1 : 0,
+                desc: [contractsSummary.master?.primary_genre, contractsSummary.master?.core_tone].filter(Boolean).join(' · ') || '未检测到主合同',
+            },
+            {
+                type: 'VOLUME_BRIEF',
+                count: contractsSummary.counts?.volumes || 0,
+                desc: `当前卷 ${contractsSummary.current_volume || '—'} · ${contractsSummary.current_contracts?.volume ? '存在' : '缺失'}`,
+            },
+            {
+                type: 'CHAPTER_BRIEF',
+                count: contractsSummary.counts?.chapters || 0,
+                desc: `${formatChapterLabel(contractsSummary.chapter)} · ${contractsSummary.current_contracts?.chapter ? '存在' : '缺失'}`,
+            },
+            {
+                type: 'REVIEW_CONTRACT',
+                count: contractsSummary.counts?.reviews || 0,
+                desc: `${contractsSummary.current_contracts?.review ? '当前章已生成审查合同' : '当前章缺少审查合同'}`,
+            },
+            {
+                type: 'COMMIT',
+                count: contractsSummary.counts?.commits || 0,
+                desc: `${contractsSummary.current_contracts?.commit ? '当前章已有 commit' : '当前章无 commit'}`,
+            },
+        ]
+    }, [contractsSummary])
+
+    const envRows = useMemo(() => {
+        if (probeResult?.checks?.length) {
+            return probeResult.checks.map(item => ({
+                name: item.name,
+                ok: item.ok,
+                detail: item.detail,
+            }))
+        }
+        if (!envStatus) return []
+
+        return [
+            {
+                name: 'embed',
+                ok: envStatus.embed?.api_key_present,
+                detail: `${envStatus.embed?.model || 'unknown'} · ${envStatus.embed?.base_url || 'no base url'}`,
+            },
+            {
+                name: 'rerank',
+                ok: envStatus.rerank?.api_key_present,
+                detail: `${envStatus.rerank?.model || 'unknown'} · ${envStatus.rerank?.base_url || 'no base url'}`,
+            },
+            {
+                name: 'vector_db',
+                ok: envStatus.vector_db?.exists && !envStatus.vector_db?.error,
+                detail: `${envStatus.vector_db?.record_count || 0} records · ${envStatus.vector_db?.size_bytes || 0} bytes`,
+            },
+            {
+                name: 'rag_mode',
+                ok: Boolean(envStatus.rag_mode),
+                detail: envStatus.rag_mode,
+            },
+        ]
+    }, [envStatus, probeResult])
+
+    return (
+        <section className="dashboard-page">
+            <header className="page-header">
+                <h2>系统状态</h2>
+            </header>
+
+            <div className="stat-grid">
+                <StatCard
+                    label="Story Runtime"
+                    value={runtimeHealth?.mainline_ready ? 'Mainline' : 'Fallback'}
+                    sub={`fallback: ${(runtimeHealth?.fallback_sources || []).join(', ') || 'none'}`}
+                />
+                <StatCard
+                    label="Latest Commit"
+                    value={latestCommit?.status || runtimeHealth?.latest_commit_status || 'missing'}
+                    sub={latestCommit ? `${formatChapterLabel(latestCommit.chapter)} · ${projectionSummary(latestCommit.projection_status)}` : '暂无 commit 数据'}
+                />
+                <StatCard
+                    label="RAG Mode"
+                    value={envStatus?.rag_mode || 'unknown'}
+                    sub={`${envStatus?.embed?.api_key_present ? 'embed ready' : 'embed missing'} · ${envStatus?.rerank?.api_key_present ? 'rerank ready' : 'rerank missing'}`}
+                />
+                <StatCard
+                    label="Vector DB"
+                    value={formatNumber(envStatus?.vector_db?.record_count || 0)}
+                    sub={`${envStatus?.vector_db?.size_bytes || 0} bytes`}
+                />
+            </div>
+
+            <article className="card">
+                <div className="card-header">
+                    <div>
+                        <div className="section-label">CONTRACT TREE</div>
+                        <div className="card-title">合同树概览</div>
+                    </div>
+                    {contractsSummary ? <Badge tone="purple">{formatChapterLabel(contractsSummary.chapter)}</Badge> : null}
+                </div>
+                <DataTable
+                    columns={[
+                        { key: 'type', label: '类型' },
+                        {
+                            key: 'count',
+                            label: '数量',
+                            render: row => <Badge tone={row.count > 0 ? 'green' : 'red'}>{row.count}</Badge>,
+                        },
+                        { key: 'desc', label: '说明' },
+                    ]}
+                    rows={contractRows}
+                    rowKey="type"
+                    pageSize={6}
+                    emptyText="暂无合同树数据"
+                    minWidth={680}
+                />
+            </article>
+
+            <article className="card">
+                <div className="card-header">
+                    <div>
+                        <div className="section-label">RECENT COMMITS</div>
+                        <div className="card-title">最近 Commit 历史</div>
+                    </div>
+                    <Badge tone="amber">{commits.length} 条</Badge>
+                </div>
+                <DataTable
+                    columns={[
+                        {
+                            key: 'chapter',
+                            label: '章节',
+                            render: row => formatChapterLabel(row.chapter),
+                        },
+                        {
+                            key: 'status',
+                            label: '状态',
+                            render: row => <Badge tone={statusTone(row.status)}>{row.status}</Badge>,
+                        },
+                        {
+                            key: 'state',
+                            label: 'state',
+                            render: row => <Badge tone={statusTone(row.projection_status?.state)}>{row.projection_status?.state || '—'}</Badge>,
+                        },
+                        {
+                            key: 'index',
+                            label: 'index',
+                            render: row => <Badge tone={statusTone(row.projection_status?.index)}>{row.projection_status?.index || '—'}</Badge>,
+                        },
+                        {
+                            key: 'summary',
+                            label: 'summary',
+                            render: row => <Badge tone={statusTone(row.projection_status?.summary)}>{row.projection_status?.summary || '—'}</Badge>,
+                        },
+                        {
+                            key: 'memory',
+                            label: 'memory',
+                            render: row => <Badge tone={statusTone(row.projection_status?.memory)}>{row.projection_status?.memory || '—'}</Badge>,
+                        },
+                        {
+                            key: 'vector',
+                            label: 'vector',
+                            render: row => <Badge tone={statusTone(row.projection_status?.vector)}>{row.projection_status?.vector || '—'}</Badge>,
+                        },
+                        {
+                            key: 'updated_at',
+                            label: '更新时间',
+                            render: row => formatDateTime(row.updated_at),
+                        },
+                    ]}
+                    rows={commits}
+                    rowKey={(row, index) => `${row.chapter || 0}-${index}`}
+                    pageSize={8}
+                    emptyText="暂无 commit 记录"
+                    minWidth={980}
+                />
+            </article>
+
+            <article className="card">
+                <div className="card-header">
+                    <div>
+                        <div className="section-label">RAG DIAGNOSIS</div>
+                        <div className="card-title">RAG 环境</div>
+                    </div>
+                    <button
+                        type="button"
+                        className="page-btn"
+                        disabled={probing}
+                        onClick={() => {
+                            setProbing(true)
+                            probeEnvStatus()
+                                .then(payload => setProbeResult(payload))
+                                .finally(() => setProbing(false))
+                        }}
+                    >
+                        {probing ? '诊断中…' : '运行诊断'}
+                    </button>
+                </div>
+                {probeResult?.checked_at ? (
+                    <div className="diagnosis-meta">
+                        上次诊断:{formatDateTime(probeResult.checked_at)} · {probeResult.ok ? '全部通过' : '存在缺项'}
+                    </div>
+                ) : null}
+                <DataTable
+                    columns={[
+                        { key: 'name', label: '组件' },
+                        {
+                            key: 'ok',
+                            label: '状态',
+                            render: row => <Badge tone={row.ok ? 'green' : 'red'}>{row.ok ? 'OK' : '缺失'}</Badge>,
+                        },
+                        { key: 'detail', label: '详情' },
+                    ]}
+                    rows={envRows}
+                    rowKey="name"
+                    pageSize={6}
+                    emptyText="暂无环境信息"
+                    minWidth={680}
+                />
+            </article>
+        </section>
+    )
+}

+ 8 - 0
webnovel-writer/dashboard/frontend/vite.config.js

@@ -11,5 +11,13 @@ export default defineConfig({
   build: {
   build: {
     outDir: 'dist',
     outDir: 'dist',
     emptyOutDir: true,
     emptyOutDir: true,
+    rollupOptions: {
+      output: {
+        manualChunks: {
+          'react-vendor': ['react', 'react-dom', 'react-router-dom'],
+          'echarts-vendor': ['echarts', 'echarts-for-react'],
+        },
+      },
+    },
   },
   },
 })
 })

+ 558 - 0
webnovel-writer/scripts/data_modules/tests/mock_demo.py

@@ -0,0 +1,558 @@
+"""
+生成仿真数据并启动 Dashboard,用于视觉验收。
+
+用法:
+    python -m data_modules.tests.mock_demo
+    然后浏览器打开 http://localhost:8765
+"""
+
+from __future__ import annotations
+
+import json
+import math
+import random
+import shutil
+import sqlite3
+import sys
+import tempfile
+from pathlib import Path
+
+# ---------------------------------------------------------------------------
+# 配置
+# ---------------------------------------------------------------------------
+
+TOTAL_CHAPTERS = 120
+TOTAL_VOLUMES = 4
+VOLUME_RANGES = [
+    (1, 1, 30),
+    (2, 31, 60),
+    (3, 61, 90),
+    (4, 91, 120),
+]
+
+STRANDS = ["quest", "fire", "constellation"]
+HOOK_STRENGTHS = ["weak", "medium", "strong"]
+LOCATIONS = [
+    "青元宗", "青元秘境", "东海仙城", "黑市", "天魔教总坛",
+    "龙脉洞府", "太虚殿", "凤鸣谷", "剑冢", "星河幻境",
+    "白骨荒原", "紫霄山", "碧落天池", "幽冥渡口", "九天雷域",
+]
+CHARACTERS = [
+    "lintian", "fenglinger", "laodaoshi", "baiyujing", "heishifanzi",
+    "jianling", "tianyao", "zixiao", "qingyun", "mozu",
+]
+CHARACTER_NAMES = {
+    "lintian": "林长青",
+    "fenglinger": "凤灵儿",
+    "laodaoshi": "老道士",
+    "baiyujing": "白玉京",
+    "heishifanzi": "黑市掮客",
+    "jianling": "剑灵",
+    "tianyao": "天妖",
+    "zixiao": "紫霄真人",
+    "qingyun": "青云",
+    "mozu": "魔祖残念",
+}
+
+FORESHADOWING = [
+    {"content": "青元秘境的钥匙碎片下落", "status": "未回收", "tier": "核心", "planted_chapter": 15, "target_chapter": 45},
+    {"content": "凤灵儿真实身份暗示", "status": "未回收", "tier": "核心", "planted_chapter": 28, "target_chapter": 80},
+    {"content": "老道士临终遗言中的数字", "status": "未回收", "tier": "核心", "planted_chapter": 42, "target_chapter": 100},
+    {"content": "黑市拍卖会幕后势力", "status": "未回收", "tier": "支线", "planted_chapter": 55, "target_chapter": 90},
+    {"content": "主角功法异变的真实原因", "status": "未回收", "tier": "核心", "planted_chapter": 65, "target_chapter": 130},
+    {"content": "天魔血脉觉醒征兆", "status": "未回收", "tier": "支线", "planted_chapter": 70, "target_chapter": 140},
+    {"content": "仙城禁地秘密", "status": "未回收", "tier": "支线", "planted_chapter": 38, "target_chapter": 110},
+    {"content": "师门灭门线索", "status": "已回收", "tier": "核心", "planted_chapter": 3, "target_chapter": 50, "resolved_chapter": 48},
+    {"content": "初代掌门遗物功用", "status": "已回收", "tier": "支线", "planted_chapter": 10, "target_chapter": 40, "resolved_chapter": 38},
+    {"content": "龙脉封印来历", "status": "已回收", "tier": "装饰", "planted_chapter": 25, "target_chapter": 60, "resolved_chapter": 58},
+    {"content": "剑冢守灵人身份", "status": "已回收", "tier": "支线", "planted_chapter": 50, "target_chapter": 85, "resolved_chapter": 82},
+    {"content": "紫霄山禁术", "status": "未回收", "tier": "装饰", "planted_chapter": 78, "target_chapter": 150},
+    {"content": "碧落天池的水源秘密", "status": "未回收", "tier": "装饰", "planted_chapter": 90, "target_chapter": 160},
+    {"content": "魔祖残念的苏醒条件", "status": "未回收", "tier": "核心", "planted_chapter": 95, "target_chapter": 180},
+    {"content": "九天雷域的上古阵法", "status": "未回收", "tier": "支线", "planted_chapter": 105, "target_chapter": 170},
+]
+
+ENTITIES = [
+    {"id": "lintian", "canonical_name": "林长青", "type": "角色", "tier": "S", "is_protagonist": True, "first_appearance": 1, "last_appearance": 120, "desc": "太虚宗弟子,身负天魔血脉,修炼无极真经。"},
+    {"id": "fenglinger", "canonical_name": "凤灵儿", "type": "角色", "tier": "S", "is_protagonist": False, "first_appearance": 8, "last_appearance": 118, "desc": "林长青师妹,真实身份成谜。"},
+    {"id": "laodaoshi", "canonical_name": "老道士", "type": "角色", "tier": "A", "is_protagonist": False, "first_appearance": 1, "last_appearance": 42, "desc": "林长青之师,太虚宗前长老,临终留下数字谜题。"},
+    {"id": "baiyujing", "canonical_name": "白玉京", "type": "角色", "tier": "A", "is_protagonist": False, "first_appearance": 20, "last_appearance": 115, "desc": "天魔教少主,林长青宿敌。"},
+    {"id": "heishifanzi", "canonical_name": "黑市掮客", "type": "角色", "tier": "B", "is_protagonist": False, "first_appearance": 50, "last_appearance": 110, "desc": "黑市情报贩子,立场模糊。"},
+    {"id": "jianling", "canonical_name": "剑灵", "type": "角色", "tier": "A", "is_protagonist": False, "first_appearance": 75, "last_appearance": 120, "desc": "初代掌门遗物中的剑灵意识。"},
+    {"id": "tianyao", "canonical_name": "天妖", "type": "角色", "tier": "B", "is_protagonist": False, "first_appearance": 35, "last_appearance": 95, "desc": "天魔教护法,实力深不可测。"},
+    {"id": "zixiao", "canonical_name": "紫霄真人", "type": "角色", "tier": "A", "is_protagonist": False, "first_appearance": 60, "last_appearance": 120, "desc": "太虚宗掌门,暗中布局百年。"},
+    {"id": "qingyun", "canonical_name": "青云", "type": "角色", "tier": "B", "is_protagonist": False, "first_appearance": 15, "last_appearance": 100, "desc": "林长青同门师兄,嫉妒心重。"},
+    {"id": "mozu", "canonical_name": "魔祖残念", "type": "角色", "tier": "S", "is_protagonist": False, "first_appearance": 95, "last_appearance": 120, "desc": "上古魔祖残留意志,沉睡于龙脉封印之下。"},
+    {"id": "taixuzong", "canonical_name": "太虚宗", "type": "势力", "tier": "S", "is_protagonist": False, "first_appearance": 1, "last_appearance": 120, "desc": "东荒第一仙门。"},
+    {"id": "tianmojiao", "canonical_name": "天魔教", "type": "势力", "tier": "A", "is_protagonist": False, "first_appearance": 20, "last_appearance": 120, "desc": "魔道第一势力。"},
+    {"id": "donghaixiancheng", "canonical_name": "东海仙城", "type": "势力", "tier": "B", "is_protagonist": False, "first_appearance": 40, "last_appearance": 110, "desc": "散修联盟据点。"},
+    {"id": "qingyuanmianjing", "canonical_name": "青元秘境", "type": "地点", "tier": "A", "is_protagonist": False, "first_appearance": 15, "last_appearance": 90, "desc": "太虚宗管辖的上古秘境。"},
+    {"id": "longmaidonfu", "canonical_name": "龙脉洞府", "type": "地点", "tier": "B", "is_protagonist": False, "first_appearance": 55, "last_appearance": 120, "desc": "封印魔祖残念之地。"},
+]
+
+RELATIONSHIPS = [
+    {"from_entity": "lintian", "to_entity": "laodaoshi", "type": "师徒", "chapter": 1, "description": "师徒"},
+    {"from_entity": "lintian", "to_entity": "taixuzong", "type": "归属", "chapter": 3, "description": "入门弟子"},
+    {"from_entity": "lintian", "to_entity": "fenglinger", "type": "同门", "chapter": 8, "description": "同门师兄妹"},
+    {"from_entity": "lintian", "to_entity": "qingyun", "type": "同门", "chapter": 15, "description": "同门师兄弟"},
+    {"from_entity": "baiyujing", "to_entity": "tianmojiao", "type": "归属", "chapter": 20, "description": "少主"},
+    {"from_entity": "lintian", "to_entity": "baiyujing", "type": "敌对", "chapter": 25, "description": "初次交手"},
+    {"from_entity": "taixuzong", "to_entity": "tianmojiao", "type": "敌对", "chapter": 30, "description": "世代仇敌"},
+    {"from_entity": "tianyao", "to_entity": "tianmojiao", "type": "归属", "chapter": 35, "description": "护法"},
+    {"from_entity": "laodaoshi", "to_entity": "donghaixiancheng", "type": "隐居", "chapter": 40, "description": "隐居"},
+    {"from_entity": "heishifanzi", "to_entity": "tianmojiao", "type": "线人", "chapter": 55, "description": "暗中合作"},
+    {"from_entity": "lintian", "to_entity": "longmaidonfu", "type": "发现", "chapter": 60, "description": "发现龙脉"},
+    {"from_entity": "zixiao", "to_entity": "taixuzong", "type": "归属", "chapter": 60, "description": "掌门"},
+    {"from_entity": "lintian", "to_entity": "jianling", "type": "契约", "chapter": 75, "description": "缔结剑灵契约"},
+    {"from_entity": "jianling", "to_entity": "longmaidonfu", "type": "封印", "chapter": 80, "description": "封印守护"},
+    {"from_entity": "mozu", "to_entity": "longmaidonfu", "type": "封印", "chapter": 95, "description": "沉睡于此"},
+    {"from_entity": "lintian", "to_entity": "mozu", "type": "对峙", "chapter": 100, "description": "初次感知"},
+]
+
+RELATIONSHIP_EVENTS = [
+    {"from_entity": "lintian", "to_entity": "fenglinger", "chapter": 45, "event_type": "升温", "description": "秘境同生共死"},
+    {"from_entity": "lintian", "to_entity": "baiyujing", "chapter": 60, "event_type": "恶化", "description": "龙脉争夺战"},
+    {"from_entity": "lintian", "to_entity": "baiyujing", "chapter": 90, "event_type": "恶化", "description": "仙城决战"},
+    {"from_entity": "qingyun", "to_entity": "lintian", "chapter": 70, "event_type": "背叛", "description": "向天魔教泄密"},
+    {"from_entity": "lintian", "to_entity": "zixiao", "chapter": 85, "event_type": "信任", "description": "获授掌门秘传"},
+    {"from_entity": "lintian", "to_entity": "jianling", "chapter": 100, "event_type": "觉醒", "description": "剑灵完全觉醒"},
+    {"from_entity": "fenglinger", "to_entity": "tianmojiao", "chapter": 110, "event_type": "揭示", "description": "身份危机"},
+]
+
+
+# ---------------------------------------------------------------------------
+# 数据生成
+# ---------------------------------------------------------------------------
+
+def volume_for_chapter(ch: int) -> int:
+    for vol, start, end in VOLUME_RANGES:
+        if start <= ch <= end:
+            return vol
+    return TOTAL_VOLUMES
+
+
+def generate_state(project_root: Path) -> None:
+    webnovel = project_root / ".webnovel"
+    webnovel.mkdir(parents=True, exist_ok=True)
+
+    chapter_meta = {}
+    for ch in range(1, TOTAL_CHAPTERS + 1):
+        chapter_meta[f"{ch:04d}"] = {"summary": f"第{ch}章概要:本章情节发展。"}
+
+    state = {
+        "project_info": {
+            "title": "《仙道长青》",
+            "genre": "仙侠",
+            "target_words": 2000000,
+            "target_chapters": 800,
+        },
+        "progress": {
+            "current_chapter": TOTAL_CHAPTERS,
+            "current_volume": volume_for_chapter(TOTAL_CHAPTERS),
+            "total_words": sum(2800 + int(math.sin(ch / 10) * 500 + random.randint(-200, 400)) for ch in range(1, TOTAL_CHAPTERS + 1)),
+            "volumes_planned": [
+                {"volume": v, "chapters_range": f"{s}-{e}"} for v, s, e in VOLUME_RANGES
+            ],
+        },
+        "protagonist_state": {
+            "name": "林长青",
+            "power": {"realm": "金丹中期", "level": 7},
+            "location": {"current": "龙脉洞府"},
+        },
+        "strand_tracker": {
+            "current_dominant": "constellation",
+            "history": [
+                {"chapter": ch, "strand": STRANDS[(ch * 7 + ch // 3) % 3]}
+                for ch in range(1, TOTAL_CHAPTERS + 1)
+            ],
+        },
+        "plot_threads": {
+            "foreshadowing": FORESHADOWING,
+        },
+        "chapter_meta": chapter_meta,
+    }
+
+    (webnovel / "state.json").write_text(
+        json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8"
+    )
+
+
+def generate_index_db(project_root: Path) -> None:
+    db_path = project_root / ".webnovel" / "index.db"
+    conn = sqlite3.connect(str(db_path))
+    cursor = conn.cursor()
+
+    # chapters
+    cursor.execute("""
+        CREATE TABLE IF NOT EXISTS chapters (
+            chapter INTEGER PRIMARY KEY,
+            title TEXT,
+            location TEXT,
+            word_count INTEGER,
+            characters TEXT,
+            summary TEXT
+        )
+    """)
+
+    titles = [
+        "初入山门", "师父赐剑", "宗门大比", "秘境开启", "血战妖兽",
+        "金丹之路", "黑市风云", "龙脉异动", "天魔来袭", "剑灵觉醒",
+        "仙城之变", "紫霄秘传", "雷域试炼", "魔祖之影", "宿命对决",
+    ]
+
+    random.seed(42)
+    for ch in range(1, TOTAL_CHAPTERS + 1):
+        title = titles[(ch - 1) % len(titles)] + f"({ch})"
+        location = LOCATIONS[(ch * 3 + ch // 7) % len(LOCATIONS)]
+        word_count = 2800 + int(math.sin(ch / 10) * 500) + random.randint(-200, 400)
+        chars = random.sample(CHARACTERS[:6], k=random.randint(1, 3))
+        if ch <= 5:
+            chars = ["lintian"]
+        summary = f"第{ch}章:林长青在{location}展开冒险。"
+
+        cursor.execute(
+            "INSERT INTO chapters VALUES (?,?,?,?,?,?)",
+            (ch, title, location, word_count, json.dumps(chars), summary),
+        )
+
+    # entities
+    cursor.execute("""
+        CREATE TABLE IF NOT EXISTS entities (
+            id TEXT PRIMARY KEY,
+            canonical_name TEXT,
+            type TEXT,
+            tier TEXT,
+            is_protagonist INTEGER DEFAULT 0,
+            is_archived INTEGER DEFAULT 0,
+            first_appearance INTEGER,
+            last_appearance INTEGER,
+            desc TEXT,
+            current_json TEXT
+        )
+    """)
+    for e in ENTITIES:
+        current_json = json.dumps({
+            "realm": "金丹中期" if e["id"] == "lintian" else "未知",
+            "location": LOCATIONS[hash(e["id"]) % len(LOCATIONS)],
+        }, ensure_ascii=False)
+        cursor.execute(
+            "INSERT INTO entities VALUES (?,?,?,?,?,0,?,?,?,?)",
+            (e["id"], e["canonical_name"], e["type"], e["tier"],
+             1 if e.get("is_protagonist") else 0,
+             e["first_appearance"], e["last_appearance"],
+             e.get("desc", ""), current_json),
+        )
+
+    # relationships
+    cursor.execute("""
+        CREATE TABLE IF NOT EXISTS relationships (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            from_entity TEXT,
+            to_entity TEXT,
+            type TEXT,
+            chapter INTEGER,
+            description TEXT
+        )
+    """)
+    for r in RELATIONSHIPS:
+        cursor.execute(
+            "INSERT INTO relationships (from_entity, to_entity, type, chapter, description) VALUES (?,?,?,?,?)",
+            (r["from_entity"], r["to_entity"], r["type"], r["chapter"], r["description"]),
+        )
+
+    # relationship_events
+    cursor.execute("""
+        CREATE TABLE IF NOT EXISTS relationship_events (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            from_entity TEXT,
+            to_entity TEXT,
+            chapter INTEGER,
+            event_type TEXT,
+            description TEXT
+        )
+    """)
+    for ev in RELATIONSHIP_EVENTS:
+        cursor.execute(
+            "INSERT INTO relationship_events (from_entity, to_entity, chapter, event_type, description) VALUES (?,?,?,?,?)",
+            (ev["from_entity"], ev["to_entity"], ev["chapter"], ev["event_type"], ev["description"]),
+        )
+
+    # state_changes
+    cursor.execute("""
+        CREATE TABLE IF NOT EXISTS state_changes (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            entity_id TEXT,
+            chapter INTEGER,
+            field TEXT,
+            old_value TEXT,
+            new_value TEXT
+        )
+    """)
+    state_changes = [
+        ("lintian", 1, "realm", "凡人", "练气一层"),
+        ("lintian", 15, "realm", "练气九层", "筑基初期"),
+        ("lintian", 40, "realm", "筑基大圆满", "金丹初期"),
+        ("lintian", 80, "realm", "金丹初期", "金丹中期"),
+        ("lintian", 25, "location", "青元宗", "青元秘境"),
+        ("lintian", 50, "location", "东海仙城", "黑市"),
+        ("lintian", 75, "location", "剑冢", "龙脉洞府"),
+        ("fenglinger", 8, "realm", "未知", "练气五层"),
+        ("fenglinger", 35, "realm", "练气九层", "筑基初期"),
+        ("fenglinger", 70, "realm", "筑基大圆满", "金丹初期"),
+        ("baiyujing", 25, "realm", "金丹初期", "金丹中期"),
+        ("baiyujing", 60, "realm", "金丹中期", "金丹大圆满"),
+        ("baiyujing", 90, "attitude", "敌意", "杀意"),
+        ("qingyun", 70, "loyalty", "太虚宗", "天魔教"),
+    ]
+    for entity_id, chapter, field, old_val, new_val in state_changes:
+        cursor.execute(
+            "INSERT INTO state_changes (entity_id, chapter, field, old_value, new_value) VALUES (?,?,?,?,?)",
+            (entity_id, chapter, field, old_val, new_val),
+        )
+
+    # aliases
+    cursor.execute("""
+        CREATE TABLE IF NOT EXISTS aliases (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            entity_id TEXT,
+            alias TEXT
+        )
+    """)
+    aliases = [
+        ("lintian", "长青"), ("lintian", "林师弟"),
+        ("fenglinger", "灵儿"), ("fenglinger", "凤师妹"),
+        ("baiyujing", "白少主"), ("laodaoshi", "无名老道"),
+    ]
+    for entity_id, alias in aliases:
+        cursor.execute("INSERT INTO aliases (entity_id, alias) VALUES (?,?)", (entity_id, alias))
+
+    # chapter_reading_power
+    cursor.execute("""
+        CREATE TABLE IF NOT EXISTS chapter_reading_power (
+            chapter INTEGER PRIMARY KEY,
+            hook_type TEXT,
+            hook_strength TEXT,
+            is_transition INTEGER DEFAULT 0,
+            override_count INTEGER DEFAULT 0,
+            debt_balance REAL DEFAULT 0.0,
+            coolpoint_patterns TEXT
+        )
+    """)
+    hook_types = ["悬念钩", "反转钩", "追杀钩", "情感钩", "秘密钩"]
+    for ch in range(1, TOTAL_CHAPTERS + 1):
+        strength = HOOK_STRENGTHS[min(2, max(0, int(math.sin(ch / 8) * 1.5 + 1.2)))]
+        is_transition = 1 if ch % 30 in (0, 1) else 0
+        override_count = random.randint(0, 2) if ch > 50 else 0
+        debt = round(random.uniform(-0.5, 2.0), 2) if ch > 30 else 0.0
+        cursor.execute(
+            "INSERT INTO chapter_reading_power VALUES (?,?,?,?,?,?,?)",
+            (ch, hook_types[ch % len(hook_types)], strength, is_transition,
+             override_count, debt, json.dumps([hook_types[ch % len(hook_types)]])),
+        )
+
+    # review_metrics
+    cursor.execute("""
+        CREATE TABLE IF NOT EXISTS review_metrics (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            start_chapter INTEGER,
+            end_chapter INTEGER,
+            overall_score REAL,
+            dimension_scores TEXT,
+            severity_counts TEXT,
+            critical_issues TEXT
+        )
+    """)
+    for ch in range(1, TOTAL_CHAPTERS + 1):
+        base = 65 + math.sin(ch / 15) * 12 + random.uniform(-5, 5)
+        score = round(min(98, max(50, base)), 1)
+        dims = json.dumps({"plot": round(score + random.uniform(-3, 3), 1),
+                           "character": round(score + random.uniform(-3, 3), 1),
+                           "pacing": round(score + random.uniform(-5, 5), 1)})
+        severity = json.dumps({"high": random.randint(0, 1),
+                               "medium": random.randint(0, 2),
+                               "low": random.randint(0, 3)})
+        critical = json.dumps([]) if score > 60 else json.dumps(["节奏过缓"])
+        cursor.execute(
+            "INSERT INTO review_metrics (start_chapter, end_chapter, overall_score, dimension_scores, severity_counts, critical_issues) VALUES (?,?,?,?,?,?)",
+            (ch, ch, score, dims, severity, critical),
+        )
+
+    # 扩展表 (空表,防止 _fetchall_safe 报错)
+    for table_sql in [
+        "CREATE TABLE IF NOT EXISTS override_contracts (id INTEGER PRIMARY KEY AUTOINCREMENT, chapter INTEGER, status TEXT, record_type TEXT, description TEXT)",
+        "CREATE TABLE IF NOT EXISTS chase_debt (id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT, updated_at TEXT)",
+        "CREATE TABLE IF NOT EXISTS debt_events (id INTEGER PRIMARY KEY AUTOINCREMENT, debt_id INTEGER, chapter INTEGER)",
+        "CREATE TABLE IF NOT EXISTS invalid_facts (id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT, marked_at TEXT)",
+        "CREATE TABLE IF NOT EXISTS rag_query_log (id INTEGER PRIMARY KEY AUTOINCREMENT, query_type TEXT, created_at TEXT)",
+        "CREATE TABLE IF NOT EXISTS tool_call_stats (id INTEGER PRIMARY KEY AUTOINCREMENT, tool_name TEXT, created_at TEXT)",
+        "CREATE TABLE IF NOT EXISTS writing_checklist_scores (id INTEGER PRIMARY KEY AUTOINCREMENT, chapter INTEGER)",
+        "CREATE TABLE IF NOT EXISTS story_events (id INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT, chapter INTEGER, event_type TEXT, subject TEXT, payload_json TEXT, created_at TEXT)",
+    ]:
+        cursor.execute(table_sql)
+
+    # scenes
+    cursor.execute("""
+        CREATE TABLE IF NOT EXISTS scenes (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            chapter INTEGER,
+            scene_index INTEGER,
+            content TEXT
+        )
+    """)
+
+    # vector db (模拟)
+    vector_db_path = project_root / ".webnovel" / "vectors.db"
+    with sqlite3.connect(str(vector_db_path)) as vconn:
+        vconn.execute("""
+            CREATE TABLE IF NOT EXISTS vectors (
+                chunk_id TEXT PRIMARY KEY,
+                chapter INTEGER,
+                scene_index INTEGER,
+                content TEXT,
+                embedding BLOB,
+                parent_chunk_id TEXT,
+                chunk_type TEXT,
+                source_file TEXT
+            )
+        """)
+        for ch in range(1, TOTAL_CHAPTERS + 1):
+            for scene in range(1, random.randint(2, 5)):
+                vconn.execute(
+                    "INSERT INTO vectors VALUES (?,?,?,?,?,?,?,?)",
+                    (f"ch{ch:04d}_s{scene}", ch, scene,
+                     f"第{ch}章场景{scene}内容片段",
+                     b"\x00" * 16, None, "scene", f"正文/第{ch:04d}章.md"),
+                )
+        vconn.commit()
+
+    conn.commit()
+    conn.close()
+
+
+def generate_story_system(project_root: Path) -> None:
+    story_root = project_root / ".story-system"
+    for subdir in ("chapters", "volumes", "reviews", "commits", "events"):
+        (story_root / subdir).mkdir(parents=True, exist_ok=True)
+
+    # MASTER_SETTING
+    (story_root / "MASTER_SETTING.json").write_text(json.dumps({
+        "meta": {"schema_version": "story-system/v1", "contract_type": "MASTER_SETTING"},
+        "route": {"primary_genre": "仙侠升级流"},
+        "master_constraints": {"core_tone": "先压后爆"},
+    }, ensure_ascii=False, indent=2), encoding="utf-8")
+
+    # Volumes
+    for vol, start, end in VOLUME_RANGES:
+        (story_root / "volumes" / f"volume_{vol:03d}.json").write_text(json.dumps({
+            "meta": {"contract_type": "VOLUME_BRIEF", "volume": vol},
+            "chapters_range": f"{start}-{end}",
+        }, ensure_ascii=False), encoding="utf-8")
+
+    # Chapter briefs + reviews + commits
+    random.seed(99)
+    for ch in range(max(1, TOTAL_CHAPTERS - 15), TOTAL_CHAPTERS + 1):
+        (story_root / "chapters" / f"chapter_{ch:03d}.json").write_text(json.dumps({
+            "meta": {"contract_type": "CHAPTER_BRIEF", "chapter": ch},
+        }, ensure_ascii=False), encoding="utf-8")
+
+        (story_root / "reviews" / f"chapter_{ch:03d}.review.json").write_text(json.dumps({
+            "meta": {"contract_type": "REVIEW_CONTRACT", "chapter": ch},
+        }, ensure_ascii=False), encoding="utf-8")
+
+        status = "accepted" if random.random() > 0.15 else "rejected"
+        proj_state = "done" if status == "accepted" else "skipped"
+        (story_root / "commits" / f"chapter_{ch:03d}.commit.json").write_text(json.dumps({
+            "meta": {"schema_version": "story-system/v1", "chapter": ch, "status": status},
+            "provenance": {"write_fact_role": "chapter_commit"},
+            "projection_status": {
+                "state": proj_state,
+                "index": proj_state,
+                "summary": proj_state,
+                "memory": proj_state,
+                "vector": proj_state,
+            },
+        }, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def generate_content_files(project_root: Path) -> None:
+    for folder in ("正文", "大纲", "设定集"):
+        (project_root / folder).mkdir(parents=True, exist_ok=True)
+
+    # 正文
+    for ch in range(1, TOTAL_CHAPTERS + 1):
+        vol = volume_for_chapter(ch)
+        vol_dir = project_root / "正文" / f"第{vol}卷"
+        vol_dir.mkdir(exist_ok=True)
+        content = f"# 第{ch}章\n\n林长青缓缓睁开双眼,灵力在经脉中流转不息。\n\n" + "这是模拟正文内容。\n" * 10
+        (vol_dir / f"第{ch:04d}章.md").write_text(content, encoding="utf-8")
+
+    # 大纲
+    (project_root / "大纲" / "总纲.md").write_text("# 仙道长青 总纲\n\n修仙世界,少年林长青踏上修仙之路……\n", encoding="utf-8")
+    for vol, _, _ in VOLUME_RANGES:
+        (project_root / "大纲" / f"第{vol}卷大纲.md").write_text(f"# 第{vol}卷大纲\n\n本卷主线情节……\n", encoding="utf-8")
+
+    # 设定集
+    (project_root / "设定集" / "修炼体系.md").write_text("# 修炼体系\n\n练气 → 筑基 → 金丹 → 元婴 → 化神\n", encoding="utf-8")
+    (project_root / "设定集" / "门派势力.md").write_text("# 门派势力\n\n太虚宗、天魔教、东海仙城……\n", encoding="utf-8")
+    (project_root / "设定集" / "法宝一览.md").write_text("# 法宝一览\n\n无极剑、天魔幡、青元镜……\n", encoding="utf-8")
+
+
+def generate_env(project_root: Path) -> None:
+    (project_root / ".env").write_text("\n".join([
+        "EMBED_BASE_URL=https://api.voyageai.com/v1",
+        "EMBED_MODEL=voyage-3-lite",
+        "EMBED_API_KEY=demo-embed-key-xxxxx",
+        "RERANK_BASE_URL=https://api.cohere.com/v2",
+        "RERANK_MODEL=rerank-v3.5",
+        "RERANK_API_KEY=demo-rerank-key-xxxxx",
+    ]), encoding="utf-8")
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+def main() -> None:
+    demo_root = Path(tempfile.mkdtemp(prefix="pixelwriter_demo_"))
+    print(f"[mock_demo] 生成仿真数据 → {demo_root}")
+
+    generate_state(demo_root)
+    generate_index_db(demo_root)
+    generate_story_system(demo_root)
+    generate_content_files(demo_root)
+    generate_env(demo_root)
+
+    print(f"[mock_demo] 数据就绪:")
+    print(f"  state.json  : {demo_root / '.webnovel' / 'state.json'}")
+    print(f"  index.db    : {demo_root / '.webnovel' / 'index.db'}")
+    print(f"  vectors.db  : {demo_root / '.webnovel' / 'vectors.db'}")
+    print(f"  .story-system/commits: {len(list((demo_root / '.story-system' / 'commits').glob('*.json')))} files")
+    print(f"  正文/        : {sum(1 for _ in (demo_root / '正文').rglob('*.md'))} chapters")
+    print()
+
+    # 启动
+    plugin_root = Path(__file__).resolve().parents[3]
+    scripts_dir = plugin_root / "scripts"
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+    if str(plugin_root) not in sys.path:
+        sys.path.insert(0, str(plugin_root))
+
+    from dashboard.app import create_app
+
+    app = create_app(demo_root)
+
+    import uvicorn
+
+    print("[mock_demo] 启动 Dashboard → http://localhost:8765")
+    print("[mock_demo] Ctrl+C 停止\n")
+
+    try:
+        uvicorn.run(app, host="0.0.0.0", port=8765, log_level="info")
+    finally:
+        print(f"\n[mock_demo] 清理临时目录: {demo_root}")
+        shutil.rmtree(demo_root, ignore_errors=True)
+
+
+if __name__ == "__main__":
+    main()

+ 381 - 0
webnovel-writer/scripts/data_modules/tests/test_dashboard_app.py

@@ -4,11 +4,320 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 import importlib
 import importlib
+import json
+import sqlite3
 import sys
 import sys
 from pathlib import Path
 from pathlib import Path
 
 
 from fastapi.testclient import TestClient
 from fastapi.testclient import TestClient
 
 
+from data_modules.config import DataModulesConfig
+from data_modules.index_manager import (
+    ChapterMeta,
+    ChapterReadingPowerMeta,
+    IndexManager,
+    ReviewMetrics,
+)
+
+
+def _create_dashboard_client(monkeypatch, project_root: Path) -> TestClient:
+    plugin_root = Path(__file__).resolve().parents[3]
+    scripts_dir = plugin_root / "scripts"
+
+    clean_path = []
+    scripts_resolved = scripts_dir.resolve()
+    for entry in sys.path:
+        try:
+            if Path(entry).resolve() == scripts_resolved:
+                continue
+        except Exception:
+            pass
+        clean_path.append(entry)
+
+    if str(plugin_root) not in clean_path:
+        clean_path.insert(0, str(plugin_root))
+
+    monkeypatch.setattr(sys, "path", clean_path)
+    for name in list(sys.modules):
+        if name == "dashboard.app" or name == "data_modules" or name.startswith("data_modules."):
+            sys.modules.pop(name, None)
+
+    module = importlib.import_module("dashboard.app")
+    app = module.create_app(project_root)
+    return TestClient(app)
+
+
+def _write_state(project_root: Path) -> None:
+    state = {
+        "project_info": {
+            "title": "像素写手测试书",
+            "genre": "玄幻",
+            "target_words": 1000000,
+            "target_chapters": 300,
+        },
+        "progress": {
+            "current_chapter": 3,
+            "current_volume": 2,
+            "total_words": 9300,
+            "volumes_planned": [
+                {"volume": 1, "chapters_range": "1-2"},
+                {"volume": 2, "chapters_range": "3-10"},
+            ],
+        },
+        "protagonist_state": {
+            "name": "林长青",
+            "power": {"realm": "筑基"},
+            "location": {"current": "青元宗"},
+        },
+        "strand_tracker": {
+            "current_dominant": "constellation",
+            "history": [
+                {"chapter": 1, "strand": "quest"},
+                {"chapter": 2, "strand": "fire"},
+                {"chapter": 3, "strand": "constellation"},
+            ],
+        },
+        "plot_threads": {
+            "foreshadowing": [
+                {
+                    "content": "青元秘境钥匙碎片",
+                    "status": "未回收",
+                    "tier": "核心",
+                    "planted_chapter": 1,
+                    "target_chapter": 2,
+                },
+                {
+                    "content": "凤灵儿真实身份",
+                    "status": "未回收",
+                    "tier": "支线",
+                    "planted_chapter": 2,
+                    "target_chapter": 6,
+                },
+                {
+                    "content": "第一卷残图",
+                    "status": "已回收",
+                    "tier": "装饰",
+                    "planted_chapter": 1,
+                    "target_chapter": 3,
+                    "resolved_chapter": 3,
+                },
+            ]
+        },
+        "chapter_meta": {
+            "0001": {"summary": "第一章概要"},
+            "0002": {"summary": "第二章概要"},
+            "0003": {"summary": "第三章概要"},
+        },
+    }
+    webnovel_dir = project_root / ".webnovel"
+    webnovel_dir.mkdir(parents=True, exist_ok=True)
+    (webnovel_dir / "state.json").write_text(
+        json.dumps(state, ensure_ascii=False, indent=2),
+        encoding="utf-8",
+    )
+
+
+def _build_project_data(project_root: Path) -> None:
+    cfg = DataModulesConfig.from_project_root(project_root)
+    cfg.ensure_dirs()
+    _write_state(project_root)
+
+    index = IndexManager(cfg)
+    index.add_chapter(
+        ChapterMeta(
+            chapter=1,
+            title="初入山门",
+            location="青元宗",
+            word_count=3000,
+            characters=["lintian"],
+            summary="第一章概要",
+        )
+    )
+    index.add_chapter(
+        ChapterMeta(
+            chapter=2,
+            title="秘境异动",
+            location="青元秘境",
+            word_count=3100,
+            characters=["lintian", "fenglinger"],
+            summary="第二章概要",
+        )
+    )
+    index.add_chapter(
+        ChapterMeta(
+            chapter=3,
+            title="夜探黑市",
+            location="黑市",
+            word_count=3200,
+            characters=["lintian", "heishifanzi"],
+            summary="第三章概要",
+        )
+    )
+
+    index.save_chapter_reading_power(
+        ChapterReadingPowerMeta(
+            chapter=1,
+            hook_type="悬念钩",
+            hook_strength="weak",
+            coolpoint_patterns=["身份伏笔"],
+        )
+    )
+    index.save_chapter_reading_power(
+        ChapterReadingPowerMeta(
+            chapter=2,
+            hook_type="反转钩",
+            hook_strength="medium",
+            coolpoint_patterns=["秘境反转"],
+        )
+    )
+    index.save_chapter_reading_power(
+        ChapterReadingPowerMeta(
+            chapter=3,
+            hook_type="追杀钩",
+            hook_strength="strong",
+            coolpoint_patterns=["黑市追杀"],
+        )
+    )
+
+    index.save_review_metrics(
+        ReviewMetrics(
+            start_chapter=1,
+            end_chapter=1,
+            overall_score=71,
+            severity_counts={"high": 1},
+        )
+    )
+    index.save_review_metrics(
+        ReviewMetrics(
+            start_chapter=2,
+            end_chapter=2,
+            overall_score=83,
+            severity_counts={"medium": 1},
+        )
+    )
+    index.save_review_metrics(
+        ReviewMetrics(
+            start_chapter=3,
+            end_chapter=3,
+            overall_score=88,
+            severity_counts={"low": 1},
+        )
+    )
+
+    story_root = project_root / ".story-system"
+    (story_root / "chapters").mkdir(parents=True, exist_ok=True)
+    (story_root / "volumes").mkdir(parents=True, exist_ok=True)
+    (story_root / "reviews").mkdir(parents=True, exist_ok=True)
+    (story_root / "commits").mkdir(parents=True, exist_ok=True)
+    (story_root / "MASTER_SETTING.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "contract_type": "MASTER_SETTING"},
+                "route": {"primary_genre": "玄幻升级流"},
+                "master_constraints": {"core_tone": "先压后爆"},
+            },
+            ensure_ascii=False,
+            indent=2,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "volumes" / "volume_001.json").write_text(
+        json.dumps({"meta": {"contract_type": "VOLUME_BRIEF", "volume": 1}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (story_root / "volumes" / "volume_002.json").write_text(
+        json.dumps({"meta": {"contract_type": "VOLUME_BRIEF", "volume": 2}}, ensure_ascii=False),
+        encoding="utf-8",
+    )
+    (story_root / "chapters" / "chapter_003.json").write_text(
+        json.dumps(
+            {"meta": {"contract_type": "CHAPTER_BRIEF", "chapter": 3}, "override_allowed": {"chapter_focus": "夜探黑市"}},
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "reviews" / "chapter_003.review.json").write_text(
+        json.dumps(
+            {"meta": {"contract_type": "REVIEW_CONTRACT", "chapter": 3}, "must_check": ["黑市冲突"]},
+            ensure_ascii=False,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "commits" / "chapter_002.commit.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "chapter": 2, "status": "accepted"},
+                "provenance": {"write_fact_role": "chapter_commit"},
+                "projection_status": {
+                    "state": "done",
+                    "index": "done",
+                    "summary": "done",
+                    "memory": "done",
+                    "vector": "done",
+                },
+            },
+            ensure_ascii=False,
+            indent=2,
+        ),
+        encoding="utf-8",
+    )
+    (story_root / "commits" / "chapter_003.commit.json").write_text(
+        json.dumps(
+            {
+                "meta": {"schema_version": "story-system/v1", "chapter": 3, "status": "rejected"},
+                "provenance": {"write_fact_role": "chapter_commit"},
+                "projection_status": {
+                    "state": "skipped",
+                    "index": "skipped",
+                    "summary": "skipped",
+                    "memory": "skipped",
+                    "vector": "skipped",
+                },
+            },
+            ensure_ascii=False,
+            indent=2,
+        ),
+        encoding="utf-8",
+    )
+
+    vector_db = cfg.vector_db
+    with sqlite3.connect(vector_db) as conn:
+        conn.execute(
+            """
+            CREATE TABLE vectors (
+                chunk_id TEXT PRIMARY KEY,
+                chapter INTEGER,
+                scene_index INTEGER,
+                content TEXT,
+                embedding BLOB,
+                parent_chunk_id TEXT,
+                chunk_type TEXT,
+                source_file TEXT
+            )
+            """
+        )
+        conn.execute(
+            """
+            INSERT INTO vectors (chunk_id, chapter, scene_index, content, embedding, parent_chunk_id, chunk_type, source_file)
+            VALUES ('ch0003_s1', 3, 1, '黑市线索', X'00', NULL, 'scene', '正文/第0003章.md')
+            """
+        )
+        conn.commit()
+
+    (project_root / ".env").write_text(
+        "\n".join(
+            [
+                "EMBED_BASE_URL=https://embed.example.com/v1",
+                "EMBED_MODEL=test-embed",
+                "EMBED_API_KEY=embed-key",
+                "RERANK_BASE_URL=https://rerank.example.com/v1",
+                "RERANK_MODEL=test-rerank",
+                "RERANK_API_KEY=rerank-key",
+            ]
+        ),
+        encoding="utf-8",
+    )
+
 
 
 def test_dashboard_app_imports_without_scripts_path(monkeypatch, tmp_path):
 def test_dashboard_app_imports_without_scripts_path(monkeypatch, tmp_path):
     plugin_root = Path(__file__).resolve().parents[3]
     plugin_root = Path(__file__).resolve().parents[3]
@@ -42,3 +351,75 @@ def test_dashboard_app_imports_without_scripts_path(monkeypatch, tmp_path):
 
 
     response = client.get("/api/story-runtime/health")
     response = client.get("/api/story-runtime/health")
     assert response.status_code == 200
     assert response.status_code == 200
+
+
+def test_dashboard_chapter_trend_endpoint_returns_recent_window(monkeypatch, tmp_path):
+    project_root = tmp_path / "book"
+    _build_project_data(project_root)
+    client = _create_dashboard_client(monkeypatch, project_root)
+
+    response = client.get("/api/stats/chapter-trend", params={"limit": 2})
+
+    assert response.status_code == 200
+    payload = response.json()
+    assert payload["total"] == 3
+    assert payload["latest_chapter"] == 3
+    assert [item["chapter"] for item in payload["items"]] == [2, 3]
+    assert payload["items"][0]["review_score"] == 83
+    assert payload["items"][0]["hook_strength"] == "medium"
+    assert payload["items"][0]["hook_strength_value"] == 3
+    assert payload["items"][0]["strand"] == "fire"
+    assert payload["items"][0]["volume"] == 1
+    assert payload["items"][1]["volume"] == 2
+
+
+def test_dashboard_commits_and_contract_summary_endpoints(monkeypatch, tmp_path):
+    project_root = tmp_path / "book"
+    _build_project_data(project_root)
+    client = _create_dashboard_client(monkeypatch, project_root)
+
+    commits_response = client.get("/api/commits", params={"limit": 2})
+    assert commits_response.status_code == 200
+    commits_payload = commits_response.json()
+    assert [item["chapter"] for item in commits_payload["items"]] == [3, 2]
+    assert commits_payload["items"][0]["status"] == "rejected"
+    assert commits_payload["items"][1]["projection_status"]["vector"] == "done"
+
+    contracts_response = client.get("/api/contracts/summary")
+    assert contracts_response.status_code == 200
+    contracts_payload = contracts_response.json()
+    assert contracts_payload["chapter"] == 3
+    assert contracts_payload["current_volume"] == 2
+    assert contracts_payload["master"]["primary_genre"] == "玄幻升级流"
+    assert contracts_payload["master"]["core_tone"] == "先压后爆"
+    assert contracts_payload["counts"]["volumes"] == 2
+    assert contracts_payload["counts"]["chapters"] == 1
+    assert contracts_payload["counts"]["reviews"] == 1
+    assert contracts_payload["counts"]["commits"] == 2
+    assert contracts_payload["current_contracts"]["chapter"] is True
+    assert contracts_payload["current_contracts"]["review"] is True
+    assert contracts_payload["current_contracts"]["commit"] is True
+
+
+def test_dashboard_env_status_endpoints_report_local_rag_state(monkeypatch, tmp_path):
+    project_root = tmp_path / "book"
+    _build_project_data(project_root)
+    client = _create_dashboard_client(monkeypatch, project_root)
+
+    status_response = client.get("/api/env-status")
+    assert status_response.status_code == 200
+    status_payload = status_response.json()
+    assert status_payload["embed"]["api_key_present"] is True
+    assert status_payload["rerank"]["api_key_present"] is True
+    assert status_payload["vector_db"]["exists"] is True
+    assert status_payload["vector_db"]["record_count"] == 1
+    assert status_payload["rag_mode"] == "full"
+
+    probe_response = client.get("/api/env-status/probe")
+    assert probe_response.status_code == 200
+    probe_payload = probe_response.json()
+    assert probe_payload["ok"] is True
+    check_names = [item["name"] for item in probe_payload["checks"]]
+    assert "embed_api_key" in check_names
+    assert "rerank_api_key" in check_names
+    assert "vector_db" in check_names

Некоторые файлы не были показаны из-за большого количества измененных файлов