Files
YG_FT/frontend/src/components/AppSidebar.vue
2026-07-27 09:12:47 +08:00

348 lines
9.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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()
const auth = useAuthStore()
/** 当前激活菜单 key取路由路径第一段如 /fine-tune/create → fine-tune */
const activeMenu = computed(() => {
const seg = route.path.split('/')[1] || 'dashboard'
// 训练日志归到模型调优
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'
// 审批模板独立高亮
if (route.path.startsWith('/approvals/templates')) return 'approval-templates'
return seg
})
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', permission: 'dashboard' }],
},
{
title: '模型服务',
items: [
{ 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', 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', permission: 'data-convert' }],
},
{
title: '算力资源',
items: [
{ key: 'compute', label: '算力节点', icon: 'fa-microchip', to: '/compute', permission: 'compute' },
],
},
{
title: '平台治理',
items: [
{ key: 'tenants', label: '租户管理', icon: 'fa-building', to: '/tenants', permission: 'user-settings' },
{ key: 'projects', label: '项目空间', icon: 'fa-folder-open', to: '/projects', permission: 'user-settings' },
{ key: 'audit-logs', label: '审计日志', icon: 'fa-history', to: '/audit-logs', permission: 'user-settings' },
{ key: 'approval-templates', label: '审批模板', icon: 'fa-list-alt', to: '/approvals/templates', permission: 'user-settings' },
{ key: 'approvals', label: '审批中心', icon: 'fa-check-square', to: '/approvals', permission: 'user-settings' },
],
},
{
title: '系统设置',
items: [
{ 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 的激活值。
*
* 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) {
const all = visibleMenuGroups.value.flatMap((g) => g.items)
const target = all.find((i) => i.key === key)
if (!target) return
const failure = await router.push(target.to)
// 导航被守卫取消(如未保存修改确认)时,把高亮校正回当前路由
if (failure) {
menuActive.value = ''
await nextTick()
menuActive.value = activeMenu.value
}
}
function handleLogout() {
auth.logout()
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
:default-active="menuActive"
class="sidebar-menu"
background-color="transparent"
text-color="var(--sidebar-text)"
active-text-color="var(--sidebar-active-text)"
@select="handleSelect"
>
<template v-for="group in visibleMenuGroups" :key="group.title || group.items[0].key">
<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">
<el-avatar :size="36" src="https://picsum.photos/id/1005/36/36" class="user-avatar" />
<div class="user-info">
<span class="user-name">{{ auth.displayName }}</span>
<span class="user-role">{{ auth.roleLabel }}</span>
</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>