Răsfoiți Sursa

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 luni în urmă
părinte
comite
a033f36d4f
54 a modificat fișierele cu 9543 adăugiri și 1787 ștergeri
  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-subtitle">init → plan → write 六层主链 | 全读写映射</div>
   <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>动态表(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 class="arch-layer ai">
         <div class="arch-layer-title">🧠 Layer 2: 裁决层(Reasoning)— story_system_engine.py</div>
         <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">_apply_reasoning()<br><small>冲突裁决排序<br>毒点加权<br>反模式注入</small></div>
         </div>
@@ -122,7 +158,7 @@
           <div class="arch-subgroup-box">
             <div class="arch-subgroup-title">写后真源(chapter-commit)</div>
             <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>
           </div>
@@ -134,7 +170,7 @@
           <div class="arch-subgroup-box">
             <div class="arch-subgroup-title">context-agent(Step 1 子代理)</div>
             <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">按需深查<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>
@@ -143,7 +179,7 @@
           <div class="arch-subgroup-box">
             <div class="arch-subgroup-title">辅助查询</div>
             <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">reference_search<br><small>CSV BM25 检索<br>per-table search_cols</small></div>
             </div>
@@ -166,7 +202,7 @@
             <div class="arch-subgroup-title">chapter-commit CLI(Step 5.2)</div>
             <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 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>
           </div>
@@ -175,11 +211,11 @@
       <div class="arch-layer infra">
         <div class="arch-layer-title">💿 Layer 6: 投影层(Projection)— EventProjectionRouter → 5 Writers</div>
         <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">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">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>
@@ -217,10 +253,12 @@
       <div class="arch-sidebar-panel">
         <div class="arch-sidebar-title">🛡️ 写入保护</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">data-agent 不直写存储</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。
 
-**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`(待更新)
 
@@ -82,9 +82,11 @@ src/
 - Modify: `dashboard/frontend/src/api.js`
 - Create: `dashboard/frontend/src/components/*.jsx`
 
-- [ ] **Step 1: 安装依赖(路由 + 图表库 + 关系图库)**
+- [ ] **Step 1: 安装依赖**
 
-具体图表库待设计确认后决定。
+```bash
+npm install echarts echarts-for-react react-router-dom pixelarticons
+```
 
 - [ ] **Step 2: 提取公共组件**
 
@@ -117,8 +119,8 @@ Pager 组件要点:
 
 **功能:**
 - 统计卡:总字数/进度、当前章节/卷、Story Runtime 状态、审查均分、紧急伏笔数
-- 审查得分可视化:支持翻页浏览(默认最近 N 章)——具体图表形式待定
-- 字数分布可视化:按卷分组——具体图表形式待定
+- 审查得分可视化:ECharts 折线图,支持翻页浏览(默认最近 N 章)
+- 字数分布可视化:ECharts 柱状图,按卷分组
 - Strand Weave 整体分布
 - 紧急伏笔 Top 5 表格
 - 最近 3 章概要卡片
@@ -142,7 +144,13 @@ Pager 组件要点:
 
 **功能:**
 - Tab 1 列表视图:实体列表 + 筛选 + 详情面板 + 状态变化历史(保留现有逻辑)
-- Tab 2 关系图谱:替换 3D 为 2D 关系图
+- Tab 2 关系图谱:ECharts graph series(力导向布局)+ 章节时间轴滑块
+
+关系图谱时间轴要点:
+- 滑块控制当前章节,节点/边按 `first_appearance` / `chapter` 过滤
+- 关系标签支持随章节变化(如"初识"→"宿敌")
+- 播放/暂停按钮,自动推进章节
+- 显示当前章节号 + 当前可见节点数
 
 - [ ] **Step 1: 实现 + Commit**
 
@@ -154,9 +162,9 @@ Pager 组件要点:
 - 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 sqlite3
 import sys
+from datetime import datetime, timezone
 from contextlib import asynccontextmanager, closing
 from pathlib import Path
 from typing import Optional
@@ -44,14 +45,176 @@ def _story_system_dir() -> Path:
 
 
 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_entry = str(scripts_dir)
     if scripts_entry not in sys.path:
         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:
         _project_root = Path(project_root).resolve()
 
+    _ensure_scripts_dir_on_path()
+
     @asynccontextmanager
     async def _lifespan(_: FastAPI):
         webnovel = _webnovel_dir()
@@ -95,10 +260,7 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
     @app.get("/api/project/info")
     def project_info():
         """返回 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")
     def story_runtime_health():
@@ -201,7 +363,12 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
     def list_chapters():
         with closing(_get_db()) as conn:
             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")
     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(
                 "SELECT * FROM review_metrics ORDER BY end_chapter DESC LIMIT ?", (limit,)
             ).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")
     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}")
         def serve_spa(full_path: str):
             """SPA fallback:任何非 /api 路径都返回 index.html。"""
+            if full_path.startswith("api/"):
+                raise HTTPException(404, "API 路径不存在")
             index = STATIC_DIR / "index.html"
             if index.is_file():
                 return FileResponse(str(index))

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

@@ -1,6 +1,7 @@
 # PIXEL WRITER HUB 设计规范
 
 > Dashboard 前端设计规范,所有页面必须遵守。
+> 原型预览:`docs/architecture/dashboard-prototype.html`
 
 ## 视觉风格:复古像素 / 8-bit 游戏
 
@@ -11,6 +12,7 @@
 | 变量 | 色值 | 用途 |
 |------|------|------|
 | `--bg-main` | `#fff7e8` | 页面背景(带 14px 网格线) |
+| `--bg-panel` | `#fffdf6` | 表格/面板内背景 |
 | `--bg-card` | `#fffaf0` | 卡片背景 |
 | `--bg-card-2` | `#fff3d5` | 表头、次级卡片 |
 | `--text-main` | `#2a220f` | 主文字 |
@@ -22,12 +24,38 @@
 | `--accent-amber` | `#f5a524` | 警告(紧急伏笔、中等分数) |
 | `--accent-red` | `#d7263d` | 危险(blocking、超期) |
 | `--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
 - **正文/数据**:`Noto Sans SC`,14px,font-weight 500-700
 - **数字**:tabular-nums(等宽数字)
+- **图例/小标签**:`Noto Sans SC` 13px,font-weight 600
 
 ## 边框与阴影
 
@@ -40,23 +68,161 @@
 
 **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`。
 
 **按钮/导航**:`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` 右边框
 - 主区域可滚动,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
 - 不用 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};

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/CharactersPage-n6sZAtwm.js


Fișier diff suprimat deoarece este prea mare
+ 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};

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/FilesPage-Czd1SC2y.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/ForeshadowingPage-BeSmFV1Z.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/OverviewPage-vgewJiV1.js


Fișier diff suprimat deoarece este prea mare
+ 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};

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/SystemPage-CRCMgVw-.js


Fișier diff suprimat deoarece este prea mare
+ 13 - 0
webnovel-writer/dashboard/frontend/dist/assets/echarts-vendor-DOu6vfXz.js


Fișier diff suprimat deoarece este prea mare
+ 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};

Fișier diff suprimat deoarece este prea mare
+ 0 - 16
webnovel-writer/dashboard/frontend/dist/assets/index-BeHSak5z.js


Fișier diff suprimat deoarece este prea mare
+ 1 - 0
webnovel-writer/dashboard/frontend/dist/assets/index-Dyazi077.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/index-R26PxixS.css


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/index-qVwzETG1.css


Fișier diff suprimat deoarece este prea mare
+ 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.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" />
-    <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>
   <body>
     <div id="root"></div>


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

@@ -8,9 +8,11 @@
       "name": "webnovel-dashboard",
       "version": "0.1.0",
       "dependencies": {
+        "echarts": "^5.6.0",
+        "echarts-for-react": "^3.0.2",
         "react": "^19.0.0",
         "react-dom": "^19.0.0",
-        "react-force-graph-3d": "^1.29.1"
+        "react-router-dom": "^7.0.0"
       },
       "devDependencies": {
         "@types/react": "^19.0.0",
@@ -253,15 +255,6 @@
         "@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": {
       "version": "7.28.6",
       "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
@@ -1159,12 +1152,6 @@
         "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": {
       "version": "7.20.5",
       "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"
       }
     },
-    "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": {
       "version": "2.10.0",
       "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@@ -1358,184 +1320,26 @@
       "dev": true,
       "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",
-      "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": {
-        "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"
     },
-    "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": {
       "version": "4.4.3",
       "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": {
       "version": "1.5.302",
       "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
@@ -1613,6 +1441,12 @@
         "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": {
       "version": "6.5.0",
       "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": {
       "version": "2.3.3",
       "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@@ -1670,28 +1490,11 @@
         "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": {
       "version": "4.0.0",
       "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
       "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+      "dev": true,
       "license": "MIT"
     },
     "node_modules/jsesc": {
@@ -1720,36 +1523,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": {
       "version": "5.1.1",
       "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_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": {
       "version": "2.0.27",
       "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz",
@@ -1831,15 +1566,6 @@
       "dev": true,
       "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": {
       "version": "1.1.1",
       "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -1860,18 +1586,6 @@
         "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": {
       "version": "8.5.8",
       "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz",
@@ -1901,27 +1615,6 @@
         "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": {
       "version": "19.2.4",
       "resolved": "https://registry.npmmirror.com/react/-/react-19.2.4.tgz",
@@ -1943,52 +1636,52 @@
         "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",
-      "dependencies": {
-        "3d-force-graph": "^1.79",
-        "prop-types": "15",
-        "react-kapsule": "^2.5"
-      },
       "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",
       "dependencies": {
-        "jerrypick": "^1.1.1"
+        "cookie": "^1.0.1",
+        "set-cookie-parser": "^2.6.0"
       },
       "engines": {
-        "node": ">=12"
+        "node": ">=20.0.0"
       },
       "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",
+      "dependencies": {
+        "react-router": "7.14.1"
+      },
       "engines": {
-        "node": ">=0.10.0"
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=18",
+        "react-dom": ">=18"
       }
     },
     "node_modules/rollup": {
@@ -2052,6 +1745,18 @@
         "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": {
       "version": "1.2.1",
       "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2062,61 +1767,6 @@
         "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": {
       "version": "0.2.15",
       "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2134,6 +1784,12 @@
         "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": {
       "version": "1.2.3",
       "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -2246,6 +1902,15 @@
       "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
       "dev": true,
       "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"
   },
   "dependencies": {
+    "echarts": "^5.6.0",
+    "echarts-for-react": "^3.0.2",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
-    "react-force-graph-3d": "^1.29.1"
+    "react-router-dom": "^7.0.0"
   },
   "devDependencies": {
     "@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() {
-    const [page, setPage] = useState('dashboard')
     const [projectInfo, setProjectInfo] = useState(null)
-    const [refreshKey, setRefreshKey] = useState(0)
+    const [refreshToken, setRefreshToken] = useState(0)
     const [connected, setConnected] = useState(false)
 
     const loadProjectInfo = useCallback(() => {
-        fetchJSON('/api/project/info')
+        fetchProjectInfo()
             .then(setProjectInfo)
             .catch(() => setProjectInfo(null))
     }, [])
 
-    useEffect(() => { loadProjectInfo() }, [loadProjectInfo, refreshKey])
+    useEffect(() => {
+        loadProjectInfo()
+    }, [loadProjectInfo, refreshToken])
 
-    // SSE 订阅
     useEffect(() => {
-        const unsub = subscribeSSE(
+        const unsubscribe = subscribeSSE(
             () => {
-                setRefreshKey(k => k + 1)
+                startTransition(() => {
+                    setRefreshToken(current => current + 1)
+                })
             },
             {
                 onOpen: () => setConnected(true),
                 onError: () => setConnected(false),
             },
         )
-        return () => { unsub(); setConnected(false) }
+
+        return () => {
+            unsubscribe()
+            setConnected(false)
+        }
     }, [])
 
-    const title = projectInfo?.project_info?.title || '未加载'
+    const title = projectInfo?.project_info?.title || '未加载项目'
 
     return (
         <div className="app-layout">
             <aside className="sidebar">
                 <div className="sidebar-header">
                     <h1>PIXEL WRITER HUB</h1>
-                    <div className="subtitle">{title}</div>
+                    <div className="subtitle" title={title}>{title}</div>
                 </div>
                 <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>
                 <div className="live-indicator">
-                    <span className={`live-dot ${connected ? '' : 'disconnected'}`} />
-                    {connected ? '实时同步中' : '未连接'}
+                    <span className="icon">
+                        {connected ? <WifiIcon /> : <WifiOffIcon />}
+                    </span>
+                    {connected ? '实时同步中' : '实时连接断开'}
                 </div>
             </aside>
 
             <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>
         </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 = {}) {
-    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() {
-    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 = {}) {
     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()
-    };
-    es.onmessage = (e) => {
+    }
+
+    eventSource.onmessage = event => {
         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;
   padding: 0;
-  box-sizing: border-box;
 }
 
 *:focus-visible {
@@ -47,20 +47,34 @@ body {
     linear-gradient(90deg, rgba(42, 34, 15, 0.05) 1px, transparent 1px),
     linear-gradient(rgba(42, 34, 15, 0.05) 1px, transparent 1px);
   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 {
   display: grid;
   grid-template-columns: 240px minmax(0, 1fr);
-  height: 100vh;
+  min-height: 100vh;
 }
 
 .sidebar {
-  border-right: 3px solid var(--border-main);
-  background: linear-gradient(180deg, #ffe8b8 0%, #ffe19f 100%);
   display: flex;
   flex-direction: column;
   min-height: 0;
+  border-right: 3px solid var(--border-main);
+  background: linear-gradient(180deg, #ffe8b8 0%, #ffe19f 100%);
 }
 
 .sidebar-header {
@@ -77,38 +91,35 @@ body {
 
 .sidebar-header .subtitle {
   margin-top: 10px;
-  font-size: 14px;
-  font-weight: 500;
-  color: var(--text-sub);
-  white-space: nowrap;
   overflow: hidden;
+  color: var(--text-sub);
   text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
 .sidebar-nav {
-  flex: 1;
-  overflow-y: auto;
-  padding: 10px;
   display: flex;
+  flex: 1;
   flex-direction: column;
   gap: 8px;
+  overflow-y: auto;
+  padding: 10px;
 }
 
 .nav-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
   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 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 {
@@ -121,119 +132,184 @@ body {
 }
 
 .nav-item .icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
   width: 22px;
-  text-align: center;
+}
+
+.pixel-icon {
+  width: 20px;
+  height: 20px;
 }
 
 .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);
-}
-
-.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 {
-  overflow-y: auto;
   min-width: 0;
+  overflow-y: auto;
   padding: 22px;
 }
 
+.dashboard-page {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+
 .page-header {
   display: flex;
   align-items: center;
-  gap: 12px;
   flex-wrap: wrap;
-  margin-bottom: 14px;
+  gap: 12px;
 }
 
 .page-header h2 {
   font-size: 22px;
-  line-height: 1.2;
   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 {
-  background: var(--bg-card);
   border: 3px solid var(--border-main);
+  background: var(--bg-card);
   box-shadow: var(--shadow-main);
+}
+
+.loading-card {
+  min-width: min(420px, 100%);
+  padding: 18px;
+}
+
+.card {
   padding: 16px;
-  margin-bottom: 16px;
 }
 
 .card-header {
   display: flex;
-  align-items: center;
+  align-items: flex-start;
   justify-content: space-between;
   gap: 12px;
-  margin-bottom: 10px;
+  margin-bottom: 12px;
+}
+
+.compact-header {
+  margin-top: 10px;
 }
 
 .card-title {
   font-size: 17px;
   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);
+  padding: 3px 8px;
   font-size: 12px;
   font-weight: 700;
-  padding: 3px 8px;
   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;
   grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
   gap: 12px;
-  margin-bottom: 14px;
 }
 
 .stat-card .stat-label {
+  color: var(--text-mute);
   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-size: 28px;
+  font-variant-numeric: tabular-nums;
+  line-height: 1.12;
 }
 
 .stat-card .stat-value.plain {
   color: var(--text-main);
 }
 
-.stat-sub {
+.stat-card .stat-sub {
+  color: var(--text-sub);
   font-size: 13px;
   font-weight: 500;
-  color: var(--text-sub);
 }
 
 .progress-track {
@@ -248,75 +324,26 @@ body {
   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;
-  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 {
@@ -329,18 +356,16 @@ body {
   width: 100%;
   min-width: 580px;
   border-collapse: collapse;
-  font-size: 14px;
-  font-family: var(--font-body);
   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;
+  text-align: left;
+  white-space: nowrap;
 }
 
 .data-table td {
@@ -348,396 +373,449 @@ body {
   border-bottom: 1px solid #d8ccb2;
   color: var(--text-main);
   font-weight: 500;
+  vertical-align: top;
 }
 
 .data-table tbody tr:hover td {
   background: #fff4d8;
 }
 
-.table-foot-note {
-  margin-top: 8px;
-}
-
-.table-pagination {
+.table-pagination,
+.pager {
   display: flex;
   align-items: center;
   justify-content: space-between;
   gap: 10px;
-  margin-top: 8px;
   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);
   background: #fff8e6;
   color: var(--text-main);
-  font-family: var(--font-body);
-  font-size: 13px;
-  font-weight: 600;
-  padding: 4px 10px;
   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;
   border-color: var(--accent-blue);
 }
 
 .page-btn:disabled {
-  opacity: 0.5;
   cursor: not-allowed;
+  opacity: 0.5;
 }
 
 .page-info {
+  color: var(--text-sub);
   font-size: 13px;
   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);
+  text-align: center;
+  padding: 18px;
 }
 
-.split-layout {
+.empty-state.compact {
+  min-height: 90px;
+}
+
+.summary-card-list {
   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);
-  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;
-  white-space: nowrap;
 }
 
-.chapter-characters {
-  max-width: 220px;
+.entity-name.protagonist::after {
+  content: ' ★';
+  color: var(--accent-amber);
 }
 
-.file-layout {
+.entity-detail {
   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;
 }
 
-.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);
+  font-size: 13px;
   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 {
   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;
-  flex-direction: column;
   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;
   text-overflow: ellipsis;
   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 {
-    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 {
-    justify-content: center;
-    padding: 12px 8px;
+    min-width: 128px;
   }
 
   .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;
-    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 { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
 import App from './App.jsx'
 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(
     <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>,
 )

+ 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: {
     outDir: 'dist',
     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
 
 import importlib
+import json
+import sqlite3
 import sys
 from pathlib import Path
 
 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):
     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")
     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

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff