agent-exec transport 4cf12d612fc8a946cd63c630834d3beb/35d799203497
This commit is contained in:
@@ -12,6 +12,8 @@ import {
|
|||||||
type CockpitMeta, type CockpitNodeRef, type CockpitTreeNode,
|
type CockpitMeta, type CockpitNodeRef, type CockpitTreeNode,
|
||||||
type CockpitVersionSummary, type Density, type PanelTabId,
|
type CockpitVersionSummary, type Density, type PanelTabId,
|
||||||
} from '../documentCockpit'
|
} from '../documentCockpit'
|
||||||
|
import type { CitationView } from '../regCitations'
|
||||||
|
import { CitedBody } from './RegCitation'
|
||||||
|
|
||||||
export interface ReaderSelection { ref: CockpitNodeRef; parentDocId?: string }
|
export interface ReaderSelection { ref: CockpitNodeRef; parentDocId?: string }
|
||||||
|
|
||||||
@@ -19,6 +21,8 @@ interface NodeDetailShape {
|
|||||||
meta: CockpitMeta
|
meta: CockpitMeta
|
||||||
node: CockpitTreeNode
|
node: CockpitTreeNode
|
||||||
body?: CockpitContentBlock[]
|
body?: CockpitContentBlock[]
|
||||||
|
/** U7:章节正文 [cit-…] 标记的引用视图(契约 §3)。 */
|
||||||
|
citations?: CitationView[] | null
|
||||||
children: CockpitTreeNode[]
|
children: CockpitTreeNode[]
|
||||||
sources: { items: any[] }
|
sources: { items: any[] }
|
||||||
history: { items: CockpitHistorySummary[]; hasMore: boolean; nextCursor?: string }
|
history: { items: CockpitHistorySummary[]; hasMore: boolean; nextCursor?: string }
|
||||||
@@ -118,7 +122,7 @@ export function CockpitReader({ pid, selection, sections, density, onBack }: {
|
|||||||
</div>
|
</div>
|
||||||
{isSection && detail && (
|
{isSection && detail && (
|
||||||
<>
|
<>
|
||||||
{renderBody(detail.body, detail.meta)}
|
{renderBody(detail.body, detail.meta, detail.citations, `sec-${selection.ref.id}`)}
|
||||||
<VersionBlock versions={detail.versions} density={density} />
|
<VersionBlock versions={detail.versions} density={density} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -133,9 +137,13 @@ export function CockpitReader({ pid, selection, sections, density, onBack }: {
|
|||||||
? <span className="cockpit-badge">内容受限({sec.blocks[0].reason || '权限'})</span>
|
? <span className="cockpit-badge">内容受限({sec.blocks[0].reason || '权限'})</span>
|
||||||
: sec.contentState === 'empty' && <span className="cockpit-badge">空</span>}
|
: sec.contentState === 'empty' && <span className="cockpit-badge">空</span>}
|
||||||
</div>
|
</div>
|
||||||
{sec.blocks.map((b, i) => b.kind === 'text'
|
{sec.blocks[0]?.kind === 'text' && (
|
||||||
? <p key={i} className="cockpit-reader-para">{b.text}</p>
|
<CitedBody texts={sec.blocks.filter(b => b.kind === 'text').map(b => b.text || '')}
|
||||||
: <p key={i} className="empty-note">内容受限——标题与状态可见,正文不展示</p>)}
|
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} />
|
<VersionBlock versions={sec.versions} density={density} />
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
@@ -145,14 +153,14 @@ export function CockpitReader({ pid, selection, sections, density, onBack }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBody(body: CockpitContentBlock[] | undefined, meta: CockpitMeta) {
|
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 || body.length === 0) return <div className="empty-note">暂无正文</div>
|
||||||
if (body[0].kind === 'restricted') {
|
if (body[0].kind === 'restricted') {
|
||||||
return <div className="empty-note">内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
|
return <div className="empty-note">内容受限({body[0].reason || '权限'})——标题与状态可见,正文不展示</div>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{body.map((b, i) => <p key={i} className="cockpit-reader-para">{b.text}</p>)}
|
<CitedBody texts={body.map(b => b.text || '')} citations={citations} scope={scope} />
|
||||||
{meta.truncated && <div className="cockpit-truncated">详情已截断:{meta.truncationReasons?.join(';')}</div>}
|
{meta.truncated && <div className="cockpit-truncated">详情已截断:{meta.truncationReasons?.join(';')}</div>}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
|
|||||||
import { classifyRdmError } from '../rdmErrors'
|
import { classifyRdmError } from '../rdmErrors'
|
||||||
import { store, useStore } from '../store'
|
import { store, useStore } from '../store'
|
||||||
import { exportNeedsDraft } from '../workspaces'
|
import { exportNeedsDraft } from '../workspaces'
|
||||||
|
import {
|
||||||
|
CITATION_CHIP_TONE, citationStateLabel, citationStyle, legStateLabel, REG_TREE_KIND_LABELS,
|
||||||
|
regsTreeParams, sealStateLabel,
|
||||||
|
type RegBatchDetail, type RegCitationDetail,
|
||||||
|
} from '../regCitations'
|
||||||
|
import { CitationCard, CitationListItem, CitationStateChips } from './RegCitation'
|
||||||
|
|
||||||
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
|
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
|
||||||
|
|
||||||
@@ -275,20 +281,202 @@ function ExportSection({ pid }: { pid: string }) {
|
|||||||
// ---- 资料与文档 ----
|
// ---- 资料与文档 ----
|
||||||
|
|
||||||
function MaterialsTab({ pid }: { pid: string }) {
|
function MaterialsTab({ pid }: { pid: string }) {
|
||||||
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
|
const [kind, setKind] = useState<'notes' | 'docs' | 'regs'>('docs')
|
||||||
return (
|
return (
|
||||||
<div className="rdm-audit-materials">
|
<div className="rdm-audit-materials">
|
||||||
<div className="rdm-audit-materials-side">
|
<div className="rdm-audit-materials-side">
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
|
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
|
||||||
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
|
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
|
||||||
|
<button className={`tab${kind === 'regs' ? ' active' : ''}`} onClick={() => setKind('regs')}>法规核验</button>
|
||||||
</div>
|
</div>
|
||||||
<TreeList pid={pid} kind={kind} />
|
{kind === 'regs' ? <RegsTreeList pid={pid} /> : <TreeList pid={pid} kind={kind} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 法规核验树(U7:rdm.inspect.tree kind=regs;根层证据批,parent=<batchId> 列引用) ----
|
||||||
|
|
||||||
|
function RegTreeNode({ item, selected, onSelect }: { item: TreeItem; selected: boolean; onSelect: () => void }) {
|
||||||
|
return (
|
||||||
|
<button className={`rdm-audit-tree-node${selected ? ' sel' : ''}`} onClick={onSelect}>
|
||||||
|
<span className="rdm-audit-tree-kind">{REG_TREE_KIND_LABELS[item.kind] || item.kind}</span>
|
||||||
|
<span className="rdm-audit-tree-title">{item.title || item.id}</span>
|
||||||
|
{item.state && <Chip tone={CITATION_CHIP_TONE[citationStyle({ state: item.state })]}>{citationStateLabel(item.state)}</Chip>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegsTreeList({ pid }: { pid: string }) {
|
||||||
|
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [children, setChildren] = useState<Record<string, ListShell<TreeItem>>>({})
|
||||||
|
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||||||
|
const [detail, setDetail] = useState<RegBatchDetail | RegCitationDetail | null>(null)
|
||||||
|
const [detailErr, setDetailErr] = useState('')
|
||||||
|
// genRef:项目切换代际(树与子页);selRef:选中代际(快速切换时丢弃旧详情响应)。
|
||||||
|
const genRef = useRef(0)
|
||||||
|
const selRef = useRef(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const gen = ++genRef.current
|
||||||
|
selRef.current++
|
||||||
|
setShell(null); setErr(''); setSelected(null); setDetail(null); setDetailErr(''); setChildren({})
|
||||||
|
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid))
|
||||||
|
.then(s => { if (gen === genRef.current) setShell({ ...s, items: s.items || [] }) })
|
||||||
|
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||||
|
}, [pid])
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
if (!shell?.nextCursor) return
|
||||||
|
const gen = genRef.current
|
||||||
|
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid, { cursor: shell.nextCursor }))
|
||||||
|
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...(s.items || [])] } : s) })
|
||||||
|
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadChildren = (batchId: string, cursor?: string) => {
|
||||||
|
const gen = genRef.current
|
||||||
|
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', regsTreeParams(pid, { parent: batchId, cursor }))
|
||||||
|
.then(s => {
|
||||||
|
if (gen !== genRef.current) return
|
||||||
|
setChildren(prev => {
|
||||||
|
const items = cursor ? [...(prev[batchId]?.items || []), ...(s.items || [])] : (s.items || [])
|
||||||
|
return { ...prev, [batchId]: { ...s, items } }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleBatch = (item: TreeItem) => {
|
||||||
|
if (children[item.id]) {
|
||||||
|
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadChildren(item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectNode = (item: TreeItem) => {
|
||||||
|
setSelected(item); setDetail(null); setDetailErr('')
|
||||||
|
const gen = genRef.current
|
||||||
|
const sel = ++selRef.current
|
||||||
|
const current = () => gen === genRef.current && sel === selRef.current
|
||||||
|
api.rdmCommand<RegBatchDetail | RegCitationDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||||||
|
.then(d => { if (current()) setDetail(d) })
|
||||||
|
.catch(e => { if (current()) setDetailErr(e.message) })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err) return <RdmErrorState raw={err} />
|
||||||
|
if (!shell) return <div className="empty-note">加载中…</div>
|
||||||
|
if (shell.items.length === 0) return <EmptyState icon="folder">暂无法规核验证据批</EmptyState>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rdm-audit-tree">
|
||||||
|
<div className="rdm-audit-tree-list">
|
||||||
|
{shell.items.map(item => {
|
||||||
|
const sub = children[item.id]
|
||||||
|
return (
|
||||||
|
<div key={item.id}>
|
||||||
|
<div className="regcite-tree-row">
|
||||||
|
<button type="button" className="regcite-tree-caret" aria-label={sub ? '收起引用' : '展开引用'}
|
||||||
|
aria-expanded={!!sub} onClick={() => toggleBatch(item)}>{sub ? '▾' : '▸'}</button>
|
||||||
|
<RegTreeNode item={item} selected={selected?.id === item.id} onSelect={() => selectNode(item)} />
|
||||||
|
</div>
|
||||||
|
{sub && (
|
||||||
|
<div className="rdm-audit-tree-children">
|
||||||
|
{sub.items.length === 0 && <div className="empty-note">无引用</div>}
|
||||||
|
{sub.items.map(c => (
|
||||||
|
<RegTreeNode key={c.id} item={c} selected={selected?.id === c.id} onSelect={() => selectNode(c)} />
|
||||||
|
))}
|
||||||
|
{sub.hasMore && (
|
||||||
|
<button className="btn v-ghost rdm-audit-more" onClick={() => loadChildren(item.id, sub.nextCursor)}>加载更多引用</button>
|
||||||
|
)}
|
||||||
|
{sub.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
|
||||||
|
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||||
|
</div>
|
||||||
|
{selected && (
|
||||||
|
<div className="rdm-audit-detail">
|
||||||
|
<SectionLabel>核验详情</SectionLabel>
|
||||||
|
{detailErr ? <RdmErrorState raw={detailErr} />
|
||||||
|
: !detail ? <div className="empty-note">加载中…</div>
|
||||||
|
: selected.kind === 'reg-batch'
|
||||||
|
? <RegBatchView item={selected} detail={detail as RegBatchDetail} />
|
||||||
|
: <RegCitationView item={selected} detail={detail as RegCitationDetail} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegBatchView({ item, detail }: { item: TreeItem; detail: RegBatchDetail }) {
|
||||||
|
const reasons = detail.stateReasons || []
|
||||||
|
const citations = detail.citations || []
|
||||||
|
const resolutions = detail.resolutions || []
|
||||||
|
return (
|
||||||
|
<div className="rdm-audit-detail-body">
|
||||||
|
<div><strong>{detail.title || item.title || item.id}</strong></div>
|
||||||
|
<div className="regcite-mono">reg-batch:{item.id}</div>
|
||||||
|
<div>状态:<CitationStateChips c={{ state: detail.state }} /></div>
|
||||||
|
{reasons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel>状态原因</SectionLabel>
|
||||||
|
<ul className="regcite-reasons">{reasons.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.legStates && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel>来源腿</SectionLabel>
|
||||||
|
<dl className="regcite-fields">
|
||||||
|
<dt>知识库</dt><dd>{legStateLabel(detail.legStates.kb, detail.legStates.kbReason)}</dd>
|
||||||
|
<dt>网络</dt><dd>{legStateLabel(detail.legStates.web, detail.legStates.webReason)}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.seal && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel>封存</SectionLabel>
|
||||||
|
<dl className="regcite-fields">
|
||||||
|
<dt>状态</dt><dd>{sealStateLabel(detail.seal.state)}</dd>
|
||||||
|
<dt>密钥</dt><dd className="regcite-mono">{detail.seal.keyId || '—'}</dd>
|
||||||
|
<dt>清单哈希</dt><dd className="regcite-mono">{detail.seal.manifestSha256 || '—'}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<SectionLabel>引用({citations.length})</SectionLabel>
|
||||||
|
{citations.length === 0 && <div className="empty-note">该批没有引用</div>}
|
||||||
|
{citations.map(c => <CitationListItem key={c.id} id={c.id} citation={c} scope={`audit-${item.id}`} />)}
|
||||||
|
</div>
|
||||||
|
{resolutions.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<SectionLabel>人工裁决({resolutions.length})</SectionLabel>
|
||||||
|
{resolutions.map((r, i) => (
|
||||||
|
<div key={i} className="rdm-audit-version">
|
||||||
|
{r.decision} · {r.actor} · {new Date(r.at).toLocaleString('zh-CN')}{r.note ? ` · ${r.note}` : ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegCitationView({ item, detail }: { item: TreeItem; detail: RegCitationDetail }) {
|
||||||
|
return (
|
||||||
|
<div className="rdm-audit-detail-body">
|
||||||
|
<div><strong>{detail.title || item.title || item.id}</strong></div>
|
||||||
|
<CitationCard id={item.id} citation={detail.citation} scope="audit" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
// U7 法规结构化引用的共享呈现:编号标记、引用卡片、引用清单(审计工作区与驾驶舱阅读器共用)。
|
||||||
|
// 正文与原文片段一律作为文本节点渲染,不经 HTML。非 verified 状态用未核验样式,invalid 用无效样式;
|
||||||
|
// 折叠只用 @ui Collapsible;颜色/字号/圆角只消费 @ui/tokens.css(见 reg-citations.css)。
|
||||||
|
import { Fragment, useState, type ReactNode } from 'react'
|
||||||
|
import { Chip, Collapsible } from '@ui'
|
||||||
|
import {
|
||||||
|
CITATION_CHIP_TONE, citationListOrder, citationMarkText, citationStateLabel, citationStyle,
|
||||||
|
disclosureLabel, excerptView, parseCitedBlocks, safeWebHref, staleLabel,
|
||||||
|
type CitationView,
|
||||||
|
} from '../regCitations'
|
||||||
|
import '../reg-citations.css'
|
||||||
|
|
||||||
|
/** 状态徽标:核验状态 + 过期「待复核」。 */
|
||||||
|
export function CitationStateChips({ c }: { c: { state?: string; stale?: boolean; staleReason?: string } }) {
|
||||||
|
const stale = staleLabel(c)
|
||||||
|
return (
|
||||||
|
<span className="regcite-chips">
|
||||||
|
<Chip tone={CITATION_CHIP_TONE[citationStyle({ state: c.state })]}>{citationStateLabel(c.state)}</Chip>
|
||||||
|
{stale && <Chip tone="warn">{stale}</Chip>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function citationHeading(c: CitationView): string {
|
||||||
|
const parts = [c.docNo, c.clauseNo].filter(Boolean)
|
||||||
|
return parts.length > 0 ? parts.join(' ') : (c.title || c.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(at: string | undefined): string {
|
||||||
|
if (!at) return '—'
|
||||||
|
const d = new Date(at)
|
||||||
|
return Number.isNaN(d.getTime()) ? at : d.toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
function Excerpt({ id, label, text, redaction }: { id: string; label: string; text?: string; redaction?: string }) {
|
||||||
|
if (!text) return <div className="regcite-redaction">{label}未展示:{redaction}</div>
|
||||||
|
return (
|
||||||
|
<Collapsible id={id} trigger={<span className="regcite-toggle">{label}</span>}>
|
||||||
|
<blockquote className="regcite-excerpt">{text}</blockquote>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 引用卡片:元数据 + 可展开原文片段(受披露约束);缺失或 invalid 的 ID 只给无效说明。 */
|
||||||
|
export function CitationCard({ id, citation, scope }: { id: string; citation?: CitationView; scope: string }) {
|
||||||
|
const style = citationStyle(citation)
|
||||||
|
if (!citation || style === 'invalid') {
|
||||||
|
return (
|
||||||
|
<div className="regcite-card regcite-card-invalid">
|
||||||
|
<div className="regcite-card-head">
|
||||||
|
<Chip tone="danger">无效引用</Chip>
|
||||||
|
<span className="regcite-mono">{id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="regcite-note">
|
||||||
|
{citation ? '引用 ID 无法解析为证据批' : '本节引用清单中没有该引用'}——不得作为法律依据
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const web = citation.web
|
||||||
|
const href = safeWebHref(web?.finalUrl || web?.url)
|
||||||
|
const fields: [string, ReactNode][] = []
|
||||||
|
const add = (label: string, v: ReactNode) => { if (v !== undefined && v !== null && v !== '') fields.push([label, v]) }
|
||||||
|
add('文号', citation.docNo)
|
||||||
|
add('标题', citation.title)
|
||||||
|
add('版本', citation.editionLabel)
|
||||||
|
add('效力状态', citation.legalStatus)
|
||||||
|
add('条款', citation.clauseNo)
|
||||||
|
add('章节路径', citation.headingPath?.length ? citation.headingPath.join(' › ') : undefined)
|
||||||
|
add('语料修订', citation.corpusRevision && <span className="regcite-mono">{citation.corpusRevision}</span>)
|
||||||
|
add('原文披露', citation.disclosure && disclosureLabel(citation.disclosure))
|
||||||
|
if (web) {
|
||||||
|
add('网络来源', href
|
||||||
|
? <a className="regcite-link" href={href} target="_blank" rel="noopener noreferrer">{web.finalUrl || web.url}</a>
|
||||||
|
: web.finalUrl || web.url)
|
||||||
|
add('官方来源', web.official ? '是' : '否')
|
||||||
|
add('抓取时间', formatTime(web.fetchedAt))
|
||||||
|
}
|
||||||
|
add('证据批', citation.batchId && <span className="regcite-mono">{citation.batchId}</span>)
|
||||||
|
add('引用 ID', <span className="regcite-mono">{citation.id}</span>)
|
||||||
|
const ex = excerptView(citation)
|
||||||
|
return (
|
||||||
|
<div className={`regcite-card regcite-card-${style}`}>
|
||||||
|
<div className="regcite-card-head">
|
||||||
|
<span className="regcite-card-title">{citationHeading(citation)}</span>
|
||||||
|
<CitationStateChips c={citation} />
|
||||||
|
</div>
|
||||||
|
{style !== 'valid' && <div className="regcite-note">未核验引用——不得作为有效法律依据</div>}
|
||||||
|
{citation.claim && <div className="regcite-claim">主张:{citation.claim}</div>}
|
||||||
|
<dl className="regcite-fields">
|
||||||
|
{fields.map(([label, v]) => (
|
||||||
|
<Fragment key={label}><dt>{label}</dt><dd>{v}</dd></Fragment>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<Excerpt id={`regcite-excerpt-${scope}-${citation.id}`} label="原文片段"
|
||||||
|
text={'excerpt' in ex ? ex.excerpt : undefined}
|
||||||
|
redaction={'redaction' in ex ? ex.redaction : undefined} />
|
||||||
|
{web?.excerpt && (
|
||||||
|
<Excerpt id={`regcite-web-${scope}-${citation.id}`} label="网页摘录" text={web.excerpt} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 引用清单一行:摘要触发 + 折叠卡片。 */
|
||||||
|
export function CitationListItem({ id, n, citation, scope }: { id: string; n?: number; citation?: CitationView; scope: string }) {
|
||||||
|
const style = citationStyle(citation)
|
||||||
|
return (
|
||||||
|
<div className={`regcite-item regcite-item-${style}`}>
|
||||||
|
<Collapsible id={`regcite-item-${scope}-${id}`} trigger={
|
||||||
|
<span className="regcite-item-head">
|
||||||
|
{n !== undefined && <span className="regcite-item-num">[{n}]</span>}
|
||||||
|
<span className="regcite-item-title">{citation && style !== 'invalid' ? citationHeading(citation) : id}</span>
|
||||||
|
{citation && style !== 'invalid' && citation.title && citation.title !== citationHeading(citation) && (
|
||||||
|
<span className="regcite-item-sub">{citation.title}</span>
|
||||||
|
)}
|
||||||
|
{citation && style !== 'invalid'
|
||||||
|
? <CitationStateChips c={citation} />
|
||||||
|
: <Chip tone="danger">无效引用</Chip>}
|
||||||
|
</span>
|
||||||
|
}>
|
||||||
|
<CitationCard id={id} citation={citation} scope={scope} />
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 带引用的正文:[cit-…] 渲染为编号标记,点击在段落下展开引用卡片;正文下附引用清单。 */
|
||||||
|
export function CitedBody({ texts, citations, scope, paraClassName = 'cockpit-reader-para' }: {
|
||||||
|
texts: string[]
|
||||||
|
citations?: CitationView[] | null
|
||||||
|
scope: string
|
||||||
|
paraClassName?: string
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState<string | null>(null)
|
||||||
|
const { blocks, order } = parseCitedBlocks(texts)
|
||||||
|
const index = new Map((citations || []).map(c => [c.id, c]))
|
||||||
|
const list = citationListOrder(order, citations)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{blocks.map((segs, bi) => {
|
||||||
|
const ids = [...new Set(segs.flatMap(s => (s.kind === 'cite' ? [s.id] : [])))]
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return <p key={bi} className={paraClassName}>{segs.map(s => (s.kind === 'text' ? s.text : '')).join('')}</p>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={bi} className="regcite-block">
|
||||||
|
<p className={paraClassName}>
|
||||||
|
{segs.map((s, si) => {
|
||||||
|
if (s.kind === 'text') return <Fragment key={si}>{s.text}</Fragment>
|
||||||
|
const c = index.get(s.id)
|
||||||
|
const key = `${bi}:${s.id}`
|
||||||
|
const isOpen = open === key
|
||||||
|
return (
|
||||||
|
<button key={si} type="button"
|
||||||
|
className={`regcite-mark regcite-${citationStyle(c)}${isOpen ? ' regcite-mark-open' : ''}`}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
title={`引用 ${s.n}:${c ? citationStateLabel(c.state) : '无效引用'}`}
|
||||||
|
onClick={() => setOpen(isOpen ? null : key)}>
|
||||||
|
[{citationMarkText(s.n, c)}]
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{ids.map(id => (
|
||||||
|
<Collapsible key={id} open={open === `${bi}:${id}`}>
|
||||||
|
<CitationCard id={id} citation={index.get(id)} scope={`${scope}-inline`} />
|
||||||
|
</Collapsible>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{list.length > 0 && (
|
||||||
|
<div className="regcite-list">
|
||||||
|
<div className="regcite-list-head">引用({list.length})</div>
|
||||||
|
{list.map((id, i) => (
|
||||||
|
<CitationListItem key={id} id={id} n={i + 1} citation={index.get(id)} scope={scope} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/* U7 法规结构化引用:编号标记、引用卡片、引用清单与审计法规树。
|
||||||
|
颜色、圆角、字号只消费 @ui/tokens.css;折叠动效由 @ui Collapsible(mk-col)承担。
|
||||||
|
有效依据(verified)用实线成功色;未核验用虚线警告色;无效用虚线错误色加删除线。 */
|
||||||
|
|
||||||
|
.regcite-chips { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; }
|
||||||
|
.regcite-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); color: var(--text-dim); word-break: break-all; }
|
||||||
|
|
||||||
|
.regcite-mark {
|
||||||
|
display: inline;
|
||||||
|
margin: 0 2px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-ui-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
vertical-align: baseline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.regcite-mark:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.regcite-mark.regcite-valid { color: var(--ok-text); border-color: var(--ok-border); background: var(--ok-soft); }
|
||||||
|
.regcite-mark.regcite-unverified { color: var(--warn-text); border-style: dashed; border-color: var(--warn); background: var(--warn-soft); }
|
||||||
|
.regcite-mark.regcite-invalid { color: var(--err-text); border-style: dashed; border-color: var(--err); background: var(--err-soft); text-decoration: line-through; }
|
||||||
|
.regcite-mark-open { outline: 1px solid var(--accent); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.regcite-block { margin: 0; }
|
||||||
|
|
||||||
|
.regcite-card {
|
||||||
|
margin: 6px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-left-width: 3px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
font-size: var(--fs-ui-sm);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.regcite-card-valid { border-left-color: var(--ok); }
|
||||||
|
.regcite-card-unverified { border-left-color: var(--warn); border-left-style: dashed; }
|
||||||
|
.regcite-card-invalid { border-left-color: var(--err); border-left-style: dashed; }
|
||||||
|
.regcite-card-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||||
|
.regcite-card-title { font-weight: 600; color: var(--text); }
|
||||||
|
.regcite-note { margin-top: 4px; color: var(--text-dim); }
|
||||||
|
.regcite-claim { margin-top: 4px; color: var(--text); }
|
||||||
|
.regcite-fields { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 2px 10px; margin: 6px 0 0; }
|
||||||
|
.regcite-fields dt { color: var(--text-dim); }
|
||||||
|
.regcite-fields dd { margin: 0; color: var(--text); word-break: break-word; }
|
||||||
|
.regcite-link { color: var(--accent-text); word-break: break-all; }
|
||||||
|
.regcite-toggle { font-size: var(--fs-ui-sm); color: var(--accent-text); }
|
||||||
|
.regcite-excerpt {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-left: 2px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-xs);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.regcite-redaction { margin-top: 6px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
.regcite-list { margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.regcite-list-head { font-size: var(--fs-ui-sm); color: var(--text-dim); padding-bottom: 4px; }
|
||||||
|
.regcite-item-head { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: var(--fs-ui-sm); color: var(--text); text-align: left; }
|
||||||
|
.regcite-item-num { font-family: var(--mono); color: var(--text-dim); }
|
||||||
|
.regcite-item-title { font-weight: 550; }
|
||||||
|
.regcite-item-sub { color: var(--text-dim); }
|
||||||
|
.regcite-item-invalid .regcite-item-title { color: var(--err-text); text-decoration: line-through; }
|
||||||
|
|
||||||
|
.regcite-reasons { margin: 0; padding-left: 18px; color: var(--text-dim); }
|
||||||
|
.regcite-tree-row { display: flex; align-items: center; gap: 2px; }
|
||||||
|
.regcite-tree-row .rdm-audit-tree-node { flex: 1; min-width: 0; }
|
||||||
|
.regcite-tree-caret {
|
||||||
|
flex: none;
|
||||||
|
width: 22px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: var(--fs-ui-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.regcite-tree-caret:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// U7 法规结构化引用纯逻辑层(契约:Dev 仓 docs/superpowers/specs
|
||||||
|
// 2026-10-18-regulations-citation-contract.md §1/§3/§5/§6)。只放可测判定:
|
||||||
|
// 正文 [cit-…] 标记解析与编号、核验状态文案与样式、原文省略原因、审计树请求参数。
|
||||||
|
// 引用是 RDM 证据批的派生视图;除 verified 外一律不得呈现为有效法律依据。
|
||||||
|
|
||||||
|
// ---- 契约类型 ----
|
||||||
|
|
||||||
|
interface CitationWeb {
|
||||||
|
url: string
|
||||||
|
finalUrl?: string
|
||||||
|
captureId?: string
|
||||||
|
contentSha256?: string
|
||||||
|
official?: boolean
|
||||||
|
fetchedAt?: string
|
||||||
|
excerpt?: string
|
||||||
|
excerptStart?: number
|
||||||
|
excerptEnd?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CitationView {
|
||||||
|
id: string
|
||||||
|
batchId?: string
|
||||||
|
claim?: string
|
||||||
|
state: string // verified|single_source|degraded|edition_unresolved|conflict|unverified|not_applicable|invalid
|
||||||
|
stale?: boolean
|
||||||
|
staleReason?: string
|
||||||
|
regulationId?: string
|
||||||
|
editionId?: string
|
||||||
|
nodeId?: string
|
||||||
|
docNo?: string
|
||||||
|
title?: string
|
||||||
|
editionLabel?: string
|
||||||
|
legalStatus?: string
|
||||||
|
clauseNo?: string
|
||||||
|
headingPath?: string[]
|
||||||
|
corpusRevision?: string
|
||||||
|
excerpt?: string
|
||||||
|
excerptStart?: number
|
||||||
|
excerptEnd?: number
|
||||||
|
nodeSha256?: string
|
||||||
|
disclosure?: string // full|excerpt|metadata_only|withheld
|
||||||
|
redactionReason?: string
|
||||||
|
web?: CitationWeb
|
||||||
|
manifestSha256?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegLegStates { kb: string; web: string; kbReason?: string; webReason?: string }
|
||||||
|
interface RegSealView { state: string; keyId?: string; manifestSha256?: string }
|
||||||
|
interface RegResolution { actor: string; decision: string; note?: string; at: string; batchManifestSha256?: string }
|
||||||
|
|
||||||
|
/** rdm.inspect.object kind=reg-batch(契约 §5)。 */
|
||||||
|
export interface RegBatchDetail {
|
||||||
|
title: string
|
||||||
|
state: string
|
||||||
|
stateReasons?: string[] | null
|
||||||
|
legStates?: RegLegStates
|
||||||
|
seal?: RegSealView
|
||||||
|
citations?: CitationView[] | null
|
||||||
|
resolutions?: RegResolution[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rdm.inspect.object kind=reg-citation(契约 §5)。 */
|
||||||
|
export interface RegCitationDetail { title: string; citation: CitationView }
|
||||||
|
|
||||||
|
// ---- 正文标记:[cit-<12 位小写十六进制>] ----
|
||||||
|
|
||||||
|
const MARKER = /\[(cit-[0-9a-f]{12})\]/g
|
||||||
|
|
||||||
|
type CitedSegment =
|
||||||
|
| { kind: 'text'; text: string }
|
||||||
|
| { kind: 'cite'; id: string; n: number }
|
||||||
|
|
||||||
|
/** 解析多段正文:按首次出现编号(跨段连续),重复标记复用编号;不合规的近似标记原样保留为文本。 */
|
||||||
|
export function parseCitedBlocks(texts: string[]): { blocks: CitedSegment[][]; order: string[] } {
|
||||||
|
const order: string[] = []
|
||||||
|
const numbers = new Map<string, number>()
|
||||||
|
const blocks = texts.map(text => {
|
||||||
|
const out: CitedSegment[] = []
|
||||||
|
let last = 0
|
||||||
|
for (const m of text.matchAll(MARKER)) {
|
||||||
|
const at = m.index ?? 0
|
||||||
|
if (at > last) out.push({ kind: 'text', text: text.slice(last, at) })
|
||||||
|
const id = m[1]
|
||||||
|
let n = numbers.get(id)
|
||||||
|
if (n === undefined) {
|
||||||
|
order.push(id)
|
||||||
|
n = order.length
|
||||||
|
numbers.set(id, n)
|
||||||
|
}
|
||||||
|
out.push({ kind: 'cite', id, n })
|
||||||
|
last = at + m[0].length
|
||||||
|
}
|
||||||
|
if (last < text.length) out.push({ kind: 'text', text: text.slice(last) })
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
return { blocks, order }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 引用清单顺序:正文编号在前,正文未出现的引用按返回顺序续编。 */
|
||||||
|
export function citationListOrder(order: string[], citations: CitationView[] | null | undefined): string[] {
|
||||||
|
const out = [...order]
|
||||||
|
const seen = new Set(order)
|
||||||
|
for (const c of citations || []) {
|
||||||
|
if (!seen.has(c.id)) { seen.add(c.id); out.push(c.id) }
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 核验状态 ----
|
||||||
|
|
||||||
|
const STATE_LABELS: Record<string, string> = {
|
||||||
|
verified: '已核验',
|
||||||
|
single_source: '单一来源',
|
||||||
|
degraded: '降级',
|
||||||
|
edition_unresolved: '版本未定',
|
||||||
|
conflict: '冲突',
|
||||||
|
unverified: '未核验',
|
||||||
|
not_applicable: '不适用',
|
||||||
|
invalid: '无效引用',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态文案;未知状态原样显示,不冒充已知状态。 */
|
||||||
|
export function citationStateLabel(state: string | undefined): string {
|
||||||
|
if (!state) return STATE_LABELS.unverified
|
||||||
|
return STATE_LABELS[state] ?? state
|
||||||
|
}
|
||||||
|
|
||||||
|
type CitationStyle = 'valid' | 'unverified' | 'invalid'
|
||||||
|
|
||||||
|
/** 样式档:仅 verified 且未过期才是有效依据;ID 缺失或 invalid 为无效;其余一律未核验档。 */
|
||||||
|
export function citationStyle(c: { state?: string; stale?: boolean } | undefined): CitationStyle {
|
||||||
|
if (!c || c.state === 'invalid') return 'invalid'
|
||||||
|
if (c.state === 'verified' && !c.stale) return 'valid'
|
||||||
|
return 'unverified'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CITATION_CHIP_TONE: Record<CitationStyle, 'ok' | 'warn' | 'danger'> = {
|
||||||
|
valid: 'ok',
|
||||||
|
unverified: 'warn',
|
||||||
|
invalid: 'danger',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STALE_REASONS: Record<string, string> = {
|
||||||
|
node_text_changed: '条款文本已变更',
|
||||||
|
legal_status_changed: '效力状态已变更',
|
||||||
|
edition_superseded: '版本已被替代',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 过期提示:stale 显示「待复核」并附原因;未过期返回 null。 */
|
||||||
|
export function staleLabel(c: { stale?: boolean; staleReason?: string }): string | null {
|
||||||
|
if (!c.stale) return null
|
||||||
|
if (!c.staleReason) return '待复核'
|
||||||
|
return `待复核(${STALE_REASONS[c.staleReason] ?? c.staleReason})`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记文字:有效依据只显示编号;其余附状态文案,不靠颜色单独区分。 */
|
||||||
|
export function citationMarkText(n: number, c: CitationView | undefined): string {
|
||||||
|
const style = citationStyle(c)
|
||||||
|
if (style === 'valid') return `${n}`
|
||||||
|
if (style === 'invalid') return `${n}·${STATE_LABELS.invalid}`
|
||||||
|
return `${n}·${c?.stale && c.state === 'verified' ? '待复核' : citationStateLabel(c?.state)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 原文披露 ----
|
||||||
|
|
||||||
|
const REDACTION_LABELS: Record<string, string> = {
|
||||||
|
withheld_by_rights: '受版权限制',
|
||||||
|
metadata_only: '仅元数据',
|
||||||
|
no_source_text_permission: '无原文查看权限',
|
||||||
|
source_withdrawn: '来源已撤回',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 原文省略原因;未知原因原样显示。 */
|
||||||
|
export function redactionLabel(reason: string | undefined): string {
|
||||||
|
if (!reason) return '未提供原文'
|
||||||
|
return REDACTION_LABELS[reason] ?? reason
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 原文片段呈现:有片段给片段;省略时给中文原因,不留空白。 */
|
||||||
|
export function excerptView(c: Pick<CitationView, 'excerpt' | 'redactionReason'>): { excerpt: string } | { redaction: string } {
|
||||||
|
if (c.excerpt) return { excerpt: c.excerpt }
|
||||||
|
return { redaction: redactionLabel(c.redactionReason) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const DISCLOSURE_LABELS: Record<string, string> = {
|
||||||
|
full: '全文',
|
||||||
|
excerpt: '片段',
|
||||||
|
metadata_only: '仅元数据',
|
||||||
|
withheld: '已扣留',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disclosureLabel(d: string | undefined): string {
|
||||||
|
if (!d) return '—'
|
||||||
|
return DISCLOSURE_LABELS[d] ?? d
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEG_LABELS: Record<string, string> = {
|
||||||
|
ok: '正常',
|
||||||
|
empty: '无结果',
|
||||||
|
unavailable: '不可用',
|
||||||
|
degraded: '降级',
|
||||||
|
error: '出错',
|
||||||
|
rejected: '已拒绝',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legStateLabel(state: string | undefined, reason?: string): string {
|
||||||
|
const label = state ? (LEG_LABELS[state] ?? state) : '—'
|
||||||
|
return reason ? `${label}(${reason})` : label
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sealStateLabel(state: string | undefined): string {
|
||||||
|
if (state === 'sealed') return '已封存'
|
||||||
|
if (state === 'unsealed') return '未封存'
|
||||||
|
return state || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只有 http(s) 地址才可作为外链;其余地址按文本显示。 */
|
||||||
|
export function safeWebHref(url: string | undefined): string | null {
|
||||||
|
if (!url) return null
|
||||||
|
return /^https?:\/\//i.test(url) ? url : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 审计读模型请求(rdm.inspect.tree kind=regs;契约 §5) ----
|
||||||
|
|
||||||
|
/** 根层列证据批;parent=<batchId> 列该批的引用。cursor 沿用既有键集分页。 */
|
||||||
|
export function regsTreeParams(projectId: string, opts: { parent?: string; cursor?: string } = {}): Record<string, unknown> {
|
||||||
|
const params: Record<string, unknown> = { projectId, kind: 'regs', cursor: opts.cursor ?? '' }
|
||||||
|
if (opts.parent) params.parent = opts.parent
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REG_TREE_KIND_LABELS: Record<string, string> = {
|
||||||
|
'reg-batch': '证据批',
|
||||||
|
'reg-citation': '引用',
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// U7 法规结构化引用:标记解析与编号、核验状态文案与样式、原文省略原因、审计树请求参数,
|
||||||
|
// 以及正文渲染不经 HTML(契约:Dev 仓 specs/2026-10-18-regulations-citation-contract.md)。
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import {
|
||||||
|
CITATION_CHIP_TONE, citationListOrder, citationMarkText, citationStateLabel, citationStyle,
|
||||||
|
excerptView, parseCitedBlocks, redactionLabel, regsTreeParams, safeWebHref, staleLabel,
|
||||||
|
type CitationView,
|
||||||
|
} from '../src/regCitations'
|
||||||
|
import { CitationCard, CitedBody } from '../src/components/RegCitation'
|
||||||
|
|
||||||
|
const A = 'cit-0123456789ab'
|
||||||
|
const B = 'cit-abcdef012345'
|
||||||
|
const C = 'cit-ffffffffffff'
|
||||||
|
|
||||||
|
const cite = (id: string, over: Partial<CitationView> = {}): CitationView => ({
|
||||||
|
id, batchId: 'rb-1', state: 'verified', docNo: 'GB 9706.1-2020', clauseNo: '4.2',
|
||||||
|
title: '医用电气设备 第1部分', editionLabel: '2020 版', legalStatus: '现行', corpusRevision: 'rev-7',
|
||||||
|
headingPath: ['4 通用要求', '4.2 风险管理'], excerpt: '应实施风险管理过程。', disclosure: 'excerpt', ...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseCitedBlocks:标记解析与编号', () => {
|
||||||
|
it('多个标记按首次出现编号,文本原样切分', () => {
|
||||||
|
const { blocks, order } = parseCitedBlocks([`甲[${A}]乙[${B}]丙`])
|
||||||
|
expect(order).toEqual([A, B])
|
||||||
|
expect(blocks[0]).toEqual([
|
||||||
|
{ kind: 'text', text: '甲' },
|
||||||
|
{ kind: 'cite', id: A, n: 1 },
|
||||||
|
{ kind: 'text', text: '乙' },
|
||||||
|
{ kind: 'cite', id: B, n: 2 },
|
||||||
|
{ kind: 'text', text: '丙' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('重复标记复用编号,编号跨段连续', () => {
|
||||||
|
const { blocks, order } = parseCitedBlocks([`[${B}]开头[${A}]`, `再引[${B}]与[${C}][${A}]`])
|
||||||
|
expect(order).toEqual([B, A, C])
|
||||||
|
const nums = blocks.map(b => b.flatMap(s => (s.kind === 'cite' ? [`${s.id}:${s.n}`] : [])))
|
||||||
|
expect(nums).toEqual([[`${B}:1`, `${A}:2`], [`${B}:1`, `${C}:3`, `${A}:2`]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('未知 ID 也按出现顺序编号(渲染时判为无效引用)', () => {
|
||||||
|
const { order } = parseCitedBlocks([`[${C}]`])
|
||||||
|
expect(order).toEqual([C])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有标记的文本原样返回单个文本片段;空文本没有片段', () => {
|
||||||
|
const { blocks, order } = parseCitedBlocks(['普通正文 [slc-1] 不变', ''])
|
||||||
|
expect(order).toEqual([])
|
||||||
|
expect(blocks).toEqual([[{ kind: 'text', text: '普通正文 [slc-1] 不变' }], []])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('形似标记但 ID 不合规的文本不解析', () => {
|
||||||
|
const texts = [
|
||||||
|
'[cit-0123456789a]', // 11 位
|
||||||
|
'[cit-0123456789abc]', // 13 位
|
||||||
|
'[cit-0123456789AB]', // 大写
|
||||||
|
'[cit-0123456789ag]', // 非十六进制
|
||||||
|
'cit-0123456789ab', // 无方括号
|
||||||
|
'[cit 0123456789ab]',
|
||||||
|
'[CIT-0123456789ab]',
|
||||||
|
]
|
||||||
|
const { blocks, order } = parseCitedBlocks(texts)
|
||||||
|
expect(order).toEqual([])
|
||||||
|
blocks.forEach((b, i) => expect(b).toEqual([{ kind: 'text', text: texts[i] }]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('嵌套方括号只取合规的内层标记', () => {
|
||||||
|
const { blocks } = parseCitedBlocks([`[[${A}]]`])
|
||||||
|
expect(blocks[0]).toEqual([{ kind: 'text', text: '[' }, { kind: 'cite', id: A, n: 1 }, { kind: 'text', text: ']' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('引用清单:正文编号在前,未出现在正文的引用续编', () => {
|
||||||
|
expect(citationListOrder([B, C], [cite(A), cite(B)])).toEqual([B, C, A])
|
||||||
|
expect(citationListOrder([], null)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('核验状态文案与样式', () => {
|
||||||
|
it('八种状态的中文文案;未知状态原样显示', () => {
|
||||||
|
expect(['verified', 'single_source', 'degraded', 'edition_unresolved', 'conflict', 'unverified', 'not_applicable', 'invalid']
|
||||||
|
.map(citationStateLabel))
|
||||||
|
.toEqual(['已核验', '单一来源', '降级', '版本未定', '冲突', '未核验', '不适用', '无效引用'])
|
||||||
|
expect(citationStateLabel('mystery')).toBe('mystery')
|
||||||
|
expect(citationStateLabel(undefined)).toBe('未核验')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('仅 verified 为有效样式;invalid 与缺失 ID 为无效样式;其余为未核验样式', () => {
|
||||||
|
expect(citationStyle({ state: 'verified' })).toBe('valid')
|
||||||
|
for (const s of ['single_source', 'degraded', 'edition_unresolved', 'conflict', 'unverified', 'not_applicable', 'mystery']) {
|
||||||
|
expect(citationStyle({ state: s })).toBe('unverified')
|
||||||
|
}
|
||||||
|
expect(citationStyle({ state: 'invalid' })).toBe('invalid')
|
||||||
|
expect(citationStyle(undefined)).toBe('invalid')
|
||||||
|
expect(CITATION_CHIP_TONE).toEqual({ valid: 'ok', unverified: 'warn', invalid: 'danger' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stale 的 verified 不再呈现为有效依据,显示「待复核」及原因', () => {
|
||||||
|
expect(citationStyle({ state: 'verified', stale: true })).toBe('unverified')
|
||||||
|
expect(staleLabel({ stale: false })).toBeNull()
|
||||||
|
expect(staleLabel({ stale: true })).toBe('待复核')
|
||||||
|
expect(staleLabel({ stale: true, staleReason: 'node_text_changed' })).toBe('待复核(条款文本已变更)')
|
||||||
|
expect(staleLabel({ stale: true, staleReason: 'legal_status_changed' })).toBe('待复核(效力状态已变更)')
|
||||||
|
expect(staleLabel({ stale: true, staleReason: 'edition_superseded' })).toBe('待复核(版本已被替代)')
|
||||||
|
expect(staleLabel({ stale: true, staleReason: 'other_reason' })).toBe('待复核(other_reason)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('标记文字:有效只给编号,其余附状态文案', () => {
|
||||||
|
expect(citationMarkText(1, cite(A))).toBe('1')
|
||||||
|
expect(citationMarkText(2, cite(A, { state: 'single_source' }))).toBe('2·单一来源')
|
||||||
|
expect(citationMarkText(3, cite(A, { stale: true }))).toBe('3·待复核')
|
||||||
|
expect(citationMarkText(4, cite(A, { state: 'invalid' }))).toBe('4·无效引用')
|
||||||
|
expect(citationMarkText(5, undefined)).toBe('5·无效引用')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('原文省略原因', () => {
|
||||||
|
it('已知原因中文化;未知原因原样;缺省给通用说明', () => {
|
||||||
|
expect(redactionLabel('withheld_by_rights')).toBe('受版权限制')
|
||||||
|
expect(redactionLabel('metadata_only')).toBe('仅元数据')
|
||||||
|
expect(redactionLabel('no_source_text_permission')).toBe('无原文查看权限')
|
||||||
|
expect(redactionLabel('source_withdrawn')).toBe('来源已撤回')
|
||||||
|
expect(redactionLabel('court_order')).toBe('court_order')
|
||||||
|
expect(redactionLabel(undefined)).toBe('未提供原文')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('有片段给片段;省略时给原因', () => {
|
||||||
|
expect(excerptView({ excerpt: '原文' })).toEqual({ excerpt: '原文' })
|
||||||
|
expect(excerptView({ redactionReason: 'withheld_by_rights' })).toEqual({ redaction: '受版权限制' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('只有 http(s) 地址可作外链', () => {
|
||||||
|
expect(safeWebHref('https://www.nmpa.gov.cn/x')).toBe('https://www.nmpa.gov.cn/x')
|
||||||
|
expect(safeWebHref('javascript:alert(1)')).toBeNull()
|
||||||
|
expect(safeWebHref(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('regsTreeParams:rdm.inspect.tree kind=regs 请求参数', () => {
|
||||||
|
it('根层:kind=regs、空游标、无 parent', () => {
|
||||||
|
expect(regsTreeParams('p1')).toEqual({ projectId: 'p1', kind: 'regs', cursor: '' })
|
||||||
|
})
|
||||||
|
it('分页沿用游标', () => {
|
||||||
|
expect(regsTreeParams('p1', { cursor: 'c2' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: 'c2' })
|
||||||
|
})
|
||||||
|
it('展开证据批:parent=<batchId>', () => {
|
||||||
|
expect(regsTreeParams('p1', { parent: 'rb-1' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: '', parent: 'rb-1' })
|
||||||
|
expect(regsTreeParams('p1', { parent: 'rb-1', cursor: 'c3' })).toEqual({ projectId: 'p1', kind: 'regs', cursor: 'c3', parent: 'rb-1' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('引用呈现', () => {
|
||||||
|
it('正文标记编号渲染,未知与 invalid 显示无效引用,正文不作 HTML', () => {
|
||||||
|
const html = renderToStaticMarkup(createElement(CitedBody, {
|
||||||
|
texts: [`<b>粗</b>甲[${A}]乙[${B}]丙[${C}]`, `再引[${A}]`],
|
||||||
|
citations: [cite(A), cite(B, { state: 'invalid' })],
|
||||||
|
scope: 't',
|
||||||
|
}))
|
||||||
|
expect(html).toContain('<b>粗</b>甲')
|
||||||
|
expect(html).not.toContain('<b>')
|
||||||
|
expect(html).toContain('regcite-mark regcite-valid')
|
||||||
|
expect(html).toContain('[1]')
|
||||||
|
expect(html).toContain('[2·无效引用]')
|
||||||
|
expect(html).toContain('[3·无效引用]')
|
||||||
|
expect(html.match(/regcite-mark regcite-valid/g)).toHaveLength(2)
|
||||||
|
expect(html).toContain('引用(3)')
|
||||||
|
expect(html).not.toContain('<details')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('无标记且无引用时正文原样,不出现引用清单', () => {
|
||||||
|
const html = renderToStaticMarkup(createElement(CitedBody, { texts: ['普通 [cit-xyz] 正文'], citations: undefined, scope: 't' }))
|
||||||
|
expect(html).toBe('<p class="cockpit-reader-para">普通 [cit-xyz] 正文</p>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('引用卡片:省略原文时显示中文原因;非 verified 标注未核验', () => {
|
||||||
|
const html = renderToStaticMarkup(createElement(CitationCard, {
|
||||||
|
id: A, scope: 't',
|
||||||
|
citation: cite(A, { state: 'conflict', excerpt: undefined, redactionReason: 'no_source_text_permission', stale: true, staleReason: 'edition_superseded' }),
|
||||||
|
}))
|
||||||
|
expect(html).toContain('regcite-card-unverified')
|
||||||
|
expect(html).toContain('冲突')
|
||||||
|
expect(html).toContain('待复核(版本已被替代)')
|
||||||
|
expect(html).toContain('未核验引用')
|
||||||
|
expect(html).toContain('原文片段未展示:无原文查看权限')
|
||||||
|
expect(html).toContain('GB 9706.1-2020')
|
||||||
|
expect(html).toContain('4 通用要求 › 4.2 风险管理')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('引用卡片:verified 为有效样式,原文片段在折叠区内;缺失 ID 为无效卡片', () => {
|
||||||
|
const ok = renderToStaticMarkup(createElement(CitationCard, { id: A, scope: 't', citation: cite(A) }))
|
||||||
|
expect(ok).toContain('regcite-card-valid')
|
||||||
|
expect(ok).toContain('mk-col')
|
||||||
|
expect(ok).not.toContain('未核验引用')
|
||||||
|
const missing = renderToStaticMarkup(createElement(CitationCard, { id: C, scope: 't' }))
|
||||||
|
expect(missing).toContain('regcite-card-invalid')
|
||||||
|
expect(missing).toContain('无效引用')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user