2026-07-10 16:45:25 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-07-11 14:49:37 +08:00
|
|
|
|
import { computed, nextTick, ref, watch } from 'vue'
|
2026-07-10 16:45:25 +08:00
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
|
import { useAuthStore } from '@/stores/auth'
|
2026-07-14 16:10:50 +08:00
|
|
|
|
import type { PermissionCode } from '@/types'
|
2026-07-10 16:45:25 +08:00
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const auth = useAuthStore()
|
|
|
|
|
|
|
|
|
|
|
|
/** 当前激活菜单 key(取路由路径第一段,如 /fine-tune/create → fine-tune) */
|
|
|
|
|
|
const activeMenu = computed(() => {
|
2026-07-11 14:49:37 +08:00
|
|
|
|
const seg = route.path.split('/')[1] || 'dashboard'
|
2026-07-10 16:45:25 +08:00
|
|
|
|
// 训练日志归到模型调优
|
|
|
|
|
|
if (seg === 'training-log') return 'fine-tune'
|
|
|
|
|
|
// 维度管理归到模型评测
|
|
|
|
|
|
if (route.path.includes('model-eval/dimension')) return 'model-eval'
|
|
|
|
|
|
// 对比对话归到模型推理
|
|
|
|
|
|
if (route.path.startsWith('/model-compare/chat')) return 'model-inference'
|
|
|
|
|
|
// 合并权重归到模型管理
|
|
|
|
|
|
if (route.path === '/model-manage/merge') return 'model-manage'
|
|
|
|
|
|
// 模型管理编辑
|
|
|
|
|
|
if (route.path.match(/^\/model-manage\/\d+\/edit/)) return 'model-manage'
|
|
|
|
|
|
// 数据集编辑/预览
|
|
|
|
|
|
if (route.path.match(/^\/dataset\/(\d+)/)) return 'dataset'
|
|
|
|
|
|
// 工具编辑
|
|
|
|
|
|
if (route.path.match(/^\/tools\/[^/]+\/edit/)) return 'tools'
|
|
|
|
|
|
return seg
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-07-14 16:10:50 +08:00
|
|
|
|
interface MenuItem {
|
|
|
|
|
|
key: string
|
|
|
|
|
|
label: string
|
|
|
|
|
|
icon: string
|
|
|
|
|
|
to: string
|
|
|
|
|
|
permission: PermissionCode
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface MenuGroup {
|
|
|
|
|
|
title?: string
|
|
|
|
|
|
showTitle?: boolean
|
|
|
|
|
|
items: MenuItem[]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const menuGroups: MenuGroup[] = [
|
2026-07-10 16:45:25 +08:00
|
|
|
|
{
|
|
|
|
|
|
// 服务看板:独立入口,不显示分类小标题
|
|
|
|
|
|
showTitle: false,
|
2026-07-14 16:10:50 +08:00
|
|
|
|
items: [{ key: 'dashboard', label: '服务看板', icon: 'fa-tachometer', to: '/dashboard', permission: 'dashboard' }],
|
2026-07-10 16:45:25 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '模型服务',
|
|
|
|
|
|
items: [
|
2026-07-14 16:10:50 +08:00
|
|
|
|
{ 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' },
|
2026-07-10 16:45:25 +08:00
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '数据治理',
|
|
|
|
|
|
items: [
|
2026-07-14 16:10:50 +08:00
|
|
|
|
{ key: 'dataset', label: '数据集管理', icon: 'fa-file-text', to: '/dataset', permission: 'dataset' },
|
|
|
|
|
|
{ key: 'data-process', label: '数据处理', icon: 'fa-filter', to: '/data-process', permission: 'data-process' },
|
2026-07-10 16:45:25 +08:00
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '其他工具',
|
2026-07-14 16:10:50 +08:00
|
|
|
|
items: [{ key: 'data-convert', label: '数据类型转换', icon: 'fa-exchange', to: '/data-convert', permission: 'data-convert' }],
|
2026-07-10 16:45:25 +08:00
|
|
|
|
},
|
2026-07-21 09:23:43 +08:00
|
|
|
|
{
|
|
|
|
|
|
title: '算力资源',
|
|
|
|
|
|
items: [
|
|
|
|
|
|
{ key: 'compute', label: '算力节点', icon: 'fa-microchip', to: '/compute', permission: 'compute' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
2026-08-03 09:34:08 +08:00
|
|
|
|
{
|
|
|
|
|
|
title: '平台治理',
|
|
|
|
|
|
items: [
|
|
|
|
|
|
{ key: 'tenants', label: '租户管理', icon: 'fa-building', to: '/tenants', permission: 'user-settings' },
|
|
|
|
|
|
{ key: 'projects', label: '项目空间', icon: 'fa-folder', to: '/projects', permission: 'user-settings' },
|
2026-08-17 09:15:36 +08:00
|
|
|
|
{ key: 'resource-acl', label: '资源授权', icon: 'fa-key', to: '/resource-acl', permission: 'user-settings' },
|
2026-08-03 09:34:08 +08:00
|
|
|
|
{ key: 'audit-logs', label: '审计日志', icon: 'fa-history', to: '/audit-logs', permission: 'user-settings' },
|
2026-08-18 14:49:12 +08:00
|
|
|
|
{ key: 'operation-logs', label: '操作日志', icon: 'fa-list', to: '/operation-logs', permission: 'user-settings' },
|
2026-08-03 09:34:08 +08:00
|
|
|
|
{ key: 'approval-templates', label: '审批模板', icon: 'fa-list-alt', to: '/approval-templates', permission: 'user-settings' },
|
|
|
|
|
|
{ key: 'approval-instances', label: '审批中心', icon: 'fa-check-square', to: '/approval-instances', permission: 'user-settings' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
2026-07-10 16:45:25 +08:00
|
|
|
|
{
|
|
|
|
|
|
title: '系统设置',
|
|
|
|
|
|
items: [
|
2026-07-14 16:10:50 +08:00
|
|
|
|
{ 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' },
|
2026-07-10 16:45:25 +08:00
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-08-17 09:15:36 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 菜单可见性过滤规则(基于 governance-user-guide.md 设计):
|
|
|
|
|
|
*
|
|
|
|
|
|
* 1. admin 用户:可以看到所有菜单
|
|
|
|
|
|
* 2. 非 admin 用户:
|
|
|
|
|
|
* - 默认可见所有业务菜单(模型训练、评测、推理、数据集、数据处理、转换、性能、日志等)
|
|
|
|
|
|
* - 仅以下菜单对非 admin 不可见:
|
|
|
|
|
|
* - user-settings(用户设置、租户管理、项目空间、审批模板/中心、审计日志)
|
|
|
|
|
|
* - compute(算力节点/GPU 分配)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 注意:移除了旧的权限码(permission code)过滤逻辑,
|
|
|
|
|
|
* 非管理员用户默认拥有所有业务功能的访问权限。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const ADMIN_ONLY_PERMISSIONS: PermissionCode[] = ['user-settings', 'compute']
|
|
|
|
|
|
|
2026-07-14 16:10:50 +08:00
|
|
|
|
const visibleMenuGroups = computed(() =>
|
|
|
|
|
|
menuGroups
|
|
|
|
|
|
.map((group) => ({
|
|
|
|
|
|
...group,
|
2026-08-10 11:41:16 +08:00
|
|
|
|
items: group.items.filter((item) => {
|
2026-08-17 09:15:36 +08:00
|
|
|
|
// admin 可以看到所有菜单
|
|
|
|
|
|
if (auth.isAdmin) return true
|
|
|
|
|
|
// 非 admin 用户:仅隐藏管理员专属菜单
|
|
|
|
|
|
return !ADMIN_ONLY_PERMISSIONS.includes(item.permission)
|
2026-08-10 11:41:16 +08:00
|
|
|
|
}),
|
2026-07-14 16:10:50 +08:00
|
|
|
|
}))
|
|
|
|
|
|
.filter((group) => group.items.length > 0),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-11 14:49:37 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 传给 el-menu 的激活值。
|
|
|
|
|
|
*
|
|
|
|
|
|
* el-menu 在点击菜单项时会立即把内部高亮移到被点击项,再触发 @select。
|
|
|
|
|
|
* 若随后 router.push 被路由守卫拦截(如未保存草稿确认),route.path 不变,
|
|
|
|
|
|
* activeMenu 也不会变化,导致 el-menu 无法把高亮校正回当前路由对应的项。
|
|
|
|
|
|
* 这里用一个可写 ref 包裹,导航失败时主动触发一次「置空 → 还原」,
|
|
|
|
|
|
* 强制 el-menu 重新对齐到真实激活项。
|
|
|
|
|
|
*/
|
|
|
|
|
|
const menuActive = ref(activeMenu.value)
|
|
|
|
|
|
|
|
|
|
|
|
watch(activeMenu, (value) => {
|
|
|
|
|
|
menuActive.value = value
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
async function handleSelect(key: string) {
|
2026-07-14 16:10:50 +08:00
|
|
|
|
const all = visibleMenuGroups.value.flatMap((g) => g.items)
|
2026-07-10 16:45:25 +08:00
|
|
|
|
const target = all.find((i) => i.key === key)
|
2026-07-11 14:49:37 +08:00
|
|
|
|
if (!target) return
|
|
|
|
|
|
|
|
|
|
|
|
const failure = await router.push(target.to)
|
|
|
|
|
|
// 导航被守卫取消(如未保存修改确认)时,把高亮校正回当前路由
|
|
|
|
|
|
if (failure) {
|
|
|
|
|
|
menuActive.value = ''
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
menuActive.value = activeMenu.value
|
|
|
|
|
|
}
|
2026-07-10 16:45:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 16:20:21 +08:00
|
|
|
|
async function handleLogout() {
|
|
|
|
|
|
await auth.logout()
|
2026-07-10 16:45:25 +08:00
|
|
|
|
router.push('/login')
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<aside class="app-sidebar">
|
|
|
|
|
|
<!-- 平台 LOGO -->
|
|
|
|
|
|
<div class="sidebar-logo">
|
|
|
|
|
|
<img src="/logo.png" alt="Logo" class="sidebar-logo-img" />
|
|
|
|
|
|
<span class="logo-text">远光软件微调平台</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 导航 -->
|
|
|
|
|
|
<el-scrollbar class="sidebar-nav" wrap-class="nav-scrollbar-wrap">
|
|
|
|
|
|
<el-menu
|
2026-07-11 14:49:37 +08:00
|
|
|
|
:default-active="menuActive"
|
2026-07-10 16:45:25 +08:00
|
|
|
|
class="sidebar-menu"
|
|
|
|
|
|
background-color="transparent"
|
|
|
|
|
|
text-color="var(--sidebar-text)"
|
|
|
|
|
|
active-text-color="var(--sidebar-active-text)"
|
|
|
|
|
|
@select="handleSelect"
|
|
|
|
|
|
>
|
2026-07-14 16:10:50 +08:00
|
|
|
|
<template v-for="group in visibleMenuGroups" :key="group.title || group.items[0].key">
|
2026-07-10 16:45:25 +08:00
|
|
|
|
<div v-if="group.title" class="sidebar-section-title">{{ group.title }}</div>
|
|
|
|
|
|
<el-menu-item
|
|
|
|
|
|
v-for="item in group.items"
|
|
|
|
|
|
:key="item.key"
|
|
|
|
|
|
:index="item.key"
|
|
|
|
|
|
>
|
|
|
|
|
|
<i class="fa" :class="item.icon" />
|
|
|
|
|
|
<span>{{ item.label }}</span>
|
|
|
|
|
|
</el-menu-item>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</el-menu>
|
|
|
|
|
|
</el-scrollbar>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 底部信息 -->
|
|
|
|
|
|
<div class="sidebar-footer">
|
|
|
|
|
|
<div class="footer-user" v-if="auth.username">
|
2026-08-11 16:34:06 +08:00
|
|
|
|
<el-avatar :size="36" class="user-avatar">{{ auth.displayName?.slice(0, 1) || '?' }}</el-avatar>
|
2026-07-10 16:45:25 +08:00
|
|
|
|
<div class="user-info">
|
2026-07-14 16:10:50 +08:00
|
|
|
|
<span class="user-name">{{ auth.displayName }}</span>
|
|
|
|
|
|
<span class="user-role">{{ auth.roleLabel }}</span>
|
2026-07-10 16:45:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<el-tooltip content="退出登录" placement="top">
|
|
|
|
|
|
<div class="logout-btn" @click="handleLogout">
|
|
|
|
|
|
<i class="fa fa-sign-out" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</el-tooltip>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.app-sidebar {
|
|
|
|
|
|
width: 240px;
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
height: 100vh;
|
|
|
|
|
|
background-color: var(--sidebar-bg);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
color: var(--sidebar-text);
|
|
|
|
|
|
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.02);
|
|
|
|
|
|
z-index: 100;
|
|
|
|
|
|
border-right: 1px solid #e2e8f0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sidebar-logo {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 24px 20px;
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
|
|
|
|
|
|
.sidebar-logo-img {
|
|
|
|
|
|
width: 36px;
|
|
|
|
|
|
height: 36px;
|
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
|
margin-right: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.logo-text {
|
|
|
|
|
|
color: #334155;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
letter-spacing: 0.5px;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sidebar-nav {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-height: 0;
|
|
|
|
|
|
padding: 0 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.nav-scrollbar-wrap) {
|
|
|
|
|
|
overflow-x: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sidebar-menu {
|
|
|
|
|
|
border-right: none;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
|
|
|
|
|
|
:deep(.el-menu-item) {
|
|
|
|
|
|
height: 46px;
|
|
|
|
|
|
line-height: 46px;
|
|
|
|
|
|
margin-bottom: 4px;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
padding: 0 16px !important;
|
|
|
|
|
|
transition: all 0.3s ease;
|
|
|
|
|
|
|
|
|
|
|
|
.fa {
|
|
|
|
|
|
width: 20px;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
margin-right: 10px;
|
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
|
transition: transform 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
background-color: rgba(0, 0, 0, 0.03) !important;
|
|
|
|
|
|
color: var(--sidebar-active-text) !important;
|
|
|
|
|
|
|
|
|
|
|
|
.fa {
|
|
|
|
|
|
transform: scale(1.1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.is-active {
|
|
|
|
|
|
background-color: var(--sidebar-active-bg) !important;
|
|
|
|
|
|
color: var(--sidebar-active-text) !important;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
|
|
|
|
|
|
.fa {
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sidebar-section-title {
|
|
|
|
|
|
padding: 16px 16px 8px;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
color: #64748b;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 1px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sidebar-footer {
|
|
|
|
|
|
padding: 20px;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
border-top: 1px solid #e2e8f0;
|
|
|
|
|
|
|
|
|
|
|
|
.footer-user {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
|
padding: 10px;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
border: 1px solid #e2e8f0;
|
|
|
|
|
|
|
|
|
|
|
|
.user-avatar {
|
|
|
|
|
|
border: 2px solid #e2e8f0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-info {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
margin-left: 12px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
.user-name {
|
|
|
|
|
|
color: #334155;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.user-role {
|
|
|
|
|
|
color: #64748b;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.logout-btn {
|
|
|
|
|
|
width: 32px;
|
|
|
|
|
|
height: 32px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
color: #94a3b8;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
|
|
|
|
|
|
&:hover {
|
|
|
|
|
|
background: rgba(239, 68, 68, 0.1);
|
|
|
|
|
|
color: var(--danger-color);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|