feat: 数据集版本管理支持删除历史版本

DatasetVersionList 新增 next_version_number,API 与 Mock 补充删除版本接口与并发控制,DatasetVersionBar 增加删除操作并对初始版本与最后版本禁用,DatasetPreviewView 接入删除流程,回归脚本与设计走查同步。
This commit is contained in:
caoxiaozhu
2026-07-14 14:47:12 +08:00
parent acbd6d91b3
commit 2bceb686c6
8 changed files with 373 additions and 22 deletions

View File

@@ -72,6 +72,17 @@ export const activateDatasetFileVersion = (
{ 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 */
export const downloadFileUrl = (
datasetId: string | number,

View File

@@ -27,7 +27,10 @@ import {
activateDatasetVersion,
appendDatasetVersion,
createInitialVersionState,
deleteDatasetVersion,
DatasetVersionMutationError,
getActiveDatasetVersion,
normalizeDatasetVersionState,
} from './datasetVersions'
import type { StoredDatasetVersion, StoredDatasetVersionState } from './datasetVersions'
@@ -83,7 +86,13 @@ function getVersionState(fileId: string): StoredDatasetVersionState {
if (persisted) {
try {
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 {
// 版本数据损坏时回退到初始版本
}
@@ -100,6 +109,14 @@ function versionMetadata(version: StoredDatasetVersion) {
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 响应 */
async function handleMock(config: AxiosRequestConfig) {
await delay(150) // 模拟网络延迟
@@ -185,10 +202,7 @@ async function handleMock(config: AxiosRequestConfig) {
if (m && method === 'get') {
const fileId = decodeURIComponent(m[1])
const state = getVersionState(fileId)
return ok({
versions: state.versions.map(versionMetadata).sort((a, b) => b.version - a.version),
active_version_id: state.active_version_id,
})
return ok(versionListResponse(state))
}
if (m && method === 'post') {
const fileId = decodeURIComponent(m[1])
@@ -249,6 +263,30 @@ async function handleMock(config: AxiosRequestConfig) {
? ok({ version: versionMetadata(version), content: version.content })
: 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)

View File

@@ -9,6 +9,18 @@ export interface StoredDatasetVersion {
export interface StoredDatasetVersionState {
versions: StoredDatasetVersion[]
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(
@@ -22,7 +34,24 @@ export function createInitialVersionState(
description: '初始版本',
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,
description = '在线编辑',
): StoredDatasetVersionState {
const nextNumber = Math.max(0, ...state.versions.map((item) => item.version)) + 1
const nextNumber = state.next_version_number
const version: StoredDatasetVersion = {
id: `v${nextNumber}`,
version: nextNumber,
@@ -43,6 +72,32 @@ export function appendDatasetVersion(
return {
versions: [...state.versions, version],
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),
}
}

View File

@@ -90,6 +90,7 @@ export interface DatasetVersion {
export interface DatasetVersionList {
versions: DatasetVersion[]
active_version_id: string
next_version_number?: number
}
// ============ 训练任务 ============

View File

@@ -2,10 +2,12 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeRouteLeave, useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import AppConfirmDialog from '@/components/AppConfirmDialog.vue'
import PageCard from '@/components/PageCard.vue'
import {
activateDatasetFileVersion,
createDatasetFileVersion,
deleteDatasetFileVersion,
downloadFileUrl,
getDataset,
getDatasetFileVersionContent,
@@ -23,6 +25,7 @@ import type { DatasetFile, DatasetItem, DatasetVersion } from '@/types'
const route = useRoute()
const datasetId = route.params.id as string
const confirmDialogRef = ref<InstanceType<typeof AppConfirmDialog>>()
const loading = ref(false)
const previewLoading = ref(false)
@@ -44,6 +47,8 @@ const activeVersionId = ref('')
const loadedVersionId = ref('')
const versionLoading = ref(false)
const activatingVersion = ref(false)
const deletingVersion = ref(false)
const nextVersionNumber = ref(1)
let versionRequestId = 0
const files = computed<DatasetFile[]>(() => dataset.value?.files || [])
@@ -54,7 +59,6 @@ const isRecordFile = computed(() => recordResult.value.supported)
const records = computed(() => recordResult.value.records)
const viewedVersion = computed(() => versions.value.find((item) => item.id === loadedVersionId.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'
? JSON.stringify(editFields.value)
: rawDraft.value)
@@ -100,6 +104,8 @@ async function loadVersions(file: DatasetFile) {
const activeContent = await getDatasetFileVersionContent(fileKey(file), result.active_version_id)
if (requestId !== versionRequestId) return
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
@@ -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) {
if (!isViewingActiveVersion.value) {
ElMessage.warning('历史版本为只读,请先设为当前版本')
@@ -296,6 +348,7 @@ async function saveVersion() {
previewContent.value = result.content
baseVersionContent.value = result.content
versions.value = [result.version, ...versions.value]
nextVersionNumber.value = result.version.version + 1
activeVersionId.value = result.version.id
selectedVersionId.value = result.version.id
loadedVersionId.value = result.version.id
@@ -357,8 +410,10 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
:version-loading="versionLoading"
:saving-record="savingRecord"
:activating-version="activatingVersion"
:deleting-version="deletingVersion"
@change="handleViewedVersionChange"
@activate="activateViewedVersion"
@delete="deleteViewedVersion"
/>
<el-alert
@@ -445,6 +500,8 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
@update:field="updateEditField"
@update:raw-draft="rawDraft = $event"
/>
<AppConfirmDialog ref="confirmDialogRef" />
</PageCard>
</template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DatasetVersion } from '@/types'
const props = defineProps<{
@@ -11,14 +12,36 @@ const props = defineProps<{
versionLoading: boolean
savingRecord: boolean
activatingVersion: boolean
deletingVersion: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
change: [versionId: string]
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) {
return new Date(value).toLocaleString('zh-CN', { hour12: false })
}
@@ -32,6 +55,16 @@ function handleVersionChange(versionId: string) {
emit('update:modelValue', 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>
<template>
@@ -70,15 +103,51 @@ function handleVersionChange(versionId: string) {
</template>
</div>
<el-button
<el-dropdown
v-if="viewedVersion && !isViewingActiveVersion"
type="primary"
:loading="activatingVersion"
@click="emit('activate')"
class="version-actions"
trigger="click"
placement="bottom-end"
popper-class="version-actions-popper"
:disabled="actionsDisabled"
@command="handleVersionAction"
>
<i class="fa fa-check-circle" />
设为当前版本
</el-button>
<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>
<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>
</template>
@@ -129,13 +198,43 @@ function handleVersionChange(versionId: string) {
white-space: nowrap;
}
.version-control-bar > .el-button {
min-height: 40px;
.version-actions {
flex: 0 0 auto;
line-height: 1;
}
.version-control-bar > .el-button i {
margin-right: 6px;
.version-actions-button {
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) {
@@ -147,8 +246,22 @@ function handleVersionChange(versionId: string) {
}
.version-select,
.version-control-bar > .el-button {
.version-selector-group {
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>