通用中后台框架第一版

This commit is contained in:
BBIT-Kai
2026-04-28 16:27:16 +08:00
commit b8d25869c6
115 changed files with 15223 additions and 0 deletions
+23
View File
@@ -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>
+96
View File
@@ -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>
+107
View File
@@ -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>
+17
View File
@@ -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>