feat:新增pure-admin前端
This commit is contained in:
215
Yi.Pure.Vue3/src/layout/components/lay-content/index.vue
Normal file
215
Yi.Pure.Vue3/src/layout/components/lay-content/index.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import LayFrame from "../lay-frame/index.vue";
|
||||
import LayFooter from "../lay-footer/index.vue";
|
||||
import { useTags } from "@/layout/hooks/useTag";
|
||||
import { useGlobal, isNumber } from "@pureadmin/utils";
|
||||
import BackTopIcon from "@/assets/svg/back_top.svg?component";
|
||||
import { h, computed, Transition, defineComponent } from "vue";
|
||||
import { usePermissionStoreHook } from "@/store/modules/permission";
|
||||
|
||||
const props = defineProps({
|
||||
fixedHeader: Boolean
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { showModel } = useTags();
|
||||
const { $storage, $config } = useGlobal<GlobalPropertiesApi>();
|
||||
|
||||
const isKeepAlive = computed(() => {
|
||||
return $config?.KeepAlive;
|
||||
});
|
||||
|
||||
const transitions = computed(() => {
|
||||
return route => {
|
||||
return route.meta.transition;
|
||||
};
|
||||
});
|
||||
|
||||
const hideTabs = computed(() => {
|
||||
return $storage?.configure.hideTabs;
|
||||
});
|
||||
|
||||
const hideFooter = computed(() => {
|
||||
return $storage?.configure.hideFooter;
|
||||
});
|
||||
|
||||
const stretch = computed(() => {
|
||||
return $storage?.configure.stretch;
|
||||
});
|
||||
|
||||
const layout = computed(() => {
|
||||
return $storage?.layout.layout === "vertical";
|
||||
});
|
||||
|
||||
const getMainWidth = computed(() => {
|
||||
return isNumber(stretch.value)
|
||||
? stretch.value + "px"
|
||||
: stretch.value
|
||||
? "1440px"
|
||||
: "100%";
|
||||
});
|
||||
|
||||
const getSectionStyle = computed(() => {
|
||||
return [
|
||||
hideTabs.value && layout ? "padding-top: 48px;" : "",
|
||||
!hideTabs.value && layout
|
||||
? showModel.value == "chrome"
|
||||
? "padding-top: 85px;"
|
||||
: "padding-top: 81px;"
|
||||
: "",
|
||||
hideTabs.value && !layout.value ? "padding-top: 48px;" : "",
|
||||
!hideTabs.value && !layout.value
|
||||
? showModel.value == "chrome"
|
||||
? "padding-top: 85px;"
|
||||
: "padding-top: 81px;"
|
||||
: "",
|
||||
props.fixedHeader
|
||||
? ""
|
||||
: `padding-top: 0;${
|
||||
hideTabs.value
|
||||
? "min-height: calc(100vh - 48px);"
|
||||
: "min-height: calc(100vh - 86px);"
|
||||
}`
|
||||
];
|
||||
});
|
||||
|
||||
const transitionMain = defineComponent({
|
||||
props: {
|
||||
route: {
|
||||
type: undefined,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
render() {
|
||||
const transitionName =
|
||||
transitions.value(this.route)?.name || "fade-transform";
|
||||
const enterTransition = transitions.value(this.route)?.enterTransition;
|
||||
const leaveTransition = transitions.value(this.route)?.leaveTransition;
|
||||
return h(
|
||||
Transition,
|
||||
{
|
||||
name: enterTransition ? "pure-classes-transition" : transitionName,
|
||||
enterActiveClass: enterTransition
|
||||
? `animate__animated ${enterTransition}`
|
||||
: undefined,
|
||||
leaveActiveClass: leaveTransition
|
||||
? `animate__animated ${leaveTransition}`
|
||||
: undefined,
|
||||
mode: "out-in",
|
||||
appear: true
|
||||
},
|
||||
{
|
||||
default: () => [this.$slots.default()]
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="[fixedHeader ? 'app-main' : 'app-main-nofixed-header']"
|
||||
:style="getSectionStyle"
|
||||
>
|
||||
<router-view>
|
||||
<template #default="{ Component, route }">
|
||||
<LayFrame :currComp="Component" :currRoute="route">
|
||||
<template #default="{ Comp, fullPath, frameInfo }">
|
||||
<el-scrollbar
|
||||
v-if="fixedHeader"
|
||||
:wrap-style="{
|
||||
display: 'flex',
|
||||
'flex-wrap': 'wrap',
|
||||
'max-width': getMainWidth,
|
||||
margin: '0 auto',
|
||||
transition: 'all 300ms cubic-bezier(0.4, 0, 0.2, 1)'
|
||||
}"
|
||||
:view-style="{
|
||||
display: 'flex',
|
||||
flex: 'auto',
|
||||
overflow: 'hidden',
|
||||
'flex-direction': 'column'
|
||||
}"
|
||||
>
|
||||
<el-backtop
|
||||
:title="t('buttons.pureBackTop')"
|
||||
target=".app-main .el-scrollbar__wrap"
|
||||
>
|
||||
<BackTopIcon />
|
||||
</el-backtop>
|
||||
<div class="grow">
|
||||
<transitionMain :route="route">
|
||||
<keep-alive
|
||||
v-if="isKeepAlive"
|
||||
:include="usePermissionStoreHook().cachePageList"
|
||||
>
|
||||
<component
|
||||
:is="Comp"
|
||||
:key="fullPath"
|
||||
:frameInfo="frameInfo"
|
||||
class="main-content"
|
||||
/>
|
||||
</keep-alive>
|
||||
<component
|
||||
:is="Comp"
|
||||
v-else
|
||||
:key="fullPath"
|
||||
:frameInfo="frameInfo"
|
||||
class="main-content"
|
||||
/>
|
||||
</transitionMain>
|
||||
</div>
|
||||
<LayFooter v-if="!hideFooter" />
|
||||
</el-scrollbar>
|
||||
<div v-else class="grow">
|
||||
<transitionMain :route="route">
|
||||
<keep-alive
|
||||
v-if="isKeepAlive"
|
||||
:include="usePermissionStoreHook().cachePageList"
|
||||
>
|
||||
<component
|
||||
:is="Comp"
|
||||
:key="fullPath"
|
||||
:frameInfo="frameInfo"
|
||||
class="main-content"
|
||||
/>
|
||||
</keep-alive>
|
||||
<component
|
||||
:is="Comp"
|
||||
v-else
|
||||
:key="fullPath"
|
||||
:frameInfo="frameInfo"
|
||||
class="main-content"
|
||||
/>
|
||||
</transitionMain>
|
||||
</div>
|
||||
</template>
|
||||
</LayFrame>
|
||||
</template>
|
||||
</router-view>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<LayFooter v-if="!hideFooter && !fixedHeader" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-main {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app-main-nofixed-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin: 24px;
|
||||
}
|
||||
</style>
|
||||
31
Yi.Pure.Vue3/src/layout/components/lay-footer/index.vue
Normal file
31
Yi.Pure.Vue3/src/layout/components/lay-footer/index.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts" setup>
|
||||
import { getConfig } from "@/config";
|
||||
|
||||
const TITLE = getConfig("Title");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer
|
||||
class="layout-footer text-[rgba(0,0,0,0.6)] dark:text-[rgba(220,220,242,0.8)]"
|
||||
>
|
||||
Copyright © 2020-present
|
||||
<a
|
||||
class="hover:text-primary"
|
||||
href="https://github.com/pure-admin"
|
||||
target="_blank"
|
||||
>
|
||||
{{ TITLE }}
|
||||
</a>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0 0 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
79
Yi.Pure.Vue3/src/layout/components/lay-frame/index.vue
Normal file
79
Yi.Pure.Vue3/src/layout/components/lay-frame/index.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { getConfig } from "@/config";
|
||||
import { useMultiFrame } from "@/layout/hooks/useMultiFrame";
|
||||
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
|
||||
import { type Component, shallowRef, watch, computed } from "vue";
|
||||
import { type RouteRecordRaw, RouteLocationNormalizedLoaded } from "vue-router";
|
||||
|
||||
const props = defineProps<{
|
||||
currRoute: RouteLocationNormalizedLoaded;
|
||||
currComp: Component;
|
||||
}>();
|
||||
|
||||
const compList = shallowRef([]);
|
||||
const { setMap, getMap, MAP, delMap } = useMultiFrame();
|
||||
|
||||
const keep = computed(() => {
|
||||
return (
|
||||
getConfig().KeepAlive &&
|
||||
props.currRoute.meta?.keepAlive &&
|
||||
!!props.currRoute.meta?.frameSrc
|
||||
);
|
||||
});
|
||||
// 避免重新渲染 LayFrame
|
||||
const normalComp = computed(() => !keep.value && props.currComp);
|
||||
|
||||
watch(useMultiTagsStoreHook().multiTags, (tags: any) => {
|
||||
if (!Array.isArray(tags) || !keep.value) {
|
||||
return;
|
||||
}
|
||||
const iframeTags = tags.filter(i => i.meta?.frameSrc);
|
||||
// tags必须是小于MAP,才是做了关闭动作,因为MAP插入的顺序在tags变化后发生
|
||||
if (iframeTags.length < MAP.size) {
|
||||
for (const i of MAP.keys()) {
|
||||
if (!tags.some(s => s.path === i)) {
|
||||
delMap(i);
|
||||
compList.value = getMap();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.currRoute.fullPath,
|
||||
path => {
|
||||
const multiTags = useMultiTagsStoreHook().multiTags as RouteRecordRaw[];
|
||||
const iframeTags = multiTags.filter(i => i.meta?.frameSrc);
|
||||
if (keep.value) {
|
||||
if (iframeTags.length !== MAP.size) {
|
||||
const sameKey = [...MAP.keys()].find(i => path === i);
|
||||
if (!sameKey) {
|
||||
// 添加缓存
|
||||
setMap(path, props.currComp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (MAP.size > 0) {
|
||||
compList.value = getMap();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<template v-for="[fullPath, Comp] in compList" :key="fullPath">
|
||||
<div v-show="fullPath === currRoute.fullPath" class="w-full h-full">
|
||||
<slot
|
||||
:fullPath="fullPath"
|
||||
:Comp="Comp"
|
||||
:frameInfo="{ frameSrc: currRoute.meta?.frameSrc, fullPath }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-show="!keep" class="w-full h-full">
|
||||
<slot :Comp="normalComp" :fullPath="currRoute.fullPath" frameInfo />
|
||||
</div>
|
||||
</template>
|
||||
199
Yi.Pure.Vue3/src/layout/components/lay-navbar/index.vue
Normal file
199
Yi.Pure.Vue3/src/layout/components/lay-navbar/index.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
import LaySearch from "../lay-search/index.vue";
|
||||
import LayNotice from "../lay-notice/index.vue";
|
||||
import LayNavMix from "../lay-sidebar/NavMix.vue";
|
||||
import { useTranslationLang } from "@/layout/hooks/useTranslationLang";
|
||||
import LaySidebarFullScreen from "../lay-sidebar/components/SidebarFullScreen.vue";
|
||||
import LaySidebarBreadCrumb from "../lay-sidebar/components/SidebarBreadCrumb.vue";
|
||||
import LaySidebarTopCollapse from "../lay-sidebar/components/SidebarTopCollapse.vue";
|
||||
|
||||
import GlobalizationIcon from "@/assets/svg/globalization.svg?component";
|
||||
import AccountSettingsIcon from "@iconify-icons/ri/user-settings-line";
|
||||
import LogoutCircleRLine from "@iconify-icons/ri/logout-circle-r-line";
|
||||
import Setting from "@iconify-icons/ri/settings-3-line";
|
||||
import Check from "@iconify-icons/ep/check";
|
||||
|
||||
const {
|
||||
layout,
|
||||
device,
|
||||
logout,
|
||||
onPanel,
|
||||
pureApp,
|
||||
username,
|
||||
userAvatar,
|
||||
avatarsStyle,
|
||||
toggleSideBar,
|
||||
toAccountSettings,
|
||||
getDropdownItemStyle,
|
||||
getDropdownItemClass
|
||||
} = useNav();
|
||||
|
||||
const { t, locale, translationCh, translationEn } = useTranslationLang();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="navbar bg-[#fff] shadow-sm shadow-[rgba(0,21,41,0.08)]">
|
||||
<LaySidebarTopCollapse
|
||||
v-if="device === 'mobile'"
|
||||
class="hamburger-container"
|
||||
:is-active="pureApp.sidebar.opened"
|
||||
@toggleClick="toggleSideBar"
|
||||
/>
|
||||
|
||||
<LaySidebarBreadCrumb
|
||||
v-if="layout !== 'mix' && device !== 'mobile'"
|
||||
class="breadcrumb-container"
|
||||
/>
|
||||
|
||||
<LayNavMix v-if="layout === 'mix'" />
|
||||
|
||||
<div v-if="layout === 'vertical'" class="vertical-header-right">
|
||||
<!-- 菜单搜索 -->
|
||||
<LaySearch id="header-search" />
|
||||
<!-- 国际化 -->
|
||||
<el-dropdown id="header-translation" trigger="click">
|
||||
<GlobalizationIcon
|
||||
class="navbar-bg-hover w-[40px] h-[48px] p-[11px] cursor-pointer outline-none"
|
||||
/>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="translation">
|
||||
<el-dropdown-item
|
||||
:style="getDropdownItemStyle(locale, 'zh')"
|
||||
:class="['dark:!text-white', getDropdownItemClass(locale, 'zh')]"
|
||||
@click="translationCh"
|
||||
>
|
||||
<IconifyIconOffline
|
||||
v-show="locale === 'zh'"
|
||||
class="check-zh"
|
||||
:icon="Check"
|
||||
/>
|
||||
简体中文
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
:style="getDropdownItemStyle(locale, 'en')"
|
||||
:class="['dark:!text-white', getDropdownItemClass(locale, 'en')]"
|
||||
@click="translationEn"
|
||||
>
|
||||
<span v-show="locale === 'en'" class="check-en">
|
||||
<IconifyIconOffline :icon="Check" />
|
||||
</span>
|
||||
English
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<!-- 全屏 -->
|
||||
<LaySidebarFullScreen id="full-screen" />
|
||||
<!-- 消息通知 -->
|
||||
<LayNotice id="header-notice" />
|
||||
<!-- 退出登录 -->
|
||||
<el-dropdown trigger="click">
|
||||
<span class="el-dropdown-link navbar-bg-hover select-none">
|
||||
<img :src="userAvatar" :style="avatarsStyle" />
|
||||
<p v-if="username" class="dark:text-white">{{ username }}</p>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="logout">
|
||||
<el-dropdown-item @click="toAccountSettings">
|
||||
<IconifyIconOffline
|
||||
:icon="AccountSettingsIcon"
|
||||
style="margin: 5px"
|
||||
/>
|
||||
{{ t("buttons.pureAccountSettings") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="logout">
|
||||
<IconifyIconOffline
|
||||
:icon="LogoutCircleRLine"
|
||||
style="margin: 5px"
|
||||
/>
|
||||
{{ t("buttons.pureLoginOut") }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<span
|
||||
class="set-icon navbar-bg-hover"
|
||||
:title="t('buttons.pureOpenSystemSet')"
|
||||
@click="onPanel"
|
||||
>
|
||||
<IconifyIconOffline :icon="Setting" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navbar {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
overflow: hidden;
|
||||
|
||||
.hamburger-container {
|
||||
float: left;
|
||||
height: 100%;
|
||||
line-height: 48px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vertical-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 280px;
|
||||
height: 48px;
|
||||
color: #000000d9;
|
||||
|
||||
.el-dropdown-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
height: 48px;
|
||||
padding: 10px;
|
||||
color: #000000d9;
|
||||
cursor: pointer;
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-container {
|
||||
float: left;
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.translation {
|
||||
::v-deep(.el-dropdown-menu__item) {
|
||||
padding: 5px 40px;
|
||||
}
|
||||
|
||||
.check-zh {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.check-en {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.logout {
|
||||
width: 120px;
|
||||
|
||||
::v-deep(.el-dropdown-menu__item) {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { ListItem } from "../data";
|
||||
import { ref, PropType, nextTick } from "vue";
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
import { deviceDetection } from "@pureadmin/utils";
|
||||
|
||||
defineProps({
|
||||
noticeItem: {
|
||||
type: Object as PropType<ListItem>,
|
||||
default: () => {}
|
||||
}
|
||||
});
|
||||
|
||||
const titleRef = ref(null);
|
||||
const titleTooltip = ref(false);
|
||||
const descriptionRef = ref(null);
|
||||
const descriptionTooltip = ref(false);
|
||||
const { tooltipEffect } = useNav();
|
||||
const isMobile = deviceDetection();
|
||||
|
||||
function hoverTitle() {
|
||||
nextTick(() => {
|
||||
titleRef.value?.scrollWidth > titleRef.value?.clientWidth
|
||||
? (titleTooltip.value = true)
|
||||
: (titleTooltip.value = false);
|
||||
});
|
||||
}
|
||||
|
||||
function hoverDescription(event, description) {
|
||||
// currentWidth 为文本在页面中所占的宽度,创建标签,加入到页面,获取currentWidth ,最后在移除
|
||||
const tempTag = document.createElement("span");
|
||||
tempTag.innerText = description;
|
||||
tempTag.className = "getDescriptionWidth";
|
||||
document.querySelector("body").appendChild(tempTag);
|
||||
const currentWidth = (
|
||||
document.querySelector(".getDescriptionWidth") as HTMLSpanElement
|
||||
).offsetWidth;
|
||||
document.querySelector(".getDescriptionWidth").remove();
|
||||
|
||||
// cellWidth为容器的宽度
|
||||
const cellWidth = event.target.offsetWidth;
|
||||
|
||||
// 当文本宽度大于容器宽度两倍时,代表文本显示超过两行
|
||||
currentWidth > 2 * cellWidth
|
||||
? (descriptionTooltip.value = true)
|
||||
: (descriptionTooltip.value = false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="notice-container border-b-[1px] border-solid border-[#f0f0f0] dark:border-[#303030]"
|
||||
>
|
||||
<el-avatar
|
||||
v-if="noticeItem.avatar"
|
||||
:size="30"
|
||||
:src="noticeItem.avatar"
|
||||
class="notice-container-avatar"
|
||||
/>
|
||||
<div class="notice-container-text">
|
||||
<div class="notice-text-title text-[#000000d9] dark:text-white">
|
||||
<el-tooltip
|
||||
popper-class="notice-title-popper"
|
||||
:effect="tooltipEffect"
|
||||
:disabled="!titleTooltip"
|
||||
:content="noticeItem.title"
|
||||
placement="top-start"
|
||||
:enterable="!isMobile"
|
||||
>
|
||||
<div
|
||||
ref="titleRef"
|
||||
class="notice-title-content"
|
||||
@mouseover="hoverTitle"
|
||||
>
|
||||
{{ noticeItem.title }}
|
||||
</div>
|
||||
</el-tooltip>
|
||||
<el-tag
|
||||
v-if="noticeItem?.extra"
|
||||
:type="noticeItem?.status"
|
||||
size="small"
|
||||
class="notice-title-extra"
|
||||
>
|
||||
{{ noticeItem?.extra }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-tooltip
|
||||
popper-class="notice-title-popper"
|
||||
:effect="tooltipEffect"
|
||||
:disabled="!descriptionTooltip"
|
||||
:content="noticeItem.description"
|
||||
placement="top-start"
|
||||
>
|
||||
<div
|
||||
ref="descriptionRef"
|
||||
class="notice-text-description"
|
||||
@mouseover="hoverDescription($event, noticeItem.description)"
|
||||
>
|
||||
{{ noticeItem.description }}
|
||||
</div>
|
||||
</el-tooltip>
|
||||
<div class="notice-text-datetime text-[#00000073] dark:text-white">
|
||||
{{ noticeItem.datetime }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.notice-title-popper {
|
||||
max-width: 238px;
|
||||
}
|
||||
</style>
|
||||
<style scoped lang="scss">
|
||||
.notice-container {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
|
||||
// border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.notice-container-avatar {
|
||||
margin-right: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.notice-container-text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.notice-text-title {
|
||||
display: flex;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5715;
|
||||
cursor: pointer;
|
||||
|
||||
.notice-title-content {
|
||||
flex: 1;
|
||||
width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notice-title-extra {
|
||||
float: right;
|
||||
margin-top: -1.5px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-text-description,
|
||||
.notice-text-datetime {
|
||||
font-size: 12px;
|
||||
line-height: 1.5715;
|
||||
}
|
||||
|
||||
.notice-text-description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.notice-text-datetime {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { PropType } from "vue";
|
||||
import { ListItem } from "../data";
|
||||
import NoticeItem from "./NoticeItem.vue";
|
||||
import { transformI18n } from "@/plugins/i18n";
|
||||
|
||||
defineProps({
|
||||
list: {
|
||||
type: Array as PropType<Array<ListItem>>,
|
||||
default: () => []
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="list.length">
|
||||
<NoticeItem v-for="(item, index) in list" :key="index" :noticeItem="item" />
|
||||
</div>
|
||||
<el-empty v-else :description="transformI18n(emptyText)" />
|
||||
</template>
|
||||
99
Yi.Pure.Vue3/src/layout/components/lay-notice/data.ts
Normal file
99
Yi.Pure.Vue3/src/layout/components/lay-notice/data.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { $t } from "@/plugins/i18n";
|
||||
|
||||
export interface ListItem {
|
||||
avatar: string;
|
||||
title: string;
|
||||
datetime: string;
|
||||
type: string;
|
||||
description: string;
|
||||
status?: "primary" | "success" | "warning" | "info" | "danger";
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
export interface TabItem {
|
||||
key: string;
|
||||
name: string;
|
||||
list: ListItem[];
|
||||
emptyText: string;
|
||||
}
|
||||
|
||||
export const noticesData: TabItem[] = [
|
||||
{
|
||||
key: "1",
|
||||
name: $t("status.pureNotify"),
|
||||
list: [],
|
||||
emptyText: $t("status.pureNoNotify")
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
name: $t("status.pureMessage"),
|
||||
list: [
|
||||
{
|
||||
avatar: "https://xiaoxian521.github.io/hyperlink/svg/smile1.svg",
|
||||
title: "小铭 评论了你",
|
||||
description: "诚在于心,信在于行,诚信在于心行合一。",
|
||||
datetime: "今天",
|
||||
type: "2"
|
||||
},
|
||||
{
|
||||
avatar: "https://xiaoxian521.github.io/hyperlink/svg/smile2.svg",
|
||||
title: "李白 回复了你",
|
||||
description: "长风破浪会有时,直挂云帆济沧海。",
|
||||
datetime: "昨天",
|
||||
type: "2"
|
||||
},
|
||||
{
|
||||
avatar: "https://xiaoxian521.github.io/hyperlink/svg/smile5.svg",
|
||||
title: "标题",
|
||||
description:
|
||||
"请将鼠标移动到此处,以便测试超长的消息在此处将如何处理。本例中设置的描述最大行数为2,超过2行的描述内容将被省略并且可以通过tooltip查看完整内容",
|
||||
datetime: "时间",
|
||||
type: "2"
|
||||
}
|
||||
],
|
||||
emptyText: $t("status.pureNoMessage")
|
||||
},
|
||||
{
|
||||
key: "3",
|
||||
name: $t("status.pureTodo"),
|
||||
list: [
|
||||
{
|
||||
avatar: "",
|
||||
title: "第三方紧急代码变更",
|
||||
description:
|
||||
"小林提交于 2024-05-10,需在 2024-05-11 前完成代码变更任务",
|
||||
datetime: "",
|
||||
extra: "马上到期",
|
||||
status: "danger",
|
||||
type: "3"
|
||||
},
|
||||
{
|
||||
avatar: "",
|
||||
title: "版本发布",
|
||||
description: "指派小铭于 2024-06-18 前完成更新并发布",
|
||||
datetime: "",
|
||||
extra: "已耗时 8 天",
|
||||
status: "warning",
|
||||
type: "3"
|
||||
},
|
||||
{
|
||||
avatar: "",
|
||||
title: "新功能开发",
|
||||
description: "开发多租户管理",
|
||||
datetime: "",
|
||||
extra: "进行中",
|
||||
type: "3"
|
||||
},
|
||||
{
|
||||
avatar: "",
|
||||
title: "任务名称",
|
||||
description: "任务需要在 2030-10-30 10:00 前启动",
|
||||
datetime: "",
|
||||
extra: "未开始",
|
||||
status: "info",
|
||||
type: "3"
|
||||
}
|
||||
],
|
||||
emptyText: $t("status.pureNoTodo")
|
||||
}
|
||||
];
|
||||
98
Yi.Pure.Vue3/src/layout/components/lay-notice/index.vue
Normal file
98
Yi.Pure.Vue3/src/layout/components/lay-notice/index.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ref, computed } from "vue";
|
||||
import { noticesData } from "./data";
|
||||
import NoticeList from "./components/NoticeList.vue";
|
||||
import BellIcon from "@iconify-icons/ep/bell";
|
||||
|
||||
const { t } = useI18n();
|
||||
const noticesNum = ref(0);
|
||||
const notices = ref(noticesData);
|
||||
const activeKey = ref(noticesData[0]?.key);
|
||||
|
||||
notices.value.map(v => (noticesNum.value += v.list.length));
|
||||
|
||||
const getLabel = computed(
|
||||
() => item =>
|
||||
t(item.name) + (item.list.length > 0 ? `(${item.list.length})` : "")
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dropdown trigger="click" placement="bottom-end">
|
||||
<span
|
||||
:class="[
|
||||
'dropdown-badge',
|
||||
'navbar-bg-hover',
|
||||
'select-none',
|
||||
Number(noticesNum) !== 0 && 'mr-[10px]'
|
||||
]"
|
||||
>
|
||||
<el-badge :value="Number(noticesNum) === 0 ? '' : noticesNum" :max="99">
|
||||
<span class="header-notice-icon">
|
||||
<IconifyIconOffline :icon="BellIcon" />
|
||||
</span>
|
||||
</el-badge>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-tabs
|
||||
v-model="activeKey"
|
||||
:stretch="true"
|
||||
class="dropdown-tabs"
|
||||
:style="{ width: notices.length === 0 ? '200px' : '330px' }"
|
||||
>
|
||||
<el-empty
|
||||
v-if="notices.length === 0"
|
||||
:description="t('status.pureNoMessage')"
|
||||
:image-size="60"
|
||||
/>
|
||||
<span v-else>
|
||||
<template v-for="item in notices" :key="item.key">
|
||||
<el-tab-pane :label="getLabel(item)" :name="`${item.key}`">
|
||||
<el-scrollbar max-height="330px">
|
||||
<div class="noticeList-container">
|
||||
<NoticeList :list="item.list" :emptyText="item.emptyText" />
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-tab-pane>
|
||||
</template>
|
||||
</span>
|
||||
</el-tabs>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dropdown-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 48px;
|
||||
cursor: pointer;
|
||||
|
||||
.header-notice-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-tabs {
|
||||
.noticeList-container {
|
||||
padding: 15px 24px 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap)::after {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap) {
|
||||
padding: 0 36px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
149
Yi.Pure.Vue3/src/layout/components/lay-panel/index.vue
Normal file
149
Yi.Pure.Vue3/src/layout/components/lay-panel/index.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { emitter } from "@/utils/mitt";
|
||||
import { onClickOutside } from "@vueuse/core";
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
||||
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
|
||||
import CloseIcon from "@iconify-icons/ep/close";
|
||||
|
||||
const target = ref(null);
|
||||
const show = ref<Boolean>(false);
|
||||
|
||||
const iconClass = computed(() => {
|
||||
return [
|
||||
"w-[22px]",
|
||||
"h-[22px]",
|
||||
"flex",
|
||||
"justify-center",
|
||||
"items-center",
|
||||
"outline-none",
|
||||
"rounded-[4px]",
|
||||
"cursor-pointer",
|
||||
"transition-colors",
|
||||
"hover:bg-[#0000000f]",
|
||||
"dark:hover:bg-[#ffffff1f]",
|
||||
"dark:hover:text-[#ffffffd9]"
|
||||
];
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { onReset } = useDataThemeChange();
|
||||
|
||||
onClickOutside(target, (event: any) => {
|
||||
if (event.clientX > target.value.offsetLeft) return;
|
||||
show.value = false;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on("openPanel", () => {
|
||||
show.value = true;
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 解绑`openPanel`公共事件,防止多次触发
|
||||
emitter.off("openPanel");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ show }">
|
||||
<div class="right-panel-background" />
|
||||
<div ref="target" class="right-panel bg-bg_color">
|
||||
<div
|
||||
class="project-configuration border-b-[1px] border-solid border-[var(--pure-border-color)]"
|
||||
>
|
||||
<h4 class="dark:text-white">
|
||||
{{ t("panel.pureSystemSet") }}
|
||||
</h4>
|
||||
<span
|
||||
v-tippy="{
|
||||
content: t('panel.pureCloseSystemSet'),
|
||||
placement: 'bottom-start',
|
||||
zIndex: 41000
|
||||
}"
|
||||
:class="iconClass"
|
||||
>
|
||||
<IconifyIconOffline
|
||||
class="dark:text-white"
|
||||
width="18px"
|
||||
height="18px"
|
||||
:icon="CloseIcon"
|
||||
@click="show = !show"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<el-scrollbar>
|
||||
<slot />
|
||||
</el-scrollbar>
|
||||
|
||||
<div
|
||||
class="flex justify-end p-3 border-t-[1px] border-solid border-[var(--pure-border-color)]"
|
||||
>
|
||||
<el-button
|
||||
v-tippy="{
|
||||
content: t('panel.pureClearCacheAndToLogin'),
|
||||
placement: 'left-start',
|
||||
zIndex: 41000
|
||||
}"
|
||||
type="danger"
|
||||
text
|
||||
bg
|
||||
@click="onReset"
|
||||
>
|
||||
{{ t("panel.pureClearCache") }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-scrollbar) {
|
||||
height: calc(100vh - 110px);
|
||||
}
|
||||
|
||||
.right-panel-background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
background: rgb(0 0 0 / 20%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s cubic-bezier(0.7, 0.3, 0.1, 1);
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 40000;
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
box-shadow: 0 0 15px 0 rgb(0 0 0 / 5%);
|
||||
transition: all 0.25s cubic-bezier(0.7, 0.3, 0.1, 1);
|
||||
transform: translate(100%);
|
||||
}
|
||||
|
||||
.show {
|
||||
transition: all 0.3s cubic-bezier(0.7, 0.3, 0.1, 1);
|
||||
|
||||
.right-panel-background {
|
||||
z-index: 20000;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
transform: translate(0);
|
||||
}
|
||||
}
|
||||
|
||||
.project-configuration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
import MdiKeyboardEsc from "@/assets/svg/keyboard_esc.svg?component";
|
||||
import EnterOutlined from "@/assets/svg/enter_outlined.svg?component";
|
||||
import ArrowUpLine from "@iconify-icons/ri/arrow-up-line";
|
||||
import ArrowDownLine from "@iconify-icons/ri/arrow-down-line";
|
||||
|
||||
withDefaults(defineProps<{ total: number }>(), {
|
||||
total: 0
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { device } = useNav();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-footer text-[#333] dark:text-white">
|
||||
<span class="search-footer-item">
|
||||
<EnterOutlined class="icon" />
|
||||
{{ t("buttons.pureConfirm") }}
|
||||
</span>
|
||||
<span class="search-footer-item">
|
||||
<IconifyIconOffline :icon="ArrowUpLine" class="icon" />
|
||||
<IconifyIconOffline :icon="ArrowDownLine" class="icon" />
|
||||
{{ t("buttons.pureSwitch") }}
|
||||
</span>
|
||||
<span class="search-footer-item">
|
||||
<MdiKeyboardEsc class="icon" />
|
||||
{{ t("buttons.pureClose") }}
|
||||
</span>
|
||||
<p v-if="device !== 'mobile' && total > 0" class="search-footer-total">
|
||||
{{ `${t("search.pureTotal")} ${total}` }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-footer {
|
||||
display: flex;
|
||||
|
||||
.search-footer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding: 2px;
|
||||
margin-right: 3px;
|
||||
font-size: 20px;
|
||||
box-shadow:
|
||||
inset 0 -2px #cdcde6,
|
||||
inset 0 0 1px 1px #fff,
|
||||
0 1px 2px 1px #1e235a66;
|
||||
}
|
||||
|
||||
.search-footer-total {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import Sortable from "sortablejs";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import SearchHistoryItem from "./SearchHistoryItem.vue";
|
||||
import type { optionsItem, dragItem, Props } from "../types";
|
||||
import { useEpThemeStoreHook } from "@/store/modules/epTheme";
|
||||
import { useResizeObserver, isArray, delay } from "@pureadmin/utils";
|
||||
import { ref, watch, nextTick, computed, getCurrentInstance } from "vue";
|
||||
|
||||
interface Emits {
|
||||
(e: "update:value", val: string): void;
|
||||
(e: "enter"): void;
|
||||
(e: "collect", val: optionsItem): void;
|
||||
(e: "delete", val: optionsItem): void;
|
||||
(e: "drag", val: dragItem): void;
|
||||
}
|
||||
|
||||
const historyRef = ref();
|
||||
const innerHeight = ref();
|
||||
/** 判断是否停止鼠标移入事件处理 */
|
||||
const stopMouseEvent = ref(false);
|
||||
|
||||
const { t } = useI18n();
|
||||
const emit = defineEmits<Emits>();
|
||||
const instance = getCurrentInstance()!;
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const itemStyle = computed(() => {
|
||||
return item => {
|
||||
return {
|
||||
background:
|
||||
item?.path === active.value ? useEpThemeStoreHook().epThemeColor : "",
|
||||
color: item.path === active.value ? "#fff" : "",
|
||||
fontSize: item.path === active.value ? "16px" : "14px"
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
const titleStyle = computed(() => {
|
||||
return {
|
||||
color: useEpThemeStoreHook().epThemeColor,
|
||||
fontWeight: 500
|
||||
};
|
||||
});
|
||||
|
||||
const active = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
},
|
||||
set(val: string) {
|
||||
emit("update:value", val);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
newValue => {
|
||||
if (newValue) {
|
||||
if (stopMouseEvent.value) {
|
||||
delay(100).then(() => (stopMouseEvent.value = false));
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const historyList = computed(() => {
|
||||
return props.options.filter(item => item.type === "history");
|
||||
});
|
||||
|
||||
const collectList = computed(() => {
|
||||
return props.options.filter(item => item.type === "collect");
|
||||
});
|
||||
|
||||
function handleCollect(item) {
|
||||
emit("collect", item);
|
||||
}
|
||||
|
||||
function handleDelete(item) {
|
||||
stopMouseEvent.value = true;
|
||||
emit("delete", item);
|
||||
}
|
||||
|
||||
/** 鼠标移入 */
|
||||
async function handleMouse(item) {
|
||||
if (!stopMouseEvent.value) active.value = item.path;
|
||||
}
|
||||
|
||||
function handleTo() {
|
||||
emit("enter");
|
||||
}
|
||||
|
||||
function resizeResult() {
|
||||
// el-scrollbar max-height="calc(90vh - 140px)"
|
||||
innerHeight.value = window.innerHeight - window.innerHeight / 10 - 140;
|
||||
}
|
||||
|
||||
useResizeObserver(historyRef, resizeResult);
|
||||
|
||||
function handleScroll(index: number) {
|
||||
const curInstance = instance?.proxy?.$refs[`historyItemRef${index}`];
|
||||
if (!curInstance) return 0;
|
||||
const curRef = isArray(curInstance)
|
||||
? (curInstance[0] as ElRef)
|
||||
: (curInstance as ElRef);
|
||||
const scrollTop = curRef.offsetTop + 128; // 128 两个history-item(56px+56px=112px)高度加上下margin(8px+8px=16px)
|
||||
return scrollTop > innerHeight.value ? scrollTop - innerHeight.value : 0;
|
||||
}
|
||||
|
||||
const handleChangeIndex = (evt): void => {
|
||||
emit("drag", { oldIndex: evt.oldIndex, newIndex: evt.newIndex });
|
||||
};
|
||||
|
||||
let sortableInstance = null;
|
||||
|
||||
watch(
|
||||
collectList,
|
||||
val => {
|
||||
if (val.length > 1) {
|
||||
nextTick(() => {
|
||||
const wrapper: HTMLElement =
|
||||
document.querySelector(".collect-container");
|
||||
if (!wrapper || sortableInstance) return;
|
||||
sortableInstance = Sortable.create(wrapper, {
|
||||
animation: 160,
|
||||
onStart: event => {
|
||||
event.item.style.cursor = "move";
|
||||
},
|
||||
onEnd: event => {
|
||||
event.item.style.cursor = "pointer";
|
||||
},
|
||||
onUpdate: handleChangeIndex
|
||||
});
|
||||
resizeResult();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
defineExpose({ handleScroll });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="historyRef" class="history">
|
||||
<template v-if="historyList.length">
|
||||
<div :style="titleStyle">
|
||||
{{ t("search.pureHistory") }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, index) in historyList"
|
||||
:key="item.path"
|
||||
:ref="'historyItemRef' + index"
|
||||
class="history-item dark:bg-[#1d1d1d]"
|
||||
:style="itemStyle(item)"
|
||||
@click="handleTo"
|
||||
@mouseenter="handleMouse(item)"
|
||||
>
|
||||
<SearchHistoryItem
|
||||
:item="item"
|
||||
@delete-item="handleDelete"
|
||||
@collect-item="handleCollect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="collectList.length">
|
||||
<div :style="titleStyle">
|
||||
{{
|
||||
`${t("search.pureCollect")}${collectList.length > 1 ? t("search.pureDragSort") : ""}`
|
||||
}}
|
||||
</div>
|
||||
<div class="collect-container">
|
||||
<div
|
||||
v-for="(item, index) in collectList"
|
||||
:key="item.path"
|
||||
:ref="'historyItemRef' + (index + historyList.length)"
|
||||
class="history-item dark:bg-[#1d1d1d]"
|
||||
:style="itemStyle(item)"
|
||||
@click="handleTo"
|
||||
@mouseenter="handleMouse(item)"
|
||||
>
|
||||
<SearchHistoryItem :item="item" @delete-item="handleDelete" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.history {
|
||||
padding-bottom: 12px;
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
padding: 14px;
|
||||
margin: 8px auto 10px;
|
||||
cursor: pointer;
|
||||
border: 0.1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
transition: font-size 0.16s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { optionsItem } from "../types";
|
||||
import { transformI18n } from "@/plugins/i18n";
|
||||
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
||||
import StarIcon from "@iconify-icons/ep/star";
|
||||
import CloseIcon from "@iconify-icons/ep/close";
|
||||
|
||||
interface Props {
|
||||
item: optionsItem;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "collectItem", val: optionsItem): void;
|
||||
(e: "deleteItem", val: optionsItem): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
withDefaults(defineProps<Props>(), {});
|
||||
|
||||
function handleCollect(item) {
|
||||
emit("collectItem", item);
|
||||
}
|
||||
|
||||
function handleDelete(item) {
|
||||
emit("deleteItem", item);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="useRenderIcon(item.meta?.icon)" />
|
||||
<span class="history-item-title">
|
||||
{{ transformI18n(item.meta?.title) }}
|
||||
</span>
|
||||
<IconifyIconOffline
|
||||
v-show="item.type === 'history'"
|
||||
:icon="StarIcon"
|
||||
class="w-[18px] h-[18px] mr-2 hover:text-[#d7d5d4]"
|
||||
@click.stop="handleCollect(item)"
|
||||
/>
|
||||
<IconifyIconOffline
|
||||
:icon="CloseIcon"
|
||||
class="w-[18px] h-[18px] hover:text-[#d7d5d4] cursor-pointer"
|
||||
@click.stop="handleDelete(item)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.history-item-title {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin-left: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,338 @@
|
||||
<script setup lang="ts">
|
||||
import { match } from "pinyin-pro";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { getConfig } from "@/config";
|
||||
import { useRouter } from "vue-router";
|
||||
import SearchResult from "./SearchResult.vue";
|
||||
import SearchFooter from "./SearchFooter.vue";
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
import SearchHistory from "./SearchHistory.vue";
|
||||
import { transformI18n, $t } from "@/plugins/i18n";
|
||||
import type { optionsItem, dragItem } from "../types";
|
||||
import { ref, computed, shallowRef, watch } from "vue";
|
||||
import { useDebounceFn, onKeyStroke } from "@vueuse/core";
|
||||
import { usePermissionStoreHook } from "@/store/modules/permission";
|
||||
import { cloneDeep, isAllEmpty, storageLocal } from "@pureadmin/utils";
|
||||
import SearchIcon from "@iconify-icons/ri/search-line";
|
||||
|
||||
interface Props {
|
||||
/** 弹窗显隐 */
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: "update:value", val: boolean): void;
|
||||
}
|
||||
|
||||
const { device } = useNav();
|
||||
const emit = defineEmits<Emits>();
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const HISTORY_TYPE = "history";
|
||||
const COLLECT_TYPE = "collect";
|
||||
const LOCALEHISTORYKEY = "menu-search-history";
|
||||
const LOCALECOLLECTKEY = "menu-search-collect";
|
||||
|
||||
const keyword = ref("");
|
||||
const resultRef = ref();
|
||||
const historyRef = ref();
|
||||
const scrollbarRef = ref();
|
||||
const activePath = ref("");
|
||||
const historyPath = ref("");
|
||||
const resultOptions = shallowRef([]);
|
||||
const historyOptions = shallowRef([]);
|
||||
const handleSearch = useDebounceFn(search, 300);
|
||||
const historyNum = getConfig().MenuSearchHistory;
|
||||
const inputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
/** 菜单树形结构 */
|
||||
const menusData = computed(() => {
|
||||
return cloneDeep(usePermissionStoreHook().wholeMenus);
|
||||
});
|
||||
|
||||
const show = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
},
|
||||
set(val: boolean) {
|
||||
emit("update:value", val);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
newValue => {
|
||||
if (newValue) getHistory();
|
||||
}
|
||||
);
|
||||
|
||||
const showSearchResult = computed(() => {
|
||||
return keyword.value && resultOptions.value.length > 0;
|
||||
});
|
||||
|
||||
const showSearchHistory = computed(() => {
|
||||
return !keyword.value && historyOptions.value.length > 0;
|
||||
});
|
||||
|
||||
const showEmpty = computed(() => {
|
||||
return (
|
||||
(!keyword.value && historyOptions.value.length === 0) ||
|
||||
(keyword.value && resultOptions.value.length === 0)
|
||||
);
|
||||
});
|
||||
|
||||
function getStorageItem(key) {
|
||||
return storageLocal().getItem<optionsItem[]>(key) || [];
|
||||
}
|
||||
|
||||
function setStorageItem(key, value) {
|
||||
storageLocal().setItem(key, value);
|
||||
}
|
||||
|
||||
/** 将菜单树形结构扁平化为一维数组,用于菜单查询 */
|
||||
function flatTree(arr) {
|
||||
const res = [];
|
||||
function deep(arr) {
|
||||
arr.forEach(item => {
|
||||
res.push(item);
|
||||
item.children && deep(item.children);
|
||||
});
|
||||
}
|
||||
deep(arr);
|
||||
return res;
|
||||
}
|
||||
|
||||
/** 查询 */
|
||||
function search() {
|
||||
const flatMenusData = flatTree(menusData.value);
|
||||
resultOptions.value = flatMenusData.filter(menu =>
|
||||
keyword.value
|
||||
? transformI18n(menu.meta?.title)
|
||||
.toLocaleLowerCase()
|
||||
.includes(keyword.value.toLocaleLowerCase().trim()) ||
|
||||
(locale.value === "zh" &&
|
||||
!isAllEmpty(
|
||||
match(
|
||||
transformI18n(menu.meta?.title).toLocaleLowerCase(),
|
||||
keyword.value.toLocaleLowerCase().trim()
|
||||
)
|
||||
))
|
||||
: false
|
||||
);
|
||||
activePath.value =
|
||||
resultOptions.value?.length > 0 ? resultOptions.value[0].path : "";
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
show.value = false;
|
||||
/** 延时处理防止用户看到某些操作 */
|
||||
setTimeout(() => {
|
||||
resultOptions.value = [];
|
||||
historyPath.value = "";
|
||||
keyword.value = "";
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function scrollTo(index) {
|
||||
const ref = resultOptions.value.length ? resultRef.value : historyRef.value;
|
||||
const scrollTop = ref.handleScroll(index);
|
||||
scrollbarRef.value.setScrollTop(scrollTop);
|
||||
}
|
||||
|
||||
/** 获取当前选项和路径 */
|
||||
function getCurrentOptionsAndPath() {
|
||||
const isResultOptions = resultOptions.value.length > 0;
|
||||
const options = isResultOptions ? resultOptions.value : historyOptions.value;
|
||||
const currentPath = isResultOptions ? activePath.value : historyPath.value;
|
||||
return { options, currentPath, isResultOptions };
|
||||
}
|
||||
|
||||
/** 更新路径并滚动到指定项 */
|
||||
function updatePathAndScroll(newIndex, isResultOptions) {
|
||||
if (isResultOptions) {
|
||||
activePath.value = resultOptions.value[newIndex].path;
|
||||
} else {
|
||||
historyPath.value = historyOptions.value[newIndex].path;
|
||||
}
|
||||
scrollTo(newIndex);
|
||||
}
|
||||
|
||||
/** key up */
|
||||
function handleUp() {
|
||||
const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
|
||||
if (options.length === 0) return;
|
||||
const index = options.findIndex(item => item.path === currentPath);
|
||||
const prevIndex = (index - 1 + options.length) % options.length;
|
||||
updatePathAndScroll(prevIndex, isResultOptions);
|
||||
}
|
||||
|
||||
/** key down */
|
||||
function handleDown() {
|
||||
const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
|
||||
if (options.length === 0) return;
|
||||
const index = options.findIndex(item => item.path === currentPath);
|
||||
const nextIndex = (index + 1) % options.length;
|
||||
updatePathAndScroll(nextIndex, isResultOptions);
|
||||
}
|
||||
|
||||
/** key enter */
|
||||
function handleEnter() {
|
||||
const { options, currentPath, isResultOptions } = getCurrentOptionsAndPath();
|
||||
if (options.length === 0 || currentPath === "") return;
|
||||
const index = options.findIndex(item => item.path === currentPath);
|
||||
if (index === -1) return;
|
||||
if (isResultOptions) {
|
||||
saveHistory();
|
||||
} else {
|
||||
updateHistory();
|
||||
}
|
||||
router.push(options[index].path);
|
||||
handleClose();
|
||||
}
|
||||
|
||||
/** 删除历史记录 */
|
||||
function handleDelete(item) {
|
||||
const key = item.type === HISTORY_TYPE ? LOCALEHISTORYKEY : LOCALECOLLECTKEY;
|
||||
let list = getStorageItem(key);
|
||||
list = list.filter(listItem => listItem.path !== item.path);
|
||||
setStorageItem(key, list);
|
||||
getHistory();
|
||||
}
|
||||
|
||||
/** 收藏历史记录 */
|
||||
function handleCollect(item) {
|
||||
let searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
|
||||
let searchCollectList = getStorageItem(LOCALECOLLECTKEY);
|
||||
searchHistoryList = searchHistoryList.filter(
|
||||
historyItem => historyItem.path !== item.path
|
||||
);
|
||||
setStorageItem(LOCALEHISTORYKEY, searchHistoryList);
|
||||
if (!searchCollectList.some(collectItem => collectItem.path === item.path)) {
|
||||
searchCollectList.unshift({ ...item, type: COLLECT_TYPE });
|
||||
setStorageItem(LOCALECOLLECTKEY, searchCollectList);
|
||||
}
|
||||
getHistory();
|
||||
}
|
||||
|
||||
/** 存储搜索记录 */
|
||||
function saveHistory() {
|
||||
const { path, meta } = resultOptions.value.find(
|
||||
item => item.path === activePath.value
|
||||
);
|
||||
const searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
|
||||
const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
|
||||
const isCollected = searchCollectList.some(item => item.path === path);
|
||||
const existingIndex = searchHistoryList.findIndex(item => item.path === path);
|
||||
if (!isCollected) {
|
||||
if (existingIndex !== -1) searchHistoryList.splice(existingIndex, 1);
|
||||
if (searchHistoryList.length >= historyNum) searchHistoryList.pop();
|
||||
searchHistoryList.unshift({ path, meta, type: HISTORY_TYPE });
|
||||
storageLocal().setItem(LOCALEHISTORYKEY, searchHistoryList);
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新存储的搜索记录 */
|
||||
function updateHistory() {
|
||||
let searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
|
||||
const historyIndex = searchHistoryList.findIndex(
|
||||
item => item.path === historyPath.value
|
||||
);
|
||||
if (historyIndex !== -1) {
|
||||
const [historyItem] = searchHistoryList.splice(historyIndex, 1);
|
||||
searchHistoryList.unshift(historyItem);
|
||||
setStorageItem(LOCALEHISTORYKEY, searchHistoryList);
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取本地历史记录 */
|
||||
function getHistory() {
|
||||
const searchHistoryList = getStorageItem(LOCALEHISTORYKEY);
|
||||
const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
|
||||
historyOptions.value = [...searchHistoryList, ...searchCollectList];
|
||||
historyPath.value = historyOptions.value[0]?.path;
|
||||
}
|
||||
|
||||
/** 拖拽改变收藏顺序 */
|
||||
function handleDrag(item: dragItem) {
|
||||
const searchCollectList = getStorageItem(LOCALECOLLECTKEY);
|
||||
const [reorderedItem] = searchCollectList.splice(item.oldIndex, 1);
|
||||
searchCollectList.splice(item.newIndex, 0, reorderedItem);
|
||||
storageLocal().setItem(LOCALECOLLECTKEY, searchCollectList);
|
||||
historyOptions.value = [
|
||||
...getStorageItem(LOCALEHISTORYKEY),
|
||||
...getStorageItem(LOCALECOLLECTKEY)
|
||||
];
|
||||
historyPath.value = reorderedItem.path;
|
||||
}
|
||||
|
||||
onKeyStroke("Enter", handleEnter);
|
||||
onKeyStroke("ArrowUp", handleUp);
|
||||
onKeyStroke("ArrowDown", handleDown);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="show"
|
||||
top="5vh"
|
||||
class="pure-search-dialog"
|
||||
:show-close="false"
|
||||
:width="device === 'mobile' ? '80vw' : '40vw'"
|
||||
:before-close="handleClose"
|
||||
:style="{
|
||||
borderRadius: '6px'
|
||||
}"
|
||||
append-to-body
|
||||
@opened="inputRef.focus()"
|
||||
@closed="inputRef.blur()"
|
||||
>
|
||||
<el-input
|
||||
ref="inputRef"
|
||||
v-model="keyword"
|
||||
size="large"
|
||||
clearable
|
||||
:placeholder="t('search.purePlaceholder')"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<IconifyIconOffline
|
||||
:icon="SearchIcon"
|
||||
class="text-primary w-[24px] h-[24px]"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="search-content">
|
||||
<el-scrollbar ref="scrollbarRef" max-height="calc(90vh - 140px)">
|
||||
<el-empty v-if="showEmpty" :description="t('search.pureEmpty')" />
|
||||
<SearchHistory
|
||||
v-if="showSearchHistory"
|
||||
ref="historyRef"
|
||||
v-model:value="historyPath"
|
||||
:options="historyOptions"
|
||||
@click="handleEnter"
|
||||
@delete="handleDelete"
|
||||
@collect="handleCollect"
|
||||
@drag="handleDrag"
|
||||
/>
|
||||
<SearchResult
|
||||
v-if="showSearchResult"
|
||||
ref="resultRef"
|
||||
v-model:value="activePath"
|
||||
:options="resultOptions"
|
||||
@click="handleEnter"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
<template #footer>
|
||||
<SearchFooter :total="resultOptions.length" />
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-content {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
import type { Props } from "../types";
|
||||
import { transformI18n } from "@/plugins/i18n";
|
||||
import { useResizeObserver } from "@pureadmin/utils";
|
||||
import { useEpThemeStoreHook } from "@/store/modules/epTheme";
|
||||
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
||||
import { ref, computed, getCurrentInstance, onMounted } from "vue";
|
||||
import EnterOutlined from "@/assets/svg/enter_outlined.svg?component";
|
||||
|
||||
interface Emits {
|
||||
(e: "update:value", val: string): void;
|
||||
(e: "enter"): void;
|
||||
}
|
||||
|
||||
const resultRef = ref();
|
||||
const innerHeight = ref();
|
||||
const emit = defineEmits<Emits>();
|
||||
const instance = getCurrentInstance()!;
|
||||
const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const itemStyle = computed(() => {
|
||||
return item => {
|
||||
return {
|
||||
background:
|
||||
item?.path === active.value ? useEpThemeStoreHook().epThemeColor : "",
|
||||
color: item.path === active.value ? "#fff" : "",
|
||||
fontSize: item.path === active.value ? "16px" : "14px"
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
const active = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
},
|
||||
set(val: string) {
|
||||
emit("update:value", val);
|
||||
}
|
||||
});
|
||||
|
||||
/** 鼠标移入 */
|
||||
async function handleMouse(item) {
|
||||
active.value = item.path;
|
||||
}
|
||||
|
||||
function handleTo() {
|
||||
emit("enter");
|
||||
}
|
||||
|
||||
function resizeResult() {
|
||||
// el-scrollbar max-height="calc(90vh - 140px)"
|
||||
innerHeight.value = window.innerHeight - window.innerHeight / 10 - 140;
|
||||
}
|
||||
|
||||
useResizeObserver(resultRef, resizeResult);
|
||||
|
||||
function handleScroll(index: number) {
|
||||
const curInstance = instance?.proxy?.$refs[`resultItemRef${index}`];
|
||||
if (!curInstance) return 0;
|
||||
const curRef = curInstance[0] as ElRef;
|
||||
const scrollTop = curRef.offsetTop + 128; // 128 两个result-item(56px+56px=112px)高度加上下margin(8px+8px=16px)
|
||||
return scrollTop > innerHeight.value ? scrollTop - innerHeight.value : 0;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resizeResult();
|
||||
});
|
||||
|
||||
defineExpose({ handleScroll });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="resultRef" class="result">
|
||||
<div
|
||||
v-for="(item, index) in options"
|
||||
:key="item.path"
|
||||
:ref="'resultItemRef' + index"
|
||||
class="result-item dark:bg-[#1d1d1d]"
|
||||
:style="itemStyle(item)"
|
||||
@click="handleTo"
|
||||
@mouseenter="handleMouse(item)"
|
||||
>
|
||||
<component :is="useRenderIcon(item.meta?.icon)" />
|
||||
<span class="result-item-title">
|
||||
{{ transformI18n(item.meta?.title) }}
|
||||
</span>
|
||||
<EnterOutlined />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.result {
|
||||
padding-bottom: 12px;
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
padding: 14px;
|
||||
margin-top: 8px;
|
||||
cursor: pointer;
|
||||
border: 0.1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
transition: font-size 0.16s;
|
||||
|
||||
&-title {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
Yi.Pure.Vue3/src/layout/components/lay-search/index.vue
Normal file
21
Yi.Pure.Vue3/src/layout/components/lay-search/index.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { useBoolean } from "../../hooks/useBoolean";
|
||||
import SearchModal from "./components/SearchModal.vue";
|
||||
|
||||
const { bool: show, toggle } = useBoolean();
|
||||
function handleSearch() {
|
||||
toggle();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="search-container w-[40px] h-[48px] flex-c cursor-pointer navbar-bg-hover"
|
||||
@click="handleSearch"
|
||||
>
|
||||
<IconifyIconOffline icon="ri:search-line" />
|
||||
</div>
|
||||
<SearchModal v-model:value="show" />
|
||||
</div>
|
||||
</template>
|
||||
20
Yi.Pure.Vue3/src/layout/components/lay-search/types.ts
Normal file
20
Yi.Pure.Vue3/src/layout/components/lay-search/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
interface optionsItem {
|
||||
path: string;
|
||||
type: "history" | "collect";
|
||||
meta: {
|
||||
icon?: string;
|
||||
title?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface dragItem {
|
||||
oldIndex: number;
|
||||
newIndex: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
options: Array<optionsItem>;
|
||||
}
|
||||
|
||||
export type { optionsItem, dragItem, Props };
|
||||
642
Yi.Pure.Vue3/src/layout/components/lay-setting/index.vue
Normal file
642
Yi.Pure.Vue3/src/layout/components/lay-setting/index.vue
Normal file
@@ -0,0 +1,642 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref,
|
||||
unref,
|
||||
watch,
|
||||
reactive,
|
||||
computed,
|
||||
nextTick,
|
||||
onUnmounted,
|
||||
onBeforeMount
|
||||
} from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { emitter } from "@/utils/mitt";
|
||||
import LayPanel from "../lay-panel/index.vue";
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
import { useAppStoreHook } from "@/store/modules/app";
|
||||
import { toggleTheme } from "@pureadmin/theme/dist/browser-utils";
|
||||
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
|
||||
import Segmented, { type OptionsType } from "@/components/ReSegmented";
|
||||
import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
|
||||
import { useDark, useGlobal, debounce, isNumber } from "@pureadmin/utils";
|
||||
|
||||
import Check from "@iconify-icons/ep/check";
|
||||
import LeftArrow from "@iconify-icons/ri/arrow-left-s-line";
|
||||
import RightArrow from "@iconify-icons/ri/arrow-right-s-line";
|
||||
import DayIcon from "@/assets/svg/day.svg?component";
|
||||
import DarkIcon from "@/assets/svg/dark.svg?component";
|
||||
import SystemIcon from "@/assets/svg/system.svg?component";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { device } = useNav();
|
||||
const { isDark } = useDark();
|
||||
const { $storage } = useGlobal<GlobalPropertiesApi>();
|
||||
|
||||
const mixRef = ref();
|
||||
const verticalRef = ref();
|
||||
const horizontalRef = ref();
|
||||
|
||||
const {
|
||||
dataTheme,
|
||||
overallStyle,
|
||||
layoutTheme,
|
||||
themeColors,
|
||||
toggleClass,
|
||||
dataThemeChange,
|
||||
setLayoutThemeColor
|
||||
} = useDataThemeChange();
|
||||
|
||||
/* body添加layout属性,作用于src/style/sidebar.scss */
|
||||
if (unref(layoutTheme)) {
|
||||
const layout = unref(layoutTheme).layout;
|
||||
const theme = unref(layoutTheme).theme;
|
||||
toggleTheme({
|
||||
scopeName: `layout-theme-${theme}`
|
||||
});
|
||||
setLayoutModel(layout);
|
||||
}
|
||||
|
||||
/** 默认灵动模式 */
|
||||
const markValue = ref($storage.configure?.showModel ?? "smart");
|
||||
|
||||
const logoVal = ref($storage.configure?.showLogo ?? true);
|
||||
|
||||
const settings = reactive({
|
||||
greyVal: $storage.configure.grey,
|
||||
weakVal: $storage.configure.weak,
|
||||
tabsVal: $storage.configure.hideTabs,
|
||||
showLogo: $storage.configure.showLogo,
|
||||
showModel: $storage.configure.showModel,
|
||||
hideFooter: $storage.configure.hideFooter,
|
||||
multiTagsCache: $storage.configure.multiTagsCache,
|
||||
stretch: $storage.configure.stretch
|
||||
});
|
||||
|
||||
const getThemeColorStyle = computed(() => {
|
||||
return color => {
|
||||
return { background: color };
|
||||
};
|
||||
});
|
||||
|
||||
/** 当网页整体为暗色风格时不显示亮白色主题配色切换选项 */
|
||||
const showThemeColors = computed(() => {
|
||||
return themeColor => {
|
||||
return themeColor === "light" && isDark.value ? false : true;
|
||||
};
|
||||
});
|
||||
|
||||
function storageConfigureChange<T>(key: string, val: T): void {
|
||||
const storageConfigure = $storage.configure;
|
||||
storageConfigure[key] = val;
|
||||
$storage.configure = storageConfigure;
|
||||
}
|
||||
|
||||
/** 灰色模式设置 */
|
||||
const greyChange = (value): void => {
|
||||
const htmlEl = document.querySelector("html");
|
||||
toggleClass(settings.greyVal, "html-grey", htmlEl);
|
||||
storageConfigureChange("grey", value);
|
||||
};
|
||||
|
||||
/** 色弱模式设置 */
|
||||
const weekChange = (value): void => {
|
||||
const htmlEl = document.querySelector("html");
|
||||
toggleClass(settings.weakVal, "html-weakness", htmlEl);
|
||||
storageConfigureChange("weak", value);
|
||||
};
|
||||
|
||||
/** 隐藏标签页设置 */
|
||||
const tagsChange = () => {
|
||||
const showVal = settings.tabsVal;
|
||||
storageConfigureChange("hideTabs", showVal);
|
||||
emitter.emit("tagViewsChange", showVal as unknown as string);
|
||||
};
|
||||
|
||||
/** 隐藏页脚设置 */
|
||||
const hideFooterChange = () => {
|
||||
const hideFooter = settings.hideFooter;
|
||||
storageConfigureChange("hideFooter", hideFooter);
|
||||
};
|
||||
|
||||
/** 标签页持久化设置 */
|
||||
const multiTagsCacheChange = () => {
|
||||
const multiTagsCache = settings.multiTagsCache;
|
||||
storageConfigureChange("multiTagsCache", multiTagsCache);
|
||||
useMultiTagsStoreHook().multiTagsCacheChange(multiTagsCache);
|
||||
};
|
||||
|
||||
function onChange({ option }) {
|
||||
const { value } = option;
|
||||
markValue.value = value;
|
||||
storageConfigureChange("showModel", value);
|
||||
emitter.emit("tagViewsShowModel", value);
|
||||
}
|
||||
|
||||
/** 侧边栏Logo */
|
||||
function logoChange() {
|
||||
unref(logoVal)
|
||||
? storageConfigureChange("showLogo", true)
|
||||
: storageConfigureChange("showLogo", false);
|
||||
emitter.emit("logoChange", unref(logoVal));
|
||||
}
|
||||
|
||||
function setFalse(Doms): any {
|
||||
Doms.forEach(v => {
|
||||
toggleClass(false, "is-select", unref(v));
|
||||
});
|
||||
}
|
||||
|
||||
/** 页宽 */
|
||||
const stretchTypeOptions = computed<Array<OptionsType>>(() => {
|
||||
return [
|
||||
{
|
||||
label: t("panel.pureStretchFixed"),
|
||||
tip: t("panel.pureStretchFixedTip"),
|
||||
value: "fixed"
|
||||
},
|
||||
{
|
||||
label: t("panel.pureStretchCustom"),
|
||||
tip: t("panel.pureStretchCustomTip"),
|
||||
value: "custom"
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
const setStretch = value => {
|
||||
settings.stretch = value;
|
||||
storageConfigureChange("stretch", value);
|
||||
};
|
||||
|
||||
const stretchTypeChange = ({ option }) => {
|
||||
const { value } = option;
|
||||
value === "custom" ? setStretch(1440) : setStretch(false);
|
||||
};
|
||||
|
||||
/** 主题色 激活选择项 */
|
||||
const getThemeColor = computed(() => {
|
||||
return current => {
|
||||
if (
|
||||
current === layoutTheme.value.theme &&
|
||||
layoutTheme.value.theme !== "light"
|
||||
) {
|
||||
return "#fff";
|
||||
} else if (
|
||||
current === layoutTheme.value.theme &&
|
||||
layoutTheme.value.theme === "light"
|
||||
) {
|
||||
return "#1d2b45";
|
||||
} else {
|
||||
return "transparent";
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const pClass = computed(() => {
|
||||
return ["mb-[12px]", "font-medium", "text-sm", "dark:text-white"];
|
||||
});
|
||||
|
||||
const themeOptions = computed<Array<OptionsType>>(() => {
|
||||
return [
|
||||
{
|
||||
label: t("panel.pureOverallStyleLight"),
|
||||
icon: DayIcon,
|
||||
theme: "light",
|
||||
tip: t("panel.pureOverallStyleLightTip"),
|
||||
iconAttrs: { fill: isDark.value ? "#fff" : "#000" }
|
||||
},
|
||||
{
|
||||
label: t("panel.pureOverallStyleDark"),
|
||||
icon: DarkIcon,
|
||||
theme: "dark",
|
||||
tip: t("panel.pureOverallStyleDarkTip"),
|
||||
iconAttrs: { fill: isDark.value ? "#fff" : "#000" }
|
||||
},
|
||||
{
|
||||
label: t("panel.pureOverallStyleSystem"),
|
||||
icon: SystemIcon,
|
||||
theme: "system",
|
||||
tip: t("panel.pureOverallStyleSystemTip"),
|
||||
iconAttrs: { fill: isDark.value ? "#fff" : "#000" }
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
const markOptions = computed<Array<OptionsType>>(() => {
|
||||
return [
|
||||
{
|
||||
label: t("panel.pureTagsStyleSmart"),
|
||||
tip: t("panel.pureTagsStyleSmartTip"),
|
||||
value: "smart"
|
||||
},
|
||||
{
|
||||
label: t("panel.pureTagsStyleCard"),
|
||||
tip: t("panel.pureTagsStyleCardTip"),
|
||||
value: "card"
|
||||
},
|
||||
{
|
||||
label: t("panel.pureTagsStyleChrome"),
|
||||
tip: t("panel.pureTagsStyleChromeTip"),
|
||||
value: "chrome"
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
/** 设置导航模式 */
|
||||
function setLayoutModel(layout: string) {
|
||||
layoutTheme.value.layout = layout;
|
||||
window.document.body.setAttribute("layout", layout);
|
||||
$storage.layout = {
|
||||
layout,
|
||||
theme: layoutTheme.value.theme,
|
||||
darkMode: $storage.layout?.darkMode,
|
||||
sidebarStatus: $storage.layout?.sidebarStatus,
|
||||
epThemeColor: $storage.layout?.epThemeColor,
|
||||
themeColor: $storage.layout?.themeColor,
|
||||
overallStyle: $storage.layout?.overallStyle
|
||||
};
|
||||
useAppStoreHook().setLayout(layout);
|
||||
}
|
||||
|
||||
watch($storage, ({ layout }) => {
|
||||
switch (layout["layout"]) {
|
||||
case "vertical":
|
||||
toggleClass(true, "is-select", unref(verticalRef));
|
||||
debounce(setFalse([horizontalRef]), 50);
|
||||
debounce(setFalse([mixRef]), 50);
|
||||
break;
|
||||
case "horizontal":
|
||||
toggleClass(true, "is-select", unref(horizontalRef));
|
||||
debounce(setFalse([verticalRef]), 50);
|
||||
debounce(setFalse([mixRef]), 50);
|
||||
break;
|
||||
case "mix":
|
||||
toggleClass(true, "is-select", unref(mixRef));
|
||||
debounce(setFalse([verticalRef]), 50);
|
||||
debounce(setFalse([horizontalRef]), 50);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
/** 根据操作系统主题设置平台整体风格 */
|
||||
function updateTheme() {
|
||||
if (overallStyle.value !== "system") return;
|
||||
if (mediaQueryList.matches) {
|
||||
dataTheme.value = true;
|
||||
} else {
|
||||
dataTheme.value = false;
|
||||
}
|
||||
dataThemeChange(overallStyle.value);
|
||||
}
|
||||
|
||||
function removeMatchMedia() {
|
||||
mediaQueryList.removeEventListener("change", updateTheme);
|
||||
}
|
||||
|
||||
/** 监听操作系统主题改变 */
|
||||
function watchSystemThemeChange() {
|
||||
updateTheme();
|
||||
removeMatchMedia();
|
||||
mediaQueryList.addEventListener("change", updateTheme);
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
/* 初始化系统配置 */
|
||||
nextTick(() => {
|
||||
watchSystemThemeChange();
|
||||
settings.greyVal &&
|
||||
document.querySelector("html")?.classList.add("html-grey");
|
||||
settings.weakVal &&
|
||||
document.querySelector("html")?.classList.add("html-weakness");
|
||||
settings.tabsVal && tagsChange();
|
||||
settings.hideFooter && hideFooterChange();
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => removeMatchMedia);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayPanel>
|
||||
<div class="p-5">
|
||||
<p :class="pClass">{{ t("panel.pureOverallStyle") }}</p>
|
||||
<Segmented
|
||||
resize
|
||||
class="select-none"
|
||||
:modelValue="overallStyle === 'system' ? 2 : dataTheme ? 1 : 0"
|
||||
:options="themeOptions"
|
||||
@change="
|
||||
theme => {
|
||||
theme.index === 1 && theme.index !== 2
|
||||
? (dataTheme = true)
|
||||
: (dataTheme = false);
|
||||
overallStyle = theme.option.theme;
|
||||
dataThemeChange(theme.option.theme);
|
||||
theme.index === 2 && watchSystemThemeChange();
|
||||
}
|
||||
"
|
||||
/>
|
||||
|
||||
<p :class="['mt-5', pClass]">{{ t("panel.pureThemeColor") }}</p>
|
||||
<ul class="theme-color">
|
||||
<li
|
||||
v-for="(item, index) in themeColors"
|
||||
v-show="showThemeColors(item.themeColor)"
|
||||
:key="index"
|
||||
:style="getThemeColorStyle(item.color)"
|
||||
@click="setLayoutThemeColor(item.themeColor)"
|
||||
>
|
||||
<el-icon
|
||||
style="margin: 0.1em 0.1em 0 0"
|
||||
:size="17"
|
||||
:color="getThemeColor(item.themeColor)"
|
||||
>
|
||||
<IconifyIconOffline :icon="Check" />
|
||||
</el-icon>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p :class="['mt-5', pClass]">{{ t("panel.pureLayoutModel") }}</p>
|
||||
<ul class="pure-theme">
|
||||
<li
|
||||
ref="verticalRef"
|
||||
v-tippy="{
|
||||
content: t('panel.pureVerticalTip'),
|
||||
zIndex: 41000
|
||||
}"
|
||||
:class="layoutTheme.layout === 'vertical' ? 'is-select' : ''"
|
||||
@click="setLayoutModel('vertical')"
|
||||
>
|
||||
<div />
|
||||
<div />
|
||||
</li>
|
||||
<li
|
||||
v-if="device !== 'mobile'"
|
||||
ref="horizontalRef"
|
||||
v-tippy="{
|
||||
content: t('panel.pureHorizontalTip'),
|
||||
zIndex: 41000
|
||||
}"
|
||||
:class="layoutTheme.layout === 'horizontal' ? 'is-select' : ''"
|
||||
@click="setLayoutModel('horizontal')"
|
||||
>
|
||||
<div />
|
||||
<div />
|
||||
</li>
|
||||
<li
|
||||
v-if="device !== 'mobile'"
|
||||
ref="mixRef"
|
||||
v-tippy="{
|
||||
content: t('panel.pureMixTip'),
|
||||
zIndex: 41000
|
||||
}"
|
||||
:class="layoutTheme.layout === 'mix' ? 'is-select' : ''"
|
||||
@click="setLayoutModel('mix')"
|
||||
>
|
||||
<div />
|
||||
<div />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<span v-if="useAppStoreHook().getViewportWidth > 1280">
|
||||
<p :class="['mt-5', pClass]">{{ t("panel.pureStretch") }}</p>
|
||||
<Segmented
|
||||
resize
|
||||
class="mb-2 select-none"
|
||||
:modelValue="isNumber(settings.stretch) ? 1 : 0"
|
||||
:options="stretchTypeOptions"
|
||||
@change="stretchTypeChange"
|
||||
/>
|
||||
<el-input-number
|
||||
v-if="isNumber(settings.stretch)"
|
||||
v-model="settings.stretch as number"
|
||||
:min="1280"
|
||||
:max="1600"
|
||||
controls-position="right"
|
||||
@change="value => setStretch(value)"
|
||||
/>
|
||||
<button
|
||||
v-else
|
||||
v-ripple="{ class: 'text-gray-300' }"
|
||||
class="bg-transparent flex-c w-full h-20 rounded-md border border-[var(--pure-border-color)]"
|
||||
@click="setStretch(!settings.stretch)"
|
||||
>
|
||||
<div
|
||||
class="flex-bc transition-all duration-300"
|
||||
:class="[settings.stretch ? 'w-[24%]' : 'w-[50%]']"
|
||||
style="color: var(--el-color-primary)"
|
||||
>
|
||||
<IconifyIconOffline
|
||||
:icon="settings.stretch ? RightArrow : LeftArrow"
|
||||
height="20"
|
||||
/>
|
||||
<div
|
||||
class="flex-grow border-b border-dashed"
|
||||
style="border-color: var(--el-color-primary)"
|
||||
/>
|
||||
<IconifyIconOffline
|
||||
:icon="settings.stretch ? LeftArrow : RightArrow"
|
||||
height="20"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<p :class="['mt-4', pClass]">{{ t("panel.pureTagsStyle") }}</p>
|
||||
<Segmented
|
||||
resize
|
||||
class="select-none"
|
||||
:modelValue="markValue === 'smart' ? 0 : markValue === 'card' ? 1 : 2"
|
||||
:options="markOptions"
|
||||
@change="onChange"
|
||||
/>
|
||||
|
||||
<p class="mt-5 font-medium text-sm dark:text-white">
|
||||
{{ t("panel.pureInterfaceDisplay") }}
|
||||
</p>
|
||||
<ul class="setting">
|
||||
<li>
|
||||
<span class="dark:text-white">{{ t("panel.pureGreyModel") }}</span>
|
||||
<el-switch
|
||||
v-model="settings.greyVal"
|
||||
inline-prompt
|
||||
:active-text="t('buttons.pureOpenText')"
|
||||
:inactive-text="t('buttons.pureCloseText')"
|
||||
@change="greyChange"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dark:text-white">{{ t("panel.pureWeakModel") }}</span>
|
||||
<el-switch
|
||||
v-model="settings.weakVal"
|
||||
inline-prompt
|
||||
:active-text="t('buttons.pureOpenText')"
|
||||
:inactive-text="t('buttons.pureCloseText')"
|
||||
@change="weekChange"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dark:text-white">{{ t("panel.pureHiddenTags") }}</span>
|
||||
<el-switch
|
||||
v-model="settings.tabsVal"
|
||||
inline-prompt
|
||||
:active-text="t('buttons.pureOpenText')"
|
||||
:inactive-text="t('buttons.pureCloseText')"
|
||||
@change="tagsChange"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dark:text-white">{{ t("panel.pureHiddenFooter") }}</span>
|
||||
<el-switch
|
||||
v-model="settings.hideFooter"
|
||||
inline-prompt
|
||||
:active-text="t('buttons.pureOpenText')"
|
||||
:inactive-text="t('buttons.pureCloseText')"
|
||||
@change="hideFooterChange"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dark:text-white">Logo</span>
|
||||
<el-switch
|
||||
v-model="logoVal"
|
||||
inline-prompt
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
:active-text="t('buttons.pureOpenText')"
|
||||
:inactive-text="t('buttons.pureCloseText')"
|
||||
@change="logoChange"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<span class="dark:text-white">
|
||||
{{ t("panel.pureMultiTagsCache") }}
|
||||
</span>
|
||||
<el-switch
|
||||
v-model="settings.multiTagsCache"
|
||||
inline-prompt
|
||||
:active-text="t('buttons.pureOpenText')"
|
||||
:inactive-text="t('buttons.pureCloseText')"
|
||||
@change="multiTagsCacheChange"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</LayPanel>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-divider__text) {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:deep(.el-switch__core) {
|
||||
--el-switch-off-color: var(--pure-switch-off-color);
|
||||
|
||||
min-width: 36px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
:deep(.el-switch__core .el-switch__action) {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.theme-color {
|
||||
height: 20px;
|
||||
|
||||
li {
|
||||
float: left;
|
||||
height: 20px;
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
&:nth-child(1) {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pure-theme {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
width: 46px;
|
||||
height: 36px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: #f0f2f5;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 2.5px 0 rgb(0 0 0 / 18%);
|
||||
|
||||
&:nth-child(1) {
|
||||
div {
|
||||
&:nth-child(1) {
|
||||
width: 30%;
|
||||
height: 100%;
|
||||
background: #1b2a47;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 70%;
|
||||
height: 30%;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 1px #888;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
div {
|
||||
&:nth-child(1) {
|
||||
width: 100%;
|
||||
height: 30%;
|
||||
background: #1b2a47;
|
||||
box-shadow: 0 0 1px #888;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
div {
|
||||
&:nth-child(1) {
|
||||
width: 100%;
|
||||
height: 30%;
|
||||
background: #1b2a47;
|
||||
box-shadow: 0 0 1px #888;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 30%;
|
||||
height: 70%;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 1px #888;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-select {
|
||||
border: 2px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
.setting {
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 3px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
184
Yi.Pure.Vue3/src/layout/components/lay-sidebar/NavHorizontal.vue
Normal file
184
Yi.Pure.Vue3/src/layout/components/lay-sidebar/NavHorizontal.vue
Normal file
@@ -0,0 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { emitter } from "@/utils/mitt";
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
import LaySearch from "../lay-search/index.vue";
|
||||
import LayNotice from "../lay-notice/index.vue";
|
||||
import { responsiveStorageNameSpace } from "@/config";
|
||||
import { ref, nextTick, computed, onMounted } from "vue";
|
||||
import { storageLocal, isAllEmpty } from "@pureadmin/utils";
|
||||
import { useTranslationLang } from "../../hooks/useTranslationLang";
|
||||
import { usePermissionStoreHook } from "@/store/modules/permission";
|
||||
import LaySidebarItem from "../lay-sidebar/components/SidebarItem.vue";
|
||||
import LaySidebarFullScreen from "../lay-sidebar/components/SidebarFullScreen.vue";
|
||||
|
||||
import GlobalizationIcon from "@/assets/svg/globalization.svg?component";
|
||||
import AccountSettingsIcon from "@iconify-icons/ri/user-settings-line";
|
||||
import LogoutCircleRLine from "@iconify-icons/ri/logout-circle-r-line";
|
||||
import Setting from "@iconify-icons/ri/settings-3-line";
|
||||
import Check from "@iconify-icons/ep/check";
|
||||
|
||||
const menuRef = ref();
|
||||
const showLogo = ref(
|
||||
storageLocal().getItem<StorageConfigs>(
|
||||
`${responsiveStorageNameSpace()}configure`
|
||||
)?.showLogo ?? true
|
||||
);
|
||||
|
||||
const { t, route, locale, translationCh, translationEn } =
|
||||
useTranslationLang(menuRef);
|
||||
const {
|
||||
title,
|
||||
logout,
|
||||
onPanel,
|
||||
getLogo,
|
||||
username,
|
||||
userAvatar,
|
||||
backTopMenu,
|
||||
avatarsStyle,
|
||||
toAccountSettings,
|
||||
getDropdownItemStyle,
|
||||
getDropdownItemClass
|
||||
} = useNav();
|
||||
|
||||
const defaultActive = computed(() =>
|
||||
!isAllEmpty(route.meta?.activePath) ? route.meta.activePath : route.path
|
||||
);
|
||||
|
||||
nextTick(() => {
|
||||
menuRef.value?.handleResize();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on("logoChange", key => {
|
||||
showLogo.value = key;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-loading="usePermissionStoreHook().wholeMenus.length === 0"
|
||||
class="horizontal-header"
|
||||
>
|
||||
<div v-if="showLogo" class="horizontal-header-left" @click="backTopMenu">
|
||||
<img :src="getLogo()" alt="logo" />
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
<el-menu
|
||||
ref="menuRef"
|
||||
mode="horizontal"
|
||||
popper-class="pure-scrollbar"
|
||||
class="horizontal-header-menu"
|
||||
:default-active="defaultActive"
|
||||
>
|
||||
<LaySidebarItem
|
||||
v-for="route in usePermissionStoreHook().wholeMenus"
|
||||
:key="route.path"
|
||||
:item="route"
|
||||
:base-path="route.path"
|
||||
/>
|
||||
</el-menu>
|
||||
<div class="horizontal-header-right">
|
||||
<!-- 菜单搜索 -->
|
||||
<LaySearch id="header-search" />
|
||||
<!-- 国际化 -->
|
||||
<el-dropdown id="header-translation" trigger="click">
|
||||
<GlobalizationIcon
|
||||
class="navbar-bg-hover w-[40px] h-[48px] p-[11px] cursor-pointer outline-none"
|
||||
/>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="translation">
|
||||
<el-dropdown-item
|
||||
:style="getDropdownItemStyle(locale, 'zh')"
|
||||
:class="['dark:!text-white', getDropdownItemClass(locale, 'zh')]"
|
||||
@click="translationCh"
|
||||
>
|
||||
<span v-show="locale === 'zh'" class="check-zh">
|
||||
<IconifyIconOffline :icon="Check" />
|
||||
</span>
|
||||
简体中文
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
:style="getDropdownItemStyle(locale, 'en')"
|
||||
:class="['dark:!text-white', getDropdownItemClass(locale, 'en')]"
|
||||
@click="translationEn"
|
||||
>
|
||||
<span v-show="locale === 'en'" class="check-en">
|
||||
<IconifyIconOffline :icon="Check" />
|
||||
</span>
|
||||
English
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<!-- 全屏 -->
|
||||
<LaySidebarFullScreen id="full-screen" />
|
||||
<!-- 消息通知 -->
|
||||
<LayNotice id="header-notice" />
|
||||
<!-- 退出登录 -->
|
||||
<el-dropdown trigger="click">
|
||||
<span class="el-dropdown-link navbar-bg-hover">
|
||||
<img :src="userAvatar" :style="avatarsStyle" />
|
||||
<p v-if="username" class="dark:text-white">{{ username }}</p>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-item @click="toAccountSettings">
|
||||
<IconifyIconOffline
|
||||
:icon="AccountSettingsIcon"
|
||||
style="margin: 5px"
|
||||
/>
|
||||
{{ t("buttons.pureAccountSettings") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-menu class="logout">
|
||||
<el-dropdown-item @click="logout">
|
||||
<IconifyIconOffline
|
||||
:icon="LogoutCircleRLine"
|
||||
style="margin: 5px"
|
||||
/>
|
||||
{{ t("buttons.pureLoginOut") }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<span
|
||||
class="set-icon navbar-bg-hover"
|
||||
:title="t('buttons.pureOpenSystemSet')"
|
||||
@click="onPanel"
|
||||
>
|
||||
<IconifyIconOffline :icon="Setting" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-loading-mask) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.translation {
|
||||
::v-deep(.el-dropdown-menu__item) {
|
||||
padding: 5px 40px;
|
||||
}
|
||||
|
||||
.check-zh {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.check-en {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.logout {
|
||||
width: 120px;
|
||||
|
||||
::v-deep(.el-dropdown-menu__item) {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
205
Yi.Pure.Vue3/src/layout/components/lay-sidebar/NavMix.vue
Normal file
205
Yi.Pure.Vue3/src/layout/components/lay-sidebar/NavMix.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import { isAllEmpty } from "@pureadmin/utils";
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
import { transformI18n } from "@/plugins/i18n";
|
||||
import LaySearch from "../lay-search/index.vue";
|
||||
import LayNotice from "../lay-notice/index.vue";
|
||||
import { ref, toRaw, watch, onMounted, nextTick } from "vue";
|
||||
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
||||
import { getParentPaths, findRouteByPath } from "@/router/utils";
|
||||
import { useTranslationLang } from "../../hooks/useTranslationLang";
|
||||
import { usePermissionStoreHook } from "@/store/modules/permission";
|
||||
import LaySidebarExtraIcon from "../lay-sidebar/components/SidebarExtraIcon.vue";
|
||||
import LaySidebarFullScreen from "../lay-sidebar/components/SidebarFullScreen.vue";
|
||||
|
||||
import GlobalizationIcon from "@/assets/svg/globalization.svg?component";
|
||||
import AccountSettingsIcon from "@iconify-icons/ri/user-settings-line";
|
||||
import LogoutCircleRLine from "@iconify-icons/ri/logout-circle-r-line";
|
||||
import Setting from "@iconify-icons/ri/settings-3-line";
|
||||
import Check from "@iconify-icons/ep/check";
|
||||
|
||||
const menuRef = ref();
|
||||
const defaultActive = ref(null);
|
||||
|
||||
const { t, route, locale, translationCh, translationEn } =
|
||||
useTranslationLang(menuRef);
|
||||
const {
|
||||
device,
|
||||
logout,
|
||||
onPanel,
|
||||
resolvePath,
|
||||
username,
|
||||
userAvatar,
|
||||
getDivStyle,
|
||||
avatarsStyle,
|
||||
toAccountSettings,
|
||||
getDropdownItemStyle,
|
||||
getDropdownItemClass
|
||||
} = useNav();
|
||||
|
||||
function getDefaultActive(routePath) {
|
||||
const wholeMenus = usePermissionStoreHook().wholeMenus;
|
||||
/** 当前路由的父级路径 */
|
||||
const parentRoutes = getParentPaths(routePath, wholeMenus)[0];
|
||||
defaultActive.value = !isAllEmpty(route.meta?.activePath)
|
||||
? route.meta.activePath
|
||||
: findRouteByPath(parentRoutes, wholeMenus)?.children[0]?.path;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getDefaultActive(route.path);
|
||||
});
|
||||
|
||||
nextTick(() => {
|
||||
menuRef.value?.handleResize();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [route.path, usePermissionStoreHook().wholeMenus],
|
||||
() => {
|
||||
getDefaultActive(route.path);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="device !== 'mobile'"
|
||||
v-loading="usePermissionStoreHook().wholeMenus.length === 0"
|
||||
class="horizontal-header"
|
||||
>
|
||||
<el-menu
|
||||
ref="menuRef"
|
||||
router
|
||||
mode="horizontal"
|
||||
popper-class="pure-scrollbar"
|
||||
class="horizontal-header-menu"
|
||||
:default-active="defaultActive"
|
||||
>
|
||||
<el-menu-item
|
||||
v-for="route in usePermissionStoreHook().wholeMenus"
|
||||
:key="route.path"
|
||||
:index="resolvePath(route) || route.redirect"
|
||||
>
|
||||
<template #title>
|
||||
<div
|
||||
v-if="toRaw(route.meta.icon)"
|
||||
:class="['sub-menu-icon', route.meta.icon]"
|
||||
>
|
||||
<component
|
||||
:is="useRenderIcon(route.meta && toRaw(route.meta.icon))"
|
||||
/>
|
||||
</div>
|
||||
<div :style="getDivStyle">
|
||||
<span class="select-none">
|
||||
{{ transformI18n(route.meta.title) }}
|
||||
</span>
|
||||
<LaySidebarExtraIcon :extraIcon="route.meta.extraIcon" />
|
||||
</div>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<div class="horizontal-header-right">
|
||||
<!-- 菜单搜索 -->
|
||||
<LaySearch id="header-search" />
|
||||
<!-- 国际化 -->
|
||||
<el-dropdown id="header-translation" trigger="click">
|
||||
<GlobalizationIcon
|
||||
class="navbar-bg-hover w-[40px] h-[48px] p-[11px] cursor-pointer outline-none"
|
||||
/>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu class="translation">
|
||||
<el-dropdown-item
|
||||
:style="getDropdownItemStyle(locale, 'zh')"
|
||||
:class="['dark:!text-white', getDropdownItemClass(locale, 'zh')]"
|
||||
@click="translationCh"
|
||||
>
|
||||
<span v-show="locale === 'zh'" class="check-zh">
|
||||
<IconifyIconOffline :icon="Check" />
|
||||
</span>
|
||||
简体中文
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
:style="getDropdownItemStyle(locale, 'en')"
|
||||
:class="['dark:!text-white', getDropdownItemClass(locale, 'en')]"
|
||||
@click="translationEn"
|
||||
>
|
||||
<span v-show="locale === 'en'" class="check-en">
|
||||
<IconifyIconOffline :icon="Check" />
|
||||
</span>
|
||||
English
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<!-- 全屏 -->
|
||||
<LaySidebarFullScreen id="full-screen" />
|
||||
<!-- 消息通知 -->
|
||||
<LayNotice id="header-notice" />
|
||||
<!-- 退出登录 -->
|
||||
<el-dropdown trigger="click">
|
||||
<span class="el-dropdown-link navbar-bg-hover select-none">
|
||||
<img :src="userAvatar" :style="avatarsStyle" />
|
||||
<p v-if="username" class="dark:text-white">{{ username }}</p>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-item @click="toAccountSettings">
|
||||
<IconifyIconOffline
|
||||
:icon="AccountSettingsIcon"
|
||||
style="margin: 5px"
|
||||
/>
|
||||
{{ t("buttons.pureAccountSettings") }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-menu class="logout">
|
||||
<el-dropdown-item @click="logout">
|
||||
<IconifyIconOffline
|
||||
:icon="LogoutCircleRLine"
|
||||
style="margin: 5px"
|
||||
/>
|
||||
{{ t("buttons.pureLoginOut") }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<span
|
||||
class="set-icon navbar-bg-hover"
|
||||
:title="t('buttons.pureOpenSystemSet')"
|
||||
@click="onPanel"
|
||||
>
|
||||
<IconifyIconOffline :icon="Setting" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-loading-mask) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.translation {
|
||||
::v-deep(.el-dropdown-menu__item) {
|
||||
padding: 5px 40px;
|
||||
}
|
||||
|
||||
.check-zh {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.check-en {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.logout {
|
||||
width: 120px;
|
||||
|
||||
::v-deep(.el-dropdown-menu__item) {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
137
Yi.Pure.Vue3/src/layout/components/lay-sidebar/NavVertical.vue
Normal file
137
Yi.Pure.Vue3/src/layout/components/lay-sidebar/NavVertical.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from "vue-router";
|
||||
import { emitter } from "@/utils/mitt";
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
import { responsiveStorageNameSpace } from "@/config";
|
||||
import { storageLocal, isAllEmpty } from "@pureadmin/utils";
|
||||
import { findRouteByPath, getParentPaths } from "@/router/utils";
|
||||
import { usePermissionStoreHook } from "@/store/modules/permission";
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import LaySidebarLogo from "../lay-sidebar/components/SidebarLogo.vue";
|
||||
import LaySidebarItem from "../lay-sidebar/components/SidebarItem.vue";
|
||||
import LaySidebarLeftCollapse from "../lay-sidebar/components/SidebarLeftCollapse.vue";
|
||||
import LaySidebarCenterCollapse from "../lay-sidebar/components/SidebarCenterCollapse.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const isShow = ref(false);
|
||||
const showLogo = ref(
|
||||
storageLocal().getItem<StorageConfigs>(
|
||||
`${responsiveStorageNameSpace()}configure`
|
||||
)?.showLogo ?? true
|
||||
);
|
||||
|
||||
const {
|
||||
device,
|
||||
pureApp,
|
||||
isCollapse,
|
||||
tooltipEffect,
|
||||
menuSelect,
|
||||
toggleSideBar
|
||||
} = useNav();
|
||||
|
||||
const subMenuData = ref([]);
|
||||
|
||||
const menuData = computed(() => {
|
||||
return pureApp.layout === "mix" && device.value !== "mobile"
|
||||
? subMenuData.value
|
||||
: usePermissionStoreHook().wholeMenus;
|
||||
});
|
||||
|
||||
const loading = computed(() =>
|
||||
pureApp.layout === "mix" ? false : menuData.value.length === 0 ? true : false
|
||||
);
|
||||
|
||||
const defaultActive = computed(() =>
|
||||
!isAllEmpty(route.meta?.activePath) ? route.meta.activePath : route.path
|
||||
);
|
||||
|
||||
function getSubMenuData() {
|
||||
let path = "";
|
||||
path = defaultActive.value;
|
||||
subMenuData.value = [];
|
||||
// path的上级路由组成的数组
|
||||
const parentPathArr = getParentPaths(
|
||||
path,
|
||||
usePermissionStoreHook().wholeMenus
|
||||
);
|
||||
// 当前路由的父级路由信息
|
||||
const parenetRoute = findRouteByPath(
|
||||
parentPathArr[0] || path,
|
||||
usePermissionStoreHook().wholeMenus
|
||||
);
|
||||
if (!parenetRoute?.children) return;
|
||||
subMenuData.value = parenetRoute?.children;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.path, usePermissionStoreHook().wholeMenus],
|
||||
() => {
|
||||
if (route.path.includes("/redirect")) return;
|
||||
getSubMenuData();
|
||||
menuSelect(route.path);
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
getSubMenuData();
|
||||
|
||||
emitter.on("logoChange", key => {
|
||||
showLogo.value = key;
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 解绑`logoChange`公共事件,防止多次触发
|
||||
emitter.off("logoChange");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-loading="loading"
|
||||
:class="['sidebar-container', showLogo ? 'has-logo' : 'no-logo']"
|
||||
@mouseenter.prevent="isShow = true"
|
||||
@mouseleave.prevent="isShow = false"
|
||||
>
|
||||
<LaySidebarLogo v-if="showLogo" :collapse="isCollapse" />
|
||||
<el-scrollbar
|
||||
wrap-class="scrollbar-wrapper"
|
||||
:class="[device === 'mobile' ? 'mobile' : 'pc']"
|
||||
>
|
||||
<el-menu
|
||||
unique-opened
|
||||
mode="vertical"
|
||||
popper-class="pure-scrollbar"
|
||||
class="outer-most select-none"
|
||||
:collapse="isCollapse"
|
||||
:collapse-transition="false"
|
||||
:popper-effect="tooltipEffect"
|
||||
:default-active="defaultActive"
|
||||
>
|
||||
<LaySidebarItem
|
||||
v-for="routes in menuData"
|
||||
:key="routes.path"
|
||||
:item="routes"
|
||||
:base-path="routes.path"
|
||||
class="outer-most select-none"
|
||||
/>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
<LaySidebarCenterCollapse
|
||||
v-if="device !== 'mobile' && (isShow || isCollapse)"
|
||||
:is-active="pureApp.sidebar.opened"
|
||||
@toggleClick="toggleSideBar"
|
||||
/>
|
||||
<LaySidebarLeftCollapse
|
||||
v-if="device !== 'mobile'"
|
||||
:is-active="pureApp.sidebar.opened"
|
||||
@toggleClick="toggleSideBar"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-loading-mask) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { isEqual } from "@pureadmin/utils";
|
||||
import { transformI18n } from "@/plugins/i18n";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ref, watch, onMounted, toRaw } from "vue";
|
||||
import { getParentPaths, findRouteByPath } from "@/router/utils";
|
||||
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
|
||||
|
||||
const route = useRoute();
|
||||
const levelList = ref([]);
|
||||
const router = useRouter();
|
||||
const routes: any = router.options.routes;
|
||||
const multiTags: any = useMultiTagsStoreHook().multiTags;
|
||||
|
||||
const getBreadcrumb = (): void => {
|
||||
// 当前路由信息
|
||||
let currentRoute;
|
||||
|
||||
if (Object.keys(route.query).length > 0) {
|
||||
multiTags.forEach(item => {
|
||||
if (isEqual(route.query, item?.query)) {
|
||||
currentRoute = toRaw(item);
|
||||
}
|
||||
});
|
||||
} else if (Object.keys(route.params).length > 0) {
|
||||
multiTags.forEach(item => {
|
||||
if (isEqual(route.params, item?.params)) {
|
||||
currentRoute = toRaw(item);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
currentRoute = findRouteByPath(router.currentRoute.value.path, routes);
|
||||
}
|
||||
|
||||
// 当前路由的父级路径组成的数组
|
||||
const parentRoutes = getParentPaths(
|
||||
router.currentRoute.value.name as string,
|
||||
routes,
|
||||
"name"
|
||||
);
|
||||
// 存放组成面包屑的数组
|
||||
const matched = [];
|
||||
|
||||
// 获取每个父级路径对应的路由信息
|
||||
parentRoutes.forEach(path => {
|
||||
if (path !== "/") matched.push(findRouteByPath(path, routes));
|
||||
});
|
||||
|
||||
matched.push(currentRoute);
|
||||
|
||||
matched.forEach((item, index) => {
|
||||
if (currentRoute?.query || currentRoute?.params) return;
|
||||
if (item?.children) {
|
||||
item.children.forEach(v => {
|
||||
if (v?.meta?.title === item?.meta?.title) {
|
||||
matched.splice(index, 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
levelList.value = matched.filter(
|
||||
item => item?.meta && item?.meta.title !== false
|
||||
);
|
||||
};
|
||||
|
||||
const handleLink = item => {
|
||||
const { redirect, name, path } = item;
|
||||
if (redirect) {
|
||||
router.push(redirect as any);
|
||||
} else {
|
||||
if (name) {
|
||||
if (item.query) {
|
||||
router.push({
|
||||
name,
|
||||
query: item.query
|
||||
});
|
||||
} else if (item.params) {
|
||||
router.push({
|
||||
name,
|
||||
params: item.params
|
||||
});
|
||||
} else {
|
||||
router.push({ name });
|
||||
}
|
||||
} else {
|
||||
router.push({ path });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getBreadcrumb();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
getBreadcrumb();
|
||||
},
|
||||
{
|
||||
deep: true
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-breadcrumb class="!leading-[50px] select-none" separator="/">
|
||||
<transition-group name="breadcrumb">
|
||||
<el-breadcrumb-item
|
||||
v-for="item in levelList"
|
||||
:key="item.path"
|
||||
class="!inline !items-stretch"
|
||||
>
|
||||
<a @click.prevent="handleLink(item)">
|
||||
{{ transformI18n(item.meta.title) }}
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
</transition-group>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useGlobal } from "@pureadmin/utils";
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
|
||||
import ArrowLeft from "@iconify-icons/ri/arrow-left-double-fill";
|
||||
|
||||
interface Props {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { tooltipEffect } = useNav();
|
||||
|
||||
const iconClass = computed(() => {
|
||||
return ["w-[16px]", "h-[16px]"];
|
||||
});
|
||||
|
||||
const { $storage } = useGlobal<GlobalPropertiesApi>();
|
||||
const themeColor = computed(() => $storage.layout?.themeColor);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "toggleClick"): void;
|
||||
}>();
|
||||
|
||||
const toggleClick = () => {
|
||||
emit("toggleClick");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-tippy="{
|
||||
content: isActive
|
||||
? t('buttons.pureClickCollapse')
|
||||
: t('buttons.pureClickExpand'),
|
||||
theme: tooltipEffect,
|
||||
hideOnClick: 'toggle',
|
||||
placement: 'right'
|
||||
}"
|
||||
class="center-collapse"
|
||||
@click="toggleClick"
|
||||
>
|
||||
<IconifyIconOffline
|
||||
:icon="ArrowLeft"
|
||||
:class="[iconClass, themeColor === 'light' ? '' : 'text-primary']"
|
||||
:style="{ transform: isActive ? 'none' : 'rotateY(180deg)' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.center-collapse {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 2px;
|
||||
z-index: 1002;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 34px;
|
||||
cursor: pointer;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--pure-border-color);
|
||||
border-radius: 4px;
|
||||
transform: translate(12px, -50%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { toRaw } from "vue";
|
||||
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
||||
|
||||
defineProps({
|
||||
extraIcon: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="extraIcon" class="flex justify-center items-center">
|
||||
<component
|
||||
:is="useRenderIcon(toRaw(extraIcon))"
|
||||
class="w-[30px] h-[30px]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
|
||||
const screenIcon = ref();
|
||||
const { toggle, isFullscreen, Fullscreen, ExitFullscreen } = useNav();
|
||||
|
||||
isFullscreen.value = !!(
|
||||
document.fullscreenElement ||
|
||||
document.webkitFullscreenElement ||
|
||||
document.mozFullScreenElement ||
|
||||
document.msFullscreenElement
|
||||
);
|
||||
|
||||
watch(
|
||||
isFullscreen,
|
||||
full => {
|
||||
screenIcon.value = full ? ExitFullscreen : Fullscreen;
|
||||
},
|
||||
{
|
||||
immediate: true
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="fullscreen-icon navbar-bg-hover" @click="toggle">
|
||||
<IconifyIconOffline :icon="screenIcon" />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import path from "path";
|
||||
import { getConfig } from "@/config";
|
||||
import { menuType } from "@/layout/types";
|
||||
import { ReText } from "@/components/ReText";
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
import { transformI18n } from "@/plugins/i18n";
|
||||
import SidebarLinkItem from "./SidebarLinkItem.vue";
|
||||
import SidebarExtraIcon from "./SidebarExtraIcon.vue";
|
||||
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
||||
import {
|
||||
type PropType,
|
||||
type CSSProperties,
|
||||
ref,
|
||||
toRaw,
|
||||
computed,
|
||||
useAttrs
|
||||
} from "vue";
|
||||
|
||||
import ArrowUp from "@iconify-icons/ep/arrow-up-bold";
|
||||
import EpArrowDown from "@iconify-icons/ep/arrow-down-bold";
|
||||
import ArrowLeft from "@iconify-icons/ep/arrow-left-bold";
|
||||
import ArrowRight from "@iconify-icons/ep/arrow-right-bold";
|
||||
|
||||
const attrs = useAttrs();
|
||||
const { layout, isCollapse, tooltipEffect, getDivStyle } = useNav();
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object as PropType<menuType>
|
||||
},
|
||||
isNest: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
basePath: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
});
|
||||
|
||||
const getNoDropdownStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
};
|
||||
});
|
||||
|
||||
const getSubMenuIconStyle = computed((): CSSProperties => {
|
||||
return {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
margin:
|
||||
layout.value === "horizontal"
|
||||
? "0 5px 0 0"
|
||||
: isCollapse.value
|
||||
? "0 auto"
|
||||
: "0 5px 0 0"
|
||||
};
|
||||
});
|
||||
|
||||
const expandCloseIcon = computed(() => {
|
||||
if (!getConfig()?.MenuArrowIconNoTransition) return "";
|
||||
return {
|
||||
"expand-close-icon": useRenderIcon(EpArrowDown),
|
||||
"expand-open-icon": useRenderIcon(ArrowUp),
|
||||
"collapse-close-icon": useRenderIcon(ArrowRight),
|
||||
"collapse-open-icon": useRenderIcon(ArrowLeft)
|
||||
};
|
||||
});
|
||||
|
||||
const onlyOneChild: menuType = ref(null);
|
||||
|
||||
function hasOneShowingChild(children: menuType[] = [], parent: menuType) {
|
||||
const showingChildren = children.filter((item: any) => {
|
||||
onlyOneChild.value = item;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (showingChildren[0]?.meta?.showParent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (showingChildren.length === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (showingChildren.length === 0) {
|
||||
onlyOneChild.value = { ...parent, path: "", noShowingChildren: true };
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolvePath(routePath) {
|
||||
const httpReg = /^http(s?):\/\//;
|
||||
if (httpReg.test(routePath) || httpReg.test(props.basePath)) {
|
||||
return routePath || props.basePath;
|
||||
} else {
|
||||
// 使用path.posix.resolve替代path.resolve 避免windows环境下使用electron出现盘符问题
|
||||
return path.posix.resolve(props.basePath, routePath);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidebarLinkItem
|
||||
v-if="
|
||||
hasOneShowingChild(item.children, item) &&
|
||||
(!onlyOneChild.children || onlyOneChild.noShowingChildren)
|
||||
"
|
||||
:to="item"
|
||||
>
|
||||
<el-menu-item
|
||||
:index="resolvePath(onlyOneChild.path)"
|
||||
:class="{ 'submenu-title-noDropdown': !isNest }"
|
||||
:style="getNoDropdownStyle"
|
||||
v-bind="attrs"
|
||||
>
|
||||
<div
|
||||
v-if="toRaw(item.meta.icon)"
|
||||
class="sub-menu-icon"
|
||||
:style="getSubMenuIconStyle"
|
||||
>
|
||||
<component
|
||||
:is="
|
||||
useRenderIcon(
|
||||
toRaw(onlyOneChild.meta.icon) ||
|
||||
(item.meta && toRaw(item.meta.icon))
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<el-text
|
||||
v-if="
|
||||
(!item?.meta.icon &&
|
||||
isCollapse &&
|
||||
layout === 'vertical' &&
|
||||
item?.pathList?.length === 1) ||
|
||||
(!onlyOneChild.meta.icon &&
|
||||
isCollapse &&
|
||||
layout === 'mix' &&
|
||||
item?.pathList?.length === 2)
|
||||
"
|
||||
truncated
|
||||
class="!w-full !pl-4 !text-inherit"
|
||||
>
|
||||
{{ transformI18n(onlyOneChild.meta.title) }}
|
||||
</el-text>
|
||||
|
||||
<template #title>
|
||||
<div :style="getDivStyle">
|
||||
<ReText
|
||||
:tippyProps="{
|
||||
offset: [0, -10],
|
||||
theme: tooltipEffect
|
||||
}"
|
||||
class="!w-full !text-inherit"
|
||||
>
|
||||
{{ transformI18n(onlyOneChild.meta.title) }}
|
||||
</ReText>
|
||||
<SidebarExtraIcon :extraIcon="onlyOneChild.meta.extraIcon" />
|
||||
</div>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</SidebarLinkItem>
|
||||
<el-sub-menu
|
||||
v-else
|
||||
ref="subMenu"
|
||||
teleported
|
||||
:index="resolvePath(item.path)"
|
||||
v-bind="expandCloseIcon"
|
||||
>
|
||||
<template #title>
|
||||
<div
|
||||
v-if="toRaw(item.meta.icon)"
|
||||
:style="getSubMenuIconStyle"
|
||||
class="sub-menu-icon"
|
||||
>
|
||||
<component :is="useRenderIcon(item.meta && toRaw(item.meta.icon))" />
|
||||
</div>
|
||||
<ReText
|
||||
v-if="
|
||||
layout === 'mix' && toRaw(item.meta.icon)
|
||||
? !isCollapse || item?.pathList?.length !== 2
|
||||
: !(
|
||||
layout === 'vertical' &&
|
||||
isCollapse &&
|
||||
toRaw(item.meta.icon) &&
|
||||
item.parentId === null
|
||||
)
|
||||
"
|
||||
:tippyProps="{
|
||||
offset: [0, -10],
|
||||
theme: tooltipEffect
|
||||
}"
|
||||
:class="{
|
||||
'!w-full': true,
|
||||
'!text-inherit': true,
|
||||
'!pl-4':
|
||||
layout !== 'horizontal' &&
|
||||
isCollapse &&
|
||||
!toRaw(item.meta.icon) &&
|
||||
item.parentId === null
|
||||
}"
|
||||
>
|
||||
{{ transformI18n(item.meta.title) }}
|
||||
</ReText>
|
||||
<SidebarExtraIcon v-if="!isCollapse" :extraIcon="item.meta.extraIcon" />
|
||||
</template>
|
||||
|
||||
<sidebar-item
|
||||
v-for="child in item.children"
|
||||
:key="child.path"
|
||||
:is-nest="true"
|
||||
:item="child"
|
||||
:base-path="resolvePath(child.path)"
|
||||
class="nest-menu"
|
||||
/>
|
||||
</el-sub-menu>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useGlobal } from "@pureadmin/utils";
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
|
||||
import MenuFold from "@iconify-icons/ri/menu-fold-fill";
|
||||
|
||||
interface Props {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { tooltipEffect } = useNav();
|
||||
|
||||
const iconClass = computed(() => {
|
||||
return [
|
||||
"ml-4",
|
||||
"mb-1",
|
||||
"w-[16px]",
|
||||
"h-[16px]",
|
||||
"inline-block",
|
||||
"align-middle",
|
||||
"cursor-pointer",
|
||||
"duration-[100ms]"
|
||||
];
|
||||
});
|
||||
|
||||
const { $storage } = useGlobal<GlobalPropertiesApi>();
|
||||
const themeColor = computed(() => $storage.layout?.themeColor);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "toggleClick"): void;
|
||||
}>();
|
||||
|
||||
const toggleClick = () => {
|
||||
emit("toggleClick");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="left-collapse">
|
||||
<IconifyIconOffline
|
||||
v-tippy="{
|
||||
content: isActive
|
||||
? t('buttons.pureClickCollapse')
|
||||
: t('buttons.pureClickExpand'),
|
||||
theme: tooltipEffect,
|
||||
hideOnClick: 'toggle',
|
||||
placement: 'right'
|
||||
}"
|
||||
:icon="MenuFold"
|
||||
:class="[iconClass, themeColor === 'light' ? '' : 'text-primary']"
|
||||
:style="{ transform: isActive ? 'none' : 'rotateY(180deg)' }"
|
||||
@click="toggleClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.left-collapse {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
box-shadow: 0 0 6px -3px var(--el-color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { isUrl } from "@pureadmin/utils";
|
||||
import { menuType } from "@/layout/types";
|
||||
|
||||
const props = defineProps<{
|
||||
to: menuType;
|
||||
}>();
|
||||
|
||||
const isExternalLink = computed(() => isUrl(props.to.name));
|
||||
const getLinkProps = (item: menuType) => {
|
||||
if (isExternalLink.value) {
|
||||
return {
|
||||
href: item.name,
|
||||
target: "_blank",
|
||||
rel: "noopener"
|
||||
};
|
||||
}
|
||||
return {
|
||||
to: item
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="isExternalLink ? 'a' : 'router-link'"
|
||||
v-bind="getLinkProps(to)"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { getTopMenu } from "@/router/utils";
|
||||
import { useNav } from "@/layout/hooks/useNav";
|
||||
|
||||
defineProps({
|
||||
collapse: Boolean
|
||||
});
|
||||
|
||||
const { title, getLogo } = useNav();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar-logo-container" :class="{ collapses: collapse }">
|
||||
<transition name="sidebarLogoFade">
|
||||
<router-link
|
||||
v-if="collapse"
|
||||
key="collapse"
|
||||
:title="title"
|
||||
class="sidebar-logo-link"
|
||||
:to="getTopMenu()?.path ?? '/'"
|
||||
>
|
||||
<img :src="getLogo()" alt="logo" />
|
||||
<span class="sidebar-title">{{ title }}</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else
|
||||
key="expand"
|
||||
:title="title"
|
||||
class="sidebar-logo-link"
|
||||
:to="getTopMenu()?.path ?? '/'"
|
||||
>
|
||||
<img :src="getLogo()" alt="logo" />
|
||||
<span class="sidebar-title">{{ title }}</span>
|
||||
</router-link>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sidebar-logo-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
overflow: hidden;
|
||||
|
||||
.sidebar-logo-link {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding-left: 10px;
|
||||
|
||||
img {
|
||||
display: inline-block;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
display: inline-block;
|
||||
height: 32px;
|
||||
margin: 2px 0 0 12px;
|
||||
overflow: hidden;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 32px;
|
||||
color: $subMenuActiveText;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n";
|
||||
import MenuFold from "@iconify-icons/ri/menu-fold-fill";
|
||||
import MenuUnfold from "@iconify-icons/ri/menu-unfold-fill";
|
||||
|
||||
interface Props {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
isActive: false
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "toggleClick"): void;
|
||||
}>();
|
||||
|
||||
const toggleClick = () => {
|
||||
emit("toggleClick");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="px-3 mr-1 navbar-bg-hover"
|
||||
:title="
|
||||
isActive ? t('buttons.pureClickCollapse') : t('buttons.pureClickExpand')
|
||||
"
|
||||
@click="toggleClick"
|
||||
>
|
||||
<IconifyIconOffline
|
||||
:icon="isActive ? MenuFold : MenuUnfold"
|
||||
class="inline-block align-middle hover:text-primary dark:hover:!text-white"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<svg class="w-full h-full">
|
||||
<defs>
|
||||
<symbol id="geometry-left" viewBox="0 0 214 36">
|
||||
<path d="M17 0h197v36H0v-2c4.5 0 9-3.5 9-8V8c0-4.5 3.5-8 8-8z" />
|
||||
</symbol>
|
||||
<symbol id="geometry-right" viewBox="0 0 214 36">
|
||||
<use xlink:href="#geometry-left" />
|
||||
</symbol>
|
||||
<clipPath>
|
||||
<rect width="100%" height="100%" x="0" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<svg width="51%" height="100%">
|
||||
<use
|
||||
xlink:href="#geometry-left"
|
||||
width="214"
|
||||
height="36"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<g transform="scale(-1, 1)">
|
||||
<svg width="51%" height="100%" x="-100%" y="0">
|
||||
<use
|
||||
xlink:href="#geometry-right"
|
||||
width="214"
|
||||
height="36"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
371
Yi.Pure.Vue3/src/layout/components/lay-tag/index.scss
Normal file
371
Yi.Pure.Vue3/src/layout/components/lay-tag/index.scss
Normal file
@@ -0,0 +1,371 @@
|
||||
@keyframes schedule-in-width {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes schedule-out-width {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
to {
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tags-view {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-primary);
|
||||
background: #fff;
|
||||
box-shadow: 0 0 1px #888;
|
||||
|
||||
.scroll-item {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
height: 34px;
|
||||
padding-left: 6px;
|
||||
line-height: 34px;
|
||||
cursor: pointer;
|
||||
transition: all 0.4s;
|
||||
|
||||
&:not(:first-child) {
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
&.chrome-item {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
margin-right: -18px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.el-icon-close {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
background-color 0.12s,
|
||||
color 0.12s;
|
||||
transform: translate(0, -50%);
|
||||
|
||||
&:hover {
|
||||
color: rgb(0 0 0 / 88%) !important;
|
||||
background-color: rgb(0 0 0 / 6%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tag-title {
|
||||
padding: 0 4px;
|
||||
color: var(--el-text-color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
&.chrome-scroll-container {
|
||||
padding-top: 4px;
|
||||
|
||||
.fixed-tag {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
float: left;
|
||||
overflow: visible;
|
||||
white-space: nowrap;
|
||||
list-style: none;
|
||||
|
||||
.scroll-item {
|
||||
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
|
||||
&:nth-child(1) {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
&.chrome-item {
|
||||
&:nth-child(1) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fixed-tag {
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 右键菜单 */
|
||||
.contextmenu {
|
||||
position: absolute;
|
||||
padding: 5px 0;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: normal;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
list-style-type: none;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
outline: 0;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-dropdown-menu {
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item:not(.is-disabled):hover {
|
||||
color: #606266;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
:deep(.el-dropdown-menu__item) i {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-dropdown-menu__item--divided) {
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item--divided::before {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.el-dropdown-menu__item.is-disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.scroll-item.is-active {
|
||||
position: relative;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 0.7px #888;
|
||||
|
||||
.chrome-tab {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.chrome-tab__bg {
|
||||
color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
|
||||
.tag-title {
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
|
||||
.chrome-close-btn {
|
||||
color: var(--el-color-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.chrome-tab-divider {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow-left,
|
||||
.arrow-right,
|
||||
.arrow-down {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 34px;
|
||||
color: var(--el-text-color-primary);
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow-left {
|
||||
box-shadow: 5px 0 5px -6px #ccc;
|
||||
|
||||
&:hover {
|
||||
cursor: w-resize;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow-right {
|
||||
border-right: 0.5px solid #ccc;
|
||||
box-shadow: -5px 0 5px -6px #ccc;
|
||||
|
||||
&:hover {
|
||||
cursor: e-resize;
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片模式下鼠标移入显示蓝色边框 */
|
||||
.card-in {
|
||||
color: var(--el-color-primary);
|
||||
|
||||
.tag-title {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片模式下鼠标移出隐藏蓝色边框 */
|
||||
.card-out {
|
||||
color: #666;
|
||||
border: none;
|
||||
|
||||
.tag-title {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
/* 灵动模式 */
|
||||
.schedule-active {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--el-color-primary);
|
||||
}
|
||||
|
||||
/* 灵动模式下鼠标移入显示蓝色进度条 */
|
||||
.schedule-in {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: var(--el-color-primary);
|
||||
animation: schedule-in-width 200ms ease-in;
|
||||
}
|
||||
|
||||
/* 灵动模式下鼠标移出隐藏蓝色进度条 */
|
||||
.schedule-out {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--el-color-primary);
|
||||
animation: schedule-out-width 200ms ease-in;
|
||||
}
|
||||
|
||||
/* 谷歌风格的页签 */
|
||||
.chrome-tab {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 24px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
|
||||
.tag-title {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chrome-tab-divider {
|
||||
position: absolute;
|
||||
right: 7px;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background-color: #2b2d2f;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
z-index: 10;
|
||||
|
||||
.chrome-tab__bg {
|
||||
color: #dee1e6;
|
||||
}
|
||||
|
||||
.tag-title {
|
||||
color: #1f1f1f;
|
||||
}
|
||||
|
||||
.chrome-tab-divider {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chrome-tab__bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -10;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chrome-close-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: #666;
|
||||
border-radius: 50%;
|
||||
|
||||
&:hover {
|
||||
color: white;
|
||||
background-color: #b1b3b8;
|
||||
}
|
||||
}
|
||||
}
|
||||
685
Yi.Pure.Vue3/src/layout/components/lay-tag/index.vue
Normal file
685
Yi.Pure.Vue3/src/layout/components/lay-tag/index.vue
Normal file
@@ -0,0 +1,685 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from "@/plugins/i18n";
|
||||
import { emitter } from "@/utils/mitt";
|
||||
import { RouteConfigs } from "../../types";
|
||||
import { useTags } from "../../hooks/useTag";
|
||||
import { routerArrays } from "@/layout/types";
|
||||
import { onClickOutside } from "@vueuse/core";
|
||||
import TagChrome from "./components/TagChrome.vue";
|
||||
import { handleAliveRoute, getTopMenu } from "@/router/utils";
|
||||
import { useSettingStoreHook } from "@/store/modules/settings";
|
||||
import { useMultiTagsStoreHook } from "@/store/modules/multiTags";
|
||||
import { usePermissionStoreHook } from "@/store/modules/permission";
|
||||
import { ref, watch, unref, toRaw, nextTick, onBeforeUnmount } from "vue";
|
||||
import {
|
||||
delay,
|
||||
isEqual,
|
||||
isAllEmpty,
|
||||
useResizeObserver
|
||||
} from "@pureadmin/utils";
|
||||
|
||||
import ExitFullscreen from "@iconify-icons/ri/fullscreen-exit-fill";
|
||||
import Fullscreen from "@iconify-icons/ri/fullscreen-fill";
|
||||
import ArrowDown from "@iconify-icons/ri/arrow-down-s-line";
|
||||
import ArrowRightSLine from "@iconify-icons/ri/arrow-right-s-line";
|
||||
import ArrowLeftSLine from "@iconify-icons/ri/arrow-left-s-line";
|
||||
|
||||
const {
|
||||
Close,
|
||||
route,
|
||||
router,
|
||||
visible,
|
||||
showTags,
|
||||
instance,
|
||||
multiTags,
|
||||
tagsViews,
|
||||
buttonTop,
|
||||
buttonLeft,
|
||||
showModel,
|
||||
translateX,
|
||||
isFixedTag,
|
||||
pureSetting,
|
||||
activeIndex,
|
||||
getTabStyle,
|
||||
isScrolling,
|
||||
iconIsActive,
|
||||
linkIsActive,
|
||||
currentSelect,
|
||||
scheduleIsActive,
|
||||
getContextMenuStyle,
|
||||
closeMenu,
|
||||
onMounted,
|
||||
onMouseenter,
|
||||
onMouseleave,
|
||||
transformI18n,
|
||||
onContentFullScreen
|
||||
} = useTags();
|
||||
|
||||
const tabDom = ref();
|
||||
const containerDom = ref();
|
||||
const scrollbarDom = ref();
|
||||
const contextmenuRef = ref();
|
||||
const isShowArrow = ref(false);
|
||||
const topPath = getTopMenu()?.path;
|
||||
const { VITE_HIDE_HOME } = import.meta.env;
|
||||
const fixedTags = [
|
||||
...routerArrays,
|
||||
...usePermissionStoreHook().flatteningRoutes.filter(v => v?.meta?.fixedTag)
|
||||
];
|
||||
|
||||
const dynamicTagView = async () => {
|
||||
await nextTick();
|
||||
const index = multiTags.value.findIndex(item => {
|
||||
if (!isAllEmpty(route.query)) {
|
||||
return isEqual(route.query, item.query);
|
||||
} else if (!isAllEmpty(route.params)) {
|
||||
return isEqual(route.params, item.params);
|
||||
} else {
|
||||
return route.path === item.path;
|
||||
}
|
||||
});
|
||||
moveToView(index);
|
||||
};
|
||||
|
||||
const moveToView = async (index: number): Promise<void> => {
|
||||
await nextTick();
|
||||
const tabNavPadding = 10;
|
||||
if (!instance.refs["dynamic" + index]) return;
|
||||
const tabItemEl = instance.refs["dynamic" + index][0];
|
||||
const tabItemElOffsetLeft = (tabItemEl as HTMLElement)?.offsetLeft;
|
||||
const tabItemOffsetWidth = (tabItemEl as HTMLElement)?.offsetWidth;
|
||||
// 标签页导航栏可视长度(不包含溢出部分)
|
||||
const scrollbarDomWidth = scrollbarDom.value
|
||||
? scrollbarDom.value?.offsetWidth
|
||||
: 0;
|
||||
|
||||
// 已有标签页总长度(包含溢出部分)
|
||||
const tabDomWidth = tabDom.value ? tabDom.value?.offsetWidth : 0;
|
||||
|
||||
scrollbarDomWidth <= tabDomWidth
|
||||
? (isShowArrow.value = true)
|
||||
: (isShowArrow.value = false);
|
||||
if (tabDomWidth < scrollbarDomWidth || tabItemElOffsetLeft === 0) {
|
||||
translateX.value = 0;
|
||||
} else if (tabItemElOffsetLeft < -translateX.value) {
|
||||
// 标签在可视区域左侧
|
||||
translateX.value = -tabItemElOffsetLeft + tabNavPadding;
|
||||
} else if (
|
||||
tabItemElOffsetLeft > -translateX.value &&
|
||||
tabItemElOffsetLeft + tabItemOffsetWidth <
|
||||
-translateX.value + scrollbarDomWidth
|
||||
) {
|
||||
// 标签在可视区域
|
||||
translateX.value = Math.min(
|
||||
0,
|
||||
scrollbarDomWidth -
|
||||
tabItemOffsetWidth -
|
||||
tabItemElOffsetLeft -
|
||||
tabNavPadding
|
||||
);
|
||||
} else {
|
||||
// 标签在可视区域右侧
|
||||
translateX.value = -(
|
||||
tabItemElOffsetLeft -
|
||||
(scrollbarDomWidth - tabNavPadding - tabItemOffsetWidth)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = (offset: number): void => {
|
||||
const scrollbarDomWidth = scrollbarDom.value
|
||||
? scrollbarDom.value?.offsetWidth
|
||||
: 0;
|
||||
const tabDomWidth = tabDom.value ? tabDom.value.offsetWidth : 0;
|
||||
if (offset > 0) {
|
||||
translateX.value = Math.min(0, translateX.value + offset);
|
||||
} else {
|
||||
if (scrollbarDomWidth < tabDomWidth) {
|
||||
if (translateX.value >= -(tabDomWidth - scrollbarDomWidth)) {
|
||||
translateX.value = Math.max(
|
||||
translateX.value + offset,
|
||||
scrollbarDomWidth - tabDomWidth
|
||||
);
|
||||
}
|
||||
} else {
|
||||
translateX.value = 0;
|
||||
}
|
||||
}
|
||||
isScrolling.value = false;
|
||||
};
|
||||
|
||||
const handleWheel = (event: WheelEvent): void => {
|
||||
isScrolling.value = true;
|
||||
const scrollIntensity = Math.abs(event.deltaX) + Math.abs(event.deltaY);
|
||||
let offset = 0;
|
||||
if (event.deltaX < 0) {
|
||||
offset = scrollIntensity > 0 ? scrollIntensity : 100;
|
||||
} else {
|
||||
offset = scrollIntensity > 0 ? -scrollIntensity : -100;
|
||||
}
|
||||
|
||||
smoothScroll(offset);
|
||||
};
|
||||
|
||||
const smoothScroll = (offset: number): void => {
|
||||
// 每帧滚动的距离
|
||||
const scrollAmount = 20;
|
||||
let remaining = Math.abs(offset);
|
||||
|
||||
const scrollStep = () => {
|
||||
const scrollOffset = Math.sign(offset) * Math.min(scrollAmount, remaining);
|
||||
handleScroll(scrollOffset);
|
||||
remaining -= Math.abs(scrollOffset);
|
||||
|
||||
if (remaining > 0) {
|
||||
requestAnimationFrame(scrollStep);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(scrollStep);
|
||||
};
|
||||
|
||||
function dynamicRouteTag(value: string): void {
|
||||
const hasValue = multiTags.value.some(item => {
|
||||
return item.path === value;
|
||||
});
|
||||
|
||||
function concatPath(arr: object[], value: string) {
|
||||
if (!hasValue) {
|
||||
arr.forEach((arrItem: any) => {
|
||||
if (arrItem.path === value) {
|
||||
useMultiTagsStoreHook().handleTags("push", {
|
||||
path: value,
|
||||
meta: arrItem.meta,
|
||||
name: arrItem.name
|
||||
});
|
||||
} else {
|
||||
if (arrItem.children && arrItem.children.length > 0) {
|
||||
concatPath(arrItem.children, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
concatPath(router.options.routes as any, value);
|
||||
}
|
||||
|
||||
/** 刷新路由 */
|
||||
function onFresh() {
|
||||
const { fullPath, query } = unref(route);
|
||||
router.replace({
|
||||
path: "/redirect" + fullPath,
|
||||
query
|
||||
});
|
||||
handleAliveRoute(route as ToRouteType, "refresh");
|
||||
}
|
||||
|
||||
function deleteDynamicTag(obj: any, current: any, tag?: string) {
|
||||
const valueIndex: number = multiTags.value.findIndex((item: any) => {
|
||||
if (item.query) {
|
||||
if (item.path === obj.path) {
|
||||
return item.query === obj.query;
|
||||
}
|
||||
} else if (item.params) {
|
||||
if (item.path === obj.path) {
|
||||
return item.params === obj.params;
|
||||
}
|
||||
} else {
|
||||
return item.path === obj.path;
|
||||
}
|
||||
});
|
||||
|
||||
const spliceRoute = (
|
||||
startIndex?: number,
|
||||
length?: number,
|
||||
other?: boolean
|
||||
): void => {
|
||||
if (other) {
|
||||
useMultiTagsStoreHook().handleTags(
|
||||
"equal",
|
||||
[
|
||||
VITE_HIDE_HOME === "false" ? fixedTags : toRaw(getTopMenu()),
|
||||
obj
|
||||
].flat()
|
||||
);
|
||||
} else {
|
||||
useMultiTagsStoreHook().handleTags("splice", "", {
|
||||
startIndex,
|
||||
length
|
||||
}) as any;
|
||||
}
|
||||
dynamicTagView();
|
||||
};
|
||||
|
||||
if (tag === "other") {
|
||||
spliceRoute(1, 1, true);
|
||||
} else if (tag === "left") {
|
||||
spliceRoute(fixedTags.length, valueIndex - 1, true);
|
||||
} else if (tag === "right") {
|
||||
spliceRoute(valueIndex + 1, multiTags.value.length);
|
||||
} else {
|
||||
// 从当前匹配到的路径中删除
|
||||
spliceRoute(valueIndex, 1);
|
||||
}
|
||||
const newRoute = useMultiTagsStoreHook().handleTags("slice");
|
||||
if (current === route.path) {
|
||||
// 如果删除当前激活tag就自动切换到最后一个tag
|
||||
if (tag === "left") return;
|
||||
if (newRoute[0]?.query) {
|
||||
router.push({ name: newRoute[0].name, query: newRoute[0].query });
|
||||
} else if (newRoute[0]?.params) {
|
||||
router.push({ name: newRoute[0].name, params: newRoute[0].params });
|
||||
} else {
|
||||
router.push({ path: newRoute[0].path });
|
||||
}
|
||||
} else {
|
||||
if (!multiTags.value.length) return;
|
||||
if (multiTags.value.some(item => item.path === route.path)) return;
|
||||
if (newRoute[0]?.query) {
|
||||
router.push({ name: newRoute[0].name, query: newRoute[0].query });
|
||||
} else if (newRoute[0]?.params) {
|
||||
router.push({ name: newRoute[0].name, params: newRoute[0].params });
|
||||
} else {
|
||||
router.push({ path: newRoute[0].path });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deleteMenu(item, tag?: string) {
|
||||
deleteDynamicTag(item, item.path, tag);
|
||||
handleAliveRoute(route as ToRouteType);
|
||||
}
|
||||
|
||||
function onClickDrop(key, item, selectRoute?: RouteConfigs) {
|
||||
if (item && item.disabled) return;
|
||||
|
||||
let selectTagRoute;
|
||||
if (selectRoute) {
|
||||
selectTagRoute = {
|
||||
path: selectRoute.path,
|
||||
meta: selectRoute.meta,
|
||||
name: selectRoute.name,
|
||||
query: selectRoute?.query,
|
||||
params: selectRoute?.params
|
||||
};
|
||||
} else {
|
||||
selectTagRoute = { path: route.path, meta: route.meta };
|
||||
}
|
||||
|
||||
// 当前路由信息
|
||||
switch (key) {
|
||||
case 0:
|
||||
// 刷新路由
|
||||
onFresh();
|
||||
break;
|
||||
case 1:
|
||||
// 关闭当前标签页
|
||||
deleteMenu(selectTagRoute);
|
||||
break;
|
||||
case 2:
|
||||
// 关闭左侧标签页
|
||||
deleteMenu(selectTagRoute, "left");
|
||||
break;
|
||||
case 3:
|
||||
// 关闭右侧标签页
|
||||
deleteMenu(selectTagRoute, "right");
|
||||
break;
|
||||
case 4:
|
||||
// 关闭其他标签页
|
||||
deleteMenu(selectTagRoute, "other");
|
||||
break;
|
||||
case 5:
|
||||
// 关闭全部标签页
|
||||
useMultiTagsStoreHook().handleTags("splice", "", {
|
||||
startIndex: fixedTags.length,
|
||||
length: multiTags.value.length
|
||||
});
|
||||
router.push(topPath);
|
||||
// router.push(fixedTags[fixedTags.length - 1]?.path);
|
||||
handleAliveRoute(route as ToRouteType);
|
||||
break;
|
||||
case 6:
|
||||
// 内容区全屏
|
||||
onContentFullScreen();
|
||||
setTimeout(() => {
|
||||
if (pureSetting.hiddenSideBar) {
|
||||
tagsViews[6].icon = ExitFullscreen;
|
||||
tagsViews[6].text = $t("buttons.pureContentExitFullScreen");
|
||||
} else {
|
||||
tagsViews[6].icon = Fullscreen;
|
||||
tagsViews[6].text = $t("buttons.pureContentFullScreen");
|
||||
}
|
||||
}, 100);
|
||||
break;
|
||||
}
|
||||
setTimeout(() => {
|
||||
showMenuModel(route.fullPath, route.query);
|
||||
});
|
||||
}
|
||||
|
||||
function handleCommand(command: any) {
|
||||
const { key, item } = command;
|
||||
onClickDrop(key, item);
|
||||
}
|
||||
|
||||
/** 触发右键中菜单的点击事件 */
|
||||
function selectTag(key, item) {
|
||||
closeMenu();
|
||||
onClickDrop(key, item, currentSelect.value);
|
||||
}
|
||||
|
||||
function showMenus(value: boolean) {
|
||||
Array.of(1, 2, 3, 4, 5).forEach(v => {
|
||||
tagsViews[v].show = value;
|
||||
});
|
||||
}
|
||||
|
||||
function disabledMenus(value: boolean, fixedTag = false) {
|
||||
Array.of(1, 2, 3, 4, 5).forEach(v => {
|
||||
tagsViews[v].disabled = value;
|
||||
});
|
||||
if (fixedTag) {
|
||||
tagsViews[2].show = false;
|
||||
tagsViews[2].disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查当前右键的菜单两边是否存在别的菜单,如果左侧的菜单是顶级菜单,则不显示关闭左侧标签页,如果右侧没有菜单,则不显示关闭右侧标签页 */
|
||||
function showMenuModel(
|
||||
currentPath: string,
|
||||
query: object = {},
|
||||
refresh = false
|
||||
) {
|
||||
const allRoute = multiTags.value;
|
||||
const routeLength = multiTags.value.length;
|
||||
let currentIndex = -1;
|
||||
if (isAllEmpty(query)) {
|
||||
currentIndex = allRoute.findIndex(v => v.path === currentPath);
|
||||
} else {
|
||||
currentIndex = allRoute.findIndex(v => isEqual(v.query, query));
|
||||
}
|
||||
function fixedTagDisabled() {
|
||||
if (allRoute[currentIndex]?.meta?.fixedTag) {
|
||||
Array.of(1, 2, 3, 4, 5).forEach(v => {
|
||||
tagsViews[v].disabled = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
showMenus(true);
|
||||
|
||||
if (refresh) {
|
||||
tagsViews[0].show = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* currentIndex为1时,左侧的菜单顶级菜单,则不显示关闭左侧标签页
|
||||
* 如果currentIndex等于routeLength-1,右侧没有菜单,则不显示关闭右侧标签页
|
||||
*/
|
||||
if (currentIndex === 1 && routeLength !== 2) {
|
||||
// 左侧的菜单是顶级菜单,右侧存在别的菜单
|
||||
tagsViews[2].show = false;
|
||||
Array.of(1, 3, 4, 5).forEach(v => {
|
||||
tagsViews[v].disabled = false;
|
||||
});
|
||||
tagsViews[2].disabled = true;
|
||||
fixedTagDisabled();
|
||||
} else if (currentIndex === 1 && routeLength === 2) {
|
||||
disabledMenus(false);
|
||||
// 左侧的菜单是顶级菜单,右侧不存在别的菜单
|
||||
Array.of(2, 3, 4).forEach(v => {
|
||||
tagsViews[v].show = false;
|
||||
tagsViews[v].disabled = true;
|
||||
});
|
||||
fixedTagDisabled();
|
||||
} else if (routeLength - 1 === currentIndex && currentIndex !== 0) {
|
||||
// 当前路由是所有路由中的最后一个
|
||||
tagsViews[3].show = false;
|
||||
Array.of(1, 2, 4, 5).forEach(v => {
|
||||
tagsViews[v].disabled = false;
|
||||
});
|
||||
tagsViews[3].disabled = true;
|
||||
if (allRoute[currentIndex - 1]?.meta?.fixedTag) {
|
||||
tagsViews[2].show = false;
|
||||
tagsViews[2].disabled = true;
|
||||
}
|
||||
fixedTagDisabled();
|
||||
} else if (currentIndex === 0 || currentPath === `/redirect${topPath}`) {
|
||||
// 当前路由为顶级菜单
|
||||
disabledMenus(true);
|
||||
} else {
|
||||
disabledMenus(false, allRoute[currentIndex - 1]?.meta?.fixedTag);
|
||||
fixedTagDisabled();
|
||||
}
|
||||
}
|
||||
|
||||
function openMenu(tag, e) {
|
||||
closeMenu();
|
||||
if (tag.path === topPath || tag?.meta?.fixedTag) {
|
||||
// 右键菜单为顶级菜单或拥有 fixedTag 属性,只显示刷新
|
||||
showMenus(false);
|
||||
tagsViews[0].show = true;
|
||||
} else if (route.path !== tag.path && route.name !== tag.name) {
|
||||
// 右键菜单不匹配当前路由,隐藏刷新
|
||||
tagsViews[0].show = false;
|
||||
showMenuModel(tag.path, tag.query);
|
||||
} else if (multiTags.value.length === 2 && route.path !== tag.path) {
|
||||
showMenus(true);
|
||||
// 只有两个标签时不显示关闭其他标签页
|
||||
tagsViews[4].show = false;
|
||||
} else if (route.path === tag.path) {
|
||||
// 右键当前激活的菜单
|
||||
showMenuModel(tag.path, tag.query, true);
|
||||
}
|
||||
|
||||
currentSelect.value = tag;
|
||||
const menuMinWidth = 140;
|
||||
const offsetLeft = unref(containerDom).getBoundingClientRect().left;
|
||||
const offsetWidth = unref(containerDom).offsetWidth;
|
||||
const maxLeft = offsetWidth - menuMinWidth;
|
||||
const left = e.clientX - offsetLeft + 5;
|
||||
if (left > maxLeft) {
|
||||
buttonLeft.value = maxLeft;
|
||||
} else {
|
||||
buttonLeft.value = left;
|
||||
}
|
||||
useSettingStoreHook().hiddenSideBar
|
||||
? (buttonTop.value = e.clientY)
|
||||
: (buttonTop.value = e.clientY - 40);
|
||||
nextTick(() => {
|
||||
visible.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
/** 触发tags标签切换 */
|
||||
function tagOnClick(item) {
|
||||
const { name, path } = item;
|
||||
if (name) {
|
||||
if (item.query) {
|
||||
router.push({
|
||||
name,
|
||||
query: item.query
|
||||
});
|
||||
} else if (item.params) {
|
||||
router.push({
|
||||
name,
|
||||
params: item.params
|
||||
});
|
||||
} else {
|
||||
router.push({ name });
|
||||
}
|
||||
} else {
|
||||
router.push({ path });
|
||||
}
|
||||
}
|
||||
|
||||
onClickOutside(contextmenuRef, closeMenu, {
|
||||
detectIframe: true
|
||||
});
|
||||
|
||||
watch(route, () => {
|
||||
activeIndex.value = -1;
|
||||
dynamicTagView();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!instance) return;
|
||||
|
||||
// 根据当前路由初始化操作标签页的禁用状态
|
||||
showMenuModel(route.fullPath);
|
||||
|
||||
// 触发隐藏标签页
|
||||
emitter.on("tagViewsChange", (key: any) => {
|
||||
if (unref(showTags as any) === key) return;
|
||||
(showTags as any).value = key;
|
||||
});
|
||||
|
||||
// 改变标签风格
|
||||
emitter.on("tagViewsShowModel", key => {
|
||||
showModel.value = key;
|
||||
});
|
||||
|
||||
// 接收侧边栏切换传递过来的参数
|
||||
emitter.on("changLayoutRoute", indexPath => {
|
||||
dynamicRouteTag(indexPath);
|
||||
setTimeout(() => {
|
||||
showMenuModel(indexPath);
|
||||
});
|
||||
});
|
||||
|
||||
useResizeObserver(scrollbarDom, dynamicTagView);
|
||||
delay().then(() => dynamicTagView());
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 解绑`tagViewsChange`、`tagViewsShowModel`、`changLayoutRoute`公共事件,防止多次触发
|
||||
emitter.off("tagViewsChange");
|
||||
emitter.off("tagViewsShowModel");
|
||||
emitter.off("changLayoutRoute");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!showTags" ref="containerDom" class="tags-view">
|
||||
<span v-show="isShowArrow" class="arrow-left">
|
||||
<IconifyIconOffline :icon="ArrowLeftSLine" @click="handleScroll(200)" />
|
||||
</span>
|
||||
<div
|
||||
ref="scrollbarDom"
|
||||
class="scroll-container"
|
||||
:class="showModel === 'chrome' && 'chrome-scroll-container'"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<div ref="tabDom" class="tab select-none" :style="getTabStyle">
|
||||
<div
|
||||
v-for="(item, index) in multiTags"
|
||||
:ref="'dynamic' + index"
|
||||
:key="index"
|
||||
:class="[
|
||||
'scroll-item is-closable',
|
||||
linkIsActive(item),
|
||||
showModel === 'chrome' && 'chrome-item',
|
||||
isFixedTag(item) && 'fixed-tag'
|
||||
]"
|
||||
@contextmenu.prevent="openMenu(item, $event)"
|
||||
@mouseenter.prevent="onMouseenter(index)"
|
||||
@mouseleave.prevent="onMouseleave(index)"
|
||||
@click="tagOnClick(item)"
|
||||
>
|
||||
<template v-if="showModel !== 'chrome'">
|
||||
<span
|
||||
class="tag-title dark:!text-text_color_primary dark:hover:!text-primary"
|
||||
>
|
||||
{{ transformI18n(item.meta.title) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
isFixedTag(item)
|
||||
? false
|
||||
: iconIsActive(item, index) ||
|
||||
(index === activeIndex && index !== 0)
|
||||
"
|
||||
class="el-icon-close"
|
||||
@click.stop="deleteMenu(item)"
|
||||
>
|
||||
<IconifyIconOffline :icon="Close" />
|
||||
</span>
|
||||
<span
|
||||
v-if="showModel !== 'card'"
|
||||
:ref="'schedule' + index"
|
||||
:class="[scheduleIsActive(item)]"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="chrome-tab">
|
||||
<div class="chrome-tab__bg">
|
||||
<TagChrome />
|
||||
</div>
|
||||
<span class="tag-title">
|
||||
{{ transformI18n(item.meta.title) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isFixedTag(item) ? false : index !== 0"
|
||||
class="chrome-close-btn"
|
||||
@click.stop="deleteMenu(item)"
|
||||
>
|
||||
<IconifyIconOffline :icon="Close" />
|
||||
</span>
|
||||
<span class="chrome-tab-divider" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-show="isShowArrow" class="arrow-right">
|
||||
<IconifyIconOffline :icon="ArrowRightSLine" @click="handleScroll(-200)" />
|
||||
</span>
|
||||
<!-- 右键菜单按钮 -->
|
||||
<transition name="el-zoom-in-top">
|
||||
<ul
|
||||
v-show="visible"
|
||||
ref="contextmenuRef"
|
||||
:key="Math.random()"
|
||||
:style="getContextMenuStyle"
|
||||
class="contextmenu"
|
||||
>
|
||||
<div
|
||||
v-for="(item, key) in tagsViews.slice(0, 6)"
|
||||
:key="key"
|
||||
style="display: flex; align-items: center"
|
||||
>
|
||||
<li v-if="item.show" @click="selectTag(key, item)">
|
||||
<IconifyIconOffline :icon="item.icon" />
|
||||
{{ transformI18n(item.text) }}
|
||||
</li>
|
||||
</div>
|
||||
</ul>
|
||||
</transition>
|
||||
<!-- 右侧功能按钮 -->
|
||||
<el-dropdown
|
||||
trigger="click"
|
||||
placement="bottom-end"
|
||||
@command="handleCommand"
|
||||
>
|
||||
<span class="arrow-down">
|
||||
<IconifyIconOffline :icon="ArrowDown" class="dark:text-white" />
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="(item, key) in tagsViews"
|
||||
:key="key"
|
||||
:command="{ key, item }"
|
||||
:divided="item.divided"
|
||||
:disabled="item.disabled"
|
||||
>
|
||||
<IconifyIconOffline :icon="item.icon" />
|
||||
{{ transformI18n(item.text) }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import url("./index.scss");
|
||||
</style>
|
||||
Reference in New Issue
Block a user