feat(admin-ui): implement RuoYi sidebar shell
This commit is contained in:
280
WonderQ-Admin-UI-Vue/src/components/layout/Sidebar.vue
Normal file
280
WonderQ-Admin-UI-Vue/src/components/layout/Sidebar.vue
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { useLayoutStore } from "@/stores/layout";
|
||||||
|
import { visibleNavigableMenus } from "@/lib/menu-display";
|
||||||
|
import type { NavigationType, SideTheme } from "@/lib/layout-settings";
|
||||||
|
import type { AdminMenu } from "@/types";
|
||||||
|
import SidebarItem from "@/components/layout/SidebarItem.vue";
|
||||||
|
|
||||||
|
type ThemeValue = SideTheme | "dark" | "light";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
menus?: AdminMenu[];
|
||||||
|
collapsed?: boolean;
|
||||||
|
sideTheme?: ThemeValue;
|
||||||
|
sidebarLogo?: boolean;
|
||||||
|
showLogo?: boolean;
|
||||||
|
navigationType?: NavigationType;
|
||||||
|
}>(), {
|
||||||
|
collapsed: false,
|
||||||
|
sideTheme: "theme-dark",
|
||||||
|
sidebarLogo: true,
|
||||||
|
navigationType: "left",
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const layout = useLayoutStore();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const activePath = computed(() => route.path);
|
||||||
|
const isMobile = computed(() => layout.device === "mobile");
|
||||||
|
const collapsedMenu = computed(() => !isMobile.value && props.collapsed);
|
||||||
|
const isDark = computed(() => props.sideTheme === "theme-dark" || props.sideTheme === "dark");
|
||||||
|
const isVisible = computed(() => props.navigationType !== "top");
|
||||||
|
const showLogo = computed(() => props.showLogo ?? props.sidebarLogo);
|
||||||
|
const menus = computed(() => visibleNavigableMenus<AdminMenu>(props.menus ?? auth.menus));
|
||||||
|
const menuBackgroundColor = computed(() => isDark.value ? "#304156" : "#ffffff");
|
||||||
|
const menuTextColor = computed(() => isDark.value ? "#bfcbd9" : "#303133");
|
||||||
|
const menuActiveTextColor = computed(() => isDark.value ? "#ffffff" : "#409eff");
|
||||||
|
|
||||||
|
const sidebarStyle = computed(() => {
|
||||||
|
const width = props.collapsed ? "54px" : "200px";
|
||||||
|
return {
|
||||||
|
width: isMobile.value ? undefined : width,
|
||||||
|
minWidth: isMobile.value ? undefined : width,
|
||||||
|
position: isMobile.value ? "fixed" : "relative",
|
||||||
|
top: isMobile.value ? "0" : undefined,
|
||||||
|
bottom: isMobile.value ? "0" : undefined,
|
||||||
|
left: isMobile.value ? "0" : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleMenuSelect() {
|
||||||
|
if (isMobile.value) layout.closeSidebar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSidebar() {
|
||||||
|
layout.closeSidebar();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="isVisible"
|
||||||
|
:class="[
|
||||||
|
'sidebar-shell',
|
||||||
|
`sidebar-shell--${props.navigationType}`,
|
||||||
|
{ 'sidebar-shell--mobile': isMobile, 'sidebar-shell--collapsed': collapsedMenu },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="isMobile && layout.sidebarOpened"
|
||||||
|
type="button"
|
||||||
|
class="sidebar-overlay"
|
||||||
|
aria-label="关闭侧边栏"
|
||||||
|
@click="closeSidebar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
:class="[
|
||||||
|
'sidebar-drawer',
|
||||||
|
isDark ? 'sidebar-drawer--dark' : 'sidebar-drawer--light',
|
||||||
|
{
|
||||||
|
'sidebar-drawer--mobile': isMobile,
|
||||||
|
'sidebar-drawer--open': isMobile && layout.sidebarOpened,
|
||||||
|
'sidebar-drawer--collapsed': collapsedMenu,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:style="sidebarStyle"
|
||||||
|
:inert="isMobile && !layout.sidebarOpened"
|
||||||
|
:aria-hidden="isMobile && !layout.sidebarOpened ? 'true' : undefined"
|
||||||
|
>
|
||||||
|
<div v-if="showLogo" class="sidebar-logo" :class="{ 'sidebar-logo--collapsed': collapsedMenu }">
|
||||||
|
<div class="sidebar-logo__mark" aria-hidden="true">WQ</div>
|
||||||
|
<div v-if="!collapsedMenu" class="sidebar-logo__copy">
|
||||||
|
<strong>WonderQ</strong>
|
||||||
|
<span>内容运营后台</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-scrollbar class="sidebar-scrollbar">
|
||||||
|
<el-menu
|
||||||
|
:default-active="activePath"
|
||||||
|
:collapse="collapsedMenu"
|
||||||
|
:collapse-transition="false"
|
||||||
|
:collapse-tooltip="true"
|
||||||
|
:unique-opened="true"
|
||||||
|
:router="true"
|
||||||
|
:background-color="menuBackgroundColor"
|
||||||
|
:text-color="menuTextColor"
|
||||||
|
:active-text-color="menuActiveTextColor"
|
||||||
|
class="sidebar-menu"
|
||||||
|
@select="handleMenuSelect"
|
||||||
|
>
|
||||||
|
<SidebarItem
|
||||||
|
v-for="menu in menus"
|
||||||
|
:key="menu.id"
|
||||||
|
:menu="menu"
|
||||||
|
base-path=""
|
||||||
|
:collapsed="collapsedMenu"
|
||||||
|
:navigation-type="props.navigationType"
|
||||||
|
/>
|
||||||
|
</el-menu>
|
||||||
|
</el-scrollbar>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar-shell {
|
||||||
|
width: 200px;
|
||||||
|
min-width: 200px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-shell--collapsed {
|
||||||
|
width: 54px;
|
||||||
|
min-width: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-shell--mobile {
|
||||||
|
width: 200px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-drawer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 200px;
|
||||||
|
min-width: 200px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-drawer--collapsed {
|
||||||
|
width: 54px;
|
||||||
|
min-width: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-drawer--dark {
|
||||||
|
--sidebar-background: #304156;
|
||||||
|
--sidebar-hover-background: #263445;
|
||||||
|
--sidebar-active-background: #1f2d3d;
|
||||||
|
--sidebar-text: #bfcbd9;
|
||||||
|
--sidebar-active-text: #409eff;
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
background: var(--sidebar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-drawer--light {
|
||||||
|
--sidebar-background: #ffffff;
|
||||||
|
--sidebar-hover-background: #f5f7fa;
|
||||||
|
--sidebar-active-background: #ecf5ff;
|
||||||
|
--sidebar-text: #303133;
|
||||||
|
--sidebar-active-text: #409eff;
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
background: var(--sidebar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, .08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo--collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding-right: 8px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo__mark {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 32px;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: #409eff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo__copy strong,
|
||||||
|
.sidebar-logo__copy span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo__copy strong {
|
||||||
|
color: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo__copy span {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-scrollbar {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
width: 200px;
|
||||||
|
min-height: 100%;
|
||||||
|
border-right: 0;
|
||||||
|
background: var(--sidebar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-drawer--collapsed .sidebar-menu,
|
||||||
|
.sidebar-drawer--collapsed :deep(.sidebar-menu.el-menu--collapse) {
|
||||||
|
width: 54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu :deep(.el-menu-item),
|
||||||
|
.sidebar-menu :deep(.el-sub-menu__title) {
|
||||||
|
color: var(--sidebar-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu :deep(.el-menu-item:hover),
|
||||||
|
.sidebar-menu :deep(.el-sub-menu__title:hover) {
|
||||||
|
background: var(--sidebar-hover-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu :deep(.el-menu-item.is-active) {
|
||||||
|
color: var(--sidebar-active-text);
|
||||||
|
background: var(--sidebar-active-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: rgba(0, 0, 0, .3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-drawer--mobile {
|
||||||
|
width: 200px;
|
||||||
|
min-width: 200px;
|
||||||
|
z-index: 1001;
|
||||||
|
transform: translate3d(-100%, 0, 0);
|
||||||
|
transition: transform 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-drawer--mobile.sidebar-drawer--open {
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
75
WonderQ-Admin-UI-Vue/src/components/layout/SidebarItem.vue
Normal file
75
WonderQ-Admin-UI-Vue/src/components/layout/SidebarItem.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import LayoutIcon from "@/components/layout/LayoutIcon.vue";
|
||||||
|
import { getMenuIconName, visibleNavigableMenus } from "@/lib/menu-display";
|
||||||
|
import type { NavigationType } from "@/lib/layout-settings";
|
||||||
|
import type { AdminMenu } from "@/types";
|
||||||
|
|
||||||
|
type SidebarMenu = AdminMenu & { isVisible?: boolean };
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
menu: SidebarMenu;
|
||||||
|
basePath?: string;
|
||||||
|
collapsed?: boolean;
|
||||||
|
navigationType?: NavigationType;
|
||||||
|
}>(), {
|
||||||
|
basePath: "",
|
||||||
|
collapsed: false,
|
||||||
|
navigationType: "left",
|
||||||
|
});
|
||||||
|
|
||||||
|
function resolveMenuPath(basePath: string, menuPath: string | null | undefined): string | undefined {
|
||||||
|
if (!menuPath) return undefined;
|
||||||
|
if (menuPath.startsWith("/")) return menuPath;
|
||||||
|
|
||||||
|
const normalizedBasePath = basePath.replace(/^\/+|\/+$/g, "");
|
||||||
|
const normalizedMenuPath = menuPath.trim().replace(/^\/+/, "");
|
||||||
|
if (!normalizedMenuPath) return normalizedBasePath ? `/${normalizedBasePath}` : undefined;
|
||||||
|
|
||||||
|
return `/${[normalizedBasePath, normalizedMenuPath].filter(Boolean).join("/")}`.replace(/\/{2,}/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath = computed(() => resolveMenuPath(props.basePath, props.menu.path));
|
||||||
|
const visibleChildren = computed(() => visibleNavigableMenus(props.menu.children ?? []));
|
||||||
|
const childBasePath = computed(() => resolvedPath.value || props.basePath || "/");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-sub-menu
|
||||||
|
v-if="menu.type === 'directory' && visibleChildren.length > 0"
|
||||||
|
:index="resolvedPath || menu.id"
|
||||||
|
:aria-label="menu.name"
|
||||||
|
:class="['sidebar-item', `sidebar-item--${navigationType}`]"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<el-tooltip :content="menu.name" :disabled="!collapsed" placement="right">
|
||||||
|
<span class="sidebar-item__content">
|
||||||
|
<LayoutIcon :name="getMenuIconName(menu)" :title="collapsed ? menu.name : undefined" size="18" />
|
||||||
|
<span v-if="!collapsed" class="sidebar-item__label">{{ menu.name }}</span>
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<SidebarItem
|
||||||
|
v-for="child in visibleChildren"
|
||||||
|
:key="child.id"
|
||||||
|
:menu="child"
|
||||||
|
:base-path="childBasePath"
|
||||||
|
:collapsed="collapsed"
|
||||||
|
:navigation-type="navigationType"
|
||||||
|
/>
|
||||||
|
</el-sub-menu>
|
||||||
|
|
||||||
|
<el-menu-item
|
||||||
|
v-else-if="menu.type === 'page' && resolvedPath"
|
||||||
|
:index="resolvedPath"
|
||||||
|
:aria-label="menu.name"
|
||||||
|
:class="['sidebar-item', `sidebar-item--${navigationType}`]"
|
||||||
|
>
|
||||||
|
<el-tooltip :content="menu.name" :disabled="!collapsed" placement="right">
|
||||||
|
<span class="sidebar-item__content">
|
||||||
|
<LayoutIcon :name="getMenuIconName(menu)" :title="collapsed ? menu.name : undefined" size="18" />
|
||||||
|
<span v-if="!collapsed" class="sidebar-item__label">{{ menu.name }}</span>
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</el-menu-item>
|
||||||
|
</template>
|
||||||
44
WonderQ-Admin-UI-Vue/src/components/layout/TopMenu.vue
Normal file
44
WonderQ-Admin-UI-Vue/src/components/layout/TopMenu.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { visibleNavigableMenus } from "@/lib/menu-display";
|
||||||
|
import type { NavigationType } from "@/lib/layout-settings";
|
||||||
|
import type { AdminMenu } from "@/types";
|
||||||
|
import SidebarItem from "@/components/layout/SidebarItem.vue";
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
menus?: AdminMenu[];
|
||||||
|
navigationType?: NavigationType;
|
||||||
|
}>(), {
|
||||||
|
navigationType: "top",
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const navigationType = computed(() => props.navigationType);
|
||||||
|
const activePath = computed(() => route.path);
|
||||||
|
const menus = computed(() => visibleNavigableMenus<AdminMenu>(props.menus ?? auth.menus));
|
||||||
|
const isVisible = computed(() => navigationType.value === "top" || navigationType.value === "mix");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav v-if="isVisible" class="top-menu" aria-label="顶部导航">
|
||||||
|
<el-menu
|
||||||
|
mode="horizontal"
|
||||||
|
:default-active="activePath"
|
||||||
|
:router="true"
|
||||||
|
:unique-opened="true"
|
||||||
|
class="top-menu__bar"
|
||||||
|
>
|
||||||
|
<SidebarItem
|
||||||
|
v-for="menu in menus"
|
||||||
|
:key="menu.id"
|
||||||
|
:menu="menu"
|
||||||
|
base-path=""
|
||||||
|
:collapsed="false"
|
||||||
|
:navigation-type="navigationType"
|
||||||
|
/>
|
||||||
|
</el-menu>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user