From 2bceb686c64b7aa3129f2374e49a67d705ce930b Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Tue, 14 Jul 2026 14:47:12 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=E6=95=B0=E6=8D=AE=E9=9B=86?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E7=AE=A1=E7=90=86=E6=94=AF=E6=8C=81=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=8E=86=E5=8F=B2=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DatasetVersionList 新增 next_version_number,API 与 Mock 补充删除版本接口与并发控制,DatasetVersionBar 增加删除操作并对初始版本与最后版本禁用,DatasetPreviewView 接入删除流程,回归脚本与设计走查同步。 --- design-qa.md | 52 +++++++ .../scripts/regression-dataset-preview.mjs | 28 +++- frontend/src/api/modules/dataset.ts | 11 ++ frontend/src/mock/adapter.ts | 48 +++++- frontend/src/mock/datasetVersions.ts | 59 +++++++- frontend/src/types/index.ts | 1 + .../src/views/dataset/DatasetPreviewView.vue | 59 +++++++- .../dataset/preview/DatasetVersionBar.vue | 137 ++++++++++++++++-- 8 files changed, 373 insertions(+), 22 deletions(-) diff --git a/design-qa.md b/design-qa.md index 46cdf43..ce1b24c 100644 --- a/design-qa.md +++ b/design-qa.md @@ -325,3 +325,55 @@ Verification evidence: Design-QA 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 diff --git a/frontend/scripts/regression-dataset-preview.mjs b/frontend/scripts/regression-dataset-preview.mjs index 28b7a78..8a339ac 100644 --- a/frontend/scripts/regression-dataset-preview.mjs +++ b/frontend/scripts/regression-dataset-preview.mjs @@ -17,6 +17,7 @@ const mockPath = path.join(root, 'src/mock/data.ts') const recordsPath = path.join(root, 'src/views/dataset/datasetRecords.ts') const versionsPath = path.join(root, 'src/mock/datasetVersions.ts') const source = fs.readFileSync(previewPath, 'utf8') +const previewLogicSource = source.slice(0, source.indexOf(' { expect(fs.existsSync(componentPath), `缺少数据集预览叶子组件:${path.basename(componentPath)}`) return fs.readFileSync(componentPath, 'utf8') @@ -40,6 +41,7 @@ const { activateDatasetVersion, appendDatasetVersion, createInitialVersionState, + deleteDatasetVersion, getActiveDatasetVersion, } = 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) } -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']) { expect(source.includes(`import ${componentName}`), `主页面未导入叶子组件:${componentName}`) expect(source.includes(`<${componentName}`), `主页面未挂载叶子组件:${componentName}`) @@ -69,7 +71,11 @@ expect(source.includes('保存版本'), '文件工具栏应提供保存版本按 expect(source.includes('baseVersionContent'), '页面应区分已保存版本与待保存工作副本') expect(previewSurfaceSource.includes('class="version-control-bar"'), '详情页应提供独立版本控制区') expect(previewSurfaceSource.includes('设为当前版本'), '历史版本应支持显式切换为当前版本') +expect(previewSurfaceSource.includes('class="version-actions"'), '历史版本操作应收纳到省略号菜单') +expect(previewSurfaceSource.includes('删除版本'), '历史版本操作菜单应提供删除入口') expect(previewSurfaceSource.includes('历史版本(只读)'), '历史版本应明确展示只读状态') +expect(source.includes('AppConfirmDialog'), '删除历史版本前应使用公共危险确认弹窗') +expect(source.includes("tone: 'danger'"), '删除历史版本确认弹窗应使用危险态') expect(source.includes('loadedVersionId.value'), '下载和展示应绑定正在查看的具体版本') expect(source.includes('versionRequestId'), '快速切换历史版本时应防止旧响应覆盖新内容') 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) expect(versionChangeSource.includes('resetRecordEditor()'), '成功切换版本后必须关闭并清空旧编辑器状态') expect(!source.includes('handleDownloadAll'), '页面不应保留整包下载逻辑') -expect(!source.includes('handleDelete'), '页面不应保留删除逻辑') +expect(!source.includes('deleteDataset('), '详情页不应恢复删除整个数据集的逻辑') expect(!source.includes('router.back()'), '页面不应保留页头返回逻辑') expect(mockSource.includes('files: ['), '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(apiSource.includes('createDatasetFileVersion'), '数据集 API 应提供创建新版本方法') expect(apiSource.includes('activateDatasetFileVersion'), '数据集 API 应提供切换当前版本方法') +expect(apiSource.includes('deleteDatasetFileVersion'), '数据集 API 应提供删除历史版本方法') expect(apiSource.includes('getDatasetFileVersionContent'), '数据集 API 应支持读取历史版本内容') expect(apiSource.includes('expected_current_version_id'), '创建和激活版本应携带乐观锁版本指针') expect(!apiSource.includes('updateDatasetFileContent'), '数据编辑不应继续覆盖历史版本内容') expect(adapterSource.includes('mock:dataset-versions:'), 'Mock 版本历史应支持刷新持久化') expect(adapterSource.includes('当前版本已被其他用户更新'), 'Mock 应拒绝基于过期版本的并发覆盖') expect(adapterSource.includes('历史版本不可覆盖'), '旧的覆盖保存接口应明确拒绝修改历史版本') +expect(versionsSource.includes('当前版本不可删除'), 'Mock 应拒绝删除当前版本') +expect(versionsSource.includes('初始版本不可删除'), 'Mock 应拒绝删除初始版本') expect(adapterSource.includes('Mock 存储空间不足'), 'Mock 存储失败时应返回明确错误且保留编辑草稿') const jsonl = [ @@ -145,6 +154,21 @@ expect(v2State.versions[1].version === 2 && v2State.active_version_id === 'v2', const switchedToV1 = activateDatasetVersion(v2State, 'v1') expect(getActiveDatasetVersion(switchedToV1).content === 'A', '切换当前版本后应读取对应历史内容') 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 try { activateDatasetVersion(v2State, 'missing') diff --git a/frontend/src/api/modules/dataset.ts b/frontend/src/api/modules/dataset.ts index b40eec8..e15c30b 100644 --- a/frontend/src/api/modules/dataset.ts +++ b/frontend/src/api/modules/dataset.ts @@ -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( + `/dataset-manage/versions/${encodeURIComponent(fileId)}/${encodeURIComponent(versionId)}`, + { expected_current_version_id: expectedCurrentVersionId }, + ) + /** 下载文件 URL */ export const downloadFileUrl = ( datasetId: string | number, diff --git a/frontend/src/mock/adapter.ts b/frontend/src/mock/adapter.ts index f59e607..2b67d76 100644 --- a/frontend/src/mock/adapter.ts +++ b/frontend/src/mock/adapter.ts @@ -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) diff --git a/frontend/src/mock/datasetVersions.ts b/frontend/src/mock/datasetVersions.ts index 88a8472..2d31d54 100644 --- a/frontend/src/mock/datasetVersions.ts +++ b/frontend/src/mock/datasetVersions.ts @@ -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 & { + 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), } } diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index f3dd501..1628948 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -90,6 +90,7 @@ export interface DatasetVersion { export interface DatasetVersionList { versions: DatasetVersion[] active_version_id: string + next_version_number?: number } // ============ 训练任务 ============ diff --git a/frontend/src/views/dataset/DatasetPreviewView.vue b/frontend/src/views/dataset/DatasetPreviewView.vue index ad835eb..db45d03 100644 --- a/frontend/src/views/dataset/DatasetPreviewView.vue +++ b/frontend/src/views/dataset/DatasetPreviewView.vue @@ -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>() 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(() => 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" /> window.removeEventListener('beforeunload', handleBeforeUnl @update:field="updateEditField" @update:raw-draft="rawDraft = $event" /> + + diff --git a/frontend/src/views/dataset/preview/DatasetVersionBar.vue b/frontend/src/views/dataset/preview/DatasetVersionBar.vue index 562ba94..7f7cc9c 100644 --- a/frontend/src/views/dataset/preview/DatasetVersionBar.vue +++ b/frontend/src/views/dataset/preview/DatasetVersionBar.vue @@ -1,4 +1,5 @@ - - - 设为当前版本 - + +