通用中后台框架第一版
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<n-breadcrumb>
|
||||
<n-breadcrumb-item v-for="item in crumbs" :key="item.path">
|
||||
{{ item.title }}
|
||||
</n-breadcrumb-item>
|
||||
</n-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const crumbs = computed(() =>
|
||||
route.matched
|
||||
.filter((item) => item.meta?.title)
|
||||
.map((item) => ({
|
||||
path: item.path,
|
||||
title: item.meta.title as string
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<n-menu
|
||||
:collapsed="authStore.layoutCollapsed"
|
||||
:collapsed-width="72"
|
||||
:collapsed-icon-size="18"
|
||||
:options="menuOptions"
|
||||
:value="activePath"
|
||||
:indent="18"
|
||||
@update:value="handleSelect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, type Component } from 'vue'
|
||||
import { NIcon, type MenuOption } from 'naive-ui'
|
||||
import * as LucideIcons from 'lucide-vue-next'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { MenuNode } from '@/types/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
function renderIcon(icon?: string | null) {
|
||||
if (!icon) return undefined
|
||||
const IconComp = LucideIcons[icon as keyof typeof LucideIcons] as unknown as Component | undefined
|
||||
if (!IconComp) return undefined
|
||||
return () => h(NIcon, null, { default: () => h(IconComp) })
|
||||
}
|
||||
|
||||
function toOption(menu: MenuNode): MenuOption | null {
|
||||
if (!menu.visible) return null
|
||||
if (menu.type === 'BUTTON') return null
|
||||
|
||||
const children = (menu.children ?? [])
|
||||
.map((item) => toOption(item))
|
||||
.filter((item): item is MenuOption => Boolean(item))
|
||||
|
||||
const key = menu.path || `catalog-${menu.id}`
|
||||
return {
|
||||
key,
|
||||
label: menu.title,
|
||||
icon: renderIcon(menu.icon),
|
||||
children: children.length > 0 ? children : undefined
|
||||
}
|
||||
}
|
||||
|
||||
const menuOptions = computed(() =>
|
||||
authStore.menus.map((item) => toOption(item)).filter((item): item is MenuOption => Boolean(item))
|
||||
)
|
||||
|
||||
const activePath = computed(() => route.path)
|
||||
|
||||
function handleSelect(key: string) {
|
||||
if (key.startsWith('catalog-')) return
|
||||
router.push(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-menu) {
|
||||
overflow-x: hidden;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.n-menu-item) {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
:deep(.n-menu-item-content) {
|
||||
color: #64748b;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
:deep(.n-menu-item-content-header),
|
||||
:deep(.n-menu-item-content__arrow) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.n-menu-item-content-header) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.n-menu-item-content--selected::before) {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
:deep(.n-menu-item-content--selected) {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="tabs-shell">
|
||||
<div class="tabs-track">
|
||||
<button
|
||||
v-for="tab in authStore.tabs"
|
||||
:key="tab.path"
|
||||
type="button"
|
||||
class="tab-chip"
|
||||
:class="{ 'tab-chip-active': tab.path === activePath }"
|
||||
@click="router.push(tab.path)"
|
||||
>
|
||||
<span class="tab-title">{{ tab.title }}</span>
|
||||
<span v-if="tab.closable" class="tab-close" @click.stop="onClose(tab.path)">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const activePath = computed(() => (route.path === '/workbench' ? '/dashboard' : route.path))
|
||||
|
||||
function onClose(path: string) {
|
||||
const tab = authStore.tabs.find((item) => item.path === path)
|
||||
if (!tab || !tab.closable) return
|
||||
authStore.closeTab(path)
|
||||
if (activePath.value === path) {
|
||||
router.push(authStore.tabs[authStore.tabs.length - 1]?.path ?? '/dashboard')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tabs-shell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid #eef2f7;
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
.tabs-track {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.tab-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-chip:hover {
|
||||
background: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.tab-chip-active {
|
||||
background: #111827;
|
||||
border-color: #111827;
|
||||
color: #ffffff;
|
||||
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
color: currentColor;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<slot v-if="visible" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface Props {
|
||||
permission: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const visible = computed(() => authStore.hasPermission(props.permission))
|
||||
</script>
|
||||
Reference in New Issue
Block a user