瀏覽代碼

feat: enhance dashboard data views and responsive pixel UI

lingfengQAQ 3 月之前
父節點
當前提交
052ce375b1

+ 113 - 0
webnovel-writer/dashboard/app.py

@@ -91,6 +91,16 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
         conn.row_factory = sqlite3.Row
         return conn
 
+    def _fetchall_safe(conn: sqlite3.Connection, query: str, params: tuple = ()) -> list[dict]:
+        """执行只读查询;若目标表不存在(旧库),返回空列表。"""
+        try:
+            rows = conn.execute(query, params).fetchall()
+            return [dict(r) for r in rows]
+        except sqlite3.OperationalError as exc:
+            if "no such table" in str(exc).lower():
+                return []
+            raise HTTPException(status_code=500, detail=f"数据库查询失败: {exc}") from exc
+
     @app.get("/api/entities")
     def list_entities(
         entity_type: Optional[str] = Query(None, alias="type"),
@@ -222,6 +232,109 @@ def create_app(project_root: str | Path | None = None) -> FastAPI:
                 rows = conn.execute("SELECT * FROM aliases").fetchall()
             return [dict(r) for r in rows]
 
+    # ===========================================================
+    # API:扩展表(v5.3+ / v5.4+)
+    # ===========================================================
+
+    @app.get("/api/overrides")
+    def list_overrides(status: Optional[str] = None, limit: int = 100):
+        with closing(_get_db()) as conn:
+            if status:
+                return _fetchall_safe(
+                    conn,
+                    "SELECT * FROM override_contracts WHERE status = ? ORDER BY chapter DESC LIMIT ?",
+                    (status, limit),
+                )
+            return _fetchall_safe(
+                conn,
+                "SELECT * FROM override_contracts ORDER BY chapter DESC LIMIT ?",
+                (limit,),
+            )
+
+    @app.get("/api/debts")
+    def list_debts(status: Optional[str] = None, limit: int = 100):
+        with closing(_get_db()) as conn:
+            if status:
+                return _fetchall_safe(
+                    conn,
+                    "SELECT * FROM chase_debt WHERE status = ? ORDER BY updated_at DESC LIMIT ?",
+                    (status, limit),
+                )
+            return _fetchall_safe(
+                conn,
+                "SELECT * FROM chase_debt ORDER BY updated_at DESC LIMIT ?",
+                (limit,),
+            )
+
+    @app.get("/api/debt-events")
+    def list_debt_events(debt_id: Optional[int] = None, limit: int = 200):
+        with closing(_get_db()) as conn:
+            if debt_id is not None:
+                return _fetchall_safe(
+                    conn,
+                    "SELECT * FROM debt_events WHERE debt_id = ? ORDER BY chapter DESC, id DESC LIMIT ?",
+                    (debt_id, limit),
+                )
+            return _fetchall_safe(
+                conn,
+                "SELECT * FROM debt_events ORDER BY chapter DESC, id DESC LIMIT ?",
+                (limit,),
+            )
+
+    @app.get("/api/invalid-facts")
+    def list_invalid_facts(status: Optional[str] = None, limit: int = 100):
+        with closing(_get_db()) as conn:
+            if status:
+                return _fetchall_safe(
+                    conn,
+                    "SELECT * FROM invalid_facts WHERE status = ? ORDER BY marked_at DESC LIMIT ?",
+                    (status, limit),
+                )
+            return _fetchall_safe(
+                conn,
+                "SELECT * FROM invalid_facts ORDER BY marked_at DESC LIMIT ?",
+                (limit,),
+            )
+
+    @app.get("/api/rag-queries")
+    def list_rag_queries(query_type: Optional[str] = None, limit: int = 100):
+        with closing(_get_db()) as conn:
+            if query_type:
+                return _fetchall_safe(
+                    conn,
+                    "SELECT * FROM rag_query_log WHERE query_type = ? ORDER BY created_at DESC LIMIT ?",
+                    (query_type, limit),
+                )
+            return _fetchall_safe(
+                conn,
+                "SELECT * FROM rag_query_log ORDER BY created_at DESC LIMIT ?",
+                (limit,),
+            )
+
+    @app.get("/api/tool-stats")
+    def list_tool_stats(tool_name: Optional[str] = None, limit: int = 200):
+        with closing(_get_db()) as conn:
+            if tool_name:
+                return _fetchall_safe(
+                    conn,
+                    "SELECT * FROM tool_call_stats WHERE tool_name = ? ORDER BY created_at DESC LIMIT ?",
+                    (tool_name, limit),
+                )
+            return _fetchall_safe(
+                conn,
+                "SELECT * FROM tool_call_stats ORDER BY created_at DESC LIMIT ?",
+                (limit,),
+            )
+
+    @app.get("/api/checklist-scores")
+    def list_checklist_scores(limit: int = 100):
+        with closing(_get_db()) as conn:
+            return _fetchall_safe(
+                conn,
+                "SELECT * FROM writing_checklist_scores ORDER BY chapter DESC LIMIT ?",
+                (limit,),
+            )
+
     # ===========================================================
     # API:文档浏览(正文/大纲/设定集 —— 只读)
     # ===========================================================

File diff suppressed because it is too large
+ 2 - 2
webnovel-writer/dashboard/frontend/dist/assets/index-BBNQa2sX.js


File diff suppressed because it is too large
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/index-Cw6rJgHT.css


File diff suppressed because it is too large
+ 0 - 0
webnovel-writer/dashboard/frontend/dist/assets/index-qVwzETG1.css


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

@@ -8,8 +8,8 @@
     <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-B2cLSGXr.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Cw6rJgHT.css">
+    <script type="module" crossorigin src="/assets/index-BBNQa2sX.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-qVwzETG1.css">
   </head>
   <body>
     <div id="root"></div>


+ 414 - 102
webnovel-writer/dashboard/frontend/src/App.jsx

@@ -40,7 +40,7 @@ export default function App() {
         <div className="app-layout">
             <aside className="sidebar">
                 <div className="sidebar-header">
-                    <h1>📖 Dashboard</h1>
+                    <h1>PIXEL WRITER HUB</h1>
                     <div className="subtitle">{title}</div>
                 </div>
                 <nav className="sidebar-nav">
@@ -82,6 +82,33 @@ const NAV_ITEMS = [
     { 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:数据总览
@@ -153,17 +180,17 @@ function DashboardPage({ data }) {
             </div>
 
             {/* Strand Weave 比例 */}
-            <div className="card" style={{ marginBottom: 20 }}>
+            <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" style={{ marginBottom: 14 }}>
+                <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 style={{ display: 'flex', gap: 24, fontSize: 13, color: 'var(--text-secondary)' }}>
+                <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>
@@ -172,24 +199,28 @@ function DashboardPage({ data }) {
 
             {/* 伏笔列表 */}
             {unresolvedForeshadow.length > 0 ? (
-                <div className="card">
+                <div className="card dashboard-section-card">
                     <div className="card-header">
                         <span className="card-title">⚠️ 待回收伏笔 (Top 20)</span>
                     </div>
-                    <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 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 />
         </>
     )
 }
@@ -206,8 +237,8 @@ function EntitiesPage() {
     const [changes, setChanges] = useState([])
 
     useEffect(() => {
-        fetchJSON('/api/entities', typeFilter ? { type: typeFilter } : {}).then(setEntities).catch(() => { })
-    }, [typeFilter])
+        fetchJSON('/api/entities').then(setEntities).catch(() => { })
+    }, [])
 
     useEffect(() => {
         if (selected) {
@@ -216,12 +247,13 @@ function EntitiesPage() {
     }, [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">{entities.length} 个实体</span>
+                <span className="card-badge badge-green">{filteredEntities.length} / {entities.length} 个实体</span>
             </div>
 
             <div className="filter-group">
@@ -231,70 +263,74 @@ function EntitiesPage() {
                 ))}
             </div>
 
-            <div style={{ display: 'flex', gap: 20 }}>
-                <div style={{ flex: 1 }}>
+            <div className="split-layout">
+                <div className="split-main">
                     <div className="card">
-                        <table className="data-table">
-                            <thead><tr><th>名称</th><th>类型</th><th>层级</th><th>首现</th><th>末现</th></tr></thead>
-                            <tbody>
-                                {entities.map(e => (
-                                    <tr
-                                        key={e.id}
-                                        role="button"
-                                        tabIndex={0}
-                                        onKeyDown={evt => (evt.key === 'Enter' || evt.key === ' ') && (evt.preventDefault(), setSelected(e))}
-                                        onClick={() => setSelected(e)}
-                                        style={{ cursor: 'pointer', background: selected?.id === e.id ? 'var(--bg-card-hover)' : undefined }}
-                                    >
-                                        <td style={{ fontWeight: 600, color: e.is_protagonist ? 'var(--accent-amber)' : 'var(--text-primary)' }}>
-                                            {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 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 style={{ width: 360, minWidth: 320 }}>
+                    <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 style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.8 }}>
+                            <div className="entity-detail">
                                 <p><strong>类型:</strong>{selected.type}</p>
-                                <p><strong>ID:</strong><code style={{ fontSize: 12, color: 'var(--text-muted)' }}>{selected.id}</code></p>
-                                {selected.desc && <p style={{ marginTop: 8 }}>{selected.desc}</p>}
+                                <p><strong>ID:</strong><code>{selected.id}</code></p>
+                                {selected.desc && <p className="entity-desc">{selected.desc}</p>}
                                 {selected.current_json && (
-                                    <div style={{ marginTop: 12 }}>
+                                    <div className="entity-current-block">
                                         <strong>当前状态:</strong>
-                                        <pre style={{ marginTop: 4, padding: 10, background: 'var(--bg-input)', borderRadius: 'var(--radius-sm)', fontSize: 12, overflow: 'auto', maxHeight: 200 }}>
+                                        <pre className="entity-json">
                                             {formatJSON(selected.current_json)}
                                         </pre>
                                     </div>
                                 )}
                             </div>
                             {changes.length > 0 ? (
-                                <div style={{ marginTop: 16 }}>
-                                    <div className="card-title" style={{ marginBottom: 8, fontSize: 14 }}>状态变化历史</div>
-                                    <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 style={{ fontSize: 12 }}>{c.old_value} → {c.new_value}</td>
-                                                </tr>
-                                            ))}
-                                        </tbody>
-                                    </table>
+                                <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>
@@ -350,18 +386,18 @@ function GraphPage() {
                 <h2>🕸️ 关系图谱</h2>
                 <span className="card-badge badge-blue">{relationships.length} 条引力链接</span>
             </div>
-            <div className="card" style={{ padding: 0, overflow: 'hidden', height: 'calc(100vh - 180px)', minHeight: 600 }}>
+            <div className="card graph-shell">
                 <ForceGraph3D
                     graphData={graphData}
                     nodeLabel="name"
                     nodeColor="color"
                     nodeRelSize={6}
-                    linkColor={() => 'rgba(139, 92, 246, 0.25)'}
+                    linkColor={() => 'rgba(127, 90, 240, 0.35)'}
                     linkWidth={1}
                     linkDirectionalParticles={2}
                     linkDirectionalParticleWidth={1.5}
                     linkDirectionalParticleSpeed={d => 0.005 + Math.random() * 0.005}
-                    backgroundColor="#080a12"
+                    backgroundColor="#fffaf0"
                     showNavInfo={false}
                 />
             </div>
@@ -391,20 +427,22 @@ function ChaptersPage() {
                 <span className="card-badge badge-green">{chapters.length} 章 · {formatNumber(totalWords)} 字</span>
             </div>
             <div className="card">
-                <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 style={{ fontWeight: 600 }}>第 {c.chapter} 章</td>
-                                <td>{c.title || '—'}</td>
-                                <td>{formatNumber(c.word_count || 0)}</td>
-                                <td>{c.location || '—'}</td>
-                                <td className="truncate" style={{ fontSize: 12, maxWidth: 200 }}>{c.characters || '—'}</td>
-                            </tr>
-                        ))}
-                    </tbody>
-                </table>
+                <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>
         </>
@@ -433,26 +471,32 @@ function FilesPage() {
         }
     }, [selectedPath])
 
+    useEffect(() => {
+        if (selectedPath) return
+        const first = findFirstFilePath(tree)
+        if (first) setSelectedPath(first)
+    }, [tree, selectedPath])
+
     return (
         <>
             <div className="page-header">
                 <h2>📁 文档浏览</h2>
             </div>
-            <div style={{ display: 'flex', gap: 20 }}>
-                <div style={{ width: 280, minWidth: 240, maxHeight: '80vh', overflowY: 'auto' }}>
+            <div className="file-layout">
+                <div className="file-tree-pane">
                     {Object.entries(tree).map(([folder, items]) => (
-                        <div key={folder} style={{ marginBottom: 12 }}>
-                            <div style={{ fontWeight: 600, fontSize: 14, padding: '6px 0', color: 'var(--text-primary)' }}>📂 {folder}</div>
+                        <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 style={{ flex: 1 }}>
+                <div className="file-content-pane">
                     {selectedPath ? (
                         <div>
-                            <div style={{ marginBottom: 12, fontSize: 13, color: 'var(--text-muted)' }}>{selectedPath}</div>
+                            <div className="selected-path">{selectedPath}</div>
                             <div className="file-preview">{content}</div>
                         </div>
                     ) : (
@@ -483,31 +527,285 @@ function ReadingPowerPage() {
                 <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><th>章节</th><th>钩子类型</th><th>钩子强度</th><th>过渡章</th><th>Override</th><th>债务余额</th></tr></thead>
+                    <thead>
+                        <tr>{columns.map(c => <th key={c}>{c}</th>)}</tr>
+                    </thead>
                     <tbody>
-                        {data.map(r => (
-                            <tr key={r.chapter}>
-                                <td style={{ fontWeight: 600 }}>第 {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 style={{ color: r.debt_balance > 0 ? 'var(--accent-red)' : 'var(--text-muted)' }}>{(r.debt_balance || 0).toFixed(2)}</td>
+                        {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>
-                {data.length === 0 ? <div className="empty-state"><div className="empty-icon">🔥</div><p>暂无追读力数据</p></div> : null}
+            </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
+}
+
 
 // ====================================================================
 // 子组件:文件树递归
@@ -575,3 +873,17 @@ function formatJSON(str) {
         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
+}

+ 493 - 504
webnovel-writer/dashboard/frontend/src/index.css

@@ -1,62 +1,25 @@
-/* ============================================
-   Webnovel Dashboard - 全局样式 v2
-   设计风格:暗黑赛博 + 玻璃拟态 + 微动画
-   ============================================ */
+@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Noto+Sans+SC:wght@400;500;700&display=swap');
 
 :root {
-  color-scheme: dark;
-  /* 色彩体系 */
-  --bg-primary: #080a12;
-  --bg-secondary: #0d1020;
-  --bg-card: rgba(18, 22, 40, 0.75);
-  --bg-card-hover: rgba(30, 36, 64, 0.85);
-  --bg-card-solid: #12162a;
-  --bg-input: rgba(15, 18, 35, 0.8);
-  --bg-glass: rgba(20, 24, 48, 0.55);
-
-  --text-primary: #eaf0ff;
-  --text-secondary: #8b92b0;
-  --text-muted: #505672;
-
-  --accent-blue: #4f8ff7;
-  --accent-purple: #8b5cf6;
-  --accent-green: #34d399;
-  --accent-amber: #f59e0b;
-  --accent-red: #ef4444;
-  --accent-cyan: #22d3ee;
-  --accent-pink: #ec4899;
-
-  --gradient-primary: linear-gradient(135deg, #4f8ff7 0%, #8b5cf6 100%);
-  --gradient-warm: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
-  --gradient-cool: linear-gradient(135deg, #22d3ee 0%, #34d399 100%);
-  --gradient-pink: linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%);
-  --gradient-bg: radial-gradient(ellipse at 20% 50%, rgba(79, 143, 247, 0.08) 0%, transparent 50%),
-    radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.06) 0%, transparent 50%),
-    radial-gradient(ellipse at 50% 80%, rgba(34, 211, 238, 0.04) 0%, transparent 50%);
-
-  --border-color: rgba(255, 255, 255, 0.06);
-  --border-glow: rgba(79, 143, 247, 0.2);
-
-  --shadow-card: 0 4px 30px rgba(0, 0, 0, 0.4);
-  --shadow-glow-blue: 0 0 30px rgba(79, 143, 247, 0.12);
-  --shadow-glow-purple: 0 0 30px rgba(139, 92, 246, 0.12);
-
-  /* 玻璃效果 */
-  --glass-blur: blur(16px);
-  --glass-border: 1px solid rgba(255, 255, 255, 0.08);
-
-  /* 圆角 */
-  --radius-sm: 8px;
-  --radius-md: 12px;
-  --radius-lg: 16px;
-  --radius-xl: 24px;
-
-  /* 字体 */
-  --font-sans: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, sans-serif;
-  --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
-
-  /* 过渡 */
-  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
+  --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', 'Segoe UI', sans-serif;
 }
 
 * {
@@ -66,7 +29,7 @@
 }
 
 *:focus-visible {
-  outline: 2px solid var(--accent-blue);
+  outline: 3px dashed var(--accent-blue);
   outline-offset: 2px;
 }
 
@@ -77,678 +40,704 @@ body,
 }
 
 body {
-  font-family: var(--font-sans);
-  background: var(--bg-primary);
-  background-image: var(--gradient-bg);
-  color: var(--text-primary);
-  line-height: 1.6;
-  -webkit-font-smoothing: antialiased;
+  font-family: var(--font-body);
+  color: var(--text-main);
+  background-color: var(--bg-main);
+  background-image:
+    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;
 }
 
-/* ===== 布局 ===== */
-
 .app-layout {
-  display: flex;
+  display: grid;
+  grid-template-columns: 240px minmax(0, 1fr);
   height: 100vh;
-  overflow: hidden;
 }
 
-/* ===== 侧边栏 ===== */
-
 .sidebar {
-  width: 240px;
-  min-width: 240px;
-  background: var(--bg-secondary);
-  border-right: var(--glass-border);
+  border-right: 3px solid var(--border-main);
+  background: linear-gradient(180deg, #ffe8b8 0%, #ffe19f 100%);
   display: flex;
   flex-direction: column;
-  overflow-y: auto;
-  position: relative;
-}
-
-.sidebar::after {
-  content: '';
-  position: absolute;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  width: 1px;
-  background: linear-gradient(to bottom, transparent, rgba(79, 143, 247, 0.15), transparent);
-  pointer-events: none;
+  min-height: 0;
 }
 
 .sidebar-header {
-  padding: 24px 20px 20px;
-  border-bottom: var(--glass-border);
+  padding: 16px;
+  border-bottom: 3px solid var(--border-main);
 }
 
 .sidebar-header h1 {
-  font-size: 17px;
-  font-weight: 700;
-  background: var(--gradient-primary);
-  -webkit-background-clip: text;
-  -webkit-text-fill-color: transparent;
-  background-clip: text;
-  letter-spacing: -0.02em;
+  font-family: var(--font-display);
+  font-size: 11px;
+  letter-spacing: 0.08em;
+  line-height: 1.45;
 }
 
 .sidebar-header .subtitle {
-  font-size: 12px;
-  color: var(--text-muted);
-  margin-top: 6px;
+  margin-top: 10px;
+  font-size: 14px;
+  font-weight: 500;
+  color: var(--text-sub);
+  white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
-  white-space: nowrap;
 }
 
 .sidebar-nav {
   flex: 1;
-  padding: 14px 10px;
+  overflow-y: auto;
+  padding: 10px;
   display: flex;
   flex-direction: column;
-  gap: 2px;
+  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: 10px;
-  padding: 10px 14px;
-  border-radius: var(--radius-md);
+  gap: 8px;
+  padding: 10px 12px;
+  font-size: 14px;
+  font-weight: 600;
   cursor: pointer;
-  color: var(--text-secondary);
-  font-size: 13.5px;
-  font-weight: 500;
-  transition: color 0.25s var(--ease-out), border-color 0.25s var(--ease-out), background 0.25s var(--ease-out), box-shadow 0.25s var(--ease-out);
-  border: 1px solid transparent;
-  background: none;
-  width: 100%;
-  text-align: left;
-  position: relative;
-  overflow: hidden;
-  outline: none;
-}
-
-.nav-item::before {
-  content: '';
-  position: absolute;
-  inset: 0;
-  border-radius: inherit;
-  opacity: 0;
-  transition: opacity 0.25s;
-  background: linear-gradient(135deg, rgba(79, 143, 247, 0.06), rgba(139, 92, 246, 0.04));
+  box-shadow: var(--shadow-soft);
+  transition: transform 0.08s ease;
 }
 
 .nav-item:hover {
-  color: var(--text-primary);
-  border-color: var(--border-color);
-}
-
-.nav-item:hover::before {
-  opacity: 1;
+  transform: translate(-1px, -1px);
 }
 
 .nav-item.active {
-  color: var(--accent-blue);
-  background: rgba(79, 143, 247, 0.08);
-  border-color: rgba(79, 143, 247, 0.15);
-  box-shadow: var(--shadow-glow-blue);
+  background: #dff3ff;
+  border-color: var(--accent-blue);
 }
 
 .nav-item .icon {
-  font-size: 17px;
   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);
+}
+
+.live-dot.disconnected {
+  background: var(--accent-red);
+}
 
 .main-content {
-  flex: 1;
   overflow-y: auto;
-  padding: 32px 36px;
-  animation: fadeIn 0.4s var(--ease-out);
+  min-width: 0;
+  padding: 22px;
 }
 
-@keyframes fadeIn {
-  from {
-    opacity: 0;
-    transform: translateY(8px);
-  }
-
-  to {
-    opacity: 1;
-    transform: translateY(0);
-  }
+.page-header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+  margin-bottom: 14px;
 }
 
-/* ===== 卡片(玻璃拟态) ===== */
-
-.card {
-  background: var(--bg-card);
-  backdrop-filter: var(--glass-blur);
-  -webkit-backdrop-filter: var(--glass-blur);
-  border: var(--glass-border);
-  border-radius: var(--radius-lg);
-  padding: 24px;
-  box-shadow: var(--shadow-card);
-  transition: border-color 0.35s var(--ease-out), box-shadow 0.35s var(--ease-out), transform 0.35s var(--ease-out);
-  position: relative;
-  overflow: hidden;
+.page-header h2 {
+  font-size: 22px;
+  line-height: 1.2;
+  font-weight: 700;
 }
 
-.card::before {
-  content: '';
-  position: absolute;
-  top: 0;
-  left: 0;
-  right: 0;
-  height: 1px;
-  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.06), transparent);
+.section-page-header {
+  margin-top: 18px;
 }
 
-.card:hover {
-  border-color: var(--border-glow);
-  box-shadow: var(--shadow-card), var(--shadow-glow-blue);
-  transform: translateY(-1px);
+.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;
-  margin-bottom: 18px;
+  gap: 12px;
+  margin-bottom: 10px;
 }
 
 .card-title {
-  font-size: 15px;
-  font-weight: 600;
-  color: var(--text-primary);
-  letter-spacing: -0.01em;
+  font-size: 17px;
+  font-weight: 700;
 }
 
 .card-badge {
-  display: inline-flex;
-  align-items: center;
-  gap: 4px;
-  padding: 4px 12px;
-  border-radius: 100px;
-  font-size: 11.5px;
-  font-weight: 600;
-  letter-spacing: 0.02em;
-  border: 1px solid transparent;
+  border: 2px solid var(--border-main);
+  font-size: 12px;
+  font-weight: 700;
+  padding: 3px 8px;
+  background: #fff;
 }
 
-.badge-blue {
-  background: rgba(79, 143, 247, 0.12);
-  color: var(--accent-blue);
-  border-color: rgba(79, 143, 247, 0.2);
+.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; }
+
+.dashboard-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+  gap: 12px;
+  margin-bottom: 14px;
 }
 
-.badge-green {
-  background: rgba(52, 211, 153, 0.12);
-  color: var(--accent-green);
-  border-color: rgba(52, 211, 153, 0.2);
+.stat-card .stat-label {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--text-mute);
 }
 
-.badge-amber {
-  background: rgba(245, 158, 11, 0.12);
-  color: var(--accent-amber);
-  border-color: rgba(245, 158, 11, 0.2);
+.stat-card .stat-value {
+  font-size: 28px;
+  line-height: 1.15;
+  margin: 6px 0 2px;
+  color: var(--accent-blue);
 }
 
-.badge-red {
-  background: rgba(239, 68, 68, 0.12);
-  color: var(--accent-red);
-  border-color: rgba(239, 68, 68, 0.2);
+.stat-card .stat-value.plain {
+  color: var(--text-main);
 }
 
-.badge-purple {
-  background: rgba(139, 92, 246, 0.12);
-  color: var(--accent-purple);
-  border-color: rgba(139, 92, 246, 0.2);
+.stat-sub {
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--text-sub);
 }
 
-.badge-cyan {
-  background: rgba(34, 211, 238, 0.12);
-  color: var(--accent-cyan);
-  border-color: rgba(34, 211, 238, 0.2);
+.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);
+}
 
-.dashboard-grid {
-  display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
-  gap: 18px;
-  margin-bottom: 22px;
+.dashboard-section-card {
+  margin-bottom: 16px;
 }
 
-.stat-card {
+.strand-bar {
+  height: 12px;
+  border: 2px solid var(--border-main);
   display: flex;
-  flex-direction: column;
-  gap: 6px;
+  margin-bottom: 10px;
 }
 
-.stat-card .stat-label {
-  font-size: 12px;
-  color: var(--text-muted);
-  font-weight: 600;
-  text-transform: uppercase;
-  letter-spacing: 0.06em;
+.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);
 }
 
-.stat-card .stat-value {
-  font-size: 30px;
-  font-weight: 700;
-  letter-spacing: -0.03em;
-  background: var(--gradient-primary);
-  -webkit-background-clip: text;
-  -webkit-text-fill-color: transparent;
-  background-clip: text;
+.demo-summary-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+  gap: 12px;
+  margin-bottom: 12px;
 }
 
-.stat-card .stat-value.plain {
-  background: none;
-  -webkit-text-fill-color: var(--text-primary);
+.demo-domain-tabs {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 12px;
 }
 
-.stat-card .stat-sub {
-  font-size: 12px;
-  color: var(--text-muted);
+.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;
 }
 
-/* ===== 进度条 ===== */
+.demo-domain-tab.active {
+  background: #dff3ff;
+  border-color: var(--accent-blue);
+}
 
-.progress-track {
-  height: 6px;
-  border-radius: 100px;
-  background: var(--bg-input);
-  margin-top: 10px;
-  overflow: hidden;
+.demo-domain-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+  gap: 12px;
+  margin-bottom: 12px;
 }
 
-.progress-fill {
-  height: 100%;
-  border-radius: 100px;
-  background: var(--gradient-primary);
-  transition: width 0.8s var(--ease-out);
-  position: relative;
+.domain-stat-number {
+  font-size: 30px;
+  color: var(--accent-purple);
+  margin-bottom: 4px;
 }
 
-.progress-fill::after {
-  content: '';
-  position: absolute;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  width: 40px;
-  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2));
-  border-radius: inherit;
+.demo-group-card {
+  margin-bottom: 12px;
 }
 
-/* ===== 表格 ===== */
+.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: 13px;
+  font-size: 14px;
+  font-family: var(--font-body);
   font-variant-numeric: tabular-nums;
 }
 
 .data-table th {
   text-align: left;
-  padding: 10px 14px;
-  color: var(--text-muted);
-  font-weight: 600;
-  font-size: 11px;
-  text-transform: uppercase;
-  letter-spacing: 0.06em;
-  border-bottom: 1px solid var(--border-color);
-  position: sticky;
-  top: 0;
-  background: var(--bg-card-solid);
-  z-index: 1;
+  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: 10px 14px;
-  border-bottom: 1px solid rgba(255, 255, 255, 0.03);
-  color: var(--text-secondary);
-  transition: background 0.15s, color 0.15s;
-}
-
-.data-table tr {
-  transition: background 0.15s;
+  padding: 8px 10px;
+  border-bottom: 1px solid #d8ccb2;
+  color: var(--text-main);
+  font-weight: 500;
 }
 
 .data-table tbody tr:hover td {
-  background: rgba(79, 143, 247, 0.04);
-  color: var(--text-primary);
+  background: #fff4d8;
 }
 
-/* ===== Strand 指示条 ===== */
+.table-foot-note {
+  margin-top: 8px;
+}
 
-.strand-bar {
+.table-pagination {
   display: flex;
-  height: 10px;
-  border-radius: 100px;
-  overflow: hidden;
-  background: var(--bg-input);
-  box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+  margin-top: 8px;
+  flex-wrap: wrap;
 }
 
-.strand-bar .segment {
-  height: 100%;
-  transition: width 0.6s var(--ease-out);
-  position: relative;
+.page-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;
 }
 
-.strand-quest {
-  background: linear-gradient(90deg, #3b82f6, #60a5fa);
-  box-shadow: 0 0 8px rgba(59, 130, 246, 0.4);
+.page-btn:hover:not(:disabled) {
+  background: #e6f7ff;
+  border-color: var(--accent-blue);
 }
 
-.strand-fire {
-  background: linear-gradient(90deg, #ef4444, #f97316);
-  box-shadow: 0 0 8px rgba(239, 68, 68, 0.4);
+.page-btn:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
 }
 
-.strand-constellation {
-  background: linear-gradient(90deg, #8b5cf6, #a78bfa);
-  box-shadow: 0 0 8px rgba(139, 92, 246, 0.4);
+.page-info {
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--text-sub);
 }
 
-/* ===== 文件浏览器 ===== */
-
-.file-tree {
-  list-style: none;
-  font-size: 13.5px;
+.split-layout {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 340px;
+  gap: 14px;
 }
 
-.file-tree li {
-  padding: 0;
+.split-main,
+.split-side {
+  min-width: 0;
 }
 
-.tree-item {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  padding: 7px 10px;
-  border-radius: var(--radius-sm);
+.entity-row {
   cursor: pointer;
-  color: var(--text-secondary);
-  transition: background 0.2s var(--ease-out), color 0.2s var(--ease-out), border-color 0.2s var(--ease-out);
-  border: 1px solid transparent;
-  outline: none;
 }
 
-.tree-item:hover {
-  background: rgba(79, 143, 247, 0.06);
-  color: var(--text-primary);
-  border-color: var(--border-color);
+.entity-row.selected td {
+  background: #e6f7ff;
 }
 
-.tree-item.active {
-  background: rgba(79, 143, 247, 0.1);
-  color: var(--accent-blue);
-  border-color: rgba(79, 143, 247, 0.2);
+.entity-name {
+  font-weight: 700;
 }
 
-.tree-icon {
-  width: 18px;
-  text-align: center;
-  flex-shrink: 0;
+.entity-name.protagonist {
+  color: #b86a00;
 }
 
-.tree-children {
-  padding-left: 18px;
-  list-style: none;
-  border-left: 1px solid rgba(255, 255, 255, 0.04);
-  margin-left: 9px;
+.entity-detail {
+  font-size: 14px;
+  color: var(--text-sub);
+  line-height: 1.7;
+  font-family: var(--font-body);
 }
 
-/* ===== 文件内容预览 ===== */
-
-.file-preview {
-  background: var(--bg-glass);
-  backdrop-filter: var(--glass-blur);
-  border: var(--glass-border);
-  border-radius: var(--radius-md);
-  padding: 24px 28px;
-  font-size: 13.5px;
-  line-height: 1.85;
-  white-space: pre-wrap;
-  word-wrap: break-word;
-  max-height: 72vh;
-  overflow-y: auto;
-  color: var(--text-primary);
-  font-family: var(--font-sans);
+.entity-detail code {
+  border: 1px solid var(--border-soft);
+  padding: 1px 4px;
+  background: #fff;
 }
 
-/* ===== 关系图谱容器 ===== */
-
-.graph-container {
-  width: 100%;
-  min-height: 500px;
-  background: var(--bg-glass);
-  backdrop-filter: var(--glass-blur);
-  border: var(--glass-border);
-  border-radius: var(--radius-lg);
-  position: relative;
-  overflow: hidden;
-  box-shadow: var(--shadow-card);
+.entity-desc {
+  margin-top: 8px;
 }
 
-.graph-container canvas,
-.graph-container svg {
-  width: 100%;
-  height: 100%;
+.entity-current-block {
+  margin-top: 10px;
 }
 
-/* ===== 实时状态指示灯 ===== */
+.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;
+}
 
-.live-indicator {
-  padding: 14px 20px;
-  border-top: var(--glass-border);
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  font-size: 11.5px;
-  color: var(--text-muted);
+.entity-history {
+  margin-top: 12px;
 }
 
-.live-dot {
-  width: 7px;
-  height: 7px;
-  border-radius: 50%;
-  background: var(--accent-green);
-  display: inline-block;
-  box-shadow: 0 0 8px rgba(52, 211, 153, 0.5);
-  animation: livePulse 2.5s ease-in-out infinite;
+.graph-shell {
+  padding: 0;
+  overflow: hidden;
+  height: calc(100vh - 120px);
+  min-height: 520px;
 }
 
-.live-dot.disconnected {
-  background: var(--accent-red);
-  box-shadow: 0 0 8px rgba(239, 68, 68, 0.5);
-  animation: none;
+.chapter-no {
+  font-weight: 700;
+  white-space: nowrap;
 }
 
-@keyframes livePulse {
+.chapter-characters {
+  max-width: 220px;
+}
 
-  0%,
-  100% {
-    opacity: 1;
-    box-shadow: 0 0 8px rgba(52, 211, 153, 0.5);
-  }
+.file-layout {
+  display: grid;
+  grid-template-columns: 300px minmax(0, 1fr);
+  gap: 12px;
+  height: calc(100vh - 130px);
+  min-height: 560px;
+}
 
-  50% {
-    opacity: 0.5;
-    box-shadow: 0 0 16px rgba(52, 211, 153, 0.2);
-  }
+.file-tree-pane {
+  height: 100%;
+  overflow-y: auto;
+  border: 3px solid var(--border-main);
+  box-shadow: var(--shadow-soft);
+  background: #fffcf5;
+  padding: 10px;
 }
 
-/* ===== 页面标题 ===== */
+.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;
+}
 
-.page-header {
+.file-content-pane > div {
+  height: 100%;
+  min-height: 0;
   display: flex;
-  align-items: center;
-  gap: 14px;
-  margin-bottom: 26px;
+  flex-direction: column;
 }
 
-.page-header h2 {
-  font-size: 22px;
+.folder-block {
+  margin-bottom: 12px;
+}
+
+.folder-title {
+  font-size: 15px;
   font-weight: 700;
-  letter-spacing: -0.02em;
+  margin-bottom: 6px;
 }
 
-/* ===== 加载与空状态 ===== */
+.selected-path {
+  margin-bottom: 8px;
+  font-size: 13px;
+  color: var(--text-mute);
+  font-weight: 600;
+  word-break: break-all;
+}
 
-.loading {
+.file-tree {
+  list-style: none;
+  font-size: 13px;
+  font-family: var(--font-body);
+}
+
+.tree-item {
+  border: 2px solid transparent;
+  padding: 6px 8px;
   display: flex;
   align-items: center;
-  justify-content: center;
-  padding: 80px 0;
-  color: var(--text-muted);
-  font-size: 14px;
-  gap: 10px;
+  gap: 8px;
+  cursor: pointer;
 }
 
-.loading::before {
-  content: '';
+.tree-item:hover {
+  background: #fff4d8;
+  border-color: #e0c98d;
+}
+
+.tree-item.active {
+  background: #e6f7ff;
+  border-color: var(--accent-blue);
+}
+
+.tree-icon {
   width: 18px;
-  height: 18px;
-  border: 2px solid var(--border-color);
-  border-top-color: var(--accent-blue);
-  border-radius: 50%;
-  animation: spin 0.8s linear infinite;
+  text-align: center;
 }
 
-@keyframes spin {
-  to {
-    transform: rotate(360deg);
-  }
+.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;
 }
 
 .empty-state {
   text-align: center;
-  padding: 80px 20px;
-  color: var(--text-muted);
+  padding: 32px 14px;
+  color: var(--text-sub);
+  font-family: var(--font-body);
 }
 
-.empty-state .empty-icon {
-  font-size: 56px;
-  margin-bottom: 16px;
-  filter: grayscale(0.3);
+.file-content-pane .empty-state {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
 }
 
-.empty-state p {
-  font-size: 14px;
+.empty-state.compact {
+  padding: 20px 10px;
 }
 
-/* ===== 筛选按钮组 ===== */
+.empty-state .empty-icon {
+  font-size: 40px;
+  margin-bottom: 10px;
+}
 
 .filter-group {
   display: flex;
-  gap: 6px;
-  margin-bottom: 18px;
   flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 12px;
 }
 
 .filter-btn {
-  padding: 6px 14px;
-  border-radius: 100px;
-  border: 1px solid var(--border-color);
-  background: transparent;
-  color: var(--text-secondary);
-  font-size: 12.5px;
-  font-weight: 500;
+  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;
-  transition: border-color 0.2s var(--ease-out), color 0.2s var(--ease-out), background 0.2s var(--ease-out);
-  font-family: var(--font-sans);
-  outline: none;
-}
-
-.filter-btn:hover {
-  border-color: var(--border-glow);
-  color: var(--text-primary);
-  background: rgba(79, 143, 247, 0.06);
 }
 
 .filter-btn.active {
-  background: rgba(79, 143, 247, 0.12);
-  border-color: rgba(79, 143, 247, 0.3);
-  color: var(--accent-blue);
+  background: #e6f7ff;
+  border-color: var(--accent-blue);
 }
 
-/* ===== 滚动条美化 ===== */
-
-::-webkit-scrollbar {
-  width: 5px;
-  height: 5px;
+.truncate {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-::-webkit-scrollbar-track {
-  background: transparent;
-}
+@media (max-width: 1280px) {
+  .split-layout {
+    grid-template-columns: 1fr;
+  }
 
-::-webkit-scrollbar-thumb {
-  background: rgba(255, 255, 255, 0.08);
-  border-radius: 100px;
-}
+  .graph-shell {
+    height: calc(100vh - 128px);
+    min-height: 460px;
+  }
 
-::-webkit-scrollbar-thumb:hover {
-  background: rgba(255, 255, 255, 0.15);
+  .file-layout {
+    grid-template-columns: 260px minmax(0, 1fr);
+    min-height: 500px;
+  }
 }
 
-/* ===== 响应式 ===== */
-
-@media (max-width: 768px) {
-  .sidebar {
-    width: 56px;
-    min-width: 56px;
+@media (max-width: 960px) {
+  .app-layout {
+    grid-template-columns: 84px minmax(0, 1fr);
   }
 
-  .sidebar-header h1 {
+  .sidebar-header h1,
+  .sidebar-header .subtitle,
+  .nav-item span:not(.icon),
+  .live-indicator {
     display: none;
   }
 
-  .sidebar-header .subtitle {
-    display: none;
+  .sidebar {
+    border-right-width: 2px;
   }
 
-  .nav-item span:not(.icon) {
-    display: none;
+  .nav-item {
+    justify-content: center;
+    padding: 12px 8px;
   }
 
   .main-content {
-    padding: 16px;
+    padding: 14px;
+  }
+
+  .graph-shell {
+    height: calc(100vh - 108px);
+    min-height: 380px;
   }
 
-  .dashboard-grid {
+  .file-layout {
     grid-template-columns: 1fr;
+    height: auto;
+    min-height: 0;
   }
-}
 
-/* ===== 辅助类 ===== */
+  .file-tree-pane {
+    height: 260px;
+    border: 2px solid var(--border-soft);
+    padding: 8px;
+    background: #fff;
+  }
 
-.truncate {
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+  .file-content-pane {
+    height: calc(100vh - 430px);
+    min-height: 320px;
+  }
 }
 
-/* ===== 减弱动态效果 ===== */
+@media (max-width: 720px) {
+  .page-header h2 {
+    font-size: 18px;
+  }
 
-@media (prefers-reduced-motion: reduce) {
+  .dashboard-grid,
+  .demo-summary-grid,
+  .demo-domain-grid {
+    grid-template-columns: 1fr;
+  }
 
-  *,
-  *::before,
-  *::after {
-    animation-duration: 0.01ms !important;
-    animation-iteration-count: 1 !important;
-    transition-duration: 0.01ms !important;
-    scroll-behavior: auto !important;
+  .demo-domain-tabs {
+    overflow-x: auto;
+    flex-wrap: nowrap;
+    padding-bottom: 4px;
   }
-}
+
+  .demo-domain-tab {
+    white-space: nowrap;
+  }
+
+  .card {
+    padding: 12px;
+  }
+
+  .graph-shell {
+    height: 58vh;
+    min-height: 320px;
+  }
+
+  .file-content-pane {
+    height: 58vh;
+    min-height: 280px;
+  }
+}

Some files were not shown because too many files changed in this diff