feat: 数据集版本管理支持删除历史版本
DatasetVersionList 新增 next_version_number,API 与 Mock 补充删除版本接口与并发控制,DatasetVersionBar 增加删除操作并对初始版本与最后版本禁用,DatasetPreviewView 接入删除流程,回归脚本与设计走查同步。
This commit is contained in:
52
design-qa.md
52
design-qa.md
@@ -325,3 +325,55 @@ Verification evidence:
|
|||||||
Design-QA final result: blocked
|
Design-QA final result: blocked
|
||||||
|
|
||||||
final result: blocked
|
final result: blocked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dataset Version Actions Design QA
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
- Source visual truth: `/Users/caoxiaozhu/.codex/generated_images/019f5e2e-3bee-77f0-b285-89ef139db56c/exec-48a163b6-d4c9-4646-9199-135957c6e72e.png`
|
||||||
|
- Historical-version implementation: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/implementation-historical-version-final.png`
|
||||||
|
- Delete-confirm implementation: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/implementation-delete-confirm.png`
|
||||||
|
- Full-view comparison: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/comparison-full.png`
|
||||||
|
- Focused version-control comparison: `/Users/caoxiaozhu/.codex/visualizations/2026/07/14/019f5e2e-3bee-77f0-b285-89ef139db56c/dataset-version-build/comparison-version-controls-final.png`
|
||||||
|
- Viewport: 1316 × 768 browser window; source crop normalized for the focused comparison.
|
||||||
|
- State: authenticated dataset detail, V3 current, V2 selected as a read-only historical version.
|
||||||
|
|
||||||
|
## Full-view comparison
|
||||||
|
|
||||||
|
The existing product shell, dataset summary, version selector, read-only alert, file toolbar, and sample table remain unchanged. The former standalone primary action has been replaced by one compact rounded-square overflow button at the far right of the version-control row, matching the selected hierarchy.
|
||||||
|
|
||||||
|
## Focused comparison and required fidelity surfaces
|
||||||
|
|
||||||
|
- Fonts and typography: existing system/PingFang stack, 13px labels, 12px metadata, and Element Plus menu text are preserved.
|
||||||
|
- Spacing and layout rhythm: the 40px overflow trigger aligns with the version selector and leaves the central status copy flexible; the 168px menu provides 40px action rows.
|
||||||
|
- Colors and visual tokens: the existing indigo primary token is used for the activate icon; the delete item and confirmation action use the danger token.
|
||||||
|
- Image and icon fidelity: no new raster assets are needed. Existing Font Awesome ellipsis, check-circle, and trash icons match the repository's icon system.
|
||||||
|
- Copy and content: the menu contains exactly “设为当前版本” and “删除版本”, separated visually; the confirmation names V2 and explains that current V3 is unaffected.
|
||||||
|
|
||||||
|
## Interaction checks
|
||||||
|
|
||||||
|
- Created V2 and V3 through the real edit-and-save flow, then switched from current V3 to historical V2.
|
||||||
|
- Historical records became read-only and the overflow trigger appeared; current V3 showed no history-operation trigger.
|
||||||
|
- Opening the trigger exposed exactly two accessible menu items: “设为当前版本” and “删除版本”.
|
||||||
|
- Choosing “删除版本” opened the danger confirmation dialog; cancelling returned focus without deleting data.
|
||||||
|
- Actual deletion behavior, protected-version rejection, optimistic-lock handling, and non-reused version numbers are covered by `test:dataset-preview`.
|
||||||
|
|
||||||
|
## Comparison history
|
||||||
|
|
||||||
|
### Iteration 1 — blocked
|
||||||
|
|
||||||
|
- [P2] The overflow trigger was circular while the selected mock used a small rounded square.
|
||||||
|
- [P2] The menu did not explicitly lock its target width or primary-action icon color.
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
|
||||||
|
- Replaced the circular trigger with a 40px square and 10px radius.
|
||||||
|
- Set the menu minimum width to 168px, action height to 40px, and the activate icon to the product primary color.
|
||||||
|
|
||||||
|
### Iteration 2 — passed
|
||||||
|
|
||||||
|
No actionable P0/P1/P2 differences remain. The desktop capture API does not retain the transient popup layer in screenshots, so the open-menu labels were additionally verified through the accessibility tree; exact popup shadow rendering remains a non-blocking P3 capture gap.
|
||||||
|
|
||||||
|
final result: passed
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const mockPath = path.join(root, 'src/mock/data.ts')
|
|||||||
const recordsPath = path.join(root, 'src/views/dataset/datasetRecords.ts')
|
const recordsPath = path.join(root, 'src/views/dataset/datasetRecords.ts')
|
||||||
const versionsPath = path.join(root, 'src/mock/datasetVersions.ts')
|
const versionsPath = path.join(root, 'src/mock/datasetVersions.ts')
|
||||||
const source = fs.readFileSync(previewPath, 'utf8')
|
const source = fs.readFileSync(previewPath, 'utf8')
|
||||||
|
const previewLogicSource = source.slice(0, source.indexOf('<style'))
|
||||||
const previewComponentSources = previewComponentPaths.map((componentPath) => {
|
const previewComponentSources = previewComponentPaths.map((componentPath) => {
|
||||||
expect(fs.existsSync(componentPath), `缺少数据集预览叶子组件:${path.basename(componentPath)}`)
|
expect(fs.existsSync(componentPath), `缺少数据集预览叶子组件:${path.basename(componentPath)}`)
|
||||||
return fs.readFileSync(componentPath, 'utf8')
|
return fs.readFileSync(componentPath, 'utf8')
|
||||||
@@ -40,6 +41,7 @@ const {
|
|||||||
activateDatasetVersion,
|
activateDatasetVersion,
|
||||||
appendDatasetVersion,
|
appendDatasetVersion,
|
||||||
createInitialVersionState,
|
createInitialVersionState,
|
||||||
|
deleteDatasetVersion,
|
||||||
getActiveDatasetVersion,
|
getActiveDatasetVersion,
|
||||||
} = await import(`data:text/javascript;base64,${Buffer.from(versionsModuleCode).toString('base64')}`)
|
} = await import(`data:text/javascript;base64,${Buffer.from(versionsModuleCode).toString('base64')}`)
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ function expect(condition, message) {
|
|||||||
if (!condition) throw new Error(message)
|
if (!condition) throw new Error(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(source.split('\n').length < 800, 'DatasetPreviewView.vue 应拆分到 800 行以内')
|
expect(previewLogicSource.split('\n').length < 520, 'DatasetPreviewView.vue 的逻辑与模板应保持在 520 行以内')
|
||||||
for (const componentName of ['DatasetVersionBar', 'DatasetRecordTable', 'DatasetRecordEditorDialog', 'DatasetRawPreview']) {
|
for (const componentName of ['DatasetVersionBar', 'DatasetRecordTable', 'DatasetRecordEditorDialog', 'DatasetRawPreview']) {
|
||||||
expect(source.includes(`import ${componentName}`), `主页面未导入叶子组件:${componentName}`)
|
expect(source.includes(`import ${componentName}`), `主页面未导入叶子组件:${componentName}`)
|
||||||
expect(source.includes(`<${componentName}`), `主页面未挂载叶子组件:${componentName}`)
|
expect(source.includes(`<${componentName}`), `主页面未挂载叶子组件:${componentName}`)
|
||||||
@@ -69,7 +71,11 @@ expect(source.includes('保存版本'), '文件工具栏应提供保存版本按
|
|||||||
expect(source.includes('baseVersionContent'), '页面应区分已保存版本与待保存工作副本')
|
expect(source.includes('baseVersionContent'), '页面应区分已保存版本与待保存工作副本')
|
||||||
expect(previewSurfaceSource.includes('class="version-control-bar"'), '详情页应提供独立版本控制区')
|
expect(previewSurfaceSource.includes('class="version-control-bar"'), '详情页应提供独立版本控制区')
|
||||||
expect(previewSurfaceSource.includes('设为当前版本'), '历史版本应支持显式切换为当前版本')
|
expect(previewSurfaceSource.includes('设为当前版本'), '历史版本应支持显式切换为当前版本')
|
||||||
|
expect(previewSurfaceSource.includes('class="version-actions"'), '历史版本操作应收纳到省略号菜单')
|
||||||
|
expect(previewSurfaceSource.includes('删除版本'), '历史版本操作菜单应提供删除入口')
|
||||||
expect(previewSurfaceSource.includes('历史版本(只读)'), '历史版本应明确展示只读状态')
|
expect(previewSurfaceSource.includes('历史版本(只读)'), '历史版本应明确展示只读状态')
|
||||||
|
expect(source.includes('AppConfirmDialog'), '删除历史版本前应使用公共危险确认弹窗')
|
||||||
|
expect(source.includes("tone: 'danger'"), '删除历史版本确认弹窗应使用危险态')
|
||||||
expect(source.includes('loadedVersionId.value'), '下载和展示应绑定正在查看的具体版本')
|
expect(source.includes('loadedVersionId.value'), '下载和展示应绑定正在查看的具体版本')
|
||||||
expect(source.includes('versionRequestId'), '快速切换历史版本时应防止旧响应覆盖新内容')
|
expect(source.includes('versionRequestId'), '快速切换历史版本时应防止旧响应覆盖新内容')
|
||||||
expect(previewSurfaceSource.includes('v-model:current-page="currentPage"'), '样本列表应支持分页浏览')
|
expect(previewSurfaceSource.includes('v-model:current-page="currentPage"'), '样本列表应支持分页浏览')
|
||||||
@@ -97,7 +103,7 @@ const activateVersionStart = source.indexOf('async function activateViewedVersio
|
|||||||
const versionChangeSource = source.slice(versionChangeStart, activateVersionStart)
|
const versionChangeSource = source.slice(versionChangeStart, activateVersionStart)
|
||||||
expect(versionChangeSource.includes('resetRecordEditor()'), '成功切换版本后必须关闭并清空旧编辑器状态')
|
expect(versionChangeSource.includes('resetRecordEditor()'), '成功切换版本后必须关闭并清空旧编辑器状态')
|
||||||
expect(!source.includes('handleDownloadAll'), '页面不应保留整包下载逻辑')
|
expect(!source.includes('handleDownloadAll'), '页面不应保留整包下载逻辑')
|
||||||
expect(!source.includes('handleDelete'), '页面不应保留删除逻辑')
|
expect(!source.includes('deleteDataset('), '详情页不应恢复删除整个数据集的逻辑')
|
||||||
expect(!source.includes('router.back()'), '页面不应保留页头返回逻辑')
|
expect(!source.includes('router.back()'), '页面不应保留页头返回逻辑')
|
||||||
expect(mockSource.includes('files: ['), 'Mock 数据集应包含可验收的文件列表')
|
expect(mockSource.includes('files: ['), 'Mock 数据集应包含可验收的文件列表')
|
||||||
expect(mockSource.includes('mockDatasetPreviews'), 'Mock 数据应包含文件预览内容')
|
expect(mockSource.includes('mockDatasetPreviews'), 'Mock 数据应包含文件预览内容')
|
||||||
@@ -107,12 +113,15 @@ expect(mockSource.includes('.map((dataset)'), '所有 Mock 数据集都应自动
|
|||||||
expect(mockSource.includes(': [{ id: `dataset-${dataset.id}-samples`'), '每个 Mock 数据集应只对应一个数据文件')
|
expect(mockSource.includes(': [{ id: `dataset-${dataset.id}-samples`'), '每个 Mock 数据集应只对应一个数据文件')
|
||||||
expect(apiSource.includes('createDatasetFileVersion'), '数据集 API 应提供创建新版本方法')
|
expect(apiSource.includes('createDatasetFileVersion'), '数据集 API 应提供创建新版本方法')
|
||||||
expect(apiSource.includes('activateDatasetFileVersion'), '数据集 API 应提供切换当前版本方法')
|
expect(apiSource.includes('activateDatasetFileVersion'), '数据集 API 应提供切换当前版本方法')
|
||||||
|
expect(apiSource.includes('deleteDatasetFileVersion'), '数据集 API 应提供删除历史版本方法')
|
||||||
expect(apiSource.includes('getDatasetFileVersionContent'), '数据集 API 应支持读取历史版本内容')
|
expect(apiSource.includes('getDatasetFileVersionContent'), '数据集 API 应支持读取历史版本内容')
|
||||||
expect(apiSource.includes('expected_current_version_id'), '创建和激活版本应携带乐观锁版本指针')
|
expect(apiSource.includes('expected_current_version_id'), '创建和激活版本应携带乐观锁版本指针')
|
||||||
expect(!apiSource.includes('updateDatasetFileContent'), '数据编辑不应继续覆盖历史版本内容')
|
expect(!apiSource.includes('updateDatasetFileContent'), '数据编辑不应继续覆盖历史版本内容')
|
||||||
expect(adapterSource.includes('mock:dataset-versions:'), 'Mock 版本历史应支持刷新持久化')
|
expect(adapterSource.includes('mock:dataset-versions:'), 'Mock 版本历史应支持刷新持久化')
|
||||||
expect(adapterSource.includes('当前版本已被其他用户更新'), 'Mock 应拒绝基于过期版本的并发覆盖')
|
expect(adapterSource.includes('当前版本已被其他用户更新'), 'Mock 应拒绝基于过期版本的并发覆盖')
|
||||||
expect(adapterSource.includes('历史版本不可覆盖'), '旧的覆盖保存接口应明确拒绝修改历史版本')
|
expect(adapterSource.includes('历史版本不可覆盖'), '旧的覆盖保存接口应明确拒绝修改历史版本')
|
||||||
|
expect(versionsSource.includes('当前版本不可删除'), 'Mock 应拒绝删除当前版本')
|
||||||
|
expect(versionsSource.includes('初始版本不可删除'), 'Mock 应拒绝删除初始版本')
|
||||||
expect(adapterSource.includes('Mock 存储空间不足'), 'Mock 存储失败时应返回明确错误且保留编辑草稿')
|
expect(adapterSource.includes('Mock 存储空间不足'), 'Mock 存储失败时应返回明确错误且保留编辑草稿')
|
||||||
|
|
||||||
const jsonl = [
|
const jsonl = [
|
||||||
@@ -145,6 +154,21 @@ expect(v2State.versions[1].version === 2 && v2State.active_version_id === 'v2',
|
|||||||
const switchedToV1 = activateDatasetVersion(v2State, 'v1')
|
const switchedToV1 = activateDatasetVersion(v2State, 'v1')
|
||||||
expect(getActiveDatasetVersion(switchedToV1).content === 'A', '切换当前版本后应读取对应历史内容')
|
expect(getActiveDatasetVersion(switchedToV1).content === 'A', '切换当前版本后应读取对应历史内容')
|
||||||
expect(v2State.active_version_id === 'v2' && v2State.versions[1].content === 'B', '切换版本不得污染原状态或历史快照')
|
expect(v2State.active_version_id === 'v2' && v2State.versions[1].content === 'B', '切换版本不得污染原状态或历史快照')
|
||||||
|
const v3State = appendDatasetVersion(v2State, 'C', '2026-07-13T02:00:00Z', '修改第二条')
|
||||||
|
const deletedV2State = deleteDatasetVersion(v3State, 'v2')
|
||||||
|
expect(!deletedV2State.versions.some((item) => item.id === 'v2'), '删除历史版本后不应继续返回该版本')
|
||||||
|
expect(deletedV2State.active_version_id === 'v3', '删除历史版本不得改变当前版本指针')
|
||||||
|
const v4State = appendDatasetVersion(deletedV2State, 'D', '2026-07-13T03:00:00Z', '继续编辑')
|
||||||
|
expect(v4State.versions.at(-1)?.version === 4, '删除历史版本后不得复用旧版本号')
|
||||||
|
for (const protectedVersionId of ['v1', 'v3']) {
|
||||||
|
let protectedVersionRejected = false
|
||||||
|
try {
|
||||||
|
deleteDatasetVersion(v3State, protectedVersionId)
|
||||||
|
} catch {
|
||||||
|
protectedVersionRejected = true
|
||||||
|
}
|
||||||
|
expect(protectedVersionRejected, `${protectedVersionId} 受保护版本不得删除`)
|
||||||
|
}
|
||||||
let invalidVersionRejected = false
|
let invalidVersionRejected = false
|
||||||
try {
|
try {
|
||||||
activateDatasetVersion(v2State, 'missing')
|
activateDatasetVersion(v2State, 'missing')
|
||||||
|
|||||||
@@ -72,6 +72,17 @@ export const activateDatasetFileVersion = (
|
|||||||
{ version_id: versionId, expected_current_version_id: expectedCurrentVersionId },
|
{ version_id: versionId, expected_current_version_id: expectedCurrentVersionId },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 删除非当前、非初始的历史版本 */
|
||||||
|
export const deleteDatasetFileVersion = (
|
||||||
|
fileId: string | number,
|
||||||
|
versionId: string,
|
||||||
|
expectedCurrentVersionId: string,
|
||||||
|
) =>
|
||||||
|
del<DatasetVersionList>(
|
||||||
|
`/dataset-manage/versions/${encodeURIComponent(fileId)}/${encodeURIComponent(versionId)}`,
|
||||||
|
{ expected_current_version_id: expectedCurrentVersionId },
|
||||||
|
)
|
||||||
|
|
||||||
/** 下载文件 URL */
|
/** 下载文件 URL */
|
||||||
export const downloadFileUrl = (
|
export const downloadFileUrl = (
|
||||||
datasetId: string | number,
|
datasetId: string | number,
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import {
|
|||||||
activateDatasetVersion,
|
activateDatasetVersion,
|
||||||
appendDatasetVersion,
|
appendDatasetVersion,
|
||||||
createInitialVersionState,
|
createInitialVersionState,
|
||||||
|
deleteDatasetVersion,
|
||||||
|
DatasetVersionMutationError,
|
||||||
getActiveDatasetVersion,
|
getActiveDatasetVersion,
|
||||||
|
normalizeDatasetVersionState,
|
||||||
} from './datasetVersions'
|
} from './datasetVersions'
|
||||||
import type { StoredDatasetVersion, StoredDatasetVersionState } from './datasetVersions'
|
import type { StoredDatasetVersion, StoredDatasetVersionState } from './datasetVersions'
|
||||||
|
|
||||||
@@ -83,7 +86,13 @@ function getVersionState(fileId: string): StoredDatasetVersionState {
|
|||||||
if (persisted) {
|
if (persisted) {
|
||||||
try {
|
try {
|
||||||
const state = JSON.parse(persisted) as StoredDatasetVersionState
|
const state = JSON.parse(persisted) as StoredDatasetVersionState
|
||||||
if (state.versions?.length && state.active_version_id) return state
|
if (state.versions?.length && state.active_version_id) {
|
||||||
|
const normalizedState = normalizeDatasetVersionState(state)
|
||||||
|
if (state.next_version_number !== normalizedState.next_version_number) {
|
||||||
|
persistVersionState(fileId, normalizedState)
|
||||||
|
}
|
||||||
|
return normalizedState
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 版本数据损坏时回退到初始版本
|
// 版本数据损坏时回退到初始版本
|
||||||
}
|
}
|
||||||
@@ -100,6 +109,14 @@ function versionMetadata(version: StoredDatasetVersion) {
|
|||||||
return metadata
|
return metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function versionListResponse(state: StoredDatasetVersionState) {
|
||||||
|
return {
|
||||||
|
versions: state.versions.map(versionMetadata).sort((a, b) => b.version - a.version),
|
||||||
|
active_version_id: state.active_version_id,
|
||||||
|
next_version_number: state.next_version_number,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 通过路径 + method 匹配 mock 响应 */
|
/** 通过路径 + method 匹配 mock 响应 */
|
||||||
async function handleMock(config: AxiosRequestConfig) {
|
async function handleMock(config: AxiosRequestConfig) {
|
||||||
await delay(150) // 模拟网络延迟
|
await delay(150) // 模拟网络延迟
|
||||||
@@ -185,10 +202,7 @@ async function handleMock(config: AxiosRequestConfig) {
|
|||||||
if (m && method === 'get') {
|
if (m && method === 'get') {
|
||||||
const fileId = decodeURIComponent(m[1])
|
const fileId = decodeURIComponent(m[1])
|
||||||
const state = getVersionState(fileId)
|
const state = getVersionState(fileId)
|
||||||
return ok({
|
return ok(versionListResponse(state))
|
||||||
versions: state.versions.map(versionMetadata).sort((a, b) => b.version - a.version),
|
|
||||||
active_version_id: state.active_version_id,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if (m && method === 'post') {
|
if (m && method === 'post') {
|
||||||
const fileId = decodeURIComponent(m[1])
|
const fileId = decodeURIComponent(m[1])
|
||||||
@@ -249,6 +263,30 @@ async function handleMock(config: AxiosRequestConfig) {
|
|||||||
? ok({ version: versionMetadata(version), content: version.content })
|
? ok({ version: versionMetadata(version), content: version.content })
|
||||||
: fail('数据集版本不存在', 404)
|
: fail('数据集版本不存在', 404)
|
||||||
}
|
}
|
||||||
|
if (m && method === 'delete') {
|
||||||
|
const fileId = decodeURIComponent(m[1])
|
||||||
|
const versionId = decodeURIComponent(m[2])
|
||||||
|
const currentState = getVersionState(fileId)
|
||||||
|
if (params.expected_current_version_id !== currentState.active_version_id) {
|
||||||
|
return fail('当前版本已被其他用户更新,请刷新后重试', 409)
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextState: StoredDatasetVersionState
|
||||||
|
try {
|
||||||
|
nextState = deleteDatasetVersion(currentState, versionId)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DatasetVersionMutationError) {
|
||||||
|
return fail(error.message, error.status)
|
||||||
|
}
|
||||||
|
return fail('删除版本失败', 500)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
persistVersionState(fileId, nextState)
|
||||||
|
} catch {
|
||||||
|
return fail('浏览器 Mock 存储空间不足', 413)
|
||||||
|
}
|
||||||
|
return ok(versionListResponse(nextState))
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 训练任务 ====================
|
// ==================== 训练任务 ====================
|
||||||
if (url === '/fine-tune' && method === 'get') return ok(mockFineTuneList)
|
if (url === '/fine-tune' && method === 'get') return ok(mockFineTuneList)
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ export interface StoredDatasetVersion {
|
|||||||
export interface StoredDatasetVersionState {
|
export interface StoredDatasetVersionState {
|
||||||
versions: StoredDatasetVersion[]
|
versions: StoredDatasetVersion[]
|
||||||
active_version_id: string
|
active_version_id: string
|
||||||
|
/** 下次创建版本时使用的序号,确保已删除的版本号不会被复用。 */
|
||||||
|
next_version_number: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DatasetVersionMutationError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'DatasetVersionMutationError'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createInitialVersionState(
|
export function createInitialVersionState(
|
||||||
@@ -22,7 +34,24 @@ export function createInitialVersionState(
|
|||||||
description: '初始版本',
|
description: '初始版本',
|
||||||
content,
|
content,
|
||||||
}
|
}
|
||||||
return { versions: [version], active_version_id: version.id }
|
return { versions: [version], active_version_id: version.id, next_version_number: 2 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为旧版 localStorage 数据补齐单调递增的版本号游标。 */
|
||||||
|
export function normalizeDatasetVersionState(
|
||||||
|
state: Omit<StoredDatasetVersionState, 'next_version_number'> & {
|
||||||
|
next_version_number?: number
|
||||||
|
},
|
||||||
|
): StoredDatasetVersionState {
|
||||||
|
const minimumNextNumber = Math.max(0, ...state.versions.map((item) => item.version)) + 1
|
||||||
|
const nextVersionNumber = Number.isInteger(state.next_version_number)
|
||||||
|
? Math.max(minimumNextNumber, state.next_version_number as number)
|
||||||
|
: minimumNextNumber
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
next_version_number: nextVersionNumber,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 追加不可变版本快照,并自动把新版本设为当前版本。 */
|
/** 追加不可变版本快照,并自动把新版本设为当前版本。 */
|
||||||
@@ -32,7 +61,7 @@ export function appendDatasetVersion(
|
|||||||
createTime: string,
|
createTime: string,
|
||||||
description = '在线编辑',
|
description = '在线编辑',
|
||||||
): StoredDatasetVersionState {
|
): StoredDatasetVersionState {
|
||||||
const nextNumber = Math.max(0, ...state.versions.map((item) => item.version)) + 1
|
const nextNumber = state.next_version_number
|
||||||
const version: StoredDatasetVersion = {
|
const version: StoredDatasetVersion = {
|
||||||
id: `v${nextNumber}`,
|
id: `v${nextNumber}`,
|
||||||
version: nextNumber,
|
version: nextNumber,
|
||||||
@@ -43,6 +72,32 @@ export function appendDatasetVersion(
|
|||||||
return {
|
return {
|
||||||
versions: [...state.versions, version],
|
versions: [...state.versions, version],
|
||||||
active_version_id: version.id,
|
active_version_id: version.id,
|
||||||
|
next_version_number: nextNumber + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除非当前、非初始的历史版本,并保留版本号游标。 */
|
||||||
|
export function deleteDatasetVersion(
|
||||||
|
state: StoredDatasetVersionState,
|
||||||
|
versionId: string,
|
||||||
|
): StoredDatasetVersionState {
|
||||||
|
const version = state.versions.find((item) => item.id === versionId)
|
||||||
|
if (!version) {
|
||||||
|
throw new DatasetVersionMutationError('数据集版本不存在', 404)
|
||||||
|
}
|
||||||
|
if (state.versions.length === 1) {
|
||||||
|
throw new DatasetVersionMutationError('唯一版本不可删除', 400)
|
||||||
|
}
|
||||||
|
if (version.id === state.active_version_id) {
|
||||||
|
throw new DatasetVersionMutationError('当前版本不可删除,请先切换到其他版本', 409)
|
||||||
|
}
|
||||||
|
if (version.version === 1) {
|
||||||
|
throw new DatasetVersionMutationError('初始版本不可删除', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
versions: state.versions.filter((item) => item.id !== versionId),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export interface DatasetVersion {
|
|||||||
export interface DatasetVersionList {
|
export interface DatasetVersionList {
|
||||||
versions: DatasetVersion[]
|
versions: DatasetVersion[]
|
||||||
active_version_id: string
|
active_version_id: string
|
||||||
|
next_version_number?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 训练任务 ============
|
// ============ 训练任务 ============
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { onBeforeRouteLeave, useRoute } from 'vue-router'
|
import { onBeforeRouteLeave, useRoute } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import AppConfirmDialog from '@/components/AppConfirmDialog.vue'
|
||||||
import PageCard from '@/components/PageCard.vue'
|
import PageCard from '@/components/PageCard.vue'
|
||||||
import {
|
import {
|
||||||
activateDatasetFileVersion,
|
activateDatasetFileVersion,
|
||||||
createDatasetFileVersion,
|
createDatasetFileVersion,
|
||||||
|
deleteDatasetFileVersion,
|
||||||
downloadFileUrl,
|
downloadFileUrl,
|
||||||
getDataset,
|
getDataset,
|
||||||
getDatasetFileVersionContent,
|
getDatasetFileVersionContent,
|
||||||
@@ -23,6 +25,7 @@ import type { DatasetFile, DatasetItem, DatasetVersion } from '@/types'
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const datasetId = route.params.id as string
|
const datasetId = route.params.id as string
|
||||||
|
const confirmDialogRef = ref<InstanceType<typeof AppConfirmDialog>>()
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const previewLoading = ref(false)
|
const previewLoading = ref(false)
|
||||||
@@ -44,6 +47,8 @@ const activeVersionId = ref('')
|
|||||||
const loadedVersionId = ref('')
|
const loadedVersionId = ref('')
|
||||||
const versionLoading = ref(false)
|
const versionLoading = ref(false)
|
||||||
const activatingVersion = ref(false)
|
const activatingVersion = ref(false)
|
||||||
|
const deletingVersion = ref(false)
|
||||||
|
const nextVersionNumber = ref(1)
|
||||||
let versionRequestId = 0
|
let versionRequestId = 0
|
||||||
|
|
||||||
const files = computed<DatasetFile[]>(() => dataset.value?.files || [])
|
const files = computed<DatasetFile[]>(() => dataset.value?.files || [])
|
||||||
@@ -54,7 +59,6 @@ const isRecordFile = computed(() => recordResult.value.supported)
|
|||||||
const records = computed(() => recordResult.value.records)
|
const records = computed(() => recordResult.value.records)
|
||||||
const viewedVersion = computed(() => versions.value.find((item) => item.id === loadedVersionId.value))
|
const viewedVersion = computed(() => versions.value.find((item) => item.id === loadedVersionId.value))
|
||||||
const isViewingActiveVersion = computed(() => Boolean(loadedVersionId.value) && loadedVersionId.value === activeVersionId.value)
|
const isViewingActiveVersion = computed(() => Boolean(loadedVersionId.value) && loadedVersionId.value === activeVersionId.value)
|
||||||
const nextVersionNumber = computed(() => Math.max(0, ...versions.value.map((item) => item.version)) + 1)
|
|
||||||
const currentDraft = computed(() => editingRecord.value?.kind === 'object'
|
const currentDraft = computed(() => editingRecord.value?.kind === 'object'
|
||||||
? JSON.stringify(editFields.value)
|
? JSON.stringify(editFields.value)
|
||||||
: rawDraft.value)
|
: rawDraft.value)
|
||||||
@@ -100,6 +104,8 @@ async function loadVersions(file: DatasetFile) {
|
|||||||
const activeContent = await getDatasetFileVersionContent(fileKey(file), result.active_version_id)
|
const activeContent = await getDatasetFileVersionContent(fileKey(file), result.active_version_id)
|
||||||
if (requestId !== versionRequestId) return
|
if (requestId !== versionRequestId) return
|
||||||
versions.value = result.versions
|
versions.value = result.versions
|
||||||
|
nextVersionNumber.value = result.next_version_number
|
||||||
|
?? Math.max(0, ...result.versions.map((item) => item.version)) + 1
|
||||||
activeVersionId.value = result.active_version_id
|
activeVersionId.value = result.active_version_id
|
||||||
selectedVersionId.value = result.active_version_id
|
selectedVersionId.value = result.active_version_id
|
||||||
loadedVersionId.value = result.active_version_id
|
loadedVersionId.value = result.active_version_id
|
||||||
@@ -169,6 +175,52 @@ async function activateViewedVersion() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteViewedVersion() {
|
||||||
|
if (
|
||||||
|
!selectedFile.value
|
||||||
|
|| !viewedVersion.value
|
||||||
|
|| isViewingActiveVersion.value
|
||||||
|
|| deletingVersion.value
|
||||||
|
) return
|
||||||
|
|
||||||
|
const targetVersion = viewedVersion.value
|
||||||
|
const confirmed = await confirmDialogRef.value?.open({
|
||||||
|
title: `删除 V${targetVersion.version} 版本?`,
|
||||||
|
message: `删除后无法恢复。当前版本 V${versions.value.find((item) => item.id === activeVersionId.value)?.version || '-'} 不受影响。`,
|
||||||
|
confirmText: '删除版本',
|
||||||
|
cancelText: '取消',
|
||||||
|
tone: 'danger',
|
||||||
|
})
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
deletingVersion.value = true
|
||||||
|
versionLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await deleteDatasetFileVersion(
|
||||||
|
fileKey(selectedFile.value),
|
||||||
|
targetVersion.id,
|
||||||
|
activeVersionId.value,
|
||||||
|
)
|
||||||
|
const activeContent = await getDatasetFileVersionContent(
|
||||||
|
fileKey(selectedFile.value),
|
||||||
|
result.active_version_id,
|
||||||
|
)
|
||||||
|
versions.value = result.versions
|
||||||
|
nextVersionNumber.value = result.next_version_number
|
||||||
|
?? Math.max(0, ...result.versions.map((item) => item.version)) + 1
|
||||||
|
activeVersionId.value = result.active_version_id
|
||||||
|
selectedVersionId.value = result.active_version_id
|
||||||
|
loadedVersionId.value = result.active_version_id
|
||||||
|
previewContent.value = activeContent.content
|
||||||
|
baseVersionContent.value = activeContent.content
|
||||||
|
resetRecordEditor()
|
||||||
|
ElMessage.success(`V${targetVersion.version} 版本已删除`)
|
||||||
|
} finally {
|
||||||
|
versionLoading.value = false
|
||||||
|
deletingVersion.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openRecordEditor(record: DatasetRecord) {
|
function openRecordEditor(record: DatasetRecord) {
|
||||||
if (!isViewingActiveVersion.value) {
|
if (!isViewingActiveVersion.value) {
|
||||||
ElMessage.warning('历史版本为只读,请先设为当前版本')
|
ElMessage.warning('历史版本为只读,请先设为当前版本')
|
||||||
@@ -296,6 +348,7 @@ async function saveVersion() {
|
|||||||
previewContent.value = result.content
|
previewContent.value = result.content
|
||||||
baseVersionContent.value = result.content
|
baseVersionContent.value = result.content
|
||||||
versions.value = [result.version, ...versions.value]
|
versions.value = [result.version, ...versions.value]
|
||||||
|
nextVersionNumber.value = result.version.version + 1
|
||||||
activeVersionId.value = result.version.id
|
activeVersionId.value = result.version.id
|
||||||
selectedVersionId.value = result.version.id
|
selectedVersionId.value = result.version.id
|
||||||
loadedVersionId.value = result.version.id
|
loadedVersionId.value = result.version.id
|
||||||
@@ -357,8 +410,10 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
|||||||
:version-loading="versionLoading"
|
:version-loading="versionLoading"
|
||||||
:saving-record="savingRecord"
|
:saving-record="savingRecord"
|
||||||
:activating-version="activatingVersion"
|
:activating-version="activatingVersion"
|
||||||
|
:deleting-version="deletingVersion"
|
||||||
@change="handleViewedVersionChange"
|
@change="handleViewedVersionChange"
|
||||||
@activate="activateViewedVersion"
|
@activate="activateViewedVersion"
|
||||||
|
@delete="deleteViewedVersion"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
@@ -445,6 +500,8 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
|
|||||||
@update:field="updateEditField"
|
@update:field="updateEditField"
|
||||||
@update:raw-draft="rawDraft = $event"
|
@update:raw-draft="rawDraft = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AppConfirmDialog ref="confirmDialogRef" />
|
||||||
</PageCard>
|
</PageCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import type { DatasetVersion } from '@/types'
|
import type { DatasetVersion } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -11,14 +12,36 @@ const props = defineProps<{
|
|||||||
versionLoading: boolean
|
versionLoading: boolean
|
||||||
savingRecord: boolean
|
savingRecord: boolean
|
||||||
activatingVersion: boolean
|
activatingVersion: boolean
|
||||||
|
deletingVersion: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: string]
|
'update:modelValue': [value: string]
|
||||||
change: [versionId: string]
|
change: [versionId: string]
|
||||||
activate: []
|
activate: []
|
||||||
|
delete: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
type VersionAction = 'activate' | 'delete'
|
||||||
|
|
||||||
|
const actionsDisabled = computed(
|
||||||
|
() =>
|
||||||
|
props.versionLoading ||
|
||||||
|
props.savingRecord ||
|
||||||
|
props.activatingVersion ||
|
||||||
|
props.deletingVersion,
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteDisabledReason = computed(() => {
|
||||||
|
if (props.viewedVersion?.version === 1) return '初始版本不可删除'
|
||||||
|
if (props.versions.length <= 1) return '至少需要保留一个版本'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteDisabled = computed(
|
||||||
|
() => actionsDisabled.value || Boolean(deleteDisabledReason.value),
|
||||||
|
)
|
||||||
|
|
||||||
function formatVersionTime(value: string) {
|
function formatVersionTime(value: string) {
|
||||||
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||||
}
|
}
|
||||||
@@ -32,6 +55,16 @@ function handleVersionChange(versionId: string) {
|
|||||||
emit('update:modelValue', versionId)
|
emit('update:modelValue', versionId)
|
||||||
emit('change', versionId)
|
emit('change', versionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleVersionAction(command: VersionAction) {
|
||||||
|
if (actionsDisabled.value) return
|
||||||
|
if (command === 'activate') {
|
||||||
|
emit('activate')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (deleteDisabled.value) return
|
||||||
|
emit('delete')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -70,15 +103,51 @@ function handleVersionChange(versionId: string) {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-button
|
<el-dropdown
|
||||||
v-if="viewedVersion && !isViewingActiveVersion"
|
v-if="viewedVersion && !isViewingActiveVersion"
|
||||||
type="primary"
|
class="version-actions"
|
||||||
:loading="activatingVersion"
|
trigger="click"
|
||||||
@click="emit('activate')"
|
placement="bottom-end"
|
||||||
|
popper-class="version-actions-popper"
|
||||||
|
:disabled="actionsDisabled"
|
||||||
|
@command="handleVersionAction"
|
||||||
>
|
>
|
||||||
<i class="fa fa-check-circle" />
|
<el-button
|
||||||
设为当前版本
|
class="version-actions-button"
|
||||||
|
:loading="activatingVersion || deletingVersion"
|
||||||
|
:disabled="actionsDisabled"
|
||||||
|
aria-label="打开历史版本操作菜单"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
v-if="!activatingVersion && !deletingVersion"
|
||||||
|
class="fa fa-ellipsis-h"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item
|
||||||
|
command="activate"
|
||||||
|
class="version-action-primary"
|
||||||
|
:disabled="actionsDisabled"
|
||||||
|
>
|
||||||
|
<i class="fa fa-check-circle" aria-hidden="true" />
|
||||||
|
设为当前版本
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item
|
||||||
|
command="delete"
|
||||||
|
class="version-action-danger"
|
||||||
|
:disabled="deleteDisabled"
|
||||||
|
:title="deleteDisabledReason || undefined"
|
||||||
|
divided
|
||||||
|
>
|
||||||
|
<i class="fa fa-trash-o" aria-hidden="true" />
|
||||||
|
删除版本
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -129,13 +198,43 @@ function handleVersionChange(versionId: string) {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-control-bar > .el-button {
|
.version-actions {
|
||||||
min-height: 40px;
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-control-bar > .el-button i {
|
.version-actions-button {
|
||||||
margin-right: 6px;
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-actions-button i {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.version-actions-popper .el-dropdown-menu__item) {
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.version-actions-popper .el-dropdown-menu) {
|
||||||
|
min-width: 168px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.version-actions-popper .version-action-primary:not(.is-disabled) i) {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.version-actions-popper .version-action-danger:not(.is-disabled)) {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.version-actions-popper .version-action-danger:not(.is-disabled):hover) {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
background: var(--el-color-danger-light-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 680px) {
|
@media (max-width: 680px) {
|
||||||
@@ -147,8 +246,22 @@ function handleVersionChange(versionId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.version-select,
|
.version-select,
|
||||||
.version-control-bar > .el-button {
|
.version-selector-group {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.version-status {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-status > span {
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-actions {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user