diff --git a/frontend/public/guide/screenshots/data-process-create.jpg b/frontend/public/guide/screenshots/data-process-create.jpg new file mode 100644 index 0000000..7d7eef2 Binary files /dev/null and b/frontend/public/guide/screenshots/data-process-create.jpg differ diff --git a/frontend/public/guide/screenshots/data-process-preview.jpg b/frontend/public/guide/screenshots/data-process-preview.jpg new file mode 100644 index 0000000..243c939 Binary files /dev/null and b/frontend/public/guide/screenshots/data-process-preview.jpg differ diff --git a/frontend/public/guide/screenshots/fine-tune-create.jpg b/frontend/public/guide/screenshots/fine-tune-create.jpg new file mode 100644 index 0000000..02be3ff Binary files /dev/null and b/frontend/public/guide/screenshots/fine-tune-create.jpg differ diff --git a/frontend/public/guide/screenshots/model-edit.jpg b/frontend/public/guide/screenshots/model-edit.jpg new file mode 100644 index 0000000..ea7f899 Binary files /dev/null and b/frontend/public/guide/screenshots/model-edit.jpg differ diff --git a/frontend/public/guide/screenshots/service-dashboard.png b/frontend/public/guide/screenshots/service-dashboard.png new file mode 100644 index 0000000..f896c3e Binary files /dev/null and b/frontend/public/guide/screenshots/service-dashboard.png differ diff --git a/frontend/public/guide/screenshots/training-detail.jpg b/frontend/public/guide/screenshots/training-detail.jpg new file mode 100644 index 0000000..d0d7975 Binary files /dev/null and b/frontend/public/guide/screenshots/training-detail.jpg differ diff --git a/frontend/scripts/regression-login-layout.mjs b/frontend/scripts/regression-login-layout.mjs new file mode 100644 index 0000000..85842ec --- /dev/null +++ b/frontend/scripts/regression-login-layout.mjs @@ -0,0 +1,26 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))) +const loginPath = resolve(root, 'src/views/login/LoginView.vue') +const heroAssetPath = resolve(root, 'src/assets/login-hero-flow.jpg') +const login = readFileSync(loginPath, 'utf8') + +function assert(condition, message) { + if (!condition) throw new Error(message) +} + +assert(login.includes('class="login-visual"'), '登录页缺少左侧品牌视觉区域') +assert(login.includes('class="login-panel"'), '登录页缺少右侧登录区域') +assert(login.includes('远光软件微调平台'), '登录页缺少平台标题') +assert(login.includes('大模型微调、评测与推理的一体化工作台'), '登录页缺少平台定位文案') +assert(login.includes('login-hero-flow.jpg'), '登录页未使用压缩后的流线主视觉资源') +assert(login.includes('@media (max-width: 1440px)'), '登录页缺少笔记本宽度适配规则') +assert(login.includes('@media (max-height: 820px) and (min-width: 901px)'), '登录页缺少笔记本短屏适配规则') +assert(login.includes('@media (max-width: 900px)'), '登录页缺少窄屏响应式规则') +assert(/\.login-page\s*\{[^}]*height:\s*100dvh;[^}]*overflow:\s*hidden;/.test(login), '桌面端登录页不应产生页面滚动') +assert(/@media \(max-width: 900px\)[\s\S]*?\.login-page\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*auto;/.test(login), '窄屏登录页应保留内容滚动能力') +assert(existsSync(heroAssetPath), '登录页流线主视觉资源不存在') + +console.log('login-layout regression checks passed') diff --git a/frontend/scripts/regression-page-surface.mjs b/frontend/scripts/regression-page-surface.mjs index 25fc074..ca350fc 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', + 'compute', 'user-settings', ] for (const routePath of selfSurfaceRoutes) { diff --git a/frontend/scripts/regression-user-guide.mjs b/frontend/scripts/regression-user-guide.mjs new file mode 100644 index 0000000..75d6769 --- /dev/null +++ b/frontend/scripts/regression-user-guide.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const sourceRoot = path.resolve(scriptDir, '../src') +const publicRoot = path.resolve(scriptDir, '../public') +const [headerSource, routerSource, guideSource, guideContentSource] = await Promise.all([ + readFile(path.join(sourceRoot, 'components/AppHeader.vue'), 'utf8'), + readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'), + readFile(path.join(sourceRoot, 'views/guide/GuideView.vue'), 'utf8'), + readFile(path.join(sourceRoot, 'views/guide/guideContent.ts'), 'utf8'), +]) + +assert.match(headerSource, /fa-book/, '顶部栏缺少书状使用文档图标') +assert.match(headerSource, /window\.open\([\s\S]*?['"]_blank['"]/, '使用文档应在浏览器新标签页打开') +assert.match(headerSource, /aria-label=["']打开使用文档["']/, '使用文档图标缺少无障碍名称') +assert.match(routerSource, /path:\s*['"]\/guide['"][\s\S]*?GuideView\.vue/, '缺少独立使用文档路由') +assert.match(routerSource, /name:\s*['"]guide['"][\s\S]*?skipPermission:\s*true/, '使用文档应允许所有已登录用户访问') +assert.ok(routerSource.indexOf("path: '/guide'") < routerSource.indexOf("path: '/'"), '使用文档不应嵌入主应用布局') +assert.match(guideSource, /class="docs-shell"/, '使用文档缺少独立文档站布局') +assert.match(guideSource, /class="docs-sidebar"/, '使用文档缺少左侧分级导航') +assert.match(guideSource, /class="article-toc"/, '使用文档缺少右侧页内目录') +assert.match(guideSource, /v-model="searchQuery"/, '使用文档缺少全局搜索能力') +assert.match(guideSource, /scrollIntoView/, '使用文档目录缺少锚点导航能力') +assert.match(guideSource, /class="architecture-flow"/, '使用文档缺少架构或流程图') +assert.match(guideSource, /articlePresentation\.stepsTitle/, '文档步骤标题应根据页面类型动态展示') +assert.match(guideSource, /查看型页面/, '文档缺少查看型页面说明') +assert.match(guideSource, /排查型页面/, '文档缺少排查型页面说明') +assert.match(guideSource, /class="step-screenshot"/, '使用文档缺少步骤内联页面截图') +assert.match(guideSource, /loading="lazy"/, '页面截图应使用懒加载') +assert.match(guideSource, /class="image-preview-overlay"/, '页面截图缺少大图预览') +assert.match(guideSource, /aria-modal="true"/, '截图预览缺少无障碍对话框语义') +assert.match(guideSource, /:inert="previewImage \? true : undefined"/, '截图预览打开时应禁用背景交互') +assert.match(guideSource, /event\.key === 'Tab'/, '截图预览缺少键盘焦点循环') +assert.match(guideSource, /:width="step\.screenshot\.width"/, '页面截图应使用真实固有尺寸') + +for (const moduleName of [ + '服务看板', + '模型训练', + '模型评测', + '模型推理', + '模型管理', + '数据集管理', + '数据处理', + '数据类型转换', + '用户设置', + '平台性能', + '查看日志', +]) { + assert.ok(guideContentSource.includes(moduleName), `使用文档缺少模块:${moduleName}`) +} + +assert.match(guideSource, /操作步骤/, '使用文档缺少分步骤教程') +assert.match(guideContentSource, /title:\s*'快速开始'/, '使用文档缺少完整快速开始教程') +assert.match(guideContentSource, /architecture:/, '使用文档内容缺少架构流程数据') +assert.match(guideContentSource, /steps:/, '使用文档内容缺少模块操作步骤') +assert.match(guideContentSource, /id:\s*'dashboard'[\s\S]*?mode:\s*'overview'/, '服务看板应标记为查看型页面') +assert.match(guideContentSource, /id:\s*'hardware'[\s\S]*?mode:\s*'overview'/, '平台性能应标记为查看型页面') +assert.match(guideContentSource, /id:\s*'logs'[\s\S]*?mode:\s*'troubleshooting'/, '查看日志应标记为排查型页面') +assert.match(guideContentSource, /screenshot:/, '使用文档步骤缺少真实页面截图引用') +assert.match(guideContentSource, /status:\s*'建设中'/, '使用文档应如实标记未开放功能') +assert.match(guideSource, /@media \(max-width: 900px\)/, '使用文档缺少窄屏响应式布局') + +for (const screenshotName of [ + 'service-dashboard.png', + 'fine-tune-create.jpg', + 'training-detail.jpg', + 'model-edit.jpg', + 'data-process-create.jpg', + 'data-process-preview.jpg', +]) { + const screenshotPath = path.join(publicRoot, 'guide/screenshots', screenshotName) + const screenshot = await readFile(screenshotPath) + assert.ok(screenshot.length > 10_000, `页面截图不存在或内容异常:${screenshotName}`) + const isValidSignature = screenshotName.endsWith('.png') + ? screenshot.subarray(1, 4).toString() === 'PNG' + : screenshot[0] === 0xff && screenshot[1] === 0xd8 && screenshot[2] === 0xff + assert.ok(isValidSignature, `页面截图扩展名与文件格式不一致:${screenshotName}`) + assert.ok( + guideContentSource.includes(`/guide/screenshots/${screenshotName}`), + `使用文档未引用页面截图:${screenshotName}`, + ) +} + +console.log('使用文档回归检查通过') diff --git a/frontend/scripts/regression-user-settings.mjs b/frontend/scripts/regression-user-settings.mjs new file mode 100644 index 0000000..593b551 --- /dev/null +++ b/frontend/scripts/regression-user-settings.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import ts from 'typescript' +import { parse as parseSfc } from '@vue/compiler-sfc' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const sourceRoot = path.resolve(scriptDir, '../src') +const [viewSource, createSource, permissionSource, sidebarSource, routerSource, apiSource, adapterSource, authSource, usersSource] = await Promise.all([ + readFile(path.join(sourceRoot, 'views/system/UserSettingsView.vue'), 'utf8'), + readFile(path.join(sourceRoot, 'views/system/UserCreateView.vue'), 'utf8'), + readFile(path.join(sourceRoot, 'views/system/UserPermissionView.vue'), 'utf8'), + readFile(path.join(sourceRoot, 'components/AppSidebar.vue'), 'utf8'), + readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'), + readFile(path.join(sourceRoot, 'api/modules/system.ts'), 'utf8'), + readFile(path.join(sourceRoot, 'mock/adapter.ts'), 'utf8'), + readFile(path.join(sourceRoot, 'stores/auth.ts'), 'utf8'), + readFile(path.join(sourceRoot, 'mock/users.ts'), 'utf8'), +]) +const userFeatureSource = [viewSource, createSource, permissionSource].join('\n') + +const descriptor = parseSfc(viewSource).descriptor +assert.ok(descriptor.template?.content, '用户设置页面缺少模板') +assert.ok(descriptor.scriptSetup?.content, '用户设置页面缺少 script setup') + +assert.match(sidebarSource, /label:\s*'用户设置'/, '系统设置菜单缺少用户设置入口') +assert.match(sidebarSource, /to:\s*'\/user-settings'/, '用户设置菜单路由不正确') +assert.match(sidebarSource, /visibleMenuGroups/, '侧边栏没有根据权限过滤菜单') +assert.match(routerSource, /path:\s*'user-settings'/, '缺少用户设置路由') +assert.match(routerSource, /permission:\s*'user-settings'/, '用户设置路由缺少权限声明') +assert.match(routerSource, /permission-denied/, '缺少无权限访问反馈页面') +assert.match(authSource, /hasPermission/, '认证状态缺少权限检查能力') + +for (const marker of [ + '创建用户', + '用户权限设置', + '功能权限', + '权限设置', + '删除', + 'AppConfirmDialog', + "tone: 'danger'", +]) { + assert.match(userFeatureSource, new RegExp(marker), `用户设置页面缺少关键交互:${marker}`) +} +assert.match(createSource, /createRules/, '创建用户缺少表单校验') +assert.match(viewSource, /deleteDisabledReason/, '删除操作缺少当前用户和内置用户保护') +assert.match(viewSource, /prefers-reduced-motion/, '页面未适配减少动态效果偏好') + +for (const apiName of ['getUsers', 'createUser', 'deleteUser', 'updateUserAccess']) { + assert.match(apiSource, new RegExp(`export const ${apiName}`), `用户 API 缺少 ${apiName}`) +} +assert.match(adapterSource, /url === '\/users' && method === 'get'/, 'Mock 缺少用户列表路由') +assert.match(adapterSource, /url === '\/users' && method === 'post'/, 'Mock 缺少创建用户路由') +assert.match(adapterSource, /userMatch && method === 'put'/, 'Mock 缺少权限更新路由') +assert.match(adapterSource, /userMatch && method === 'delete'/, 'Mock 缺少删除用户路由') +assert.match(usersSource, /mock:system-users:v1/, 'Mock 用户没有持久化存储') + +const usersModuleCode = ts.transpileModule(usersSource, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, +}).outputText +const usersModule = await import( + `data:text/javascript;base64,${Buffer.from(usersModuleCode).toString('base64')}` +) + +const initialUsers = usersModule.listMockUsers() +assert.equal(initialUsers.length, 2, '初始状态应包含内置管理员和操作员') +assert.equal(initialUsers[0].username, 'admin', '默认管理员账号应为 admin') +assert.equal(initialUsers[0].permissions.length, 12, '默认管理员应拥有全部模块权限') +assert.equal(initialUsers[1].username, 'operator', '默认操作员账号应为 operator') +assert.ok(initialUsers[1].permissions.includes('compute'), '默认操作员应拥有算力节点权限') +assert.ok(!initialUsers[1].permissions.includes('user-settings'), '默认操作员不应拥有用户管理权限') +assert.ok(!('password' in initialUsers[0]), '用户列表不能向页面返回密码') + +const operator = usersModule.createMockUser({ + username: 'operator01', + display_name: '测试操作员', + password: '123456', + role: 'operator', + status: 'active', +}) +assert.equal(usersModule.listMockUsers().length, 3, '创建用户后列表数量应增加') +assert.ok(!operator.permissions.includes('user-settings'), '操作员默认不应拥有用户管理权限') + +assert.throws( + () => usersModule.createMockUser({ username: 'OPERATOR01', display_name: '重复账号', password: '123456', role: 'viewer' }), + /用户名已存在/, + '用户名唯一校验应忽略大小写', +) + +const updated = usersModule.updateMockUserAccess(operator.id, { + role: 'viewer', + permissions: ['dashboard', 'dataset'], +}) +assert.deepEqual(updated.permissions, ['dashboard', 'dataset'], '权限更新应准确保存勾选结果') +assert.equal(updated.role, 'viewer', '权限设置应同步保存用户角色') + +const loginResult = usersModule.authenticateMockUser('operator01', '123456') +assert.equal(loginResult.user.id, operator.id, '新建用户应可使用初始密码登录') +assert.ok(loginResult.user.last_login, '登录后应记录最近登录时间') + +assert.throws( + () => usersModule.deleteMockUser(operator.id, 'operator01'), + /不能删除当前登录用户/, + '不能删除当前登录账号', +) +assert.throws( + () => usersModule.deleteMockUser(initialUsers[0].id, 'other-user'), + /系统内置管理员不能删除/, + '不能删除系统内置管理员', +) + +usersModule.deleteMockUser(operator.id, 'admin') +assert.equal(usersModule.listMockUsers().length, 2, '确认删除后用户应从列表移除') + +console.log('用户设置页回归检查通过') diff --git a/frontend/src/assets/login-hero-flow.jpg b/frontend/src/assets/login-hero-flow.jpg index 5c139c4..75b5f48 100644 Binary files a/frontend/src/assets/login-hero-flow.jpg and b/frontend/src/assets/login-hero-flow.jpg differ diff --git a/frontend/src/mock/users.ts b/frontend/src/mock/users.ts index ff340b3..ef0d9ef 100644 --- a/frontend/src/mock/users.ts +++ b/frontend/src/mock/users.ts @@ -4,11 +4,13 @@ import type { PermissionCode, SystemUser, UpdateUserAccessPayload, + UserRole, } from '@/types' const STORAGE_KEY = 'mock:system-users' +const LEGACY_STORAGE_KEY = 'mock:system-users:v1' -const allPermissions: PermissionCode[] = [ +export const ALL_PERMISSION_CODES: PermissionCode[] = [ 'dashboard', 'fine-tune', 'model-eval', @@ -23,30 +25,43 @@ const allPermissions: PermissionCode[] = [ 'user-settings', ] -const defaultPasswords: Record = { +const ROLE_DEFAULT_PERMISSIONS: Record = { + admin: ALL_PERMISSION_CODES, + operator: ALL_PERMISSION_CODES.filter((permission) => permission !== 'user-settings'), + viewer: ['dashboard', 'dataset', 'hardware', 'logs'], +} + +const DEFAULT_PASSWORDS: Record = { admin: 'admin123', operator: 'operator123', } -export class UserMutationError extends Error { - status: number +interface StoredSystemUser extends SystemUser { + password: string +} - constructor(message: string, status = 400) { +export class UserMutationError extends Error { + constructor( + message: string, + public readonly status = 400, + ) { super(message) this.name = 'UserMutationError' - this.status = status } } -function defaultUsers(): SystemUser[] { +let memoryUsers: StoredSystemUser[] | null = null + +function createDefaultUsers(): StoredSystemUser[] { return [ { id: 'u_admin', username: 'admin', display_name: 'Platform Admin', + password: DEFAULT_PASSWORDS.admin, role: 'admin', status: 'active', - permissions: allPermissions, + permissions: [...ALL_PERMISSION_CODES], create_time: '2026-01-01T00:00:00Z', protected: true, }, @@ -54,91 +69,206 @@ function defaultUsers(): SystemUser[] { id: 'u_operator', username: 'operator', display_name: 'Platform Operator', + password: DEFAULT_PASSWORDS.operator, role: 'operator', status: 'active', - permissions: allPermissions.filter((item) => item !== 'user-settings'), + permissions: [...ROLE_DEFAULT_PERMISSIONS.operator], create_time: '2026-01-01T00:00:00Z', protected: false, }, ] } -function readUsers(): SystemUser[] { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return defaultUsers() - try { - const users = JSON.parse(raw) as SystemUser[] - return users.length ? users : defaultUsers() - } catch { - return defaultUsers() +function canUseLocalStorage() { + return typeof localStorage !== 'undefined' +} + +function persistUsers(users: StoredSystemUser[]) { + memoryUsers = users + if (canUseLocalStorage()) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(users)) } } -function writeUsers(users: SystemUser[]) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(users)) +function normalizePermissions(role: UserRole, permissions?: PermissionCode[]) { + if (role === 'admin') return [...ALL_PERMISSION_CODES] + const candidates = Array.isArray(permissions) ? permissions : ROLE_DEFAULT_PERMISSIONS[role] + return ALL_PERMISSION_CODES.filter((permission) => candidates.includes(permission)) +} + +function normalizeStoredUser(user: Partial): StoredSystemUser | null { + if (!user.id || !user.username || !user.role) return null + if (!['admin', 'operator', 'viewer'].includes(user.role)) return null + const role = user.role as UserRole + const username = String(user.username).trim() + if (!username) return null + return { + id: String(user.id), + username, + display_name: String(user.display_name || username).trim(), + password: String(user.password || DEFAULT_PASSWORDS[username] || 'platform123'), + role, + status: user.status === 'disabled' ? 'disabled' : 'active', + permissions: normalizePermissions(role, user.permissions), + create_time: user.create_time || new Date().toISOString(), + last_login: user.last_login, + protected: Boolean(user.protected), + } +} + +function loadUsers(): StoredSystemUser[] { + if (canUseLocalStorage()) { + for (const storageKey of [STORAGE_KEY, LEGACY_STORAGE_KEY]) { + const persisted = localStorage.getItem(storageKey) + if (!persisted) continue + try { + const parsed = JSON.parse(persisted) as Partial[] + const normalized = Array.isArray(parsed) + ? parsed.map(normalizeStoredUser).filter((user): user is StoredSystemUser => Boolean(user)) + : [] + if (normalized.length > 0) { + persistUsers(normalized) + return normalized + } + } catch { + // 当前存储损坏时继续尝试旧版数据,全部不可用才恢复内置账号。 + } + } + } else if (memoryUsers) { + return memoryUsers + } + + const users = createDefaultUsers() + persistUsers(users) + return users +} + +function publicUser(user: StoredSystemUser): SystemUser { + const { password: _password, ...result } = user + return { + ...result, + permissions: [...result.permissions], + } +} + +function nextUserId() { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return `user-${crypto.randomUUID()}` + } + return `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +} + +function findUserOrThrow(users: StoredSystemUser[], id: string) { + const user = users.find((item) => item.id === id) + if (!user) throw new UserMutationError('用户不存在', 404) + return user +} + +function isLastActiveAdmin(users: StoredSystemUser[], target: StoredSystemUser) { + if (target.role !== 'admin' || target.status !== 'active') return false + return users.filter((user) => user.role === 'admin' && user.status === 'active').length === 1 +} + +function isLastAdmin(users: StoredSystemUser[], target: StoredSystemUser) { + return target.role === 'admin' && users.filter((user) => user.role === 'admin').length === 1 } export function listMockUsers(): SystemUser[] { - return readUsers() + return loadUsers().map(publicUser) } export function authenticateMockUser(username: string, password: string): LoginResponse { - const users = readUsers() - const user = users.find((item) => item.username === username) - if (!user || user.status !== 'active') { - throw new UserMutationError('Invalid username or disabled account', 401) + const users = loadUsers() + const normalizedUsername = username.trim().toLocaleLowerCase() + const user = users.find((item) => item.username.toLocaleLowerCase() === normalizedUsername) + if (!user || user.password !== password) { + throw new UserMutationError('账号或密码错误', 401) } - const expected = defaultPasswords[username] || 'platform123' - if (password !== expected) { - throw new UserMutationError('Invalid username or password', 401) + if (user.status === 'disabled') { + throw new UserMutationError('该账号已被禁用,请联系管理员', 403) } + user.last_login = new Date().toISOString() - writeUsers(users) + persistUsers(users) return { - token: `mock-token-${user.id}`, - user, + token: `mock-token-${user.id}-${Date.now()}`, + user: publicUser(user), } } export function createMockUser(payload: CreateUserPayload): SystemUser { - const users = readUsers() - if (users.some((item) => item.username === payload.username)) { - throw new UserMutationError('Username already exists', 409) + const users = loadUsers() + const username = String(payload.username || '').trim() + const displayName = String(payload.display_name || '').trim() + if (!username) throw new UserMutationError('用户名不能为空') + if (!displayName) throw new UserMutationError('用户名称不能为空') + if (!payload.password) throw new UserMutationError('密码不能为空') + if (!['admin', 'operator', 'viewer'].includes(payload.role)) { + throw new UserMutationError('用户角色不正确') } - const user: SystemUser = { - id: `u_${Date.now()}`, - username: payload.username, - display_name: payload.display_name || payload.username, + if (payload.status && !['active', 'disabled'].includes(payload.status)) { + throw new UserMutationError('用户状态不正确') + } + if (users.some((user) => user.username.toLocaleLowerCase() === username.toLocaleLowerCase())) { + throw new UserMutationError('用户名已存在', 409) + } + + const user: StoredSystemUser = { + id: nextUserId(), + username, + display_name: displayName, + password: payload.password, role: payload.role, - status: payload.status || 'active', - permissions: payload.permissions || [], + status: payload.status ?? 'active', + permissions: normalizePermissions(payload.role, payload.permissions), create_time: new Date().toISOString(), protected: false, } - defaultPasswords[user.username] = payload.password || 'platform123' - users.push(user) - writeUsers(users) - return user + persistUsers([...users, user]) + return publicUser(user) } export function updateMockUserAccess(id: string, payload: UpdateUserAccessPayload): SystemUser { - const users = readUsers() - const user = users.find((item) => item.id === id) - if (!user) throw new UserMutationError('User not found', 404) - if (payload.role) user.role = payload.role - if (payload.status) user.status = payload.status - if (payload.permissions) user.permissions = payload.permissions - writeUsers(users) - return user + const users = loadUsers() + const user = findUserOrThrow(users, id) + if (user.protected) { + throw new UserMutationError('系统内置管理员的状态和权限不可修改', 409) + } + if (payload.role && !['admin', 'operator', 'viewer'].includes(payload.role)) { + throw new UserMutationError('用户角色不正确') + } + if (payload.status && !['active', 'disabled'].includes(payload.status)) { + throw new UserMutationError('用户状态不正确') + } + const nextRole = payload.role ?? user.role + const nextStatus = payload.status ?? user.status + + if (isLastActiveAdmin(users, user) && (nextRole !== 'admin' || nextStatus !== 'active')) { + throw new UserMutationError('不能停用或降级最后一个管理员', 409) + } + + const nextPermissions = payload.permissions + ?? (payload.role && payload.role !== user.role ? undefined : user.permissions) + user.role = nextRole + user.status = nextStatus + user.permissions = normalizePermissions(nextRole, nextPermissions) + persistUsers(users) + return publicUser(user) } export function deleteMockUser(id: string, currentUsername: string): { deleted: string } { - const users = readUsers() - const user = users.find((item) => item.id === id) - if (!user) throw new UserMutationError('User not found', 404) - if (user.protected || user.username === currentUsername) { - throw new UserMutationError('Protected or current user cannot be deleted', 400) + const users = loadUsers() + const user = findUserOrThrow(users, id) + if (user.protected) { + throw new UserMutationError('系统内置管理员不能删除', 409) } - writeUsers(users.filter((item) => item.id !== id)) + if (user.username.toLocaleLowerCase() === currentUsername.trim().toLocaleLowerCase()) { + throw new UserMutationError('不能删除当前登录用户', 409) + } + if (isLastAdmin(users, user)) { + throw new UserMutationError('不能删除最后一个管理员', 409) + } + + persistUsers(users.filter((item) => item.id !== id)) return { deleted: id } } diff --git a/frontend/src/plugins/echarts-hardware.ts b/frontend/src/plugins/echarts-hardware.ts index f7c1f9f..3259755 100644 --- a/frontend/src/plugins/echarts-hardware.ts +++ b/frontend/src/plugins/echarts-hardware.ts @@ -1,3 +1,4 @@ +/** 平台性能页图表按需注册。 */ import { use } from 'echarts/core' import { CanvasRenderer } from 'echarts/renderers' import { GaugeChart, LineChart } from 'echarts/charts' diff --git a/frontend/src/plugins/echarts-training-log.ts b/frontend/src/plugins/echarts-training-log.ts index 4ce6041..9da5bef 100644 --- a/frontend/src/plugins/echarts-training-log.ts +++ b/frontend/src/plugins/echarts-training-log.ts @@ -1,3 +1,4 @@ +/** 训练日志曲线按需注册。 */ import { use } from 'echarts/core' import { CanvasRenderer } from 'echarts/renderers' import { LineChart } from 'echarts/charts' diff --git a/frontend/src/views/eval/create/BasicMetricSetupStep.vue b/frontend/src/views/eval/create/BasicMetricSetupStep.vue index 962a96d..245a232 100644 --- a/frontend/src/views/eval/create/BasicMetricSetupStep.vue +++ b/frontend/src/views/eval/create/BasicMetricSetupStep.vue @@ -1,5 +1,5 @@ @@ -46,4 +104,53 @@ function nameOf(items: T[], id .start-eval-step { max-width: 920px; } + +.ready-alert { + margin-bottom: 24px; +} + +.summary-section + .summary-section { + margin-top: 24px; +} + +.summary-section h3 { + padding-left: 10px; + border-left: 3px solid #5146e5; + margin: 0 0 12px; + color: #1e293b; + font-size: 15px; + font-weight: 650; +} + +.metric-tags { + display: flex; + min-height: 56px; + align-items: center; + gap: 10px; + padding: 14px 16px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; +} + +.precision-copy { + margin-left: auto; + color: #64748b; + font-size: 13px; +} + +@media (max-width: 760px) { + .summary-section :deep(.el-descriptions__body .el-descriptions__table) { + table-layout: auto; + } + + .metric-tags { + flex-wrap: wrap; + } + + .precision-copy { + width: 100%; + margin-left: 0; + } +} diff --git a/frontend/src/views/guide/GuideView.vue b/frontend/src/views/guide/GuideView.vue index 4e7a078..ec6f111 100644 --- a/frontend/src/views/guide/GuideView.vue +++ b/frontend/src/views/guide/GuideView.vue @@ -1,12 +1,1681 @@ + + - diff --git a/frontend/src/views/guide/guideContent.ts b/frontend/src/views/guide/guideContent.ts new file mode 100644 index 0000000..1385b6d --- /dev/null +++ b/frontend/src/views/guide/guideContent.ts @@ -0,0 +1,381 @@ +export interface GuideScreenshot { + src: string + alt: string + caption: string + width: number + height: number +} + +export interface GuideStep { + title: string + description: string + screenshot?: GuideScreenshot +} + +export type GuideArticleMode = 'workflow' | 'overview' | 'troubleshooting' + +export interface GuideArticle { + id: string + group: string + mode: GuideArticleMode + title: string + summary: string + capabilities: string[] + architecture: string[] + prerequisites: string[] + steps: GuideStep[] + validation: string[] + notice?: string + status?: '建设中' +} + +export interface GuideNavigationGroup { + title: string + articleIds: string[] +} + +const guideScreenshots = { + serviceDashboard: { + src: '/guide/screenshots/service-dashboard.png', + alt: '远光软件微调平台服务看板主内容,展示运行指标、服务状态和训练任务列表', + caption: '服务看板:集中查看平台指标、服务状态和最近训练任务。', + width: 1243, + height: 1058, + }, + fineTuneCreate: { + src: '/guide/screenshots/fine-tune-create.jpg', + alt: '远光软件微调平台创建训练任务主内容', + caption: '创建训练任务:在同一页面配置模型、数据集、GPU 和训练参数。', + width: 1040, + height: 720, + }, + trainingDetail: { + src: '/guide/screenshots/training-detail.jpg', + alt: '远光软件微调平台训练任务详情主内容,展示任务信息和训练指标', + caption: '训练任务详情:先核对任务信息与训练参数,再继续向下查看指标和日志。', + width: 1200, + height: 900, + }, + modelEdit: { + src: '/guide/screenshots/model-edit.jpg', + alt: '远光软件微调平台编辑模型配置主内容', + caption: '模型编辑页:配置模型用途、来源、本地路径和说明。', + width: 1200, + height: 1024, + }, + dataProcessCreate: { + src: '/guide/screenshots/data-process-create.jpg', + alt: '远光软件微调平台新建数据处理任务主内容', + caption: '新建数据处理任务:通过四步向导选择处理类型并上传源数据。', + width: 1200, + height: 1024, + }, + dataProcessPreview: { + src: '/guide/screenshots/data-process-preview.jpg', + alt: '远光软件微调平台数据处理切分预览主内容', + caption: '数据预览:定位切片并检查、编辑切分后的样本内容。', + width: 1200, + height: 1024, + }, +} + +export const guideArticles: GuideArticle[] = [ + { + id: 'getting-started', + group: '入门', + mode: 'workflow', + title: '快速开始', + summary: '通过一次完整的“模型准备 → 数据准备 → 模型训练 → 评测与推理”流程,快速理解微调平台的核心使用方式。', + capabilities: [ + '理解平台各模块在模型微调生命周期中的位置', + '完成一个最小可用的训练任务并查看运行状态', + '使用评测与推理验证训练后的模型效果', + ], + architecture: ['模型管理', '数据集管理', '模型训练', '模型评测', '模型推理', '性能与日志'], + prerequisites: [ + '已获得平台账号,并拥有模型管理、数据集管理、模型训练相关权限。', + '已准备可访问的基座模型,以及符合训练模板的 JSON 或 JSONL 数据。', + '平台至少有一张空闲 GPU,显存能够满足所选模型和训练方法。', + ], + steps: [ + { + title: '登记或确认基座模型', + description: '进入“模型管理”,确认目标基座模型已存在且路径可访问;若不存在,先添加模型并设置模型类型、用途和来源。', + screenshot: guideScreenshots.modelEdit, + }, + { + title: '上传并检查训练数据', + description: '进入“数据集管理”上传 JSON/JSONL 文件,在预览页检查字段、编码和样本内容,确保数据格式与训练模板一致。', + }, + { + title: '创建训练任务', + description: '进入“模型训练”,选择基座模型、数据集、训练类型、训练方法和 GPU,再配置学习率、批次大小与训练轮次。', + screenshot: guideScreenshots.fineTuneCreate, + }, + { + title: '启动并观察训练', + description: '核对配置后启动任务,通过任务详情观察 Loss、学习率、进度、GPU 使用情况和原始日志;异常时先停止任务再调整参数。', + screenshot: guideScreenshots.trainingDetail, + }, + { + title: '评测训练结果', + description: '训练完成并合并权重后创建评测任务,选择评测数据集与指标,查看综合评分、维度表现和逐条样本结果。', + }, + { + title: '启动推理验证', + description: '创建推理服务并加载训练模型,在对话页用真实问题测试回答质量,按需调整 Temperature、Top P 和系统提示词。', + }, + ], + validation: [ + '训练任务状态为“已完成”,训练日志中没有未处理的错误。', + '模型权重已完成合并,可被评测和推理模块选择。', + '评测结果达到预期,推理对话能够稳定回答目标场景问题。', + ], + notice: '首次验证建议使用小规模数据集和 LoRA 训练,先跑通完整流程,再逐步扩大数据量和训练规模。', + }, + { + id: 'dashboard', + group: '工作台', + mode: 'overview', + title: '服务看板', + summary: '集中查看平台运行状态、训练趋势、模型服务、用户活跃情况和最近训练任务。', + capabilities: ['平台健康与关键指标概览', '近 7 天训练趋势和服务状态', '最近任务、用户操作和登录情况'], + architecture: ['资源与任务数据', '指标聚合', '服务看板', '任务详情 / 平台性能'], + prerequisites: ['拥有服务看板访问权限。', '需要查看真实运行数据时,确认相关监控和任务服务已经正常连接。'], + steps: [ + { + title: '先看总体状态', + description: '先查看在线服务、运行中任务和待处理告警,快速判断平台是否处于健康状态。', + screenshot: guideScreenshots.serviceDashboard, + }, + { title: '再看训练趋势', description: '结合近 7 天训练次数、GPU 使用数和平均准确率,判断资源负载和训练质量变化。' }, + { title: '对照服务状态', description: '查看模型推理、模型训练、模型评测和数据处理服务的状态与实例数量,确认异常来自哪个服务。' }, + { title: '需要时下钻详情', description: '看板只负责发现问题;需要进一步处理时,再从训练任务进入详情或模型训练列表。' }, + ], + validation: [ + '能够判断平台整体是否健康,以及是否存在待处理告警。', + '能够定位需要关注的训练趋势、异常服务或失败任务。', + '需要处理时,能够从看板进入对应业务详情继续排查。', + ], + }, + { + id: 'fine-tune', + group: '模型服务', + mode: 'workflow', + title: '模型训练', + summary: '创建和管理 SFT、DPO、CPT 训练任务,支持 LoRA 或全参数微调、权重合并与训练后量化。', + capabilities: ['基座模型、模板、数据集和 GPU 选择', '训练方法与超参数配置', '训练进度、指标、GPU 状态和日志跟踪'], + architecture: ['任务配置', '资源校验', 'GPU 调度', '模型训练', '权重保存', '合并 / 量化'], + prerequisites: ['模型管理中已配置可用的基座模型。', '数据集已通过格式检查。', '已确认所需 GPU 空闲且显存充足。'], + steps: [ + { + title: '创建任务并填写基本信息', + description: '点击“创建训练任务”,填写名称,选择 SFT、DPO 或 CPT,并选择 LoRA 或全参数训练。', + screenshot: guideScreenshots.fineTuneCreate, + }, + { title: '选择模型和数据', description: '选择基座模型、训练模板、训练数据集和目标 GPU;确认数据字段与模板要求一致。' }, + { title: '配置训练参数', description: '设置学习率、批次大小、梯度累积、训练轮次、保存频率等参数;首次运行优先采用保守配置。' }, + { title: '配置训练后处理', description: '按使用场景决定是否自动合并 LoRA 权重,以及是否在训练完成后执行量化。' }, + { + title: '核对并启动任务', + description: '检查生成的训练命令和资源配置,确认无误后启动训练;返回列表确认任务已创建并进入正确状态。', + }, + { + title: '跟踪与处理异常', + description: '在列表查看进度,在详情页观察 Loss、梯度、学习率和 GPU 状态;出现 OOM 或数据错误时停止任务并修正配置。', + screenshot: guideScreenshots.trainingDetail, + }, + ], + validation: ['任务状态变为“已完成”。', '最终权重目录存在且文件完整。', 'Loss 变化合理,日志中没有 OOM、NaN 或数据解析错误。'], + notice: '全参数训练资源开销较大。首次训练建议优先使用 LoRA,并通过小数据集验证参数和模板。', + }, + { + id: 'model-eval', + group: '模型服务', + mode: 'workflow', + title: '模型评测', + summary: '使用大模型裁判和基础指标评估训练模型,并查看综合评分、维度表现和样本级结果。', + capabilities: ['评测模型、数据集和 GPU 配置', '大模型裁判与基础指标组合', '综合评价、维度分析和样本判定'], + architecture: ['待评模型', '评测数据集', '裁判与指标', '评测执行', '结果聚合', '报告分析'], + prerequisites: ['待评模型已经完成训练和权重合并。', '评测数据集包含可用于输入和参考答案的字段。', '大模型裁判模式下已配置可用的裁判模型。'], + steps: [ + { title: '创建评测任务', description: '点击“创建评测任务”,选择待评模型、评测数据集和运行 GPU。' }, + { title: '配置裁判方式', description: '选择评测大模型,设置 Prompt、评分区间、评估规则和需要关注的业务维度。' }, + { title: '选择基础指标', description: '根据任务类型启用 BLEU、ROUGE、余弦相似度等指标,避免使用与任务目标无关的指标。' }, + { title: '启动并等待完成', description: '核对配置后启动评测,通过列表观察任务状态和执行进度。' }, + { title: '分析评测结果', description: '在详情页查看综合评分、改进建议、维度表现和逐条样本判定,定位模型薄弱场景。' }, + ], + validation: ['评测任务正常完成且样本覆盖数量符合预期。', '综合评分与维度评分均有结果。', '抽查样本判定,确认裁判 Prompt 与业务标准一致。'], + }, + { + id: 'model-inference', + group: '模型服务', + mode: 'workflow', + title: '模型推理', + summary: '启动本地模型或训练模型的推理服务,通过多轮对话验证回答效果并进行模型对比。', + capabilities: ['推理服务创建、加载与停止', '系统提示词和多轮对话', 'Temperature、Top P 与生成长度配置'], + architecture: ['选择模型', '分配 GPU', '加载推理服务', '发送请求', '生成回答', '效果验证'], + prerequisites: ['目标模型已在模型管理中配置,或训练模型已完成权重合并。', '至少有一张满足模型显存需求的 GPU。'], + steps: [ + { title: '新建推理任务', description: '填写推理任务名称,选择本地模型或已合并的训练模型,并指定运行 GPU。' }, + { title: '加载模型服务', description: '创建后点击“加载”,等待模型完成初始化并进入“已就绪”状态。' }, + { title: '配置推理参数', description: '在对话设置中填写系统提示词,并根据任务调整 Temperature、Top P 和最大生成长度。' }, + { title: '进行多轮验证', description: '输入真实业务问题,观察回答准确性、格式稳定性、上下文保持能力和边界行为。' }, + { title: '结束并释放资源', description: '验证完成后返回列表停止服务,避免持续占用 GPU。' }, + ], + validation: ['服务状态为“已就绪”且请求能够正常返回。', '模型回答符合系统提示词和目标格式。', '停止服务后对应 GPU 资源得到释放。'], + }, + { + id: 'model-manage', + group: '模型服务', + mode: 'workflow', + title: '模型管理', + summary: '统一维护基座模型、推理模型、评测模型,以及训练产生的 LoRA 权重和合并模型。', + capabilities: ['本地模型与在线 API 模型配置', '模型用途、来源和路径管理', 'LoRA 权重合并、导出与删除'], + architecture: ['模型来源', '模型登记', '用途配置', '训练 / 评测 / 推理', '权重合并', '导出归档'], + prerequisites: ['本地模型路径对平台服务可访问。', '在线模型已准备正确的 API 地址、模型名称和鉴权信息。'], + steps: [ + { title: '选择模型类型', description: '进入“配置模型”,根据来源选择本地模型或在线 API 模型。' }, + { + title: '填写模型配置', + description: '设置名称、模型类型、使用场景、来源和路径;在线模型还需配置接口信息。', + screenshot: guideScreenshots.modelEdit, + }, + { title: '验证模型可用性', description: '保存后确认模型出现在列表,并能够被对应的训练、评测或推理页面选择。' }, + { title: '管理训练权重', description: '在“训练模型”中查看训练方法、基座模型和权重状态。' }, + { title: '合并或导出模型', description: '对 LoRA 权重执行合并,完成后用于评测和推理;不再使用的权重可按需导出或删除。' }, + ], + validation: ['模型配置保存成功且路径或接口可访问。', '模型用途与可选择的业务模块一致。', '权重合并完成后生成独立可加载模型。'], + }, + { + id: 'dataset', + group: '数据治理', + mode: 'workflow', + title: '数据集管理', + summary: '管理训练集与评测集,支持格式校验、样本编辑、存储配置和版本控制。', + capabilities: ['JSON / JSONL 数据上传与预览', '样本搜索、编辑和错误修复', '版本创建、切换、删除与下载'], + architecture: ['源文件', '格式校验', '样本预览', '内容编辑', '版本保存', '训练 / 评测引用'], + prerequisites: ['文件使用 UTF-8 编码。', 'JSON/JSONL 结构合法,字段与目标训练或评测模板一致。'], + steps: [ + { title: '创建数据集', description: '点击“上传数据集”,填写名称,选择训练集或评测集,并选择本地或 MinIO 存储。' }, + { title: '上传源文件', description: '上传 JSON 或 JSONL 文件,等待平台完成格式和基础字段校验。' }, + { title: '预览与搜索样本', description: '进入详情页查看原始内容和表格预览,通过搜索快速定位目标样本。' }, + { title: '修正数据内容', description: '逐条编辑异常样本,重点检查缺失字段、转义字符、空内容和格式不一致。' }, + { title: '创建并启用版本', description: '暂存修改后创建新版本,复查无误后将目标版本设为当前版本。' }, + ], + validation: ['数据集状态正常且样本数量符合预期。', '预览页面没有格式错误提示。', '训练或评测任务能够正确选择当前版本。'], + }, + { + id: 'data-process', + group: '数据治理', + mode: 'workflow', + title: '数据处理', + summary: '将结构化数据、非结构化文档或外部数据源加工为可训练、可评测的数据。', + capabilities: ['清洗、切分和数据集划分', '大模型生成与质量筛选', '结果预览、编辑和校验'], + architecture: ['数据源', '清洗与切分', '模型生成', '质量筛选', '人工校验', '保存为数据集'], + prerequisites: ['已准备本地文件,或可访问的数据库/API 数据源。', '需要模型生成时,已配置可用的生成模型。'], + steps: [ + { + title: '创建任务并选择数据源', + description: '选择结构化、非结构化或外部数据源,并填写任务基本信息。', + screenshot: guideScreenshots.dataProcessCreate, + }, + { title: '配置预处理规则', description: '设置清洗、切分、字段映射和训练集/验证集/测试集划分方式。' }, + { title: '选择生成模型', description: '需要扩写、问答生成或内容转换时,选择生成模型并配置 Prompt 与生成参数。' }, + { + title: '检查数据预览', + description: '上传文件或连接数据源后检查预览,确认字段、切分结果和样本数量符合预期。', + screenshot: guideScreenshots.dataProcessPreview, + }, + { title: '启动生成与筛选', description: '执行任务,观察生成进度和质量筛选结果,及时处理失败样本。' }, + { title: '编辑并保存结果', description: '修正校验问题和低质量样本,确认结果后保存任务并生成可复用数据集。' }, + ], + validation: ['任务生成过程没有未处理失败项。', '结果样本通过字段和内容校验。', '生成数据集可在数据集管理和训练任务中正常使用。'], + }, + { + id: 'data-convert', + group: '其他工具', + mode: 'workflow', + title: '数据类型转换', + summary: '将 JSON 数组转换为逐行记录的 JSONL 文件,使数据能够被训练和评测模块直接使用。', + capabilities: ['JSON 转 JSONL', '输出文件名和编码配置', '训练数据格式标准化'], + architecture: ['JSON 源文件', '结构检查', '逐条展开', 'JSONL 输出', '数据集上传'], + prerequisites: ['源文件为合法 JSON 数组。', '文件使用 UTF-8 编码,每个数组元素代表一条数据记录。'], + steps: [ + { title: '确认转换类型', description: '进入页面后确认转换方向为 JSON → JSONL。' }, + { title: '配置输出信息', description: '设置输出文件名和 UTF-8 编码,避免中文内容或特殊字符出现乱码。' }, + { title: '上传并执行转换', description: '功能开放后上传 JSON 文件,执行转换并下载 JSONL 结果。' }, + { title: '校验并上传数据集', description: '抽查输出文件的首尾记录,确认每行都是完整 JSON 对象后再上传到数据集管理。' }, + ], + validation: ['输出文件每一行均可独立解析为 JSON。', '转换前后记录数量一致。'], + notice: '当前页面为功能原型,文件上传和实际转换能力尚未开放。', + status: '建设中', + }, + { + id: 'user-settings', + group: '系统设置', + mode: 'workflow', + title: '用户设置', + summary: '创建和维护平台账号,配置角色、启用状态以及各业务模块的访问权限。', + capabilities: ['用户创建、状态维护和删除', '角色预设与模块权限分配', '内置账号和当前账号安全保护'], + architecture: ['创建账号', '分配角色', '勾选模块权限', '保存生效', '路由访问校验'], + prerequisites: ['当前账号拥有用户设置权限。', '已明确新用户的角色职责和最小权限范围。'], + steps: [ + { title: '创建用户', description: '填写登录账号、用户姓名、初始密码、角色和账号状态。' }, + { title: '检查角色预设', description: '根据管理员、操作员或观察员角色加载默认权限,并确认是否符合岗位需求。' }, + { title: '调整模块权限', description: '在“权限设置”中逐项勾选用户可访问的业务模块,遵循最小权限原则。' }, + { title: '保存并验证', description: '保存后让目标用户重新进入平台,确认菜单显示和页面访问范围正确。' }, + ], + validation: ['新用户可以使用初始密码登录。', '侧边栏仅显示已授权模块。', '访问未授权地址时会进入无权访问页面。'], + notice: '内置超级管理员和当前登录账号受到保护,不能在当前会话中删除或修改自身关键权限。', + }, + { + id: 'hardware', + group: '系统设置', + mode: 'overview', + title: '平台性能', + summary: '实时监控 CPU、内存、磁盘、网络和 GPU 资源,辅助判断平台负载并定位设备异常。', + capabilities: ['主机与资源池健康概览', 'CPU、内存、磁盘和网络趋势', 'GPU 温度、功耗、显存与占用进程'], + architecture: ['主机 / GPU 指标', '周期采样', '趋势聚合', '性能看板', '单卡与进程详情'], + prerequisites: ['拥有平台性能访问权限。', '监控服务能够读取主机和 GPU 指标。'], + steps: [ + { title: '先看健康概览', description: '查看 CPU、内存、磁盘、网络和 GPU 资源池是否存在告警,先判断整体负载。' }, + { title: '选择观察频率', description: '日常观察使用较长刷新周期;临时排障时再选择 1、3 或 5 秒短周期,避免被瞬时波动误导。' }, + { title: '再看资源趋势', description: '结合最近 60 次采样判断资源是否持续高位、突增或出现异常波动。' }, + { title: '需要时查看单卡', description: '发现 GPU 异常后再进入单卡详情,对照温度、功耗、显存、利用率和占用进程。' }, + ], + validation: [ + '能够判断 CPU、内存、磁盘、网络和 GPU 是否处于健康范围。', + '能够区分瞬时波动与持续高负载。', + '发现异常时,能够定位到具体 GPU 或占用进程。', + ], + }, + { + id: 'logs', + group: '系统设置', + mode: 'troubleshooting', + title: '查看日志', + summary: '集中查看系统日志和训练日志,通过刷新、筛选和关键词搜索定位异常。', + capabilities: ['系统日志与训练日志分类', '按日期、任务和进程选择日志', '自动刷新、手动刷新和关键词过滤'], + architecture: ['系统 / 训练进程', '日志文件', '类型与日期筛选', '关键词过滤', '异常定位'], + prerequisites: ['拥有查看日志权限。', '已确认需要排查的时间范围、任务名称或进程 PID。'], + steps: [ + { title: '选择日志类型', description: '切换“系统日志”或“训练日志”页签,确定排查范围。' }, + { title: '定位日志来源', description: '系统日志按日期选择文件;训练日志按任务名称和 PID 选择具体进程。' }, + { title: '设置刷新方式', description: '持续观察时开启自动刷新;复盘历史问题时关闭自动刷新并手动定位。' }, + { title: '搜索关键内容', description: '输入 error、warning、OOM、任务 ID 或业务关键词,结合上下文判断异常原因。' }, + ], + validation: ['日志时间与目标问题发生时间一致。', '异常前后的上下文信息完整。', '能够将错误关联到具体服务、任务或进程。'], + }, +] + +export const guideNavigation: GuideNavigationGroup[] = [ + { title: '入门', articleIds: ['getting-started'] }, + { title: '工作台', articleIds: ['dashboard'] }, + { title: '模型服务', articleIds: ['fine-tune', 'model-eval', 'model-inference', 'model-manage'] }, + { title: '数据治理', articleIds: ['dataset', 'data-process'] }, + { title: '其他工具', articleIds: ['data-convert'] }, + { title: '系统设置', articleIds: ['user-settings', 'hardware', 'logs'] }, +] + +export const guideArticleMap = new Map(guideArticles.map((article) => [article.id, article])) diff --git a/frontend/src/views/system/PermissionDeniedView.vue b/frontend/src/views/system/PermissionDeniedView.vue index c00917b..5f6800b 100644 --- a/frontend/src/views/system/PermissionDeniedView.vue +++ b/frontend/src/views/system/PermissionDeniedView.vue @@ -1,12 +1,28 @@ + + diff --git a/frontend/src/views/system/UserCreateView.vue b/frontend/src/views/system/UserCreateView.vue index b6cfb4f..e787ef5 100644 --- a/frontend/src/views/system/UserCreateView.vue +++ b/frontend/src/views/system/UserCreateView.vue @@ -2,93 +2,104 @@ import { reactive, ref } from 'vue' import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' +import type { FormInstance, FormRules } from 'element-plus' +import PageCard from '@/components/PageCard.vue' import { createUser } from '@/api/modules/system' -import type { CreateUserPayload, PermissionCode } from '@/types' +import type { CreateUserPayload, PermissionCode, UserRole } from '@/types' const router = useRouter() -const submitting = ref(false) +const saving = ref(false) +const createFormRef = ref() -const form = reactive({ +const rolePermissionPresets: Record = { + admin: ['dashboard', 'fine-tune', 'model-eval', 'model-inference', 'model-manage', 'dataset', 'data-process', 'data-convert', 'compute', 'hardware', 'logs', 'user-settings'], + operator: ['dashboard', 'fine-tune', 'model-eval', 'model-inference', 'model-manage', 'dataset', 'data-process', 'data-convert', 'compute', 'hardware', 'logs'], + viewer: ['dashboard', 'hardware', 'logs'], +} + +const createForm = reactive({ username: '', display_name: '', - password: 'platform123', - role: 'viewer', + password: '', + role: 'operator', status: 'active', - permissions: ['dashboard'], + permissions: [...rolePermissionPresets.operator], }) -const permissionOptions: PermissionCode[] = [ - 'dashboard', - 'fine-tune', - 'model-eval', - 'model-inference', - 'model-manage', - 'dataset', - 'data-process', - 'data-convert', - 'compute', - 'hardware', - 'logs', - 'user-settings', -] +const createRules: FormRules = { + username: [ + { required: true, message: '请输入登录账号', trigger: 'blur' }, + { pattern: /^[A-Za-z][A-Za-z0-9_]{2,31}$/, message: '账号需以字母开头,由 3-32 位字母、数字或下划线组成', trigger: 'blur' }, + ], + display_name: [{ required: true, message: '请输入用户姓名', trigger: 'blur' }], + password: [ + { required: true, message: '请输入初始密码', trigger: 'blur' }, + { min: 6, message: '初始密码至少 6 位', trigger: 'blur' }, + ], + role: [{ required: true, message: '请选择用户角色', trigger: 'change' }], +} -async function submit() { - submitting.value = true +function applyCreateRolePreset(role: UserRole) { + createForm.permissions = [...rolePermissionPresets[role]] +} + +async function submitCreate() { + const valid = await createFormRef.value?.validate().catch(() => false) + if (!valid) return + saving.value = true try { - await createUser(form) - ElMessage.success('用户已创建') + await createUser({ ...createForm, permissions: [...(createForm.permissions ?? [])] }) + ElMessage.success('用户创建成功') router.push('/user-settings') } finally { - submitting.value = false + saving.value = false } } + +function handleCancel() { + router.back() +} diff --git a/frontend/src/views/system/UserPermissionView.vue b/frontend/src/views/system/UserPermissionView.vue index a79c2b8..3a1d19f 100644 --- a/frontend/src/views/system/UserPermissionView.vue +++ b/frontend/src/views/system/UserPermissionView.vue @@ -1,12 +1,206 @@ + + diff --git a/frontend/src/views/system/UserSettingsView.vue b/frontend/src/views/system/UserSettingsView.vue index ccf4534..f92434c 100644 --- a/frontend/src/views/system/UserSettingsView.vue +++ b/frontend/src/views/system/UserSettingsView.vue @@ -1,10 +1,59 @@ -