feat: initial commit
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "noma-dashboard",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-force-graph-3d": "^1.29.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { fetchJSON, subscribeSSE } from './api.js'
|
||||
import ForceGraph3D from 'react-force-graph-3d'
|
||||
|
||||
// ====================================================================
|
||||
// 主应用
|
||||
// ====================================================================
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState('dashboard')
|
||||
const [projectInfo, setProjectInfo] = useState(null)
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [connected, setConnected] = useState(false)
|
||||
|
||||
const loadProjectInfo = useCallback(() => {
|
||||
fetchJSON('/api/project/info')
|
||||
.then(setProjectInfo)
|
||||
.catch(() => setProjectInfo(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadProjectInfo() }, [loadProjectInfo, refreshKey])
|
||||
|
||||
// SSE 订阅
|
||||
useEffect(() => {
|
||||
const unsub = subscribeSSE(
|
||||
() => {
|
||||
setRefreshKey(k => k + 1)
|
||||
},
|
||||
{
|
||||
onOpen: () => setConnected(true),
|
||||
onError: () => setConnected(false),
|
||||
},
|
||||
)
|
||||
return () => { unsub(); setConnected(false) }
|
||||
}, [])
|
||||
|
||||
const title = projectInfo?.project_info?.title || '未加载'
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h1>PIXEL WRITER HUB</h1>
|
||||
<div className="subtitle">{title}</div>
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
{NAV_ITEMS.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`nav-item ${page === item.id ? 'active' : ''}`}
|
||||
onClick={() => setPage(item.id)}
|
||||
>
|
||||
<span className="icon">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="live-indicator">
|
||||
<span className={`live-dot ${connected ? '' : 'disconnected'}`} />
|
||||
{connected ? '实时同步中' : '未连接'}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main-content">
|
||||
{page === 'dashboard' && <DashboardPage data={projectInfo} key={refreshKey} />}
|
||||
{page === 'entities' && <EntitiesPage key={refreshKey} />}
|
||||
{page === 'graph' && <GraphPage key={refreshKey} />}
|
||||
{page === 'chapters' && <ChaptersPage key={refreshKey} />}
|
||||
{page === 'files' && <FilesPage />}
|
||||
{page === 'reading' && <ReadingPowerPage key={refreshKey} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: 'dashboard', icon: '📊', label: '数据总览' },
|
||||
{ id: 'entities', icon: '👤', label: '设定词典' },
|
||||
{ id: 'graph', icon: '🕸️', label: '关系图谱' },
|
||||
{ id: 'chapters', icon: '📝', label: '章节一览' },
|
||||
{ id: 'files', icon: '📁', label: '文档浏览' },
|
||||
{ id: 'reading', icon: '🔥', label: '追读力' },
|
||||
]
|
||||
|
||||
const FULL_DATA_GROUPS = [
|
||||
{ key: 'entities', title: '实体', columns: ['id', 'canonical_name', 'type', 'tier', 'first_appearance', 'last_appearance'], domain: 'core' },
|
||||
{ key: 'chapters', title: '章节', columns: ['chapter', 'title', 'word_count', 'location', 'characters'], domain: 'core' },
|
||||
{ key: 'scenes', title: '场景', columns: ['chapter', 'scene_index', 'location', 'time', 'summary'], domain: 'core' },
|
||||
{ key: 'aliases', title: '别名', columns: ['alias', 'entity_id', 'entity_type'], domain: 'core' },
|
||||
{ key: 'stateChanges', title: '状态变化', columns: ['entity_id', 'field', 'old_value', 'new_value', 'chapter'], domain: 'core' },
|
||||
{ key: 'relationships', title: '关系', columns: ['from_entity', 'to_entity', 'type', 'chapter', 'description'], domain: 'network' },
|
||||
{ key: 'relationshipEvents', title: '关系事件', columns: ['from_entity', 'to_entity', 'type', 'chapter', 'event_type', 'description'], domain: 'network' },
|
||||
{ key: 'readingPower', title: '追读力', columns: ['chapter', 'hook_type', 'hook_strength', 'is_transition', 'override_count', 'debt_balance'], domain: 'network' },
|
||||
{ key: 'overrides', title: 'Override 合约', columns: ['chapter', 'constraint_type', 'constraint_id', 'due_chapter', 'status'], domain: 'network' },
|
||||
{ key: 'debts', title: '追读债务', columns: ['id', 'debt_type', 'current_amount', 'interest_rate', 'due_chapter', 'status'], domain: 'network' },
|
||||
{ key: 'debtEvents', title: '债务事件', columns: ['debt_id', 'event_type', 'amount', 'chapter', 'note'], domain: 'network' },
|
||||
{ key: 'reviewMetrics', title: '审查指标', columns: ['start_chapter', 'end_chapter', 'overall_score', 'severity_counts', 'created_at'], domain: 'quality' },
|
||||
{ key: 'invalidFacts', title: '无效事实', columns: ['source_type', 'source_id', 'reason', 'status', 'chapter_discovered'], domain: 'quality' },
|
||||
{ key: 'checklistScores', title: '写作清单评分', columns: ['chapter', 'template', 'score', 'completion_rate', 'completed_items', 'total_items'], domain: 'quality' },
|
||||
{ key: 'ragQueries', title: 'RAG 查询日志', columns: ['query_type', 'query', 'results_count', 'latency_ms', 'chapter', 'created_at'], domain: 'ops' },
|
||||
{ key: 'toolStats', title: '工具调用统计', columns: ['tool_name', 'success', 'retry_count', 'error_code', 'chapter', 'created_at'], domain: 'ops' },
|
||||
]
|
||||
|
||||
const FULL_DATA_DOMAINS = [
|
||||
{ id: 'overview', label: '总览' },
|
||||
{ id: 'core', label: '基础档案' },
|
||||
{ id: 'network', label: '关系与剧情' },
|
||||
{ id: 'quality', label: '质量审查' },
|
||||
{ id: 'ops', label: 'RAG 与工具' },
|
||||
]
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// 页面 1:数据总览
|
||||
// ====================================================================
|
||||
|
||||
function DashboardPage({ data }) {
|
||||
if (!data) return <div className="loading">加载中…</div>
|
||||
|
||||
const info = data.project_info || {}
|
||||
const progress = data.progress || {}
|
||||
const protagonist = data.protagonist_state || {}
|
||||
const strand = data.strand_tracker || {}
|
||||
const foreshadowing = data.plot_threads?.foreshadowing || []
|
||||
|
||||
const totalWords = progress.total_words || 0
|
||||
const targetWords = info.target_words || 2000000
|
||||
const pct = targetWords > 0 ? Math.min(100, (totalWords / targetWords * 100)).toFixed(1) : 0
|
||||
|
||||
const unresolvedForeshadow = foreshadowing.filter(f => {
|
||||
const s = (f.status || '').toLowerCase()
|
||||
return s !== '已回收' && s !== '已兑现' && s !== 'resolved'
|
||||
})
|
||||
|
||||
// Strand 历史统计
|
||||
const history = strand.history || []
|
||||
const strandCounts = { quest: 0, fire: 0, constellation: 0 }
|
||||
history.forEach(h => { if (strandCounts[h.strand] !== undefined) strandCounts[h.strand]++ })
|
||||
const total = history.length || 1
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h2>📊 数据总览</h2>
|
||||
<span className="card-badge badge-blue">{info.genre || '未知题材'}</span>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<div className="card stat-card">
|
||||
<span className="stat-label">总字数</span>
|
||||
<span className="stat-value">{formatNumber(totalWords)}</span>
|
||||
<span className="stat-sub">目标 {formatNumber(targetWords)} 字 · {pct}%</span>
|
||||
<div className="progress-track">
|
||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card stat-card">
|
||||
<span className="stat-label">当前章节</span>
|
||||
<span className="stat-value">第 {progress.current_chapter || 0} 章</span>
|
||||
<span className="stat-sub">目标 {info.target_chapters || '?'} 章 · 卷 {progress.current_volume || 1}</span>
|
||||
</div>
|
||||
|
||||
<div className="card stat-card">
|
||||
<span className="stat-label">主角状态</span>
|
||||
<span className="stat-value plain">{protagonist.name || '未设定'}</span>
|
||||
<span className="stat-sub">
|
||||
{protagonist.power?.realm || '未知境界'}
|
||||
{protagonist.location?.current ? ` · ${protagonist.location.current}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="card stat-card">
|
||||
<span className="stat-label">未回收伏笔</span>
|
||||
<span className="stat-value" style={{ color: unresolvedForeshadow.length > 10 ? 'var(--accent-red)' : 'var(--accent-amber)' }}>
|
||||
{unresolvedForeshadow.length}
|
||||
</span>
|
||||
<span className="stat-sub">总计 {foreshadowing.length} 条伏笔</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Strand Weave 比例 */}
|
||||
<div className="card dashboard-section-card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">Strand Weave 节奏分布</span>
|
||||
<span className="card-badge badge-purple">{strand.current_dominant || '?'}</span>
|
||||
</div>
|
||||
<div className="strand-bar">
|
||||
<div className="segment strand-quest" style={{ width: `${(strandCounts.quest / total * 100).toFixed(1)}%` }} />
|
||||
<div className="segment strand-fire" style={{ width: `${(strandCounts.fire / total * 100).toFixed(1)}%` }} />
|
||||
<div className="segment strand-constellation" style={{ width: `${(strandCounts.constellation / total * 100).toFixed(1)}%` }} />
|
||||
</div>
|
||||
<div className="strand-legend">
|
||||
<span>🔵 Quest {(strandCounts.quest / total * 100).toFixed(0)}%</span>
|
||||
<span>🔴 Fire {(strandCounts.fire / total * 100).toFixed(0)}%</span>
|
||||
<span>🟣 Constellation {(strandCounts.constellation / total * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 伏笔列表 */}
|
||||
{unresolvedForeshadow.length > 0 ? (
|
||||
<div className="card dashboard-section-card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">⚠️ 待回收伏笔 (Top 20)</span>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>内容</th><th>状态</th><th>埋设章</th></tr></thead>
|
||||
<tbody>
|
||||
{unresolvedForeshadow.slice(0, 20).map((f, i) => (
|
||||
<tr key={i}>
|
||||
<td className="truncate" style={{ maxWidth: 400 }}>{f.content || f.description || '—'}</td>
|
||||
<td><span className="card-badge badge-amber">{f.status || '未知'}</span></td>
|
||||
<td>{f.chapter || f.planted_chapter || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MergedDataView />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// 页面 2:设定词典
|
||||
// ====================================================================
|
||||
|
||||
function EntitiesPage() {
|
||||
const [entities, setEntities] = useState([])
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [changes, setChanges] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchJSON('/api/entities').then(setEntities).catch(() => { })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
fetchJSON('/api/state-changes', { entity: selected.id, limit: 30 }).then(setChanges).catch(() => setChanges([]))
|
||||
}
|
||||
}, [selected])
|
||||
|
||||
const types = [...new Set(entities.map(e => e.type))].sort()
|
||||
const filteredEntities = typeFilter ? entities.filter(e => e.type === typeFilter) : entities
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h2>👤 设定词典</h2>
|
||||
<span className="card-badge badge-green">{filteredEntities.length} / {entities.length} 个实体</span>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<button className={`filter-btn ${typeFilter === '' ? 'active' : ''}`} onClick={() => setTypeFilter('')}>全部</button>
|
||||
{types.map(t => (
|
||||
<button key={t} className={`filter-btn ${typeFilter === t ? 'active' : ''}`} onClick={() => setTypeFilter(t)}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="split-layout">
|
||||
<div className="split-main">
|
||||
<div className="card">
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>名称</th><th>类型</th><th>层级</th><th>首现</th><th>末现</th></tr></thead>
|
||||
<tbody>
|
||||
{filteredEntities.map(e => (
|
||||
<tr
|
||||
key={e.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`entity-row ${selected?.id === e.id ? 'selected' : ''}`}
|
||||
onKeyDown={evt => (evt.key === 'Enter' || evt.key === ' ') && (evt.preventDefault(), setSelected(e))}
|
||||
onClick={() => setSelected(e)}
|
||||
>
|
||||
<td className={e.is_protagonist ? 'entity-name protagonist' : 'entity-name'}>
|
||||
{e.canonical_name} {e.is_protagonist ? '⭐' : ''}
|
||||
</td>
|
||||
<td><span className="card-badge badge-blue">{e.type}</span></td>
|
||||
<td>{e.tier}</td>
|
||||
<td>{e.first_appearance || '—'}</td>
|
||||
<td>{e.last_appearance || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="split-side">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="card-title">{selected.canonical_name}</span>
|
||||
<span className="card-badge badge-purple">{selected.tier}</span>
|
||||
</div>
|
||||
<div className="entity-detail">
|
||||
<p><strong>类型:</strong>{selected.type}</p>
|
||||
<p><strong>ID:</strong><code>{selected.id}</code></p>
|
||||
{selected.desc && <p className="entity-desc">{selected.desc}</p>}
|
||||
{selected.current_json && (
|
||||
<div className="entity-current-block">
|
||||
<strong>当前状态:</strong>
|
||||
<pre className="entity-json">
|
||||
{formatJSON(selected.current_json)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{changes.length > 0 ? (
|
||||
<div className="entity-history">
|
||||
<div className="card-title">状态变化历史</div>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>章</th><th>字段</th><th>变化</th></tr></thead>
|
||||
<tbody>
|
||||
{changes.map((c, i) => (
|
||||
<tr key={i}>
|
||||
<td>{c.chapter}</td>
|
||||
<td>{c.field}</td>
|
||||
<td>{c.old_value} → {c.new_value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// 页面 3:3D 宇宙关系图谱
|
||||
// ====================================================================
|
||||
|
||||
function GraphPage() {
|
||||
const [relationships, setRelationships] = useState([])
|
||||
const [graphData, setGraphData] = useState({ nodes: [], links: [] })
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetchJSON('/api/relationships', { limit: 1000 }),
|
||||
fetchJSON('/api/entities'),
|
||||
]).then(([rels, ents]) => {
|
||||
setRelationships(rels)
|
||||
const typeColors = {
|
||||
'角色': '#4f8ff7', '地点': '#34d399', '星球': '#22d3ee', '神仙': '#f59e0b',
|
||||
'势力': '#8b5cf6', '招式': '#ef4444', '法宝': '#ec4899'
|
||||
}
|
||||
const relatedIds = new Set()
|
||||
rels.forEach(r => { relatedIds.add(r.from_entity); relatedIds.add(r.to_entity) })
|
||||
const entityMap = {}
|
||||
ents.forEach(e => { entityMap[e.id] = e })
|
||||
|
||||
const nodes = [...relatedIds].map(id => ({
|
||||
id,
|
||||
name: entityMap[id]?.canonical_name || id,
|
||||
val: (entityMap[id]?.tier === 'S' ? 8 : entityMap[id]?.tier === 'A' ? 5 : 2),
|
||||
color: typeColors[entityMap[id]?.type] || '#5c6078'
|
||||
}))
|
||||
const links = rels.map(r => ({
|
||||
source: r.from_entity,
|
||||
target: r.to_entity,
|
||||
name: r.type
|
||||
}))
|
||||
setGraphData({ nodes, links })
|
||||
}).catch(() => { })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h2>🕸️ 关系图谱</h2>
|
||||
<span className="card-badge badge-blue">{relationships.length} 条引力链接</span>
|
||||
</div>
|
||||
<div className="card graph-shell">
|
||||
<ForceGraph3D
|
||||
graphData={graphData}
|
||||
nodeLabel="name"
|
||||
nodeColor="color"
|
||||
nodeRelSize={6}
|
||||
linkColor={() => 'rgba(127, 90, 240, 0.35)'}
|
||||
linkWidth={1}
|
||||
linkDirectionalParticles={2}
|
||||
linkDirectionalParticleWidth={1.5}
|
||||
linkDirectionalParticleSpeed={d => 0.005 + Math.random() * 0.005}
|
||||
backgroundColor="#fffaf0"
|
||||
showNavInfo={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// 页面 4:章节一览
|
||||
// ====================================================================
|
||||
|
||||
function ChaptersPage() {
|
||||
const [chapters, setChapters] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchJSON('/api/chapters').then(setChapters).catch(() => { })
|
||||
}, [])
|
||||
|
||||
const totalWords = chapters.reduce((s, c) => s + (c.word_count || 0), 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h2>📝 章节一览</h2>
|
||||
<span className="card-badge badge-green">{chapters.length} 章 · {formatNumber(totalWords)} 字</span>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>章节</th><th>标题</th><th>字数</th><th>地点</th><th>角色</th></tr></thead>
|
||||
<tbody>
|
||||
{chapters.map(c => (
|
||||
<tr key={c.chapter}>
|
||||
<td className="chapter-no">第 {c.chapter} 章</td>
|
||||
<td>{c.title || '—'}</td>
|
||||
<td>{formatNumber(c.word_count || 0)}</td>
|
||||
<td>{c.location || '—'}</td>
|
||||
<td className="truncate chapter-characters">{c.characters || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{chapters.length === 0 ? <div className="empty-state"><div className="empty-icon">📭</div><p>暂无章节数据</p></div> : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// 页面 5:文档浏览
|
||||
// ====================================================================
|
||||
|
||||
function FilesPage() {
|
||||
const [tree, setTree] = useState({})
|
||||
const [selectedPath, setSelectedPath] = useState(null)
|
||||
const [content, setContent] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchJSON('/api/files/tree').then(setTree).catch(() => { })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPath) {
|
||||
fetchJSON('/api/files/read', { path: selectedPath })
|
||||
.then(d => setContent(d.content))
|
||||
.catch(() => setContent('[读取失败]'))
|
||||
}
|
||||
}, [selectedPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPath) return
|
||||
const first = findFirstFilePath(tree)
|
||||
if (first) setSelectedPath(first)
|
||||
}, [tree, selectedPath])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h2>📁 文档浏览</h2>
|
||||
</div>
|
||||
<div className="file-layout">
|
||||
<div className="file-tree-pane">
|
||||
{Object.entries(tree).map(([folder, items]) => (
|
||||
<div key={folder} className="folder-block">
|
||||
<div className="folder-title">📂 {folder}</div>
|
||||
<ul className="file-tree">
|
||||
<TreeNodes items={items} selected={selectedPath} onSelect={setSelectedPath} />
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="file-content-pane">
|
||||
{selectedPath ? (
|
||||
<div>
|
||||
<div className="selected-path">{selectedPath}</div>
|
||||
<div className="file-preview">{content}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state"><div className="empty-icon">📄</div><p>选择左侧文件以预览内容</p></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// 页面 6:追读力
|
||||
// ====================================================================
|
||||
|
||||
function ReadingPowerPage() {
|
||||
const [data, setData] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchJSON('/api/reading-power', { limit: 50 }).then(setData).catch(() => { })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header">
|
||||
<h2>🔥 追读力分析</h2>
|
||||
<span className="card-badge badge-amber">{data.length} 章数据</span>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead><tr><th>章节</th><th>钩子类型</th><th>钩子强度</th><th>过渡章</th><th>Override</th><th>债务余额</th></tr></thead>
|
||||
<tbody>
|
||||
{data.map(r => (
|
||||
<tr key={r.chapter}>
|
||||
<td className="chapter-no">第 {r.chapter} 章</td>
|
||||
<td>{r.hook_type || '—'}</td>
|
||||
<td>
|
||||
<span className={`card-badge ${r.hook_strength === 'strong' ? 'badge-green' : r.hook_strength === 'weak' ? 'badge-red' : 'badge-amber'}`}>
|
||||
{r.hook_strength || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{r.is_transition ? '✅' : '—'}</td>
|
||||
<td>{r.override_count || 0}</td>
|
||||
<td className={r.debt_balance > 0 ? 'debt-positive' : 'debt-normal'}>{(r.debt_balance || 0).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{data.length === 0 ? <div className="empty-state"><div className="empty-icon">🔥</div><p>暂无追读力数据</p></div> : null}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function findFirstFilePath(tree) {
|
||||
const roots = Object.values(tree || {})
|
||||
for (const items of roots) {
|
||||
const p = walkFirstFile(items)
|
||||
if (p) return p
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function walkFirstFile(items) {
|
||||
if (!Array.isArray(items)) return null
|
||||
for (const item of items) {
|
||||
if (item?.type === 'file' && item?.path) return item.path
|
||||
if (item?.type === 'dir' && Array.isArray(item.children)) {
|
||||
const p = walkFirstFile(item.children)
|
||||
if (p) return p
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// 数据总览内嵌:全量数据视图
|
||||
// ====================================================================
|
||||
|
||||
function MergedDataView() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [payload, setPayload] = useState({})
|
||||
const [domain, setDomain] = useState('overview')
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true)
|
||||
const requests = [
|
||||
['entities', fetchJSON('/api/entities')],
|
||||
['chapters', fetchJSON('/api/chapters')],
|
||||
['scenes', fetchJSON('/api/scenes', { limit: 200 })],
|
||||
['relationships', fetchJSON('/api/relationships', { limit: 300 })],
|
||||
['relationshipEvents', fetchJSON('/api/relationship-events', { limit: 200 })],
|
||||
['readingPower', fetchJSON('/api/reading-power', { limit: 100 })],
|
||||
['reviewMetrics', fetchJSON('/api/review-metrics', { limit: 50 })],
|
||||
['stateChanges', fetchJSON('/api/state-changes', { limit: 120 })],
|
||||
['aliases', fetchJSON('/api/aliases')],
|
||||
['overrides', fetchJSON('/api/overrides', { limit: 120 })],
|
||||
['debts', fetchJSON('/api/debts', { limit: 120 })],
|
||||
['debtEvents', fetchJSON('/api/debt-events', { limit: 150 })],
|
||||
['invalidFacts', fetchJSON('/api/invalid-facts', { limit: 120 })],
|
||||
['ragQueries', fetchJSON('/api/rag-queries', { limit: 150 })],
|
||||
['toolStats', fetchJSON('/api/tool-stats', { limit: 200 })],
|
||||
['checklistScores', fetchJSON('/api/checklist-scores', { limit: 120 })],
|
||||
]
|
||||
|
||||
const entries = await Promise.all(
|
||||
requests.map(async ([key, p]) => {
|
||||
try {
|
||||
const val = await p
|
||||
return [key, val]
|
||||
} catch {
|
||||
return [key, []]
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (!disposed) {
|
||||
setPayload(Object.fromEntries(entries))
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadAll()
|
||||
return () => { disposed = true }
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="loading">加载全量数据中…</div>
|
||||
|
||||
const groups = domain === 'overview'
|
||||
? FULL_DATA_GROUPS
|
||||
: FULL_DATA_GROUPS.filter(g => g.domain === domain)
|
||||
const totalRows = FULL_DATA_GROUPS.reduce((sum, g) => sum + (payload[g.key] || []).length, 0)
|
||||
const nonEmptyGroups = FULL_DATA_GROUPS.filter(g => (payload[g.key] || []).length > 0).length
|
||||
const maxChapter = FULL_DATA_GROUPS.reduce((max, g) => {
|
||||
const rows = payload[g.key] || []
|
||||
rows.slice(0, 120).forEach(r => {
|
||||
const c = extractChapter(r)
|
||||
if (c > max) max = c
|
||||
})
|
||||
return max
|
||||
}, 0)
|
||||
const domainStats = FULL_DATA_DOMAINS.filter(d => d.id !== 'overview').map(d => {
|
||||
const ds = FULL_DATA_GROUPS.filter(g => g.domain === d.id)
|
||||
const rowCount = ds.reduce((sum, g) => sum + (payload[g.key] || []).length, 0)
|
||||
const filled = ds.filter(g => (payload[g.key] || []).length > 0).length
|
||||
return { ...d, rowCount, filled, total: ds.length }
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-header section-page-header">
|
||||
<h2>🧪 全量数据视图</h2>
|
||||
<span className="card-badge badge-cyan">{FULL_DATA_GROUPS.length} 类数据源</span>
|
||||
</div>
|
||||
|
||||
<div className="demo-summary-grid">
|
||||
<div className="card stat-card">
|
||||
<span className="stat-label">总记录数</span>
|
||||
<span className="stat-value">{formatNumber(totalRows)}</span>
|
||||
<span className="stat-sub">当前返回的全部数据行</span>
|
||||
</div>
|
||||
<div className="card stat-card">
|
||||
<span className="stat-label">已覆盖数据源</span>
|
||||
<span className="stat-value plain">{nonEmptyGroups}/{FULL_DATA_GROUPS.length}</span>
|
||||
<span className="stat-sub">有数据的表 / 总表数</span>
|
||||
</div>
|
||||
<div className="card stat-card">
|
||||
<span className="stat-label">最新章节触达</span>
|
||||
<span className="stat-value plain">{maxChapter > 0 ? `第 ${maxChapter} 章` : '—'}</span>
|
||||
<span className="stat-sub">按可识别 chapter 字段估算</span>
|
||||
</div>
|
||||
<div className="card stat-card">
|
||||
<span className="stat-label">当前视图</span>
|
||||
<span className="stat-value plain">{FULL_DATA_DOMAINS.find(d => d.id === domain)?.label}</span>
|
||||
<span className="stat-sub">{groups.length} 个数据分组</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="demo-domain-tabs">
|
||||
{FULL_DATA_DOMAINS.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`demo-domain-tab ${domain === item.id ? 'active' : ''}`}
|
||||
onClick={() => setDomain(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{domain === 'overview' ? (
|
||||
<div className="demo-domain-grid">
|
||||
{domainStats.map(ds => (
|
||||
<div className="card" key={ds.id}>
|
||||
<div className="card-header">
|
||||
<span className="card-title">{ds.label}</span>
|
||||
<span className="card-badge badge-purple">{ds.filled}/{ds.total}</span>
|
||||
</div>
|
||||
<div className="domain-stat-number">{formatNumber(ds.rowCount)}</div>
|
||||
<div className="stat-sub">该数据域总记录数</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{groups.map(g => {
|
||||
const count = (payload[g.key] || []).length
|
||||
return (
|
||||
<div className="card demo-group-card" key={g.key}>
|
||||
<div className="card-header">
|
||||
<span className="card-title">{g.title}</span>
|
||||
<span className={`card-badge ${count > 0 ? 'badge-blue' : 'badge-amber'}`}>{count} 条</span>
|
||||
</div>
|
||||
<MiniTable
|
||||
rows={payload[g.key] || []}
|
||||
columns={g.columns}
|
||||
pageSize={12}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniTable({ rows, columns, pageSize = 12 }) {
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [rows, columns, pageSize])
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return <div className="empty-state compact"><p>暂无数据</p></div>
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize))
|
||||
const safePage = Math.min(page, totalPages)
|
||||
const start = (safePage - 1) * pageSize
|
||||
const list = rows.slice(start, start + pageSize)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>{columns.map(c => <th key={c}>{c}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map(c => (
|
||||
<td key={c} className="truncate" style={{ maxWidth: 240 }}>
|
||||
{formatCell(row?.[c])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="table-pagination">
|
||||
<button
|
||||
className="page-btn"
|
||||
type="button"
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={safePage <= 1}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="page-info">
|
||||
第 {safePage} / {totalPages} 页 · 共 {rows.length} 条
|
||||
</span>
|
||||
<button
|
||||
className="page-btn"
|
||||
type="button"
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={safePage >= totalPages}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function extractChapter(row) {
|
||||
if (!row || typeof row !== 'object') return 0
|
||||
const candidates = [
|
||||
row.chapter,
|
||||
row.start_chapter,
|
||||
row.end_chapter,
|
||||
row.chapter_discovered,
|
||||
row.first_appearance,
|
||||
row.last_appearance,
|
||||
]
|
||||
for (const c of candidates) {
|
||||
const n = Number(c)
|
||||
if (Number.isFinite(n) && n > 0) return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// 子组件:文件树递归
|
||||
// ====================================================================
|
||||
|
||||
function TreeNodes({ items, selected, onSelect, depth = 0 }) {
|
||||
const [expanded, setExpanded] = useState({})
|
||||
if (!items || items.length === 0) return null
|
||||
|
||||
return items.map((item, i) => {
|
||||
const key = item.path || `${depth}-${i}`
|
||||
if (item.type === 'dir') {
|
||||
const isOpen = expanded[key]
|
||||
return (
|
||||
<li key={key}>
|
||||
<div
|
||||
className="tree-item"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), setExpanded(prev => ({ ...prev, [key]: !prev[key] })))}
|
||||
onClick={() => setExpanded(prev => ({ ...prev, [key]: !prev[key] }))}
|
||||
>
|
||||
<span className="tree-icon">{isOpen ? '📂' : '📁'}</span>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{isOpen && item.children && (
|
||||
<ul className="tree-children">
|
||||
<TreeNodes items={item.children} selected={selected} onSelect={onSelect} depth={depth + 1} />
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<li key={key}>
|
||||
<div
|
||||
className={`tree-item ${selected === item.path ? 'active' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onSelect(item.path))}
|
||||
onClick={() => onSelect(item.path)}
|
||||
>
|
||||
<span className="tree-icon">📄</span>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// ====================================================================
|
||||
// 辅助:数字格式化
|
||||
// ====================================================================
|
||||
|
||||
function formatNumber(n) {
|
||||
if (n >= 10000) return new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 1 }).format(n / 10000) + ' 万'
|
||||
return new Intl.NumberFormat('zh-CN').format(n)
|
||||
}
|
||||
|
||||
function formatJSON(str) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(str), null, 2)
|
||||
} catch {
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
function formatCell(v) {
|
||||
if (v === null || v === undefined) return '—'
|
||||
if (typeof v === 'boolean') return v ? 'true' : 'false'
|
||||
if (typeof v === 'object') {
|
||||
try {
|
||||
return JSON.stringify(v)
|
||||
} catch {
|
||||
return String(v)
|
||||
}
|
||||
}
|
||||
const s = String(v)
|
||||
return s.length > 180 ? `${s.slice(0, 180)}...` : s
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Handoff_Diff_View.jsx - 状态交接人工审查 Commit 界面
|
||||
*
|
||||
* 特性:
|
||||
* - 展示 AI 生成的下一章 handoff.json Diff
|
||||
* - 支持人工微调
|
||||
* - Commit 后进入实际写作
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { fetchJSON } from './api.js'
|
||||
|
||||
export default function HandoffDiffView({ projectRoot, onCommit, onCancel }) {
|
||||
const [currentHandoff, setCurrentHandoff] = useState(null)
|
||||
const [proposedHandoff, setProposedHandoff] = useState(null)
|
||||
const [diff, setDiff] = useState(null)
|
||||
const [editedFrame, setEditedFrame] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [commitMessage, setCommitMessage] = useState('')
|
||||
|
||||
// 加载当前 Handoff Frame
|
||||
const loadHandoff = useCallback(async () => {
|
||||
try {
|
||||
// 实际应从 API 获取
|
||||
const current = await fetchJSON('/api/project/info')
|
||||
const handoff = current.handoff_frame || generateMockHandoff()
|
||||
|
||||
setCurrentHandoff(handoff)
|
||||
setProposedHandoff(handoff)
|
||||
setEditedFrame(handoff)
|
||||
setDiff(computeDiff(handoff, handoff))
|
||||
} catch (error) {
|
||||
console.error('Failed to load handoff:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadHandoff()
|
||||
}, [loadHandoff])
|
||||
|
||||
// 生成下一章的 Handoff 预测
|
||||
const generateNextHandoff = useCallback((current) => {
|
||||
return {
|
||||
...current,
|
||||
tick: current.tick + 1,
|
||||
chapter: (current.metadata?.chapter_number || 0) + 1,
|
||||
metadata: {
|
||||
...current.metadata,
|
||||
previous_chapter_summary: `第${current.metadata?.chapter_number || 1}章内容摘要`,
|
||||
next_chapter_hint: '根据当前状态自动生成'
|
||||
},
|
||||
world_state: {
|
||||
...current.world_state,
|
||||
tension_level: Math.max(0, (current.world_state?.tension_level || 50) - 20)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 计算 Diff
|
||||
const computeDiff = (current, proposed) => {
|
||||
if (!current || !proposed) return null
|
||||
|
||||
const changes = []
|
||||
const currentStr = JSON.stringify(current)
|
||||
const proposedStr = JSON.stringify(proposed)
|
||||
|
||||
if (currentStr !== proposedStr) {
|
||||
// 逐层比较
|
||||
const currentJson = current
|
||||
const proposedJson = proposed
|
||||
|
||||
// Active Characters
|
||||
if (JSON.stringify(currentJson.active_characters) !== JSON.stringify(proposedJson.active_characters)) {
|
||||
changes.push({
|
||||
section: 'active_characters',
|
||||
type: 'modified',
|
||||
before: currentJson.active_characters,
|
||||
after: proposedJson.active_characters
|
||||
})
|
||||
}
|
||||
|
||||
// World State
|
||||
if (JSON.stringify(currentJson.world_state) !== JSON.stringify(proposedJson.world_state)) {
|
||||
changes.push({
|
||||
section: 'world_state',
|
||||
type: 'modified',
|
||||
before: currentJson.world_state,
|
||||
after: proposedJson.world_state
|
||||
})
|
||||
}
|
||||
|
||||
// Metadata
|
||||
if (JSON.stringify(currentJson.metadata) !== JSON.stringify(proposedJson.metadata)) {
|
||||
changes.push({
|
||||
section: 'metadata',
|
||||
type: 'modified',
|
||||
before: currentJson.metadata,
|
||||
after: proposedJson.metadata
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
// 处理字段编辑
|
||||
const handleFieldEdit = (section, field, value) => {
|
||||
const updated = {
|
||||
...editedFrame,
|
||||
[section]: {
|
||||
...editedFrame[section],
|
||||
[field]: value
|
||||
}
|
||||
}
|
||||
setEditedFrame(updated)
|
||||
setDiff(computeDiff(currentHandoff, updated))
|
||||
}
|
||||
|
||||
// 处理 Commit
|
||||
const handleCommit = () => {
|
||||
if (!commitMessage.trim()) {
|
||||
alert('请输入 commit 消息')
|
||||
return
|
||||
}
|
||||
|
||||
const commitData = {
|
||||
handoff_frame: editedFrame,
|
||||
commit_message: commitMessage,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
onCommit?.(commitData)
|
||||
}
|
||||
|
||||
// 生成模拟 Handoff
|
||||
const generateMockHandoff = () => ({
|
||||
tick: 1,
|
||||
timestamp: Date.now(),
|
||||
active_characters: [
|
||||
{ id: 'protagonist', name: '主角', status: 'active', position: { x: 0, y: 0, z: 0 } }
|
||||
],
|
||||
suspended_characters: [],
|
||||
imminent_actions: [],
|
||||
world_state: {
|
||||
location: '天云宗',
|
||||
time_of_day: '早晨',
|
||||
weather: '晴',
|
||||
tension_level: 50,
|
||||
hook_pressure: 30
|
||||
},
|
||||
metadata: {
|
||||
chapter_number: 1,
|
||||
scene_number: 1,
|
||||
previous_chapter_summary: '开篇设定',
|
||||
next_chapter_hint: '引入冲突'
|
||||
}
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">加载 Handoff 数据中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="handoff-diff-view">
|
||||
<div className="diff-header">
|
||||
<h2>📋 Handoff 状态交接审查</h2>
|
||||
<p className="diff-subtitle">审查 AI 生成的下一章状态交接帧,确认后 Commit</p>
|
||||
</div>
|
||||
|
||||
<div className="diff-layout">
|
||||
{/* 左侧: 当前状态 */}
|
||||
<div className="diff-panel current">
|
||||
<h3>当前帧 (Tick {currentHandoff?.tick})</h3>
|
||||
<div className="diff-content">
|
||||
<HandoffPanel
|
||||
title="活跃角色"
|
||||
data={currentHandoff?.active_characters || []}
|
||||
type="characters"
|
||||
/>
|
||||
<HandoffPanel
|
||||
title="世界状态"
|
||||
data={currentHandoff?.world_state || {}}
|
||||
type="world_state"
|
||||
/>
|
||||
<HandoffPanel
|
||||
title="元数据"
|
||||
data={currentHandoff?.metadata || {}}
|
||||
type="metadata"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中间: Diff */}
|
||||
<div className="diff-panel changes">
|
||||
<h3>变更内容</h3>
|
||||
<div className="diff-changes">
|
||||
{diff && diff.length > 0 ? (
|
||||
diff.map((change, idx) => (
|
||||
<div key={idx} className={`diff-item diff-${change.type}`}>
|
||||
<div className="diff-section">{change.section}</div>
|
||||
<div className="diff-values">
|
||||
<div className="diff-before">
|
||||
<span className="label">-</span>
|
||||
<pre>{JSON.stringify(change.before, null, 2)}</pre>
|
||||
</div>
|
||||
<div className="diff-after">
|
||||
<span className="label">+</span>
|
||||
<pre>{JSON.stringify(change.after, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="no-changes">无变更</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧: 提议状态 */}
|
||||
<div className="diff-panel proposed">
|
||||
<h3>提议帧 (Tick {(editedFrame?.tick || currentHandoff?.tick) + 1})</h3>
|
||||
<div className="diff-content">
|
||||
<HandoffEditablePanel
|
||||
title="活跃角色"
|
||||
data={editedFrame?.active_characters || []}
|
||||
type="characters"
|
||||
onEdit={(field, value) => handleFieldEdit('active_characters', field, value)}
|
||||
/>
|
||||
<HandoffEditablePanel
|
||||
title="世界状态"
|
||||
data={editedFrame?.world_state || {}}
|
||||
type="world_state"
|
||||
onEdit={(field, value) => handleFieldEdit('world_state', field, value)}
|
||||
/>
|
||||
<HandoffEditablePanel
|
||||
title="元数据"
|
||||
data={editedFrame?.metadata || {}}
|
||||
type="metadata"
|
||||
onEdit={(field, value) => handleFieldEdit('metadata', field, value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Commit 控制区 */}
|
||||
<div className="commit-controls">
|
||||
<div className="commit-message">
|
||||
<label>Commit 消息:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
placeholder="描述本次交接的变更..."
|
||||
/>
|
||||
</div>
|
||||
<div className="commit-actions">
|
||||
<button className="btn-cancel" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn-commit" onClick={handleCommit}>
|
||||
✓ Commit & 继续写作
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HandoffPanel({ title, data, type }) {
|
||||
return (
|
||||
<div className="handoff-section">
|
||||
<h4>{title}</h4>
|
||||
<pre className="handoff-data">
|
||||
{type === 'characters'
|
||||
? data.map(c => `${c.name} (${c.status})`).join(', ')
|
||||
: JSON.stringify(data, null, 2)
|
||||
}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HandoffEditablePanel({ title, data, type, onEdit }) {
|
||||
return (
|
||||
<div className="handoff-section editable">
|
||||
<h4>{title}</h4>
|
||||
{type === 'characters' ? (
|
||||
<div className="character-list">
|
||||
{data.map((char, idx) => (
|
||||
<div key={char.id || idx} className="character-item">
|
||||
<input
|
||||
type="text"
|
||||
value={char.name}
|
||||
onChange={(e) => onEdit(`${idx}.name`, e.target.value)}
|
||||
/>
|
||||
<select
|
||||
value={char.status}
|
||||
onChange={(e) => onEdit(`${idx}.status`, e.target.value)}
|
||||
>
|
||||
<option value="active">活跃</option>
|
||||
<option value="dormant">休眠</option>
|
||||
<option value="suspended">悬置</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="field-editors">
|
||||
{Object.entries(data).map(([field, value]) => (
|
||||
<div key={field} className="field-editor">
|
||||
<label>{field}:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeof value === 'object' ? JSON.stringify(value) : value}
|
||||
onChange={(e) => onEdit(field, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Meta_Narrative_Panel.jsx - 元叙事开关与黑天鹅注入控制台
|
||||
*
|
||||
* 特性:
|
||||
* - 第四面墙 Toggle: 开启后关闭物理连贯性校验
|
||||
* - 黑天鹅注入: 一键注入等价对冲债务或平账大纲
|
||||
* - Hook 池监控: 显示当前压强状态
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { fetchJSON } from './api.js'
|
||||
|
||||
export default function MetaNarrativePanel({ projectRoot, onToggle, onInject }) {
|
||||
const [fourthWallToggle, setFourthWallToggle] = useState(false)
|
||||
const [hookPressure, setHookPressure] = useState(0)
|
||||
const [debtBalance, setDebtBalance] = useState(0)
|
||||
const [blackSwanOptions, setBlackSwanOptions] = useState([])
|
||||
const [selectedSwan, setSelectedSwan] = useState(null)
|
||||
const [swanParams, setSwanParams] = useState({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 加载 Hook 池状态
|
||||
const loadHookStatus = useCallback(async () => {
|
||||
try {
|
||||
const info = await fetchJSON('/api/project/info')
|
||||
const hooks = info.hooks_pool?.hooks || []
|
||||
const unresolvedCount = hooks.filter(h => !h.resolved).length
|
||||
const pressure = hooks.reduce((sum, h) => sum + (h.tension_weight || 0), 0)
|
||||
|
||||
setHookPressure(pressure)
|
||||
setDebtBalance(info.debt_balance || 0)
|
||||
|
||||
// 生成黑天鹅选项
|
||||
generateBlackSwanOptions(pressure, unresolvedCount)
|
||||
} catch (error) {
|
||||
console.error('Failed to load hook status:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadHookStatus()
|
||||
// 定期刷新
|
||||
const interval = setInterval(loadHookStatus, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [loadHookStatus])
|
||||
|
||||
// 生成黑天鹅注入选项
|
||||
const generateBlackSwanOptions = (pressure, unresolvedCount) => {
|
||||
const options = []
|
||||
|
||||
if (pressure > 80) {
|
||||
options.push({
|
||||
id: 'swan_release',
|
||||
label: '🕊️ 高压释放',
|
||||
description: '引入突发转折,大量释放压强',
|
||||
pressureImpact: -40,
|
||||
type: 'release'
|
||||
})
|
||||
}
|
||||
|
||||
if (unresolvedCount > 5) {
|
||||
options.push({
|
||||
id: 'swan_closure',
|
||||
label: '🔗 批量收束',
|
||||
description: '同时回收多个伏笔,认知闭合',
|
||||
pressureImpact: -20,
|
||||
type: 'closure'
|
||||
})
|
||||
}
|
||||
|
||||
if (debtBalance > 0) {
|
||||
options.push({
|
||||
id: 'swan_hedge',
|
||||
label: '⚖️ 对冲平账',
|
||||
description: '注入等价对冲债务,平衡账本',
|
||||
pressureImpact: 0,
|
||||
type: 'hedge'
|
||||
})
|
||||
}
|
||||
|
||||
options.push({
|
||||
id: 'swan_new_hook',
|
||||
label: '🎣 新增悬念',
|
||||
description: '引入全新悬念,增加压强',
|
||||
pressureImpact: 15,
|
||||
type: 'buildup'
|
||||
})
|
||||
|
||||
if (fourthWallToggle) {
|
||||
options.push({
|
||||
id: 'swan_meta',
|
||||
label: '🌀 元叙事突破',
|
||||
description: '打破第四面墙,引入反规则事件',
|
||||
pressureImpact: 30,
|
||||
type: 'meta',
|
||||
requiresFourthWall: true
|
||||
})
|
||||
}
|
||||
|
||||
setBlackSwanOptions(options)
|
||||
}
|
||||
|
||||
// 切换第四面墙
|
||||
const handleFourthWallToggle = () => {
|
||||
const newState = !fourthWallToggle
|
||||
setFourthWallToggle(newState)
|
||||
onToggle?.(newState)
|
||||
}
|
||||
|
||||
// 注入黑天鹅
|
||||
const handleInjectSwan = () => {
|
||||
if (!selectedSwan) {
|
||||
alert('请选择一个黑天鹅事件')
|
||||
return
|
||||
}
|
||||
|
||||
const swanConfig = {
|
||||
...selectedSwan,
|
||||
params: swanParams,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
onInject?.(swanConfig)
|
||||
}
|
||||
|
||||
// 获取压力颜色
|
||||
const getPressureColor = (pressure) => {
|
||||
if (pressure < 30) return 'var(--accent-green)'
|
||||
if (pressure < 60) return 'var(--accent-amber)'
|
||||
if (pressure < 80) return 'var(--accent-orange)'
|
||||
return 'var(--accent-red)'
|
||||
}
|
||||
|
||||
// 获取压力状态
|
||||
const getPressureStatus = (pressure) => {
|
||||
if (pressure < 30) return '低压 - 可积累'
|
||||
if (pressure < 60) return '中压 - 正常'
|
||||
if (pressure < 80) return '高压 - 警告'
|
||||
return '危险 - 急需释放'
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">加载中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="meta-narrative-panel">
|
||||
<div className="panel-header">
|
||||
<h2>🌀 元叙事控制台</h2>
|
||||
<p className="panel-subtitle">第四面墙切换与黑天鹅事件注入</p>
|
||||
</div>
|
||||
|
||||
{/* 第四面墙 Toggle */}
|
||||
<div className="control-section fourth-wall">
|
||||
<div className="section-header">
|
||||
<h3>第四面墙 Toggle</h3>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fourthWallToggle}
|
||||
onChange={handleFourthWallToggle}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="toggle-description">
|
||||
{fourthWallToggle ? (
|
||||
<p className="status-active">
|
||||
🟢 <strong>元叙事模式已激活</strong><br />
|
||||
<span>物理连贯性校验已关闭,允许"反规则"写法</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="status-inactive">
|
||||
⚪ <strong>正常叙事模式</strong><br />
|
||||
<span>物理连贯性校验正常执行</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="toggle-effects">
|
||||
<h4>开启后的效果:</h4>
|
||||
<ul>
|
||||
<li>✓ 关闭战力/境界一致性校验</li>
|
||||
<li>✓ 关闭时间线连续性检查</li>
|
||||
<li>✓ 允许梦境、意识流等反逻辑场景</li>
|
||||
<li>✓ 允许主角打破第四面墙</li>
|
||||
<li>⚠️ 可能产生世界观不一致风险</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hook 池监控 */}
|
||||
<div className="control-section hook-monitor">
|
||||
<div className="section-header">
|
||||
<h3>📊 Hook 池监控</h3>
|
||||
</div>
|
||||
<div className="pressure-display">
|
||||
<div className="pressure-gauge">
|
||||
<div
|
||||
className="pressure-fill"
|
||||
style={{
|
||||
width: `${Math.min(100, hookPressure)}%`,
|
||||
backgroundColor: getPressureColor(hookPressure)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="pressure-value">
|
||||
<span className="pressure-number" style={{ color: getPressureColor(hookPressure) }}>
|
||||
{hookPressure}
|
||||
</span>
|
||||
<span className="pressure-max">/ 100</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pressure-status" style={{ color: getPressureColor(hookPressure) }}>
|
||||
{getPressureStatus(hookPressure)}
|
||||
</div>
|
||||
<div className="debt-balance">
|
||||
<span>债务余额:</span>
|
||||
<span className={debtBalance > 0 ? 'debt-positive' : 'debt-normal'}>
|
||||
{debtBalance.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hookPressure > 80 && (
|
||||
<div className="pressure-warning">
|
||||
<span className="warning-icon">⚠️</span>
|
||||
<span>压强过高,建议立即注入黑天鹅事件释放</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 黑天鹅注入 */}
|
||||
<div className="control-section black-swan">
|
||||
<div className="section-header">
|
||||
<h3>🦢 黑天鹅事件注入</h3>
|
||||
</div>
|
||||
<p className="section-desc">
|
||||
点击下方选项注入突发事件,对冲债务或释放压强
|
||||
</p>
|
||||
|
||||
<div className="swan-options">
|
||||
{blackSwanOptions.map((option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={`swan-option ${selectedSwan?.id === option.id ? 'selected' : ''} ${option.requiresFourthWall && !fourthWallToggle ? 'disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (!option.requiresFourthWall || fourthWallToggle) {
|
||||
setSelectedSwan(option)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="swan-label">{option.label}</div>
|
||||
<div className="swan-desc">{option.description}</div>
|
||||
<div className="swan-impact">
|
||||
压强变化:{' '}
|
||||
<span className={option.pressureImpact < 0 ? 'negative' : option.pressureImpact > 0 ? 'positive' : ''}>
|
||||
{option.pressureImpact > 0 ? '+' : ''}{option.pressureImpact}
|
||||
</span>
|
||||
</div>
|
||||
{option.requiresFourthWall && !fourthWallToggle && (
|
||||
<div className="swan-requires">需要第四面墙开启</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedSwan && (
|
||||
<div className="swan-params">
|
||||
<h4>事件参数</h4>
|
||||
<div className="param-inputs">
|
||||
<div className="param-field">
|
||||
<label>事件名称:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={swanParams.name || ''}
|
||||
onChange={(e) => setSwanParams({ ...swanParams, name: e.target.value })}
|
||||
placeholder="输入事件名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="param-field">
|
||||
<label>影响章节数:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={swanParams.chapters || 1}
|
||||
onChange={(e) => setSwanParams({ ...swanParams, chapters: parseInt(e.target.value) })}
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="btn-inject"
|
||||
onClick={handleInjectSwan}
|
||||
disabled={!selectedSwan}
|
||||
>
|
||||
🦢 注入黑天鹅事件
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Retcon_Arbitrator.jsx - 设定修改冲突分析与补丁确认台
|
||||
*
|
||||
* 特性:
|
||||
* - 接收作者的设定修改需求
|
||||
* - 分析与现有账本/Hook池的冲突
|
||||
* - 生成兼容性补丁方案
|
||||
* - 人类确认后应用热补丁
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { fetchJSON } from './api.js'
|
||||
|
||||
export default function RetconArbitrator({ projectRoot, onApply, onCancel }) {
|
||||
const [authorIntent, setAuthorIntent] = useState('')
|
||||
const [analysisResult, setAnalysisResult] = useState(null)
|
||||
const [patchProposal, setPatchProposal] = useState(null)
|
||||
const [selectedOperations, setSelectedOperations] = useState({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// 分析 Retcon 兼容性
|
||||
const analyzeRetcon = useCallback(async () => {
|
||||
if (!authorIntent.trim()) {
|
||||
alert('请输入设定修改需求')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
// 模拟 API 调用
|
||||
const result = await mockAnalyzeRetcon(authorIntent)
|
||||
setAnalysisResult(result)
|
||||
|
||||
if (result.conflicts.length > 0) {
|
||||
setPatchProposal(result.patch)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Analysis failed:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [authorIntent])
|
||||
|
||||
// 应用补丁
|
||||
const applyPatch = useCallback(() => {
|
||||
const selectedOps = Object.entries(selectedOperations)
|
||||
.filter(([_, selected]) => selected)
|
||||
.map(([opId, _]) => patchProposal.operations.find(op => op.id === opId))
|
||||
|
||||
const patch = {
|
||||
...patchProposal,
|
||||
operations: selectedOps
|
||||
}
|
||||
|
||||
onApply?.(patch)
|
||||
}, [selectedOperations, patchProposal, onApply])
|
||||
|
||||
// 切换操作选择
|
||||
const toggleOperation = (opId) => {
|
||||
setSelectedOperations(prev => ({
|
||||
...prev,
|
||||
[opId]: !prev[opId]
|
||||
}))
|
||||
}
|
||||
|
||||
// 模拟分析结果
|
||||
const mockAnalyzeRetcon = (intent) => {
|
||||
// 简单模拟:当输入包含特定关键词时触发冲突
|
||||
const conflicts = []
|
||||
const patch = {
|
||||
id: `retcon_${Date.now()}`,
|
||||
generated_at: Date.now(),
|
||||
operations: [],
|
||||
compatibility_issues: []
|
||||
}
|
||||
|
||||
if (intent.includes('克苏鲁') || intent.includes('邪神')) {
|
||||
conflicts.push({
|
||||
element_id: 'asset_haoqi_sword',
|
||||
element_name: '浩然正气剑',
|
||||
element_type: 'asset',
|
||||
original_value: '正道至宝',
|
||||
proposed_change: '与克苏鲁设定冲突',
|
||||
compatibility_score: 25,
|
||||
resolution: '将浩然正气剑转化为上古邪神骨殖的伪装'
|
||||
})
|
||||
|
||||
patch.operations.push({
|
||||
id: 'op_1',
|
||||
type: 'relabel',
|
||||
target_type: 'asset',
|
||||
target_id: 'asset_haoqi_sword',
|
||||
old_value: { name: '浩然正气剑', type: 'skill', value: 80 },
|
||||
new_value: { name: '邪神骨殖剑', type: 'skill', value: 80, tags: ['corrupted', 'dangerous'] },
|
||||
description: '将正道之物转化为邪神相关设定'
|
||||
})
|
||||
|
||||
patch.operations.push({
|
||||
id: 'op_2',
|
||||
type: 'create',
|
||||
target_type: 'liability',
|
||||
target_id: 'liab_haoqi_curse',
|
||||
old_value: null,
|
||||
new_value: {
|
||||
id: 'liab_haoqi_curse',
|
||||
type: 'unresolved_conflict',
|
||||
name: '浩然正气反噬',
|
||||
description: '正气剑被邪神侵蚀后的反噬',
|
||||
severity: 60
|
||||
},
|
||||
description: '创建反噬负债'
|
||||
})
|
||||
|
||||
patch.compatibility_issues.push('资产"浩然正气剑"被标记为不兼容,转化为对冲负债')
|
||||
}
|
||||
|
||||
if (intent.includes('穿越') || intent.includes('异世界')) {
|
||||
conflicts.push({
|
||||
element_id: 'world_rule_cultivation',
|
||||
element_name: '修仙世界观',
|
||||
element_type: 'world_rule',
|
||||
original_value: '传统修仙',
|
||||
proposed_change: '引入穿越元素',
|
||||
compatibility_score: 60,
|
||||
resolution: '将穿越者设定为域外天魔'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
author_intent: intent,
|
||||
conflicts,
|
||||
patch,
|
||||
compatibility_score: conflicts.length > 0
|
||||
? Math.min(...conflicts.map(c => c.compatibility_score))
|
||||
: 100
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="retcon-arbitrator">
|
||||
<div className="arbi-header">
|
||||
<h2>🔧 Retcon 仲裁台</h2>
|
||||
<p className="arbi-subtitle">
|
||||
输入设定修改需求,系统分析冲突并生成补丁方案
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 输入区 */}
|
||||
<div className="arbi-input-section">
|
||||
<label>设定修改需求:</label>
|
||||
<textarea
|
||||
value={authorIntent}
|
||||
onChange={(e) => setAuthorIntent(e.target.value)}
|
||||
placeholder="描述你想要修改的设定,例如: - 将修仙世界观转为克苏鲁修仙 - 主角穿越到异世界 - 移除某个配角的反派设定"
|
||||
rows={5}
|
||||
/>
|
||||
<button
|
||||
className="btn-analyze"
|
||||
onClick={analyzeRetcon}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '分析中...' : '⚡ 分析冲突'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 分析结果 */}
|
||||
{analysisResult && (
|
||||
<div className="arbi-analysis">
|
||||
<div className="analysis-summary">
|
||||
<div className={`compatibility-badge ${analysisResult.compatibility_score >= 70 ? 'high' : analysisResult.compatibility_score >= 40 ? 'medium' : 'low'}`}>
|
||||
兼容性: {analysisResult.compatibility_score}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 冲突列表 */}
|
||||
{analysisResult.conflicts.length > 0 && (
|
||||
<div className="conflicts-section">
|
||||
<h3>⚠️ 发现 {analysisResult.conflicts.length} 个冲突</h3>
|
||||
<div className="conflicts-list">
|
||||
{analysisResult.conflicts.map((conflict, idx) => (
|
||||
<div key={idx} className="conflict-card">
|
||||
<div className="conflict-header">
|
||||
<span className="element-name">{conflict.element_name}</span>
|
||||
<span className={`compatibility-score ${conflict.compatibility_score >= 70 ? 'high' : conflict.compatibility_score >= 40 ? 'medium' : 'low'}`}>
|
||||
{conflict.compatibility_score}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="conflict-body">
|
||||
<p><strong>原值:</strong> {conflict.original_value}</p>
|
||||
<p><strong>变更:</strong> {conflict.proposed_change}</p>
|
||||
<p><strong>建议:</strong> {conflict.resolution}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysisResult.conflicts.length === 0 && (
|
||||
<div className="no-conflicts">
|
||||
<p>✓ 未发现冲突,可以直接应用变更</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 补丁方案 */}
|
||||
{patchProposal && patchProposal.operations.length > 0 && (
|
||||
<div className="patch-section">
|
||||
<h3>📦 补丁方案</h3>
|
||||
<div className="operations-list">
|
||||
{patchProposal.operations.map((op) => (
|
||||
<div
|
||||
key={op.id}
|
||||
className={`operation-card ${selectedOperations[op.id] ? 'selected' : ''}`}
|
||||
onClick={() => toggleOperation(op.id)}
|
||||
>
|
||||
<div className="op-header">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!selectedOperations[op.id]}
|
||||
onChange={() => toggleOperation(op.id)}
|
||||
/>
|
||||
<span className="op-type">{op.type}</span>
|
||||
<span className="op-target">{op.target_type}: {op.target_id}</span>
|
||||
</div>
|
||||
<div className="op-body">
|
||||
<p className="op-description">{op.description}</p>
|
||||
<div className="op-values">
|
||||
<div className="op-old">
|
||||
<span>旧值:</span>
|
||||
<pre>{JSON.stringify(op.old_value, null, 2)}</pre>
|
||||
</div>
|
||||
<div className="op-new">
|
||||
<span>新值:</span>
|
||||
<pre>{JSON.stringify(op.new_value, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制区 */}
|
||||
<div className="arbi-controls">
|
||||
<button className="btn-cancel" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="btn-apply"
|
||||
onClick={applyPatch}
|
||||
disabled={!patchProposal || Object.values(selectedOperations).every(v => !v)}
|
||||
>
|
||||
✓ 应用选定补丁
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* API 请求工具函数
|
||||
*/
|
||||
|
||||
const BASE = ''; // 开发时由 vite proxy 代理到 FastAPI
|
||||
|
||||
export async function fetchJSON(path, params = {}) {
|
||||
const url = new URL(path, window.location.origin);
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null) url.searchParams.set(k, v);
|
||||
});
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅 SSE 实时事件流
|
||||
* @param {function} onMessage 收到 data 时回调
|
||||
* @param {{onOpen?: function, onError?: function}} handlers 连接状态回调
|
||||
* @returns {function} 取消订阅函数
|
||||
*/
|
||||
export function subscribeSSE(onMessage, handlers = {}) {
|
||||
const { onOpen, onError } = handlers
|
||||
const es = new EventSource(`${BASE}/api/events`);
|
||||
es.onopen = () => {
|
||||
if (onOpen) onOpen()
|
||||
};
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
onMessage(JSON.parse(e.data));
|
||||
} catch { /* ignore parse errors */ }
|
||||
};
|
||||
es.onerror = (e) => {
|
||||
// EventSource 会自动重连,这里只更新连接状态
|
||||
if (onError) onError(e)
|
||||
};
|
||||
return () => es.close();
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Noto+Sans+SC:wght@400;500;700&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-main: #fff7e8;
|
||||
--bg-panel: #fffdf6;
|
||||
--bg-card: #fffaf0;
|
||||
--bg-card-2: #fff3d5;
|
||||
--text-main: #2a220f;
|
||||
--text-sub: #5d5035;
|
||||
--text-mute: #8f7f5c;
|
||||
--accent-blue: #26a8ff;
|
||||
--accent-purple: #7f5af0;
|
||||
--accent-green: #2ec27e;
|
||||
--accent-amber: #f5a524;
|
||||
--accent-red: #d7263d;
|
||||
--accent-cyan: #00b8d4;
|
||||
--border-main: #2a220f;
|
||||
--border-soft: #8f7f5c;
|
||||
--shadow-main: 6px 6px 0 #2a220f;
|
||||
--shadow-soft: 3px 3px 0 #8f7f5c;
|
||||
--font-display: 'Press Start 2P', monospace;
|
||||
--font-body: 'Noto Sans SC', 'Microsoft YaHei', 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 3px dashed var(--accent-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
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: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 3px solid var(--border-main);
|
||||
background: linear-gradient(180deg, #ffe8b8 0%, #ffe19f 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 16px;
|
||||
border-bottom: 3px solid var(--border-main);
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.sidebar-header .subtitle {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-sub);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
border: 2px solid var(--border-main);
|
||||
background: #fff9e8;
|
||||
color: var(--text-main);
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-soft);
|
||||
transition: transform 0.08s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
transform: translate(-1px, -1px);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: #dff3ff;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.nav-item .icon {
|
||||
width: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.live-indicator {
|
||||
border-top: 3px solid var(--border-main);
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--accent-green);
|
||||
border: 2px solid var(--border-main);
|
||||
}
|
||||
|
||||
.live-dot.disconnected {
|
||||
background: var(--accent-red);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-page-header {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 3px solid var(--border-main);
|
||||
box-shadow: var(--shadow-main);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
border: 2px solid var(--border-main);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 3px 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 28px;
|
||||
line-height: 1.15;
|
||||
margin: 6px 0 2px;
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.stat-card .stat-value.plain {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
margin-top: 8px;
|
||||
height: 12px;
|
||||
border: 2px solid var(--border-main);
|
||||
background: #f8e3b8;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #26a8ff, #7f5af0);
|
||||
}
|
||||
|
||||
.dashboard-section-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.strand-bar {
|
||||
height: 12px;
|
||||
border: 2px solid var(--border-main);
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.strand-bar .segment { height: 100%; }
|
||||
.strand-quest { background: #26a8ff; }
|
||||
.strand-fire { background: #ff5c8a; }
|
||||
.strand-constellation { background: #7f5af0; }
|
||||
|
||||
.strand-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
.demo-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.demo-domain-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.demo-domain-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.domain-stat-number {
|
||||
font-size: 30px;
|
||||
color: var(--accent-purple);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.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: 14px;
|
||||
font-family: var(--font-body);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 2px solid var(--border-soft);
|
||||
background: var(--bg-card-2);
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #d8ccb2;
|
||||
color: var(--text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover td {
|
||||
background: #fff4d8;
|
||||
}
|
||||
|
||||
.table-foot-note {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background: #e6f7ff;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-sub);
|
||||
}
|
||||
|
||||
.split-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 340px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.split-main,
|
||||
.split-side {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entity-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.entity-row.selected td {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.entity-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.entity-name.protagonist {
|
||||
color: #b86a00;
|
||||
}
|
||||
|
||||
.entity-detail {
|
||||
font-size: 14px;
|
||||
color: var(--text-sub);
|
||||
line-height: 1.7;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.entity-detail code {
|
||||
border: 1px solid var(--border-soft);
|
||||
padding: 1px 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.entity-desc {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.entity-history {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.graph-shell {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
height: calc(100vh - 120px);
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.chapter-no {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chapter-characters {
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.file-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
height: calc(100vh - 130px);
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.file-content-pane > div {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.folder-block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.folder-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.selected-path {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-mute);
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-item:hover {
|
||||
background: #fff4d8;
|
||||
border-color: #e0c98d;
|
||||
}
|
||||
|
||||
.tree-item.active {
|
||||
background: #e6f7ff;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.tree-icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.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: 32px 14px;
|
||||
color: var(--text-sub);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.file-content-pane .empty-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state.compact {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.empty-state .empty-icon {
|
||||
font-size: 40px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
border: 2px solid var(--border-main);
|
||||
background: #fff8e6;
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: #e6f7ff;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.split-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.graph-shell {
|
||||
height: calc(100vh - 128px);
|
||||
min-height: 460px;
|
||||
}
|
||||
|
||||
.file-layout {
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
min-height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.app-layout {
|
||||
grid-template-columns: 84px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar-header h1,
|
||||
.sidebar-header .subtitle,
|
||||
.nav-item span:not(.icon),
|
||||
.live-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right-width: 2px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.graph-shell {
|
||||
height: calc(100vh - 108px);
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.file-content-pane {
|
||||
height: calc(100vh - 430px);
|
||||
min-height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page-header h2 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.dashboard-grid,
|
||||
.demo-summary-grid,
|
||||
.demo-domain-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://127.0.0.1:8765',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user