270 lines
14 KiB
TypeScript
270 lines
14 KiB
TypeScript
// 文档驾驶舱 I3:阅读器(连续正文 + 版本摘要/差异折叠)与右侧依据/影响面板。
|
||
// 消费 I1/I3 命令面 rdm.cockpit.node(+versions)/node.history(Dev 仓 docs/superpowers
|
||
// specs 2026-10-16 view-spec §5–§6)。不新增第四栏:右栏是选中节点的同一依据面,
|
||
// 全景与阅读器共享 selectedNodeRef;面板刷新原位进行,不抢占滚动位置(DV-R05/§5)。
|
||
// 折叠只用 @ui Collapsible;颜色/字号只消费 @ui/tokens.css。
|
||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import { api } from '../api'
|
||
import { Collapsible, EmptyState } from '@ui'
|
||
import {
|
||
assembleReader, diffLines, historyLine, mergeHistoryStrict, versionSummary,
|
||
PANEL_TABS, type CockpitContentBlock, type CockpitHistorySummary,
|
||
type CockpitMeta, type CockpitNodeRef, type CockpitTreeNode,
|
||
type CockpitVersionSummary, type Density, type PanelTabId,
|
||
} from '../documentCockpit'
|
||
import type { CitationView } from '../regCitations'
|
||
import { CitedBody } from './RegCitation'
|
||
|
||
export interface ReaderSelection { ref: CockpitNodeRef; parentDocId?: string }
|
||
|
||
interface NodeDetailShape {
|
||
meta: CockpitMeta
|
||
node: CockpitTreeNode
|
||
body?: CockpitContentBlock[]
|
||
/** U7:章节正文 [cit-…] 标记的引用视图(契约 §3)。 */
|
||
citations?: CitationView[] | null
|
||
children: CockpitTreeNode[]
|
||
sources: { items: any[] }
|
||
history: { items: CockpitHistorySummary[]; hasMore: boolean; nextCursor?: string }
|
||
impacts: { items: any[] }
|
||
conflicts: { items: any[] }
|
||
versions?: CockpitVersionSummary[]
|
||
}
|
||
|
||
function useNodeDetail(pid: string, sel?: CockpitNodeRef) {
|
||
const [detail, setDetail] = useState<NodeDetailShape | null>(null)
|
||
const [err, setErr] = useState('')
|
||
const gen = useRef(0)
|
||
useEffect(() => {
|
||
const g = ++gen.current
|
||
setDetail(null); setErr('')
|
||
if (!sel) return
|
||
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: sel.kind, id: sel.id })
|
||
.then(d => {
|
||
if (g !== gen.current) return
|
||
// 后端空列表可能投影为 null(Go nil slice);统一归一,消费端按数组使用。
|
||
for (const k of ['sources', 'history', 'impacts', 'conflicts'] as const) {
|
||
if (d?.[k] && !Array.isArray(d[k].items)) d[k].items = []
|
||
}
|
||
setDetail(d)
|
||
})
|
||
.catch(e => { if (g === gen.current) setErr(e.message) })
|
||
return () => { gen.current++ }
|
||
}, [pid, sel?.kind, sel?.id])
|
||
return { detail, err }
|
||
}
|
||
|
||
/** 版本块:当前版本 + 前一版本摘要;完整差异(行级标记)折叠。受限只给元数据。 */
|
||
export function VersionBlock({ versions, density }: { versions?: CockpitVersionSummary[]; density: Density }) {
|
||
if (!versions || versions.length === 0) return null
|
||
const { current, previous, redacted } = versionSummary(versions)
|
||
if (redacted || !current || current.content === undefined) {
|
||
return <div className="empty-note">版本受限(仅元数据可见)</div>
|
||
}
|
||
const prev = previous && previous.content !== undefined ? previous : undefined
|
||
return (
|
||
<Collapsible
|
||
id={`cockpit-versions-${current.version}`}
|
||
trigger={<span className="cockpit-versions-toggle">版本 {current.version}{previous ? `(前一版 ${previous.version})` : '(首版)'}与完整差异</span>}
|
||
>
|
||
<div className={`cockpit-versions cockpit-density-${density}`}>
|
||
{prev ? (
|
||
<>
|
||
<div className="cockpit-versions-head">当前 {current.version} · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
|
||
<pre className="cockpit-diff">
|
||
{diffLines(current.content || '', prev.content || '').map((l, i) => (
|
||
<div key={i} className={`cockpit-diff-${l.flag === '=' ? 'same' : l.flag === '+' ? 'add' : 'del'}`}>{l.flag} {l.text}</div>
|
||
))}
|
||
</pre>
|
||
<div className="cockpit-versions-head">前一版 {prev.version} · {prev.actor || '未知'} · {new Date(prev.at).toLocaleString('zh-CN')}</div>
|
||
</>
|
||
) : (
|
||
<div className="cockpit-versions-head">首版 · {current.actor || '未知'} · {new Date(current.at).toLocaleString('zh-CN')}</div>
|
||
)}
|
||
</div>
|
||
</Collapsible>
|
||
)
|
||
}
|
||
|
||
/** 阅读器中栏:选中章节=该节连续正文+版本块;选中文档=全章节连续装配(每节详情按需拉取)。 */
|
||
export function CockpitReader({ pid, selection, sections, density, onBack }: {
|
||
pid: string
|
||
selection: ReaderSelection
|
||
sections: CockpitTreeNode[]
|
||
density: Density
|
||
onBack: () => void
|
||
}) {
|
||
const { detail, err } = useNodeDetail(pid, selection.ref)
|
||
const isSection = selection.ref.kind === 'section'
|
||
const [sectionDetails, setSectionDetails] = useState<Record<string, NodeDetailShape | undefined>>({})
|
||
const gen = useRef(0)
|
||
// 文档阅读器:按树顺序装配全部章节详情(上限 20;失败章节给受限占位不拖垮整篇)。
|
||
useEffect(() => {
|
||
if (isSection || selection.ref.kind !== 'document') return
|
||
const g = ++gen.current
|
||
const list = sections.slice(0, 20)
|
||
setSectionDetails({})
|
||
for (const sec of list) {
|
||
api.rdmCommand<any>('rdm.cockpit.node', { projectId: pid, kind: 'section', id: sec.ref.id })
|
||
.then(d => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: d })) })
|
||
.catch(() => { if (g === gen.current) setSectionDetails(prev => ({ ...prev, [sec.ref.id]: undefined })) })
|
||
}
|
||
return () => { gen.current++ }
|
||
}, [pid, isSection, selection.ref.id, sections])
|
||
const anchorRef = useRef<HTMLDivElement | null>(null)
|
||
useEffect(() => { anchorRef.current?.scrollIntoView({ block: 'start' }) }, [selection.ref.id])
|
||
if (err) return <EmptyState icon="alert">阅读器加载失败:{err}</EmptyState>
|
||
return (
|
||
<div className={`cockpit-reader cockpit-density-${density}`}>
|
||
<div className="cockpit-nodehead" ref={anchorRef}>
|
||
<button type="button" className="cockpit-back" onClick={onBack}>← 返回全景</button>
|
||
<span className="cockpit-nodetitle">{detail?.node?.title || (isSection ? '章节' : '文档')}</span>
|
||
</div>
|
||
{isSection && detail && (
|
||
<>
|
||
{renderBody(detail.body, detail.meta, detail.citations, `sec-${selection.ref.id}`)}
|
||
<VersionBlock versions={detail.versions} density={density} />
|
||
</>
|
||
)}
|
||
{!isSection && (
|
||
<>
|
||
{sections.length === 0 && <div className="empty-note">该文档暂无章节</div>}
|
||
{assembleReader(sections, sectionDetails).map(sec => (
|
||
<section key={sec.ref.id} id={`sec-${sec.ref.id}`} className="cockpit-reader-section">
|
||
<div className="cockpit-reader-sechead">
|
||
<span className="cockpit-reader-sectitle">{sec.title}</span>
|
||
{sec.blocks[0]?.kind === 'restricted'
|
||
? <span className="cockpit-badge">内容受限({sec.blocks[0].reason || '权限'})</span>
|
||
: sec.contentState === 'empty' && <span className="cockpit-badge">空</span>}
|
||
</div>
|
||
{sec.blocks[0]?.kind === 'text' && (
|
||
<CitedBody texts={sec.blocks.filter(b => b.kind === 'text').map(b => b.text || '')}
|
||
citations={sectionDetails[sec.ref.id]?.citations} scope={`doc-${sec.ref.id}`} />
|
||
)}
|
||
{sec.blocks.map((b, i) => b.kind !== 'text' && (
|
||
<p key={i} className="empty-note">内容受限——标题与状态可见,正文不展示</p>
|
||
))}
|
||
<VersionBlock versions={sec.versions} density={density} />
|
||
</section>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta, citations: CitationView[] | null | undefined, scope: string) {
|
||
if (!body || body.length === 0) return <div className="empty-note">暂无正文</div>
|
||
if (body[0].kind === 'restricted') {
|
||
return <div className="empty-note">内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
|
||
}
|
||
return (
|
||
<>
|
||
<CitedBody texts={body.map(b => b.text || '')} citations={citations} scope={scope} />
|
||
{meta.truncated && <div className="cockpit-truncated">详情已截断:{meta.truncationReasons?.join(';')}</div>}
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 右栏:依据/历史/影响/冲突四标签(同一选中节点;刷新原位,不抢占滚动)。 */
|
||
export function CockpitPanel({ pid, selection, tab, onTab, density }: {
|
||
pid: string
|
||
selection: ReaderSelection
|
||
tab: PanelTabId
|
||
onTab: (t: PanelTabId) => void
|
||
density: Density
|
||
}) {
|
||
const { detail, err } = useNodeDetail(pid, selection.ref)
|
||
const [historyItems, setHistoryItems] = useState<CockpitHistorySummary[]>([])
|
||
const [historyMore, setHistoryMore] = useState(false)
|
||
const [historyCursor, setHistoryCursor] = useState<string | undefined>(undefined)
|
||
useEffect(() => {
|
||
setHistoryItems([]); setHistoryMore(false); setHistoryCursor(undefined)
|
||
}, [selection.ref.kind, selection.ref.id])
|
||
useEffect(() => {
|
||
if (!detail?.history) return
|
||
// 严格合并:只接受 seq 更新的条目,乱序/重复丢弃;不重置已渲染内容。
|
||
setHistoryItems(prev => mergeHistoryStrict(prev, detail.history.items).items)
|
||
setHistoryMore(detail.history.hasMore)
|
||
setHistoryCursor(detail.history.nextCursor)
|
||
}, [detail])
|
||
const loadMoreHistory = useCallback(() => {
|
||
if (!historyCursor) return
|
||
api.rdmCommand<any>('rdm.cockpit.node.history', { projectId: pid, kind: selection.ref.kind, id: selection.ref.id, cursor: historyCursor, limit: 20 })
|
||
.then(page => {
|
||
setHistoryItems(prev => mergeHistoryStrict(prev, page.items).items)
|
||
setHistoryMore(page.hasMore)
|
||
setHistoryCursor(page.nextCursor)
|
||
})
|
||
.catch(() => { /* 保留已渲染历史;可再次点击重试 */ })
|
||
}, [pid, selection.ref.kind, selection.ref.id, historyCursor])
|
||
const node = detail?.node
|
||
return (
|
||
<div className={`cockpit-panel cockpit-density-${density}`}>
|
||
<div className="cockpit-panel-tabs" role="tablist" aria-label="依据与影响">
|
||
{PANEL_TABS.map(t => (
|
||
<button key={t.id} type="button" role="tab" aria-selected={tab === t.id}
|
||
className={`cockpit-panel-tab${tab === t.id ? ' cockpit-panel-tab-active' : ''}`}
|
||
onClick={() => onTab(t.id)}>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{err && <div className="empty-note">依据面板加载失败:{err}</div>}
|
||
{!err && !detail && <div className="empty-note">加载依据…</div>}
|
||
{detail && tab === 'sources' && (
|
||
<div className="cockpit-panel-body">
|
||
<div className="cockpit-panel-count">来源 {node?.state.sourceCount ?? detail.sources.items.length} 条</div>
|
||
{detail.sources.items.length === 0 && <div className="empty-note">无来源——章节正文未挂依据切片</div>}
|
||
{detail.sources.items.map((s: any) => (
|
||
<div key={s.ref.id} className="cockpit-panel-row">
|
||
<span className="cockpit-panel-kind">{s.ref.kind}</span>
|
||
<span className="cockpit-panel-label">{s.label}</span>
|
||
{s.confidence && <span className="cockpit-badge">置信度 {s.confidence}</span>}
|
||
{s.redactedReason
|
||
? <span className="cockpit-badge">受限({s.redactedReason})</span>
|
||
: s.verification && s.verification !== 'not_applicable' && <span className="cockpit-badge">{s.verification}</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{detail && tab === 'history' && (
|
||
<div className="cockpit-panel-body">
|
||
<div className="cockpit-panel-count">已提交事件(最新在前)</div>
|
||
{historyItems.length === 0 && <div className="empty-note">暂无历史</div>}
|
||
{historyItems.map(h => <div key={h.seq} className="cockpit-panel-row">{historyLine(h)}</div>)}
|
||
{historyMore && <button type="button" className="cockpit-tree-more" onClick={loadMoreHistory}>加载更早历史</button>}
|
||
</div>
|
||
)}
|
||
{detail && tab === 'impacts' && (
|
||
<div className="cockpit-panel-body">
|
||
<div className="cockpit-panel-count">影响 {node?.state.impactCount ?? detail.impacts.items.length} 项(一跳)</div>
|
||
{detail.impacts.items.length === 0 && <div className="empty-note">无一跳影响对象</div>}
|
||
{detail.impacts.items.map((r: any, i: number) => (
|
||
<div key={`${r.ref.kind}-${r.ref.id}-${i}`} className="cockpit-panel-row">
|
||
<span className="cockpit-panel-kind">{r.direction === 'incoming' ? '←' : '→'}</span>
|
||
<span className="cockpit-panel-label">{r.ref.kind}:{r.ref.id}</span>
|
||
<span className="cockpit-badge">{r.edgeType}</span>
|
||
<span className="cockpit-badge">{r.edgeState}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{detail && tab === 'conflicts' && (
|
||
<div className="cockpit-panel-body">
|
||
<div className="cockpit-panel-count">冲突 {node?.state.conflictCount ?? 0} 项</div>
|
||
{detail.conflicts.items.length === 0 && <div className="empty-note">无开放冲突</div>}
|
||
{detail.conflicts.items.map(c => (
|
||
<div key={c.id} className="cockpit-panel-row cockpit-panel-conflict">
|
||
<span className="cockpit-badge">{c.state === 'open' ? '开放' : c.state}</span>
|
||
<div className="cockpit-conflict-side">甲方:{c.left.visibility === 'full' ? c.left.claim : `受限(${c.left.redactedReason || '权限'})`}</div>
|
||
<div className="cockpit-conflict-side">乙方:{c.right.visibility === 'full' ? c.right.claim : `受限(${c.right.redactedReason || '权限'})`}</div>
|
||
<div className="cockpit-changetime">{new Date(c.detectedAt).toLocaleString('zh-CN')}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|