docs: add skill version compare redesign spec

This commit is contained in:
dongmucat 2026-05-06 16:22:31 +08:00
parent 43a201c8d5
commit 7caa320c09

View file

@ -0,0 +1,406 @@
# Skill Version Compare Redesign
**Date:** 2026-05-06
**Issue:** [#267](https://github.com/iflytek/skillhub/issues/267)
**Status:** Approved
## Problem Statement
当前技能版本对比功能PR #345)存在以下问题:
1. **交互不便**:逐文件点击展开/折叠,无法一次性浏览所有变更
2. **功能缺失**:缺少文件树导航、全局统计、文件搜索等关键功能
3. **视觉局限**:对话框容器限制了 diff 呈现空间
用户需要类似 GitHub PR Files Changed 的体验:独立页面、左侧文件树、右侧 diff 主区、一次性加载所有变更。
## Goals
1. 提供独立的版本对比页面,支持 URL 分享和收藏
2. 类 GitHub PR Files Changed 的双栏布局(左侧文件树 + 右侧 diff 主区)
3. 后端计算 diff 并返回结构化数据,前端专注渲染
4. 支持文件树导航、全局统计、文件搜索、超大文件按需加载
5. 仅支持 unified 视图(简化 MVP 范围)
## Non-Goals
- Split 视图(左右双栏对照)
- Markdown 渲染对比README 也按文本 diff 处理)
- 行内评论inline comments
- 三方合并冲突解决
- 对比草稿版本或审核中版本
---
## Design Overview
### Architecture Choice: 方案 A
**后端**
- 新增 API `GET /api/v1/skills/{ns}/{slug}/versions/compare?from=vX&to=vY`
- 使用 `java-diff-utils` 库(轻量级 ~100KB无传递依赖计算行级 diff
- 返回结构化 JSON文件列表 + 每个文件的 hunks变更块+ 行号映射 + 统计数据
- 超大文件(>1MB 或 >5000 行)标记为 `truncated: true`,前端按需加载
**前端**
- 新路由 `/skills/:namespace/:slug/compare`TanStack Router
- 使用 `react-diff-view`Uber 开源,~50KB专为 React 设计)
- 左侧文件树Radix Collapsible + 虚拟滚动(文件数 >100 时)
- 右侧 diff 主区:`react-diff-view` 渲染 unified 视图Prism.js syntax highlight
- 顶部版本选择器Radix Select 双下拉框base → head
**依赖库选型依据**(详见后端调研报告):
- `java-diff-utils` 优于 `diff-match-patch`(字符级需转换)和 JGit5.5MB 过重)
- `react-diff-view` 成熟稳定API 清晰bundle 小,社区活跃
---
## User Flow
1. 用户在技能详情页版本列表中点击某版本的"对比版本"按钮
2. 跳转到 `/skills/{ns}/{slug}/compare?from={当前版本}&to={最新已发布版本}`
3. 页面加载,显示双栏布局:
- 顶部:版本选择器(两个下拉框)+ 全局统计X 文件,+Y/-Z 行)
- 左侧:文件树(可搜索、可折叠、点击跳转)
- 右侧:所有文件的 diff 纵向铺开,长页面滚动
4. 用户可切换版本下拉框URL 更新,数据重新加载
5. 用户可在文件树搜索框输入关键词过滤文件
6. 用户点击文件树中的文件,右侧滚动到对应 diff 位置
7. 超大文件默认折叠,显示"Load diff"按钮,点击后加载
---
## Backend API Design
### Primary API
**Endpoint**: `GET /api/v1/skills/{namespace}/{slug}/versions/compare`
**Query Parameters**:
- `from`: 源版本号(必填)
- `to`: 目标版本号(必填)
**Response Structure**:
```json
{
"from": "1.0.0",
"to": "1.2.0",
"summary": {
"totalFiles": 12,
"addedFiles": 3,
"modifiedFiles": 7,
"removedFiles": 2,
"addedLines": 245,
"removedLines": 89
},
"files": [
{
"path": "src/main.py",
"changeType": "MODIFIED",
"oldSize": 1024,
"newSize": 1280,
"isBinary": false,
"isTruncated": false,
"hunks": [
{
"oldStart": 10,
"oldLines": 5,
"newStart": 10,
"newLines": 8,
"lines": [
{
"type": "CONTEXT",
"content": "def foo():",
"oldLineNumber": 10,
"newLineNumber": 10
},
{
"type": "DELETE",
"content": " return 1",
"oldLineNumber": 11,
"newLineNumber": null
},
{
"type": "ADD",
"content": " return 2",
"oldLineNumber": null,
"newLineNumber": 11
}
]
}
]
}
]
}
```
**Field Descriptions**:
- `changeType`: `ADDED` | `MODIFIED` | `REMOVED`
- `isBinary`: 按扩展名判断(`.png`, `.jpg`, `.zip`, `.jar`, `.exe` 等)
- `isTruncated`: 单文件 >1MB 或 >5000 行时为 `true`,此时 `hunks` 为空数组
- `hunks[].lines[].type`: `CONTEXT` | `ADD` | `DELETE`
**Implementation**:
- Controller: `VersionCompareController` in `skillhub-app`
- Service: 新增 `VersionCompareService`(或复用现有 Service 层)
- Diff 库: `io.github.java-diff-utils:java-diff-utils:4.12`Maven Central
- 流程:
1. 校验两个版本都是 `PUBLISHED` 状态
2. 从对象存储并行拉取两个版本的文件列表和内容
3. 对比文件列表,分类为 Added/Modified/Removed
4. 对每个文本文件调用 `DiffUtils.diff()` 计算 hunks
5. 二进制文件标记 `isBinary: true`,不计算 diff
6. 超大文件标记 `isTruncated: true`,返回空 hunks
7. 返回结构化 JSON启用 gzip 压缩
### Secondary API (Optional, for truncated files)
**Endpoint**: `GET /api/v1/skills/{namespace}/{slug}/versions/compare/file`
**Query Parameters**:
- `from`: 源版本号
- `to`: 目标版本号
- `path`: 文件路径
**Purpose**: 强制返回超大文件的完整 diff无大小限制用于用户点击"Load diff"按钮时按需加载。
**Response**: 与主 API 中单个文件的结构相同,但 `isTruncated` 始终为 `false`
---
## Frontend Design
### Route Definition
**Path**: `/skills/:namespace/:slug/compare`
**Query Parameters**:
- `from`: 源版本号(必填)
- `to`: 目标版本号(必填)
**Example**: `/skills/clawhub/git-helper/compare?from=1.0.0&to=1.2.0`
**TanStack Router Config**:
```typescript
export const Route = createFileRoute('/skills/$namespace/$slug/compare')({
validateSearch: (search) => ({
from: search.from as string,
to: search.to as string,
}),
loaderDeps: ({ search }) => ({ from: search.from, to: search.to }),
loader: async ({ params, deps }) => {
await queryClient.ensureQueryData(
skillVersionCompareQueryOptions(
params.namespace,
params.slug,
deps.from,
deps.to
)
)
},
})
```
### Component Structure
```
SkillVersionComparePage (web/src/pages/skill-version-compare.tsx)
├── CompareHeader (顶部固定栏)
│ ├── VersionSelector (两个 Radix Select 下拉框)
│ └── CompareSummary (全局统计X 文件,+Y/-Z 行)
├── CompareLayout (双栏布局容器)
│ ├── FileTree (左侧文件树280px 宽,可折叠)
│ │ ├── FileTreeSearch (搜索框)
│ │ └── FileTreeList (文件列表)
│ │ └── FileTreeItem (单个文件项,点击跳转)
│ └── DiffMainArea (右侧 diff 主区)
│ └── FileDiffSection (每个文件一个 section)
│ ├── FileDiffHeader (文件名 + 统计 + 展开/折叠按钮)
│ └── FileDiffContent (diff 内容)
│ ├── BinaryFilePlaceholder (二进制文件占位)
│ ├── TruncatedFilePlaceholder (超大文件占位 + Load 按钮)
│ └── UnifiedDiffView (react-diff-view 渲染)
```
### Key Interactions
1. **版本切换**:顶部下拉框切换 → 更新 URL query → TanStack Query 自动重新拉取数据
2. **文件树跳转**:点击文件 → `scrollIntoView` 滚动到右侧对应 section → 高亮当前文件
3. **文件树搜索**:输入关键词 → 过滤文件列表(前端内存过滤)
4. **超大文件加载**:点击"Load diff"按钮 → 调用后端 `/compare/file` API 单独拉取该文件 diff
### Dependencies
- `react-diff-view`: diff 渲染(~50KB
- `prismjs`: syntax highlight按需加载语言包
- `@tanstack/react-virtual`: 文件数 >100 时,文件树使用虚拟滚动
---
## Entry Point Modification
**Location**: `web/src/pages/skill-detail.tsx`
**Change**: 在版本列表的每个版本卡片操作区增加"对比版本"按钮。
**Logic**:
```typescript
const handleCompareVersion = (version: string) => {
const publishedVersions = versions.filter(v => v.status === 'PUBLISHED')
if (publishedVersions.length < 2) {
toast.error(t('skillDetail.compareUnavailable'))
return
}
const latestVersion = publishedVersions[0].version
navigate({
to: '/skills/$namespace/$slug/compare',
params: { namespace, slug },
search: { from: version, to: latestVersion }
})
}
```
**Button Placement**: 在版本卡片的"下载"按钮旁边,仅对已发布版本显示。
---
## Edge Cases & Error Handling
| Case | Handling |
|------|----------|
| 版本不存在 | 后端返回 404前端 toast 提示"版本不存在" |
| 非已发布版本 | 后端返回 400前端 toast 提示"仅支持对比已发布版本" |
| 相同版本对比 | 后端返回 400前端 toast 提示"无法对比相同版本" |
| 无权限访问 | 复用技能详情页权限逻辑,无权限 → 重定向到 404 |
| 二进制文件 | 后端按扩展名判断,前端显示"Binary file — cannot display diff" + 下载链接 |
| 超大文件 (>1MB 或 >5000 行) | 后端标记 `isTruncated: true`,前端显示"Load diff"按钮,点击后调用 `/compare/file` API |
| 完全相同的版本 | 前端显示空状态:"No changes between these versions" |
| API 请求失败 | 显示错误提示 + 重试按钮 |
| 文件数 >100 | 文件树使用 `@tanstack/react-virtual` 虚拟滚动 |
**Binary File Extensions**:
`.png`, `.jpg`, `.jpeg`, `.gif`, `.ico`, `.woff`, `.woff2`, `.ttf`, `.eot`, `.zip`, `.tar`, `.gz`, `.jar`, `.war`, `.class`, `.so`, `.dll`, `.exe`, `.pdf`
---
## i18n Keys
新增 key 到 `web/src/i18n/locales/zh.json``en.json`
```json
{
"skillCompare": {
"pageTitle": "版本对比",
"selectBaseVersion": "选择基准版本",
"selectTargetVersion": "选择目标版本",
"filesChanged": "{count} 个文件变更",
"linesAdded": "+{count} 行",
"linesRemoved": "-{count} 行",
"searchFiles": "搜索文件",
"noFilesFound": "未找到匹配文件",
"fileAdded": "新增",
"fileModified": "修改",
"fileRemoved": "删除",
"binaryFile": "二进制文件,无法显示差异",
"fileTooLarge": "文件过大 ({size} / {lines} 行),点击加载",
"loadDiff": "加载差异",
"noChanges": "这两个版本之间无变更",
"errorLoadingCompare": "加载对比数据失败",
"retry": "重试",
"onlyPublishedVersions": "仅支持对比已发布版本",
"sameVersionError": "无法对比相同版本",
"compareUnavailable": "至少需要两个已发布版本才能对比"
}
}
```
---
## Testing Strategy
### Backend Unit Tests (JUnit 5 + Spring Boot Test)
**`VersionCompareControllerTest`**:
- 正常对比:返回正确的文件列表和 hunks
- 版本不存在:返回 404
- 非已发布版本:返回 400
- 相同版本:返回 400
- 权限校验:私有技能未授权访问返回 403
**`VersionCompareServiceTest`**(如果抽取 Service 层):
- 文本文件 diff 计算正确性
- 二进制文件识别
- 超大文件截断逻辑
- 空文件、单行文件等边界情况
### Frontend Unit Tests (Vitest + React Testing Library)
- `VersionSelector.test.tsx`:下拉框切换更新 URL
- `FileTree.test.tsx`:搜索过滤、点击跳转
- `FileDiffSection.test.tsx`二进制占位、超大文件占位、diff 渲染
### E2E Tests (Playwright)
**File**: `web/e2e/skill-version-compare.spec.ts`
**Scenario**:
1. 前置条件上传同一技能的两个版本v1.0.0 和 v1.1.0v1.1.0 修改了 2 个文件
2. 进入技能详情页
3. 点击 v1.0.0 的"对比版本"按钮
4. 验证跳转到 `/compare?from=1.0.0&to=1.1.0`
5. 验证顶部显示"2 个文件变更"
6. 验证左侧文件树显示 2 个文件
7. 点击第一个文件,验证右侧滚动到对应位置
8. 验证 diff 内容正确渲染(检查 DOM 中是否有 `+``-` 行)
9. 切换目标版本下拉框,验证 URL 更新并重新加载数据
---
## Implementation Checklist
### Backend
- [ ] 引入 `java-diff-utils:4.12` 依赖到 `skillhub-app/pom.xml`
- [ ] 新增 `VersionCompareController``/compare` API
- [ ] 实现 diff 计算逻辑Service 层)
- [ ] 实现二进制文件判断
- [ ] 实现超大文件截断逻辑
- [ ] 编写单元测试
- [ ] 更新 OpenAPI 文档
### Frontend
- [ ] 安装依赖:`react-diff-view`, `prismjs`, `@tanstack/react-virtual`
- [ ] 新增路由 `/skills/$namespace.$slug.compare.tsx`
- [ ] 实现 `SkillVersionComparePage` 组件
- [ ] 实现 `CompareHeader``VersionSelector`
- [ ] 实现 `FileTree` 和搜索功能
- [ ] 实现 `DiffMainArea``FileDiffSection`
- [ ] 集成 `react-diff-view` 渲染 unified diff
- [ ] 实现超大文件按需加载
- [ ] 添加 i18n keys
- [ ] 编写单元测试
- [ ] 编写 E2E 测试
### Entry Point
- [ ] 修改 `skill-detail.tsx`,增加"对比版本"按钮
- [ ] 实现跳转逻辑
---
## Performance Considerations
1. **后端响应体压缩**:启用 gzipSpring Boot 默认支持)
2. **文件内容并行拉取**:使用 `CompletableFuture` 并行从对象存储拉取文件
3. **前端虚拟滚动**:文件数 >100 时,文件树使用 `@tanstack/react-virtual`
4. **Syntax highlight 按需加载**Prism.js 语言包按需加载,避免 bundle 膨胀
5. **超大文件按需加载**>1MB 或 >5000 行的文件默认折叠,点击后才加载
---
## Future Enhancements (Out of Scope for MVP)
- Split 视图(左右双栏对照)
- Markdown 渲染对比README 特殊处理)
- 行内评论功能
- 对比草稿版本或审核中版本
- 后端分页返回(文件数 >500 时)
- 文件树按目录层级折叠
- Diff 统计图表(文件变更热力图)