20260810172235

This commit is contained in:
oneao committed 2026-08-10 17:22:36 +08:00
1 parent c4891538a7
commit ed5ab3885d
35 files changed
+1314 -653

No files matched your search

+24 -13
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { useEventListener, usePreferredReducedMotion } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
@@ -12,6 +12,7 @@ import {
const route = useRoute()
const appStore = useAppStore()
const preferredReducedMotion = usePreferredReducedMotion()
const routeInstanceKey = computed(
() => `${route.fullPath}:${appStore.getRouteRefreshKey(route.fullPath)}`,
)
@@ -59,7 +60,10 @@ function onRailMouseDown(e: MouseEvent) {
</script>
<template>
<SidebarProvider class="group/sidebar bg-sidebar" :style="{ '--sidebar-width': '14rem' }">
<SidebarProvider
class="group/sidebar h-svh min-w-0 overflow-hidden bg-sidebar"
:style="{ '--sidebar-width': '14rem' }"
>
<AppSidebar />
<!-- Drag rail: hidden when collapsed, follows sidebar width when expanded -->
@@ -70,13 +74,17 @@ function onRailMouseDown(e: MouseEvent) {
@mousedown="onRailMouseDown"
/>
<SidebarInset class="m-2 ml-0 rounded-xl shadow z-10">
<SidebarInset class="m-2 ml-0 min-w-0 overflow-hidden rounded-xl shadow z-10">
<AppTopbar />
<AppTabs />
<div class="flex flex-1 flex-col gap-4 p-4 pt-2">
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-4 overflow-auto p-4 pt-2">
<router-view v-slot="{ Component }">
<transition name="slide" mode="out-in">
<transition
name="page"
mode="out-in"
:css="preferredReducedMotion !== 'reduce'"
>
<keep-alive :max="8">
<component :is="Component" :key="routeInstanceKey" />
</keep-alive>
@@ -88,16 +96,19 @@ function onRailMouseDown(e: MouseEvent) {
</template>
<style>
.slide-enter-active,
.slide-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
.page-enter-active {
transition:
opacity 160ms ease-out,
transform 160ms ease-out;
}
.slide-enter-from {
.page-leave-active {
transition: opacity 80ms ease-in;
}
.page-enter-from {
opacity: 0;
transform: translateX(12px);
transform: translateY(4px);
}
.slide-leave-to {
.page-leave-to {
opacity: 0;
transform: translateX(-12px);
}
</style>
</style>
@@ -1,11 +1,15 @@
<script setup lang="ts">
import type { SidebarProps } from '@/components/ui/sidebar'
import type { NavItem } from '@/layouts/components/NavMenuItem.vue'
import {
AudioWaveform, ChevronsUpDown, Command,
GalleryVerticalEnd, LayoutGrid, Palette, Plus,
GalleryVerticalEnd, LayoutGrid, Plus,
} from "@lucide/vue"
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { usePermissionStore } from '@/stores/permissions'
import { getIcon } from '@/utils/lucideIcons'
import { buildTree, sortTree } from '@/utils/tree'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
@@ -22,6 +26,7 @@ const props = withDefaults(defineProps<SidebarProps>(), {
})
const { isMobile: isSidebarMobile } = useSidebar()
const permissionStore = usePermissionStore()
// ---- teams ----
const teams = [
@@ -31,21 +36,66 @@ const teams = [
]
const activeTeam = ref(teams[0]!)
// ---- nav items ----
const navMain = [
{
title: "工作台", url: "/dashboard", icon: LayoutGrid, isActive: true,
},
{
title: "Demo 页面", url: "#", icon: Palette,
items: [
{ title: "表格示例", url: "/demo/table" },
{ title: "表单示例", url: "/demo/form" },
{ title: "卡片示例", url: "/demo/cards" },
{ title: "设置示例", url: "/demo/settings" },
],
},
]
// ---- dynamic nav items ----
const navMain = computed(() => {
const items: NavItem[] = [
{ title: "工作台", url: "/dashboard", icon: LayoutGrid },
]
const menuModules = permissionStore.modules.filter(
(m: any) =>
m.b_canmenu === 1 &&
m.b_canuse === 1 &&
["directory", "page"].includes(m.b_module_type),
)
if (!menuModules.length) return items
// Keep only modules whose parent (if any) is also in the menu
const allowedIds = new Set(menuModules.map((m: any) => String(m.b_id)))
const valid = menuModules.filter(
(m: any) =>
m.b_parent_id == null || allowedIds.has(String(m.b_parent_id)),
)
const cloned: any[] = JSON.parse(JSON.stringify(valid))
const tree = buildTree(cloned)
sortTree(tree)
for (const node of tree) {
const item = toNavItem(node)
if (item) items.push(item)
}
return items
})
function toNavItem(node: any): NavItem | null {
const isDir = node.b_module_type === "directory"
const icon = isDir ? getIcon(node.b_icon) ?? undefined : undefined
const item: NavItem = {
title: node.b_name || node.b_code,
url: node.b_route ? String(node.b_route) : `#${node.b_code}`,
}
if (icon) item.icon = icon
if (!isDir && node.b_code) {
item.moduleId = String(node.b_code)
}
if (node.children?.length) {
item.items = node.children
.map((c: any) => toNavItem(c))
.filter(Boolean) as NavItem[]
}
return item
}
onMounted(() => {
permissionStore.load()
})
</script>
<template>
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight, X } from "@lucide/vue"
import { useResizeObserver } from "@vueuse/core"
import { useRoute, useRouter } from "vue-router"
import { computed, nextTick, onMounted, onBeforeUnmount, ref, watch } from "vue"
import { computed, nextTick, ref, watch } from "vue"
import { useAppStore } from "@/stores/app"
import { Button } from "@/components/ui/button"
const route = useRoute()
const router = useRouter()
@@ -15,12 +17,10 @@ const canScrollRight = ref(false)
const activeKey = computed(() => route.fullPath)
const tabs = computed(() => appStore.visitedTabs)
let resizeObserver: ResizeObserver | null = null
// Watch route changes to add tabs
watch(
() => route.fullPath,
() => {
async () => {
const meta = route.meta as { title?: string }
if (meta.title) {
appStore.addVisitedTab({
@@ -29,6 +29,9 @@ watch(
closable: route.fullPath !== "/dashboard",
})
}
await nextTick()
revealActiveTab()
updateScrollState()
},
{ immediate: true },
)
@@ -39,13 +42,7 @@ watch(() => appStore.visitedTabs.length, async () => {
updateScrollState()
})
onMounted(() => {
resizeObserver = new ResizeObserver(updateScrollState)
if (trackRef.value) resizeObserver.observe(trackRef.value)
updateScrollState()
})
onBeforeUnmount(() => resizeObserver?.disconnect())
useResizeObserver(trackRef, updateScrollState)
function changeTab(key: string) {
if (key !== route.fullPath) router.push(key)
@@ -87,6 +84,15 @@ function scrollTabs(direction: number) {
})
}
function revealActiveTab(behavior: ScrollBehavior = "smooth") {
const track = trackRef.value
if (!track) return
const activeElement = Array.from(track.querySelectorAll<HTMLElement>("[data-tab-key]")).find(
element => element.dataset.tabKey === activeKey.value,
)
activeElement?.scrollIntoView({ behavior, block: "nearest", inline: "center" })
}
function handleWheel(e: WheelEvent) {
const track = trackRef.value
if (!track || track.scrollWidth <= track.clientWidth) return
@@ -102,22 +108,29 @@ function refreshTab(key: string) {
</script>
<template>
<div v-if="tabs.length" class="flex shrink-0 items-center h-9 gap-0.5 px-2 border-b border-border/40">
<div
v-if="tabs.length"
class="flex h-10 w-full min-w-0 shrink-0 items-center gap-0.5 overflow-hidden px-2"
>
<!-- Left scroll button -->
<button
<Button
v-if="overflowed"
type="button"
class="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30 disabled:cursor-default transition-colors"
variant="ghost"
size="icon-sm"
class="size-7 text-muted-foreground disabled:cursor-default disabled:opacity-30"
aria-label="向左滚动页签"
:disabled="!canScrollLeft"
@click="scrollTabs(-1)"
>
<ChevronLeft class="size-3.5" />
</button>
<ChevronLeft />
</Button>
<!-- Tab track -->
<div
ref="trackRef"
class="flex flex-1 min-w-0 gap-1.5 overflow-x-auto overflow-y-visible scrollbar-none overscroll-contain"
class="tab-track flex min-w-0 flex-1 gap-1.5 overflow-x-auto overflow-y-hidden overscroll-contain"
role="tablist"
@scroll.passive="updateScrollState"
@wheel="handleWheel"
>
@@ -125,10 +138,14 @@ function refreshTab(key: string) {
v-for="tab in tabs"
:key="tab.key"
type="button"
class="group/tab relative flex shrink-0 items-center gap-1 h-7 rounded-md px-2.5 text-xs font-medium transition-colors cursor-pointer border"
:class="tab.key === activeKey
role="tab"
:data-tab-key="tab.key"
:aria-selected="tab.key === activeKey"
class="group/tab relative flex shrink-0 items-center gap-1 h-7 rounded-md pl-2.5 text-xs font-medium transition-colors cursor-pointer border"
:class="[tab.key === activeKey
? 'bg-muted text-foreground border-border'
: 'bg-background text-muted-foreground border-border/70 hover:border-border hover:text-foreground'"
: 'bg-background text-muted-foreground border-border/70 hover:border-border hover:text-foreground',
tab.closable ? 'pr-0' : 'pr-2.5']"
@click="changeTab(tab.key)"
@dblclick.prevent="refreshTab(tab.key)"
>
@@ -136,7 +153,7 @@ function refreshTab(key: string) {
<!-- Close button -->
<span
v-if="tab.closable"
class="grid size-4 shrink-0 place-items-center rounded-sm text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/20 transition-colors"
class="flex flex-1 items-center justify-center self-stretch px-1 rounded-sm text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/20 transition-colors"
@click="closeTab(tab.key, $event)"
>
<X class="size-3" />
@@ -145,14 +162,27 @@ function refreshTab(key: string) {
</div>
<!-- Right scroll button -->
<button
<Button
v-if="overflowed"
type="button"
class="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30 disabled:cursor-default transition-colors"
variant="ghost"
size="icon-sm"
class="size-7 text-muted-foreground disabled:cursor-default disabled:opacity-30"
aria-label="向右滚动页签"
:disabled="!canScrollRight"
@click="scrollTabs(1)"
>
<ChevronRight class="size-3.5" />
</button>
<ChevronRight />
</Button>
</div>
</template>
<style scoped lang="scss">
.tab-track {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
</style>
@@ -3,8 +3,10 @@ import {
BadgeCheck, Bell, ChevronDown, CreditCard, Languages, LogOut, Moon, Palette, Sun,
} from "@lucide/vue"
import { computed, onMounted, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { toast } from "vue-sonner"
import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import {
Avatar, AvatarFallback, AvatarImage,
} from '@/components/ui/avatar'
@@ -20,12 +22,20 @@ import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const authStore = useAuthStore()
const user = {
name: "张三",
email: "zhangsan@example.com",
avatar: "/avatars/shadcn.jpg",
const user = computed(() => ({
name: authStore.user?.name ?? '用户',
email: authStore.user?.account ?? '',
avatar: '',
}))
function logout() {
authStore.clearSession()
toast.info('已退出登录')
router.replace('/login')
}
const breadcrumbTitle = computed(() => (route.meta as { title?: string }).title ?? String(route.name ?? ''))
@@ -88,14 +98,14 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
<!-- Right tools -->
<div class="flex items-center gap-1 px-4">
<Button variant="ghost" size="icon-sm" @click="store.toggleLocale" :title="store.locale === 'zh-CN' ? 'English' : '中文'">
<Button variant="ghost" size="icon-sm" class="cursor-pointer" @click="store.toggleLocale" :title="store.locale === 'zh-CN' ? 'English' : '中文'">
<Languages />
</Button>
<!-- Color scheme grid -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon-sm" title="主题色切换">
<Button variant="ghost" size="icon-sm" class="cursor-pointer" title="主题色切换">
<Palette />
</Button>
</DropdownMenuTrigger>
@@ -113,11 +123,11 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
</DropdownMenuContent>
</DropdownMenu>
<Button variant="ghost" size="icon-sm" @click="store.toggleTheme" :title="store.isDark ? '浅色模式' : '深色模式'">
<Button variant="ghost" size="icon-sm" class="cursor-pointer" @click="store.toggleTheme" :title="store.isDark ? '浅色模式' : '深色模式'">
<Sun v-if="store.isDark" />
<Moon v-else />
</Button>
<Button variant="ghost" size="icon-sm" title="通知">
<Button variant="ghost" size="icon-sm" class="cursor-pointer" title="通知">
<Bell />
</Button>
<Separator orientation="vertical" class="mx-1 data-[orientation=vertical]:h-6" />
@@ -125,7 +135,7 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
<!-- User dropdown -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="sm" class="gap-2 data-[state=open]:bg-accent">
<Button variant="ghost" size="sm" class="gap-2 cursor-pointer data-[state=open]:bg-accent">
<Avatar class="size-7 rounded-md">
<AvatarImage :src="user.avatar" :alt="user.name" />
<AvatarFallback class="rounded-md text-xs">{{ user.name.slice(0, 1) }}</AvatarFallback>
@@ -143,7 +153,7 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{{ user.name }}</span>
<span class="truncate text-xs text-muted-foreground">{{ user.email }}</span>
<span class="truncate text-xs text-muted-foreground">{{ authStore.orgId }}</span>
</div>
</div>
</DropdownMenuLabel>
@@ -154,7 +164,7 @@ function applyTheme(theme: { primary: string; primaryFg: string; ring: string; s
<DropdownMenuItem><Bell /> 通知设置</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem><LogOut /> 退出登录</DropdownMenuItem>
<DropdownMenuItem @click="logout"><LogOut /> 退出登录</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -2,17 +2,25 @@
import type { LucideIcon } from "@lucide/vue"
import { ChevronRight } from "@lucide/vue"
import { RouterLink, useRoute } from 'vue-router'
import { computed } from 'vue'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from '@/components/ui/sidebar'
export type NavItem = {
@@ -21,6 +29,7 @@ export type NavItem = {
icon?: LucideIcon
isActive?: boolean
items?: NavItem[]
moduleId?: string
}
const props = withDefaults(defineProps<{
@@ -31,6 +40,9 @@ const props = withDefaults(defineProps<{
})
const route = useRoute()
const { state } = useSidebar()
const isCollapsed = computed(() => state.value === 'collapsed')
function isActive(url: string) {
if (url === '#') return false
@@ -44,31 +56,73 @@ function hasActiveChild(item: NavItem): boolean {
</script>
<template>
<!-- Has children: render as collapsible -->
<Collapsible v-if="item.items?.length" as-child :default-open="hasActiveChild(item)" class="group/collapsible">
<!-- Root level: SidebarMenuItem + SidebarMenuButton -->
<SidebarMenuItem v-if="props.depth === 0">
<CollapsibleTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" v-if="item.icon" />
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
<ChevronRight class="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<NavMenuItem
v-for="child in item.items"
:key="child.title"
:item="child"
:depth="1"
/>
</SidebarMenuSub>
</CollapsibleContent>
<!-- Root-level parent with children -->
<template v-if="item.items?.length && props.depth === 0">
<!-- Collapsed mode: DropdownMenu -->
<SidebarMenuItem v-if="isCollapsed">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" v-if="item.icon" />
<span>{{ item.title }}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-40 rounded-lg" side="right" :side-offset="8" align="start">
<template v-for="child in item.items" :key="child.title">
<!-- Child with grandchildren: nested DropdownMenu -->
<DropdownMenuItem v-if="child.items?.length" as-child>
<DropdownMenu>
<DropdownMenuTrigger class="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm outline-none cursor-pointer hover:bg-accent hover:text-accent-foreground">
<span>{{ child.title }}</span>
<ChevronRight class="size-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent class="w-40 rounded-lg" side="right" :side-offset="4" align="start">
<DropdownMenuItem
v-for="grandchild in child.items"
:key="grandchild.title"
as-child
>
<RouterLink :to="grandchild.url" class="w-full">{{ grandchild.title }}</RouterLink>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuItem>
<!-- Leaf child: direct link -->
<DropdownMenuItem v-else as-child>
<RouterLink :to="child.url" class="w-full">{{ child.title }}</RouterLink>
</DropdownMenuItem>
</template>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<!-- Sub level: SidebarMenuSubItem + SidebarMenuSubButton -->
<SidebarMenuSubItem v-else>
<!-- Expanded mode: Collapsible -->
<Collapsible v-else as-child :default-open="hasActiveChild(item)" class="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" v-if="item.icon" />
<span>{{ item.title }}</span>
<ChevronRight class="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<NavMenuItem
v-for="child in item.items"
:key="child.title"
:item="child"
:depth="1"
/>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</template>
<!-- Sub-level parent with children -->
<Collapsible v-else-if="item.items?.length" as-child :default-open="hasActiveChild(item)" class="group/collapsible">
<SidebarMenuSubItem>
<CollapsibleTrigger as-child>
<SidebarMenuSubButton>
<span>{{ item.title }}</span>