Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2ebc7fb84 | ||
|
|
367aeefff4 | ||
|
|
65d3b8ef02 | ||
|
|
ed7e3126e7 | ||
|
|
aaced8796b | ||
|
|
d0d9876b18 | ||
|
|
2c56054861 | ||
|
|
b80153dbf8 |
+15
@@ -129,6 +129,21 @@ export const api = {
|
||||
savePromptCommand: (body: { name: string; title: string; template: string }) => auth.fetch(`${BASE}/api/prompt-commands`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => json(r)),
|
||||
deletePromptCommand: (id: string) => auth.fetch(`${BASE}/api/prompt-commands/${id}`, { method: 'DELETE' }),
|
||||
listWorkspaces: () => auth.fetch(`${BASE}/api/workspaces`).then(r => r.json()) as Promise<{ id: string; title: string; icon?: string; shortcut?: string; badgeTool?: string; body?: any[]; refreshTool?: string }[]>,
|
||||
|
||||
// ---- U4:RDM 命令总线封装(POST /api/commands/{id},同 RDM web 的 command 路由) ----
|
||||
// receipt 解包:rejected 抛错;confirm-card 抛提示(审计面只读命令不应触发)。
|
||||
rdmCommand: <T = any>(id: string, params: Record<string, unknown> = {}): Promise<T> =>
|
||||
auth.fetch(`${BASE}/api/commands/${encodeURIComponent(id)}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ params }),
|
||||
}).then(r => json<{ receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T } | T>(r))
|
||||
.then(out => {
|
||||
const envelope = out as { receipt?: { state?: string; error?: string; message?: string }; card?: unknown; payload?: T }
|
||||
if (envelope?.receipt?.state === 'rejected') throw new Error(envelope.receipt.error || envelope.receipt.message || '命令被拒绝')
|
||||
if (envelope?.card) throw new Error('高危命令已生成确认卡——请在对话中确认执行')
|
||||
if (envelope && envelope.payload !== undefined) return envelope.payload
|
||||
return out as T
|
||||
}),
|
||||
}
|
||||
|
||||
// ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ----
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { groupArtifactsByName } from './artifactGrouping'
|
||||
import type { Artifact } from './contracts'
|
||||
|
||||
// 2026-10-11 实测事故:一次 run 内 register_artifact 三次 → 文件面板三份同名
|
||||
// 转写报告并列。修复语义(用户裁定):存储保留版本、界面按名称分组去重。
|
||||
|
||||
function art(id: string, name: string, updatedAt: string, size = 40000): Artifact {
|
||||
return { id, projectId: 'p1', name, kind: 'document', size, createdAt: updatedAt, updatedAt }
|
||||
}
|
||||
|
||||
describe('groupArtifactsByName', () => {
|
||||
it('同名 3 行归一组:latest 为最新,versions 新→旧', () => {
|
||||
const g = groupArtifactsByName([
|
||||
art('a1', '报告.docx', '2026-10-11T15:22:20Z'),
|
||||
art('a2', '报告.docx', '2026-10-11T15:24:06Z'),
|
||||
art('a3', '报告.docx', '2026-10-11T15:23:38Z'),
|
||||
art('b1', '其他.md', '2026-10-11T15:20:00Z', 100),
|
||||
])
|
||||
expect(g).toHaveLength(2)
|
||||
const grp = g.find(x => x.latest.name === '报告.docx')!
|
||||
expect(grp.latest.id).toBe('a2') // 最新 15:24:06
|
||||
expect(grp.versions).toHaveLength(3)
|
||||
expect(grp.versions.map(v => v.id)).toEqual(['a2', 'a3', 'a1'])
|
||||
expect(g.find(x => x.latest.name === '其他.md')!.versions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('不同名互不影响;空列表返回空', () => {
|
||||
const g = groupArtifactsByName([art('x', 'a.md', 'T1'), art('y', 'b.md', 'T2')])
|
||||
expect(g).toHaveLength(2)
|
||||
expect(groupArtifactsByName([])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
// 同名工件版本分组(2026-10-11 实测事故的修复语义,用户裁定:存储保留版本、
|
||||
// 界面去重):组内按 updated_at 新→旧,latest 为面板显示行,其余为历史版本。
|
||||
import type { Artifact } from './contracts'
|
||||
|
||||
export interface ArtifactGroup {
|
||||
latest: Artifact
|
||||
versions: Artifact[]
|
||||
}
|
||||
|
||||
export function groupArtifactsByName(list: Artifact[]): ArtifactGroup[] {
|
||||
const byName = new Map<string, Artifact[]>()
|
||||
for (const a of list) {
|
||||
const arr = byName.get(a.name) ?? []
|
||||
arr.push(a)
|
||||
byName.set(a.name, arr)
|
||||
}
|
||||
const out: ArtifactGroup[] = []
|
||||
for (const versions of byName.values()) {
|
||||
versions.sort((x, y) => (x.updatedAt < y.updatedAt ? 1 : x.updatedAt > y.updatedAt ? -1 : 0))
|
||||
out.push({ latest: versions[0], versions })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -3,17 +3,24 @@
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { api } from '../api'
|
||||
import { Icon } from '@ui'
|
||||
import { Collapsible, Icon } from '@ui'
|
||||
import { artifactIcon, humanSize } from '../messageparts'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { groupArtifactsByName } from '../artifactGrouping'
|
||||
|
||||
export function ArtifactsPanel() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const surfaces = useStore(s => s.surfaces)
|
||||
const [q, setQ] = useState('')
|
||||
const [kind, setKind] = useState<'all' | 'document' | 'data' | 'image'>('all')
|
||||
|
||||
const kinds = ['all', 'document', 'data', 'image'] as const
|
||||
const visible = artifacts.filter(a =>
|
||||
(kind === 'all' || a.kind === kind) && a.name.toLowerCase().includes(q.toLowerCase()))
|
||||
// 同名工件 = 同一交付物的版本序列(存储保留全量):面板按名称分组去重,
|
||||
// 只显示最新版;组>1 时可展开看历史版本。2026-10-11 实测 3 份同名转写报告
|
||||
// 并列的直接修复(存储与提示词语义「修订=新版本」保持不变)。
|
||||
const groups = groupArtifactsByName(visible)
|
||||
|
||||
return (
|
||||
<div className="artifacts-panel">
|
||||
@@ -32,26 +39,47 @@ export function ArtifactsPanel() {
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
{surfaces.length > 0 && (
|
||||
<div className="ap-surfaces" data-ap-surfaces>
|
||||
<div className="ap-section-title">生成页面</div>
|
||||
{surfaces.map(sv => (
|
||||
<div key={String(sv.identity.surfaceId)} className="artifact-row" data-surface-row={String(sv.identity.surfaceId)}
|
||||
onClick={() => store.openSurface(String(sv.identity.surfaceId))} title={`打开页面 ${sv.spec?.title ?? ''}`}>
|
||||
<span className="ar-icon"><Icon name="grid" size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{sv.spec?.title || '(未命名页面)'}</span>
|
||||
<span className="meta">页面 · 修订 {sv.revision} · {sv.identity.pluginId}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{artifacts.length === 0
|
||||
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
|
||||
: visible.length === 0
|
||||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||
: visible.map(a => (
|
||||
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
|
||||
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
|
||||
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
|
||||
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{a.name}</span>
|
||||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||
</span>
|
||||
<span className="ar-acts">
|
||||
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
: groups.map(g => g.versions.length === 1
|
||||
? <ArtifactRow key={g.latest.id} a={g.latest} />
|
||||
: <Collapsible key={g.latest.id} id={'artv:' + g.latest.id}
|
||||
trigger={
|
||||
<div className="artifact-row artifact-group" data-artifact-group={g.latest.id}>
|
||||
<ArtifactRowInner a={g.latest} badge={`${g.versions.length} 版`} />
|
||||
</div>
|
||||
}>
|
||||
{g.versions.slice(1).map(v => (
|
||||
<div key={v.id} className="artifact-row artifact-version" data-artifact-version={v.id}
|
||||
onClick={() => store.requestPreview(v)} title={`预览历史版本 ${v.name}`}>
|
||||
<span className="ar-icon"><Icon name="clock" size={13} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="meta">v{shortTime(v.updatedAt)} · {humanSize(v.size) || '—'}</span>
|
||||
</span>
|
||||
<span className="ar-acts">
|
||||
<a className="icon-btn" title="下载此版本" href={api.artifactDownloadUrl(v.id)} download={v.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={13} /></a>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Collapsible>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -65,3 +93,32 @@ function shortTime(iso: string): string {
|
||||
: d.toLocaleDateString([], { month: '2-digit', day: '2-digit' })
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
function ArtifactRow({ a }: { a: Artifact }) {
|
||||
return (
|
||||
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
|
||||
<div className="artifact-row" data-artifact-row={a.id}
|
||||
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
|
||||
<ArtifactRowInner a={a} />
|
||||
<span className="ar-acts">
|
||||
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactRowInner({ a, badge }: { a: Artifact; badge?: string }) {
|
||||
return (
|
||||
<>
|
||||
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{a.name}{badge ? <span className="ver-badge" data-ver-badge>{badge}</span> : null}</span>
|
||||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export function PreviewPane() {
|
||||
</div>
|
||||
<div className="preview-pane-body">
|
||||
{surfaceSnap
|
||||
? <SurfaceView snapshot={surfaceSnap} onAction={(actionId, payload) => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), actionId, payload as Record<string, unknown>)} docBridge={{ onOpenDoc: docId => {
|
||||
? <SurfaceView snapshot={surfaceSnap} onAction={id => store.handleSurfaceAction(String(surfaceSnap?.identity.surfaceId), id, {})} docBridge={{ onOpenDoc: docId => {
|
||||
const art = artifacts.find(a => a.id === docId)
|
||||
if (art) store.requestPreview(art)
|
||||
} }} />
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
// U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。
|
||||
// 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。
|
||||
// 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { Chip, EmptyState, SectionLabel } from '@ui'
|
||||
|
||||
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
|
||||
|
||||
interface InspectProject { projectId: string; name: string; canAudit: boolean }
|
||||
interface Capabilities { actor: string; projects: InspectProject[]; crossProject: boolean }
|
||||
interface CoverageItem { face: string; reason: string }
|
||||
interface ListShell<T = any> { items: T[]; nextCursor?: string; hasMore: boolean; truncated: boolean; coverage?: CoverageItem[] }
|
||||
interface SummaryData { projectId: string; counts: Record<string, number>; watermark: number; lastOpAt: string; coverage?: CoverageItem[] }
|
||||
interface TreeItem { kind: string; id: string; title: string; state?: string }
|
||||
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
|
||||
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
|
||||
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
|
||||
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
|
||||
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
|
||||
|
||||
// ---- 工作区主组件 ----
|
||||
|
||||
const TABS = [
|
||||
{ id: 'summary', label: '概览' },
|
||||
{ id: 'materials', label: '资料与文档' },
|
||||
{ id: 'trace', label: '关联追溯' },
|
||||
{ id: 'timeline', label: '操作记录' },
|
||||
] as const
|
||||
type TabId = (typeof TABS)[number]['id']
|
||||
|
||||
export function RdmAuditWorkspace() {
|
||||
const [caps, setCaps] = useState<Capabilities | null>(null)
|
||||
const [pid, setPid] = useState('')
|
||||
const [tab, setTab] = useState<TabId>('summary')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
|
||||
.then(c => { if (!cancelled) { setCaps(c); if (c.projects.length > 0) setPid(prev => prev || c.projects[0].projectId) } })
|
||||
.catch(e => { if (!cancelled) setError(e.message) })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const project = caps?.projects.find(p => p.projectId === pid)
|
||||
if (error) return <EmptyState icon="alert">{error}</EmptyState>
|
||||
if (!caps) return <div className="empty-note">加载中…</div>
|
||||
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
|
||||
|
||||
return (
|
||||
<div className="rdm-audit">
|
||||
<div className="rdm-audit-header">
|
||||
<select className="ui-input rdm-audit-project" value={pid} onChange={e => setPid(e.target.value)} aria-label="选择项目">
|
||||
{caps.projects.map(p => (
|
||||
<option key={p.projectId} value={p.projectId}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{project?.canAudit && <Chip tone="ok">可审计</Chip>}
|
||||
{caps.crossProject && <Chip tone="info">跨项目</Chip>}
|
||||
</div>
|
||||
<div className="tabs rdm-audit-tabs" role="tablist">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} role="tab" aria-selected={tab === t.id}
|
||||
className={`tab${tab === t.id ? ' active' : ''}`}
|
||||
onClick={() => setTab(t.id)}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rdm-audit-body">
|
||||
{tab === 'summary' && <SummaryTab pid={pid} />}
|
||||
{tab === 'materials' && <MaterialsTab pid={pid} />}
|
||||
{tab === 'trace' && <TraceTab pid={pid} />}
|
||||
{tab === 'timeline' && <TimelineTab pid={pid} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 概览 ----
|
||||
|
||||
function SummaryTab({ pid }: { pid: string }) {
|
||||
const [data, setData] = useState<SummaryData | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
useEffect(() => {
|
||||
let c = false
|
||||
setData(null); setErr('')
|
||||
api.rdmCommand<SummaryData>('rdm.inspect.summary', { projectId: pid })
|
||||
.then(d => { if (!c) setData(d) })
|
||||
.catch(e => { if (!c) setErr(e.message) })
|
||||
return () => { c = true }
|
||||
}, [pid])
|
||||
if (err) return <EmptyState icon="alert">{err}</EmptyState>
|
||||
if (!data) return <div className="empty-note">加载中…</div>
|
||||
const counts = data.counts || {}
|
||||
const entries: [string, string, number][] = [
|
||||
['切片', 'slices', counts.slices ?? 0],
|
||||
['章节', 'sections', counts.sections ?? 0],
|
||||
['文档', 'docs', counts.docs ?? 0],
|
||||
['生效边', 'edgesActive', counts.edgesActive ?? 0],
|
||||
['待确认边', 'edgesCandidate', counts.edgesCandidate ?? 0],
|
||||
['结论', 'conclusions', counts.conclusions ?? 0],
|
||||
['待办', 'openItems', counts.openItems ?? 0],
|
||||
['挂起上传', 'uploadsHeld', counts.uploadsHeld ?? 0],
|
||||
['空缺章节', 'emptySections', counts.emptySections ?? 0],
|
||||
]
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>统计(截至水位 {data.watermark})</SectionLabel>
|
||||
<div className="rdm-audit-cards">
|
||||
{entries.map(([label, , val]) => (
|
||||
<div key={label} className="rdm-audit-card">
|
||||
<div className="rdm-audit-card-num">{val}</div>
|
||||
<div className="rdm-audit-card-label">{label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.lastOpAt && <div className="rdm-audit-meta">最近动作:{new Date(data.lastOpAt).toLocaleString('zh-CN')}</div>}
|
||||
{data.coverage && data.coverage.length > 0 && (
|
||||
<CoverageList cov={data.coverage} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 资料与文档 ----
|
||||
|
||||
function MaterialsTab({ pid }: { pid: string }) {
|
||||
const [kind, setKind] = useState<'notes' | 'docs'>('docs')
|
||||
return (
|
||||
<div className="rdm-audit-materials">
|
||||
<div className="rdm-audit-materials-side">
|
||||
<div className="tabs">
|
||||
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
|
||||
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
|
||||
</div>
|
||||
<TreeList pid={pid} kind={kind} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||||
const [detail, setDetail] = useState<ObjectDetail | null>(null)
|
||||
const genRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const gen = ++genRef.current
|
||||
setShell(null); setErr(''); setSelected(null); setDetail(null)
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setShell(s) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, kind])
|
||||
|
||||
const loadMore = () => {
|
||||
if (!shell?.nextCursor) return
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, 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 selectNode = (item: TreeItem) => {
|
||||
setSelected(item); setDetail(null)
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||||
.then(d => { if (gen === genRef.current) setDetail(d) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
if (err) return <EmptyState icon="alert">{err}</EmptyState>
|
||||
if (!shell) return <div className="empty-note">加载中…</div>
|
||||
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-tree">
|
||||
<div className="rdm-audit-tree-list">
|
||||
{shell.items.map(item => (
|
||||
<button key={item.id} className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(item)}>
|
||||
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{item.title}</span>
|
||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
|
||||
</button>
|
||||
))}
|
||||
{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>
|
||||
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 关联追溯 ----
|
||||
|
||||
function TraceTab({ pid }: { pid: string }) {
|
||||
const [sel, setSel] = useState<{ kind: string; id: string; title: string } | null>(null)
|
||||
const [relations, setRelations] = useState<RelationEntry[] | null>(null)
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const genRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sel) return
|
||||
const gen = ++genRef.current
|
||||
setRelations(null); setErr('')
|
||||
api.rdmCommand<ListShell<RelationEntry>>('rdm.inspect.relations', { projectId: pid, kind: sel.kind, id: sel.id, depth: 1 })
|
||||
.then(s => { if (gen === genRef.current) { setRelations(s.items); setTruncated(s.truncated) } })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, sel])
|
||||
|
||||
// 未选对象时列出章节供选择
|
||||
const [sections, setSections] = useState<TreeItem[]>([])
|
||||
useEffect(() => {
|
||||
if (sel) return
|
||||
let c = false
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs' })
|
||||
.then(s => { if (!c) setSections(s.items.filter(i => i.kind === 'section')) })
|
||||
.catch(() => {})
|
||||
return () => { c = true }
|
||||
}, [pid, sel])
|
||||
|
||||
if (!sel) {
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>选择对象查看来源与影响</SectionLabel>
|
||||
{sections.length === 0 ? <EmptyState icon="folder">暂无章节</EmptyState> : (
|
||||
<div className="rdm-audit-pick">
|
||||
{sections.map(s => (
|
||||
<button key={s.id} className="btn v-ghost" onClick={() => setSel({ kind: 'section', id: s.id, title: s.title })}>
|
||||
{s.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sources = relations?.filter(r => r.to?.id === sel.id && r.to?.kind === sel.kind) ?? []
|
||||
const impacts = relations?.filter(r => r.from?.id === sel.id && r.from?.kind === sel.kind) ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rdm-audit-trace-head">
|
||||
<button className="btn v-ghost" onClick={() => setSel(null)}>← 返回</button>
|
||||
<strong>{sel.title}</strong>
|
||||
<Chip tone="plain">{sel.kind}:{sel.id}</Chip>
|
||||
</div>
|
||||
{err && <EmptyState icon="alert">{err}</EmptyState>}
|
||||
{!relations && !err && <div className="empty-note">加载中…</div>}
|
||||
{relations && relations.length === 0 && <EmptyState icon="folder">没有已知关联</EmptyState>}
|
||||
{relations && relations.length > 0 && (
|
||||
<div className="rdm-audit-trace-cols">
|
||||
<div className="rdm-audit-trace-col">
|
||||
<SectionLabel>来源依据({sources.length})</SectionLabel>
|
||||
{sources.length === 0 ? <div className="empty-note">无来源</div> : sources.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
</div>
|
||||
<div className="rdm-audit-trace-col">
|
||||
<SectionLabel>影响对象({impacts.length})</SectionLabel>
|
||||
{impacts.length === 0 ? <div className="empty-note">无影响</div> : impacts.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RelationRow({ r }: { r: RelationEntry }) {
|
||||
const other = r.from?.id && r.to?.id ? (r.from?.id < r.to?.id ? r.from : r.to) : (r.from || r.to)
|
||||
return (
|
||||
<div className="rdm-audit-rel">
|
||||
<Chip tone={r.layer === 'compliance' ? 'info' : 'plain'}>{r.layer}</Chip>
|
||||
<span className="rdm-audit-rel-type">{r.type || r.kind || '关联'}</span>
|
||||
<span className="rdm-audit-rel-endpoint">{other?.kind}:{other?.id}{other?.ver ? `@${other.ver}` : ''}</span>
|
||||
<Chip tone={r.state === 'active' ? 'ok' : r.state === 'candidate' ? 'warn' : 'plain'}>{r.state}</Chip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 操作记录(事件时间线) ----
|
||||
|
||||
function TimelineTab({ pid }: { pid: string }) {
|
||||
const [items, setItems] = useState<(OpGroup | EventDTO)[]>([])
|
||||
const [cursor, setCursor] = useState('')
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [err, setErr] = useState('')
|
||||
const [filterOp, setFilterOp] = useState('')
|
||||
const [filterActor, setFilterActor] = useState('')
|
||||
const [historyMode, setHistoryMode] = useState(false)
|
||||
const [historySeq, setHistorySeq] = useState(0)
|
||||
const [historyData, setHistoryData] = useState<HistoryData | null>(null)
|
||||
const genRef = useRef(0)
|
||||
|
||||
const load = useCallback((cur: string, replace: boolean) => {
|
||||
const gen = ++genRef.current
|
||||
if (replace) { setItems([]); setErr('') }
|
||||
const params: Record<string, unknown> = { projectId: pid, cursor: cur, limit: 30 }
|
||||
if (filterOp) params.op = filterOp
|
||||
if (filterActor) params.actor = filterActor
|
||||
api.rdmCommand<ListShell<OpGroup | EventDTO>>('rdm.inspect.events', params)
|
||||
.then(s => {
|
||||
if (gen !== genRef.current) return
|
||||
setItems(prev => replace ? s.items : [...prev, ...s.items])
|
||||
setCursor(s.nextCursor || '')
|
||||
setHasMore(s.hasMore)
|
||||
})
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, filterOp, filterActor])
|
||||
|
||||
useEffect(() => { if (!historyMode) load('', true) }, [load, historyMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyMode) { setHistoryData(null); return }
|
||||
const gen = genRef.current
|
||||
setHistoryData(null)
|
||||
const params: Record<string, unknown> = { projectId: pid, atSeq: historySeq || undefined }
|
||||
api.rdmCommand<HistoryData>('rdm.inspect.history', params)
|
||||
.then(d => { if (gen === genRef.current) setHistoryData(d) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, historyMode, historySeq])
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-timeline">
|
||||
<div className="rdm-audit-timeline-filters">
|
||||
<input className="ui-input" placeholder="过滤操作者…" value={filterActor}
|
||||
onChange={e => setFilterActor(e.target.value)} />
|
||||
<input className="ui-input" placeholder="过滤操作类型…" value={filterOp}
|
||||
onChange={e => setFilterOp(e.target.value)} />
|
||||
<button className={`btn v-ghost${historyMode ? ' active' : ''}`} onClick={() => setHistoryMode(!historyMode)}>
|
||||
{historyMode ? '← 返回当前' : '历史模式'}
|
||||
</button>
|
||||
{historyMode && (
|
||||
<input className="ui-input rdm-audit-seq" type="number" placeholder="截止 seq…"
|
||||
value={historySeq || ''} onChange={e => setHistorySeq(Number(e.target.value) || 0)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{historyMode ? (
|
||||
<div>
|
||||
{!historyData ? <div className="empty-note">加载中…</div> : (
|
||||
<div>
|
||||
<SectionLabel>截至 seq {historyData.atSeq}(当前水位 {historyData.watermark})</SectionLabel>
|
||||
{historyData.countOnlySnapshots ? <div className="rdm-audit-truncated">含 {historyData.countOnlySnapshots} 个旧计数快照(不可作恢复依据)</div> : null}
|
||||
{historyData.formalMissing ? <div className="empty-note">{historyData.formalMissing}</div> : null}
|
||||
{historyData.object ? (
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div>标题:{historyData.object.title}</div>
|
||||
<div>状态:{historyData.object.state}</div>
|
||||
{historyData.object.contentPreview && <div>内容预览:{historyData.object.contentPreview}</div>}
|
||||
</div>
|
||||
) : historyData.empty ? <EmptyState icon="folder">该时点无事件</EmptyState> :
|
||||
<div className="empty-note">输入对象 kind:id 查看历史快照</div>}
|
||||
{historyData.formal && (
|
||||
<div className="rdm-audit-formal">
|
||||
正式版 R{historyData.formal.rev}(内容 v{historyData.formal.seq})——{historyData.formal.actor} 于 {new Date(historyData.formal.at).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{err && <EmptyState icon="alert">{err}</EmptyState>}
|
||||
{!err && items.length === 0 && <EmptyState icon="clock">暂无事件</EmptyState>}
|
||||
{items.map((item, i) => {
|
||||
if ('events' in item && Array.isArray((item as OpGroup).events)) {
|
||||
return <OpGroupView key={item.operationId || i} group={item as OpGroup} />
|
||||
}
|
||||
return <EventRow key={(item as EventDTO).seq} ev={item as EventDTO} />
|
||||
})}
|
||||
{hasMore && <button className="btn v-ghost rdm-audit-more" onClick={() => load(cursor, false)}>加载更多</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OpGroupView({ group }: { group: OpGroup }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<div className="rdm-audit-opgroup">
|
||||
<button className="rdm-audit-opgroup-head" onClick={() => setOpen(!open)}>
|
||||
<span className="rdm-audit-opgroup-actor">{group.actor}</span>
|
||||
<span className="rdm-audit-opgroup-time">{new Date(group.at).toLocaleString('zh-CN')}</span>
|
||||
<Chip tone="plain">{group.events.length} 个事件</Chip>
|
||||
</button>
|
||||
{open && group.events.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ ev }: { ev: EventDTO }) {
|
||||
return (
|
||||
<div className="rdm-audit-event">
|
||||
<span className="rdm-audit-event-time">{new Date(ev.at).toLocaleTimeString('zh-CN')}</span>
|
||||
<span className="rdm-audit-event-op">{ev.op}</span>
|
||||
<span className="rdm-audit-event-actor">{ev.actor}</span>
|
||||
{ev.detail && <span className="rdm-audit-event-detail">{ev.detail.slice(0, 80)}</span>}
|
||||
{ev.disclosure !== 'ok' && <Chip tone="warn">{ev.disclosure === 'model-candidate' ? '模型候选' : '来源受限'}</Chip>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 对象详情视图 ----
|
||||
|
||||
function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
|
||||
return (
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div><strong>{detail.title || detail.id}</strong></div>
|
||||
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
|
||||
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
|
||||
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
|
||||
{detail.versions && detail.versions.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>版本链({detail.versions.length})</SectionLabel>
|
||||
{detail.versions.map((v: any, i: number) => (
|
||||
<div key={i} className="rdm-audit-version">
|
||||
v{v.seq} · {v.actor} · {new Date(v.at).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{detail.formal && (
|
||||
<div className="rdm-audit-formal">
|
||||
正式版 R{detail.formal.rev}(内容 v{detail.formal.seq})——{detail.formal.actor} 于 {new Date(detail.formal.at).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
)}
|
||||
{detail.relations && detail.relations.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>一跳关系({detail.relations.length})</SectionLabel>
|
||||
{detail.relations.map((r, i) => <RelationRow key={i} r={r} />)}
|
||||
</div>
|
||||
)}
|
||||
{detail.recentEvents && detail.recentEvents.length > 0 && (
|
||||
<div>
|
||||
<SectionLabel>最近事件</SectionLabel>
|
||||
{detail.recentEvents.map(ev => <EventRow key={ev.seq} ev={ev} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 覆盖范围标注(不冒充完整) ----
|
||||
|
||||
function CoverageList({ cov }: { cov: CoverageItem[] }) {
|
||||
return (
|
||||
<div className="rdm-audit-coverage">
|
||||
<SectionLabel>覆盖范围</SectionLabel>
|
||||
{cov.map(c => (
|
||||
<div key={c.face} className="rdm-audit-coverage-item">
|
||||
<Chip tone="plain">{c.face}</Chip>
|
||||
<span>{c.reason}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { OverlayHost, type DocBridge } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { AgentRow } from './Workbench'
|
||||
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
|
||||
import { RdmAuditWorkspace } from './RdmAuditWorkspace'
|
||||
|
||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
|
||||
export function workspaceEntries(): WorkspaceEntry[] {
|
||||
@@ -28,6 +29,11 @@ export function Workspaces() {
|
||||
<AgentsPanel runs={convRuns} />
|
||||
</OverlayHost>
|
||||
}
|
||||
if (active === 'rdm-audit') {
|
||||
return <OverlayHost title="追溯审计" icon="🔍" shortcut={entry?.shortcut} sayHint="打开追溯审计" onClose={close}>
|
||||
<RdmAuditWorkspace />
|
||||
</OverlayHost>
|
||||
}
|
||||
if (active.startsWith('plugin:')) {
|
||||
const panel = pluginPanels.find(p => `plugin:${p.id}` === active)
|
||||
return <OverlayHost title={panel?.title ?? '插件面板'} icon="🧩" onClose={close}
|
||||
|
||||
+23
-3
@@ -385,7 +385,23 @@ export const store = {
|
||||
})
|
||||
}
|
||||
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
||||
api.surfaceList(convId).then(list => { if (state.currentConvId === convId) set({ surfaces: list ?? [] }) }).catch(() => {})
|
||||
api.surfaceList(convId).then(list => {
|
||||
if (state.currentConvId !== convId) return
|
||||
set({ surfaces: list ?? [] })
|
||||
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
|
||||
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const tabs = state.previewTabs[conv] ?? []
|
||||
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
|
||||
if (!hasSurfaceTab && (list?.length ?? 0) > 0) {
|
||||
const latest = list[0]
|
||||
const opened = openPreviewTab(tabs, { id: 'surface:' + String(latest.surfaceId), name: latest.title || '页面' })
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
})
|
||||
}
|
||||
}).catch(() => {})
|
||||
const resumeSeq = await store.reconcile()
|
||||
store.connectLive(resumeSeq ?? state.lastSeq)
|
||||
},
|
||||
@@ -518,8 +534,9 @@ export const store = {
|
||||
if (!existing || p.revision > existing.revision) {
|
||||
surfaces.push(p)
|
||||
set({ surfaces })
|
||||
// 新页面到达且当前没有活动的页面标签时,为其开预览标签(不抢占用户
|
||||
// 已打开的其他对象;KTD「新结果不抢占」)。
|
||||
// 新页面到达:没有页面标签或当前正在看页面 → 开标签并把右栏切到
|
||||
// 文件预览(与 requestPreview 同语义,用户能立刻看到);否则不抢占
|
||||
// 已打开的对象,但留 toast 痕迹(KTD「新结果不抢占」)。
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const tabs = state.previewTabs[conv] ?? []
|
||||
const activeId = state.previewActive[conv] ?? null
|
||||
@@ -530,6 +547,9 @@ export const store = {
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
})
|
||||
syncFilesEntry(true)
|
||||
} else {
|
||||
store.toast(`页面已更新:${p.spec?.title || id}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
@@ -577,3 +577,52 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.session-note { width: 12rem; }
|
||||
.rewind-actions { display: flex; gap: 8px; }
|
||||
.rewind-error { color: var(--err-text); font-size: var(--fs-ui-sm); }
|
||||
|
||||
/* ---- U4 追溯审计工作区(令牌驱动;@ui/tokens.css 消费) ---- */
|
||||
.rdm-audit { display: flex; flex-direction: column; min-height: 300px; gap: 0; }
|
||||
.rdm-audit-header { display: flex; align-items: center; gap: 10px; padding: 0 0 10px; }
|
||||
.rdm-audit-project { max-width: 220px; height: 32px; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-tabs { padding: 0 0 10px; }
|
||||
.rdm-audit-body { flex: 1; min-height: 0; overflow-y: auto; padding-top: 10px; }
|
||||
.rdm-audit-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 8px; margin-bottom: 12px; }
|
||||
.rdm-audit-card { background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px; text-align: center; }
|
||||
.rdm-audit-card-num { font-size: var(--fs-ui-xl); font-weight: 600; color: var(--text); }
|
||||
.rdm-audit-card-label { font-size: var(--fs-ui-xs); color: var(--text-dim); margin-top: 2px; }
|
||||
.rdm-audit-meta { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 8px; }
|
||||
.rdm-audit-materials { display: flex; gap: 0; min-height: 200px; }
|
||||
.rdm-audit-materials-side { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.rdm-audit-tree { display: flex; gap: 14px; min-height: 200px; }
|
||||
.rdm-audit-tree-list { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.rdm-audit-tree-node { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: none; border: none; border-radius: var(--radius-sm); cursor: pointer; text-align: left; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.rdm-audit-tree-node:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.rdm-audit-tree-node.sel { background: var(--bg-active); color: var(--text); }
|
||||
.rdm-audit-tree-kind { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 3.2em; }
|
||||
.rdm-audit-tree-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rdm-audit-more { margin-top: 8px; }
|
||||
.rdm-audit-truncated { font-size: var(--fs-ui-sm); color: var(--text-faint); padding: 6px 0; }
|
||||
.rdm-audit-detail { flex: 1; min-width: 0; border-left: 1px solid var(--border-soft); padding-left: 14px; }
|
||||
.rdm-audit-detail-body { display: flex; flex-direction: column; gap: 6px; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-version { padding: 3px 0; color: var(--text-dim); }
|
||||
.rdm-audit-formal { margin-top: 8px; padding: 8px 10px; background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--ok-text); }
|
||||
.rdm-audit-pick { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.rdm-audit-trace-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.rdm-audit-trace-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.rdm-audit-trace-col { min-width: 0; }
|
||||
.rdm-audit-rel { display: flex; align-items: center; gap: 6px; padding: 4px 0; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-rel-type { color: var(--text-dim); }
|
||||
.rdm-audit-rel-endpoint { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rdm-audit-timeline-filters { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.rdm-audit-timeline-filters .ui-input { max-width: 160px; height: 30px; font-size: var(--fs-ui-sm); }
|
||||
.rdm-audit-seq { max-width: 100px; }
|
||||
.rdm-audit-opgroup { border: 1px solid var(--border-soft); border-radius: var(--radius-sm); margin-bottom: 4px; overflow: hidden; }
|
||||
.rdm-audit-opgroup-head { display: flex; align-items: center; gap: 8px; width: 100%; padding: 7px 10px; background: var(--bg-panel); border: none; cursor: pointer; font-size: var(--fs-ui-sm); color: var(--text-dim); text-align: left; }
|
||||
.rdm-audit-opgroup-head:hover { background: var(--bg-hover); }
|
||||
.rdm-audit-opgroup-actor { font-weight: 550; color: var(--text); }
|
||||
.rdm-audit-opgroup-time { font-size: var(--fs-ui-xs); color: var(--text-faint); }
|
||||
.rdm-audit-event { display: flex; align-items: center; gap: 8px; padding: 4px 10px; font-size: var(--fs-ui-sm); border-top: 1px solid var(--border-soft); }
|
||||
.rdm-audit-event-time { font-size: var(--fs-ui-xs); color: var(--text-faint); min-width: 5em; }
|
||||
.rdm-audit-event-op { font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--accent-text); min-width: 8em; }
|
||||
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
|
||||
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
||||
.rdm-audit-coverage { margin-top: 12px; }
|
||||
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { store } from './store'
|
||||
import { previewTabsKey } from './previewtabs'
|
||||
import type { StreamEvent, SurfaceSnapshot } from './contracts'
|
||||
|
||||
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
|
||||
@@ -37,3 +38,22 @@ describe('surface.updated projection', () => {
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('surface.updated 可见性联动', () => {
|
||||
it('自动开标签时右栏切到文件预览(syncFilesEntry 语义)', () => {
|
||||
store.applyEvent(surfEvent(101, 'sv1', 1, '可见性页'))
|
||||
const ui = store.getState().ui
|
||||
expect(ui.rightTab).toBe('files')
|
||||
expect(ui.rightCollapsed).toBe(false)
|
||||
expect(ui.filesView).toBe('preview')
|
||||
const tabs = store.getState().previewTabs[previewTabsKey(store.getState().currentConvId)] ?? []
|
||||
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(true)
|
||||
})
|
||||
|
||||
it('抑制开标签时留 toast 痕迹', () => {
|
||||
// 预置:已有 surface 标签但 active 是别的工件标签 → 新页面不开标签
|
||||
store.applyEvent(surfEvent(102, 'sv2', 1, '第二页'))
|
||||
// sv2 已入 surfaces 数组(事件投影),标签未强开——至少状态一致
|
||||
expect(store.getState().surfaces.some(x => String(x.identity.surfaceId) === 'sv2')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface WorkspaceSource {
|
||||
|
||||
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
|
||||
{ key: 'agents', title: '代理', icon: '🤖' },
|
||||
{ key: 'rdm-audit', title: '追溯审计', icon: '🔍' },
|
||||
]
|
||||
|
||||
function shortcutNum(raw?: string): number | null {
|
||||
|
||||
Reference in New Issue
Block a user