diff --git a/e2e/rdm-audit.playwright.ts b/e2e/rdm-audit.playwright.ts index f874bb9..13f52ee 100644 --- a/e2e/rdm-audit.playwright.ts +++ b/e2e/rdm-audit.playwright.ts @@ -144,3 +144,81 @@ test.describe.serial('U4 项目追溯审计工作区', () => { expect(file.suggestedFilename()).toMatch(/\.docx$/) }) }) + +// W10 审计工作台(rdm.audit.operations + rdm.trace.coverage/pending-recheck)。 +// 需真实组合:RDM 二进制含 W8b/W5 命令面(SVC_REAL_RDM_BIN 指向该构建)且 serve 以 auth on 运行; +// e2e.sh 默认从 RDM origin/main 构建,未必含这些命令——缺 SVC_REAL_RDM_BIN 时整组跳过,不伪造结果。 +const OPS_ROUTE = '**/api/commands/rdm.audit.operations' + +async function openOperations(page: Page) { + await login(page) + await openAudit(page) + await page.getByRole('tab', { name: '审计记录' }).click() + await expect(page.getByText('正在读取审计记录…')).toHaveCount(0, { timeout: 15_000 }) +} + +/** 改写请求体的 params(命令总线请求体为 { params, ... })。 */ +const withParams = (raw: string | null, patch: Record) => { + const body = JSON.parse(raw || '{}') as { params?: Record } + return JSON.stringify({ ...body, params: { ...(body.params || {}), ...patch } }) +} + +test.describe.serial('W10 审计工作台:审计记录', () => { + test.skip(!process.env.SVC_REAL_RDM_BIN, '需真实 RDM 二进制(SVC_REAL_RDM_BIN,含 rdm.audit.operations / rdm.trace.*)与 auth-on 组合环境') + + test('列表呈现业务摘要与受控覆盖,行展开见字段差异与来源链', async ({ page }) => { + await openOperations(page) + await expect(page.getByText('受控追溯覆盖').or(page.getByText(/受控覆盖统计暂不可用/))).toBeVisible() + const rows = page.locator('.rdm-audit-op') + await expect(rows.first()).toBeVisible() + await expect(page.getByText(/已显示 \d+ \/ 共 \d+ 条 · 截至水位 \d+/)).toBeVisible() + await rows.first().locator('.mk-col-trigger').click() + await expect(rows.first().getByText('字段差异')).toBeVisible() + await expect(rows.first().getByText('来源与确认链')).toBeVisible() + await expect(rows.first().getByText('影响(当时与当前)')).toBeVisible() + }) + + test('游标翻页:每页一条时加载更多追加下一条', async ({ page }) => { + await page.route(OPS_ROUTE, route => route.continue({ postData: withParams(route.request().postData(), { limit: 1 }) })) + await openOperations(page) + const rows = page.locator('.rdm-audit-op') + await expect(rows).toHaveCount(1) + const meta = await page.getByText(/已显示 \d+ \/ 共 \d+ 条/).textContent() + const total = Number(/共 (\d+) 条/.exec(meta || '')?.[1] || 0) + test.skip(total < 2, `种子项目仅 ${total} 条审计记录,无法验证翻页`) + await page.getByRole('button', { name: '加载更多记录' }).click() + await expect(rows).toHaveCount(2) + }) + + test('游标失效:续页报明确错误,可从第一页重新查询', async ({ page }) => { + await page.route(OPS_ROUTE, async route => { + const body = JSON.parse(route.request().postData() || '{}') as { params?: { cursor?: string } } + if (body.params?.cursor) { + await route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ receipt: { state: 'rejected', error: '操作记录游标已失效:游标绑定主体不符——请从首页重新查询' } }) }) + return + } + const res = await route.fetch({ postData: withParams(route.request().postData(), { limit: 1 }) }) + const json = await res.json() as { payload?: Record } & Record + const pageBody = (json.payload ?? json) as Record + pageBody.hasMore = true + pageBody.nextCursor = 'stale-cursor' + await route.fulfill({ response: res, json }) + }) + await openOperations(page) + await page.getByRole('button', { name: '加载更多记录' }).click() + await expect(page.getByText(/翻页位置已失效/)).toBeVisible() + await expect(page.getByRole('button', { name: '加载更多记录' })).toHaveCount(0) + await page.getByRole('button', { name: '从第一页重新查询' }).click() + await expect(page.getByText(/翻页位置已失效/)).toHaveCount(0) + await expect(page.locator('.rdm-audit-op')).toHaveCount(1) + }) + + test('无审计授权:首页错误态可读', async ({ page }) => { + await page.route(OPS_ROUTE, route => route.fulfill({ status: 200, contentType: 'application/json', + body: JSON.stringify({ receipt: { state: 'rejected', error: '权限拒绝:无项目审计授权' } }) })) + await openOperations(page) + await expect(page.getByText(/你没有该项目的审计授权/)).toBeVisible() + await expect(page.getByText('原始回执')).toBeVisible() + }) +}) diff --git a/src/api.ts b/src/api.ts index 4aea859..c51a57f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -70,6 +70,109 @@ export interface ExecEnvView { bindings?: { conversationId: string; baseId: string; extensions: string[] | null; isolated: string }[] } +// ---- W8b 操作记录投影(rdm.audit.operations):服务端已裁剪的业务 DTO ---- +// 缺项 recorded=false 且 display 为「当时未记录」;current 只是当前状态补出的名称(marker「当前」), +// 不冒充历史。change.disclosure=restricted 时摘要已被服务端替换为「受限」,页面不得假设有正文。 +export interface RdmOperationLabel { key: string; label: string } +export interface RdmOperationText { value?: string; recorded: boolean; display: string } +export interface RdmOperationCurrent { value: string; marker: string } +interface RdmOperationRevision { value?: number; recorded: boolean; display: string } +export interface RdmOperationRef { kind: RdmOperationLabel; id: string } +export interface RdmOperationRefs { items: RdmOperationRef[]; recorded: boolean; display: string } +export interface RdmOperationRecord { + id: string + operation: RdmOperationLabel + actor: { name: RdmOperationText; duty: RdmOperationText; current?: RdmOperationCurrent } + initiatedAt: string + effectiveAt: { value?: string; recorded: boolean; display: string } + object: { recorded: boolean; kind: RdmOperationLabel; id?: string; display: string; current?: RdmOperationCurrent } + change: { before: RdmOperationRevision; after: RdmOperationRevision; summary: RdmOperationText; disclosure: string } + basis: { reason: RdmOperationText; evidence: RdmOperationRefs } + outcome: RdmOperationLabel + impact: { direct: RdmOperationRefs; indirect: RdmOperationRefs; analyses: RdmOperationRefs } + run: { recorded: boolean; runId?: string; stepId?: string; attempt?: number; display: string } +} +export interface RdmOperationDictionary { types: RdmOperationLabel[]; outcomes: RdmOperationLabel[]; kinds: RdmOperationLabel[] } +export interface RdmOperationPage { + items: RdmOperationRecord[] + nextCursor?: string + hasMore: boolean + cutoffSeq: number + total: number + dictionary: RdmOperationDictionary +} +/** 游标绑定项目、主体、授权 epoch、筛选与截止水位;续页须带回首页的 cutoffSeq。 */ +export interface RdmOperationQuery { + targetKind?: string; targetId?: string + types?: string[]; outcomes?: string[] + from?: string; to?: string + cutoffSeq?: number; cursor?: string; limit?: number +} + +// ---- W5 受控影响分析 / 覆盖 / 待复核(rdm.compliance.impact.* / rdm.trace.*) ---- +export interface RdmObjectRef { projectId?: string; kind: string; id: string; revision?: string; anchor?: string } +/** 不可变影响快照:后续边变化不改写历史结果;重算生成新 ID。 */ +export interface RdmImpactAnalysis { + id: string + start: RdmObjectRef + direct: RdmObjectRef[] + layers: RdmObjectRef[][] + involved: RdmObjectRef[] + truncated: boolean + cursor?: { frontier: RdmObjectRef[]; depth: number } + maxNodes: number + maxDepth: number + at: string + actor?: string +} +interface RdmImpactStart { startKind: string; startId: string; startRevision: number; maxNodes?: number; maxDepth?: number } +export interface RdmTraceGap { rule: number; node: string; reason: string } +/** confirmed 为正式覆盖;candidate 分列且不计正式覆盖。 */ +export interface RdmTraceCoverage { confirmed: number; candidate: number; gaps: RdmTraceGap[] } +export interface RdmPendingRecheckEdge { + kind: string + from: string + to: string + fromOldRevision: string + fromNewRevision: string + toOldRevision: string + toNewRevision: string + state: string + source?: string + edgeId?: string +} + +const emptyRefs = (refs?: RdmOperationRefs | null): RdmOperationRefs => + refs ? { ...refs, items: refs.items ?? [] } : { items: [], recorded: false, display: '当时未记录' } + +function normalizeOperationPage(page: RdmOperationPage): RdmOperationPage { + const dictionary = page.dictionary ?? { types: [], outcomes: [], kinds: [] } + return { + ...page, + items: (page.items ?? []).map(r => ({ + ...r, + basis: { ...r.basis, evidence: emptyRefs(r.basis?.evidence) }, + impact: { direct: emptyRefs(r.impact?.direct), indirect: emptyRefs(r.impact?.indirect), analyses: emptyRefs(r.impact?.analyses) }, + })), + hasMore: Boolean(page.hasMore), + dictionary: { types: dictionary.types ?? [], outcomes: dictionary.outcomes ?? [], kinds: dictionary.kinds ?? [] }, + } +} + +function normalizeImpact(a: RdmImpactAnalysis): RdmImpactAnalysis { + return { ...a, direct: a.direct ?? [], layers: (a.layers ?? []).map(l => l ?? []), involved: a.involved ?? [] } +} + +function operationParams(projectId: string, q: RdmOperationQuery): Record { + const params: Record = { projectId } + for (const [k, v] of Object.entries(q)) { + if (v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) continue + if (k === 'cutoffSeq' && !v) continue + params[k] = v + } + return params +} + async function checked(res: Response): Promise { if (!res.ok) { const text = await res.text() @@ -297,6 +400,19 @@ export const api = { if (envelope && envelope.payload !== undefined) return envelope.payload return out as T }), + rdmAuditOperations: (projectId: string, query: RdmOperationQuery = {}) => + api.rdmCommand('rdm.audit.operations', operationParams(projectId, query)).then(normalizeOperationPage), + rdmImpactAnalyze: (projectId: string, start: RdmImpactStart) => + api.rdmCommand('rdm.compliance.impact.analyze', { projectId, ...start }).then(normalizeImpact), + rdmImpactGet: (projectId: string, analysisId: string) => + api.rdmCommand('rdm.compliance.impact.get', { projectId, analysisId }).then(normalizeImpact), + rdmImpactRecompute: (projectId: string, start: RdmImpactStart) => + api.rdmCommand('rdm.compliance.impact.recompute', { projectId, ...start }).then(normalizeImpact), + rdmTracePendingRecheck: (projectId: string) => + api.rdmCommand<{ edges?: RdmPendingRecheckEdge[] | null }>('rdm.trace.pending-recheck', { projectId }).then(r => r?.edges ?? []), + rdmTraceCoverage: (projectId: string) => + api.rdmCommand('rdm.trace.coverage', { projectId }) + .then(r => ({ confirmed: r?.confirmed ?? 0, candidate: r?.candidate ?? 0, gaps: r?.gaps ?? [] })), } // ---- SSE 客户端:Last-Event-ID 游标续传;断线自动重连 ---- diff --git a/src/companion-conversation.css b/src/companion-conversation.css index 2a0f58e..985efff 100644 --- a/src/companion-conversation.css +++ b/src/companion-conversation.css @@ -137,6 +137,18 @@ .conversation-action-anchor { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; width: 100%; margin-top: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); color: var(--text); text-align: left; font-size: var(--fs-ui-caption); cursor: pointer; } .conversation-action-anchor > span:last-child { color: var(--accent-text); } .conversation-action-anchor:hover { background: var(--bg-hover); } +.conversation-work-required { display: grid; gap: 8px; min-width: 0; } +.conversation-candidate { display: grid; gap: 8px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); font-size: var(--fs-ui-sm); min-width: 0; } +.conversation-candidate-head { color: var(--text); font-weight: 600; overflow-wrap: anywhere; } +.conversation-candidate-change { display: grid; gap: 6px; margin: 0; } +.conversation-candidate-change > div { display: grid; grid-template-columns: 4.5em minmax(0, 1fr); gap: 8px; } +.conversation-candidate-change dt { color: var(--text-dim); font-size: var(--fs-ui-caption); } +.conversation-candidate-change dd { margin: 0; color: var(--text); overflow-wrap: anywhere; } +.conversation-candidate .acard-actions { margin-top: 0; } +.conversation-basis { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 9px 14px; border-bottom: 1px solid var(--border-soft); color: var(--text-dim); font-size: var(--fs-ui-caption); min-width: 0; } +.conversation-basis-label { color: var(--text); font-weight: 600; } +.conversation-basis-chip { padding: 2px 8px; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--bg-panel); overflow-wrap: anywhere; } +.conversation-basis-warn { color: var(--warn); } .work-companion-chat .live-bar { flex-wrap: wrap; } .work-companion-chat .part-error-detail, .work-companion-chat .hitl-detail { overflow-wrap: anywhere; white-space: pre-wrap; } @media (max-width: 540px) { diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 5c1832f..24e60a8 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -9,7 +9,7 @@ import { liveToMessage, livePhaseLabel, runEventsToTimeline, showProcessInline } import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate' import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts' import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui' -import { docBridge } from './Workbench' +import { docBridge, pendingConfirmations } from './Workbench' import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts' import { ProcessTimeline } from './ProcessTimeline' import { ConversationWork } from './ConversationWork' @@ -335,15 +335,32 @@ function MessageTools({ msg, convId }: { msg: Message; convId: string }) { ) } +/** 本次实际依据:只列来源名称与 token 估算,不展开正文(正文不随观测下发)。 */ +export function BasisStrip({ obs }: { obs: ContextObs }) { + const sources = obs.sections && obs.sections.length > 0 + ? obs.sections.map(s => ({ key: s.name, label: `${s.name} · 约 ${s.estTokens} token` })) + : obs.items.map(it => ({ key: it, label: it })) + if (sources.length === 0) return null + return ( +
+ 本次依据 + {sources.map(s => {s.label})} + 共约 {obs.estChars} token + {obs.truncations && obs.truncations.length > 0 && 有 {obs.truncations.length} 处内容被裁剪} +
+ ) +} + // 上下文组装观测(4.10/4.17):模型实际看到什么、裁了什么——可解释性入口。 function ContextObsView({ obs }: { obs: ContextObs }) { return (
+ ▸ 🧩 上下文构成 - {obs.items.join(' · ')}(约 {obs.estChars} token) + 查看构成明细 }>
    @@ -551,6 +568,8 @@ function ConversationSession() { // Background work stays in its own card and does not insert another chat turn. const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length const unreadResults = cards.filter(card => card.work?.result && card.work.resultVersion > card.work.readVersion) + const pendingHere = pendingConfirmations(cards, currentConvId, currentProjectId) + const pendingCardId = cards.find(card => card.conversationId === currentConvId && card.projectId === currentProjectId && card.status === 'action_required')?.id ?? '' useEffect(() => { if (!stickRef.current) return @@ -712,6 +731,13 @@ function ConversationSession() {
    + {!workPaneOwnsCards && pendingHere > 0 && } {unreadResults.length > 0 && } diff --git a/src/components/ConversationWork.tsx b/src/components/ConversationWork.tsx index 31f08c2..ab7ce5e 100644 --- a/src/components/ConversationWork.tsx +++ b/src/components/ConversationWork.tsx @@ -5,6 +5,52 @@ import { docBridge } from './Workbench' import type { WorkCardAction } from '../contracts' import { WorkExecutionRecord } from './WorkExecutionRecord' +interface CandidateChange { key: string; before?: string; after: string; impact?: string } + +/** 候选差异与影响:只取服务端已给出的发现字段(corrects 为更正前,text 为候选,impact 为影响),不读取正文。 */ +export function candidateChanges(card: ActivityCard): CandidateChange[] { + if (card.status !== 'action_required') return [] + return (card.task?.findings ?? []) + .filter(f => f.status === 'active' && (f.corrects || f.impact)) + .map(f => ({ key: `${f.id}:${f.revision}`, before: f.corrects, after: f.text, impact: f.impact })) +} + +const CONFIRM_IDS = new Set(['confirm', 'accept', 'approve']) +const REJECT_IDS = new Set(['reject', 'decline', 'deny']) + +/** 确认/拒绝动作:沿用卡片自带 actions;非工作卡的动作行已由 ActivityCardView 渲染,这里只补工作卡。 */ +export function candidateActions(card: ActivityCard) { + if (!card.work || card.status !== 'action_required') return [] + return (card.actions ?? []).filter(a => CONFIRM_IDS.has(a.id) || REJECT_IDS.has(a.id)) + .sort((a, b) => Number(REJECT_IDS.has(a.id)) - Number(REJECT_IDS.has(b.id))) +} + +function CandidateReview({ card, locked, onAction }: { card: ActivityCard; locked: boolean; onAction: (card: ActivityCard, actionId: string, payload?: WorkCardAction) => void }) { + const changes = candidateChanges(card) + const actions = candidateActions(card) + if (changes.length === 0 && actions.length === 0) return null + return ( +
    +
    候选变更 · {card.title}
    + {changes.map(c => ( +
    + {c.before &&
    原内容
    {c.before}
    } +
    {c.before ? '候选' : '候选内容'}
    {c.after}
    + {c.impact &&
    影响
    {c.impact}
    } +
    + ))} + {actions.length > 0 && ( +
    + {actions.map(a => ( + + ))} +
    + )} +
    + ) +} + /** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */ export function ConversationWork() { const cards = useStore(s => s.cards) @@ -65,7 +111,10 @@ export function ConversationWork() {
    工作进展{summary || '已处理的工作'}
    - {required.map(view)} + {required.map(card =>
    + + {view(card)} +
    )} {focused && ▸{focused.title} · 查看摘要}> {view(focused)} diff --git a/src/components/RdmAuditWorkspace.test.tsx b/src/components/RdmAuditWorkspace.test.tsx index d11ead2..51b81b7 100644 --- a/src/components/RdmAuditWorkspace.test.tsx +++ b/src/components/RdmAuditWorkspace.test.tsx @@ -1,6 +1,15 @@ import { describe, expect, it, vi } from 'vitest' import { renderToStaticMarkup } from 'react-dom/server' -import { startLatestFetch, TreeNodeRow } from './RdmAuditWorkspace' +import type { RdmImpactAnalysis, RdmOperationPage, RdmOperationQuery, RdmOperationRecord } from '../api' +import { + classifyOperationsError, createOperationsFeed, describeOperation, diffImpact, isLegacyOperation, + OperationRow, OperationsListView, startLatestFetch, TraceCoverageView, TreeNodeRow, + type OperationsFeedState, +} from './RdmAuditWorkspace' +import type { ActivityCard } from '../contracts' +import { BasisStrip } from './Conversation' +import { candidateActions, candidateChanges } from './ConversationWork' +import { pendingConfirmations } from './Workbench' function deferred() { let resolve!: (v: T) => void @@ -82,3 +91,309 @@ describe('TreeNodeRow 无障碍属性', () => { expect(html).not.toContain('rdm-audit-tree-caret') }) }) + +// ---- W10 审计记录 ---- + +const NOT_RECORDED = { recorded: false, display: '当时未记录' } +const refs = (items: { key: string; label: string; id: string }[]) => ({ + items: items.map(i => ({ kind: { key: i.key, label: i.label }, id: i.id })), + recorded: items.length > 0, + display: items.length > 0 ? `共 ${items.length} 项` : '当时未记录', +}) + +function record(id: string, patch: Partial = {}): RdmOperationRecord { + return { + id, + operation: { key: 'domain.mutate', label: '领域变更' }, + actor: { name: { value: '张三', recorded: true, display: '张三' }, duty: { value: '设计负责人', recorded: true, display: '设计负责人' } }, + initiatedAt: '2026-10-20T08:00:00Z', + effectiveAt: { value: '2026-10-20T08:00:01Z', recorded: true, display: '2026-10-20T08:00:01Z' }, + object: { recorded: true, kind: { key: 'section', label: '章节' }, id: 'S-1', display: '章节 S-1' }, + change: { + before: { value: 1, recorded: true, display: 'r1' }, + after: { value: 2, recorded: true, display: 'r2' }, + summary: { value: '补充风险控制措施', recorded: true, display: '补充风险控制措施' }, + disclosure: 'ok', + }, + basis: { reason: { value: '评审意见', recorded: true, display: '评审意见' }, evidence: refs([{ key: 'slice', label: '笔记切片', id: 'SL-9' }]) }, + outcome: { key: 'succeeded', label: '成功' }, + impact: { direct: refs([{ key: 'doc', label: '文档', id: 'D-1' }]), indirect: refs([]), analyses: refs([{ key: 'impact-analysis', label: '影响分析', id: 'IA-1' }]) }, + run: { recorded: true, runId: 'run-7', stepId: 'step-2', attempt: 1, display: '宿主可信回执' }, + ...patch, + } +} + +function page(ids: string[], extra: Partial = {}): RdmOperationPage { + return { + items: ids.map(id => record(id)), hasMore: false, cutoffSeq: 42, total: ids.length, + dictionary: { types: [], outcomes: [], kinds: [] }, ...extra, + } +} + +const READY: OperationsFeedState = { + phase: 'ready', items: [], nextCursor: '', hasMore: false, cutoffSeq: 42, total: 0, + dictionary: null, loadingMore: false, error: null, +} +const listHtml = (state: OperationsFeedState) => + renderToStaticMarkup( {}} onRestart={() => {}} />) + +describe('classifyOperationsError', () => { + it('游标失效给出明确的从首页重查文案', () => { + const e = classifyOperationsError(new Error('操作记录游标已失效:游标绑定主体不符——请从首页重新查询')) + expect(e.kind).toBe('cursor') + expect(e.message).toContain('翻页位置已失效') + }) + it('无审计授权与能力缺失分开呈现', () => { + expect(classifyOperationsError(new Error('权限拒绝:无项目审计授权')).kind).toBe('denied') + expect(classifyOperationsError(new Error('400: {"error":"unknown or disabled command"}')).kind).toBe('capability') + expect(classifyOperationsError(new Error('boom')).kind).toBe('query') + }) +}) + +describe('createOperationsFeed(审计列表单一异步所有者)', () => { + it('首页加载后续页带回游标与截止水位并追加', async () => { + const calls: RdmOperationQuery[] = [] + const feed = createOperationsFeed(async q => { + calls.push(q) + return q.cursor ? page(['b']) : page(['a'], { hasMore: true, nextCursor: 'c1', total: 2 }) + }) + feed.first() + await flush() + expect(feed.getState().items.map(r => r.id)).toEqual(['a']) + feed.more() + expect(feed.getState().loadingMore).toBe(true) + await flush() + expect(calls[1]).toEqual({ cursor: 'c1', cutoffSeq: 42 }) + expect(feed.getState().items.map(r => r.id)).toEqual(['a', 'b']) + expect(feed.getState().hasMore).toBe(false) + }) + + it('按 seq 丢弃迟到响应:重查后旧请求的结果不写回', async () => { + const older = deferred() + const newer = deferred() + const queue = [older, newer] + const feed = createOperationsFeed(() => queue.shift()!.promise) + const seen = vi.fn() + feed.subscribe(seen) + feed.first() + feed.first() + newer.resolve(page(['new'])) + await flush() + older.resolve(page(['old'])) + await flush() + expect(feed.getState().items.map(r => r.id)).toEqual(['new']) + older.reject(new Error('late')) + await flush() + expect(feed.getState().error).toBeNull() + }) + + it('翻页途中重查:迟到的续页不混入新列表', async () => { + const more = deferred() + let n = 0 + const feed = createOperationsFeed(q => { + n++ + if (q.cursor) return more.promise + return Promise.resolve(n === 1 ? page(['a'], { hasMore: true, nextCursor: 'c1' }) : page(['fresh'])) + }) + feed.first() + await flush() + feed.more() + feed.first() + await flush() + more.resolve(page(['stale-b'])) + await flush() + expect(feed.getState().items.map(r => r.id)).toEqual(['fresh']) + }) + + it('dispose 后在途响应被丢弃', async () => { + const pending = deferred() + const feed = createOperationsFeed(() => pending.promise) + feed.first() + feed.dispose() + pending.resolve(page(['late'])) + await flush() + expect(feed.getState().phase).toBe('loading') + }) + + it('续页游标失效:保留已读记录并给出游标错误', async () => { + const feed = createOperationsFeed(async q => { + if (q.cursor) throw new Error('操作记录游标已失效:游标绑定项目不符') + return page(['a'], { hasMore: true, nextCursor: 'c1' }) + }) + feed.first() + await flush() + feed.more() + await flush() + const s = feed.getState() + expect(s.items.map(r => r.id)).toEqual(['a']) + expect(s.error?.kind).toBe('cursor') + expect(s.loadingMore).toBe(false) + }) +}) + +describe('OperationsListView 状态文案', () => { + it('加载态', () => { + expect(listHtml({ ...READY, phase: 'loading' })).toContain('正在读取审计记录') + }) + it('首页错误:无权限态可读且附原始回执', () => { + const html = listHtml({ ...READY, phase: 'error', error: classifyOperationsError(new Error('权限拒绝:x')) }) + expect(html).toContain('你没有该项目的审计授权') + expect(html).toContain('原始回执') + expect(html).not.toContain(' { + expect(listHtml(READY)).toContain('当前筛选下没有审计记录') + }) + it('列表含业务摘要、计数与翻页按钮', () => { + const html = listHtml({ ...READY, items: [record('a')], total: 3, hasMore: true, nextCursor: 'c1' }) + expect(html).toContain('张三') + expect(html).toContain('领域变更') + expect(html).toContain('章节 S-1') + expect(html).toContain('已显示 1 / 共 3 条 · 截至水位 42') + expect(html).toContain('加载更多记录') + }) + it('续页游标失效:显示错误与从第一页重新查询,不再提供续页按钮', () => { + const html = listHtml({ ...READY, items: [record('a')], hasMore: true, nextCursor: 'c1', error: classifyOperationsError(new Error('操作记录游标已失效')) }) + expect(html).toContain('翻页位置已失效') + expect(html).toContain('从第一页重新查询') + expect(html).not.toContain('加载更多记录') + }) +}) + +describe('OperationRow 展开内容', () => { + const row = (r: RdmOperationRecord) => renderToStaticMarkup() + + it('字段差异、来源/确认链、当时影响与证据跳转入口', () => { + const html = row(record('a')) + expect(html).toContain('字段差异') + expect(html).toContain('r1') + expect(html).toContain('r2') + expect(html).toContain('补充风险控制措施') + expect(html).toContain('来源与确认链') + expect(html).toContain('评审意见') + expect(html).toContain('运行 run-7') + expect(html).toContain('查看证据:笔记切片 SL-9') + expect(html).toContain('文档 D-1') + expect(html).toContain('查看影响分析 IA-1') + }) + + it('人物以当时名称为主,当前名称只带「当前」标记', () => { + const r = record('a', { actor: { name: { value: '张三', recorded: true, display: '张三' }, duty: NOT_RECORDED, current: { value: '张三丰', marker: '当前' } } }) + const v = describeOperation(r) + expect(v.who).toBe('张三') + expect(v.whoNow).toBe('当前:张三丰') + expect(row(r)).toContain('当前:张三丰') + }) + + it('旧数据:缺项显示「当时未记录」并标旧记录', () => { + const r = record('a', { basis: { reason: NOT_RECORDED, evidence: refs([]) }, change: { ...record('a').change, before: NOT_RECORDED } }) + expect(isLegacyOperation(r)).toBe(true) + const html = row(r) + expect(html).toContain('旧记录') + expect(html).toContain('当时未记录') + }) + + it('无正文权:只显示服务端给出的「受限」元数据', () => { + const r = record('a', { change: { ...record('a').change, summary: { recorded: true, display: '受限' }, disclosure: 'restricted' } }) + const html = row(r) + expect(html).toContain('正文受限') + expect(html).toContain('受限') + expect(html).not.toContain('补充风险控制措施') + }) + + it('长对象名可换行(不截断)', () => { + const long = '章节 ' + 'X'.repeat(200) + const html = row(record('a', { object: { recorded: true, kind: { key: 'section', label: '章节' }, id: 'X', display: long } })) + expect(html).toContain(long) + expect(html).toContain('class="rdm-audit-op-object"') + expect(html).toContain('rdm-audit-event-summary rdm-audit-wrap') + }) +}) + +describe('TraceCoverageView 部分覆盖', () => { + it('候选不计正式覆盖;缺项与待复核提示部分覆盖', () => { + const html = renderToStaticMarkup() + expect(html).toContain('正式覆盖') + expect(html).toContain('候选(不计正式覆盖)') + expect(html).toContain('部分覆盖') + expect(html).toContain('缺项链(1)') + expect(html).toContain('待复核受控边(1)') + }) + it('全部确认时不提示部分覆盖', () => { + const html = renderToStaticMarkup() + expect(html).not.toContain('部分覆盖') + }) +}) + +describe('diffImpact(当时与当前影响)', () => { + const analysis = (involved: string[]): RdmImpactAnalysis => ({ + id: 'x', start: { kind: 'UR', id: '1', revision: '1' }, direct: [], layers: [], + involved: involved.map(id => ({ kind: 'SR', id })), truncated: false, maxNodes: 100, maxDepth: 5, at: '2026-10-20T00:00:00Z', + }) + it('按 kind:id 比较新增与不再涉及', () => { + const d = diffImpact(analysis(['a', 'b']), analysis(['b', 'c'])) + expect(d.added.map(r => r.id)).toEqual(['c']) + expect(d.removed.map(r => r.id)).toEqual(['a']) + }) +}) + +describe('对话侧:候选差异、确认路径与依据条', () => { + const card = (over: Partial): ActivityCard => ({ + id: 'c1', projectId: 'p1', conversationId: 'v1', kind: 'progress', title: '追溯候选', status: 'action_required', + createdAt: '2026-10-20T00:00:00Z', updatedAt: '2026-10-20T00:00:00Z', ...over, + }) + const finding = (over: Record) => ({ id: 'f1', text: '需求 UR-1 由 SR-2 覆盖', kind: 'analysis', status: 'active', revision: 1, sources: [], ...over }) + const task = (findings: unknown[]) => ({ findings } as unknown as ActivityCard['task']) + + it('候选差异只取服务端发现字段:更正前、候选与影响', () => { + const changes = candidateChanges(card({ task: task([ + finding({ corrects: '需求 UR-1 无覆盖', impact: '验证 VR-3 需复核' }), + finding({ id: 'f2', status: 'superseded', corrects: '旧' }), + finding({ id: 'f3' }), + ]) })) + expect(changes).toEqual([{ key: 'f1:1', before: '需求 UR-1 无覆盖', after: '需求 UR-1 由 SR-2 覆盖', impact: '验证 VR-3 需复核' }]) + }) + + it('非待处理卡不呈现候选差异', () => { + expect(candidateChanges(card({ status: 'done', task: task([finding({ impact: 'x' })]) }))).toEqual([]) + }) + + it('确认/拒绝只为工作卡补齐(非工作卡动作行由卡片自身渲染),确认在前', () => { + const actions = [{ id: 'reject', label: '拒绝' }, { id: 'confirm', label: '确认' }, { id: 'recheck', label: '重新检查' }] + expect(candidateActions(card({ actions }))).toEqual([]) + const work = { resultVersion: 2, readVersion: 2 } + expect(candidateActions(card({ actions, work })).map(a => a.id)).toEqual(['confirm', 'reject']) + expect(candidateActions(card({ actions, work, status: 'resolved' }))).toEqual([]) + }) + + it('待确认计数只算本会话待处理卡,供右栏收起后的对话确认路径', () => { + const cards = [ + card({ id: 'a' }), + card({ id: 'b', status: 'active', work: { resultVersion: 1, readVersion: 1, decision: { mode: 'single', state: 'open' } } }), + card({ id: 'c', conversationId: 'other' }), + card({ id: 'd', status: 'done' }), + ] + expect(pendingConfirmations(cards, 'v1', 'p1')).toBe(2) + expect(pendingConfirmations(cards, 'v1', 'p2')).toBe(0) + }) + + it('依据条列出来源与 token 摘要,不含正文', () => { + const html = renderToStaticMarkup() + expect(html).toContain('本次依据') + expect(html).toContain('项目知识 · 约 1200 token') + expect(html).toContain('共约 1800 token') + expect(html).toContain('有 1 处内容被裁剪') + expect(html).not.toContain('历史已裁剪') + }) + + it('无分节时退回条目名;无来源时不渲染', () => { + expect(renderToStaticMarkup()).toContain('系统提示') + expect(renderToStaticMarkup()).toBe('') + }) +}) diff --git a/src/components/RdmAuditWorkspace.tsx b/src/components/RdmAuditWorkspace.tsx index 48f51c4..1cefb87 100644 --- a/src/components/RdmAuditWorkspace.tsx +++ b/src/components/RdmAuditWorkspace.tsx @@ -1,8 +1,12 @@ // U4:项目追溯审计工作区(四入口)——消费 U3 rdm.inspect.* 命令面。 // 只读:审批/确认关系仍回原业务流程(D31:弹层不是唯一路径)。 // 令牌驱动(DESIGN.md):颜色/字号/圆角仅消费 @ui/tokens.css;折叠用 Collapsible。 -import { useCallback, useEffect, useRef, useState } from 'react' -import { api } from '../api' +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' +import { + api, + type RdmImpactAnalysis, type RdmObjectRef, type RdmOperationDictionary, type RdmOperationPage, type RdmOperationQuery, + type RdmOperationRecord, type RdmOperationRef, type RdmOperationRefs, type RdmPendingRecheckEdge, type RdmTraceCoverage, +} from '../api' import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui' import { classifyRdmError } from '../rdmErrors' import { @@ -45,6 +49,7 @@ const TABS = [ { id: 'materials', label: '资料与文档' }, { id: 'trace', label: '关联追溯' }, { id: 'timeline', label: '操作记录' }, + { id: 'operations', label: '审计记录' }, ] as const type TabId = (typeof TABS)[number]['id'] @@ -129,6 +134,7 @@ export function RdmAuditWorkspace() { {tab === 'materials' && } {tab === 'trace' && } {tab === 'timeline' && } + {tab === 'operations' && }
    ) @@ -582,6 +588,7 @@ function TreeList({ pid, kind, onOpenDoc }: { pid: string; kind: 'notes' | 'docs ) } +// TODO(W10):知识多挂载位置暂不显示——rdm.inspect.tree/object DTO 尚无挂载位置字段,待 RDM 增补后在本行与对象详情跟进。 export function TreeNodeRow({ item, selectedId, children, onSelect, onLoadMore, onOpenDoc }: { item: TreeItem selectedId?: string @@ -918,3 +925,430 @@ function CoverageList({ cov }: { cov: CoverageItem[] }) {
) } + +// ---- 审计记录(W10:rdm.audit.operations 正式审计列表 + W5 影响/覆盖/待复核) ---- +// 只渲染服务端已裁剪的 DTO:「当时未记录」「当前」「受限」等文案由服务端给出,页面不补正文。 + +const fmtTime = (s?: string) => { + if (!s) return '—' + const d = new Date(s) + return Number.isNaN(d.getTime()) ? s : d.toLocaleString('zh-CN') +} + +export interface OperationsFeedError { kind: 'cursor' | 'denied' | 'capability' | 'auth' | 'query'; message: string; raw: string } + +export function classifyOperationsError(err: unknown): OperationsFeedError { + const raw = err instanceof Error ? err.message : String(err) + if (/游标已失效|游标不合法/.test(raw)) { + return { kind: 'cursor', message: '翻页位置已失效:项目、登录身份或授权、筛选条件或截止水位已变化,不能接着上一页继续读。请从第一页重新查询。', raw } + } + if (/权限拒绝/.test(raw)) { + return { kind: 'denied', message: '你没有该项目的审计授权,审计记录不可查看。如需查看,请联系项目创建者或域管理员授权。', raw } + } + const c = classifyRdmError(raw) + return { kind: c.kind, message: c.message, raw } +} + +export interface OperationsFeedState { + phase: 'loading' | 'ready' | 'error' + items: RdmOperationRecord[] + nextCursor: string + hasMore: boolean + cutoffSeq: number + total: number + dictionary: RdmOperationDictionary | null + loadingMore: boolean + error: OperationsFeedError | null +} + +const FEED_LOADING: OperationsFeedState = { + phase: 'loading', items: [], nextCursor: '', hasMore: false, cutoffSeq: 0, total: 0, + dictionary: null, loadingMore: false, error: null, +} + +function appendUnique(prev: RdmOperationRecord[], next: RdmOperationRecord[]) { + const seen = new Set(prev.map(r => r.id)) + return [...prev, ...next.filter(r => !seen.has(r.id))] +} + +/** 审计列表的唯一异步状态所有者:每次请求取新 seq,非最新 seq 的响应(含失败)一律丢弃。 + * 续页带回首页的 cutoffSeq,保证翻页期间新写入不让已翻过的页面漂移。 */ +export function createOperationsFeed(fetchPage: (q: RdmOperationQuery) => Promise) { + let seq = 0 + let state = FEED_LOADING + const listeners = new Set<() => void>() + const set = (next: OperationsFeedState) => { + state = next + for (const l of listeners) l() + } + const request = ( + q: RdmOperationQuery, + onPage: (p: RdmOperationPage) => OperationsFeedState, + onError: (e: OperationsFeedError) => OperationsFeedState, + ) => { + const mine = ++seq + fetchPage(q).then( + p => { if (mine === seq) set(onPage(p)) }, + e => { if (mine === seq) set(onError(classifyOperationsError(e))) }, + ) + } + return { + getState: () => state, + subscribe: (l: () => void) => { + listeners.add(l) + return () => { listeners.delete(l) } + }, + first: () => { + set(FEED_LOADING) + request({}, + p => ({ ...FEED_LOADING, phase: 'ready', items: p.items, nextCursor: p.nextCursor || '', hasMore: p.hasMore, cutoffSeq: p.cutoffSeq, total: p.total, dictionary: p.dictionary }), + error => ({ ...FEED_LOADING, phase: 'error', error })) + }, + more: () => { + if (state.phase !== 'ready' || state.loadingMore || !state.hasMore || !state.nextCursor) return + set({ ...state, loadingMore: true, error: null }) + request({ cursor: state.nextCursor, cutoffSeq: state.cutoffSeq }, + p => ({ ...state, loadingMore: false, items: appendUnique(state.items, p.items), nextCursor: p.nextCursor || '', hasMore: p.hasMore, total: p.total }), + error => ({ ...state, loadingMore: false, error })) + }, + dispose: () => { seq++ }, + } +} + +const OUTCOME_TONE: Record = { + succeeded: 'ok', rejected: 'danger', failed: 'danger', no_effect: 'plain', unknown_delay: 'warn', +} + +/** 旧记录:服务端以 recorded=false 标出的缺项(显示「当时未记录」,不代表为空)。 */ +export function isLegacyOperation(r: RdmOperationRecord): boolean { + return !r.actor.name.recorded || !r.object.recorded || !r.change.before.recorded || !r.change.after.recorded + || !r.change.summary.recorded || !r.basis.reason.recorded +} + +/** 业务中文摘要:当时名称为主;「当前」名称只作附注,不冒充历史。 */ +export function describeOperation(r: RdmOperationRecord) { + const who = r.actor.name.display + const whoNow = r.actor.current && r.actor.current.value !== r.actor.name.value + ? `${r.actor.current.marker}:${r.actor.current.value}` : '' + const object = r.object.display + const objectNow = r.object.current ? `${r.object.current.marker}:${r.object.current.value}` : '' + const when = fmtTime(r.initiatedAt) + return { + who, whoNow, object, objectNow, when, + action: r.operation.label, + outcome: r.outcome.label, + sentence: `${who} 于 ${when} 对「${object}」执行「${r.operation.label}」,结果:${r.outcome.label}`, + } +} + +const refsText = (refs: RdmOperationRefs) => + refs.recorded && refs.items.length > 0 ? refs.items.map(x => `${x.kind.label} ${x.id}`).join('、') : refs.display + +function OperationsTab({ pid }: { pid: string }) { + const identity = useStore(() => store.getIdentityEpoch()) + const [outcome, setOutcome] = useState('') + const [type, setType] = useState('') + const [dict, setDict] = useState(null) + const feed = useMemo(() => createOperationsFeed(q => api.rdmAuditOperations(pid, { + ...q, limit: 30, outcomes: outcome ? [outcome] : undefined, types: type ? [type] : undefined, + })), [pid, outcome, type, identity]) + useEffect(() => { + feed.first() + return feed.dispose + }, [feed]) + const state = useSyncExternalStore(feed.subscribe, feed.getState, feed.getState) + useEffect(() => { if (state.dictionary) setDict(state.dictionary) }, [state.dictionary]) + + return ( +
+ + 审计记录 +
+ + +
+ +
+ ) +} + +export function OperationsListView({ pid, state, onMore, onRestart }: { + pid: string + state: OperationsFeedState + onMore: () => void + onRestart: () => void +}) { + if (state.phase === 'loading') return
正在读取审计记录…
+ if (state.phase === 'error' && state.error) return + return ( +
+ {state.items.length === 0 && 当前筛选下没有审计记录} + {state.items.map(r => )} + {state.items.length > 0 && ( +
已显示 {state.items.length} / 共 {state.total} 条 · 截至水位 {state.cutoffSeq}
+ )} + {state.error && } + {state.hasMore && !state.error && ( + + )} +
+ ) +} + +function OperationsErrorView({ error, onRestart }: { error: OperationsFeedError; onRestart: () => void }) { + return ( +
+ {error.message} + {(error.kind === 'cursor' || error.kind === 'query') && ( + + )} + 原始回执}> + {error.raw} + +
+ ) +} + +export function OperationRow({ pid, record, defaultOpen }: { pid: string; record: RdmOperationRecord; defaultOpen?: boolean }) { + const view = describeOperation(record) + const legacy = isLegacyOperation(record) + const restricted = record.change.disclosure === 'restricted' + const { actor, change, basis, impact, run, effectiveAt } = record + return ( +
+ + {view.who} + {view.whoNow && {view.whoNow}} + {view.action} + {view.object} + {view.objectNow && {view.objectNow}} + {view.when} + {view.outcome} + {legacy && 旧记录} + {restricted && 正文受限} + + )}> +
+
{view.sentence}
+ {legacy &&
这是旧记录:标为「当时未记录」的字段在操作发生时没有记录,不代表为空或为零。
} + {restricted &&
正文受限:你当前没有该对象的正文权(跨项目、无权或已撤权),这里只显示元数据。
} + 字段差异 +
+
修改前
{change.before.display}
+
修改后
{change.after.display}
+
差异摘要
{change.summary.display}
+
+ 来源与确认链 +
+
操作人(当时)
{actor.name.display}{actor.duty.recorded ? ` · ${actor.duty.display}` : ''}{view.whoNow ? `(${view.whoNow})` : ''}
+
依据原因
{basis.reason.display}
+
执行回执
{run.display}{run.runId ? ` · 运行 ${run.runId}` : ''}{run.stepId ? ` · 步骤 ${run.stepId}` : ''}{run.attempt ? ` · 第 ${run.attempt} 次尝试` : ''}
+
发起时间
{view.when}
+
生效时间
{effectiveAt.recorded && effectiveAt.value ? fmtTime(effectiveAt.value) : effectiveAt.display}
+
对象路径
{view.object}{view.objectNow ? `(${view.objectNow})` : ''}
+
+ + 影响(当时与当前) +
+
直接影响(当时)
{refsText(impact.direct)}
+
间接影响(当时)
{refsText(impact.indirect)}
+
受控影响分析
{impact.analyses.display}
+
+ {impact.analyses.items.map(a => )} +
+
+
+ ) +} + +function EvidenceList({ pid, refs }: { pid: string; refs: RdmOperationRefs }) { + return ( +
+ 依据证据 + {refs.items.length === 0 + ?
{refs.display}
+ : refs.items.map(r => )} +
+ ) +} + +/** 证据跳转:按需读取对象详情;服务端按当前正文权判定,无权时只给出可读拒绝,不假设有正文。 */ +function EvidenceLink({ pid, evidence }: { pid: string; evidence: RdmOperationRef }) { + const [open, setOpen] = useState(false) + const [detail, setDetail] = useState(null) + const [err, setErr] = useState('') + const genRef = useRef(0) + useEffect(() => { + if (!open) return + setDetail(null); setErr('') + return startLatestFetch(genRef, + () => api.rdmCommand('rdm.inspect.object', { projectId: pid, kind: evidence.kind.key, id: evidence.id }), + setDetail, setErr) + }, [open, pid, evidence.kind.key, evidence.id]) + const label = `${evidence.kind.label} ${evidence.id}` + return ( +
+ + {open && (err + ? (/权限拒绝|无权|受限/.test(err) + ?
无权查看该证据(跨项目、无权或已撤权),仅保留元数据:{label}
+ : ) + : !detail ?
加载中…
: )} +
+ ) +} + +const refKey = (r: RdmObjectRef) => `${r.kind}:${r.id}` +const refLabel = (r: RdmObjectRef) => `${r.kind} ${r.id}${r.revision ? ` r${r.revision}` : ''}` + +/** 当时快照与按当前受控图重算结果的差异(按对象 kind:id 比较)。 */ +export function diffImpact(then: RdmImpactAnalysis, now: RdmImpactAnalysis) { + const thenKeys = new Set(then.involved.map(refKey)) + const nowKeys = new Set(now.involved.map(refKey)) + return { + added: now.involved.filter(r => !thenKeys.has(refKey(r))), + removed: then.involved.filter(r => !nowKeys.has(refKey(r))), + } +} + +function ImpactSnapshotView({ title, a }: { title: string; a: RdmImpactAnalysis }) { + return ( +
+ {title} +
起点 {refLabel(a.start)} · 分析 {a.id} · {fmtTime(a.at)}{a.actor ? ` · ${a.actor}` : ''}
+
直接影响 {a.direct.length} 项 · 共涉及 {a.involved.length} 项 · {a.layers.length} 层
+ {a.direct.length > 0 &&
直接:{a.direct.map(refLabel).join('、')}
} + {a.truncated &&
部分覆盖:已达上限({a.maxNodes} 个对象 / {a.maxDepth} 层)截断,结果不完整
} +
+ ) +} + +/** 当时与当前影响:读取不可变快照;重算须显式触发(生成新分析快照,不覆盖历史)。 */ +function ImpactAnalysisBlock({ pid, analysisId }: { pid: string; analysisId: string }) { + const [open, setOpen] = useState(false) + const [snapshot, setSnapshot] = useState(null) + const [current, setCurrent] = useState(null) + const [err, setErr] = useState('') + const [busy, setBusy] = useState(false) + const snapRef = useRef(0) + const currentRef = useRef(0) + useEffect(() => { + if (!open) return + setSnapshot(null); setErr('') + return startLatestFetch(snapRef, () => api.rdmImpactGet(pid, analysisId), setSnapshot, setErr) + }, [open, pid, analysisId]) + useEffect(() => () => { currentRef.current++ }, [pid, analysisId]) + + const revision = Number(snapshot?.start.revision) + const recompute = () => { + if (!snapshot || !(revision > 0)) return + setBusy(true); setErr('') + startLatestFetch(currentRef, + () => api.rdmImpactRecompute(pid, { startKind: snapshot.start.kind, startId: snapshot.start.id, startRevision: revision, maxNodes: snapshot.maxNodes, maxDepth: snapshot.maxDepth }), + a => { setCurrent(a); setBusy(false) }, + e => { setErr(e); setBusy(false) }) + } + const diff = snapshot && current ? diffImpact(snapshot, current) : null + + return ( +
+ + {open && ( + <> + {err && } + {!snapshot && !err &&
加载中…
} + {snapshot && } + {snapshot && revision > 0 && ( + + )} + {current && } + {diff && ( +
+
当前新增:{diff.added.length ? diff.added.map(refLabel).join('、') : '无'}
+
当前不再涉及:{diff.removed.length ? diff.removed.map(refLabel).join('、') : '无'}
+
+ )} + + )} +
+ ) +} + +interface TraceCoverageData { coverage: RdmTraceCoverage; pending: RdmPendingRecheckEdge[] } + +const PENDING_STATE_LABELS: Record = { 'pending-recheck': '待复核', stale: '已过期待复核' } + +function TraceCoveragePanel({ pid }: { pid: string }) { + const [data, setData] = useState(null) + const [err, setErr] = useState('') + const genRef = useRef(0) + useEffect(() => { + setData(null); setErr('') + return startLatestFetch(genRef, + () => Promise.all([api.rdmTraceCoverage(pid), api.rdmTracePendingRecheck(pid)]).then(([coverage, pending]) => ({ coverage, pending })), + setData, setErr) + }, [pid]) + if (err) return
受控覆盖统计暂不可用:{classifyOperationsError(err).message}
+ if (!data) return
正在读取受控覆盖…
+ return +} + +export function TraceCoverageView({ pid, data }: { pid: string; data: TraceCoverageData }) { + const { coverage, pending } = data + const partial = coverage.gaps.length > 0 || coverage.candidate > 0 || pending.length > 0 + const cards: [string, number][] = [ + ['正式覆盖', coverage.confirmed], + ['候选(不计正式覆盖)', coverage.candidate], + ['待复核', pending.length], + ['缺项链', coverage.gaps.length], + ] + return ( +
+ 受控追溯覆盖 +
+ {cards.map(([label, n]) => ( +
+
{n}
+
{label}
+
+ ))} +
+ {partial &&
部分覆盖:存在候选、待复核或缺项链,正式覆盖只计已确认的受控边。
} + {coverage.gaps.length > 0 && ( + 缺项链({coverage.gaps.length})}> + {coverage.gaps.map((g, i) => ( +
+ 规则 {g.rule}{g.node} · {g.reason} +
+ ))} +
+ )} + {pending.length > 0 && ( + 待复核受控边({pending.length})}> + {pending.map((e, i) => ( +
+ {PENDING_STATE_LABELS[e.state] ?? humanizeState(e.state)} + {e.kind} + {e.from}(r{e.fromOldRevision} → r{e.fromNewRevision || '?'})⇢ {e.to}(r{e.toOldRevision} → r{e.toNewRevision || '?'}) +
+ ))} +
+ )} +
+ ) +} diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index e36f95c..11f75d2 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -34,6 +34,12 @@ const scratchInboxCallbacks = { } const inboxCallbacks = { ...scratchInboxCallbacks, onArchive: (c: ActivityCard) => store.cardToProject(c) } +/** 本会话仍待用户处理的卡片数:右栏收起后由对话承接确认路径。 */ +export function pendingConfirmations(cards: ActivityCard[], conversationId: string | null | undefined, projectId: string | null | undefined): number { + return cards.filter(card => card.status !== 'dismissed' && card.conversationId === conversationId && card.projectId === projectId + && (card.status === 'action_required' || card.work?.decision?.state === 'open')).length +} + /** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */ function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxFilter }) { const own = cards.filter(c => !c.conversationId) @@ -72,6 +78,8 @@ export function Workbench() { const visible = inboxScope === 'project' ? projectCards : cards const pending = pendingCount(visible) const unread = visible.filter(card => card.work && card.work.resultVersion > card.work.readVersion).length + const pendingHere = useStore(s => pendingConfirmations(s.cards, s.currentConvId, s.currentProjectId)) + const collapseHint = pendingHere > 0 ? `收起右栏(${pendingHere} 项待确认会在对话中继续处理)` : '收起右栏' // 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。 const switchTab = (tab: 'work' | 'files') => store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false }) @@ -86,7 +94,7 @@ export function Workbench() { {rightTab === 'work' && ( )} - +
{rightTab === 'files' ? diff --git a/src/styles.css b/src/styles.css index 54f065a..0f02bc3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -669,6 +669,9 @@ button { font-family: inherit; font-size: inherit; color: inherit; } .rdm-audit-export-result { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); } .rdm-audit-export-err { font-size: var(--fs-ui-sm); color: var(--err-text); } .rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); } +.rdm-audit-wrap { overflow-wrap: anywhere; min-width: 0; } +.rdm-audit-op .rdm-audit-opgroup-head, .rdm-audit-op-pending { flex-wrap: wrap; } +.rdm-audit-op-object { color: var(--text); overflow-wrap: anywhere; min-width: 0; } .rdm-audit-err { display: flex; flex-direction: column; gap: 8px; } .rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); } .rdm-audit-err-raw { display: block; font-family: var(--mono); font-size: var(--fs-code); color: var(--text-dim); background: var(--bg-inset); border-radius: var(--radius-sm); padding: 8px 12px; white-space: pre-wrap; word-break: break-all; }