|
|
@@ -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
|
|
|
+}
|