mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ui,router): gate routes and sidebar by workspace capability
This commit is contained in:
parent
aa27853712
commit
777e2ecee2
37
mateclaw-ui/src/composables/useNavItems.ts
Normal file
37
mateclaw-ui/src/composables/useNavItems.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { computed } from 'vue'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
import type { Capability } from '@/composables/capabilities'
|
||||
|
||||
/**
|
||||
* Shared sidebar/nav filtering. The three layouts (Main / Settings / Security)
|
||||
* all call this with their own scope so they cannot drift in which menu items
|
||||
* a given role sees. Items with no `requiredCapability` are visible to anyone
|
||||
* authenticated; items requiring `globalAdmin: true` only show for global
|
||||
* admins regardless of workspace role.
|
||||
*/
|
||||
export interface NavItem {
|
||||
/** Vue Router path (absolute) — used for both `:to` and active match. */
|
||||
path: string
|
||||
/** i18n key or literal label rendered in the menu. */
|
||||
label: string
|
||||
/** Lucide / pixel icon name or symbol. Layout decides how to render. */
|
||||
icon?: string
|
||||
/** Required workspace capability; absent means "any authenticated user". */
|
||||
requiredCapability?: Capability
|
||||
/** When true, only shows for global admins (mate_user.role='admin'). */
|
||||
globalAdmin?: boolean
|
||||
}
|
||||
|
||||
export function useNavItems(items: NavItem[]) {
|
||||
const store = useWorkspaceStore()
|
||||
return computed(() => {
|
||||
// Default deny while capabilities are still loading — render nothing
|
||||
// rather than flashing the full menu and snapping it back.
|
||||
if (!store.accessLoaded) return []
|
||||
return items.filter((item) => {
|
||||
if (item.globalAdmin) return store.isGlobalAdmin
|
||||
if (item.requiredCapability && !store.can(item.requiredCapability)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
}
|
||||
@ -3382,4 +3382,11 @@ export default {
|
||||
listAvailableSkills: 'List Skills',
|
||||
readSkillFile: 'Read Skill File',
|
||||
},
|
||||
forbidden: {
|
||||
title: 'Access denied',
|
||||
message: 'Your role in this workspace does not include this page.',
|
||||
currentRole: 'Current role: {role}',
|
||||
goChat: 'Go to chat',
|
||||
goBack: 'Go back',
|
||||
},
|
||||
} as const
|
||||
|
||||
@ -3474,4 +3474,11 @@ export default {
|
||||
listAvailableSkills: '列出可用技能',
|
||||
readSkillFile: '读取技能文件',
|
||||
},
|
||||
forbidden: {
|
||||
title: '无权访问',
|
||||
message: '当前工作区中你的角色无法访问该页面。',
|
||||
currentRole: '当前角色:{role}',
|
||||
goChat: '返回对话',
|
||||
goBack: '返回上一页',
|
||||
},
|
||||
} as const
|
||||
|
||||
@ -1,4 +1,16 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { Capability } from '@/composables/capabilities'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
|
||||
// Augment vue-router's RouteMeta so each route can declare its capability gate.
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
keepAlive?: boolean
|
||||
requireAdmin?: boolean
|
||||
requiredCapability?: Capability
|
||||
}
|
||||
}
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@ -13,19 +25,19 @@ const router = createRouter({
|
||||
path: 'chat',
|
||||
name: 'Chat',
|
||||
component: () => import('@/views/ChatConsole.vue'),
|
||||
meta: { title: 'Chat' },
|
||||
meta: { title: 'Chat', requiredCapability: 'chat' },
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/Dashboard.vue'),
|
||||
meta: { title: 'Dashboard' },
|
||||
meta: { title: 'Dashboard', requiredCapability: 'view:dashboard' },
|
||||
},
|
||||
{
|
||||
path: 'agents',
|
||||
name: 'Agents',
|
||||
component: () => import('@/views/Agents.vue'),
|
||||
meta: { title: 'Agents' },
|
||||
meta: { title: 'Agents', requiredCapability: 'manage:agents' },
|
||||
},
|
||||
{
|
||||
path: 'backstage',
|
||||
@ -37,19 +49,19 @@ const router = createRouter({
|
||||
path: 'wiki',
|
||||
name: 'Wiki',
|
||||
component: () => import('@/views/Wiki/index.vue'),
|
||||
meta: { title: 'Wiki' },
|
||||
meta: { title: 'Wiki', requiredCapability: 'view:wiki' },
|
||||
},
|
||||
{
|
||||
path: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
component: () => import('@/views/Enterprise/index.vue'),
|
||||
meta: { title: 'Enterprise Scenarios' },
|
||||
meta: { title: 'Enterprise Scenarios', requiredCapability: 'manage:agents' },
|
||||
},
|
||||
{
|
||||
path: 'memory',
|
||||
name: 'Memory',
|
||||
component: () => import('@/views/Memory/index.vue'),
|
||||
meta: { title: 'Memory' },
|
||||
meta: { title: 'Memory', requiredCapability: 'view:memory' },
|
||||
},
|
||||
// ==================== Connect ====================
|
||||
{
|
||||
@ -59,13 +71,13 @@ const router = createRouter({
|
||||
// keepAlive: cache the component instance so navigating away and
|
||||
// back doesn't re-mount + re-fetch the list. Channels.vue must
|
||||
// pause polling in onDeactivated to avoid a leaked timer.
|
||||
meta: { title: 'Channels', keepAlive: true },
|
||||
meta: { title: 'Channels', keepAlive: true, requiredCapability: 'manage:channels' },
|
||||
},
|
||||
{
|
||||
path: 'skills',
|
||||
name: 'Skills',
|
||||
component: () => import('@/views/SkillMarket.vue'),
|
||||
meta: { title: 'Skills' },
|
||||
meta: { title: 'Skills', requiredCapability: 'manage:skills' },
|
||||
},
|
||||
// Tools 顶层入口已降级到 Settings ▸ Tools (Catalog) (RFC-090 Phase 1)
|
||||
// 旧路径 /tools 由下方 redirect 兼容
|
||||
@ -73,20 +85,20 @@ const router = createRouter({
|
||||
path: 'activity',
|
||||
name: 'Activity',
|
||||
component: () => import('@/views/Security/Activity/index.vue'),
|
||||
meta: { title: 'Activity' },
|
||||
meta: { title: 'Activity', requiredCapability: 'manage:security' },
|
||||
},
|
||||
// RFC-091: Skill 模板库 + 创作向导
|
||||
{
|
||||
path: 'skills/templates',
|
||||
name: 'SkillTemplates',
|
||||
component: () => import('@/views/SkillTemplates.vue'),
|
||||
meta: { title: 'Skill Templates' },
|
||||
meta: { title: 'Skill Templates', requiredCapability: 'manage:skills' },
|
||||
},
|
||||
{
|
||||
path: 'plugins',
|
||||
name: 'Plugins',
|
||||
component: () => import('@/views/Plugins.vue'),
|
||||
meta: { title: 'Plugins' },
|
||||
meta: { title: 'Plugins', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
// ==================== Settings (absorbs advanced pages) ====================
|
||||
{
|
||||
@ -98,62 +110,62 @@ const router = createRouter({
|
||||
path: 'models',
|
||||
name: 'SettingsModels',
|
||||
component: () => import('@/views/Settings/Models/index.vue'),
|
||||
meta: { title: 'Settings - Models' },
|
||||
meta: { title: 'Settings - Models', requiredCapability: 'manage:models' },
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
name: 'SettingsSystem',
|
||||
component: () => import('@/views/Settings/System/index.vue'),
|
||||
meta: { title: 'Settings - System' },
|
||||
meta: { title: 'Settings - System', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
{
|
||||
path: 'image',
|
||||
name: 'SettingsImage',
|
||||
component: () => import('@/views/Settings/Image/index.vue'),
|
||||
meta: { title: 'Settings - Image' },
|
||||
meta: { title: 'Settings - Image', requiredCapability: 'manage:models' },
|
||||
},
|
||||
{
|
||||
path: 'tts',
|
||||
name: 'SettingsTts',
|
||||
component: () => import('@/views/Settings/Tts/index.vue'),
|
||||
meta: { title: 'Settings - TTS' },
|
||||
meta: { title: 'Settings - TTS', requiredCapability: 'manage:models' },
|
||||
},
|
||||
{
|
||||
path: 'stt',
|
||||
name: 'SettingsStt',
|
||||
component: () => import('@/views/Settings/Stt/index.vue'),
|
||||
meta: { title: 'Settings - STT' },
|
||||
meta: { title: 'Settings - STT', requiredCapability: 'manage:models' },
|
||||
},
|
||||
{
|
||||
path: 'music',
|
||||
name: 'SettingsMusic',
|
||||
component: () => import('@/views/Settings/Music/index.vue'),
|
||||
meta: { title: 'Settings - Music' },
|
||||
meta: { title: 'Settings - Music', requiredCapability: 'manage:models' },
|
||||
},
|
||||
{
|
||||
path: 'video',
|
||||
name: 'SettingsVideo',
|
||||
component: () => import('@/views/Settings/Video/index.vue'),
|
||||
meta: { title: 'Settings - Video' },
|
||||
meta: { title: 'Settings - Video', requiredCapability: 'manage:models' },
|
||||
},
|
||||
{
|
||||
path: 'model3d',
|
||||
name: 'SettingsModel3D',
|
||||
component: () => import('@/views/Settings/Model3D/index.vue'),
|
||||
meta: { title: 'Settings - 3D Model' },
|
||||
meta: { title: 'Settings - 3D Model', requiredCapability: 'manage:models' },
|
||||
},
|
||||
// Workspace management
|
||||
{
|
||||
path: 'workspaces',
|
||||
name: 'SettingsWorkspaces',
|
||||
component: () => import('@/views/Security/Workspaces/index.vue'),
|
||||
meta: { title: 'Settings - Workspaces' },
|
||||
meta: { title: 'Settings - Workspaces', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
{
|
||||
path: 'members',
|
||||
name: 'SettingsMembers',
|
||||
component: () => import('@/views/Security/Members/index.vue'),
|
||||
meta: { title: 'Settings - Members' },
|
||||
meta: { title: 'Settings - Members', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
// RFC-090 Phase 4: Activity 提升到顶层 /activity(下方 children-out
|
||||
// 的 settings/activity redirect 兼容旧链接,此处不再注册子路由)
|
||||
@ -162,62 +174,62 @@ const router = createRouter({
|
||||
path: 'agent-context',
|
||||
name: 'SettingsAgentContext',
|
||||
component: () => import('@/views/AgentContext.vue'),
|
||||
meta: { title: 'Settings - Agent Context' },
|
||||
meta: { title: 'Settings - Agent Context', requiredCapability: 'manage:agents' },
|
||||
},
|
||||
{
|
||||
path: 'cron-jobs',
|
||||
name: 'SettingsCronJobs',
|
||||
component: () => import('@/views/CronJobs.vue'),
|
||||
meta: { title: 'Settings - Cron Jobs' },
|
||||
meta: { title: 'Settings - Cron Jobs', requiredCapability: 'manage:agents' },
|
||||
},
|
||||
{
|
||||
path: 'workflows',
|
||||
name: 'SettingsWorkflows',
|
||||
component: () => import('@/views/Workflows.vue'),
|
||||
meta: { title: 'Settings - Workflows' },
|
||||
meta: { title: 'Settings - Workflows', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
{
|
||||
path: 'triggers',
|
||||
name: 'SettingsTriggers',
|
||||
component: () => import('@/views/Triggers.vue'),
|
||||
meta: { title: 'Settings - Triggers' },
|
||||
meta: { title: 'Settings - Triggers', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
{
|
||||
path: 'datasources',
|
||||
name: 'SettingsDatasources',
|
||||
component: () => import('@/views/Datasources.vue'),
|
||||
meta: { title: 'Settings - Datasources' },
|
||||
meta: { title: 'Settings - Datasources', requiredCapability: 'manage:models' },
|
||||
},
|
||||
{
|
||||
path: 'mcp-servers',
|
||||
name: 'SettingsMcpServers',
|
||||
component: () => import('@/views/McpServers.vue'),
|
||||
meta: { title: 'Settings - MCP Connections' },
|
||||
meta: { title: 'Settings - MCP Connections', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
{
|
||||
path: 'tools',
|
||||
name: 'SettingsTools',
|
||||
component: () => import('@/views/Tools.vue'),
|
||||
meta: { title: 'Settings - Tools Catalog' },
|
||||
meta: { title: 'Settings - Tools Catalog', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
// RFC-090 Phase 7: ACP endpoints (External coding agents)
|
||||
{
|
||||
path: 'acp',
|
||||
name: 'SettingsAcpEndpoints',
|
||||
component: () => import('@/views/AcpEndpoints.vue'),
|
||||
meta: { title: 'Settings - ACP Endpoints' },
|
||||
meta: { title: 'Settings - ACP Endpoints', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
{
|
||||
path: 'token-usage',
|
||||
name: 'SettingsTokenUsage',
|
||||
component: () => import('@/views/TokenUsage.vue'),
|
||||
meta: { title: 'Settings - Token Usage' },
|
||||
meta: { title: 'Settings - Token Usage', requiredCapability: 'view:dashboard' },
|
||||
},
|
||||
{
|
||||
path: 'feature-flags',
|
||||
name: 'SettingsFeatureFlags',
|
||||
component: () => import('@/views/Settings/FeatureFlags/index.vue'),
|
||||
meta: { title: 'Settings - Feature Flags' },
|
||||
meta: { title: 'Settings - Feature Flags', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
@ -237,22 +249,29 @@ const router = createRouter({
|
||||
path: 'tool-guard',
|
||||
name: 'SecurityToolGuard',
|
||||
component: () => import('@/views/Security/ToolGuard/index.vue'),
|
||||
meta: { title: 'Security - Tool Guard' },
|
||||
meta: { title: 'Security - Tool Guard', requiredCapability: 'manage:security' },
|
||||
},
|
||||
{
|
||||
path: 'file-guard',
|
||||
name: 'SecurityFileGuard',
|
||||
component: () => import('@/views/Security/FileGuard/index.vue'),
|
||||
meta: { title: 'Security - File Guard' },
|
||||
meta: { title: 'Security - File Guard', requiredCapability: 'manage:security' },
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
name: 'SecurityAuditLogs',
|
||||
component: () => import('@/views/Security/AuditLogs/index.vue'),
|
||||
meta: { title: 'Security - Audit Logs' },
|
||||
meta: { title: 'Security - Audit Logs', requiredCapability: 'manage:security' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// ==================== Forbidden ====================
|
||||
{
|
||||
path: 'forbidden',
|
||||
name: 'Forbidden',
|
||||
component: () => import('@/views/Forbidden.vue'),
|
||||
meta: { title: 'Forbidden' },
|
||||
},
|
||||
// ==================== Redirects (backward compatibility) ====================
|
||||
{ path: 'sessions', redirect: '/chat' },
|
||||
{ path: 'workspace', redirect: '/settings/agent-context' },
|
||||
@ -281,21 +300,37 @@ const router = createRouter({
|
||||
],
|
||||
})
|
||||
|
||||
// 路由守卫:未登录跳转到登录页(开发环境可通过 VITE_SKIP_AUTH=true 跳过)
|
||||
router.beforeEach((to, _from, next) => {
|
||||
if (import.meta.env.VITE_SKIP_AUTH === 'true') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
// Auth + capability guard. Order matters: bail to /login before we touch the
|
||||
// workspace store, and never let an uninitialized capability set fall through
|
||||
// to a protected route (the store enforces default-deny while accessLoaded is
|
||||
// false; we await refreshAccess so the decision is made on real data).
|
||||
router.beforeEach(async (to) => {
|
||||
if (import.meta.env.VITE_SKIP_AUTH === 'true') return true
|
||||
const token = localStorage.getItem('token')
|
||||
if (to.name === 'Login' && token) {
|
||||
// Already logged in — skip login page
|
||||
next({ path: '/' })
|
||||
} else if (to.name !== 'Login' && !token) {
|
||||
next({ name: 'Login' })
|
||||
} else {
|
||||
next()
|
||||
|
||||
if (to.name === 'Login' && token) return { path: '/' }
|
||||
if (to.name !== 'Login' && !token) return { name: 'Login' }
|
||||
if (to.name === 'Login' || to.name === 'Forbidden') return true
|
||||
|
||||
const store = useWorkspaceStore()
|
||||
if (!store.accessLoaded) {
|
||||
if (!store.workspaces.length) {
|
||||
await store.fetchWorkspaces()
|
||||
} else {
|
||||
await store.refreshAccess()
|
||||
}
|
||||
}
|
||||
|
||||
const requireAdmin = to.meta.requireAdmin === true
|
||||
if (requireAdmin && !store.isGlobalAdmin) {
|
||||
return store.can('chat') ? { path: '/chat' } : { path: '/forbidden' }
|
||||
}
|
||||
|
||||
const required = to.meta.requiredCapability
|
||||
if (required && !store.can(required)) {
|
||||
return store.can('chat') ? { path: '/chat' } : { path: '/forbidden' }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
107
mateclaw-ui/src/views/Forbidden.vue
Normal file
107
mateclaw-ui/src/views/Forbidden.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="forbidden-page">
|
||||
<div class="forbidden-card">
|
||||
<div class="forbidden-icon">🚫</div>
|
||||
<h1 class="forbidden-title">{{ t('forbidden.title') }}</h1>
|
||||
<p class="forbidden-message">{{ t('forbidden.message') }}</p>
|
||||
<p v-if="role" class="forbidden-meta">
|
||||
{{ t('forbidden.currentRole', { role }) }}
|
||||
</p>
|
||||
<div class="forbidden-actions">
|
||||
<button class="btn-primary" @click="goHome">{{ t('forbidden.goChat') }}</button>
|
||||
<button class="btn-secondary" @click="goBack">{{ t('forbidden.goBack') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const store = useWorkspaceStore()
|
||||
|
||||
const role = computed(() => store.currentRole || '')
|
||||
|
||||
function goHome() {
|
||||
router.replace('/chat')
|
||||
}
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.replace('/chat')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.forbidden-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.forbidden-card {
|
||||
max-width: 480px;
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
background: var(--el-bg-color, #fff);
|
||||
border: 1px solid var(--el-border-color, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.forbidden-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.forbidden-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.forbidden-message {
|
||||
color: var(--el-text-color-regular);
|
||||
margin: 0 0 0.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.forbidden-meta {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.forbidden-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--el-color-primary, #3b82f6);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border-color: var(--el-border-color, #e5e7eb);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.btn-primary:hover,
|
||||
.btn-secondary:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
@ -317,93 +317,140 @@ const localeOptions = computed<{ value: AppLocale; label: string; short: string
|
||||
{ value: 'en-US', label: t('settings.languageOptions.enUS'), short: 'EN' },
|
||||
])
|
||||
|
||||
// Capability-gated nav. Each item declares a capability or globalAdmin flag;
|
||||
// useWorkspaceStore.can() decides visibility from the backend access set so
|
||||
// the sidebar can't drift from the route guard or controller annotations.
|
||||
type NavItem = {
|
||||
path: string
|
||||
label: string
|
||||
icon: string
|
||||
tooltip?: string
|
||||
requiredCapability?:
|
||||
| 'chat'
|
||||
| 'view:wiki'
|
||||
| 'view:memory'
|
||||
| 'view:dashboard'
|
||||
| 'manage:wiki'
|
||||
| 'manage:agents'
|
||||
| 'manage:skills'
|
||||
| 'manage:channels'
|
||||
| 'manage:models'
|
||||
| 'manage:security'
|
||||
| 'manage:settings'
|
||||
globalAdmin?: boolean
|
||||
}
|
||||
|
||||
function filterNav(items: NavItem[]): NavItem[] {
|
||||
// Default deny while access is still loading — render an empty group rather
|
||||
// than flashing the full menu before refreshAccess() returns.
|
||||
if (!workspaceStore.accessLoaded) return []
|
||||
return items.filter((item) => {
|
||||
if (item.globalAdmin) return workspaceStore.isGlobalAdmin
|
||||
if (item.requiredCapability && !workspaceStore.can(item.requiredCapability as never)) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const navGroups = computed(() => [
|
||||
{
|
||||
key: 'core',
|
||||
label: t('nav.core'),
|
||||
items: [
|
||||
items: filterNav([
|
||||
{
|
||||
path: '/dashboard',
|
||||
label: t('nav.dashboard', 'Dashboard'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`,
|
||||
requiredCapability: 'view:dashboard',
|
||||
},
|
||||
{
|
||||
path: '/chat',
|
||||
label: t('nav.chat'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
|
||||
requiredCapability: 'chat',
|
||||
},
|
||||
{
|
||||
path: '/agents',
|
||||
label: t('nav.agents'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`,
|
||||
requiredCapability: 'manage:agents',
|
||||
},
|
||||
...(isAdminRole.value ? [{
|
||||
{
|
||||
path: '/backstage',
|
||||
label: t('nav.backstage'),
|
||||
tooltip: t('nav.backstageTooltip'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>`,
|
||||
}] : []),
|
||||
globalAdmin: true,
|
||||
},
|
||||
{
|
||||
path: '/wiki',
|
||||
label: t('nav.wiki'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
|
||||
requiredCapability: 'view:wiki',
|
||||
},
|
||||
{
|
||||
path: '/memory',
|
||||
label: t('nav.memory'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/><path d="M16 14H8a4 4 0 0 0-4 4v2h16v-2a4 4 0 0 0-4-4z"/><line x1="12" y1="11" x2="12" y2="14"/></svg>`,
|
||||
requiredCapability: 'view:memory',
|
||||
},
|
||||
{
|
||||
path: '/enterprise',
|
||||
label: t('nav.enterprise'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V7l7-4 7 4v14"/><path d="M9 9h.01"/><path d="M9 12h.01"/><path d="M9 15h.01"/><path d="M9 18h.01"/><path d="M15 9h.01"/><path d="M15 12h.01"/><path d="M15 15h.01"/><path d="M15 18h.01"/></svg>`,
|
||||
requiredCapability: 'manage:agents',
|
||||
},
|
||||
],
|
||||
] as NavItem[]),
|
||||
},
|
||||
{
|
||||
key: 'connect',
|
||||
label: t('nav.connect'),
|
||||
items: [
|
||||
items: filterNav([
|
||||
{
|
||||
path: '/channels',
|
||||
label: t('nav.channels'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 1.18h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.73a16 16 0 0 0 6.29 6.29l1.62-1.62a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>`,
|
||||
requiredCapability: 'manage:channels',
|
||||
},
|
||||
{
|
||||
path: '/skills',
|
||||
label: t('nav.skills'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
|
||||
requiredCapability: 'manage:skills',
|
||||
},
|
||||
{
|
||||
path: '/plugins',
|
||||
label: t('nav.plugins'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2" ry="2"/><path d="M16 3h-8v4h8V3z"/></svg>`,
|
||||
requiredCapability: 'manage:settings',
|
||||
},
|
||||
// RFC-090 Phase 4: Activity 提升到顶层
|
||||
{
|
||||
path: '/activity',
|
||||
label: t('nav.activity'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`,
|
||||
requiredCapability: 'manage:security',
|
||||
},
|
||||
],
|
||||
] as NavItem[]),
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
label: t('nav.system'),
|
||||
items: [
|
||||
items: filterNav([
|
||||
{
|
||||
path: '/settings/models',
|
||||
label: t('nav.settings'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/></svg>`,
|
||||
requiredCapability: 'manage:models',
|
||||
},
|
||||
{
|
||||
path: '/security',
|
||||
label: t('nav.security'),
|
||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
|
||||
requiredCapability: 'manage:security',
|
||||
},
|
||||
],
|
||||
] as NavItem[]),
|
||||
},
|
||||
])
|
||||
].filter((group) => group.items.length > 0))
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
|
||||
Loading…
Reference in New Issue
Block a user