feat: initial commit

This commit is contained in:
2026-06-23 20:29:02 +08:00
commit ea8f6066c4
217 changed files with 60754 additions and 0 deletions
@@ -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>
</>
)
}
// ====================================================================
// 页面 33D 宇宙关系图谱
// ====================================================================
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="描述你想要修改的设定,例如:&#10;- 将修仙世界观转为克苏鲁修仙&#10;- 主角穿越到异世界&#10;- 移除某个配角的反派设定"
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,
},
})