first commit

This commit is contained in:
2024-04-08 21:34:02 +08:00
commit a41a7f32ab
223 changed files with 44629 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
<template>
<a-layout class="layout layout-default">
<Asider></Asider>
<a-layout class="layout-default-right">
<Header></Header>
<Tabs></Tabs>
<Main></Main>
</a-layout>
</a-layout>
</template>
<script setup lang="ts">
import Asider from './components/Asider/index.vue'
import Header from './components/Header/index.vue'
import Main from './components/Main.vue'
import Tabs from './components/Tabs/index.vue'
defineOptions({ name: 'LayoutDefault' })
</script>
<style lang="scss" scoped>
.layout {
height: 100%;
}
.layout-default {
flex-direction: row;
&-right {
overflow: hidden;
}
}
</style>

178
src/layout/LayoutMix.vue Normal file
View File

@@ -0,0 +1,178 @@
<template>
<div class="layout-mix">
<section
v-if="isDesktop"
class="layout-mix-left"
:class="{ 'app-menu-dark': appStore.menuDark }"
:style="appStore.menuDark ? appStore.themeCSSVar : undefined"
>
<Logo :collapsed="appStore.menuCollapse"></Logo>
<Menu :menus="leftMenus" :menu-style="{ width: '200px', flex: 1 }"></Menu>
</section>
<section class="layout-mix-right">
<header class="header">
<MenuFoldBtn></MenuFoldBtn>
<a-menu
v-if="isDesktop"
mode="horizontal"
:selected-keys="activeMenu"
:auto-open-selected="false"
:trigger-props="{ animationName: 'slide-dynamic-origin' }"
@menu-item-click="onMenuItemClick"
>
<a-menu-item v-for="item in topMenus" :key="item.path">
<template #icon>
<GiSvgIcon
v-if="getMenuIcon(item, 'svgIcon')"
:name="getMenuIcon(item, 'svgIcon')"
:size="24"
></GiSvgIcon>
<template v-else>
<component v-if="getMenuIcon(item, 'svgIcon')" :is="getMenuIcon(item, 'icon')"></component>
</template>
</template>
<span>{{ item.meta?.title || item.children?.[0]?.meta?.title || '' }}</span>
</a-menu-item>
</a-menu>
<HeaderRightBar></HeaderRightBar>
</header>
<Tabs></Tabs>
<Main></Main>
</section>
</div>
</template>
<script setup lang="ts">
import Main from './components/Main.vue'
import Tabs from './components/Tabs/index.vue'
import Menu from './components/Menu/index.vue'
import HeaderRightBar from './components/HeaderRightBar/index.vue'
import Logo from './components/Logo.vue'
import MenuFoldBtn from './components/MenuFoldBtn.vue'
import { useAppStore, useRouteStore } from '@/stores'
import type { RouteRecordRaw } from 'vue-router'
import { isExternal } from '@/utils/validate'
import { searchTree } from 'xe-utils'
import { filterTree } from '@/utils'
import { useDevice } from '@/hooks'
defineOptions({ name: 'LayoutMix' })
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const routeStore = useRouteStore()
const { isDesktop } = useDevice()
// 过滤是菜单的路由
const menuRoutes = filterTree(routeStore.routes, (i) => i.meta?.hidden === false)
// 顶部一级菜单
const topMenus = ref<RouteRecordRaw[]>([])
topMenus.value = JSON.parse(JSON.stringify(menuRoutes))
console.log('topMenus', toRaw(topMenus.value))
const getMenuIcon = (item: RouteRecordRaw, key: 'svgIcon' | 'icon') => {
return item.meta?.[key] || item.children?.[0].meta?.[key]
}
const onMenuItemClick = (key: string) => {
if (isExternal(key)) {
window.open(key)
return
}
setTimeout(() => getLeftMenus(key))
const obj = topMenus.value.find((i) => i.path === key)
if (obj && obj.redirect === 'noRedirect') return
router.push({ path: key })
}
// 克隆是菜单的路由
const cloneMenuRoutes: RouteRecordRaw[] = JSON.parse(JSON.stringify(menuRoutes))
// 顶部一级菜单选中的
const activeMenu = ref<string[]>([])
// 左侧的菜单
const leftMenus = ref<RouteRecordRaw[]>([])
// 获取左侧菜单
const getLeftMenus = (key: string) => {
const arr = searchTree(cloneMenuRoutes, (i) => i.path === key, { children: 'children' })
const rootPath = arr.length ? arr[0].path : ''
const obj = cloneMenuRoutes.find((i) => i.path === rootPath)
activeMenu.value = obj ? [obj.path] : ['']
leftMenus.value = obj ? (obj.children as RouteRecordRaw[]) : []
}
watch(
() => route.path,
(newPath) => {
nextTick(() => {
getLeftMenus(newPath)
})
},
{ immediate: true }
)
</script>
<style lang="scss" scoped>
:deep(.arco-menu-pop) {
white-space: nowrap;
}
:deep(.arco-menu.arco-menu-vertical.arco-menu-collapsed) {
// Menu菜单组件修改
.arco-menu-icon {
margin-right: 0;
padding: 10px 0;
}
.arco-menu-has-icon {
padding: 0;
justify-content: center;
}
.arco-menu-title {
display: none;
}
}
:deep(.arco-menu-horizontal) {
flex: 1;
overflow: hidden;
.arco-menu-inner {
padding-left: 0;
.arco-menu-overflow-wrap {
white-space: nowrap;
}
}
}
.layout-mix {
height: 100%;
display: flex;
align-items: stretch;
overflow: hidden;
&-left {
border-right: 1px solid var(--color-border);
background-color: var(--color-bg-1);
display: flex;
flex-direction: column;
overflow: hidden;
}
&-right {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
}
.header {
padding: 0 $padding;
height: 56px;
color: var(--color-text-1);
background: var(--color-bg-1);
border-bottom: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
align-items: center;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,74 @@
<template>
<div
v-if="isDesktop"
class="asider"
:class="{ 'app-menu-dark': appStore.menuDark }"
:style="appStore.menuDark ? appStore.themeCSSVar : undefined"
>
<Logo :collapsed="appStore.menuCollapse"></Logo>
<a-layout-sider
class="menu"
collapsible
breakpoint="xl"
hide-trigger
:width="220"
:collapsed="appStore.menuCollapse"
@collapse="handleCollapse"
>
<a-scrollbar outer-class="h-full" style="height: 100%; overflow: auto">
<Menu></Menu>
</a-scrollbar>
</a-layout-sider>
</div>
</template>
<script setup lang="ts">
import { useAppStore } from '@/stores'
import Menu from '../Menu/index.vue'
import Logo from '../Logo.vue'
import { useDevice } from '@/hooks'
defineOptions({ name: 'Asider' })
const appStore = useAppStore()
const { isDesktop } = useDevice()
const handleCollapse = (isCollapsed: boolean) => {
appStore.menuCollapse = isCollapsed
}
</script>
<style lang="scss" scoped>
:deep(.arco-menu.arco-menu-vertical.arco-menu-collapsed) {
// Menu菜单组件修改
.arco-menu-icon {
margin-right: 0;
padding: 10px 0;
}
.arco-menu-has-icon {
padding: 0;
justify-content: center;
}
.arco-menu-title {
display: none;
}
}
:deep(.arco-layout-sider-children) {
overflow: hidden;
}
.asider {
z-index: 1000;
display: flex;
flex-direction: column;
border-right: 1px solid var(--color-border-2);
box-sizing: border-box;
color: var(--color-text-1);
background-color: var(--color-bg-1);
.menu {
flex: 1;
overflow: hidden;
background-color: inherit;
}
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<a-layout-header class="header">
<section class="fold-btn-wrapper">
<MenuFoldBtn></MenuFoldBtn>
</section>
<a-row align="center" class="h-full header-right">
<a-col :xs="0" :md="10" :lg="10" :xl="12" :xxl="12">
<Breadcrumb></Breadcrumb>
</a-col>
<a-col :xs="24" :md="14" :lg="14" :xl="12" :xxl="12">
<a-row justify="end" align="center">
<HeaderRightBar></HeaderRightBar>
</a-row>
</a-col>
</a-row>
</a-layout-header>
</template>
<script setup lang="ts">
import HeaderRightBar from '../HeaderRightBar/index.vue'
import MenuFoldBtn from '../MenuFoldBtn.vue'
defineOptions({ name: 'Header' })
</script>
<style lang="scss" scoped>
.arco-dropdown-open .arco-icon-down {
transform: rotate(180deg);
}
.header {
display: flex;
align-items: center;
.header-right {
flex: 1;
overflow: hidden;
margin-left: $padding;
}
}
.arco-layout-header {
padding: 0 $padding;
height: 56px;
background: var(--color-bg-1);
border-bottom: 1px solid var(--color-neutral-3);
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<div class="message">
<a-tabs default-active-key="1">
<a-tab-pane key="1">
<template #title>通知(1)</template>
</a-tab-pane>
<a-tab-pane key="2">
<template #title>关注(1)</template>
</a-tab-pane>
<a-tab-pane key="3">
<template #title>待办(2)</template>
</a-tab-pane>
</a-tabs>
<section>
<a-comment
v-for="(item, index) in list"
:key="index"
:author="item.name"
:content="item.content"
:datetime="item.datetime"
>
<template #actions></template>
<template #avatar>
<a-avatar><img :src="item.avatar" /></a-avatar>
</template>
</a-comment>
</section>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'Message' })
const list = [
{
name: 'Socrates',
datetime: '1小时之前',
content: 'Comment body content.',
avatar: 'https://lolicode.gitee.io/scui-doc/demo/img/avatar2.gif'
},
{
name: '木木糖醇',
datetime: '2小时之前',
content: '关注了你',
avatar: 'https://s1.ax1x.com/2022/06/14/XhteeO.jpg'
},
{
name: '徐欣',
datetime: '2个半小时之前',
content: '收藏了你的文章',
avatar: 'https://s1.ax1x.com/2022/06/14/XhtSwF.jpg'
}
]
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,158 @@
<template>
<a-drawer v-model:visible="visible" title="项目配置" width="300px" unmount-on-close :footer="false">
<a-space :size="15" direction="vertical" fill>
<a-divider orientation="center">系统布局</a-divider>
<a-row justify="center">
<a-space>
<a-badge>
<template #content>
<icon-check-circle-fill
v-if="appStore.layout === 'left'"
style="color: rgb(var(--success-6))"
:size="16"
></icon-check-circle-fill>
</template>
<LayoutItem mode="left" @click="appStore.layout = 'left'"></LayoutItem>
<p class="layout-text">默认布局</p>
</a-badge>
<a-badge>
<template #content>
<icon-check-circle-fill
v-if="appStore.layout === 'mix'"
:size="16"
style="color: rgb(var(--success-6))"
></icon-check-circle-fill>
</template>
<LayoutItem mode="mix" @click="appStore.layout = 'mix'"></LayoutItem>
<p class="layout-text">混合布局</p>
</a-badge>
</a-space>
</a-row>
<a-divider orientation="center">系统主题</a-divider>
<a-row justify="center">
<ColorPicker
theme="dark"
:color="appStore.themeColor"
:sucker-hide="true"
:colors-default="defaultColorList"
@changeColor="changeColor"
></ColorPicker>
</a-row>
<a-divider orientation="center">界面显示</a-divider>
<a-descriptions :column="1" :align="{ value: 'right' }" :value-style="{ paddingRight: 0 }">
<a-descriptions-item label="页签显示">
<a-switch v-model="appStore.tab" />
</a-descriptions-item>
<a-descriptions-item label="页签风格">
<a-select
v-model="appStore.tabMode"
placeholder="请选择"
:options="tabModeList"
:disabled="!appStore.tab"
:trigger-props="{ autoFitPopupMinWidth: true }"
:style="{ width: '120px' }"
>
</a-select>
</a-descriptions-item>
<a-descriptions-item label="动画显示">
<a-switch v-model="appStore.animate" />
</a-descriptions-item>
<a-descriptions-item label="动画显示">
<a-select
v-model="appStore.animateMode"
placeholder="请选择"
:options="animateModeList"
:disabled="!appStore.animate"
:style="{ width: '120px' }"
>
</a-select>
</a-descriptions-item>
<a-descriptions-item label="深色菜单">
<a-switch v-model="appStore.menuDark" />
</a-descriptions-item>
<a-descriptions-item label="手风琴效果">
<a-switch v-model="appStore.menuAccordion" />
</a-descriptions-item>
</a-descriptions>
</a-space>
</a-drawer>
</template>
<script setup lang="ts">
import { useAppStore } from '@/stores'
import { ColorPicker } from 'vue-color-kit'
import 'vue-color-kit/dist/vue-color-kit.css'
import LayoutItem from './components/LayoutItem.vue'
defineOptions({ name: 'SettingDrawer' })
const appStore = useAppStore()
const visible = ref(false)
const tabModeList: App.TabItem[] = [
{ label: '卡片', value: 'card' },
{ label: '间隔卡片', value: 'card-gutter' },
{ label: '圆角', value: 'rounded' }
]
const animateModeList: App.AnimateItem[] = [
{ label: '默认', value: 'zoom-fade' },
{ label: '滑动', value: 'fade-slide' },
{ label: '渐变', value: 'fade' },
{ label: '底部滑出', value: 'fade-bottom' },
{ label: '缩放消退', value: 'fade-scale' }
]
const open = () => {
visible.value = true
}
defineExpose({ open })
// 默认显示的主题色列表
const defaultColorList = [
'#165DFF',
'#409EFF',
'#18A058',
'#2d8cf0',
'#007AFF',
'#5ac8fa',
'#5856D6',
'#536dfe',
'#9c27b0',
'#AF52DE',
'#0096c7',
'#00C1D4',
'#43a047',
'#e53935',
'#f4511e',
'#6d4c41'
]
type ColorObj = {
hex: string
hsv: { h: number; s: number; v: number }
rgba: { r: number; g: number; b: number; a: number }
}
// 改变主题色
const changeColor = (colorObj: ColorObj) => {
if (!/^#[0-9A-Za-z]{6}/.test(colorObj.hex)) return
appStore.setThemeColor(colorObj.hex)
}
</script>
<style lang="scss" scoped>
:deep(.arco-descriptions-item-label-block) {
color: var(--color-text-1);
}
.layout-text {
font-size: 12px;
text-align: center;
color: var(--color-text-2);
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<div class="layout-item" :class="`layout-item-${mode}`" @click="emit('click')"></div>
</template>
<script setup lang="ts">
interface Props {
mode: 'left' | 'top' | 'mix'
}
withDefaults(defineProps<Props>(), {})
const emit = defineEmits(['click'])
</script>
<style lang="scss" scoped>
.layout-item {
width: 60px;
height: 50px;
background-color: var(--color-fill-3);
border-radius: 3px;
overflow: hidden;
cursor: pointer;
box-sizing: border-box;
position: relative;
&::before {
content: '';
width: 12px;
height: 100%;
background-color: rgb(var(--gray-9));
position: absolute;
top: 0;
left: 0;
display: none;
}
&::after {
content: '';
width: 100%;
height: 10px;
background-color: rgb(var(--gray-9));
position: absolute;
top: 0;
left: 0;
display: none;
}
&-left {
&::before {
display: block;
}
}
&-top {
&::after {
display: block;
}
}
&-mix {
&::before,
&::after {
display: block;
}
}
}
</style>

View File

@@ -0,0 +1,125 @@
<template>
<a-row justify="end" align="center">
<a-space size="medium">
<!-- 项目配置 -->
<a-tooltip content="项目配置" position="bl">
<a-button size="mini" class="gi_hover_btn" @click="SettingDrawerRef?.open">
<template #icon>
<icon-settings :size="18" />
</template>
</a-button>
</a-tooltip>
<!-- 消息通知 -->
<a-popover position="bottom" trigger="click">
<a-badge :count="9" dot>
<a-button size="mini" class="gi_hover_btn">
<template #icon>
<icon-notification :size="18" />
</template>
</a-button>
</a-badge>
<template #content>
<Message></Message>
</template>
</a-popover>
<!-- 全屏切换组件 -->
<a-tooltip v-if="!isMobile()" content="全屏切换" position="bottom">
<a-button size="mini" class="gi_hover_btn" @click="toggle">
<template #icon>
<icon-fullscreen :size="18" v-if="!isFullscreen" />
<icon-fullscreen-exit :size="18" v-else />
</template>
</a-button>
</a-tooltip>
<!-- 暗黑模式切换 -->
<a-tooltip content="主题切换" position="bottom">
<GiThemeBtn></GiThemeBtn>
</a-tooltip>
<!-- 管理员账户 -->
<a-dropdown trigger="hover">
<a-row align="center" :wrap="false" class="user">
<!-- 管理员头像 -->
<a-avatar :size="32">
<img :src="userStore.avatar" alt="avatar" />
</a-avatar>
<span class="username">{{ userStore.name }}</span>
<icon-down />
</a-row>
<template #content>
<a-doption @click="toUser">
<span>账号管理</span>
</a-doption>
<a-divider :margin="0" />
<a-doption @click="logout">
<span>退出登录</span>
</a-doption>
</template>
</a-dropdown>
</a-space>
</a-row>
<SettingDrawer ref="SettingDrawerRef"></SettingDrawer>
</template>
<script setup lang="ts">
import { Modal } from '@arco-design/web-vue'
import { useUserStore } from '@/stores'
import SettingDrawer from './SettingDrawer.vue'
import Message from './Message.vue'
import { isMobile } from '@/utils'
import { useFullscreen } from '@vueuse/core'
const { isFullscreen, toggle } = useFullscreen()
defineOptions({ name: 'HeaderRight' })
const router = useRouter()
const userStore = useUserStore()
const SettingDrawerRef = ref<InstanceType<typeof SettingDrawer>>()
// 跳转基本信息
const toUser = () => {
router.push('/setting/profile')
}
// 退出登录
const logout = () => {
Modal.warning({
title: '提示',
content: '确认退出登录?',
hideCancel: false,
closable: true,
onBeforeOk: async () => {
try {
await userStore.logout()
router.replace('/login')
return true
} catch (error) {
return false
}
}
})
}
</script>
<style lang="scss" scoped>
.arco-dropdown-open .arco-icon-down {
transform: rotate(180deg);
}
.user {
cursor: pointer;
color: var(--color-text-1);
.username {
margin-left: 10px;
white-space: nowrap;
}
.arco-icon-down {
transition: all 0.3s;
margin-left: 2px;
}
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<section class="system-logo" :class="{ collapsed: props.collapsed }" @click="toHome">
<img class="logo" src="@/assets/images/logo.svg" />
<span class="system-name">ContiNew Admin</span>
</section>
</template>
<script setup lang="ts">
interface Props {
collapsed?: boolean
}
const props = withDefaults(defineProps<Props>(), {
collapsed: false
})
const router = useRouter()
// 跳转首页
const toHome = () => {
router.push('/')
}
</script>
<style lang="scss" scoped>
.system-logo {
height: 56px;
padding: 0 12px;
color: var(--color-text-1);
font-size: 20px;
line-height: 1;
display: flex;
align-items: center;
flex-shrink: 0;
cursor: pointer;
user-select: none;
box-sizing: border-box;
&.collapsed {
padding: 0;
display: flex;
justify-content: center;
align-items: center;
// .logo {
// width: 24px;
// height: 24px;
// }
.system-name {
display: none;
}
}
.logo {
width: 32px;
height: 32px;
border-radius: 6px;
transition: all 0.2s;
overflow: hidden;
flex-shrink: 0;
}
.system-name {
padding-left: 10px;
white-space: nowrap;
transition: color 0.3s;
&:hover {
color: $color-theme !important;
cursor: pointer;
}
}
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<a-layout class="main">
<router-view v-slot="{ Component, route }">
<transition :name="transitionName(route)" mode="out-in" appear>
<keep-alive :include="(tabsStore.cacheList as string[])">
<component :is="Component" :key="route.matched?.[1]?.path" />
</keep-alive>
</transition>
</router-view>
</a-layout>
</template>
<script setup lang="ts">
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { useAppStore, useTabsStore } from '@/stores'
defineOptions({ name: 'Main' })
const appStore = useAppStore()
const tabsStore = useTabsStore()
// 过渡动画
const transitionName = computed(() => {
return function (route: RouteLocationNormalizedLoaded) {
if (route?.matched?.[1]?.meta?.animation === false) {
return ''
}
return appStore.transitionName
}
})
</script>
<style lang="scss" scoped>
.main {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
</style>

View File

@@ -0,0 +1,15 @@
<template>
<GiSvgIcon v-if="props.svgIcon" :name="props.svgIcon" :size="18"></GiSvgIcon>
<component v-else-if="props.icon" :is="props.icon" style="height: 18px; width: 18px"></component>
</template>
<script lang="ts" setup>
interface Props {
svgIcon?: string
icon?: string
}
const props = withDefaults(defineProps<Props>(), {})
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,70 @@
<template>
<template v-if="!item.meta?.hidden">
<a-menu-item
v-if="
isOneShowingChild &&
(!onlyOneChild?.children || onlyOneChild?.meta?.noShowingChildren) &&
!item?.meta?.alwaysShow
"
v-bind="attrs"
:key="onlyOneChild?.path"
>
<template #icon>
<MenuIcon
:svg-icon="onlyOneChild?.meta?.svgIcon || item?.meta?.svgIcon"
:icon="onlyOneChild?.meta?.icon || item?.meta?.icon"
></MenuIcon>
</template>
<span>{{ onlyOneChild?.meta?.title }}</span>
</a-menu-item>
<a-sub-menu v-else v-bind="attrs" :key="item.path" :title="item?.meta?.title">
<template #icon>
<MenuIcon :svg-icon="item?.meta?.svgIcon" :icon="item?.meta?.icon"></MenuIcon>
</template>
<MenuItem v-for="child in item.children" :key="child.path" :item="child"></MenuItem>
</a-sub-menu>
</template>
</template>
<script lang="ts" setup>
import type { RouteRecordRaw } from 'vue-router'
import MenuIcon from './MenuIcon.vue'
defineOptions({ name: 'MenuItem' })
const attrs = useAttrs()
interface Props {
item: RouteRecordRaw
}
const props = withDefaults(defineProps<Props>(), {})
// 如果hidden: false那么代表这个路由项显示在左侧菜单栏中
// 如果props.item的子项chidren只有一个hidden: false的子元素, 那么onlyOneChild就表示这个子元素
const onlyOneChild = ref<RouteRecordRaw | null>(null)
const isOneShowingChild = ref(false)
const handleFunction = () => {
const chidrens = props.item?.children?.length ? props.item.children : []
// 判断是否只有一个显示的子项
const showingChildrens = chidrens.filter((i) => i.meta?.hidden === false)
if (showingChildrens.length) {
// 保存子项最后一个hidden: false的元素
onlyOneChild.value = showingChildrens[showingChildrens.length - 1]
}
// 当只有一个要显示子路由时, 默认显示该子路由器
if (showingChildrens.length === 1) {
isOneShowingChild.value = true
}
// 如果没有要显示的子路由, 则显示父路由
if (showingChildrens.length === 0) {
onlyOneChild.value = { ...props.item, meta: { ...props.item.meta, noShowingChildren: true } } as any
isOneShowingChild.value = true
}
}
handleFunction()
</script>

View File

@@ -0,0 +1,87 @@
<template>
<a-menu
:mode="mode"
:selected-keys="activeMenu"
:auto-open-selected="autoOpenSelected"
:accordion="appStore.menuAccordion"
:breakpoint="appStore.layout === 'mix' ? 'xl' : undefined"
:trigger-props="{ animationName: 'slide-dynamic-origin' }"
:collapsed="!isDesktop ? false : appStore.menuCollapse"
@menu-item-click="onMenuItemClick"
@collapse="onCollapse"
:style="menuStyle"
>
<MenuItem v-for="(route, index) in sidebarRoutes" :key="route.path + index" :item="route"></MenuItem>
</a-menu>
</template>
<script setup lang="ts">
import { useAppStore, useRouteStore } from '@/stores'
import MenuItem from './MenuItem.vue'
import { isExternal } from '@/utils/validate'
import type { RouteRecordRaw } from 'vue-router'
import type { CSSProperties } from 'vue'
import { useDevice } from '@/hooks'
defineOptions({ name: 'Menu' })
const emit = defineEmits<{
(e: 'menuItemClickAfter'): void
}>()
interface Props {
menus?: RouteRecordRaw[]
menuStyle?: CSSProperties
}
const props = withDefaults(defineProps<Props>(), {})
const { isDesktop } = useDevice()
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const routeStore = useRouteStore()
const sidebarRoutes = computed(() => (props.menus ? props.menus : routeStore.routes))
// console.log('sidebarRoutes', sidebarRoutes.value)
// 菜单垂直模式/水平模式
const mode = computed(() => {
if (!['left', 'mix'].includes(appStore.layout)) {
return 'horizontal'
} else {
return 'vertical'
}
})
// 是否默认展开选中的菜单
const autoOpenSelected = computed(() => {
return ['left', 'mix'].includes(appStore.layout);
})
// 当前页面激活菜单路径,先从路由里面找
const activeMenu = computed(() => {
const { meta, path } = route
if (meta?.activeMenu) {
return [meta.activeMenu]
}
return [path]
})
// 菜单项点击事件
const onMenuItemClick = (key: string) => {
if (isExternal(key)) {
window.open(key)
return
}
router.push({ path: key })
emit('menuItemClickAfter')
}
// 折叠状态改变时触发
const onCollapse = (collapsed: boolean) => {
if (appStore.layout === 'mix') {
appStore.menuCollapse = collapsed
}
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,71 @@
<template>
<a-button size="mini" class="gi_hover_btn menu-fold-btn" @click="onClick">
<template #icon>
<icon-menu-fold v-if="!appStore.menuCollapse" :size="18" :stroke-width="3" />
<icon-menu-unfold v-else :size="18" :stroke-width="3" />
</template>
</a-button>
<div
class="drawer"
:class="{ 'app-menu-dark': appStore.menuDark }"
:style="appStore.menuDark ? appStore.themeCSSVar : undefined"
>
<a-drawer
v-model:visible="visible"
placement="left"
:header="false"
:footer="false"
:render-to-body="false"
:drawer-style="{
'border-right': '1px solid var(--color-border-2)',
'box-sizing': 'border-box',
'background-color': 'var(--color-bg-1)'
}"
>
<Logo :collapsed="false"></Logo>
<Menu class="menu w-full" @menu-item-click-after="visible = false"></Menu>
</a-drawer>
</div>
</template>
<script lang="ts" setup>
import { useAppStore } from '@/stores'
import Logo from '@/layout/components/Logo.vue'
import Menu from '@/layout/components/Menu/index.vue'
import { useDevice } from '@/hooks'
defineOptions({ name: 'MenuFoldBtn' })
const appStore = useAppStore()
const { isDesktop } = useDevice()
const visible = ref(false)
const onClick = () => {
if (isDesktop.value) {
appStore.setMenuCollapse(!appStore.menuCollapse)
} else {
visible.value = !visible.value
}
}
</script>
<style lang="scss" scoped>
.menu-fold-btn {
background-color: var(--color-secondary-hover) !important;
flex-shrink: 0;
}
.drawer {
.menu {
flex: 1;
overflow: hidden;
background-color: inherit;
}
:deep(.arco-drawer-body) {
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<span class="gi-more-icon-wrap">
<span class="gi-more-icon">
<i class="block block-top"></i>
<i class="block block-bottom"></i>
</span>
</span>
</template>
<script lang="ts" setup></script>
<style lang="scss" scoped>
.gi-more-icon-wrap {
position: relative;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
&::before {
content: '';
width: 26px;
height: 26px;
position: absolute;
left: -6px;
top: -6px;
cursor: pointer;
}
.gi-more-icon {
display: inline-block;
color: var(--color-text-2);
cursor: pointer;
transition: transform 0.3s ease-out;
.block {
position: relative;
display: block;
width: 14px;
height: 6px;
// &.block-top:before {
// transition: transform 0.3s ease-out 0.3s;
// }
&.block-bottom {
margin-top: 2px;
}
}
.block:before {
position: absolute;
left: 0;
width: 6px;
height: 6px;
content: '';
background: var(--color-text-3);
}
.block:after {
position: absolute;
left: 8px;
width: 6px;
height: 6px;
content: '';
background: var(--color-text-3);
}
}
}
.gi-more-icon-wrap:hover .gi-more-icon .block:first-child::before,
.arco-dropdown-open .gi-more-icon .block:first-child::before {
transform: rotate(45deg);
background: rgb(var(--primary-3));
}
.gi-more-icon-wrap:hover .gi-more-icon .block:before,
.arco-dropdown-open .gi-more-icon .block:before {
background: rgb(var(--primary-6));
}
.gi-more-icon-wrap:hover .gi-more-icon .block:after,
.arco-dropdown-open .gi-more-icon .block:after {
background: rgb(var(--primary-6));
}
.gi-more-icon-wrap:hover .gi-more-icon,
.arco-dropdown-open .gi-more-icon {
transform: rotate(90deg);
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="tabs" v-if="appStore.tab">
<a-tabs
editable
hide-content
size="medium"
:type="appStore.tabMode"
:active-key="route.path"
@tab-click="(key) => handleTabClick(key as string)"
@delete="tabsStore.closeCurrent"
>
<a-tab-pane
v-for="item of tabsStore.tagList"
:key="item.path"
:title="(item.meta?.title as string)"
:closable="Boolean(!item.meta?.affix)"
>
</a-tab-pane>
<template #extra>
<a-dropdown trigger="hover">
<MagicIcon class="gi_mr"></MagicIcon>
<template #content>
<a-doption @click="tabsStore.closeCurrent(route.path)">
<template #icon><icon-close /></template>
<template #default>关闭当前</template>
</a-doption>
<a-doption @click="tabsStore.closeOther(route.path)">
<template #icon><icon-eraser /></template>
<template #default>关闭其他</template>
</a-doption>
<a-doption @click="tabsStore.closeAll">
<template #icon><icon-minus /></template>
<template #default>关闭全部</template>
</a-doption>
</template>
</a-dropdown>
</template>
</a-tabs>
</div>
</template>
<script setup lang="ts">
import type { RouteRecordRaw } from 'vue-router'
import { useTabsStore, useAppStore } from '@/stores'
import MagicIcon from './MagicIcon.vue'
defineOptions({ name: 'Tabs' })
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const tabsStore = useTabsStore()
// 重置, 同时把 affix: true 的路由筛选出来
tabsStore.reset()
// 监听路由变化
watch(
() => route.path,
() => {
handleRouteChange()
}
)
// 路由发生改变触发
const handleRouteChange = () => {
const item = { ...route } as unknown as RouteRecordRaw
tabsStore.addTagItem(item)
tabsStore.addCacheItem(item)
// console.log('路由对象', toRaw(item))
// console.log('tagList', toRaw(tabsStore.tagList))
// console.log('cacheList', toRaw(tabsStore.cacheList))
}
handleRouteChange()
// 点击页签
const handleTabClick = (key: string) => {
router.push({ path: key })
}
</script>
<style lang="scss" scoped>
:deep(.arco-tabs-nav-tab) {
.arco-tabs-tab {
border-bottom-color: transparent !important;
svg {
width: 0;
transition: all 0.15s;
}
&:hover {
svg {
width: 1em;
}
}
}
&:not(.arco-tabs-nav-tab-scroll) {
.arco-tabs-tab:first-child {
border-left: 0;
}
}
}
:deep(.arco-dropdown-option-icon) {
color: var(--color-text-3);
}
.tabs {
padding-top: 5px;
background-color: var(--color-bg-1);
}
</style>

15
src/layout/index.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<LayoutMix v-if="appStore.layout === 'mix'"></LayoutMix>
<LayoutDefault v-else></LayoutDefault>
</template>
<script setup lang="ts">
import LayoutDefault from './LayoutDefault.vue'
import LayoutMix from './LayoutMix.vue'
import { useAppStore } from '@/stores'
defineOptions({ name: 'Layout' })
const appStore = useAppStore()
</script>
<style lang="scss" scoped></style>