diff --git a/web/src/features/review/review-compliance-diff-panel.test.tsx b/web/src/features/review/review-compliance-diff-panel.test.tsx new file mode 100644 index 00000000..139104ee --- /dev/null +++ b/web/src/features/review/review-compliance-diff-panel.test.tsx @@ -0,0 +1,104 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import type { SkillVersion } from '@/api/types' +import { ReviewComplianceDiffPanel } from './review-compliance-diff-panel' + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next') + return { + ...actual, + useTranslation: () => ({ + t: (key: string, values?: Record) => { + if (key === 'review.complianceDiffAddedLabel' || key === 'review.complianceDiffRemovedLabel' || key === 'review.complianceDiffModifiedLabel') { + return `${key}:${values?.count}` + } + if (key === 'review.complianceDiffDescription') { + return `${key}:${values?.baseVersion}->${values?.pendingVersion}` + } + return key + }, + i18n: { language: 'zh' }, + }), + } +}) + +function createVersion(overrides: Partial = {}): SkillVersion { + return { + id: 1, + version: '1.0.0', + status: 'PUBLISHED', + changelog: '', + fileCount: 1, + totalSize: 100, + publishedAt: '2026-03-19T00:00:00Z', + downloadAvailable: true, + ...overrides, + } +} + +describe('ReviewComplianceDiffPanel', () => { + it('renders a clickable diff summary and item details', () => { + const html = renderToStaticMarkup( + , + ) + + expect(html).toContain('review.complianceDiffTitle') + expect(html).toContain('review.complianceDiffRemovedLabel:1') + expect(html).toContain('review.complianceDiffBaseDigest') + expect(html).toContain('review.complianceDiffPendingDigest') + expect(html).toContain('soc2') + expect(html).toContain('CC7.2') + expect(html).toContain('review.complianceDiffViewDetails') + }) + + it('renders nothing when there is no diff', () => { + const html = renderToStaticMarkup( + , + ) + + expect(html).toBe('') + }) +}) diff --git a/web/src/features/review/review-compliance-diff-panel.tsx b/web/src/features/review/review-compliance-diff-panel.tsx new file mode 100644 index 00000000..7ee5be8a --- /dev/null +++ b/web/src/features/review/review-compliance-diff-panel.tsx @@ -0,0 +1,321 @@ +import { ChevronDown, ShieldAlert, ShieldCheck } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import type { ComplianceMapping, ComplianceSnapshot, SkillVersion } from '@/api/types' +import { cn } from '@/shared/lib/utils' + +interface ReviewComplianceDiffPanelProps { + baseVersion?: SkillVersion | null + pendingVersion?: SkillVersion | null + className?: string +} + +type DiffKind = 'added' | 'removed' | 'modified' + +interface DiffEntry { + kind: DiffKind + key: string + base?: ComplianceMapping + pending?: ComplianceMapping +} + +function shortDigest(digest?: string) { + if (!digest) { + return '—' + } + if (digest.length <= 20) { + return digest + } + return `${digest.slice(0, 17)}…` +} + +function mappingKey(mapping: ComplianceMapping) { + return [ + mapping.standard?.trim().toLowerCase() ?? '', + mapping.version?.trim() ?? '', + mapping.controlId?.trim() ?? '', + ].join('\u0000') +} + +function mappingSignature(mapping: ComplianceMapping) { + return JSON.stringify({ + standard: mapping.standard?.trim().toLowerCase() ?? '', + version: mapping.version?.trim() ?? '', + controlId: mapping.controlId?.trim() ?? '', + title: mapping.title?.trim() ?? '', + evidence: (mapping.evidence ?? []).map((item) => ({ + type: item.type?.trim() ?? '', + path: item.path?.trim() ?? '', + url: item.url?.trim() ?? '', + sha256: item.sha256?.trim() ?? '', + })), + }) +} + +function compareComplianceSnapshots(baseSnapshot?: ComplianceSnapshot | null, pendingSnapshot?: ComplianceSnapshot | null) { + const baseItems = new Map((baseSnapshot?.items ?? []).map((item) => [mappingKey(item), item])) + const pendingItems = new Map((pendingSnapshot?.items ?? []).map((item) => [mappingKey(item), item])) + const keys = new Set([...baseItems.keys(), ...pendingItems.keys()]) + const diffs: DiffEntry[] = [] + + for (const key of keys) { + const base = baseItems.get(key) + const pending = pendingItems.get(key) + if (base && pending) { + if (mappingSignature(base) !== mappingSignature(pending)) { + diffs.push({ kind: 'modified', key, base, pending }) + } + continue + } + if (base) { + diffs.push({ kind: 'removed', key, base }) + continue + } + if (pending) { + diffs.push({ kind: 'added', key, pending }) + } + } + + const sorted = diffs.sort((left, right) => { + const rank: Record = { + removed: 0, + modified: 1, + added: 2, + } + return rank[left.kind] - rank[right.kind] + || (left.base?.standard ?? left.pending?.standard ?? '').localeCompare(right.base?.standard ?? right.pending?.standard ?? '', 'zh-Hans-CN') + || (left.base?.controlId ?? left.pending?.controlId ?? '').localeCompare(right.base?.controlId ?? right.pending?.controlId ?? '', 'zh-Hans-CN') + }) + + return { + diffs: sorted, + added: sorted.filter((item) => item.kind === 'added').length, + removed: sorted.filter((item) => item.kind === 'removed').length, + modified: sorted.filter((item) => item.kind === 'modified').length, + } +} + +function formatMappingLabel(mapping?: ComplianceMapping) { + if (!mapping) { + return '—' + } + const parts = [mapping.standard, mapping.controlId].filter(Boolean) + return parts.length > 0 ? parts.join(' · ') : '—' +} + +function renderEvidenceLabel(path?: string, url?: string, type?: string) { + return path ?? url ?? type ?? '—' +} + +function MappingDetails({ + mapping, + emptyMessage, +}: { + mapping?: ComplianceMapping + emptyMessage: string +}) { + const { t } = useTranslation() + + if (!mapping) { + return ( +
+ {emptyMessage} +
+ ) + } + + return ( +
+
+ + {mapping.standard ?? t('compliance.unknownStandard')} + + {mapping.version ? ( + {mapping.version} + ) : null} + {mapping.controlId ?? '—'} +
+ + {mapping.title ? ( +

{mapping.title}

+ ) : null} + +
+ {(mapping.evidence ?? []).length > 0 ? ( + (mapping.evidence ?? []).map((evidence, index) => ( +
+ + {renderEvidenceLabel(evidence.path, evidence.url, evidence.type)} + + {evidence.sha256 ? ( + {evidence.sha256} + ) : null} +
+ )) + ) : ( +
{t('compliance.evidence')}
+ )} +
+
+ ) +} + +function DiffItem({ entry }: { entry: DiffEntry }) { + const { t } = useTranslation() + const labelKey = entry.kind === 'added' + ? 'review.complianceDiffAdded' + : entry.kind === 'removed' + ? 'review.complianceDiffRemoved' + : 'review.complianceDiffModified' + const label = t(labelKey) + const title = entry.pending?.title ?? entry.base?.title + + return ( +
+ + + {entry.kind === 'removed' ? : } + + +
+
+ + {label} + + {formatMappingLabel(entry.base ?? entry.pending)} +
+ + {title ?

{title}

: null} +
+ +
+ {t('review.complianceDiffViewDetails')} + +
+
+ +
+
+
+ {t('review.complianceDiffBaseVersion')} +
+ +
+
+
+ {t('review.complianceDiffPendingVersion')} +
+ +
+
+
+ ) +} + +function pickBaseVersion(versions: SkillVersion[], activeVersion: string) { + const publishedVersions = versions.filter((version) => version.status === 'PUBLISHED' && version.version !== activeVersion) + if (publishedVersions.length > 0) { + return publishedVersions.sort((left, right) => { + const leftTime = new Date(left.publishedAt).getTime() + const rightTime = new Date(right.publishedAt).getTime() + if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) { + return rightTime - leftTime + } + return right.id - left.id + })[0] + } + return versions.find((version) => version.version !== activeVersion) ?? null +} + +export function ReviewComplianceDiffPanel({ baseVersion, pendingVersion, className }: ReviewComplianceDiffPanelProps) { + const { t } = useTranslation() + if (!baseVersion || !pendingVersion) { + return null + } + + const diff = compareComplianceSnapshots(baseVersion.complianceSnapshot, pendingVersion.complianceSnapshot) + if (diff.diffs.length === 0) { + return null + } + + return ( +
+
+
+
+ + {t('review.complianceDiffTitle')} +
+

+ {t('review.complianceDiffDescription', { + baseVersion: baseVersion.version, + pendingVersion: pendingVersion.version, + })} +

+
+ +
+ + {t('review.complianceDiffAddedLabel', { count: diff.added })} + + + {t('review.complianceDiffRemovedLabel', { count: diff.removed })} + + + {t('review.complianceDiffModifiedLabel', { count: diff.modified })} + +
+
+ +
+
+
+ {t('review.complianceDiffBaseDigest')} +
+
+ {shortDigest(baseVersion.complianceSnapshot?.digest)} +
+
+
+
+ {t('review.complianceDiffPendingDigest')} +
+
+ {shortDigest(pendingVersion.complianceSnapshot?.digest)} +
+
+
+ +
+ {diff.diffs.map((entry) => ( + + ))} +
+
+ ) +} + +export { compareComplianceSnapshots, pickBaseVersion } diff --git a/web/src/features/review/review-skill-detail-section.tsx b/web/src/features/review/review-skill-detail-section.tsx index e638c580..bd72630b 100644 --- a/web/src/features/review/review-skill-detail-section.tsx +++ b/web/src/features/review/review-skill-detail-section.tsx @@ -8,6 +8,7 @@ import { FilePreviewDialog } from '@/features/skill/file-preview-dialog' import type { FileTreeNode } from '@/features/skill/file-tree-builder' import { MarkdownRenderer } from '@/features/skill/markdown-renderer' import { ComplianceSnapshotPanel } from '@/features/skill/compliance-snapshot-panel' +import { ReviewComplianceDiffPanel, pickBaseVersion } from './review-compliance-diff-panel' import { Button, buttonVariants } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' @@ -77,6 +78,8 @@ export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId } const documentation = getReviewSkillDocumentation(detail) + const pendingVersion = detail.versions.find((version) => version.version === detail.activeVersion) ?? null + const baseVersion = pickBaseVersion(detail.versions, detail.activeVersion) return ( @@ -115,6 +118,12 @@ export function ReviewSkillDetailSection({ detail, isLoading, hasError, reviewId + + {t('skillDetail.tabOverview')} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 65a1e265..2e8e53da 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1442,6 +1442,21 @@ "skillDetailError": "Failed to load the skill detail for this review.", "activeReviewVersion": "Review Version", "downloadSkillZip": "Download Skill ZIP", + "complianceDiffTitle": "Compliance declaration diff", + "complianceDiffDescription": "Compare compliance changes between published version {{baseVersion}} and pending version {{pendingVersion}}.", + "complianceDiffBaseVersion": "Base version", + "complianceDiffPendingVersion": "Pending version", + "complianceDiffBaseDigest": "Base digest", + "complianceDiffPendingDigest": "Pending digest", + "complianceDiffAdded": "Added", + "complianceDiffRemoved": "Removed", + "complianceDiffModified": "Modified", + "complianceDiffAddedLabel": "Added {{count}}", + "complianceDiffRemovedLabel": "Removed {{count}}", + "complianceDiffModifiedLabel": "Modified {{count}}", + "complianceDiffViewDetails": "View details", + "complianceDiffBaseRemoved": "This declaration exists in the base version and was removed from the pending version.", + "complianceDiffPendingAdded": "This declaration was added in the pending version.", "noDocumentation": "This review version does not include a readable documentation file." }, "token": { diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index cf1b92b8..88441922 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1443,6 +1443,21 @@ "skillDetailError": "技能详情加载失败,请稍后重试。", "activeReviewVersion": "当前审核版本", "downloadSkillZip": "下载技能 ZIP", + "complianceDiffTitle": "合规声明差异", + "complianceDiffDescription": "对比已发布版本 {{baseVersion}} 与待审核版本 {{pendingVersion}} 的合规声明变化。", + "complianceDiffBaseVersion": "基线版本", + "complianceDiffPendingVersion": "待审版本", + "complianceDiffBaseDigest": "基线摘要", + "complianceDiffPendingDigest": "待审摘要", + "complianceDiffAdded": "新增", + "complianceDiffRemoved": "删除", + "complianceDiffModified": "修改", + "complianceDiffAddedLabel": "新增 {{count}} 项", + "complianceDiffRemovedLabel": "删除 {{count}} 项", + "complianceDiffModifiedLabel": "修改 {{count}} 项", + "complianceDiffViewDetails": "查看详情", + "complianceDiffBaseRemoved": "基线版本包含该声明,待审版本已移除。", + "complianceDiffPendingAdded": "待审版本新增了这条声明。", "noDocumentation": "当前待审核版本未提供可读的说明文档。" }, "token": {