diff --git a/design-qa.md b/design-qa.md index 46cdf43..381eee6 100644 --- a/design-qa.md +++ b/design-qa.md @@ -325,3 +325,105 @@ 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 + +--- + +# Login Page Responsive Design QA + +## Evidence + +- Source visual truth: `/Users/caoxiaozhu/.codex/generated_images/019f5f3f-f7e1-7e41-9605-ea307d9f09e6/exec-d376c603-0c47-45ad-86fd-3e99c1a95ec7.png` +- Implementation route: `http://localhost:6801/login` +- Implementation screenshot: unavailable because the in-app browser runtime could not initialize in this session. +- Intended desktop viewport: 1536 × 1024. +- Intended laptop viewports: 1366 × 768 and 1280 × 720. +- State: unauthenticated login page, default username and password populated. + +## Static and automated evidence + +- Added a 1440px width breakpoint that shifts the split from 58/42 to 55/45 and caps the form at 440px. +- Added a short-screen breakpoint for heights up to 820px that reduces title, form, input, footer, and panel spacing without hiding the left visual. +- Kept the single-column fallback at 900px and below. +- `regression-login-layout.mjs`: passed. +- `regression-default-dashboard.mjs`: passed. +- `vue-tsc -b --noEmit`: passed. +- Vite dev transform for `LoginView.vue`: HTTP 200. +- Production build: blocked by the pre-existing missing route component `UserPermissionView.vue`, outside the login-page change. + +## Required fidelity surfaces + +- Fonts and typography: code uses the existing product font stack with laptop-specific display-size reductions; visual comparison remains unavailable. +- Spacing and layout rhythm: dedicated width and height media queries are present; rendered measurements remain unavailable. +- Colors and visual tokens: existing indigo tokens and the selected dark-purple visual asset are preserved. +- Image quality and asset fidelity: the generated `login-hero-flow.png` is used directly; the official `logo.png` is reused for the brand lockup. +- Copy and content: platform title, supporting copy, form labels, actions, and footer match the selected design. + +## Findings + +- [P2] Browser-rendered laptop comparison unavailable + Location: login page at 1366 × 768 and 1280 × 720. + Evidence: the in-app browser runtime failed during initialization, so no implementation screenshot or side-by-side comparison could be captured. + Impact: static checks cannot prove that all visible spacing and crop details match the selected design at laptop sizes. + Fix: capture both laptop viewports in a working in-app browser session, compare them with the source, and resolve any remaining P0/P1/P2 differences. + +## Comparison history + +### Iteration 1 — blocked + +- User reported that the initial implementation was optimized for large displays and did not compose well on laptop screens. +- Added explicit laptop-width and short-screen layout rules and passed targeted regression/type checks. +- Post-fix visual evidence remains unavailable because browser capture is blocked. + +final result: blocked diff --git a/frontend/package.json b/frontend/package.json index fb2b390..dc2037e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "type-check": "vue-tsc -b --noEmit", "test": "node scripts/run-regressions.mjs", + "test:login-layout": "node scripts/regression-login-layout.mjs", "test:default-dashboard": "node scripts/regression-default-dashboard.mjs", "test:dashboard": "node scripts/regression-dashboard.mjs", "test:data-process-list": "node scripts/regression-data-process-list.mjs", @@ -21,6 +22,7 @@ "test:eval-detail": "node scripts/regression-eval-detail.mjs", "test:model-manage": "node scripts/regression-model-manage.mjs", "test:hardware": "node scripts/regression-hardware-dashboard.mjs", + "test:user-settings": "node scripts/regression-user-settings.mjs", "test:training-log-layout": "node scripts/regression-training-log-layout.mjs", "test:fine-tune-create": "node scripts/regression-fine-tune-create-ui.mjs", "test:page-surface": "node scripts/regression-page-surface.mjs" diff --git a/frontend/scripts/regression-dashboard.mjs b/frontend/scripts/regression-dashboard.mjs index 1c43f08..759be26 100644 --- a/frontend/scripts/regression-dashboard.mjs +++ b/frontend/scripts/regression-dashboard.mjs @@ -19,6 +19,8 @@ assert.match( assert.match(echartsSource, /import\s*\{[^}]*BarChart[^}]*\}\s*from\s*['"]echarts\/charts['"]/, 'ECharts 未注册 BarChart') assert.match(echartsSource, /use\(\[[\s\S]*?BarChart[\s\S]*?\]\)/, 'BarChart 未加入 ECharts 按需注册列表') +assert.match(echartsSource, /import\s*\{[^}]*PieChart[^}]*\}\s*from\s*['"]echarts\/charts['"]/, 'ECharts 未注册 PieChart') +assert.match(echartsSource, /use\(\[[\s\S]*?PieChart[\s\S]*?\]\)/, 'PieChart 未加入 ECharts 按需注册列表') for (const copy of [ '平台运行状态', @@ -39,16 +41,24 @@ assert.match(dashboardSource, /router\.push\(['"]\/fine-tune['"]\)/, '查看全 assert.match(dashboardSource, /router\.push\(`\/training-log\/\$\{task\.id\}`\)/, '训练任务详情应进入训练日志页') assert.doesNotMatch(dashboardSource, /class=["']dashboard-heading["']/, '服务看板不应重复展示页面标题栏') assert.doesNotMatch(dashboardSource, /查看告警/, '服务看板不应保留冗余的顶部告警按钮') -assert.match(dashboardSource, /\.dashboard-view\s*\{[\s\S]*?gap:\s*10px;/, '服务看板应使用紧凑的区块间距') -assert.match(dashboardSource, /\.dashboard-middle\s*\{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?min-height:\s*0;/, '中间区域应弹性占满剩余高度') +assert.match(dashboardSource, /\.dashboard-view\s*\{[\s\S]*?gap:\s*16px;/, '服务看板区块间距应保持舒展') +assert.match(dashboardSource, /\.dashboard-view\s*\{[\s\S]*?min-height:\s*100%;/, '服务看板应至少填满页面可用高度') +assert.match(dashboardSource, /\.dashboard-middle\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?min-height:\s*0;/, '中间区域不应因新增统计卡片被压缩') assert.match(dashboardSource, /grid-template-columns:\s*minmax\(0,\s*1\.9fr\)\s*minmax\(300px,\s*0\.82fr\);/, '服务状态列应收窄,为训练图表释放更多宽度') -assert.match(dashboardSource, /\.training-chart\s*\{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?height:\s*auto;[\s\S]*?min-height:\s*230px;/, '训练统计图应随可用空间自动拉伸') -assert.match(dashboardSource, /@media\s*\(max-height:\s*900px\)[\s\S]*?\.training-chart\s*\{[\s\S]*?min-height:\s*200px;/, '较矮桌面视口应保留图表最小可读高度') +assert.match(dashboardSource, /\.training-chart\s*\{[\s\S]*?height:\s*300px;[\s\S]*?min-height:\s*300px;/, '训练统计图应保持舒展、稳定的展示高度') assert.match(dashboardSource, /\.service-table\s*\{[\s\S]*?grid-template-rows:[^;]*repeat\(4,\s*minmax\(48px,\s*1fr\)\)/, '服务状态行应随中间区域同步拉伸') assert.match(dashboardSource, /\.tasks-heading\s*\{[\s\S]*?min-height:\s*32px;[\s\S]*?padding:\s*0 14px 10px;/, '训练任务标题区应适当加高') assert.match(dashboardSource, /\.tasks-table\s*\{[\s\S]*?th,\s*td\s*\{[\s\S]*?height:\s*42px;[\s\S]*?th\s*\{[\s\S]*?height:\s*36px;/, '训练任务表格行应适当加高') assert.match(dashboardSource, /@media\s*\(max-height:\s*900px\)[\s\S]*?\.tasks-heading\s*\{[\s\S]*?min-height:\s*28px;[\s\S]*?padding:\s*0 12px 8px;[\s\S]*?\.tasks-table\s*\{[\s\S]*?height:\s*36px;[\s\S]*?th\s*\{[\s\S]*?height:\s*32px;/, '低高度桌面视口也应提高训练任务占比') -assert.match(mainLayoutSource, /\.layout-content:has\(\.dashboard-view\)[\s\S]*?overflow-y:\s*hidden;/, '服务看板容器应避免产生页面纵向滚动条') -assert.match(mainLayoutSource, /\.page-canvas\s*\{[\s\S]*?flex:\s*1 1 auto;[\s\S]*?overflow:\s*hidden;/, '服务看板页面画布应约束在可用视口内') +assert.match(dashboardSource, /class=["']user-stats-row["'][\s\S]*?用户操作分布[\s\S]*?登录时长排行[\s\S]*?最近登录用户/, '服务看板应展示三块用户统计卡片') +assert.match(dashboardSource, /class=["']duration-chart["'][\s\S]*?loginDurationChartOption/, '登录时长应使用 ECharts 图表展示') +assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?type:\s*['"]bar['"][\s\S]*?formatter:\s*['"]\{c\} 小时['"]/, '登录时长应以横向柱状图展示具体小时数') +assert.match(dashboardSource, /const operationChartOption[\s\S]*?position:\s*['"]outside['"][\s\S]*?labelLine:\s*\{[\s\S]*?show:\s*true/, '饼图应以外侧引导线标注操作名称') +assert.match(dashboardSource, /const loginDurationChartOption[\s\S]*?grid:\s*\{\s*top:\s*8,\s*right:\s*12,\s*bottom:\s*6,\s*left:\s*8,[\s\S]*?max:\s*Math\.ceil\(Math\.max[\s\S]*?position:\s*['"]insideRight['"]/, '登录时长图应收紧左右边距、按数据范围拉伸,并将数值置于柱内') +assert.match(dashboardSource, /\.user-stats-row\s*\{[\s\S]*?repeat\(3,\s*minmax\(0,\s*1fr\)\)/, '宽屏用户统计卡片应保持三列') +assert.match(dashboardSource, /@media\s*\(max-width:\s*1180px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?repeat\(2,\s*minmax\(0,\s*1fr\)\)/, '中等宽度应将用户统计卡片降为两列') +assert.match(dashboardSource, /@media\s*\(max-width:\s*720px\)[\s\S]*?\.user-stats-row\s*\{[\s\S]*?grid-template-columns:\s*1fr;/, '窄屏应将用户统计卡片降为单列') +assert.match(mainLayoutSource, /\.layout-content:has\(\.dashboard-view\)[\s\S]*?overflow-y:\s*auto;/, '服务看板应允许在内容超出视口时纵向滚动') +assert.match(mainLayoutSource, /\.page-canvas\s*\{[\s\S]*?flex:\s*1 0 auto;[\s\S]*?overflow:\s*visible;/, '服务看板画布应允许新增卡片完整显示') console.log('服务看板回归检查通过') 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/scripts/regression-page-surface.mjs b/frontend/scripts/regression-page-surface.mjs index 81822e5..e297fd8 100644 --- a/frontend/scripts/regression-page-surface.mjs +++ b/frontend/scripts/regression-page-surface.mjs @@ -76,6 +76,7 @@ const selfSurfaceRoutes = [ 'data-process', 'data-process/create', 'dataset', + 'user-settings', ] for (const routePath of selfSurfaceRoutes) { const routeBlock = extractRouteBlock(routerSource, routePath) 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/api/modules/system.ts b/frontend/src/api/modules/system.ts index 8d8e6f8..4df2a4d 100644 --- a/frontend/src/api/modules/system.ts +++ b/frontend/src/api/modules/system.ts @@ -1,5 +1,12 @@ -import { get, post } from '../request' -import type { SystemInfo, HealthMetrics } from '@/types' +import { del, get, post, put } from '../request' +import type { + CreateUserPayload, + HealthMetrics, + LoginResponse, + SystemInfo, + SystemUser, + UpdateUserAccessPayload, +} from '@/types' /** 系统信息(CPU/内存/磁盘/GPU/网络/系统) */ export const getSystemInfo = () => get('/system-info') @@ -9,4 +16,21 @@ export const getHealth = () => get('/health') /** 登录 */ export const login = (username: string, password: string) => - post('/login', { username, password }) + post('/login', { username, password }) + +/** 用户列表 */ +export const getUsers = () => get('/users') + +/** 创建用户 */ +export const createUser = (payload: CreateUserPayload) => + post('/users', payload) + +/** 删除用户,currentUsername 用于防止删除当前登录账号 */ +export const deleteUser = (id: string, currentUsername: string) => + del<{ deleted: string }>(`/users/${encodeURIComponent(id)}`, { + current_username: currentUsername, + }) + +/** 更新用户角色、状态及页面权限 */ +export const updateUserAccess = (id: string, payload: UpdateUserAccessPayload) => + put(`/users/${encodeURIComponent(id)}`, payload) diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index af5fa6b..97372f5 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -2,6 +2,7 @@ import { computed, nextTick, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' +import type { PermissionCode } from '@/types' const route = useRoute() const router = useRouter() @@ -27,41 +28,65 @@ const activeMenu = computed(() => { return seg }) -const menuGroups = [ +interface MenuItem { + key: string + label: string + icon: string + to: string + permission: PermissionCode +} + +interface MenuGroup { + title?: string + showTitle?: boolean + items: MenuItem[] +} + +const menuGroups: MenuGroup[] = [ { // 服务看板:独立入口,不显示分类小标题 showTitle: false, - items: [{ key: 'dashboard', label: '服务看板', icon: 'fa-tachometer', to: '/dashboard' }], + items: [{ key: 'dashboard', label: '服务看板', icon: 'fa-tachometer', to: '/dashboard', permission: 'dashboard' }], }, { title: '模型服务', items: [ - { key: 'fine-tune', label: '模型训练', icon: 'fa-cogs', to: '/fine-tune' }, - { key: 'model-eval', label: '模型评测', icon: 'fa-line-chart', to: '/model-eval' }, - { key: 'model-inference', label: '模型推理', icon: 'fa-server', to: '/model-inference' }, - { key: 'model-manage', label: '模型管理', icon: 'fa-cube', to: '/model-manage' }, + { key: 'fine-tune', label: '模型训练', icon: 'fa-cogs', to: '/fine-tune', permission: 'fine-tune' }, + { key: 'model-eval', label: '模型评测', icon: 'fa-line-chart', to: '/model-eval', permission: 'model-eval' }, + { key: 'model-inference', label: '模型推理', icon: 'fa-server', to: '/model-inference', permission: 'model-inference' }, + { key: 'model-manage', label: '模型管理', icon: 'fa-cube', to: '/model-manage', permission: 'model-manage' }, ], }, { title: '数据治理', items: [ - { key: 'dataset', label: '数据集管理', icon: 'fa-file-text', to: '/dataset' }, - { key: 'data-process', label: '数据处理', icon: 'fa-filter', to: '/data-process' }, + { key: 'dataset', label: '数据集管理', icon: 'fa-file-text', to: '/dataset', permission: 'dataset' }, + { key: 'data-process', label: '数据处理', icon: 'fa-filter', to: '/data-process', permission: 'data-process' }, ], }, { title: '其他工具', - items: [{ key: 'data-convert', label: '数据类型转换', icon: 'fa-exchange', to: '/data-convert' }], + items: [{ key: 'data-convert', label: '数据类型转换', icon: 'fa-exchange', to: '/data-convert', permission: 'data-convert' }], }, { title: '系统设置', items: [ - { key: 'hardware', label: '平台性能', icon: 'fa-bar-chart', to: '/hardware' }, - { key: 'logs', label: '查看日志', icon: 'fa-file-text', to: '/logs' }, + { key: 'user-settings', label: '用户设置', icon: 'fa-users', to: '/user-settings', permission: 'user-settings' }, + { key: 'hardware', label: '平台性能', icon: 'fa-bar-chart', to: '/hardware', permission: 'hardware' }, + { key: 'logs', label: '查看日志', icon: 'fa-file-text', to: '/logs', permission: 'logs' }, ], }, ] +const visibleMenuGroups = computed(() => + menuGroups + .map((group) => ({ + ...group, + items: group.items.filter((item) => auth.hasPermission(item.permission)), + })) + .filter((group) => group.items.length > 0), +) + /** * 传给 el-menu 的激活值。 * @@ -78,7 +103,7 @@ watch(activeMenu, (value) => { }) async function handleSelect(key: string) { - const all = menuGroups.flatMap((g) => g.items) + const all = visibleMenuGroups.value.flatMap((g) => g.items) const target = all.find((i) => i.key === key) if (!target) return @@ -115,7 +140,7 @@ function handleLogout() { active-text-color="var(--sidebar-active-text)" @select="handleSelect" > - @@ -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; + } } diff --git a/frontend/src/views/login/LoginView.vue b/frontend/src/views/login/LoginView.vue index bef2528..aa89d4b 100644 --- a/frontend/src/views/login/LoginView.vue +++ b/frontend/src/views/login/LoginView.vue @@ -40,225 +40,308 @@ async function handleLogin() {