first commit
This commit is contained in:
43
packages/effects/layouts/package.json
Normal file
43
packages/effects/layouts/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@vben/layouts",
|
||||
"version": "5.5.4",
|
||||
"homepage": "https://github.com/vbenjs/vue-vben-admin",
|
||||
"bugs": "https://github.com/vbenjs/vue-vben-admin/issues",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vbenjs/vue-vben-admin.git",
|
||||
"directory": "packages/effects/layouts"
|
||||
},
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vben-core/composables": "workspace:*",
|
||||
"@vben-core/form-ui": "workspace:*",
|
||||
"@vben-core/layout-ui": "workspace:*",
|
||||
"@vben-core/menu-ui": "workspace:*",
|
||||
"@vben-core/popup-ui": "workspace:*",
|
||||
"@vben-core/shadcn-ui": "workspace:*",
|
||||
"@vben-core/shared": "workspace:*",
|
||||
"@vben-core/tabs-ui": "workspace:*",
|
||||
"@vben/constants": "workspace:*",
|
||||
"@vben/hooks": "workspace:*",
|
||||
"@vben/icons": "workspace:*",
|
||||
"@vben/locales": "workspace:*",
|
||||
"@vben/preferences": "workspace:*",
|
||||
"@vben/stores": "workspace:*",
|
||||
"@vben/types": "workspace:*",
|
||||
"@vben/utils": "workspace:*",
|
||||
"@vueuse/core": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
}
|
||||
}
|
||||
162
packages/effects/layouts/src/authentication/authentication.vue
Normal file
162
packages/effects/layouts/src/authentication/authentication.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import type { ToolbarType } from './types';
|
||||
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
|
||||
import { Copyright } from '../basic/copyright';
|
||||
import AuthenticationFormView from './form.vue';
|
||||
import SloganIcon from './icons/slogan.vue';
|
||||
import Toolbar from './toolbar.vue';
|
||||
|
||||
interface Props {
|
||||
appName?: string;
|
||||
logo?: string;
|
||||
pageTitle?: string;
|
||||
pageDescription?: string;
|
||||
sloganImage?: string;
|
||||
toolbar?: boolean;
|
||||
copyright?: boolean;
|
||||
toolbarList?: ToolbarType[];
|
||||
clickLogo?: () => void;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
appName: '',
|
||||
copyright: true,
|
||||
logo: '',
|
||||
pageDescription: '',
|
||||
pageTitle: '',
|
||||
sloganImage: '',
|
||||
toolbar: true,
|
||||
toolbarList: () => ['color', 'language', 'layout', 'theme'],
|
||||
clickLogo: () => {},
|
||||
});
|
||||
|
||||
const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
|
||||
usePreferences();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[isDark]"
|
||||
class="flex min-h-full flex-1 select-none overflow-x-hidden"
|
||||
>
|
||||
<template v-if="toolbar">
|
||||
<slot name="toolbar">
|
||||
<Toolbar :toolbar-list="toolbarList" />
|
||||
</slot>
|
||||
</template>
|
||||
<!-- 左侧认证面板 -->
|
||||
<AuthenticationFormView
|
||||
v-if="authPanelLeft"
|
||||
class="min-h-full w-2/5 flex-1"
|
||||
transition-name="slide-left"
|
||||
>
|
||||
<template v-if="copyright" #copyright>
|
||||
<slot name="copyright">
|
||||
<Copyright
|
||||
v-if="preferences.copyright.enable"
|
||||
v-bind="preferences.copyright"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
</AuthenticationFormView>
|
||||
|
||||
<!-- 头部 Logo 和应用名称 -->
|
||||
<div
|
||||
v-if="logo || appName"
|
||||
class="absolute left-0 top-0 z-10 flex flex-1"
|
||||
@click="clickLogo"
|
||||
>
|
||||
<div
|
||||
class="text-foreground lg:text-foreground ml-4 mt-4 flex flex-1 items-center sm:left-6 sm:top-6"
|
||||
>
|
||||
<img v-if="logo" :alt="appName" :src="logo" class="mr-2" width="42" />
|
||||
<p v-if="appName" class="m-0 text-xl font-medium">
|
||||
{{ appName }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统介绍 -->
|
||||
<div v-if="!authPanelCenter" class="relative hidden w-0 flex-1 lg:block">
|
||||
<div
|
||||
class="bg-background-deep absolute inset-0 h-full w-full dark:bg-[#070709]"
|
||||
>
|
||||
<div class="login-background absolute left-0 top-0 size-full"></div>
|
||||
<div class="flex-col-center -enter-x mr-20 h-full">
|
||||
<template v-if="sloganImage">
|
||||
<img
|
||||
:alt="appName"
|
||||
:src="sloganImage"
|
||||
class="animate-float h-64 w-2/5"
|
||||
/>
|
||||
</template>
|
||||
<SloganIcon v-else :alt="appName" class="animate-float h-64 w-2/5" />
|
||||
<div class="text-1xl text-foreground mt-6 font-sans lg:text-2xl">
|
||||
{{ pageTitle }}
|
||||
</div>
|
||||
<div class="dark:text-muted-foreground mt-2">
|
||||
{{ pageDescription }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中心认证面板 -->
|
||||
<div v-if="authPanelCenter" class="flex-center relative w-full">
|
||||
<div class="login-background absolute left-0 top-0 size-full"></div>
|
||||
<AuthenticationFormView
|
||||
class="md:bg-background shadow-primary/5 shadow-float w-full rounded-3xl pb-20 md:w-2/3 lg:w-1/2 xl:w-[36%]"
|
||||
>
|
||||
<template v-if="copyright" #copyright>
|
||||
<slot name="copyright">
|
||||
<Copyright
|
||||
v-if="preferences.copyright.enable"
|
||||
v-bind="preferences.copyright"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
</AuthenticationFormView>
|
||||
</div>
|
||||
|
||||
<!-- 右侧认证面板 -->
|
||||
<AuthenticationFormView
|
||||
v-if="authPanelRight"
|
||||
class="min-h-full w-[34%] flex-1"
|
||||
>
|
||||
<template v-if="copyright" #copyright>
|
||||
<slot name="copyright">
|
||||
<Copyright
|
||||
v-if="preferences.copyright.enable"
|
||||
v-bind="preferences.copyright"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
</AuthenticationFormView>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-background {
|
||||
background: linear-gradient(
|
||||
154deg,
|
||||
#07070915 30%,
|
||||
hsl(var(--primary) / 30%) 48%,
|
||||
#07070915 64%
|
||||
);
|
||||
filter: blur(100px);
|
||||
}
|
||||
|
||||
.dark {
|
||||
.login-background {
|
||||
background: linear-gradient(
|
||||
154deg,
|
||||
#07070915 30%,
|
||||
hsl(var(--primary) / 20%) 48%,
|
||||
#07070915 64%
|
||||
);
|
||||
filter: blur(100px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
33
packages/effects/layouts/src/authentication/form.vue
Normal file
33
packages/effects/layouts/src/authentication/form.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'AuthenticationFormView',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex-col-center dark:bg-background-deep bg-background relative px-6 py-10 lg:flex-initial lg:px-8"
|
||||
>
|
||||
<slot></slot>
|
||||
<!-- Router View with Transition and KeepAlive -->
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition appear mode="out-in" name="slide-right">
|
||||
<KeepAlive :include="['Login']">
|
||||
<component
|
||||
:is="Component"
|
||||
:key="route.fullPath"
|
||||
class="enter-x mt-6 w-full sm:mx-auto md:max-w-md"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
|
||||
<!-- Footer Copyright -->
|
||||
|
||||
<div
|
||||
class="text-muted-foreground absolute bottom-3 flex text-center text-xs"
|
||||
>
|
||||
<slot name="copyright"> </slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
4568
packages/effects/layouts/src/authentication/icons/slogan.vue
Normal file
4568
packages/effects/layouts/src/authentication/icons/slogan.vue
Normal file
File diff suppressed because it is too large
Load Diff
1
packages/effects/layouts/src/authentication/index.ts
Normal file
1
packages/effects/layouts/src/authentication/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as AuthPageLayout } from './authentication.vue';
|
||||
49
packages/effects/layouts/src/authentication/toolbar.vue
Normal file
49
packages/effects/layouts/src/authentication/toolbar.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { ToolbarType } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
import {
|
||||
AuthenticationColorToggle,
|
||||
AuthenticationLayoutToggle,
|
||||
LanguageToggle,
|
||||
ThemeToggle,
|
||||
} from '../widgets';
|
||||
|
||||
interface Props {
|
||||
toolbarList?: ToolbarType[];
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'AuthenticationToolbar',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
toolbarList: () => ['color', 'language', 'layout', 'theme'],
|
||||
});
|
||||
|
||||
const showColor = computed(() => props.toolbarList.includes('color'));
|
||||
const showLayout = computed(() => props.toolbarList.includes('layout'));
|
||||
const showLanguage = computed(() => props.toolbarList.includes('language'));
|
||||
const showTheme = computed(() => props.toolbarList.includes('theme'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'bg-accent rounded-3xl px-3 py-1': toolbarList.length > 1,
|
||||
}"
|
||||
class="flex-center absolute right-2 top-4 z-10"
|
||||
>
|
||||
<!-- Only show on medium and larger screens -->
|
||||
<div class="hidden md:flex">
|
||||
<AuthenticationColorToggle v-if="showColor" />
|
||||
<AuthenticationLayoutToggle v-if="showLayout" />
|
||||
</div>
|
||||
<!-- Always show Language and Theme toggles -->
|
||||
<LanguageToggle v-if="showLanguage && preferences.widget.languageToggle" />
|
||||
<ThemeToggle v-if="showTheme && preferences.widget.themeToggle" />
|
||||
</div>
|
||||
</template>
|
||||
1
packages/effects/layouts/src/authentication/types.ts
Normal file
1
packages/effects/layouts/src/authentication/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ToolbarType = 'color' | 'language' | 'layout' | 'theme';
|
||||
7
packages/effects/layouts/src/basic/README.md
Normal file
7
packages/effects/layouts/src/basic/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
## layout
|
||||
|
||||
### header
|
||||
|
||||
- 支持N个自定义插槽,命名方式:header-right-n,header-left-n
|
||||
- header-left-n ,排序方式:0-19 ,breadcrumb 21-x
|
||||
- header-right-n ,排序方式:0-49,global-search,51-59,theme-toggle,61-69,language-toggle,71-79,fullscreen,81-89,notification,91-149,user-dropdown,151-x
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { VbenSpinner } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useContentSpinner } from './use-content-spinner';
|
||||
|
||||
defineOptions({ name: 'LayoutContentSpinner' });
|
||||
|
||||
const { spinning } = useContentSpinner();
|
||||
</script>
|
||||
<template>
|
||||
<VbenSpinner :spinning="spinning" />
|
||||
</template>
|
||||
148
packages/effects/layouts/src/basic/content/content.vue
Normal file
148
packages/effects/layouts/src/basic/content/content.vue
Normal file
@@ -0,0 +1,148 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VNode } from 'vue';
|
||||
import type {
|
||||
RouteLocationNormalizedLoaded,
|
||||
RouteLocationNormalizedLoadedGeneric,
|
||||
} from 'vue-router';
|
||||
|
||||
import { computed } from 'vue';
|
||||
import { RouterView } from 'vue-router';
|
||||
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { storeToRefs, useTabbarStore } from '@vben/stores';
|
||||
|
||||
import { IFrameRouterView } from '../../iframe';
|
||||
|
||||
defineOptions({ name: 'LayoutContent' });
|
||||
|
||||
const tabbarStore = useTabbarStore();
|
||||
const { keepAlive } = usePreferences();
|
||||
|
||||
const { getCachedTabs, getExcludeCachedTabs, renderRouteView } =
|
||||
storeToRefs(tabbarStore);
|
||||
|
||||
/**
|
||||
* 是否使用动画
|
||||
*/
|
||||
const getEnabledTransition = computed(() => {
|
||||
const { transition } = preferences;
|
||||
const transitionName = transition.name;
|
||||
return transitionName && transition.enable;
|
||||
});
|
||||
|
||||
// 页面切换动画
|
||||
function getTransitionName(_route: RouteLocationNormalizedLoaded) {
|
||||
// 如果偏好设置未设置,则不使用动画
|
||||
const { tabbar, transition } = preferences;
|
||||
const transitionName = transition.name;
|
||||
if (!transitionName || !transition.enable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 标签页未启用或者未开启缓存,则使用全局配置动画
|
||||
if (!tabbar.enable || !keepAlive) {
|
||||
return transitionName;
|
||||
}
|
||||
|
||||
// 如果页面已经加载过,则不使用动画
|
||||
// if (route.meta.loaded) {
|
||||
// return;
|
||||
// }
|
||||
// 已经打开且已经加载过的页面不使用动画
|
||||
// const inTabs = getCachedTabs.value.includes(route.name as string);
|
||||
|
||||
// return inTabs && route.meta.loaded ? undefined : transitionName;
|
||||
return transitionName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换组件,自动添加 name
|
||||
* @param component
|
||||
*/
|
||||
function transformComponent(
|
||||
component: VNode,
|
||||
route: RouteLocationNormalizedLoadedGeneric,
|
||||
) {
|
||||
// 组件视图未找到,如果有设置后备视图,则返回后备视图,如果没有,则抛出错误
|
||||
if (!component) {
|
||||
console.error(
|
||||
'Component view not found,please check the route configuration',
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const routeName = route.name as string;
|
||||
// 如果组件没有 name,则直接返回
|
||||
if (!routeName) {
|
||||
return component;
|
||||
}
|
||||
const componentName = (component?.type as any)?.name;
|
||||
|
||||
// 已经设置过 name,则直接返回
|
||||
if (componentName) {
|
||||
return component;
|
||||
}
|
||||
|
||||
// componentName 与 routeName 一致,则直接返回
|
||||
if (componentName === routeName) {
|
||||
return component;
|
||||
}
|
||||
|
||||
// 设置 name
|
||||
component.type ||= {};
|
||||
(component.type as any).name = routeName;
|
||||
|
||||
return component;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<IFrameRouterView />
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition
|
||||
v-if="getEnabledTransition"
|
||||
:name="getTransitionName(route)"
|
||||
appear
|
||||
mode="out-in"
|
||||
>
|
||||
<KeepAlive
|
||||
v-if="keepAlive"
|
||||
:exclude="getExcludeCachedTabs"
|
||||
:include="getCachedTabs"
|
||||
>
|
||||
<component
|
||||
:is="transformComponent(Component, route)"
|
||||
v-if="renderRouteView"
|
||||
v-show="!route.meta.iframeSrc"
|
||||
:key="route.fullPath"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<component
|
||||
:is="Component"
|
||||
v-else-if="renderRouteView"
|
||||
:key="route.fullPath"
|
||||
/>
|
||||
</Transition>
|
||||
<template v-else>
|
||||
<KeepAlive
|
||||
v-if="keepAlive"
|
||||
:exclude="getExcludeCachedTabs"
|
||||
:include="getCachedTabs"
|
||||
>
|
||||
<component
|
||||
:is="transformComponent(Component, route)"
|
||||
v-if="renderRouteView"
|
||||
v-show="!route.meta.iframeSrc"
|
||||
:key="route.fullPath"
|
||||
/>
|
||||
</KeepAlive>
|
||||
<component
|
||||
:is="Component"
|
||||
v-else-if="renderRouteView"
|
||||
:key="route.fullPath"
|
||||
/>
|
||||
</template>
|
||||
</RouterView>
|
||||
</div>
|
||||
</template>
|
||||
2
packages/effects/layouts/src/basic/content/index.ts
Normal file
2
packages/effects/layouts/src/basic/content/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as LayoutContentSpinner } from './content-spinner.vue';
|
||||
export { default as LayoutContent } from './content.vue';
|
||||
@@ -0,0 +1,50 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
function useContentSpinner() {
|
||||
const spinning = ref(false);
|
||||
const startTime = ref(0);
|
||||
const router = useRouter();
|
||||
const minShowTime = 500; // 最小显示时间
|
||||
const enableLoading = computed(() => preferences.transition.loading);
|
||||
|
||||
// 结束加载动画
|
||||
const onEnd = () => {
|
||||
if (!enableLoading.value) {
|
||||
return;
|
||||
}
|
||||
const processTime = performance.now() - startTime.value;
|
||||
if (processTime < minShowTime) {
|
||||
setTimeout(() => {
|
||||
spinning.value = false;
|
||||
}, minShowTime - processTime);
|
||||
} else {
|
||||
spinning.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 路由前置守卫
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.loaded || !enableLoading.value || to.meta.iframeSrc) {
|
||||
return true;
|
||||
}
|
||||
startTime.value = performance.now();
|
||||
spinning.value = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 路由后置守卫
|
||||
router.afterEach((to) => {
|
||||
if (to.meta.loaded || !enableLoading.value || to.meta.iframeSrc) {
|
||||
return true;
|
||||
}
|
||||
onEnd();
|
||||
return true;
|
||||
});
|
||||
|
||||
return { spinning };
|
||||
}
|
||||
|
||||
export { useContentSpinner };
|
||||
48
packages/effects/layouts/src/basic/copyright/copyright.vue
Normal file
48
packages/effects/layouts/src/basic/copyright/copyright.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts" setup>
|
||||
interface Props {
|
||||
companyName: string;
|
||||
companySiteLink?: string;
|
||||
date: string;
|
||||
icp?: string;
|
||||
icpLink?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'Copyright',
|
||||
});
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
companyName: 'Vben Admin',
|
||||
companySiteLink: '',
|
||||
date: '2024',
|
||||
icp: '',
|
||||
icpLink: '',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-md flex-center">
|
||||
<!-- ICP Link -->
|
||||
<a
|
||||
v-if="icp"
|
||||
:href="icpLink || 'javascript:void(0)'"
|
||||
class="hover:text-primary-hover mx-1"
|
||||
target="_blank"
|
||||
>
|
||||
{{ icp }}
|
||||
</a>
|
||||
|
||||
<!-- Copyright Text -->
|
||||
Copyright © {{ date }}
|
||||
|
||||
<!-- Company Link -->
|
||||
<a
|
||||
v-if="companyName"
|
||||
:href="companySiteLink || 'javascript:void(0)'"
|
||||
class="hover:text-primary-hover mx-1"
|
||||
target="_blank"
|
||||
>
|
||||
{{ companyName }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
1
packages/effects/layouts/src/basic/copyright/index.ts
Normal file
1
packages/effects/layouts/src/basic/copyright/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Copyright } from './copyright.vue';
|
||||
11
packages/effects/layouts/src/basic/footer/footer.vue
Normal file
11
packages/effects/layouts/src/basic/footer/footer.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
defineOptions({
|
||||
name: 'LayoutFooter',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-center text-muted-foreground relative h-full w-full text-xs">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
1
packages/effects/layouts/src/basic/footer/index.ts
Normal file
1
packages/effects/layouts/src/basic/footer/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LayoutFooter } from './footer.vue';
|
||||
185
packages/effects/layouts/src/basic/header/header.vue
Normal file
185
packages/effects/layouts/src/basic/header/header.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, useSlots } from 'vue';
|
||||
|
||||
import { useRefresh } from '@vben/hooks';
|
||||
import { RotateCw } from '@vben/icons';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { VbenFullScreen, VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
import {
|
||||
GlobalSearch,
|
||||
LanguageToggle,
|
||||
PreferencesButton,
|
||||
ThemeToggle,
|
||||
} from '../../widgets';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Logo 主题
|
||||
*/
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'LayoutHeader',
|
||||
});
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
theme: 'light',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
|
||||
|
||||
const REFERENCE_VALUE = 50;
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const { globalSearchShortcutKey, preferencesButtonPosition } = usePreferences();
|
||||
const slots = useSlots();
|
||||
const { refresh } = useRefresh();
|
||||
|
||||
const rightSlots = computed(() => {
|
||||
const list = [{ index: REFERENCE_VALUE + 100, name: 'user-dropdown' }];
|
||||
if (preferences.widget.globalSearch) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE,
|
||||
name: 'global-search',
|
||||
});
|
||||
}
|
||||
|
||||
if (preferencesButtonPosition.value.header) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 10,
|
||||
name: 'preferences',
|
||||
});
|
||||
}
|
||||
if (preferences.widget.themeToggle) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 20,
|
||||
name: 'theme-toggle',
|
||||
});
|
||||
}
|
||||
if (preferences.widget.languageToggle) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 30,
|
||||
name: 'language-toggle',
|
||||
});
|
||||
}
|
||||
if (preferences.widget.fullscreen) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 40,
|
||||
name: 'fullscreen',
|
||||
});
|
||||
}
|
||||
if (preferences.widget.notification) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 50,
|
||||
name: 'notification',
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(slots).forEach((key) => {
|
||||
const name = key.split('-');
|
||||
if (key.startsWith('header-right')) {
|
||||
list.push({ index: Number(name[2]), name: key });
|
||||
}
|
||||
});
|
||||
return list.sort((a, b) => a.index - b.index);
|
||||
});
|
||||
|
||||
const leftSlots = computed(() => {
|
||||
const list: Array<{ index: number; name: string }> = [];
|
||||
|
||||
if (preferences.widget.refresh) {
|
||||
list.push({
|
||||
index: 0,
|
||||
name: 'refresh',
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(slots).forEach((key) => {
|
||||
const name = key.split('-');
|
||||
if (key.startsWith('header-left')) {
|
||||
list.push({ index: Number(name[2]), name: key });
|
||||
}
|
||||
});
|
||||
return list.sort((a, b) => a.index - b.index);
|
||||
});
|
||||
|
||||
function clearPreferencesAndLogout() {
|
||||
emit('clearPreferencesAndLogout');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template
|
||||
v-for="slot in leftSlots.filter((item) => item.index < REFERENCE_VALUE)"
|
||||
:key="slot.name"
|
||||
>
|
||||
<slot :name="slot.name">
|
||||
<template v-if="slot.name === 'refresh'">
|
||||
<VbenIconButton class="my-0 mr-1 rounded-md" @click="refresh">
|
||||
<RotateCw class="size-4" />
|
||||
</VbenIconButton>
|
||||
</template>
|
||||
</slot>
|
||||
</template>
|
||||
<div class="flex-center hidden lg:block">
|
||||
<slot name="breadcrumb"></slot>
|
||||
</div>
|
||||
<template
|
||||
v-for="slot in leftSlots.filter((item) => item.index > REFERENCE_VALUE)"
|
||||
:key="slot.name"
|
||||
>
|
||||
<slot :name="slot.name"></slot>
|
||||
</template>
|
||||
<div
|
||||
:class="`menu-align-${preferences.header.menuAlign}`"
|
||||
class="flex h-full min-w-0 flex-1 items-center"
|
||||
>
|
||||
<slot name="menu"></slot>
|
||||
</div>
|
||||
<div class="flex h-full min-w-0 flex-shrink-0 items-center">
|
||||
<template v-for="slot in rightSlots" :key="slot.name">
|
||||
<slot :name="slot.name">
|
||||
<template v-if="slot.name === 'global-search'">
|
||||
<GlobalSearch
|
||||
:enable-shortcut-key="globalSearchShortcutKey"
|
||||
:menus="accessStore.accessMenus"
|
||||
class="mr-1 sm:mr-4"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="slot.name === 'preferences'">
|
||||
<PreferencesButton
|
||||
class="mr-1"
|
||||
@clear-preferences-and-logout="clearPreferencesAndLogout"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'theme-toggle'">
|
||||
<ThemeToggle class="mr-1 mt-[2px]" />
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'language-toggle'">
|
||||
<LanguageToggle class="mr-1" />
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'fullscreen'">
|
||||
<VbenFullScreen class="mr-1" />
|
||||
</template>
|
||||
</slot>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.menu-align-start {
|
||||
--menu-align: start;
|
||||
}
|
||||
|
||||
.menu-align-center {
|
||||
--menu-align: center;
|
||||
}
|
||||
|
||||
.menu-align-end {
|
||||
--menu-align: end;
|
||||
}
|
||||
</style>
|
||||
1
packages/effects/layouts/src/basic/header/index.ts
Normal file
1
packages/effects/layouts/src/basic/header/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as LayoutHeader } from './header.vue';
|
||||
1
packages/effects/layouts/src/basic/index.ts
Normal file
1
packages/effects/layouts/src/basic/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as BasicLayout } from './layout.vue';
|
||||
371
packages/effects/layouts/src/basic/layout.vue
Normal file
371
packages/effects/layouts/src/basic/layout.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SetupContext } from 'vue';
|
||||
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import { computed, useSlots, watch } from 'vue';
|
||||
|
||||
import { useRefresh } from '@vben/hooks';
|
||||
import { $t, i18n } from '@vben/locales';
|
||||
import {
|
||||
preferences,
|
||||
updatePreferences,
|
||||
usePreferences,
|
||||
} from '@vben/preferences';
|
||||
import { useLockStore } from '@vben/stores';
|
||||
import { cloneDeep, mapTree } from '@vben/utils';
|
||||
|
||||
import { VbenAdminLayout } from '@vben-core/layout-ui';
|
||||
import { VbenBackTop, VbenLogo } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { Breadcrumb, CheckUpdates, Preferences } from '../widgets';
|
||||
import { LayoutContent, LayoutContentSpinner } from './content';
|
||||
import { Copyright } from './copyright';
|
||||
import { LayoutFooter } from './footer';
|
||||
import { LayoutHeader } from './header';
|
||||
import {
|
||||
LayoutExtraMenu,
|
||||
LayoutMenu,
|
||||
LayoutMixedMenu,
|
||||
useExtraMenu,
|
||||
useMixedMenu,
|
||||
} from './menu';
|
||||
import { LayoutTabbar } from './tabbar';
|
||||
|
||||
defineOptions({ name: 'BasicLayout' });
|
||||
|
||||
const emit = defineEmits<{ clearPreferencesAndLogout: []; clickLogo: [] }>();
|
||||
|
||||
const {
|
||||
isDark,
|
||||
isHeaderNav,
|
||||
isMixedNav,
|
||||
isMobile,
|
||||
isSideMixedNav,
|
||||
isHeaderMixedNav,
|
||||
isHeaderSidebarNav,
|
||||
layout,
|
||||
preferencesButtonPosition,
|
||||
sidebarCollapsed,
|
||||
theme,
|
||||
} = usePreferences();
|
||||
const lockStore = useLockStore();
|
||||
const { refresh } = useRefresh();
|
||||
|
||||
const sidebarTheme = computed(() => {
|
||||
const dark = isDark.value || preferences.theme.semiDarkSidebar;
|
||||
return dark ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
const headerTheme = computed(() => {
|
||||
const dark = isDark.value || preferences.theme.semiDarkHeader;
|
||||
return dark ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
const logoClass = computed(() => {
|
||||
const { collapsedShowTitle } = preferences.sidebar;
|
||||
const classes: string[] = [];
|
||||
|
||||
if (collapsedShowTitle && sidebarCollapsed.value && !isMixedNav.value) {
|
||||
classes.push('mx-auto');
|
||||
}
|
||||
|
||||
if (isSideMixedNav.value) {
|
||||
classes.push('flex-center');
|
||||
}
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
const isMenuRounded = computed(() => {
|
||||
return preferences.navigation.styleType === 'rounded';
|
||||
});
|
||||
|
||||
const logoCollapsed = computed(() => {
|
||||
if (isMobile.value && sidebarCollapsed.value) {
|
||||
return true;
|
||||
}
|
||||
if (isHeaderNav.value || isMixedNav.value || isHeaderSidebarNav.value) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
sidebarCollapsed.value || isSideMixedNav.value || isHeaderMixedNav.value
|
||||
);
|
||||
});
|
||||
|
||||
const showHeaderNav = computed(() => {
|
||||
return (
|
||||
!isMobile.value &&
|
||||
(isHeaderNav.value || isMixedNav.value || isHeaderMixedNav.value)
|
||||
);
|
||||
});
|
||||
|
||||
const {
|
||||
handleMenuSelect,
|
||||
handleMenuOpen,
|
||||
headerActive,
|
||||
headerMenus,
|
||||
sidebarActive,
|
||||
sidebarMenus,
|
||||
mixHeaderMenus,
|
||||
sidebarVisible,
|
||||
} = useMixedMenu();
|
||||
|
||||
// 侧边多列菜单
|
||||
const {
|
||||
extraActiveMenu,
|
||||
extraMenus,
|
||||
handleDefaultSelect,
|
||||
handleMenuMouseEnter,
|
||||
handleMixedMenuSelect,
|
||||
handleSideMouseLeave,
|
||||
sidebarExtraVisible,
|
||||
} = useExtraMenu(mixHeaderMenus);
|
||||
|
||||
/**
|
||||
* 包装菜单,翻译菜单名称
|
||||
* @param menus 原始菜单数据
|
||||
* @param deep 是否深度包装。对于双列布局,只需要包装第一层,因为更深层的数据会在扩展菜单中重新包装
|
||||
*/
|
||||
function wrapperMenus(menus: MenuRecordRaw[], deep: boolean = true) {
|
||||
return deep
|
||||
? mapTree(menus, (item) => {
|
||||
return { ...cloneDeep(item), name: $t(item.name) };
|
||||
})
|
||||
: menus.map((item) => {
|
||||
return { ...cloneDeep(item), name: $t(item.name) };
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
updatePreferences({
|
||||
sidebar: {
|
||||
hidden: !preferences.sidebar.hidden,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function clearPreferencesAndLogout() {
|
||||
emit('clearPreferencesAndLogout');
|
||||
}
|
||||
|
||||
function clickLogo() {
|
||||
emit('clickLogo');
|
||||
}
|
||||
|
||||
watch(
|
||||
() => preferences.app.layout,
|
||||
async (val) => {
|
||||
if (val === 'sidebar-mixed-nav' && preferences.sidebar.hidden) {
|
||||
updatePreferences({
|
||||
sidebar: {
|
||||
hidden: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 语言更新后,刷新页面
|
||||
// i18n.global.locale会在preference.app.locale变更之后才会更新,因此watchpreference.app.locale是不合适的,刷新页面时可能语言配置尚未完全加载完成
|
||||
watch(i18n.global.locale, refresh, { flush: 'post' });
|
||||
|
||||
const slots: SetupContext['slots'] = useSlots();
|
||||
const headerSlots = computed(() => {
|
||||
return Object.keys(slots).filter((key) => key.startsWith('header-'));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VbenAdminLayout
|
||||
v-model:sidebar-extra-visible="sidebarExtraVisible"
|
||||
:content-compact="preferences.app.contentCompact"
|
||||
:footer-enable="preferences.footer.enable"
|
||||
:footer-fixed="preferences.footer.fixed"
|
||||
:header-hidden="preferences.header.hidden"
|
||||
:header-mode="preferences.header.mode"
|
||||
:header-theme="headerTheme"
|
||||
:header-toggle-sidebar-button="preferences.widget.sidebarToggle"
|
||||
:header-visible="preferences.header.enable"
|
||||
:is-mobile="preferences.app.isMobile"
|
||||
:layout="layout"
|
||||
:sidebar-collapse="preferences.sidebar.collapsed"
|
||||
:sidebar-collapse-show-title="preferences.sidebar.collapsedShowTitle"
|
||||
:sidebar-enable="sidebarVisible"
|
||||
:sidebar-collapsed-button="preferences.sidebar.collapsedButton"
|
||||
:sidebar-fixed-button="preferences.sidebar.fixedButton"
|
||||
:sidebar-expand-on-hover="preferences.sidebar.expandOnHover"
|
||||
:sidebar-extra-collapse="preferences.sidebar.extraCollapse"
|
||||
:sidebar-hidden="preferences.sidebar.hidden"
|
||||
:sidebar-theme="sidebarTheme"
|
||||
:sidebar-width="preferences.sidebar.width"
|
||||
:tabbar-enable="preferences.tabbar.enable"
|
||||
:tabbar-height="preferences.tabbar.height"
|
||||
@side-mouse-leave="handleSideMouseLeave"
|
||||
@toggle-sidebar="toggleSidebar"
|
||||
@update:sidebar-collapse="
|
||||
(value: boolean) => updatePreferences({ sidebar: { collapsed: value } })
|
||||
"
|
||||
@update:sidebar-enable="
|
||||
(value: boolean) => updatePreferences({ sidebar: { enable: value } })
|
||||
"
|
||||
@update:sidebar-expand-on-hover="
|
||||
(value: boolean) =>
|
||||
updatePreferences({ sidebar: { expandOnHover: value } })
|
||||
"
|
||||
@update:sidebar-extra-collapse="
|
||||
(value: boolean) =>
|
||||
updatePreferences({ sidebar: { extraCollapse: value } })
|
||||
"
|
||||
>
|
||||
<!-- logo -->
|
||||
<template #logo>
|
||||
<VbenLogo
|
||||
v-if="preferences.logo.enable"
|
||||
:class="logoClass"
|
||||
:collapsed="logoCollapsed"
|
||||
:src="preferences.logo.source"
|
||||
:text="preferences.app.name"
|
||||
:theme="showHeaderNav ? headerTheme : theme"
|
||||
@click="clickLogo"
|
||||
>
|
||||
<template v-if="$slots['logo-text']" #text>
|
||||
<slot name="logo-text"></slot>
|
||||
</template>
|
||||
</VbenLogo>
|
||||
</template>
|
||||
<!-- 头部区域 -->
|
||||
<template #header>
|
||||
<LayoutHeader
|
||||
:theme="theme"
|
||||
@clear-preferences-and-logout="clearPreferencesAndLogout"
|
||||
>
|
||||
<template
|
||||
v-if="!showHeaderNav && preferences.breadcrumb.enable"
|
||||
#breadcrumb
|
||||
>
|
||||
<Breadcrumb
|
||||
:hide-when-only-one="preferences.breadcrumb.hideOnlyOne"
|
||||
:show-home="preferences.breadcrumb.showHome"
|
||||
:show-icon="preferences.breadcrumb.showIcon"
|
||||
:type="preferences.breadcrumb.styleType"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="showHeaderNav" #menu>
|
||||
<LayoutMenu
|
||||
:default-active="headerActive"
|
||||
:menus="wrapperMenus(headerMenus)"
|
||||
:rounded="isMenuRounded"
|
||||
:theme="headerTheme"
|
||||
class="w-full"
|
||||
mode="horizontal"
|
||||
@select="handleMenuSelect"
|
||||
/>
|
||||
</template>
|
||||
<template #user-dropdown>
|
||||
<slot name="user-dropdown"></slot>
|
||||
</template>
|
||||
<template #notification>
|
||||
<slot name="notification"></slot>
|
||||
</template>
|
||||
<template v-for="item in headerSlots" #[item]>
|
||||
<slot :name="item"></slot>
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
</template>
|
||||
<!-- 侧边菜单区域 -->
|
||||
<template #menu>
|
||||
<LayoutMenu
|
||||
:accordion="preferences.navigation.accordion"
|
||||
:collapse="preferences.sidebar.collapsed"
|
||||
:collapse-show-title="preferences.sidebar.collapsedShowTitle"
|
||||
:default-active="sidebarActive"
|
||||
:menus="wrapperMenus(sidebarMenus)"
|
||||
:rounded="isMenuRounded"
|
||||
:theme="sidebarTheme"
|
||||
mode="vertical"
|
||||
@open="handleMenuOpen"
|
||||
@select="handleMenuSelect"
|
||||
/>
|
||||
</template>
|
||||
<template #mixed-menu>
|
||||
<LayoutMixedMenu
|
||||
:active-path="extraActiveMenu"
|
||||
:menus="wrapperMenus(mixHeaderMenus, false)"
|
||||
:rounded="isMenuRounded"
|
||||
:theme="sidebarTheme"
|
||||
@default-select="handleDefaultSelect"
|
||||
@enter="handleMenuMouseEnter"
|
||||
@select="handleMixedMenuSelect"
|
||||
/>
|
||||
</template>
|
||||
<!-- 侧边额外区域 -->
|
||||
<template #side-extra>
|
||||
<LayoutExtraMenu
|
||||
:accordion="preferences.navigation.accordion"
|
||||
:collapse="preferences.sidebar.extraCollapse"
|
||||
:menus="wrapperMenus(extraMenus)"
|
||||
:rounded="isMenuRounded"
|
||||
:theme="sidebarTheme"
|
||||
/>
|
||||
</template>
|
||||
<template #side-extra-title>
|
||||
<VbenLogo
|
||||
v-if="preferences.logo.enable"
|
||||
:text="preferences.app.name"
|
||||
:theme="theme"
|
||||
>
|
||||
<template v-if="$slots['logo-text']" #text>
|
||||
<slot name="logo-text"></slot>
|
||||
</template>
|
||||
</VbenLogo>
|
||||
</template>
|
||||
|
||||
<template #tabbar>
|
||||
<LayoutTabbar
|
||||
v-if="preferences.tabbar.enable"
|
||||
:show-icon="preferences.tabbar.showIcon"
|
||||
:theme="theme"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<template #content>
|
||||
<LayoutContent />
|
||||
</template>
|
||||
|
||||
<template v-if="preferences.transition.loading" #content-overlay>
|
||||
<LayoutContentSpinner />
|
||||
</template>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<template v-if="preferences.footer.enable" #footer>
|
||||
<LayoutFooter>
|
||||
<Copyright
|
||||
v-if="preferences.copyright.enable"
|
||||
v-bind="preferences.copyright"
|
||||
/>
|
||||
</LayoutFooter>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<slot name="extra"></slot>
|
||||
<CheckUpdates
|
||||
v-if="preferences.app.enableCheckUpdates"
|
||||
:check-updates-interval="preferences.app.checkUpdatesInterval"
|
||||
/>
|
||||
|
||||
<Transition v-if="preferences.widget.lockScreen" name="slide-up">
|
||||
<slot v-if="lockStore.isLockScreen" name="lock-screen"></slot>
|
||||
</Transition>
|
||||
|
||||
<template v-if="preferencesButtonPosition.fixed">
|
||||
<Preferences
|
||||
class="z-100 fixed bottom-20 right-0"
|
||||
@clear-preferences-and-logout="clearPreferencesAndLogout"
|
||||
/>
|
||||
</template>
|
||||
<VbenBackTop />
|
||||
</template>
|
||||
</VbenAdminLayout>
|
||||
</template>
|
||||
41
packages/effects/layouts/src/basic/menu/extra-menu.vue
Normal file
41
packages/effects/layouts/src/basic/menu/extra-menu.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import type { MenuProps } from '@vben-core/menu-ui';
|
||||
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { Menu } from '@vben-core/menu-ui';
|
||||
|
||||
import { useNavigation } from './use-navigation';
|
||||
|
||||
interface Props extends MenuProps {
|
||||
collapse?: boolean;
|
||||
menus: MenuRecordRaw[];
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
accordion: true,
|
||||
menus: () => [],
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const { navigation } = useNavigation();
|
||||
|
||||
async function handleSelect(key: string) {
|
||||
await navigation(key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Menu
|
||||
:accordion="accordion"
|
||||
:collapse="collapse"
|
||||
:default-active="route.meta?.activePath || route.path"
|
||||
:menus="menus"
|
||||
:rounded="rounded"
|
||||
:theme="theme"
|
||||
mode="vertical"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</template>
|
||||
5
packages/effects/layouts/src/basic/menu/index.ts
Normal file
5
packages/effects/layouts/src/basic/menu/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as LayoutExtraMenu } from './extra-menu.vue';
|
||||
export { default as LayoutMenu } from './menu.vue';
|
||||
export { default as LayoutMixedMenu } from './mixed-menu.vue';
|
||||
export * from './use-extra-menu';
|
||||
export * from './use-mixed-menu';
|
||||
44
packages/effects/layouts/src/basic/menu/menu.vue
Normal file
44
packages/effects/layouts/src/basic/menu/menu.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import type { MenuProps } from '@vben-core/menu-ui';
|
||||
|
||||
import { Menu } from '@vben-core/menu-ui';
|
||||
|
||||
interface Props extends MenuProps {
|
||||
menus: MenuRecordRaw[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
accordion: true,
|
||||
menus: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [string, string[]];
|
||||
select: [string, string?];
|
||||
}>();
|
||||
|
||||
function handleMenuSelect(key: string) {
|
||||
emit('select', key, props.mode);
|
||||
}
|
||||
|
||||
function handleMenuOpen(key: string, path: string[]) {
|
||||
emit('open', key, path);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Menu
|
||||
:accordion="accordion"
|
||||
:collapse="collapse"
|
||||
:collapse-show-title="collapseShowTitle"
|
||||
:default-active="defaultActive"
|
||||
:menus="menus"
|
||||
:mode="mode"
|
||||
:rounded="rounded"
|
||||
:theme="theme"
|
||||
@open="handleMenuOpen"
|
||||
@select="handleMenuSelect"
|
||||
/>
|
||||
</template>
|
||||
46
packages/effects/layouts/src/basic/menu/mixed-menu.vue
Normal file
46
packages/effects/layouts/src/basic/menu/mixed-menu.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import type { NormalMenuProps } from '@vben-core/menu-ui';
|
||||
|
||||
import { onBeforeMount } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { findMenuByPath } from '@vben/utils';
|
||||
|
||||
import { NormalMenu } from '@vben-core/menu-ui';
|
||||
|
||||
interface Props extends NormalMenuProps {}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
defaultSelect: [MenuRecordRaw, MenuRecordRaw?];
|
||||
enter: [MenuRecordRaw];
|
||||
select: [MenuRecordRaw];
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
onBeforeMount(() => {
|
||||
const menu = findMenuByPath(props.menus || [], route.path);
|
||||
if (menu) {
|
||||
const rootMenu = (props.menus || []).find(
|
||||
(item) => item.path === menu.parents?.[0],
|
||||
);
|
||||
emit('defaultSelect', menu, rootMenu);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NormalMenu
|
||||
:active-path="activePath"
|
||||
:collapse="collapse"
|
||||
:menus="menus"
|
||||
:rounded="rounded"
|
||||
:theme="theme"
|
||||
@enter="(menu) => emit('enter', menu)"
|
||||
@select="(menu) => emit('select', menu)"
|
||||
/>
|
||||
</template>
|
||||
133
packages/effects/layouts/src/basic/menu/use-extra-menu.ts
Normal file
133
packages/effects/layouts/src/basic/menu/use-extra-menu.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { ComputedRef } from 'vue';
|
||||
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
import { findRootMenuByPath } from '@vben/utils';
|
||||
|
||||
import { useNavigation } from './use-navigation';
|
||||
|
||||
function useExtraMenu(useRootMenus?: ComputedRef<MenuRecordRaw[]>) {
|
||||
const accessStore = useAccessStore();
|
||||
const { navigation, willOpenedByWindow } = useNavigation();
|
||||
|
||||
const menus = computed(() => useRootMenus?.value ?? accessStore.accessMenus);
|
||||
|
||||
/** 记录当前顶级菜单下哪个子菜单最后激活 */
|
||||
const defaultSubMap = new Map<string, string>();
|
||||
const extraRootMenus = ref<MenuRecordRaw[]>([]);
|
||||
const route = useRoute();
|
||||
const extraMenus = ref<MenuRecordRaw[]>([]);
|
||||
const sidebarExtraVisible = ref<boolean>(false);
|
||||
const extraActiveMenu = ref('');
|
||||
const parentLevel = computed(() =>
|
||||
preferences.app.layout === 'header-mixed-nav' ? 1 : 0,
|
||||
);
|
||||
|
||||
/**
|
||||
* 选择混合菜单事件
|
||||
* @param menu
|
||||
*/
|
||||
const handleMixedMenuSelect = async (menu: MenuRecordRaw) => {
|
||||
const _extraMenus = menu?.children ?? [];
|
||||
const hasChildren = _extraMenus.length > 0;
|
||||
|
||||
if (!willOpenedByWindow(menu.path)) {
|
||||
extraMenus.value = _extraMenus ?? [];
|
||||
extraActiveMenu.value = menu.parents?.[parentLevel.value] ?? menu.path;
|
||||
sidebarExtraVisible.value = hasChildren;
|
||||
}
|
||||
|
||||
if (!hasChildren) {
|
||||
await navigation(menu.path);
|
||||
} else if (preferences.sidebar.autoActivateChild) {
|
||||
await navigation(
|
||||
defaultSubMap.has(menu.path)
|
||||
? (defaultSubMap.get(menu.path) as string)
|
||||
: menu.path,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 选择默认菜单事件
|
||||
* @param menu
|
||||
* @param rootMenu
|
||||
*/
|
||||
const handleDefaultSelect = async (
|
||||
menu: MenuRecordRaw,
|
||||
rootMenu?: MenuRecordRaw,
|
||||
) => {
|
||||
extraMenus.value = rootMenu?.children ?? extraRootMenus.value ?? [];
|
||||
extraActiveMenu.value = menu.parents?.[parentLevel.value] ?? menu.path;
|
||||
|
||||
if (preferences.sidebar.expandOnHover) {
|
||||
sidebarExtraVisible.value = extraMenus.value.length > 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 侧边菜单鼠标移出事件
|
||||
*/
|
||||
const handleSideMouseLeave = () => {
|
||||
if (preferences.sidebar.expandOnHover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
|
||||
menus.value,
|
||||
route.path,
|
||||
);
|
||||
extraActiveMenu.value = rootMenuPath ?? findMenu?.path ?? '';
|
||||
extraMenus.value = rootMenu?.children ?? [];
|
||||
};
|
||||
|
||||
const handleMenuMouseEnter = (menu: MenuRecordRaw) => {
|
||||
if (!preferences.sidebar.expandOnHover) {
|
||||
const { findMenu } = findRootMenuByPath(menus.value, menu.path);
|
||||
extraMenus.value = findMenu?.children ?? [];
|
||||
extraActiveMenu.value = menu.parents?.[parentLevel.value] ?? menu.path;
|
||||
sidebarExtraVisible.value = extraMenus.value.length > 0;
|
||||
}
|
||||
};
|
||||
|
||||
function calcExtraMenus(path: string) {
|
||||
const currentPath = route.meta?.activePath || path;
|
||||
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
|
||||
menus.value,
|
||||
currentPath,
|
||||
parentLevel.value,
|
||||
);
|
||||
extraRootMenus.value = rootMenu?.children ?? [];
|
||||
if (rootMenuPath) defaultSubMap.set(rootMenuPath, currentPath);
|
||||
extraActiveMenu.value = rootMenuPath ?? findMenu?.path ?? '';
|
||||
extraMenus.value = rootMenu?.children ?? [];
|
||||
if (preferences.sidebar.expandOnHover) {
|
||||
sidebarExtraVisible.value = extraMenus.value.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.path, preferences.app.layout],
|
||||
([path]) => {
|
||||
calcExtraMenus(path || '');
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return {
|
||||
extraActiveMenu,
|
||||
extraMenus,
|
||||
handleDefaultSelect,
|
||||
handleMenuMouseEnter,
|
||||
handleMixedMenuSelect,
|
||||
handleSideMouseLeave,
|
||||
sidebarExtraVisible,
|
||||
};
|
||||
}
|
||||
|
||||
export { useExtraMenu };
|
||||
169
packages/effects/layouts/src/basic/menu/use-mixed-menu.ts
Normal file
169
packages/effects/layouts/src/basic/menu/use-mixed-menu.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import { computed, onBeforeMount, ref, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
import { findRootMenuByPath } from '@vben/utils';
|
||||
|
||||
import { useNavigation } from './use-navigation';
|
||||
|
||||
function useMixedMenu() {
|
||||
const { navigation, willOpenedByWindow } = useNavigation();
|
||||
const accessStore = useAccessStore();
|
||||
const route = useRoute();
|
||||
const splitSideMenus = ref<MenuRecordRaw[]>([]);
|
||||
const rootMenuPath = ref<string>('');
|
||||
const mixedRootMenuPath = ref<string>('');
|
||||
const mixExtraMenus = ref<MenuRecordRaw[]>([]);
|
||||
/** 记录当前顶级菜单下哪个子菜单最后激活 */
|
||||
const defaultSubMap = new Map<string, string>();
|
||||
const { isMixedNav, isHeaderMixedNav } = usePreferences();
|
||||
|
||||
const needSplit = computed(
|
||||
() =>
|
||||
(preferences.navigation.split && isMixedNav.value) ||
|
||||
isHeaderMixedNav.value,
|
||||
);
|
||||
|
||||
const sidebarVisible = computed(() => {
|
||||
const enableSidebar = preferences.sidebar.enable;
|
||||
if (needSplit.value) {
|
||||
return enableSidebar && splitSideMenus.value.length > 0;
|
||||
}
|
||||
return enableSidebar;
|
||||
});
|
||||
const menus = computed(() => accessStore.accessMenus);
|
||||
|
||||
/**
|
||||
* 头部菜单
|
||||
*/
|
||||
const headerMenus = computed(() => {
|
||||
if (!needSplit.value) {
|
||||
return menus.value;
|
||||
}
|
||||
return menus.value.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
children: [],
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边菜单
|
||||
*/
|
||||
const sidebarMenus = computed(() => {
|
||||
return needSplit.value ? splitSideMenus.value : menus.value;
|
||||
});
|
||||
|
||||
const mixHeaderMenus = computed(() => {
|
||||
return isHeaderMixedNav.value ? sidebarMenus.value : headerMenus.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边菜单激活路径
|
||||
*/
|
||||
const sidebarActive = computed(() => {
|
||||
return (route?.meta?.activePath as string) ?? route.path;
|
||||
});
|
||||
|
||||
/**
|
||||
* 头部菜单激活路径
|
||||
*/
|
||||
const headerActive = computed(() => {
|
||||
if (!needSplit.value) {
|
||||
return route.meta?.activePath ?? route.path;
|
||||
}
|
||||
return rootMenuPath.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 菜单点击事件处理
|
||||
* @param key 菜单路径
|
||||
* @param mode 菜单模式
|
||||
*/
|
||||
const handleMenuSelect = (key: string, mode?: string) => {
|
||||
if (!needSplit.value || mode === 'vertical') {
|
||||
navigation(key);
|
||||
return;
|
||||
}
|
||||
const rootMenu = menus.value.find((item) => item.path === key);
|
||||
const _splitSideMenus = rootMenu?.children ?? [];
|
||||
|
||||
if (!willOpenedByWindow(key)) {
|
||||
rootMenuPath.value = rootMenu?.path ?? '';
|
||||
splitSideMenus.value = _splitSideMenus;
|
||||
}
|
||||
|
||||
if (_splitSideMenus.length === 0) {
|
||||
navigation(key);
|
||||
} else if (rootMenu && preferences.sidebar.autoActivateChild) {
|
||||
navigation(
|
||||
defaultSubMap.has(rootMenu.path)
|
||||
? (defaultSubMap.get(rootMenu.path) as string)
|
||||
: rootMenu.path,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 侧边菜单展开事件
|
||||
* @param key 路由路径
|
||||
* @param parentsPath 父级路径
|
||||
*/
|
||||
const handleMenuOpen = (key: string, parentsPath: string[]) => {
|
||||
if (parentsPath.length <= 1 && preferences.sidebar.autoActivateChild) {
|
||||
navigation(
|
||||
defaultSubMap.has(key) ? (defaultSubMap.get(key) as string) : key,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算侧边菜单
|
||||
* @param path 路由路径
|
||||
*/
|
||||
function calcSideMenus(path: string = route.path) {
|
||||
let { rootMenu } = findRootMenuByPath(menus.value, path);
|
||||
if (!rootMenu) {
|
||||
rootMenu = menus.value.find((item) => item.path === path);
|
||||
}
|
||||
const result = findRootMenuByPath(rootMenu?.children || [], path, 1);
|
||||
mixedRootMenuPath.value = result.rootMenuPath ?? '';
|
||||
mixExtraMenus.value = result.rootMenu?.children ?? [];
|
||||
rootMenuPath.value = rootMenu?.path ?? '';
|
||||
splitSideMenus.value = rootMenu?.children ?? [];
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
const currentPath = (route?.meta?.activePath as string) ?? path;
|
||||
calcSideMenus(currentPath);
|
||||
if (rootMenuPath.value)
|
||||
defaultSubMap.set(rootMenuPath.value, currentPath);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 初始化计算侧边菜单
|
||||
onBeforeMount(() => {
|
||||
calcSideMenus(route.meta?.activePath || route.path);
|
||||
});
|
||||
|
||||
return {
|
||||
handleMenuSelect,
|
||||
handleMenuOpen,
|
||||
headerActive,
|
||||
headerMenus,
|
||||
sidebarActive,
|
||||
sidebarMenus,
|
||||
mixHeaderMenus,
|
||||
mixExtraMenus,
|
||||
sidebarVisible,
|
||||
};
|
||||
}
|
||||
|
||||
export { useMixedMenu };
|
||||
47
packages/effects/layouts/src/basic/menu/use-navigation.ts
Normal file
47
packages/effects/layouts/src/basic/menu/use-navigation.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { isHttpUrl, openRouteInNewWindow, openWindow } from '@vben/utils';
|
||||
|
||||
function useNavigation() {
|
||||
const router = useRouter();
|
||||
const routes = router.getRoutes();
|
||||
|
||||
const routeMetaMap = new Map<string, RouteRecordNormalized>();
|
||||
|
||||
routes.forEach((route) => {
|
||||
routeMetaMap.set(route.path, route);
|
||||
});
|
||||
|
||||
const navigation = async (path: string) => {
|
||||
const route = routeMetaMap.get(path);
|
||||
const { openInNewWindow = false, query = {} } = route?.meta ?? {};
|
||||
if (isHttpUrl(path)) {
|
||||
openWindow(path, { target: '_blank' });
|
||||
} else if (openInNewWindow) {
|
||||
openRouteInNewWindow(path);
|
||||
} else {
|
||||
await router.push({
|
||||
path,
|
||||
query,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const willOpenedByWindow = (path: string) => {
|
||||
const route = routeMetaMap.get(path);
|
||||
const { openInNewWindow = false } = route?.meta ?? {};
|
||||
if (isHttpUrl(path)) {
|
||||
return true;
|
||||
} else if (openInNewWindow) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return { navigation, willOpenedByWindow };
|
||||
}
|
||||
|
||||
export { useNavigation };
|
||||
2
packages/effects/layouts/src/basic/tabbar/index.ts
Normal file
2
packages/effects/layouts/src/basic/tabbar/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as LayoutTabbar } from './tabbar.vue';
|
||||
export * from './use-tabbar';
|
||||
75
packages/effects/layouts/src/basic/tabbar/tabbar.vue
Normal file
75
packages/effects/layouts/src/basic/tabbar/tabbar.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { useContentMaximize, useTabs } from '@vben/hooks';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useTabbarStore } from '@vben/stores';
|
||||
|
||||
import { TabsToolMore, TabsToolScreen, TabsView } from '@vben-core/tabs-ui';
|
||||
|
||||
import { useTabbar } from './use-tabbar';
|
||||
|
||||
defineOptions({
|
||||
name: 'LayoutTabbar',
|
||||
});
|
||||
|
||||
defineProps<{ showIcon?: boolean; theme?: string }>();
|
||||
|
||||
const route = useRoute();
|
||||
const tabbarStore = useTabbarStore();
|
||||
const { contentIsMaximize, toggleMaximize } = useContentMaximize();
|
||||
const { unpinTab } = useTabs();
|
||||
|
||||
const {
|
||||
createContextMenus,
|
||||
currentActive,
|
||||
currentTabs,
|
||||
handleClick,
|
||||
handleClose,
|
||||
} = useTabbar();
|
||||
|
||||
const menus = computed(() => {
|
||||
const tab = tabbarStore.getTabByPath(currentActive.value);
|
||||
const menus = createContextMenus(tab);
|
||||
return menus.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
label: item.text,
|
||||
value: item.key,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// 刷新后如果不保持tab状态,关闭其他tab
|
||||
if (!preferences.tabbar.persist) {
|
||||
tabbarStore.closeOtherTabs(route);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsView
|
||||
:active="currentActive"
|
||||
:class="theme"
|
||||
:context-menus="createContextMenus"
|
||||
:draggable="preferences.tabbar.draggable"
|
||||
:show-icon="showIcon"
|
||||
:style-type="preferences.tabbar.styleType"
|
||||
:tabs="currentTabs"
|
||||
:wheelable="preferences.tabbar.wheelable"
|
||||
:middle-click-to-close="preferences.tabbar.middleClickToClose"
|
||||
@close="handleClose"
|
||||
@sort-tabs="tabbarStore.sortTabs"
|
||||
@unpin="unpinTab"
|
||||
@update:active="handleClick"
|
||||
/>
|
||||
<div class="flex-center h-full">
|
||||
<TabsToolMore v-if="preferences.tabbar.showMore" :menus="menus" />
|
||||
<TabsToolScreen
|
||||
v-if="preferences.tabbar.showMaximize"
|
||||
:screen="contentIsMaximize"
|
||||
@change="toggleMaximize"
|
||||
@update:screen="toggleMaximize"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
223
packages/effects/layouts/src/basic/tabbar/use-tabbar.ts
Normal file
223
packages/effects/layouts/src/basic/tabbar/use-tabbar.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type { RouteLocationNormalizedGeneric } from 'vue-router';
|
||||
|
||||
import type { TabDefinition } from '@vben/types';
|
||||
|
||||
import type { IContextMenuItem } from '@vben-core/tabs-ui';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useContentMaximize, useTabs } from '@vben/hooks';
|
||||
import {
|
||||
ArrowLeftToLine,
|
||||
ArrowRightLeft,
|
||||
ArrowRightToLine,
|
||||
ExternalLink,
|
||||
FoldHorizontal,
|
||||
Fullscreen,
|
||||
Minimize2,
|
||||
Pin,
|
||||
PinOff,
|
||||
RotateCw,
|
||||
X,
|
||||
} from '@vben/icons';
|
||||
import { $t, useI18n } from '@vben/locales';
|
||||
import { useAccessStore, useTabbarStore } from '@vben/stores';
|
||||
import { filterTree } from '@vben/utils';
|
||||
|
||||
export function useTabbar() {
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const accessStore = useAccessStore();
|
||||
const tabbarStore = useTabbarStore();
|
||||
const { contentIsMaximize, toggleMaximize } = useContentMaximize();
|
||||
const {
|
||||
closeAllTabs,
|
||||
closeCurrentTab,
|
||||
closeLeftTabs,
|
||||
closeOtherTabs,
|
||||
closeRightTabs,
|
||||
closeTabByKey,
|
||||
getTabDisableState,
|
||||
openTabInNewWindow,
|
||||
refreshTab,
|
||||
toggleTabPin,
|
||||
} = useTabs();
|
||||
|
||||
const currentActive = computed(() => {
|
||||
return route.fullPath;
|
||||
});
|
||||
|
||||
const { locale } = useI18n();
|
||||
const currentTabs = ref<RouteLocationNormalizedGeneric[]>();
|
||||
watch(
|
||||
[
|
||||
() => tabbarStore.getTabs,
|
||||
() => tabbarStore.updateTime,
|
||||
() => locale.value,
|
||||
],
|
||||
([tabs]) => {
|
||||
currentTabs.value = tabs.map((item) => wrapperTabLocale(item));
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 初始化固定标签页
|
||||
*/
|
||||
const initAffixTabs = () => {
|
||||
const affixTabs = filterTree(router.getRoutes(), (route) => {
|
||||
return !!route.meta?.affixTab;
|
||||
});
|
||||
tabbarStore.setAffixTabs(affixTabs);
|
||||
};
|
||||
|
||||
// 点击tab,跳转路由
|
||||
const handleClick = (key: string) => {
|
||||
router.push(key);
|
||||
};
|
||||
|
||||
// 关闭tab
|
||||
const handleClose = async (key: string) => {
|
||||
await closeTabByKey(key);
|
||||
};
|
||||
|
||||
function wrapperTabLocale(tab: RouteLocationNormalizedGeneric) {
|
||||
return {
|
||||
...tab,
|
||||
meta: {
|
||||
...tab?.meta,
|
||||
title: $t(tab?.meta?.title as string),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => accessStore.accessMenus,
|
||||
() => {
|
||||
initAffixTabs();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
const meta = route.matched?.[route.matched.length - 1]?.meta;
|
||||
tabbarStore.addTab({
|
||||
...route,
|
||||
meta: meta || route.meta,
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const createContextMenus = (tab: TabDefinition) => {
|
||||
const {
|
||||
disabledCloseAll,
|
||||
disabledCloseCurrent,
|
||||
disabledCloseLeft,
|
||||
disabledCloseOther,
|
||||
disabledCloseRight,
|
||||
disabledRefresh,
|
||||
} = getTabDisableState(tab);
|
||||
|
||||
const affixTab = tab?.meta?.affixTab ?? false;
|
||||
|
||||
const menus: IContextMenuItem[] = [
|
||||
{
|
||||
disabled: disabledCloseCurrent,
|
||||
handler: async () => {
|
||||
await closeCurrentTab(tab);
|
||||
},
|
||||
icon: X,
|
||||
key: 'close',
|
||||
text: $t('preferences.tabbar.contextMenu.close'),
|
||||
},
|
||||
{
|
||||
handler: async () => {
|
||||
await toggleTabPin(tab);
|
||||
},
|
||||
icon: affixTab ? PinOff : Pin,
|
||||
key: 'affix',
|
||||
text: affixTab
|
||||
? $t('preferences.tabbar.contextMenu.unpin')
|
||||
: $t('preferences.tabbar.contextMenu.pin'),
|
||||
},
|
||||
{
|
||||
handler: async () => {
|
||||
if (!contentIsMaximize.value) {
|
||||
await router.push(tab.fullPath);
|
||||
}
|
||||
toggleMaximize();
|
||||
},
|
||||
icon: contentIsMaximize.value ? Minimize2 : Fullscreen,
|
||||
key: contentIsMaximize.value ? 'restore-maximize' : 'maximize',
|
||||
text: contentIsMaximize.value
|
||||
? $t('preferences.tabbar.contextMenu.restoreMaximize')
|
||||
: $t('preferences.tabbar.contextMenu.maximize'),
|
||||
},
|
||||
{
|
||||
disabled: disabledRefresh,
|
||||
handler: refreshTab,
|
||||
icon: RotateCw,
|
||||
key: 'reload',
|
||||
text: $t('preferences.tabbar.contextMenu.reload'),
|
||||
},
|
||||
{
|
||||
handler: async () => {
|
||||
await openTabInNewWindow(tab);
|
||||
},
|
||||
icon: ExternalLink,
|
||||
key: 'open-in-new-window',
|
||||
separator: true,
|
||||
text: $t('preferences.tabbar.contextMenu.openInNewWindow'),
|
||||
},
|
||||
|
||||
{
|
||||
disabled: disabledCloseLeft,
|
||||
handler: async () => {
|
||||
await closeLeftTabs(tab);
|
||||
},
|
||||
icon: ArrowLeftToLine,
|
||||
key: 'close-left',
|
||||
text: $t('preferences.tabbar.contextMenu.closeLeft'),
|
||||
},
|
||||
{
|
||||
disabled: disabledCloseRight,
|
||||
handler: async () => {
|
||||
await closeRightTabs(tab);
|
||||
},
|
||||
icon: ArrowRightToLine,
|
||||
key: 'close-right',
|
||||
separator: true,
|
||||
text: $t('preferences.tabbar.contextMenu.closeRight'),
|
||||
},
|
||||
{
|
||||
disabled: disabledCloseOther,
|
||||
handler: async () => {
|
||||
await closeOtherTabs(tab);
|
||||
},
|
||||
icon: FoldHorizontal,
|
||||
key: 'close-other',
|
||||
text: $t('preferences.tabbar.contextMenu.closeOther'),
|
||||
},
|
||||
{
|
||||
disabled: disabledCloseAll,
|
||||
handler: closeAllTabs,
|
||||
icon: ArrowRightLeft,
|
||||
key: 'close-all',
|
||||
text: $t('preferences.tabbar.contextMenu.closeAll'),
|
||||
},
|
||||
];
|
||||
|
||||
return menus.filter((item) => tabbarStore.getMenuList.includes(item.key));
|
||||
};
|
||||
|
||||
return {
|
||||
createContextMenus,
|
||||
currentActive,
|
||||
currentTabs,
|
||||
handleClick,
|
||||
handleClose,
|
||||
};
|
||||
}
|
||||
86
packages/effects/layouts/src/iframe/iframe-router-view.vue
Normal file
86
packages/effects/layouts/src/iframe/iframe-router-view.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts" setup>
|
||||
import type { RouteLocationNormalized } from 'vue-router';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useTabbarStore } from '@vben/stores';
|
||||
|
||||
import { VbenSpinner } from '@vben-core/shadcn-ui';
|
||||
|
||||
defineOptions({ name: 'IFrameRouterView' });
|
||||
|
||||
const spinningList = ref<boolean[]>([]);
|
||||
const tabbarStore = useTabbarStore();
|
||||
const route = useRoute();
|
||||
|
||||
const enableTabbar = computed(() => preferences.tabbar.enable);
|
||||
|
||||
const iframeRoutes = computed(() => {
|
||||
if (!enableTabbar.value) {
|
||||
return route.meta.iframeSrc ? [route] : [];
|
||||
}
|
||||
return tabbarStore.getTabs.filter((tab) => !!tab.meta?.iframeSrc);
|
||||
});
|
||||
|
||||
const tabNames = computed(
|
||||
() => new Set(iframeRoutes.value.map((item) => item.name as string)),
|
||||
);
|
||||
|
||||
const showIframe = computed(() => iframeRoutes.value.length > 0);
|
||||
|
||||
function routeShow(tabItem: RouteLocationNormalized) {
|
||||
return tabItem.name === route.name;
|
||||
}
|
||||
|
||||
function canRender(tabItem: RouteLocationNormalized) {
|
||||
const { meta, name } = tabItem;
|
||||
|
||||
if (!name || !tabbarStore.renderRouteView) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!enableTabbar.value) {
|
||||
return routeShow(tabItem);
|
||||
}
|
||||
|
||||
// 跟随 keepAlive 状态,与其他tab页保持一致
|
||||
if (
|
||||
!meta?.keepAlive &&
|
||||
tabNames.value.has(name as string) &&
|
||||
name !== route.name
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return tabbarStore.getTabs.some((tab) => tab.name === name);
|
||||
}
|
||||
|
||||
function hideLoading(index: number) {
|
||||
spinningList.value[index] = false;
|
||||
}
|
||||
|
||||
function showSpinning(index: number) {
|
||||
const curSpinning = spinningList.value[index];
|
||||
// 首次加载时显示loading
|
||||
return curSpinning === undefined ? true : curSpinning;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<template v-if="showIframe">
|
||||
<template v-for="(item, index) in iframeRoutes" :key="item.fullPath">
|
||||
<div
|
||||
v-if="canRender(item)"
|
||||
v-show="routeShow(item)"
|
||||
class="relative size-full"
|
||||
>
|
||||
<VbenSpinner :spinning="showSpinning(index)" />
|
||||
<iframe
|
||||
:src="item.meta.iframeSrc as string"
|
||||
class="size-full"
|
||||
@load="hideLoading(index)"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
3
packages/effects/layouts/src/iframe/iframe-view.vue
Normal file
3
packages/effects/layouts/src/iframe/iframe-view.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
2
packages/effects/layouts/src/iframe/index.ts
Normal file
2
packages/effects/layouts/src/iframe/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as IFrameRouterView } from './iframe-router-view.vue';
|
||||
export { default as IFrameView } from './iframe-view.vue';
|
||||
4
packages/effects/layouts/src/index.ts
Normal file
4
packages/effects/layouts/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './authentication';
|
||||
export * from './basic';
|
||||
export * from './iframe';
|
||||
export * from './widgets';
|
||||
74
packages/effects/layouts/src/widgets/breadcrumb.vue
Normal file
74
packages/effects/layouts/src/widgets/breadcrumb.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts" setup>
|
||||
import type { BreadcrumbStyleType } from '@vben/types';
|
||||
|
||||
import type { IBreadcrumb } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { VbenBreadcrumbView } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props {
|
||||
hideWhenOnlyOne?: boolean;
|
||||
showHome?: boolean;
|
||||
showIcon?: boolean;
|
||||
type?: BreadcrumbStyleType;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showHome: false,
|
||||
showIcon: false,
|
||||
type: 'normal',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const breadcrumbs = computed((): IBreadcrumb[] => {
|
||||
const matched = route.matched;
|
||||
|
||||
const resultBreadcrumb: IBreadcrumb[] = [];
|
||||
|
||||
for (const match of matched) {
|
||||
const { meta, path } = match;
|
||||
const { hideChildrenInMenu, hideInBreadcrumb, icon, name, title } =
|
||||
meta || {};
|
||||
if (hideInBreadcrumb || hideChildrenInMenu || !path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resultBreadcrumb.push({
|
||||
icon,
|
||||
path: path || route.path,
|
||||
title: title ? $t((title || name) as string) : '',
|
||||
});
|
||||
}
|
||||
if (props.showHome) {
|
||||
resultBreadcrumb.unshift({
|
||||
icon: 'mdi:home-outline',
|
||||
isHome: true,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
if (props.hideWhenOnlyOne && resultBreadcrumb.length === 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return resultBreadcrumb;
|
||||
});
|
||||
|
||||
function handleSelect(path: string) {
|
||||
router.push(path);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<VbenBreadcrumbView
|
||||
:breadcrumbs="breadcrumbs"
|
||||
:show-icon="showIcon"
|
||||
:style-type="type"
|
||||
class="ml-2"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useVbenModal } from '@vben-core/popup-ui';
|
||||
|
||||
interface Props {
|
||||
// 轮训时间,分钟
|
||||
checkUpdatesInterval?: number;
|
||||
// 检查更新的地址
|
||||
checkUpdateUrl?: string;
|
||||
}
|
||||
|
||||
defineOptions({ name: 'CheckUpdates' });
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
checkUpdatesInterval: 1,
|
||||
checkUpdateUrl: import.meta.env.BASE_URL || '/',
|
||||
});
|
||||
|
||||
let isCheckingUpdates = false;
|
||||
const currentVersionTag = ref('');
|
||||
const lastVersionTag = ref('');
|
||||
const timer = ref<ReturnType<typeof setInterval>>();
|
||||
|
||||
const [UpdateNoticeModal, modalApi] = useVbenModal({
|
||||
closable: false,
|
||||
closeOnPressEscape: false,
|
||||
closeOnClickModal: false,
|
||||
onConfirm() {
|
||||
lastVersionTag.value = currentVersionTag.value;
|
||||
window.location.reload();
|
||||
// handleSubmitLogout();
|
||||
},
|
||||
});
|
||||
|
||||
async function getVersionTag() {
|
||||
try {
|
||||
if (
|
||||
location.hostname === 'localhost' ||
|
||||
location.hostname === '127.0.0.1'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const response = await fetch(props.checkUpdateUrl, {
|
||||
cache: 'no-cache',
|
||||
method: 'HEAD',
|
||||
});
|
||||
|
||||
return (
|
||||
response.headers.get('etag') || response.headers.get('last-modified')
|
||||
);
|
||||
} catch {
|
||||
console.error('Failed to fetch version tag');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates() {
|
||||
const versionTag = await getVersionTag();
|
||||
if (!versionTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 首次运行时不提示更新
|
||||
if (!lastVersionTag.value) {
|
||||
lastVersionTag.value = versionTag;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastVersionTag.value !== versionTag && versionTag) {
|
||||
clearInterval(timer.value);
|
||||
handleNotice(versionTag);
|
||||
}
|
||||
}
|
||||
function handleNotice(versionTag: string) {
|
||||
currentVersionTag.value = versionTag;
|
||||
modalApi.open();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (props.checkUpdatesInterval <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 每 checkUpdatesInterval(默认值为1) 分钟检查一次
|
||||
timer.value = setInterval(
|
||||
checkForUpdates,
|
||||
props.checkUpdatesInterval * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
function handleVisibilitychange() {
|
||||
if (document.hidden) {
|
||||
stop();
|
||||
} else {
|
||||
if (!isCheckingUpdates) {
|
||||
isCheckingUpdates = true;
|
||||
checkForUpdates().finally(() => {
|
||||
isCheckingUpdates = false;
|
||||
start();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearInterval(timer.value);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
start();
|
||||
document.addEventListener('visibilitychange', handleVisibilitychange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stop();
|
||||
document.removeEventListener('visibilitychange', handleVisibilitychange);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<UpdateNoticeModal
|
||||
:cancel-text="$t('common.cancel')"
|
||||
:confirm-text="$t('common.refresh')"
|
||||
:fullscreen-button="false"
|
||||
:title="$t('ui.widgets.checkUpdatesTitle')"
|
||||
centered
|
||||
content-class="px-8 min-h-10"
|
||||
footer-class="border-none mb-3 mr-3"
|
||||
header-class="border-none"
|
||||
>
|
||||
{{ $t('ui.widgets.checkUpdatesDescription') }}
|
||||
</UpdateNoticeModal>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CheckUpdates } from './check-updates.vue';
|
||||
64
packages/effects/layouts/src/widgets/color-toggle.vue
Normal file
64
packages/effects/layouts/src/widgets/color-toggle.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import type { BuiltinThemeType } from '@vben/types';
|
||||
|
||||
import { Palette } from '@vben/icons';
|
||||
import {
|
||||
COLOR_PRESETS,
|
||||
preferences,
|
||||
updatePreferences,
|
||||
} from '@vben/preferences';
|
||||
|
||||
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'AuthenticationColorToggle',
|
||||
});
|
||||
|
||||
function handleUpdate(colorPrimary: string, type: BuiltinThemeType) {
|
||||
updatePreferences({
|
||||
theme: {
|
||||
colorPrimary,
|
||||
builtinType: type,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group relative flex items-center overflow-hidden">
|
||||
<div
|
||||
class="flex w-0 overflow-hidden transition-all duration-500 ease-out group-hover:w-60"
|
||||
>
|
||||
<template v-for="preset in COLOR_PRESETS" :key="preset.color">
|
||||
<VbenIconButton
|
||||
class="flex-center flex-shrink-0"
|
||||
@click="handleUpdate(preset.color, preset.type)"
|
||||
>
|
||||
<div
|
||||
:style="{ backgroundColor: preset.color }"
|
||||
class="flex-center relative size-5 rounded-full hover:scale-110"
|
||||
>
|
||||
<svg
|
||||
v-if="preferences.theme.builtinType === preset.type"
|
||||
class="h-3.5 w-3.5 text-white"
|
||||
height="1em"
|
||||
viewBox="0 0 15 15"
|
||||
width="1em"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M11.467 3.727c.289.189.37.576.181.865l-4.25 6.5a.625.625 0 0 1-.944.12l-2.75-2.5a.625.625 0 0 1 .841-.925l2.208 2.007l3.849-5.886a.625.625 0 0 1 .865-.181"
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</VbenIconButton>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<VbenIconButton>
|
||||
<Palette class="text-primary size-4" />
|
||||
</VbenIconButton>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CornerDownLeft,
|
||||
MdiKeyboardEsc,
|
||||
Search,
|
||||
} from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { isWindowsOs } from '@vben/utils';
|
||||
|
||||
import { useVbenModal } from '@vben-core/popup-ui';
|
||||
|
||||
import { useMagicKeys, whenever } from '@vueuse/core';
|
||||
|
||||
import SearchPanel from './search-panel.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'GlobalSearch',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ enableShortcutKey?: boolean; menus: MenuRecordRaw[] }>(),
|
||||
{
|
||||
enableShortcutKey: true,
|
||||
menus: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
const keyword = ref('');
|
||||
const searchInputRef = ref<HTMLInputElement>();
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
onCancel() {
|
||||
modalApi.close();
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
keyword.value = '';
|
||||
}
|
||||
},
|
||||
});
|
||||
const open = modalApi.useStore((state) => state.isOpen);
|
||||
|
||||
function handleClose() {
|
||||
modalApi.close();
|
||||
keyword.value = '';
|
||||
}
|
||||
|
||||
const keys = useMagicKeys();
|
||||
const cmd = isWindowsOs() ? keys['ctrl+k'] : keys['cmd+k'];
|
||||
whenever(cmd!, () => {
|
||||
if (props.enableShortcutKey) {
|
||||
modalApi.open();
|
||||
}
|
||||
});
|
||||
|
||||
whenever(open, () => {
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus();
|
||||
});
|
||||
});
|
||||
|
||||
const preventDefaultBrowserSearchHotKey = (event: KeyboardEvent) => {
|
||||
if (event.key?.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleKeydownListener = () => {
|
||||
if (props.enableShortcutKey) {
|
||||
window.addEventListener('keydown', preventDefaultBrowserSearchHotKey);
|
||||
} else {
|
||||
window.removeEventListener('keydown', preventDefaultBrowserSearchHotKey);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOpen = () => {
|
||||
open.value ? modalApi.close() : modalApi.open();
|
||||
};
|
||||
|
||||
watch(() => props.enableShortcutKey, toggleKeydownListener);
|
||||
|
||||
onMounted(() => {
|
||||
toggleKeydownListener();
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', preventDefaultBrowserSearchHotKey);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Modal
|
||||
:fullscreen-button="false"
|
||||
class="w-[600px]"
|
||||
header-class="py-2 border-b"
|
||||
>
|
||||
<template #title>
|
||||
<div class="flex items-center">
|
||||
<Search class="text-muted-foreground mr-2 size-4" />
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="keyword"
|
||||
:placeholder="$t('ui.widgets.search.searchNavigate')"
|
||||
class="ring-none placeholder:text-muted-foreground w-[80%] rounded-md border border-none bg-transparent p-2 pl-0 text-sm font-normal outline-none ring-0 ring-offset-transparent focus-visible:ring-transparent"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SearchPanel :keyword="keyword" :menus="menus" @close="handleClose" />
|
||||
<template #footer>
|
||||
<div class="flex w-full justify-start text-xs">
|
||||
<div class="mr-2 flex items-center">
|
||||
<CornerDownLeft class="mr-1 size-3" />
|
||||
{{ $t('ui.widgets.search.select') }}
|
||||
</div>
|
||||
<div class="mr-2 flex items-center">
|
||||
<ArrowUp class="mr-1 size-3" />
|
||||
<ArrowDown class="mr-1 size-3" />
|
||||
{{ $t('ui.widgets.search.navigate') }}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<MdiKeyboardEsc class="mr-1 size-3" />
|
||||
{{ $t('ui.widgets.search.close') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
<div
|
||||
class="md:bg-accent group flex h-8 cursor-pointer items-center gap-3 rounded-2xl border-none bg-none px-2 py-0.5 outline-none"
|
||||
@click="toggleOpen()"
|
||||
>
|
||||
<Search
|
||||
class="text-muted-foreground group-hover:text-foreground size-4 group-hover:opacity-100"
|
||||
/>
|
||||
<span
|
||||
class="text-muted-foreground group-hover:text-foreground hidden text-xs duration-300 md:block"
|
||||
>
|
||||
{{ $t('ui.widgets.search.title') }}
|
||||
</span>
|
||||
<span
|
||||
v-if="enableShortcutKey"
|
||||
class="bg-background border-foreground/60 text-muted-foreground group-hover:text-foreground relative hidden rounded-sm rounded-r-xl px-1.5 py-1 text-xs leading-none group-hover:opacity-100 md:block"
|
||||
>
|
||||
{{ isWindowsOs() ? 'Ctrl' : '⌘' }}
|
||||
<kbd>K</kbd>
|
||||
</span>
|
||||
<span v-else></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as GlobalSearch } from './global-search.vue';
|
||||
@@ -0,0 +1,288 @@
|
||||
<script setup lang="ts">
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import { nextTick, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { SearchX, X } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { mapTree, traverseTreeValues, uniqueByField } from '@vben/utils';
|
||||
|
||||
import { VbenIcon, VbenScrollbar } from '@vben-core/shadcn-ui';
|
||||
import { isHttpUrl } from '@vben-core/shared/utils';
|
||||
|
||||
import { onKeyStroke, useLocalStorage, useThrottleFn } from '@vueuse/core';
|
||||
|
||||
defineOptions({
|
||||
name: 'SearchPanel',
|
||||
});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ keyword: string; menus: MenuRecordRaw[] }>(),
|
||||
{
|
||||
keyword: '',
|
||||
menus: () => [],
|
||||
},
|
||||
);
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
|
||||
const router = useRouter();
|
||||
const searchHistory = useLocalStorage<MenuRecordRaw[]>(
|
||||
`__search-history-${location.hostname}__`,
|
||||
[],
|
||||
);
|
||||
const activeIndex = ref(-1);
|
||||
const searchItems = shallowRef<MenuRecordRaw[]>([]);
|
||||
const searchResults = ref<MenuRecordRaw[]>([]);
|
||||
|
||||
const handleSearch = useThrottleFn(search, 200);
|
||||
|
||||
// 搜索函数,用于根据搜索关键词查找匹配的菜单项
|
||||
function search(searchKey: string) {
|
||||
// 去除搜索关键词的前后空格
|
||||
searchKey = searchKey.trim();
|
||||
|
||||
// 如果搜索关键词为空,清空搜索结果并返回
|
||||
if (!searchKey) {
|
||||
searchResults.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用搜索关键词创建正则表达式
|
||||
const reg = createSearchReg(searchKey);
|
||||
|
||||
// 初始化结果数组
|
||||
const results: MenuRecordRaw[] = [];
|
||||
|
||||
// 遍历搜索项
|
||||
traverseTreeValues(searchItems.value, (item) => {
|
||||
// 如果菜单项的名称匹配正则表达式,将其添加到结果数组中
|
||||
if (reg.test(item.name?.toLowerCase())) {
|
||||
results.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
// 更新搜索结果
|
||||
searchResults.value = results;
|
||||
|
||||
// 如果有搜索结果,设置索引为 0
|
||||
if (results.length > 0) {
|
||||
activeIndex.value = 0;
|
||||
}
|
||||
|
||||
// 赋值索引为 0
|
||||
activeIndex.value = 0;
|
||||
}
|
||||
|
||||
// When the keyboard up and down keys move to an invisible place
|
||||
// the scroll bar needs to scroll automatically
|
||||
function scrollIntoView() {
|
||||
const element = document.querySelector(
|
||||
`[data-search-item="${activeIndex.value}"]`,
|
||||
);
|
||||
|
||||
if (element) {
|
||||
element.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
// enter keyboard event
|
||||
async function handleEnter() {
|
||||
if (searchResults.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
const result = searchResults.value;
|
||||
const index = activeIndex.value;
|
||||
if (result.length === 0 || index < 0) {
|
||||
return;
|
||||
}
|
||||
const to = result[index];
|
||||
if (to) {
|
||||
searchHistory.value.push(to);
|
||||
handleClose();
|
||||
await nextTick();
|
||||
if (isHttpUrl(to.path)) {
|
||||
window.open(to.path, '_blank');
|
||||
} else {
|
||||
router.push({ path: to.path, replace: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow key up
|
||||
function handleUp() {
|
||||
if (searchResults.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
activeIndex.value--;
|
||||
if (activeIndex.value < 0) {
|
||||
activeIndex.value = searchResults.value.length - 1;
|
||||
}
|
||||
scrollIntoView();
|
||||
}
|
||||
|
||||
// Arrow key down
|
||||
function handleDown() {
|
||||
if (searchResults.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
activeIndex.value++;
|
||||
if (activeIndex.value > searchResults.value.length - 1) {
|
||||
activeIndex.value = 0;
|
||||
}
|
||||
scrollIntoView();
|
||||
}
|
||||
|
||||
// close search modal
|
||||
function handleClose() {
|
||||
searchResults.value = [];
|
||||
emit('close');
|
||||
}
|
||||
|
||||
// Activate when the mouse moves to a certain line
|
||||
function handleMouseenter(e: MouseEvent) {
|
||||
const index = (e.target as HTMLElement)?.dataset.index;
|
||||
activeIndex.value = Number(index);
|
||||
}
|
||||
|
||||
function removeItem(index: number) {
|
||||
if (props.keyword) {
|
||||
searchResults.value.splice(index, 1);
|
||||
} else {
|
||||
searchHistory.value.splice(index, 1);
|
||||
}
|
||||
activeIndex.value = Math.max(activeIndex.value - 1, 0);
|
||||
scrollIntoView();
|
||||
}
|
||||
|
||||
// 存储所有需要转义的特殊字符
|
||||
const code = new Set([
|
||||
'$',
|
||||
'(',
|
||||
')',
|
||||
'*',
|
||||
'+',
|
||||
'.',
|
||||
'?',
|
||||
'[',
|
||||
'\\',
|
||||
']',
|
||||
'^',
|
||||
'{',
|
||||
'|',
|
||||
'}',
|
||||
]);
|
||||
|
||||
// 转换函数,用于转义特殊字符
|
||||
function transform(c: string) {
|
||||
// 如果字符在特殊字符列表中,返回转义后的字符
|
||||
// 如果不在,返回字符本身
|
||||
return code.has(c) ? `\\${c}` : c;
|
||||
}
|
||||
|
||||
// 创建搜索正则表达式
|
||||
function createSearchReg(key: string) {
|
||||
// 将输入的字符串拆分为单个字符
|
||||
// 对每个字符进行转义
|
||||
// 然后用'.*'连接所有字符,创建正则表达式
|
||||
const keys = [...key].map((item) => transform(item)).join('.*');
|
||||
// 返回创建的正则表达式
|
||||
return new RegExp(`.*${keys}.*`);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.keyword,
|
||||
(val) => {
|
||||
if (val) {
|
||||
handleSearch(val);
|
||||
} else {
|
||||
searchResults.value = [...searchHistory.value];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
searchItems.value = mapTree(props.menus, (item) => {
|
||||
return {
|
||||
...item,
|
||||
name: $t(item?.name),
|
||||
};
|
||||
});
|
||||
if (searchHistory.value.length > 0) {
|
||||
searchResults.value = searchHistory.value;
|
||||
}
|
||||
// enter search
|
||||
onKeyStroke('Enter', handleEnter);
|
||||
// Monitor keyboard arrow keys
|
||||
onKeyStroke('ArrowUp', handleUp);
|
||||
onKeyStroke('ArrowDown', handleDown);
|
||||
// esc close
|
||||
onKeyStroke('Escape', handleClose);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VbenScrollbar>
|
||||
<div class="!flex h-full justify-center px-2 sm:max-h-[450px]">
|
||||
<!-- 无搜索结果 -->
|
||||
<div
|
||||
v-if="keyword && searchResults.length === 0"
|
||||
class="text-muted-foreground text-center"
|
||||
>
|
||||
<SearchX class="mx-auto mt-4 size-12" />
|
||||
<p class="mb-10 mt-6 text-xs">
|
||||
{{ $t('ui.widgets.search.noResults') }}
|
||||
<span class="text-foreground text-sm font-medium">
|
||||
"{{ keyword }}"
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- 历史搜索记录 & 没有搜索结果 -->
|
||||
<div
|
||||
v-if="!keyword && searchResults.length === 0"
|
||||
class="text-muted-foreground text-center"
|
||||
>
|
||||
<p class="my-10 text-xs">
|
||||
{{ $t('ui.widgets.search.noRecent') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul v-show="searchResults.length > 0" class="w-full">
|
||||
<li
|
||||
v-if="searchHistory.length > 0 && !keyword"
|
||||
class="text-muted-foreground mb-2 text-xs"
|
||||
>
|
||||
{{ $t('ui.widgets.search.recent') }}
|
||||
</li>
|
||||
<li
|
||||
v-for="(item, index) in uniqueByField(searchResults, 'path')"
|
||||
:key="item.path"
|
||||
:class="
|
||||
activeIndex === index
|
||||
? 'active bg-primary text-primary-foreground'
|
||||
: ''
|
||||
"
|
||||
:data-index="index"
|
||||
:data-search-item="index"
|
||||
class="bg-accent flex-center group mb-3 w-full cursor-pointer rounded-lg px-4 py-4"
|
||||
@click="handleEnter"
|
||||
@mouseenter="handleMouseenter"
|
||||
>
|
||||
<VbenIcon
|
||||
:icon="item.icon"
|
||||
class="mr-2 size-5 flex-shrink-0"
|
||||
fallback
|
||||
/>
|
||||
|
||||
<span class="flex-1">{{ item.name }}</span>
|
||||
<div
|
||||
class="flex-center dark:hover:bg-accent hover:text-primary-foreground rounded-full p-1 hover:scale-110"
|
||||
@click.stop="removeItem(index)"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</VbenScrollbar>
|
||||
</template>
|
||||
11
packages/effects/layouts/src/widgets/index.ts
Normal file
11
packages/effects/layouts/src/widgets/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { default as Breadcrumb } from './breadcrumb.vue';
|
||||
export * from './check-updates';
|
||||
export { default as AuthenticationColorToggle } from './color-toggle.vue';
|
||||
export * from './global-search';
|
||||
export { default as LanguageToggle } from './language-toggle.vue';
|
||||
export { default as AuthenticationLayoutToggle } from './layout-toggle.vue';
|
||||
export * from './lock-screen';
|
||||
export * from './notification';
|
||||
export * from './preferences';
|
||||
export * from './theme-toggle';
|
||||
export * from './user-dropdown';
|
||||
38
packages/effects/layouts/src/widgets/language-toggle.vue
Normal file
38
packages/effects/layouts/src/widgets/language-toggle.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { SupportedLanguagesType } from '@vben/locales';
|
||||
|
||||
import { SUPPORT_LANGUAGES } from '@vben/constants';
|
||||
import { Languages } from '@vben/icons';
|
||||
import { loadLocaleMessages } from '@vben/locales';
|
||||
import { preferences, updatePreferences } from '@vben/preferences';
|
||||
|
||||
import { VbenDropdownRadioMenu, VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'LanguageToggle',
|
||||
});
|
||||
|
||||
async function handleUpdate(value: string) {
|
||||
const locale = value as SupportedLanguagesType;
|
||||
updatePreferences({
|
||||
app: {
|
||||
locale,
|
||||
},
|
||||
});
|
||||
await loadLocaleMessages(locale);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VbenDropdownRadioMenu
|
||||
:menus="SUPPORT_LANGUAGES"
|
||||
:model-value="preferences.app.locale"
|
||||
@update:model-value="handleUpdate"
|
||||
>
|
||||
<VbenIconButton>
|
||||
<Languages class="text-foreground size-4" />
|
||||
</VbenIconButton>
|
||||
</VbenDropdownRadioMenu>
|
||||
</div>
|
||||
</template>
|
||||
63
packages/effects/layouts/src/widgets/layout-toggle.vue
Normal file
63
packages/effects/layouts/src/widgets/layout-toggle.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import type { AuthPageLayoutType } from '@vben/types';
|
||||
|
||||
import type { VbenDropdownMenuItem } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { InspectionPanel, PanelLeft, PanelRight } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import {
|
||||
preferences,
|
||||
updatePreferences,
|
||||
usePreferences,
|
||||
} from '@vben/preferences';
|
||||
|
||||
import { VbenDropdownRadioMenu, VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'AuthenticationLayoutToggle',
|
||||
});
|
||||
|
||||
const menus = computed((): VbenDropdownMenuItem[] => [
|
||||
{
|
||||
icon: PanelLeft,
|
||||
label: $t('authentication.layout.alignLeft'),
|
||||
value: 'panel-left',
|
||||
},
|
||||
{
|
||||
icon: InspectionPanel,
|
||||
label: $t('authentication.layout.center'),
|
||||
value: 'panel-center',
|
||||
},
|
||||
{
|
||||
icon: PanelRight,
|
||||
label: $t('authentication.layout.alignRight'),
|
||||
value: 'panel-right',
|
||||
},
|
||||
]);
|
||||
|
||||
const { authPanelCenter, authPanelLeft, authPanelRight } = usePreferences();
|
||||
|
||||
function handleUpdate(value: string) {
|
||||
updatePreferences({
|
||||
app: {
|
||||
authPageLayout: value as AuthPageLayoutType,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VbenDropdownRadioMenu
|
||||
:menus="menus"
|
||||
:model-value="preferences.app.authPageLayout"
|
||||
@update:model-value="handleUpdate"
|
||||
>
|
||||
<VbenIconButton>
|
||||
<PanelRight v-if="authPanelRight" class="size-4" />
|
||||
<PanelLeft v-if="authPanelLeft" class="size-4" />
|
||||
<InspectionPanel v-if="authPanelCenter" class="size-4" />
|
||||
</VbenIconButton>
|
||||
</VbenDropdownRadioMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as LockScreenModal } from './lock-screen-modal.vue';
|
||||
export { default as LockScreen } from './lock-screen.vue';
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import type { Recordable } from '@vben/types';
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { useVbenForm, z } from '@vben-core/form-ui';
|
||||
import { useVbenModal } from '@vben-core/popup-ui';
|
||||
import { VbenAvatar, VbenButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props {
|
||||
avatar?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'LockScreenModal',
|
||||
});
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
avatar: '',
|
||||
text: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [Recordable<any>];
|
||||
}>();
|
||||
|
||||
const [Form, { resetForm, validate, getValues }] = useVbenForm(
|
||||
reactive({
|
||||
commonConfig: {
|
||||
hideLabel: true,
|
||||
hideRequiredMark: true,
|
||||
},
|
||||
schema: computed(() => [
|
||||
{
|
||||
component: 'VbenInputPassword' as const,
|
||||
componentProps: {
|
||||
placeholder: $t('ui.widgets.lockScreen.placeholder'),
|
||||
},
|
||||
fieldName: 'lockScreenPassword',
|
||||
formFieldProps: { validateOnBlur: false },
|
||||
label: $t('authentication.password'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, { message: $t('ui.widgets.lockScreen.placeholder') }),
|
||||
},
|
||||
]),
|
||||
showDefaultActions: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const [Modal] = useVbenModal({
|
||||
onConfirm() {
|
||||
handleSubmit();
|
||||
},
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
resetForm();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
const { valid } = await validate();
|
||||
const values = await getValues();
|
||||
if (valid) {
|
||||
emit('submit', values?.lockScreenPassword);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:footer="false"
|
||||
:fullscreen-button="false"
|
||||
:title="$t('ui.widgets.lockScreen.title')"
|
||||
>
|
||||
<div
|
||||
class="mb-10 flex w-full flex-col items-center px-10"
|
||||
@keydown.enter.prevent="handleSubmit"
|
||||
>
|
||||
<div class="w-full">
|
||||
<div class="ml-2 flex w-full flex-col items-center">
|
||||
<VbenAvatar
|
||||
:src="avatar"
|
||||
class="size-20"
|
||||
dot-class="bottom-0 right-1 border-2 size-4 bg-green-500"
|
||||
/>
|
||||
<div class="text-foreground my-6 flex items-center font-medium">
|
||||
{{ text }}
|
||||
</div>
|
||||
</div>
|
||||
<Form />
|
||||
<VbenButton class="mt-1 w-full" @click="handleSubmit">
|
||||
{{ $t('ui.widgets.lockScreen.screenButton') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
156
packages/effects/layouts/src/widgets/lock-screen/lock-screen.vue
Normal file
156
packages/effects/layouts/src/widgets/lock-screen/lock-screen.vue
Normal file
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { LockKeyhole } from '@vben/icons';
|
||||
import { $t, useI18n } from '@vben/locales';
|
||||
import { storeToRefs, useLockStore } from '@vben/stores';
|
||||
|
||||
import { useScrollLock } from '@vben-core/composables';
|
||||
import { useVbenForm, z } from '@vben-core/form-ui';
|
||||
import { VbenAvatar, VbenButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useDateFormat, useNow } from '@vueuse/core';
|
||||
|
||||
interface Props {
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'LockScreen',
|
||||
});
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
avatar: '',
|
||||
});
|
||||
|
||||
defineEmits<{ toLogin: [] }>();
|
||||
|
||||
const { locale } = useI18n();
|
||||
const lockStore = useLockStore();
|
||||
|
||||
const now = useNow();
|
||||
const meridiem = useDateFormat(now, 'A');
|
||||
const hour = useDateFormat(now, 'HH');
|
||||
const minute = useDateFormat(now, 'mm');
|
||||
const date = useDateFormat(now, 'YYYY-MM-DD dddd', { locales: locale.value });
|
||||
|
||||
const showUnlockForm = ref(false);
|
||||
const { lockScreenPassword } = storeToRefs(lockStore);
|
||||
|
||||
const [Form, { form, validate }] = useVbenForm(
|
||||
reactive({
|
||||
commonConfig: {
|
||||
hideLabel: true,
|
||||
hideRequiredMark: true,
|
||||
},
|
||||
schema: computed(() => [
|
||||
{
|
||||
component: 'VbenInputPassword' as const,
|
||||
componentProps: {
|
||||
placeholder: $t('ui.widgets.lockScreen.placeholder'),
|
||||
},
|
||||
fieldName: 'password',
|
||||
label: $t('authentication.password'),
|
||||
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
|
||||
},
|
||||
]),
|
||||
showDefaultActions: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const validPass = computed(
|
||||
() => lockScreenPassword?.value === form?.values?.password,
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
const { valid } = await validate();
|
||||
if (valid) {
|
||||
if (validPass.value) {
|
||||
lockStore.unlockScreen();
|
||||
} else {
|
||||
form.setFieldError('password', $t('authentication.passwordErrorTip'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleUnlockForm() {
|
||||
showUnlockForm.value = !showUnlockForm.value;
|
||||
}
|
||||
|
||||
useScrollLock();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-background fixed z-[2000] size-full">
|
||||
<transition name="slide-left">
|
||||
<div v-show="!showUnlockForm" class="size-full">
|
||||
<div
|
||||
class="flex-col-center text-foreground/80 hover:text-foreground group my-4 cursor-pointer text-xl font-semibold"
|
||||
@click="toggleUnlockForm"
|
||||
>
|
||||
<LockKeyhole
|
||||
class="size-5 transition-all duration-300 group-hover:scale-125"
|
||||
/>
|
||||
<span>{{ $t('ui.widgets.lockScreen.unlock') }}</span>
|
||||
</div>
|
||||
<div class="flex h-full justify-center px-[10%]">
|
||||
<div
|
||||
class="bg-accent flex-center relative mb-14 mr-20 h-4/5 w-2/5 flex-auto rounded-3xl text-center text-[260px]"
|
||||
>
|
||||
<span class="absolute left-4 top-4 text-xl font-semibold">
|
||||
{{ meridiem }}
|
||||
</span>
|
||||
{{ hour }}
|
||||
</div>
|
||||
<div
|
||||
class="bg-accent flex-center mb-14 h-4/5 w-2/5 flex-auto rounded-3xl text-center text-[260px]"
|
||||
>
|
||||
{{ minute }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<transition name="slide-right">
|
||||
<div
|
||||
v-if="showUnlockForm"
|
||||
class="flex-center size-full"
|
||||
@keydown.enter.prevent="handleSubmit"
|
||||
>
|
||||
<div class="flex-col-center mb-10 w-[300px]">
|
||||
<VbenAvatar :src="avatar" class="enter-x mb-6 size-20" />
|
||||
|
||||
<div class="enter-x mb-2 w-full items-center">
|
||||
<Form />
|
||||
</div>
|
||||
<VbenButton class="enter-x w-full" @click="handleSubmit">
|
||||
{{ $t('ui.widgets.lockScreen.entry') }}
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
class="enter-x my-2 w-full"
|
||||
variant="ghost"
|
||||
@click="$emit('toLogin')"
|
||||
>
|
||||
{{ $t('ui.widgets.lockScreen.backToLogin') }}
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
class="enter-x mr-2 w-full"
|
||||
variant="ghost"
|
||||
@click="toggleUnlockForm"
|
||||
>
|
||||
{{ $t('common.back') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div
|
||||
class="enter-y absolute bottom-5 w-full text-center xl:text-xl 2xl:text-3xl"
|
||||
>
|
||||
<div v-if="showUnlockForm" class="enter-x mb-2 text-3xl">
|
||||
{{ hour }}:{{ minute }} <span class="text-lg">{{ meridiem }}</span>
|
||||
</div>
|
||||
<div class="text-3xl">{{ date }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Notification } from './notification.vue';
|
||||
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,187 @@
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationItem } from './types';
|
||||
|
||||
import { Bell, MailCheck } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
VbenButton,
|
||||
VbenIconButton,
|
||||
VbenPopover,
|
||||
VbenScrollbar,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 显示圆点
|
||||
*/
|
||||
dot?: boolean;
|
||||
/**
|
||||
* 消息列表
|
||||
*/
|
||||
notifications?: NotificationItem[];
|
||||
}
|
||||
|
||||
defineOptions({ name: 'NotificationPopup' });
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
dot: false,
|
||||
notifications: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
clear: [];
|
||||
makeAll: [];
|
||||
read: [NotificationItem];
|
||||
viewAll: [];
|
||||
}>();
|
||||
|
||||
const [open, toggle] = useToggle();
|
||||
|
||||
function close() {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function handleViewAll() {
|
||||
emit('viewAll');
|
||||
close();
|
||||
}
|
||||
|
||||
function handleMakeAll() {
|
||||
emit('makeAll');
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
emit('clear');
|
||||
}
|
||||
|
||||
function handleClick(item: NotificationItem) {
|
||||
emit('read', item);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<VbenPopover
|
||||
v-model:open="open"
|
||||
content-class="relative right-2 w-[360px] p-0"
|
||||
>
|
||||
<template #trigger>
|
||||
<div class="flex-center mr-2 h-full" @click.stop="toggle()">
|
||||
<VbenIconButton class="bell-button text-foreground relative">
|
||||
<span
|
||||
v-if="dot"
|
||||
class="bg-primary absolute right-0.5 top-0.5 h-2 w-2 rounded"
|
||||
></span>
|
||||
<Bell class="size-4" />
|
||||
</VbenIconButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="relative">
|
||||
<div class="flex items-center justify-between p-4 py-3">
|
||||
<div class="text-foreground">{{ $t('ui.widgets.notifications') }}</div>
|
||||
<VbenIconButton
|
||||
:disabled="notifications.length <= 0"
|
||||
:tooltip="$t('ui.widgets.markAllAsRead')"
|
||||
@click="handleMakeAll"
|
||||
>
|
||||
<MailCheck class="size-4" />
|
||||
</VbenIconButton>
|
||||
</div>
|
||||
<VbenScrollbar v-if="notifications.length > 0">
|
||||
<ul class="!flex max-h-[360px] w-full flex-col">
|
||||
<template v-for="item in notifications" :key="item.title">
|
||||
<li
|
||||
class="hover:bg-accent border-border relative flex w-full cursor-pointer items-start gap-5 border-t px-3 py-3"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<span
|
||||
v-if="!item.isRead"
|
||||
class="bg-primary absolute right-2 top-2 h-2 w-2 rounded"
|
||||
></span>
|
||||
|
||||
<span
|
||||
class="relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full"
|
||||
>
|
||||
<img
|
||||
:src="item.avatar"
|
||||
class="aspect-square h-full w-full object-cover"
|
||||
role="img"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex flex-col gap-1 leading-none">
|
||||
<p class="font-semibold">{{ item.title }}</p>
|
||||
<p class="text-muted-foreground my-1 line-clamp-2 text-xs">
|
||||
{{ item.message }}
|
||||
</p>
|
||||
<p class="text-muted-foreground line-clamp-2 text-xs">
|
||||
{{ item.date }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</VbenScrollbar>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex-center text-muted-foreground min-h-[150px] w-full">
|
||||
{{ $t('common.noData') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="border-border flex items-center justify-between border-t px-4 py-3"
|
||||
>
|
||||
<VbenButton
|
||||
:disabled="notifications.length <= 0"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="handleClear"
|
||||
>
|
||||
{{ $t('ui.widgets.clearNotifications') }}
|
||||
</VbenButton>
|
||||
<VbenButton size="sm" @click="handleViewAll">
|
||||
{{ $t('ui.widgets.viewAll') }}
|
||||
</VbenButton>
|
||||
</div>
|
||||
</div>
|
||||
</VbenPopover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.bell-button) {
|
||||
&:hover {
|
||||
svg {
|
||||
animation: bell-ring 1s both;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bell-ring {
|
||||
0%,
|
||||
100% {
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
15% {
|
||||
transform: rotateZ(10deg);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: rotateZ(-10deg);
|
||||
}
|
||||
|
||||
45% {
|
||||
transform: rotateZ(5deg);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: rotateZ(-5deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: rotateZ(2deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
interface NotificationItem {
|
||||
avatar: string;
|
||||
date: string;
|
||||
isRead?: boolean;
|
||||
message: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export type { NotificationItem };
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceBlock',
|
||||
});
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col py-4">
|
||||
<h3 class="mb-3 font-semibold leading-none tracking-tight">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from '@vben/types';
|
||||
|
||||
import { useSlots } from 'vue';
|
||||
|
||||
import { CircleHelp } from '@vben/icons';
|
||||
|
||||
import { VbenCheckButtonGroup, VbenTooltip } from '@vben-core/shadcn-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceCheckboxItem',
|
||||
});
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
items: SelectOption[];
|
||||
multiple?: boolean;
|
||||
onBtnClick?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
placeholder: '',
|
||||
items: () => [],
|
||||
onBtnClick: () => {},
|
||||
multiple: false,
|
||||
},
|
||||
);
|
||||
|
||||
const inputValue = defineModel<string[]>();
|
||||
|
||||
const slots = useSlots();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'hover:bg-accent': !slots.tip,
|
||||
'pointer-events-none opacity-50': disabled,
|
||||
}"
|
||||
class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1"
|
||||
>
|
||||
<span class="flex items-center text-sm">
|
||||
<slot></slot>
|
||||
|
||||
<VbenTooltip v-if="slots.tip" side="bottom">
|
||||
<template #trigger>
|
||||
<CircleHelp class="ml-1 size-3 cursor-help" />
|
||||
</template>
|
||||
<slot name="tip"></slot>
|
||||
</VbenTooltip>
|
||||
</span>
|
||||
<VbenCheckButtonGroup
|
||||
v-model="inputValue"
|
||||
class="h-8 w-[165px]"
|
||||
:options="items"
|
||||
:disabled="disabled"
|
||||
:multiple="multiple"
|
||||
@btn-click="onBtnClick"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceAnimation',
|
||||
});
|
||||
|
||||
const transitionProgress = defineModel<boolean>('transitionProgress', {
|
||||
// 默认值
|
||||
default: false,
|
||||
});
|
||||
const transitionName = defineModel<string>('transitionName');
|
||||
const transitionEnable = defineModel<boolean>('transitionEnable');
|
||||
const transitionLoading = defineModel<boolean>('transitionLoading');
|
||||
|
||||
const transitionPreset = ['fade', 'fade-slide', 'fade-up', 'fade-down'];
|
||||
|
||||
function handleClick(value: string) {
|
||||
transitionName.value = value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchItem v-model="transitionProgress">
|
||||
{{ $t('preferences.animation.progress') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="transitionLoading">
|
||||
{{ $t('preferences.animation.loading') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="transitionEnable">
|
||||
{{ $t('preferences.animation.transition') }}
|
||||
</SwitchItem>
|
||||
<div
|
||||
v-if="transitionEnable"
|
||||
class="mb-2 mt-3 flex justify-between gap-3 px-2"
|
||||
>
|
||||
<div
|
||||
v-for="item in transitionPreset"
|
||||
:key="item"
|
||||
:class="{
|
||||
'outline-box-active': transitionName === item,
|
||||
}"
|
||||
class="outline-box p-2"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<div :class="`${item}-slow`" class="bg-accent h-10 w-12 rounded-md"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { SUPPORT_LANGUAGES } from '@vben/constants';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import SelectItem from '../select-item.vue';
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceGeneralConfig',
|
||||
});
|
||||
|
||||
const appLocale = defineModel<string>('appLocale');
|
||||
const appDynamicTitle = defineModel<boolean>('appDynamicTitle');
|
||||
const appWatermark = defineModel<boolean>('appWatermark');
|
||||
const appEnableCheckUpdates = defineModel<boolean>('appEnableCheckUpdates');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItem v-model="appLocale" :items="SUPPORT_LANGUAGES">
|
||||
{{ $t('preferences.language') }}
|
||||
</SelectItem>
|
||||
<SwitchItem v-model="appDynamicTitle">
|
||||
{{ $t('preferences.dynamicTitle') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="appWatermark">
|
||||
{{ $t('preferences.watermark') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="appEnableCheckUpdates">
|
||||
{{ $t('preferences.checkUpdates') }}
|
||||
</SwitchItem>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
export { default as Block } from './block.vue';
|
||||
export { default as Animation } from './general/animation.vue';
|
||||
export { default as General } from './general/general.vue';
|
||||
export { default as Breadcrumb } from './layout/breadcrumb.vue';
|
||||
export { default as Content } from './layout/content.vue';
|
||||
export { default as Copyright } from './layout/copyright.vue';
|
||||
export { default as Footer } from './layout/footer.vue';
|
||||
export { default as Header } from './layout/header.vue';
|
||||
export { default as Layout } from './layout/layout.vue';
|
||||
export { default as Navigation } from './layout/navigation.vue';
|
||||
export { default as Sidebar } from './layout/sidebar.vue';
|
||||
export { default as Tabbar } from './layout/tabbar.vue';
|
||||
export { default as Widget } from './layout/widget.vue';
|
||||
export { default as GlobalShortcutKeys } from './shortcut-keys/global.vue';
|
||||
export { default as SwitchItem } from './switch-item.vue';
|
||||
export { default as BuiltinTheme } from './theme/builtin.vue';
|
||||
export { default as ColorMode } from './theme/color-mode.vue';
|
||||
export { default as Radius } from './theme/radius.vue';
|
||||
export { default as Theme } from './theme/theme.vue';
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from '@vben/types';
|
||||
|
||||
import { useSlots } from 'vue';
|
||||
|
||||
import { CircleHelp } from '@vben/icons';
|
||||
|
||||
import { Input, VbenTooltip } from '@vben-core/shadcn-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceSelectItem',
|
||||
});
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
items?: SelectOption[];
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
placeholder: '',
|
||||
items: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
const inputValue = defineModel<string>();
|
||||
|
||||
const slots = useSlots();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'hover:bg-accent': !slots.tip,
|
||||
'pointer-events-none opacity-50': disabled,
|
||||
}"
|
||||
class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1"
|
||||
>
|
||||
<span class="flex items-center text-sm">
|
||||
<slot></slot>
|
||||
|
||||
<VbenTooltip v-if="slots.tip" side="bottom">
|
||||
<template #trigger>
|
||||
<CircleHelp class="ml-1 size-3 cursor-help" />
|
||||
</template>
|
||||
<slot name="tip"></slot>
|
||||
</VbenTooltip>
|
||||
</span>
|
||||
<Input v-model="inputValue" class="h-8 w-[165px]" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from '@vben/types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
import ToggleItem from '../toggle-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceBreadcrumbConfig',
|
||||
});
|
||||
|
||||
const props = defineProps<{ disabled?: boolean }>();
|
||||
|
||||
const breadcrumbEnable = defineModel<boolean>('breadcrumbEnable');
|
||||
const breadcrumbShowIcon = defineModel<boolean>('breadcrumbShowIcon');
|
||||
const breadcrumbStyleType = defineModel<string>('breadcrumbStyleType');
|
||||
const breadcrumbShowHome = defineModel<boolean>('breadcrumbShowHome');
|
||||
const breadcrumbHideOnlyOne = defineModel<boolean>('breadcrumbHideOnlyOne');
|
||||
|
||||
const typeItems: SelectOption[] = [
|
||||
{ label: $t('preferences.normal'), value: 'normal' },
|
||||
{ label: $t('preferences.breadcrumb.background'), value: 'background' },
|
||||
];
|
||||
|
||||
const disableItem = computed(() => {
|
||||
return !breadcrumbEnable.value || props.disabled;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchItem v-model="breadcrumbEnable" :disabled="disabled">
|
||||
{{ $t('preferences.breadcrumb.enable') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="breadcrumbHideOnlyOne" :disabled="disableItem">
|
||||
{{ $t('preferences.breadcrumb.hideOnlyOne') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="breadcrumbShowIcon" :disabled="disableItem">
|
||||
{{ $t('preferences.breadcrumb.icon') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem
|
||||
v-model="breadcrumbShowHome"
|
||||
:disabled="disableItem || !breadcrumbShowIcon"
|
||||
>
|
||||
{{ $t('preferences.breadcrumb.home') }}
|
||||
</SwitchItem>
|
||||
<ToggleItem
|
||||
v-model="breadcrumbStyleType"
|
||||
:disabled="disableItem"
|
||||
:items="typeItems"
|
||||
>
|
||||
{{ $t('preferences.breadcrumb.style') }}
|
||||
</ToggleItem>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ContentCompact, ContentWide } from '../../icons';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceLayoutContent',
|
||||
});
|
||||
|
||||
const modelValue = defineModel<string>({ default: 'wide' });
|
||||
|
||||
const components: Record<string, Component> = {
|
||||
compact: ContentCompact,
|
||||
wide: ContentWide,
|
||||
};
|
||||
|
||||
const PRESET = computed(() => [
|
||||
{
|
||||
name: $t('preferences.wide'),
|
||||
type: 'wide',
|
||||
},
|
||||
{
|
||||
name: $t('preferences.compact'),
|
||||
type: 'compact',
|
||||
},
|
||||
]);
|
||||
|
||||
function activeClass(theme: string): string[] {
|
||||
return theme === modelValue.value ? ['outline-box-active'] : [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full gap-5">
|
||||
<template v-for="theme in PRESET" :key="theme.name">
|
||||
<div
|
||||
class="flex w-[100px] cursor-pointer flex-col"
|
||||
@click="modelValue = theme.type"
|
||||
>
|
||||
<div :class="activeClass(theme.type)" class="outline-box flex-center">
|
||||
<component :is="components[theme.type]" />
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-2 text-center text-xs">
|
||||
{{ theme.name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import InputItem from '../input-item.vue';
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
|
||||
const props = defineProps<{ disabled: boolean }>();
|
||||
|
||||
const copyrightEnable = defineModel<boolean>('copyrightEnable');
|
||||
const copyrightDate = defineModel<string>('copyrightDate');
|
||||
const copyrightIcp = defineModel<string>('copyrightIcp');
|
||||
const copyrightIcpLink = defineModel<string>('copyrightIcpLink');
|
||||
const copyrightCompanyName = defineModel<string>('copyrightCompanyName');
|
||||
const copyrightCompanySiteLink = defineModel<string>(
|
||||
'copyrightCompanySiteLink',
|
||||
);
|
||||
|
||||
const itemDisabled = computed(() => props.disabled || !copyrightEnable.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchItem v-model="copyrightEnable" :disabled="disabled">
|
||||
{{ $t('preferences.copyright.enable') }}
|
||||
</SwitchItem>
|
||||
|
||||
<InputItem v-model="copyrightCompanyName" :disabled="itemDisabled">
|
||||
{{ $t('preferences.copyright.companyName') }}
|
||||
</InputItem>
|
||||
<InputItem v-model="copyrightCompanySiteLink" :disabled="itemDisabled">
|
||||
{{ $t('preferences.copyright.companySiteLink') }}
|
||||
</InputItem>
|
||||
<InputItem v-model="copyrightDate" :disabled="itemDisabled">
|
||||
{{ $t('preferences.copyright.date') }}
|
||||
</InputItem>
|
||||
|
||||
<InputItem v-model="copyrightIcp" :disabled="itemDisabled">
|
||||
{{ $t('preferences.copyright.icp') }}
|
||||
</InputItem>
|
||||
<InputItem v-model="copyrightIcpLink" :disabled="itemDisabled">
|
||||
{{ $t('preferences.copyright.icpLink') }}
|
||||
</InputItem>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
|
||||
const footerEnable = defineModel<boolean>('footerEnable');
|
||||
const footerFixed = defineModel<boolean>('footerFixed');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchItem v-model="footerEnable">
|
||||
{{ $t('preferences.footer.visible') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="footerFixed" :disabled="!footerEnable">
|
||||
{{ $t('preferences.footer.fixed') }}
|
||||
</SwitchItem>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
LayoutHeaderMenuAlignType,
|
||||
LayoutHeaderModeType,
|
||||
SelectOption,
|
||||
} from '@vben/types';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import SelectItem from '../select-item.vue';
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
import ToggleItem from '../toggle-item.vue';
|
||||
|
||||
defineProps<{ disabled: boolean }>();
|
||||
|
||||
const headerEnable = defineModel<boolean>('headerEnable');
|
||||
const headerMode = defineModel<LayoutHeaderModeType>('headerMode');
|
||||
const headerMenuAlign =
|
||||
defineModel<LayoutHeaderMenuAlignType>('headerMenuAlign');
|
||||
|
||||
const localeItems: SelectOption[] = [
|
||||
{
|
||||
label: $t('preferences.header.modeStatic'),
|
||||
value: 'static',
|
||||
},
|
||||
{
|
||||
label: $t('preferences.header.modeFixed'),
|
||||
value: 'fixed',
|
||||
},
|
||||
{
|
||||
label: $t('preferences.header.modeAuto'),
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
label: $t('preferences.header.modeAutoScroll'),
|
||||
value: 'auto-scroll',
|
||||
},
|
||||
];
|
||||
|
||||
const headerMenuAlignItems: SelectOption[] = [
|
||||
{
|
||||
label: $t('preferences.header.menuAlignStart'),
|
||||
value: 'start',
|
||||
},
|
||||
{
|
||||
label: $t('preferences.header.menuAlignCenter'),
|
||||
value: 'center',
|
||||
},
|
||||
{
|
||||
label: $t('preferences.header.menuAlignEnd'),
|
||||
value: 'end',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchItem v-model="headerEnable" :disabled="disabled">
|
||||
{{ $t('preferences.header.visible') }}
|
||||
</SwitchItem>
|
||||
<SelectItem
|
||||
v-model="headerMode"
|
||||
:disabled="!headerEnable"
|
||||
:items="localeItems"
|
||||
>
|
||||
{{ $t('preferences.mode') }}
|
||||
</SelectItem>
|
||||
<ToggleItem
|
||||
v-model="headerMenuAlign"
|
||||
:disabled="!headerEnable"
|
||||
:items="headerMenuAlignItems"
|
||||
>
|
||||
{{ $t('preferences.header.menuAlign') }}
|
||||
</ToggleItem>
|
||||
</template>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { LayoutType } from '@vben/types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { CircleHelp } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { VbenTooltip } from '@vben-core/shadcn-ui';
|
||||
|
||||
import {
|
||||
FullContent,
|
||||
HeaderMixedNav,
|
||||
HeaderNav,
|
||||
HeaderSidebarNav,
|
||||
MixedNav,
|
||||
SidebarMixedNav,
|
||||
SidebarNav,
|
||||
} from '../../icons';
|
||||
|
||||
interface PresetItem {
|
||||
name: string;
|
||||
tip: string;
|
||||
type: LayoutType;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceLayout',
|
||||
});
|
||||
|
||||
const modelValue = defineModel<LayoutType>({ default: 'sidebar-nav' });
|
||||
|
||||
const components: Record<LayoutType, Component> = {
|
||||
'full-content': FullContent,
|
||||
'header-nav': HeaderNav,
|
||||
'mixed-nav': MixedNav,
|
||||
'sidebar-mixed-nav': SidebarMixedNav,
|
||||
'sidebar-nav': SidebarNav,
|
||||
'header-mixed-nav': HeaderMixedNav,
|
||||
'header-sidebar-nav': HeaderSidebarNav,
|
||||
};
|
||||
|
||||
const PRESET = computed((): PresetItem[] => [
|
||||
{
|
||||
name: $t('preferences.vertical'),
|
||||
tip: $t('preferences.verticalTip'),
|
||||
type: 'sidebar-nav',
|
||||
},
|
||||
{
|
||||
name: $t('preferences.twoColumn'),
|
||||
tip: $t('preferences.twoColumnTip'),
|
||||
type: 'sidebar-mixed-nav',
|
||||
},
|
||||
{
|
||||
name: $t('preferences.horizontal'),
|
||||
tip: $t('preferences.horizontalTip'),
|
||||
type: 'header-nav',
|
||||
},
|
||||
{
|
||||
name: $t('preferences.headerSidebarNav'),
|
||||
tip: $t('preferences.headerSidebarNavTip'),
|
||||
type: 'header-sidebar-nav',
|
||||
},
|
||||
{
|
||||
name: $t('preferences.mixedMenu'),
|
||||
tip: $t('preferences.mixedMenuTip'),
|
||||
type: 'mixed-nav',
|
||||
},
|
||||
{
|
||||
name: $t('preferences.headerTwoColumn'),
|
||||
tip: $t('preferences.headerTwoColumnTip'),
|
||||
type: 'header-mixed-nav',
|
||||
},
|
||||
{
|
||||
name: $t('preferences.fullContent'),
|
||||
tip: $t('preferences.fullContentTip'),
|
||||
type: 'full-content',
|
||||
},
|
||||
]);
|
||||
|
||||
function activeClass(theme: string): string[] {
|
||||
return theme === modelValue.value ? ['outline-box-active'] : [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full flex-wrap gap-5">
|
||||
<template v-for="theme in PRESET" :key="theme.name">
|
||||
<div
|
||||
class="flex w-[100px] cursor-pointer flex-col"
|
||||
@click="modelValue = theme.type"
|
||||
>
|
||||
<div :class="activeClass(theme.type)" class="outline-box flex-center">
|
||||
<component :is="components[theme.type]" />
|
||||
</div>
|
||||
<div
|
||||
class="text-muted-foreground flex-center hover:text-foreground mt-2 text-center text-xs"
|
||||
>
|
||||
{{ theme.name }}
|
||||
<VbenTooltip v-if="theme.tip" side="bottom">
|
||||
<template #trigger>
|
||||
<CircleHelp class="ml-1 size-3 cursor-help" />
|
||||
</template>
|
||||
{{ theme.tip }}
|
||||
</VbenTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from '@vben/types';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
import ToggleItem from '../toggle-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceNavigationConfig',
|
||||
});
|
||||
|
||||
defineProps<{ disabled?: boolean; disabledNavigationSplit?: boolean }>();
|
||||
|
||||
const navigationStyleType = defineModel<string>('navigationStyleType');
|
||||
const navigationSplit = defineModel<boolean>('navigationSplit');
|
||||
const navigationAccordion = defineModel<boolean>('navigationAccordion');
|
||||
|
||||
const stylesItems: SelectOption[] = [
|
||||
{ label: $t('preferences.rounded'), value: 'rounded' },
|
||||
{ label: $t('preferences.plain'), value: 'plain' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToggleItem
|
||||
v-model="navigationStyleType"
|
||||
:disabled="disabled"
|
||||
:items="stylesItems"
|
||||
>
|
||||
{{ $t('preferences.navigationMenu.style') }}
|
||||
</ToggleItem>
|
||||
<SwitchItem
|
||||
v-model="navigationSplit"
|
||||
:disabled="disabledNavigationSplit || disabled"
|
||||
>
|
||||
{{ $t('preferences.navigationMenu.split') }}
|
||||
<template #tip>
|
||||
{{ $t('preferences.navigationMenu.splitTip') }}
|
||||
</template>
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="navigationAccordion" :disabled="disabled">
|
||||
{{ $t('preferences.navigationMenu.accordion') }}
|
||||
</SwitchItem>
|
||||
</template>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import type { LayoutType } from '@vben/types';
|
||||
|
||||
import { onMounted } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import CheckboxItem from '../checkbox-item.vue';
|
||||
import NumberFieldItem from '../number-field-item.vue';
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
|
||||
defineProps<{ currentLayout?: LayoutType; disabled: boolean }>();
|
||||
|
||||
const sidebarEnable = defineModel<boolean>('sidebarEnable');
|
||||
const sidebarWidth = defineModel<number>('sidebarWidth');
|
||||
const sidebarCollapsedShowTitle = defineModel<boolean>(
|
||||
'sidebarCollapsedShowTitle',
|
||||
);
|
||||
const sidebarAutoActivateChild = defineModel<boolean>(
|
||||
'sidebarAutoActivateChild',
|
||||
);
|
||||
const sidebarCollapsed = defineModel<boolean>('sidebarCollapsed');
|
||||
const sidebarExpandOnHover = defineModel<boolean>('sidebarExpandOnHover');
|
||||
|
||||
const sidebarButtons = defineModel<string[]>('sidebarButtons', { default: [] });
|
||||
const sidebarCollapsedButton = defineModel<boolean>('sidebarCollapsedButton');
|
||||
const sidebarFixedButton = defineModel<boolean>('sidebarFixedButton');
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
sidebarCollapsedButton.value &&
|
||||
!sidebarButtons.value.includes('collapsed')
|
||||
) {
|
||||
sidebarButtons.value.push('collapsed');
|
||||
}
|
||||
if (sidebarFixedButton.value && !sidebarButtons.value.includes('fixed')) {
|
||||
sidebarButtons.value.push('fixed');
|
||||
}
|
||||
});
|
||||
|
||||
const handleCheckboxChange = () => {
|
||||
sidebarCollapsedButton.value = !!sidebarButtons.value.includes('collapsed');
|
||||
sidebarFixedButton.value = !!sidebarButtons.value.includes('fixed');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchItem v-model="sidebarEnable" :disabled="disabled">
|
||||
{{ $t('preferences.sidebar.visible') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="sidebarCollapsed" :disabled="!sidebarEnable || disabled">
|
||||
{{ $t('preferences.sidebar.collapsed') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem
|
||||
v-model="sidebarExpandOnHover"
|
||||
:disabled="!sidebarEnable || disabled || !sidebarCollapsed"
|
||||
:tip="$t('preferences.sidebar.expandOnHoverTip')"
|
||||
>
|
||||
{{ $t('preferences.sidebar.expandOnHover') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem
|
||||
v-model="sidebarCollapsedShowTitle"
|
||||
:disabled="!sidebarEnable || disabled || !sidebarCollapsed"
|
||||
>
|
||||
{{ $t('preferences.sidebar.collapsedShowTitle') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem
|
||||
v-model="sidebarAutoActivateChild"
|
||||
:disabled="
|
||||
!sidebarEnable ||
|
||||
!['sidebar-mixed-nav', 'mixed-nav', 'header-mixed-nav'].includes(
|
||||
currentLayout as string,
|
||||
) ||
|
||||
disabled
|
||||
"
|
||||
:tip="$t('preferences.sidebar.autoActivateChildTip')"
|
||||
>
|
||||
{{ $t('preferences.sidebar.autoActivateChild') }}
|
||||
</SwitchItem>
|
||||
<CheckboxItem
|
||||
:items="[
|
||||
{ label: '收缩按钮', value: 'collapsed' },
|
||||
{ label: '固定按钮', value: 'fixed' },
|
||||
]"
|
||||
multiple
|
||||
v-model="sidebarButtons"
|
||||
:on-btn-click="handleCheckboxChange"
|
||||
>
|
||||
按钮配置
|
||||
</CheckboxItem>
|
||||
<NumberFieldItem
|
||||
v-model="sidebarWidth"
|
||||
:disabled="!sidebarEnable || disabled"
|
||||
:max="320"
|
||||
:min="160"
|
||||
:step="10"
|
||||
>
|
||||
{{ $t('preferences.sidebar.width') }}
|
||||
</NumberFieldItem>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from '@vben/types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import NumberFieldItem from '../number-field-item.vue';
|
||||
import SelectItem from '../select-item.vue';
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceTabsConfig',
|
||||
});
|
||||
|
||||
defineProps<{ disabled?: boolean }>();
|
||||
|
||||
const tabbarEnable = defineModel<boolean>('tabbarEnable');
|
||||
const tabbarShowIcon = defineModel<boolean>('tabbarShowIcon');
|
||||
const tabbarPersist = defineModel<boolean>('tabbarPersist');
|
||||
const tabbarDraggable = defineModel<boolean>('tabbarDraggable');
|
||||
const tabbarWheelable = defineModel<boolean>('tabbarWheelable');
|
||||
const tabbarStyleType = defineModel<string>('tabbarStyleType');
|
||||
const tabbarShowMore = defineModel<boolean>('tabbarShowMore');
|
||||
const tabbarShowMaximize = defineModel<boolean>('tabbarShowMaximize');
|
||||
const tabbarMaxCount = defineModel<number>('tabbarMaxCount');
|
||||
const tabbarMiddleClickToClose = defineModel<boolean>(
|
||||
'tabbarMiddleClickToClose',
|
||||
);
|
||||
|
||||
const styleItems = computed((): SelectOption[] => [
|
||||
{
|
||||
label: $t('preferences.tabbar.styleType.chrome'),
|
||||
value: 'chrome',
|
||||
},
|
||||
{
|
||||
label: $t('preferences.tabbar.styleType.plain'),
|
||||
value: 'plain',
|
||||
},
|
||||
{
|
||||
label: $t('preferences.tabbar.styleType.card'),
|
||||
value: 'card',
|
||||
},
|
||||
|
||||
{
|
||||
label: $t('preferences.tabbar.styleType.brisk'),
|
||||
value: 'brisk',
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchItem v-model="tabbarEnable" :disabled="disabled">
|
||||
{{ $t('preferences.tabbar.enable') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="tabbarPersist" :disabled="!tabbarEnable">
|
||||
{{ $t('preferences.tabbar.persist') }}
|
||||
</SwitchItem>
|
||||
<NumberFieldItem
|
||||
v-model="tabbarMaxCount"
|
||||
:disabled="!tabbarEnable"
|
||||
:max="30"
|
||||
:min="0"
|
||||
:step="5"
|
||||
:tip="$t('preferences.tabbar.maxCountTip')"
|
||||
>
|
||||
{{ $t('preferences.tabbar.maxCount') }}
|
||||
</NumberFieldItem>
|
||||
<SwitchItem v-model="tabbarDraggable" :disabled="!tabbarEnable">
|
||||
{{ $t('preferences.tabbar.draggable') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem
|
||||
v-model="tabbarWheelable"
|
||||
:disabled="!tabbarEnable"
|
||||
:tip="$t('preferences.tabbar.wheelableTip')"
|
||||
>
|
||||
{{ $t('preferences.tabbar.wheelable') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="tabbarMiddleClickToClose" :disabled="!tabbarEnable">
|
||||
{{ $t('preferences.tabbar.middleClickClose') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="tabbarShowIcon" :disabled="!tabbarEnable">
|
||||
{{ $t('preferences.tabbar.icon') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="tabbarShowMore" :disabled="!tabbarEnable">
|
||||
{{ $t('preferences.tabbar.showMore') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="tabbarShowMaximize" :disabled="!tabbarEnable">
|
||||
{{ $t('preferences.tabbar.showMaximize') }}
|
||||
</SwitchItem>
|
||||
<SelectItem v-model="tabbarStyleType" :items="styleItems">
|
||||
{{ $t('preferences.tabbar.styleType.title') }}
|
||||
</SelectItem>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from '@vben/types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import SelectItem from '../select-item.vue';
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceInterfaceControl',
|
||||
});
|
||||
|
||||
const widgetGlobalSearch = defineModel<boolean>('widgetGlobalSearch');
|
||||
const widgetFullscreen = defineModel<boolean>('widgetFullscreen');
|
||||
const widgetLanguageToggle = defineModel<boolean>('widgetLanguageToggle');
|
||||
const widgetNotification = defineModel<boolean>('widgetNotification');
|
||||
const widgetThemeToggle = defineModel<boolean>('widgetThemeToggle');
|
||||
const widgetSidebarToggle = defineModel<boolean>('widgetSidebarToggle');
|
||||
const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
|
||||
const appPreferencesButtonPosition = defineModel<string>(
|
||||
'appPreferencesButtonPosition',
|
||||
);
|
||||
const widgetRefresh = defineModel<boolean>('widgetRefresh');
|
||||
|
||||
const positionItems = computed((): SelectOption[] => [
|
||||
{
|
||||
label: $t('preferences.position.auto'),
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
label: $t('preferences.position.header'),
|
||||
value: 'header',
|
||||
},
|
||||
{
|
||||
label: $t('preferences.position.fixed'),
|
||||
value: 'fixed',
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchItem v-model="widgetGlobalSearch">
|
||||
{{ $t('preferences.widget.globalSearch') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="widgetThemeToggle">
|
||||
{{ $t('preferences.widget.themeToggle') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="widgetLanguageToggle">
|
||||
{{ $t('preferences.widget.languageToggle') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="widgetFullscreen">
|
||||
{{ $t('preferences.widget.fullscreen') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="widgetNotification">
|
||||
{{ $t('preferences.widget.notification') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="widgetLockScreen">
|
||||
{{ $t('preferences.widget.lockScreen') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="widgetSidebarToggle">
|
||||
{{ $t('preferences.widget.sidebarToggle') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="widgetRefresh">
|
||||
{{ $t('preferences.widget.refresh') }}
|
||||
</SwitchItem>
|
||||
<SelectItem v-model="appPreferencesButtonPosition" :items="positionItems">
|
||||
{{ $t('preferences.position.title') }}
|
||||
</SelectItem>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from '@vben/types';
|
||||
|
||||
import { useSlots } from 'vue';
|
||||
|
||||
import { CircleHelp } from '@vben/icons';
|
||||
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldContent,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
VbenTooltip,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceSelectItem',
|
||||
});
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
items?: SelectOption[];
|
||||
placeholder?: string;
|
||||
tip?: string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
placeholder: '',
|
||||
tip: '',
|
||||
items: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
const inputValue = defineModel<number>();
|
||||
|
||||
const slots = useSlots();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'hover:bg-accent': !slots.tip,
|
||||
'pointer-events-none opacity-50': disabled,
|
||||
}"
|
||||
class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1"
|
||||
>
|
||||
<span class="flex items-center text-sm">
|
||||
<slot></slot>
|
||||
|
||||
<VbenTooltip v-if="slots.tip || tip" side="bottom">
|
||||
<template #trigger>
|
||||
<CircleHelp class="ml-1 size-3 cursor-help" />
|
||||
</template>
|
||||
<slot name="tip">
|
||||
<template v-if="tip">
|
||||
<p v-for="(line, index) in tip.split('\n')" :key="index">
|
||||
{{ line }}
|
||||
</p>
|
||||
</template>
|
||||
</slot>
|
||||
</VbenTooltip>
|
||||
</span>
|
||||
|
||||
<NumberField v-model="inputValue" v-bind="$attrs" class="w-[165px]">
|
||||
<NumberFieldContent>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldContent>
|
||||
</NumberField>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from '@vben/types';
|
||||
|
||||
import { useSlots } from 'vue';
|
||||
|
||||
import { CircleHelp } from '@vben/icons';
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
VbenTooltip,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceSelectItem',
|
||||
});
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
items?: SelectOption[];
|
||||
placeholder?: string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
placeholder: '',
|
||||
items: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
const selectValue = defineModel<string>();
|
||||
|
||||
const slots = useSlots();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'hover:bg-accent': !slots.tip,
|
||||
'pointer-events-none opacity-50': disabled,
|
||||
}"
|
||||
class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1"
|
||||
>
|
||||
<span class="flex items-center text-sm">
|
||||
<slot></slot>
|
||||
|
||||
<VbenTooltip v-if="slots.tip" side="bottom">
|
||||
<template #trigger>
|
||||
<CircleHelp class="ml-1 size-3 cursor-help" />
|
||||
</template>
|
||||
<slot name="tip"></slot>
|
||||
</VbenTooltip>
|
||||
</span>
|
||||
<Select v-model="selectValue">
|
||||
<SelectTrigger class="h-8 w-[165px]">
|
||||
<SelectValue :placeholder="placeholder" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<template v-for="item in items" :key="item.value">
|
||||
<SelectItem :value="item.value"> {{ item.label }} </SelectItem>
|
||||
</template>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { $t } from '@vben/locales';
|
||||
import { isWindowsOs } from '@vben/utils';
|
||||
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceGeneralConfig',
|
||||
});
|
||||
|
||||
const shortcutKeysEnable = defineModel<boolean>('shortcutKeysEnable');
|
||||
const shortcutKeysGlobalSearch = defineModel<boolean>(
|
||||
'shortcutKeysGlobalSearch',
|
||||
);
|
||||
const shortcutKeysLogout = defineModel<boolean>('shortcutKeysLogout');
|
||||
// const shortcutKeysPreferences = defineModel<boolean>('shortcutKeysPreferences');
|
||||
const shortcutKeysLockScreen = defineModel<boolean>('shortcutKeysLockScreen');
|
||||
|
||||
const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchItem v-model="shortcutKeysEnable">
|
||||
{{ $t('preferences.shortcutKeys.title') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem
|
||||
v-model="shortcutKeysGlobalSearch"
|
||||
:disabled="!shortcutKeysEnable"
|
||||
>
|
||||
{{ $t('preferences.shortcutKeys.search') }}
|
||||
<template #shortcut>
|
||||
{{ isWindowsOs() ? 'Ctrl' : '⌘' }}
|
||||
<kbd> K </kbd>
|
||||
</template>
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="shortcutKeysLogout" :disabled="!shortcutKeysEnable">
|
||||
{{ $t('preferences.shortcutKeys.logout') }}
|
||||
<template #shortcut> {{ altView }} Q </template>
|
||||
</SwitchItem>
|
||||
<!-- <SwitchItem v-model="shortcutKeysPreferences" :disabled="!shortcutKeysEnable">
|
||||
{{ $t('preferences.shortcutKeys.preferences') }}
|
||||
<template #shortcut> {{ altView }} , </template>
|
||||
</SwitchItem> -->
|
||||
<SwitchItem v-model="shortcutKeysLockScreen" :disabled="!shortcutKeysEnable">
|
||||
{{ $t('ui.widgets.lockScreen.title') }}
|
||||
<template #shortcut> {{ altView }} L </template>
|
||||
</SwitchItem>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { useSlots } from 'vue';
|
||||
|
||||
import { CircleHelp } from '@vben/icons';
|
||||
|
||||
import { Switch, VbenTooltip } from '@vben-core/shadcn-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceSwitchItem',
|
||||
});
|
||||
|
||||
withDefaults(defineProps<{ disabled?: boolean; tip?: string }>(), {
|
||||
disabled: false,
|
||||
tip: '',
|
||||
});
|
||||
|
||||
const checked = defineModel<boolean>();
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
function handleClick() {
|
||||
checked.value = !checked.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'pointer-events-none opacity-50': disabled,
|
||||
}"
|
||||
class="hover:bg-accent my-1 flex w-full items-center justify-between rounded-md px-2 py-2.5"
|
||||
@click="handleClick"
|
||||
>
|
||||
<span class="flex items-center text-sm">
|
||||
<slot></slot>
|
||||
|
||||
<VbenTooltip v-if="slots.tip || tip" side="bottom">
|
||||
<template #trigger>
|
||||
<CircleHelp class="ml-1 size-3 cursor-help" />
|
||||
</template>
|
||||
<slot name="tip">
|
||||
<template v-if="tip">
|
||||
<p v-for="(line, index) in tip.split('\n')" :key="index">
|
||||
{{ line }}
|
||||
</p>
|
||||
</template>
|
||||
</slot>
|
||||
</VbenTooltip>
|
||||
</span>
|
||||
<span v-if="$slots.shortcut" class="ml-auto mr-2 text-xs opacity-60">
|
||||
<slot name="shortcut"></slot>
|
||||
</span>
|
||||
<Switch v-model:checked="checked" @click.stop />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import type { BuiltinThemePreset } from '@vben/preferences';
|
||||
import type { BuiltinThemeType } from '@vben/types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { UserRoundPen } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { BUILT_IN_THEME_PRESETS } from '@vben/preferences';
|
||||
import { convertToHsl, TinyColor } from '@vben/utils';
|
||||
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceBuiltinTheme',
|
||||
});
|
||||
|
||||
const props = defineProps<{ isDark: boolean }>();
|
||||
|
||||
const colorInput = ref();
|
||||
const modelValue = defineModel<BuiltinThemeType>({ default: 'default' });
|
||||
const themeColorPrimary = defineModel<string>('themeColorPrimary');
|
||||
|
||||
const updateThemeColorPrimary = useThrottleFn(
|
||||
(value: string) => {
|
||||
themeColorPrimary.value = value;
|
||||
},
|
||||
300,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
const inputValue = computed(() => {
|
||||
return new TinyColor(themeColorPrimary.value || '').toHexString();
|
||||
});
|
||||
|
||||
const builtinThemePresets = computed(() => {
|
||||
return [...BUILT_IN_THEME_PRESETS];
|
||||
});
|
||||
|
||||
function typeView(name: BuiltinThemeType) {
|
||||
switch (name) {
|
||||
case 'custom': {
|
||||
return $t('preferences.theme.builtin.custom');
|
||||
}
|
||||
case 'deep-blue': {
|
||||
return $t('preferences.theme.builtin.deepBlue');
|
||||
}
|
||||
case 'deep-green': {
|
||||
return $t('preferences.theme.builtin.deepGreen');
|
||||
}
|
||||
case 'default': {
|
||||
return $t('preferences.theme.builtin.default');
|
||||
}
|
||||
case 'gray': {
|
||||
return $t('preferences.theme.builtin.gray');
|
||||
}
|
||||
case 'green': {
|
||||
return $t('preferences.theme.builtin.green');
|
||||
}
|
||||
|
||||
case 'neutral': {
|
||||
return $t('preferences.theme.builtin.neutral');
|
||||
}
|
||||
case 'orange': {
|
||||
return $t('preferences.theme.builtin.orange');
|
||||
}
|
||||
case 'pink': {
|
||||
return $t('preferences.theme.builtin.pink');
|
||||
}
|
||||
case 'rose': {
|
||||
return $t('preferences.theme.builtin.rose');
|
||||
}
|
||||
case 'sky-blue': {
|
||||
return $t('preferences.theme.builtin.skyBlue');
|
||||
}
|
||||
case 'slate': {
|
||||
return $t('preferences.theme.builtin.slate');
|
||||
}
|
||||
case 'violet': {
|
||||
return $t('preferences.theme.builtin.violet');
|
||||
}
|
||||
case 'yellow': {
|
||||
return $t('preferences.theme.builtin.yellow');
|
||||
}
|
||||
case 'zinc': {
|
||||
return $t('preferences.theme.builtin.zinc');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(theme: BuiltinThemePreset) {
|
||||
modelValue.value = theme.type;
|
||||
}
|
||||
|
||||
function handleInputChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
updateThemeColorPrimary(convertToHsl(target.value));
|
||||
}
|
||||
|
||||
function selectColor() {
|
||||
colorInput.value?.[0]?.click?.();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [modelValue.value, props.isDark] as [BuiltinThemeType, boolean],
|
||||
([themeType, isDark]) => {
|
||||
const theme = builtinThemePresets.value.find(
|
||||
(item) => item.type === themeType,
|
||||
);
|
||||
if (theme) {
|
||||
const primaryColor = isDark
|
||||
? theme.darkPrimaryColor || theme.primaryColor
|
||||
: theme.primaryColor;
|
||||
|
||||
themeColorPrimary.value = primaryColor || theme.color;
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full flex-wrap justify-between">
|
||||
<template v-for="theme in builtinThemePresets" :key="theme.type">
|
||||
<div class="flex cursor-pointer flex-col" @click="handleSelect(theme)">
|
||||
<div
|
||||
:class="{
|
||||
'outline-box-active': theme.type === modelValue,
|
||||
}"
|
||||
class="outline-box flex-center group cursor-pointer"
|
||||
>
|
||||
<template v-if="theme.type !== 'custom'">
|
||||
<div
|
||||
:style="{ backgroundColor: theme.color }"
|
||||
class="mx-10 my-2 size-5 rounded-md"
|
||||
></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="size-full px-10 py-2" @click.stop="selectColor">
|
||||
<div class="flex-center relative size-5 rounded-sm">
|
||||
<UserRoundPen
|
||||
class="absolute z-10 size-5 opacity-60 group-hover:opacity-100"
|
||||
/>
|
||||
<input
|
||||
ref="colorInput"
|
||||
:value="inputValue"
|
||||
class="absolute inset-0 opacity-0"
|
||||
type="color"
|
||||
@input="handleInputChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="text-muted-foreground my-2 text-center text-xs">
|
||||
{{ typeView(theme.type) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceColorMode',
|
||||
});
|
||||
|
||||
const appColorWeakMode = defineModel<boolean>('appColorWeakMode', {
|
||||
default: false,
|
||||
});
|
||||
|
||||
const appColorGrayMode = defineModel<boolean>('appColorGrayMode', {
|
||||
default: false,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchItem v-model="appColorWeakMode">
|
||||
{{ $t('preferences.theme.weakMode') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="appColorGrayMode">
|
||||
{{ $t('preferences.theme.grayMode') }}
|
||||
</SwitchItem>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ToggleGroup, ToggleGroupItem } from '@vben-core/shadcn-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceColorMode',
|
||||
});
|
||||
|
||||
const modelValue = defineModel<string | undefined>('themeRadius', {
|
||||
default: '0.5',
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ label: '0', value: '0' },
|
||||
{ label: '0.25', value: '0.25' },
|
||||
{ label: '0.5', value: '0.5' },
|
||||
{ label: '0.75', value: '0.75' },
|
||||
{ label: '1', value: '1' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ToggleGroup
|
||||
v-model="modelValue"
|
||||
class="gap-2"
|
||||
size="sm"
|
||||
type="single"
|
||||
variant="outline"
|
||||
>
|
||||
<template v-for="item in items" :key="item.value">
|
||||
<ToggleGroupItem
|
||||
:value="item.value"
|
||||
class="data-[state=on]:bg-primary data-[state=on]:text-primary-foreground h-7 w-16 rounded-sm"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ToggleGroupItem>
|
||||
</template>
|
||||
</ToggleGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { ThemeModeType } from '@vben/types';
|
||||
|
||||
import { MoonStar, Sun, SunMoon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import SwitchItem from '../switch-item.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceTheme',
|
||||
});
|
||||
|
||||
const modelValue = defineModel<string>({ default: 'auto' });
|
||||
const themeSemiDarkSidebar = defineModel<boolean>('themeSemiDarkSidebar');
|
||||
const themeSemiDarkHeader = defineModel<boolean>('themeSemiDarkHeader');
|
||||
|
||||
const THEME_PRESET: Array<{ icon: Component; name: ThemeModeType }> = [
|
||||
{
|
||||
icon: Sun,
|
||||
name: 'light',
|
||||
},
|
||||
{
|
||||
icon: MoonStar,
|
||||
name: 'dark',
|
||||
},
|
||||
{
|
||||
icon: SunMoon,
|
||||
name: 'auto',
|
||||
},
|
||||
];
|
||||
|
||||
function activeClass(theme: string): string[] {
|
||||
return theme === modelValue.value ? ['outline-box-active'] : [];
|
||||
}
|
||||
|
||||
function nameView(name: string) {
|
||||
switch (name) {
|
||||
case 'auto': {
|
||||
return $t('preferences.followSystem');
|
||||
}
|
||||
case 'dark': {
|
||||
return $t('preferences.theme.dark');
|
||||
}
|
||||
case 'light': {
|
||||
return $t('preferences.theme.light');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full flex-wrap justify-between">
|
||||
<template v-for="theme in THEME_PRESET" :key="theme.name">
|
||||
<div
|
||||
class="flex cursor-pointer flex-col"
|
||||
@click="modelValue = theme.name"
|
||||
>
|
||||
<div
|
||||
:class="activeClass(theme.name)"
|
||||
class="outline-box flex-center py-4"
|
||||
>
|
||||
<component :is="theme.icon" class="mx-9 size-5" />
|
||||
</div>
|
||||
<div class="text-muted-foreground mt-2 text-center text-xs">
|
||||
{{ nameView(theme.name) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SwitchItem
|
||||
v-model="themeSemiDarkSidebar"
|
||||
:disabled="modelValue === 'dark'"
|
||||
class="mt-6"
|
||||
>
|
||||
{{ $t('preferences.theme.darkSidebar') }}
|
||||
</SwitchItem>
|
||||
<SwitchItem v-model="themeSemiDarkHeader" :disabled="modelValue === 'dark'">
|
||||
{{ $t('preferences.theme.darkHeader') }}
|
||||
</SwitchItem>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from '@vben/types';
|
||||
|
||||
import { ToggleGroup, ToggleGroupItem } from '@vben-core/shadcn-ui';
|
||||
|
||||
defineOptions({
|
||||
name: 'PreferenceToggleItem',
|
||||
});
|
||||
|
||||
withDefaults(defineProps<{ disabled?: boolean; items: SelectOption[] }>(), {
|
||||
disabled: false,
|
||||
items: () => [],
|
||||
});
|
||||
|
||||
const modelValue = defineModel<string>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'pointer-events-none opacity-50': disabled,
|
||||
}"
|
||||
class="hover:bg-accent flex w-full items-center justify-between rounded-md px-2 py-2"
|
||||
disabled
|
||||
>
|
||||
<span class="text-sm">
|
||||
<slot></slot>
|
||||
</span>
|
||||
<ToggleGroup
|
||||
v-model="modelValue"
|
||||
class="gap-2"
|
||||
size="sm"
|
||||
type="single"
|
||||
variant="outline"
|
||||
>
|
||||
<template v-for="item in items" :key="item.value">
|
||||
<ToggleGroupItem
|
||||
:value="item.value"
|
||||
class="data-[state=on]:bg-primary data-[state=on]:text-primary-foreground h-7 rounded-sm"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ToggleGroupItem>
|
||||
</template>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<svg
|
||||
class="custom-radio-image"
|
||||
fill="none"
|
||||
height="66"
|
||||
width="104"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<rect
|
||||
id="svg_1"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.02"
|
||||
height="66"
|
||||
rx="4"
|
||||
stroke="null"
|
||||
width="104"
|
||||
x="0.13514"
|
||||
y="0.13514"
|
||||
/>
|
||||
<rect
|
||||
id="svg_8"
|
||||
fill="hsl(var(--primary))"
|
||||
height="9.07027"
|
||||
stroke="null"
|
||||
width="104.07934"
|
||||
x="-0.07419"
|
||||
y="-0.05773"
|
||||
/>
|
||||
<rect
|
||||
id="svg_3"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="15.58168"
|
||||
y="3.20832"
|
||||
/>
|
||||
<path
|
||||
id="svg_12"
|
||||
d="m98.19822,2.872c0,-0.54338 0.45662,-1 1,-1l1.925,0c0.54338,0 1,0.45662 1,1l0,2.4c0,0.54338 -0.45662,1 -1,1l-1.925,0c-0.54338,0 -1,-0.45662 -1,-1l0,-2.4z"
|
||||
fill="#ffffff"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<rect
|
||||
id="svg_13"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.51892"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="41.98275"
|
||||
x="45.37589"
|
||||
y="13.53192"
|
||||
/>
|
||||
<path
|
||||
id="svg_14"
|
||||
d="m16.4123,15.53192c0,-1.08676 0.74096,-2 1.62271,-2l21.74653,0c0.88175,0 1.62271,0.91324 1.62271,2l0,17.24865c0,1.08676 -0.74096,2 -1.62271,2l-21.74653,0c-0.88175,0 -1.62271,-0.91324 -1.62271,-2l0,-17.24865z"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<rect
|
||||
id="svg_15"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.65405"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="71.10636"
|
||||
x="16.54743"
|
||||
y="39.34689"
|
||||
/>
|
||||
<rect
|
||||
id="svg_21"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="28.14924"
|
||||
y="3.07319"
|
||||
/>
|
||||
<rect
|
||||
id="svg_22"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="41.25735"
|
||||
y="3.20832"
|
||||
/>
|
||||
<rect
|
||||
id="svg_23"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="54.23033"
|
||||
y="3.07319"
|
||||
/>
|
||||
<rect
|
||||
id="svg_4"
|
||||
fill="#ffffff"
|
||||
height="7.13843"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="7.78397"
|
||||
x="1.5327"
|
||||
y="0.881"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<svg
|
||||
class="custom-radio-image"
|
||||
fill="none"
|
||||
height="66"
|
||||
width="104"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
id="svg_1"
|
||||
d="m0.13514,4.13514c0,-2.17352 1.82648,-4 4,-4l96,0c2.17352,0 4,1.82648 4,4l0,58c0,2.17352 -1.82648,4 -4,4l-96,0c-2.17352,0 -4,-1.82648 -4,-4l0,-58z"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.02"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<rect
|
||||
id="svg_13"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="26.57155"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="53.18333"
|
||||
x="45.79979"
|
||||
y="3.77232"
|
||||
/>
|
||||
<path
|
||||
id="svg_14"
|
||||
d="m4.28142,5.96169c0,-1.37748 1.06465,-2.53502 2.33158,-2.53502l31.2463,0c1.26693,0 2.33158,1.15754 2.33158,2.53502l0,21.86282c0,1.37748 -1.06465,2.53502 -2.33158,2.53502l-31.2463,0c-1.26693,0 -2.33158,-1.15754 -2.33158,-2.53502l0,-21.86282z"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<rect
|
||||
id="svg_15"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="25.02247"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="94.39371"
|
||||
x="4.56735"
|
||||
y="34.92584"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<svg
|
||||
class="custom-radio-image"
|
||||
fill="none"
|
||||
height="66"
|
||||
width="104"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<rect
|
||||
id="svg_1"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.02"
|
||||
height="66"
|
||||
rx="4"
|
||||
stroke="null"
|
||||
width="104"
|
||||
x="0.13514"
|
||||
y="0.13514"
|
||||
/>
|
||||
<path
|
||||
id="svg_2"
|
||||
d="m-3.37838,3.7543a1.93401,4.02457 0 0 1 1.93401,-4.02457l11.3488,0l0,66.40541l-11.3488,0a1.93401,4.02457 0 0 1 -1.93401,-4.02457l0,-58.35627z"
|
||||
fill="hsl(var(--primary))"
|
||||
stroke="null"
|
||||
/>
|
||||
<rect
|
||||
id="svg_3"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="5.47439"
|
||||
x="1.64059"
|
||||
y="15.46086"
|
||||
/>
|
||||
<rect
|
||||
id="svg_4"
|
||||
fill="#ffffff"
|
||||
height="7.67897"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="8.18938"
|
||||
x="0.58676"
|
||||
y="1.42154"
|
||||
/>
|
||||
<rect
|
||||
id="svg_8"
|
||||
fill="hsl(var(--primary))"
|
||||
height="9.07027"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="75.91967"
|
||||
x="25.38277"
|
||||
y="1.42876"
|
||||
/>
|
||||
<rect
|
||||
id="svg_9"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="27.91529"
|
||||
y="3.69284"
|
||||
/>
|
||||
<rect
|
||||
id="svg_10"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="80.75054"
|
||||
y="3.62876"
|
||||
/>
|
||||
<rect
|
||||
id="svg_11"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="87.78868"
|
||||
y="3.69981"
|
||||
/>
|
||||
<rect
|
||||
id="svg_12"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="94.6847"
|
||||
y="3.62876"
|
||||
/>
|
||||
<rect
|
||||
id="svg_13"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.51892"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="42.9287"
|
||||
x="58.75427"
|
||||
y="14.613"
|
||||
/>
|
||||
<rect
|
||||
id="svg_14"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="20.97838"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="28.36894"
|
||||
x="26.14342"
|
||||
y="14.613"
|
||||
/>
|
||||
<rect
|
||||
id="svg_15"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.65405"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="75.09493"
|
||||
x="26.34264"
|
||||
y="39.68822"
|
||||
/>
|
||||
<rect
|
||||
id="svg_5"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="5.47439"
|
||||
x="1.79832"
|
||||
y="28.39462"
|
||||
/>
|
||||
<rect
|
||||
id="svg_6"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="5.47439"
|
||||
x="1.64059"
|
||||
y="41.80156"
|
||||
/>
|
||||
<rect
|
||||
id="svg_7"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="5.47439"
|
||||
x="1.64059"
|
||||
y="55.36623"
|
||||
/>
|
||||
<rect
|
||||
id="svg_16"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="65.72065"
|
||||
stroke="null"
|
||||
width="12.49265"
|
||||
x="9.85477"
|
||||
y="-0.02618"
|
||||
/>
|
||||
<rect
|
||||
id="svg_21"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="35.14924"
|
||||
y="4.07319"
|
||||
/>
|
||||
<rect
|
||||
id="svg_22"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="47.25735"
|
||||
y="4.20832"
|
||||
/>
|
||||
<rect
|
||||
id="svg_23"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="59.23033"
|
||||
y="4.07319"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<svg
|
||||
class="custom-radio-image"
|
||||
fill="none"
|
||||
height="66"
|
||||
width="104"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<rect
|
||||
id="svg_1"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.02"
|
||||
height="66"
|
||||
rx="4"
|
||||
stroke="null"
|
||||
width="104"
|
||||
x="0.13514"
|
||||
y="0.13514"
|
||||
/>
|
||||
<rect
|
||||
id="svg_8"
|
||||
fill="hsl(var(--primary))"
|
||||
height="9.07027"
|
||||
stroke="null"
|
||||
width="104.07934"
|
||||
x="-0.07419"
|
||||
y="-0.05773"
|
||||
/>
|
||||
<rect
|
||||
id="svg_3"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="15.58168"
|
||||
y="3.20832"
|
||||
/>
|
||||
<path
|
||||
id="svg_12"
|
||||
d="m98.19822,2.872c0,-0.54338 0.45662,-1 1,-1l1.925,0c0.54338,0 1,0.45662 1,1l0,2.4c0,0.54338 -0.45662,1 -1,1l-1.925,0c-0.54338,0 -1,-0.45662 -1,-1l0,-2.4z"
|
||||
fill="#ffffff"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<rect
|
||||
id="svg_13"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.51892"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="53.60438"
|
||||
x="43.484"
|
||||
y="13.66705"
|
||||
/>
|
||||
<path
|
||||
id="svg_14"
|
||||
d="m3.43932,15.53192c0,-1.08676 1.03344,-2 2.26323,-2l30.33036,0c1.22979,0 2.26323,0.91324 2.26323,2l0,17.24865c0,1.08676 -1.03344,2 -2.26323,2l-30.33036,0c-1.22979,0 -2.26323,-0.91324 -2.26323,-2l0,-17.24865z"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<rect
|
||||
id="svg_15"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.65405"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="95.02528"
|
||||
x="3.30419"
|
||||
y="39.34689"
|
||||
/>
|
||||
<rect
|
||||
id="svg_21"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="28.14924"
|
||||
y="3.07319"
|
||||
/>
|
||||
<rect
|
||||
id="svg_22"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="41.25735"
|
||||
y="3.20832"
|
||||
/>
|
||||
<rect
|
||||
id="svg_23"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="54.23033"
|
||||
y="3.07319"
|
||||
/>
|
||||
<rect
|
||||
id="svg_4"
|
||||
fill="#ffffff"
|
||||
height="7.13843"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="7.78397"
|
||||
x="1.5327"
|
||||
y="0.881"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<svg
|
||||
class="custom-radio-image"
|
||||
fill="none"
|
||||
height="66"
|
||||
width="104"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<rect
|
||||
id="svg_1"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.02"
|
||||
height="66"
|
||||
rx="4"
|
||||
stroke="null"
|
||||
width="104"
|
||||
x="0.13514"
|
||||
y="0.13514"
|
||||
/>
|
||||
<rect
|
||||
id="svg_8"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="9.07027"
|
||||
stroke="null"
|
||||
width="104.07934"
|
||||
x="-0.07419"
|
||||
y="-0.05773"
|
||||
/>
|
||||
<rect
|
||||
id="svg_3"
|
||||
fill="#b2b2b2"
|
||||
height="1.689"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="6.52486"
|
||||
x="10.08168"
|
||||
y="3.50832"
|
||||
/>
|
||||
<rect
|
||||
id="svg_10"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="80.75054"
|
||||
y="2.89362"
|
||||
/>
|
||||
<rect
|
||||
id="svg_11"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="87.58249"
|
||||
y="2.89362"
|
||||
/>
|
||||
<path
|
||||
id="svg_12"
|
||||
d="m98.19822,2.872c0,-0.54338 0.45662,-1 1,-1l1.925,0c0.54338,0 1,0.45662 1,1l0,2.4c0,0.54338 -0.45662,1 -1,1l-1.925,0c-0.54338,0 -1,-0.45662 -1,-1l0,-2.4z"
|
||||
fill="#ffffff"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<rect
|
||||
id="svg_13"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.51892"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="44.13071"
|
||||
x="53.37873"
|
||||
y="13.45652"
|
||||
/>
|
||||
<path
|
||||
id="svg_14"
|
||||
d="m19.4393,15.74245c0,-1.08676 0.79001,-2 1.73013,-2l23.18605,0c0.94011,0 1.73013,0.91324 1.73013,2l0,17.24865c0,1.08676 -0.79001,2 -1.73013,2l-23.18605,0c-0.94011,0 -1.73013,-0.91324 -1.73013,-2l0,-17.24865z"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<rect
|
||||
id="svg_15"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.65405"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="78.39372"
|
||||
x="19.93575"
|
||||
y="39.34689"
|
||||
/>
|
||||
<rect
|
||||
id="svg_21"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="28.14924"
|
||||
y="3.07319"
|
||||
/>
|
||||
<rect
|
||||
id="svg_22"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="41.25735"
|
||||
y="3.20832"
|
||||
/>
|
||||
<rect
|
||||
id="svg_23"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="54.23033"
|
||||
y="3.07319"
|
||||
/>
|
||||
<rect
|
||||
id="svg_4"
|
||||
fill="#ffffff"
|
||||
height="5.13843"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="5.78397"
|
||||
x="1.5327"
|
||||
y="1.081"
|
||||
/>
|
||||
<rect
|
||||
id="svg_5"
|
||||
fill="hsl(var(--primary))"
|
||||
height="56.81191"
|
||||
stroke="null"
|
||||
width="15.44642"
|
||||
x="-0.06423"
|
||||
y="9.03113"
|
||||
/>
|
||||
<path
|
||||
id="svg_2"
|
||||
d="m2.38669,15.38074c0,-0.20384 0.27195,-0.37513 0.59557,-0.37513l7.98149,0c0.32362,0 0.59557,0.17129 0.59557,0.37513l0,3.23525c0,0.20384 -0.27195,0.37513 -0.59557,0.37513l-7.98149,0c-0.32362,0 -0.59557,-0.17129 -0.59557,-0.37513l0,-3.23525z"
|
||||
fill="#fff"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<path
|
||||
id="svg_6"
|
||||
d="m2.38669,28.43336c0,-0.20384 0.27195,-0.37513 0.59557,-0.37513l7.98149,0c0.32362,0 0.59557,0.17129 0.59557,0.37513l0,3.23525c0,0.20384 -0.27195,0.37513 -0.59557,0.37513l-7.98149,0c-0.32362,0 -0.59557,-0.17129 -0.59557,-0.37513l0,-3.23525z"
|
||||
fill="#fff"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<path
|
||||
id="svg_7"
|
||||
d="m2.17616,41.27545c0,-0.20384 0.27195,-0.37513 0.59557,-0.37513l7.98149,0c0.32362,0 0.59557,0.17129 0.59557,0.37513l0,3.23525c0,0.20384 -0.27195,0.37513 -0.59557,0.37513l-7.98149,0c-0.32362,0 -0.59557,-0.17129 -0.59557,-0.37513l0,-3.23525z"
|
||||
fill="#fff"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<path
|
||||
id="svg_9"
|
||||
d="m2.17616,54.32806c0,-0.20384 0.27195,-0.37513 0.59557,-0.37513l7.98149,0c0.32362,0 0.59557,0.17129 0.59557,0.37513l0,3.23525c0,0.20384 -0.27195,0.37513 -0.59557,0.37513l-7.98149,0c-0.32362,0 -0.59557,-0.17129 -0.59557,-0.37513l0,-3.23525z"
|
||||
fill="#fff"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
import HeaderNav from './header-nav.vue';
|
||||
|
||||
export { default as ContentCompact } from './content-compact.vue';
|
||||
export { default as FullContent } from './full-content.vue';
|
||||
export { default as HeaderMixedNav } from './header-mixed-nav.vue';
|
||||
export { default as HeaderSidebarNav } from './header-sidebar-nav.vue';
|
||||
export { default as MixedNav } from './mixed-nav.vue';
|
||||
export { default as SidebarMixedNav } from './sidebar-mixed-nav.vue';
|
||||
export { default as SidebarNav } from './sidebar-nav.vue';
|
||||
|
||||
const ContentWide = HeaderNav;
|
||||
export { ContentWide, HeaderNav };
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<svg
|
||||
class="custom-radio-image"
|
||||
fill="none"
|
||||
height="66"
|
||||
width="104"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<rect
|
||||
id="svg_1"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.02"
|
||||
height="66"
|
||||
rx="4"
|
||||
stroke="null"
|
||||
width="104"
|
||||
x="0.13514"
|
||||
y="0.13514"
|
||||
/>
|
||||
<rect
|
||||
id="svg_8"
|
||||
fill="hsl(var(--primary))"
|
||||
height="9.07027"
|
||||
stroke="null"
|
||||
width="104.07934"
|
||||
x="-0.07419"
|
||||
y="-0.05773"
|
||||
/>
|
||||
<rect
|
||||
id="svg_3"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="15.58168"
|
||||
y="3.20832"
|
||||
/>
|
||||
<path
|
||||
id="svg_12"
|
||||
d="m98.19822,2.872c0,-0.54338 0.45662,-1 1,-1l1.925,0c0.54338,0 1,0.45662 1,1l0,2.4c0,0.54338 -0.45662,1 -1,1l-1.925,0c-0.54338,0 -1,-0.45662 -1,-1l0,-2.4z"
|
||||
fill="#ffffff"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<rect
|
||||
id="svg_13"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.51892"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="44.13071"
|
||||
x="53.37873"
|
||||
y="13.45652"
|
||||
/>
|
||||
<path
|
||||
id="svg_14"
|
||||
d="m19.4393,15.74245c0,-1.08676 0.79001,-2 1.73013,-2l23.18605,0c0.94011,0 1.73013,0.91324 1.73013,2l0,17.24865c0,1.08676 -0.79001,2 -1.73013,2l-23.18605,0c-0.94011,0 -1.73013,-0.91324 -1.73013,-2l0,-17.24865z"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<rect
|
||||
id="svg_15"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.65405"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="78.39372"
|
||||
x="19.93575"
|
||||
y="39.34689"
|
||||
/>
|
||||
<rect
|
||||
id="svg_21"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="28.14924"
|
||||
y="3.07319"
|
||||
/>
|
||||
<rect
|
||||
id="svg_22"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="41.25735"
|
||||
y="3.20832"
|
||||
/>
|
||||
<rect
|
||||
id="svg_23"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="7.52486"
|
||||
x="54.23033"
|
||||
y="3.07319"
|
||||
/>
|
||||
<rect
|
||||
id="svg_4"
|
||||
fill="#ffffff"
|
||||
height="7.13843"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="7.78397"
|
||||
x="1.5327"
|
||||
y="0.881"
|
||||
/>
|
||||
<rect
|
||||
id="svg_5"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="56.81191"
|
||||
stroke="null"
|
||||
width="15.44642"
|
||||
x="-0.06423"
|
||||
y="9.03113"
|
||||
/>
|
||||
<path
|
||||
id="svg_2"
|
||||
d="m2.38669,15.38074c0,-0.20384 0.27195,-0.37513 0.59557,-0.37513l7.98149,0c0.32362,0 0.59557,0.17129 0.59557,0.37513l0,3.23525c0,0.20384 -0.27195,0.37513 -0.59557,0.37513l-7.98149,0c-0.32362,0 -0.59557,-0.17129 -0.59557,-0.37513l0,-3.23525z"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<path
|
||||
id="svg_6"
|
||||
d="m2.38669,28.43336c0,-0.20384 0.27195,-0.37513 0.59557,-0.37513l7.98149,0c0.32362,0 0.59557,0.17129 0.59557,0.37513l0,3.23525c0,0.20384 -0.27195,0.37513 -0.59557,0.37513l-7.98149,0c-0.32362,0 -0.59557,-0.17129 -0.59557,-0.37513l0,-3.23525z"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<path
|
||||
id="svg_7"
|
||||
d="m2.17616,41.27545c0,-0.20384 0.27195,-0.37513 0.59557,-0.37513l7.98149,0c0.32362,0 0.59557,0.17129 0.59557,0.37513l0,3.23525c0,0.20384 -0.27195,0.37513 -0.59557,0.37513l-7.98149,0c-0.32362,0 -0.59557,-0.17129 -0.59557,-0.37513l0,-3.23525z"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
<path
|
||||
id="svg_9"
|
||||
d="m2.17616,54.32806c0,-0.20384 0.27195,-0.37513 0.59557,-0.37513l7.98149,0c0.32362,0 0.59557,0.17129 0.59557,0.37513l0,3.23525c0,0.20384 -0.27195,0.37513 -0.59557,0.37513l-7.98149,0c-0.32362,0 -0.59557,-0.17129 -0.59557,-0.37513l0,-3.23525z"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
opacity="undefined"
|
||||
stroke="null"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<svg
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19.9 12.66a1 1 0 0 1 0-1.32l1.28-1.44a1 1 0 0 0 .12-1.17l-2-3.46a1 1 0 0 0-1.07-.48l-1.88.38a1 1 0 0 1-1.15-.66l-.61-1.83a1 1 0 0 0-.95-.68h-4a1 1 0 0 0-1 .68l-.56 1.83a1 1 0 0 1-1.15.66L5 4.79a1 1 0 0 0-1 .48L2 8.73a1 1 0 0 0 .1 1.17l1.27 1.44a1 1 0 0 1 0 1.32L2.1 14.1a1 1 0 0 0-.1 1.17l2 3.46a1 1 0 0 0 1.07.48l1.88-.38a1 1 0 0 1 1.15.66l.61 1.83a1 1 0 0 0 1 .68h4a1 1 0 0 0 .95-.68l.61-1.83a1 1 0 0 1 1.15-.66l1.88.38a1 1 0 0 0 1.07-.48l2-3.46a1 1 0 0 0-.12-1.17ZM18.41 14l.8.9l-1.28 2.22l-1.18-.24a3 3 0 0 0-3.45 2L12.92 20h-2.56L10 18.86a3 3 0 0 0-3.45-2l-1.18.24l-1.3-2.21l.8-.9a3 3 0 0 0 0-4l-.8-.9l1.28-2.2l1.18.24a3 3 0 0 0 3.45-2L10.36 4h2.56l.38 1.14a3 3 0 0 0 3.45 2l1.18-.24l1.28 2.22l-.8.9a3 3 0 0 0 0 3.98m-6.77-6a4 4 0 1 0 4 4a4 4 0 0 0-4-4m0 6a2 2 0 1 1 2-2a2 2 0 0 1-2 2"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<svg
|
||||
class="custom-radio-image"
|
||||
fill="none"
|
||||
height="66"
|
||||
width="104"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<rect
|
||||
id="svg_1"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.02"
|
||||
height="66"
|
||||
rx="4"
|
||||
stroke="null"
|
||||
width="104"
|
||||
x="0.13514"
|
||||
y="0.13514"
|
||||
/>
|
||||
<path
|
||||
id="svg_2"
|
||||
d="m-3.37838,3.7543a1.93401,4.02457 0 0 1 1.93401,-4.02457l11.3488,0l0,66.40541l-11.3488,0a1.93401,4.02457 0 0 1 -1.93401,-4.02457l0,-58.35627z"
|
||||
fill="hsl(var(--primary))"
|
||||
stroke="null"
|
||||
/>
|
||||
<rect
|
||||
id="svg_3"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="5.47439"
|
||||
x="1.64059"
|
||||
y="15.46086"
|
||||
/>
|
||||
<rect
|
||||
id="svg_4"
|
||||
fill="#ffffff"
|
||||
height="7.67897"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="8.18938"
|
||||
x="0.58676"
|
||||
y="1.42154"
|
||||
/>
|
||||
<rect
|
||||
id="svg_8"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="9.07027"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="75.91967"
|
||||
x="25.38277"
|
||||
y="1.42876"
|
||||
/>
|
||||
<rect
|
||||
id="svg_9"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="27.91529"
|
||||
y="3.69284"
|
||||
/>
|
||||
<rect
|
||||
id="svg_10"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="80.75054"
|
||||
y="3.62876"
|
||||
/>
|
||||
<rect
|
||||
id="svg_11"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="87.78868"
|
||||
y="3.69981"
|
||||
/>
|
||||
<rect
|
||||
id="svg_12"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="94.6847"
|
||||
y="3.62876"
|
||||
/>
|
||||
<rect
|
||||
id="svg_13"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.51892"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="42.9287"
|
||||
x="58.75427"
|
||||
y="14.613"
|
||||
/>
|
||||
<rect
|
||||
id="svg_14"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="20.97838"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="28.36894"
|
||||
x="26.14342"
|
||||
y="14.613"
|
||||
/>
|
||||
<rect
|
||||
id="svg_15"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.65405"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="75.09493"
|
||||
x="26.34264"
|
||||
y="39.68822"
|
||||
/>
|
||||
<rect
|
||||
id="svg_5"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="5.47439"
|
||||
x="1.79832"
|
||||
y="28.39462"
|
||||
/>
|
||||
<rect
|
||||
id="svg_6"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="5.47439"
|
||||
x="1.64059"
|
||||
y="41.80156"
|
||||
/>
|
||||
<rect
|
||||
id="svg_7"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
stroke="null"
|
||||
width="5.47439"
|
||||
x="1.64059"
|
||||
y="55.36623"
|
||||
/>
|
||||
<rect
|
||||
id="svg_16"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="65.72065"
|
||||
stroke="null"
|
||||
width="12.49265"
|
||||
x="9.85477"
|
||||
y="-0.02618"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<svg
|
||||
class="custom-radio-image"
|
||||
fill="none"
|
||||
height="66"
|
||||
width="104"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<rect
|
||||
id="svg_1"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.02"
|
||||
height="66"
|
||||
rx="4"
|
||||
stroke="null"
|
||||
width="104"
|
||||
/>
|
||||
<path
|
||||
id="svg_2"
|
||||
d="m-3.37838,3.61916a4.4919,4.02457 0 0 1 4.4919,-4.02457l26.35848,0l0,66.40541l-26.35848,0a4.4919,4.02457 0 0 1 -4.4919,-4.02457l0,-58.35627z"
|
||||
fill="hsl(var(--primary))"
|
||||
stroke="null"
|
||||
/>
|
||||
<rect
|
||||
id="svg_3"
|
||||
fill="#e5e5e5"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
width="17.66"
|
||||
x="4.906"
|
||||
y="23.884"
|
||||
/>
|
||||
<rect
|
||||
id="svg_4"
|
||||
fill="#ffffff"
|
||||
height="9.706"
|
||||
rx="2"
|
||||
width="9.811"
|
||||
x="8.83"
|
||||
y="5.881"
|
||||
/>
|
||||
<path
|
||||
id="svg_5"
|
||||
d="m4.906,35.833c0,-0.75801 0.63699,-1.395 1.395,-1.395l14.87,0c0.75801,0 1.395,0.63699 1.395,1.395l0,-0.001c0,0.75801 -0.63699,1.395 -1.395,1.395l-14.87,0c-0.75801,0 -1.395,-0.63699 -1.395,-1.395l0,0.001z"
|
||||
fill="#ffffff"
|
||||
opacity="undefined"
|
||||
/>
|
||||
<rect
|
||||
id="svg_6"
|
||||
fill="#ffffff"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
width="17.66"
|
||||
x="4.906"
|
||||
y="44.992"
|
||||
/>
|
||||
<rect
|
||||
id="svg_7"
|
||||
fill="#ffffff"
|
||||
height="2.789"
|
||||
rx="1.395"
|
||||
width="17.66"
|
||||
x="4.906"
|
||||
y="55.546"
|
||||
/>
|
||||
<rect
|
||||
id="svg_8"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="9.07027"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="73.53879"
|
||||
x="28.97986"
|
||||
y="1.42876"
|
||||
/>
|
||||
<rect
|
||||
id="svg_9"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="32.039"
|
||||
y="3.89903"
|
||||
/>
|
||||
<rect
|
||||
id="svg_10"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="80.75054"
|
||||
y="3.62876"
|
||||
/>
|
||||
<rect
|
||||
id="svg_11"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="87.58249"
|
||||
y="3.49362"
|
||||
/>
|
||||
<rect
|
||||
id="svg_12"
|
||||
fill="#b2b2b2"
|
||||
height="4.4"
|
||||
rx="1"
|
||||
stroke="null"
|
||||
width="3.925"
|
||||
x="94.6847"
|
||||
y="3.62876"
|
||||
/>
|
||||
<rect
|
||||
id="svg_13"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.51892"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="45.63141"
|
||||
x="56.05157"
|
||||
y="14.613"
|
||||
/>
|
||||
<rect
|
||||
id="svg_14"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="20.97838"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="22.82978"
|
||||
x="29.38527"
|
||||
y="14.613"
|
||||
/>
|
||||
<rect
|
||||
id="svg_15"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.08"
|
||||
height="21.65405"
|
||||
rx="2"
|
||||
stroke="null"
|
||||
width="72.45771"
|
||||
x="28.97986"
|
||||
y="39.48203"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as PreferencesButton } from './preferences-button.vue';
|
||||
export { default as Preferences } from './preferences.vue';
|
||||
export * from './use-open-preferences';
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
import { Settings } from '@vben/icons';
|
||||
|
||||
import { VbenIconButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
import Preferences from './preferences.vue';
|
||||
|
||||
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
|
||||
|
||||
function clearPreferencesAndLogout() {
|
||||
emit('clearPreferencesAndLogout');
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Preferences @clear-preferences-and-logout="clearPreferencesAndLogout">
|
||||
<VbenIconButton>
|
||||
<Settings class="text-foreground size-4" />
|
||||
</VbenIconButton>
|
||||
</Preferences>
|
||||
</template>
|
||||
@@ -0,0 +1,449 @@
|
||||
<script setup lang="ts">
|
||||
import type { SupportedLanguagesType } from '@vben/locales';
|
||||
import type {
|
||||
BreadcrumbStyleType,
|
||||
BuiltinThemeType,
|
||||
ContentCompactType,
|
||||
LayoutHeaderMenuAlignType,
|
||||
LayoutHeaderModeType,
|
||||
LayoutType,
|
||||
NavigationStyleType,
|
||||
PreferencesButtonPositionType,
|
||||
ThemeModeType,
|
||||
} from '@vben/types';
|
||||
|
||||
import type { SegmentedItem } from '@vben-core/shadcn-ui';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Copy, RotateCw } from '@vben/icons';
|
||||
import { $t, loadLocaleMessages } from '@vben/locales';
|
||||
import {
|
||||
clearPreferencesCache,
|
||||
preferences,
|
||||
resetPreferences,
|
||||
usePreferences,
|
||||
} from '@vben/preferences';
|
||||
|
||||
import { useVbenDrawer } from '@vben-core/popup-ui';
|
||||
import {
|
||||
VbenButton,
|
||||
VbenIconButton,
|
||||
VbenSegmented,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
import { globalShareState } from '@vben-core/shared/global-state';
|
||||
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
|
||||
import {
|
||||
Animation,
|
||||
Block,
|
||||
Breadcrumb,
|
||||
BuiltinTheme,
|
||||
ColorMode,
|
||||
Content,
|
||||
Copyright,
|
||||
Footer,
|
||||
General,
|
||||
GlobalShortcutKeys,
|
||||
Header,
|
||||
Layout,
|
||||
Navigation,
|
||||
Radius,
|
||||
Sidebar,
|
||||
Tabbar,
|
||||
Theme,
|
||||
Widget,
|
||||
} from './blocks';
|
||||
|
||||
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
|
||||
|
||||
const message = globalShareState.getMessage();
|
||||
|
||||
const appLocale = defineModel<SupportedLanguagesType>('appLocale');
|
||||
const appDynamicTitle = defineModel<boolean>('appDynamicTitle');
|
||||
const appLayout = defineModel<LayoutType>('appLayout');
|
||||
const appColorGrayMode = defineModel<boolean>('appColorGrayMode');
|
||||
const appColorWeakMode = defineModel<boolean>('appColorWeakMode');
|
||||
const appContentCompact = defineModel<ContentCompactType>('appContentCompact');
|
||||
const appWatermark = defineModel<boolean>('appWatermark');
|
||||
const appEnableCheckUpdates = defineModel<boolean>('appEnableCheckUpdates');
|
||||
const appPreferencesButtonPosition = defineModel<PreferencesButtonPositionType>(
|
||||
'appPreferencesButtonPosition',
|
||||
);
|
||||
|
||||
const transitionProgress = defineModel<boolean>('transitionProgress');
|
||||
const transitionName = defineModel<string>('transitionName');
|
||||
const transitionLoading = defineModel<boolean>('transitionLoading');
|
||||
const transitionEnable = defineModel<boolean>('transitionEnable');
|
||||
|
||||
const themeColorPrimary = defineModel<string>('themeColorPrimary');
|
||||
const themeBuiltinType = defineModel<BuiltinThemeType>('themeBuiltinType');
|
||||
const themeMode = defineModel<ThemeModeType>('themeMode');
|
||||
const themeRadius = defineModel<string>('themeRadius');
|
||||
const themeSemiDarkSidebar = defineModel<boolean>('themeSemiDarkSidebar');
|
||||
const themeSemiDarkHeader = defineModel<boolean>('themeSemiDarkHeader');
|
||||
|
||||
const sidebarEnable = defineModel<boolean>('sidebarEnable');
|
||||
const sidebarWidth = defineModel<number>('sidebarWidth');
|
||||
const sidebarCollapsed = defineModel<boolean>('sidebarCollapsed');
|
||||
const sidebarCollapsedShowTitle = defineModel<boolean>(
|
||||
'sidebarCollapsedShowTitle',
|
||||
);
|
||||
const sidebarAutoActivateChild = defineModel<boolean>(
|
||||
'sidebarAutoActivateChild',
|
||||
);
|
||||
const sidebarExpandOnHover = defineModel<boolean>('sidebarExpandOnHover');
|
||||
const sidebarCollapsedButton = defineModel<boolean>('sidebarCollapsedButton');
|
||||
const sidebarFixedButton = defineModel<boolean>('sidebarFixedButton');
|
||||
const headerEnable = defineModel<boolean>('headerEnable');
|
||||
const headerMode = defineModel<LayoutHeaderModeType>('headerMode');
|
||||
const headerMenuAlign =
|
||||
defineModel<LayoutHeaderMenuAlignType>('headerMenuAlign');
|
||||
|
||||
const breadcrumbEnable = defineModel<boolean>('breadcrumbEnable');
|
||||
const breadcrumbShowIcon = defineModel<boolean>('breadcrumbShowIcon');
|
||||
const breadcrumbShowHome = defineModel<boolean>('breadcrumbShowHome');
|
||||
const breadcrumbStyleType = defineModel<BreadcrumbStyleType>(
|
||||
'breadcrumbStyleType',
|
||||
);
|
||||
const breadcrumbHideOnlyOne = defineModel<boolean>('breadcrumbHideOnlyOne');
|
||||
|
||||
const tabbarEnable = defineModel<boolean>('tabbarEnable');
|
||||
const tabbarShowIcon = defineModel<boolean>('tabbarShowIcon');
|
||||
const tabbarShowMore = defineModel<boolean>('tabbarShowMore');
|
||||
const tabbarShowMaximize = defineModel<boolean>('tabbarShowMaximize');
|
||||
const tabbarPersist = defineModel<boolean>('tabbarPersist');
|
||||
const tabbarDraggable = defineModel<boolean>('tabbarDraggable');
|
||||
const tabbarWheelable = defineModel<boolean>('tabbarWheelable');
|
||||
const tabbarStyleType = defineModel<string>('tabbarStyleType');
|
||||
const tabbarMaxCount = defineModel<number>('tabbarMaxCount');
|
||||
const tabbarMiddleClickToClose = defineModel<boolean>(
|
||||
'tabbarMiddleClickToClose',
|
||||
);
|
||||
|
||||
const navigationStyleType = defineModel<NavigationStyleType>(
|
||||
'navigationStyleType',
|
||||
);
|
||||
const navigationSplit = defineModel<boolean>('navigationSplit');
|
||||
const navigationAccordion = defineModel<boolean>('navigationAccordion');
|
||||
|
||||
// const logoVisible = defineModel<boolean>('logoVisible');
|
||||
|
||||
const footerEnable = defineModel<boolean>('footerEnable');
|
||||
const footerFixed = defineModel<boolean>('footerFixed');
|
||||
|
||||
const copyrightSettingShow = defineModel<boolean>('copyrightSettingShow');
|
||||
const copyrightEnable = defineModel<boolean>('copyrightEnable');
|
||||
const copyrightCompanyName = defineModel<string>('copyrightCompanyName');
|
||||
const copyrightCompanySiteLink = defineModel<string>(
|
||||
'copyrightCompanySiteLink',
|
||||
);
|
||||
const copyrightDate = defineModel<string>('copyrightDate');
|
||||
const copyrightIcp = defineModel<string>('copyrightIcp');
|
||||
const copyrightIcpLink = defineModel<string>('copyrightIcpLink');
|
||||
|
||||
const shortcutKeysEnable = defineModel<boolean>('shortcutKeysEnable');
|
||||
const shortcutKeysGlobalSearch = defineModel<boolean>(
|
||||
'shortcutKeysGlobalSearch',
|
||||
);
|
||||
const shortcutKeysGlobalLogout = defineModel<boolean>(
|
||||
'shortcutKeysGlobalLogout',
|
||||
);
|
||||
|
||||
const shortcutKeysGlobalLockScreen = defineModel<boolean>(
|
||||
'shortcutKeysGlobalLockScreen',
|
||||
);
|
||||
|
||||
const widgetGlobalSearch = defineModel<boolean>('widgetGlobalSearch');
|
||||
const widgetFullscreen = defineModel<boolean>('widgetFullscreen');
|
||||
const widgetLanguageToggle = defineModel<boolean>('widgetLanguageToggle');
|
||||
const widgetNotification = defineModel<boolean>('widgetNotification');
|
||||
const widgetThemeToggle = defineModel<boolean>('widgetThemeToggle');
|
||||
const widgetSidebarToggle = defineModel<boolean>('widgetSidebarToggle');
|
||||
const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
|
||||
const widgetRefresh = defineModel<boolean>('widgetRefresh');
|
||||
|
||||
const {
|
||||
diffPreference,
|
||||
isDark,
|
||||
isFullContent,
|
||||
isHeaderNav,
|
||||
isHeaderSidebarNav,
|
||||
isMixedNav,
|
||||
isSideMixedNav,
|
||||
isSideMode,
|
||||
isSideNav,
|
||||
} = usePreferences();
|
||||
const { copy } = useClipboard({ legacy: true });
|
||||
|
||||
const [Drawer] = useVbenDrawer();
|
||||
|
||||
const activeTab = ref('appearance');
|
||||
|
||||
const tabs = computed((): SegmentedItem[] => {
|
||||
return [
|
||||
{
|
||||
label: $t('preferences.appearance'),
|
||||
value: 'appearance',
|
||||
},
|
||||
{
|
||||
label: $t('preferences.layout'),
|
||||
value: 'layout',
|
||||
},
|
||||
{
|
||||
label: $t('preferences.shortcutKeys.title'),
|
||||
value: 'shortcutKey',
|
||||
},
|
||||
{
|
||||
label: $t('preferences.general'),
|
||||
value: 'general',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const showBreadcrumbConfig = computed(() => {
|
||||
return (
|
||||
!isFullContent.value &&
|
||||
!isMixedNav.value &&
|
||||
!isHeaderNav.value &&
|
||||
preferences.header.enable
|
||||
);
|
||||
});
|
||||
|
||||
async function handleCopy() {
|
||||
await copy(JSON.stringify(diffPreference.value, null, 2));
|
||||
|
||||
message.copyPreferencesSuccess?.(
|
||||
$t('preferences.copyPreferencesSuccessTitle'),
|
||||
$t('preferences.copyPreferencesSuccess'),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleClearCache() {
|
||||
resetPreferences();
|
||||
clearPreferencesCache();
|
||||
emit('clearPreferencesAndLogout');
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
if (!diffPreference.value) {
|
||||
return;
|
||||
}
|
||||
resetPreferences();
|
||||
await loadLocaleMessages(preferences.app.locale);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Drawer
|
||||
:description="$t('preferences.subtitle')"
|
||||
:title="$t('preferences.title')"
|
||||
class="sm:max-w-sm"
|
||||
>
|
||||
<template #extra>
|
||||
<div class="flex items-center">
|
||||
<VbenIconButton
|
||||
:disabled="!diffPreference"
|
||||
:tooltip="$t('preferences.resetTip')"
|
||||
class="relative"
|
||||
>
|
||||
<span
|
||||
v-if="diffPreference"
|
||||
class="bg-primary absolute right-0.5 top-0.5 h-2 w-2 rounded"
|
||||
></span>
|
||||
<RotateCw class="size-4" @click="handleReset" />
|
||||
</VbenIconButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="p-1">
|
||||
<VbenSegmented v-model="activeTab" :tabs="tabs">
|
||||
<template #general>
|
||||
<Block :title="$t('preferences.general')">
|
||||
<General
|
||||
v-model:app-dynamic-title="appDynamicTitle"
|
||||
v-model:app-enable-check-updates="appEnableCheckUpdates"
|
||||
v-model:app-locale="appLocale"
|
||||
v-model:app-watermark="appWatermark"
|
||||
/>
|
||||
</Block>
|
||||
|
||||
<Block :title="$t('preferences.animation.title')">
|
||||
<Animation
|
||||
v-model:transition-enable="transitionEnable"
|
||||
v-model:transition-loading="transitionLoading"
|
||||
v-model:transition-name="transitionName"
|
||||
v-model:transition-progress="transitionProgress"
|
||||
/>
|
||||
</Block>
|
||||
</template>
|
||||
<template #appearance>
|
||||
<Block :title="$t('preferences.theme.title')">
|
||||
<Theme
|
||||
v-model="themeMode"
|
||||
v-model:theme-semi-dark-header="themeSemiDarkHeader"
|
||||
v-model:theme-semi-dark-sidebar="themeSemiDarkSidebar"
|
||||
/>
|
||||
</Block>
|
||||
<Block :title="$t('preferences.theme.builtin.title')">
|
||||
<BuiltinTheme
|
||||
v-model="themeBuiltinType"
|
||||
v-model:theme-color-primary="themeColorPrimary"
|
||||
:is-dark="isDark"
|
||||
/>
|
||||
</Block>
|
||||
<Block :title="$t('preferences.theme.radius')">
|
||||
<Radius v-model="themeRadius" />
|
||||
</Block>
|
||||
<Block :title="$t('preferences.other')">
|
||||
<ColorMode
|
||||
v-model:app-color-gray-mode="appColorGrayMode"
|
||||
v-model:app-color-weak-mode="appColorWeakMode"
|
||||
/>
|
||||
</Block>
|
||||
</template>
|
||||
<template #layout>
|
||||
<Block :title="$t('preferences.layout')">
|
||||
<Layout v-model="appLayout" />
|
||||
</Block>
|
||||
<Block :title="$t('preferences.content')">
|
||||
<Content v-model="appContentCompact" />
|
||||
</Block>
|
||||
|
||||
<Block :title="$t('preferences.sidebar.title')">
|
||||
<Sidebar
|
||||
v-model:sidebar-auto-activate-child="sidebarAutoActivateChild"
|
||||
v-model:sidebar-collapsed="sidebarCollapsed"
|
||||
v-model:sidebar-collapsed-show-title="sidebarCollapsedShowTitle"
|
||||
v-model:sidebar-enable="sidebarEnable"
|
||||
v-model:sidebar-expand-on-hover="sidebarExpandOnHover"
|
||||
v-model:sidebar-width="sidebarWidth"
|
||||
v-model:sidebar-collapsed-button="sidebarCollapsedButton"
|
||||
v-model:sidebar-fixed-button="sidebarFixedButton"
|
||||
:current-layout="appLayout"
|
||||
:disabled="!isSideMode"
|
||||
/>
|
||||
</Block>
|
||||
|
||||
<Block :title="$t('preferences.header.title')">
|
||||
<Header
|
||||
v-model:header-enable="headerEnable"
|
||||
v-model:header-menu-align="headerMenuAlign"
|
||||
v-model:header-mode="headerMode"
|
||||
:disabled="isFullContent"
|
||||
/>
|
||||
</Block>
|
||||
|
||||
<Block :title="$t('preferences.navigationMenu.title')">
|
||||
<Navigation
|
||||
v-model:navigation-accordion="navigationAccordion"
|
||||
v-model:navigation-split="navigationSplit"
|
||||
v-model:navigation-style-type="navigationStyleType"
|
||||
:disabled="isFullContent"
|
||||
:disabled-navigation-split="!isMixedNav"
|
||||
/>
|
||||
</Block>
|
||||
|
||||
<Block :title="$t('preferences.breadcrumb.title')">
|
||||
<Breadcrumb
|
||||
v-model:breadcrumb-enable="breadcrumbEnable"
|
||||
v-model:breadcrumb-hide-only-one="breadcrumbHideOnlyOne"
|
||||
v-model:breadcrumb-show-home="breadcrumbShowHome"
|
||||
v-model:breadcrumb-show-icon="breadcrumbShowIcon"
|
||||
v-model:breadcrumb-style-type="breadcrumbStyleType"
|
||||
:disabled="
|
||||
!showBreadcrumbConfig ||
|
||||
!(isSideNav || isSideMixedNav || isHeaderSidebarNav)
|
||||
"
|
||||
/>
|
||||
</Block>
|
||||
<Block :title="$t('preferences.tabbar.title')">
|
||||
<Tabbar
|
||||
v-model:tabbar-draggable="tabbarDraggable"
|
||||
v-model:tabbar-enable="tabbarEnable"
|
||||
v-model:tabbar-persist="tabbarPersist"
|
||||
v-model:tabbar-show-icon="tabbarShowIcon"
|
||||
v-model:tabbar-show-maximize="tabbarShowMaximize"
|
||||
v-model:tabbar-show-more="tabbarShowMore"
|
||||
v-model:tabbar-style-type="tabbarStyleType"
|
||||
v-model:tabbar-wheelable="tabbarWheelable"
|
||||
v-model:tabbar-max-count="tabbarMaxCount"
|
||||
v-model:tabbar-middle-click-to-close="tabbarMiddleClickToClose"
|
||||
/>
|
||||
</Block>
|
||||
<Block :title="$t('preferences.widget.title')">
|
||||
<Widget
|
||||
v-model:app-preferences-button-position="
|
||||
appPreferencesButtonPosition
|
||||
"
|
||||
v-model:widget-fullscreen="widgetFullscreen"
|
||||
v-model:widget-global-search="widgetGlobalSearch"
|
||||
v-model:widget-language-toggle="widgetLanguageToggle"
|
||||
v-model:widget-lock-screen="widgetLockScreen"
|
||||
v-model:widget-notification="widgetNotification"
|
||||
v-model:widget-refresh="widgetRefresh"
|
||||
v-model:widget-sidebar-toggle="widgetSidebarToggle"
|
||||
v-model:widget-theme-toggle="widgetThemeToggle"
|
||||
/>
|
||||
</Block>
|
||||
<Block :title="$t('preferences.footer.title')">
|
||||
<Footer
|
||||
v-model:footer-enable="footerEnable"
|
||||
v-model:footer-fixed="footerFixed"
|
||||
/>
|
||||
</Block>
|
||||
<Block
|
||||
v-if="copyrightSettingShow"
|
||||
:title="$t('preferences.copyright.title')"
|
||||
>
|
||||
<Copyright
|
||||
v-model:copyright-company-name="copyrightCompanyName"
|
||||
v-model:copyright-company-site-link="copyrightCompanySiteLink"
|
||||
v-model:copyright-date="copyrightDate"
|
||||
v-model:copyright-enable="copyrightEnable"
|
||||
v-model:copyright-icp="copyrightIcp"
|
||||
v-model:copyright-icp-link="copyrightIcpLink"
|
||||
:disabled="!footerEnable"
|
||||
/>
|
||||
</Block>
|
||||
</template>
|
||||
|
||||
<template #shortcutKey>
|
||||
<Block :title="$t('preferences.shortcutKeys.global')">
|
||||
<GlobalShortcutKeys
|
||||
v-model:shortcut-keys-enable="shortcutKeysEnable"
|
||||
v-model:shortcut-keys-global-search="shortcutKeysGlobalSearch"
|
||||
v-model:shortcut-keys-lock-screen="shortcutKeysGlobalLockScreen"
|
||||
v-model:shortcut-keys-logout="shortcutKeysGlobalLogout"
|
||||
/>
|
||||
</Block>
|
||||
</template>
|
||||
</VbenSegmented>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<VbenButton
|
||||
:disabled="!diffPreference"
|
||||
class="mx-4 w-full"
|
||||
size="sm"
|
||||
variant="default"
|
||||
@click="handleCopy"
|
||||
>
|
||||
<Copy class="mr-2 size-3" />
|
||||
{{ $t('preferences.copyPreferences') }}
|
||||
</VbenButton>
|
||||
<VbenButton
|
||||
:disabled="!diffPreference"
|
||||
class="mr-4 w-full"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
@click="handleClearCache"
|
||||
>
|
||||
{{ $t('preferences.clearAndLogout') }}
|
||||
</VbenButton>
|
||||
</template>
|
||||
</Drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Settings } from '@vben/icons';
|
||||
import { $t, loadLocaleMessages } from '@vben/locales';
|
||||
import { preferences, updatePreferences } from '@vben/preferences';
|
||||
import { capitalizeFirstLetter } from '@vben/utils';
|
||||
|
||||
import { useVbenDrawer } from '@vben-core/popup-ui';
|
||||
import { VbenButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
import PreferencesDrawer from './preferences-drawer.vue';
|
||||
|
||||
const [Drawer, drawerApi] = useVbenDrawer({
|
||||
connectedComponent: PreferencesDrawer,
|
||||
});
|
||||
|
||||
/**
|
||||
* preferences 转成 vue props
|
||||
* preferences.widget.fullscreen=>widgetFullscreen
|
||||
*/
|
||||
const attrs = computed(() => {
|
||||
const result: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(preferences)) {
|
||||
for (const [subKey, subValue] of Object.entries(value)) {
|
||||
result[`${key}${capitalizeFirstLetter(subKey)}`] = subValue;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
/**
|
||||
* preferences 转成 vue listener
|
||||
* preferences.widget.fullscreen=>@update:widgetFullscreen
|
||||
*/
|
||||
const listen = computed(() => {
|
||||
const result: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(preferences)) {
|
||||
if (typeof value === 'object') {
|
||||
for (const subKey of Object.keys(value)) {
|
||||
result[`update:${key}${capitalizeFirstLetter(subKey)}`] = (
|
||||
val: any,
|
||||
) => {
|
||||
updatePreferences({ [key]: { [subKey]: val } });
|
||||
if (key === 'app' && subKey === 'locale') {
|
||||
loadLocaleMessages(val);
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<Drawer v-bind="{ ...$attrs, ...attrs }" v-on="listen" />
|
||||
|
||||
<div @click="() => drawerApi.open()">
|
||||
<slot>
|
||||
<VbenButton
|
||||
:title="$t('preferences.title')"
|
||||
class="bg-primary flex-col-center size-10 cursor-pointer rounded-l-lg rounded-r-none border-none"
|
||||
>
|
||||
<Settings class="size-5" />
|
||||
</VbenButton>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
const openPreferences = ref(false);
|
||||
|
||||
function useOpenPreferences() {
|
||||
function handleOpenPreference() {
|
||||
openPreferences.value = true;
|
||||
}
|
||||
|
||||
return {
|
||||
handleOpenPreference,
|
||||
openPreferences,
|
||||
};
|
||||
}
|
||||
|
||||
export { useOpenPreferences };
|
||||
@@ -0,0 +1 @@
|
||||
export { default as ThemeToggle } from './theme-toggle.vue';
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, nextTick } from 'vue';
|
||||
|
||||
import { VbenButton } from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 类型
|
||||
*/
|
||||
type?: 'icon' | 'normal';
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'ThemeToggleButton',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'normal',
|
||||
});
|
||||
|
||||
const isDark = defineModel<boolean>();
|
||||
|
||||
const theme = computed(() => {
|
||||
return isDark.value ? 'light' : 'dark';
|
||||
});
|
||||
|
||||
const bindProps = computed(() => {
|
||||
const type = props.type;
|
||||
|
||||
return type === 'normal'
|
||||
? {
|
||||
variant: 'heavy' as const,
|
||||
}
|
||||
: {
|
||||
class: 'rounded-full',
|
||||
size: 'icon' as const,
|
||||
style: { padding: '7px' },
|
||||
variant: 'icon' as const,
|
||||
};
|
||||
});
|
||||
|
||||
function toggleTheme(event: MouseEvent) {
|
||||
const isAppearanceTransition =
|
||||
// @ts-expect-error
|
||||
document.startViewTransition &&
|
||||
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (!isAppearanceTransition || !event) {
|
||||
isDark.value = !isDark.value;
|
||||
return;
|
||||
}
|
||||
const x = event.clientX;
|
||||
const y = event.clientY;
|
||||
const endRadius = Math.hypot(
|
||||
Math.max(x, innerWidth - x),
|
||||
Math.max(y, innerHeight - y),
|
||||
);
|
||||
// @ts-ignore startViewTransition
|
||||
const transition = document.startViewTransition(async () => {
|
||||
isDark.value = !isDark.value;
|
||||
await nextTick();
|
||||
});
|
||||
transition.ready.then(() => {
|
||||
const clipPath = [
|
||||
`circle(0px at ${x}px ${y}px)`,
|
||||
`circle(${endRadius}px at ${x}px ${y}px)`,
|
||||
];
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
|
||||
},
|
||||
{
|
||||
duration: 450,
|
||||
easing: 'ease-in',
|
||||
pseudoElement: isDark.value
|
||||
? '::view-transition-old(root)'
|
||||
: '::view-transition-new(root)',
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VbenButton
|
||||
:aria-label="theme"
|
||||
:class="[`is-${theme}`]"
|
||||
aria-live="polite"
|
||||
class="theme-toggle cursor-pointer border-none bg-none"
|
||||
v-bind="bindProps"
|
||||
@click.stop="toggleTheme"
|
||||
>
|
||||
<svg aria-hidden="true" height="24" viewBox="0 0 24 24" width="24">
|
||||
<mask
|
||||
id="theme-toggle-moon"
|
||||
class="theme-toggle__moon"
|
||||
fill="hsl(var(--foreground)/80%)"
|
||||
stroke="none"
|
||||
>
|
||||
<rect fill="white" height="100%" width="100%" x="0" y="0" />
|
||||
<circle cx="40" cy="8" fill="black" r="11" />
|
||||
</mask>
|
||||
<circle
|
||||
id="sun"
|
||||
class="theme-toggle__sun"
|
||||
cx="12"
|
||||
cy="12"
|
||||
mask="url(#theme-toggle-moon)"
|
||||
r="11"
|
||||
/>
|
||||
<g class="theme-toggle__sun-beams">
|
||||
<line x1="12" x2="12" y1="1" y2="3" />
|
||||
<line x1="12" x2="12" y1="21" y2="23" />
|
||||
<line x1="4.22" x2="5.64" y1="4.22" y2="5.64" />
|
||||
<line x1="18.36" x2="19.78" y1="18.36" y2="19.78" />
|
||||
<line x1="1" x2="3" y1="12" y2="12" />
|
||||
<line x1="21" x2="23" y1="12" y2="12" />
|
||||
<line x1="4.22" x2="5.64" y1="19.78" y2="18.36" />
|
||||
<line x1="18.36" x2="19.78" y1="5.64" y2="4.22" />
|
||||
</g>
|
||||
</svg>
|
||||
</VbenButton>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-toggle {
|
||||
&__moon {
|
||||
& > circle {
|
||||
transition: transform 0.5s cubic-bezier(0, 0, 0.3, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&__sun {
|
||||
@apply fill-foreground/90 stroke-none;
|
||||
|
||||
transition: transform 1.6s cubic-bezier(0.25, 0, 0.2, 1);
|
||||
transform-origin: center center;
|
||||
|
||||
&:hover > svg > & {
|
||||
@apply fill-foreground/90;
|
||||
}
|
||||
}
|
||||
|
||||
&__sun-beams {
|
||||
@apply stroke-foreground/90 stroke-[2px];
|
||||
|
||||
transition:
|
||||
transform 1.6s cubic-bezier(0.5, 1.5, 0.75, 1.25),
|
||||
opacity 0.6s cubic-bezier(0.25, 0, 0.3, 1);
|
||||
transform-origin: center center;
|
||||
|
||||
&:hover > svg > & {
|
||||
@apply stroke-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-light {
|
||||
.theme-toggle__sun {
|
||||
@apply scale-50;
|
||||
}
|
||||
|
||||
.theme-toggle__sun-beams {
|
||||
transform: rotateZ(0.25turn);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-dark {
|
||||
.theme-toggle__moon {
|
||||
& > circle {
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-toggle__sun-beams {
|
||||
@apply opacity-0;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover > svg {
|
||||
.theme-toggle__sun,
|
||||
.theme-toggle__moon {
|
||||
@apply fill-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ThemeModeType } from '@vben/types';
|
||||
|
||||
import { MoonStar, Sun, SunMoon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import {
|
||||
preferences,
|
||||
updatePreferences,
|
||||
usePreferences,
|
||||
} from '@vben/preferences';
|
||||
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
VbenTooltip,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
import ThemeButton from './theme-button.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'ThemeToggle',
|
||||
});
|
||||
|
||||
withDefaults(defineProps<{ shouldOnHover?: boolean }>(), {
|
||||
shouldOnHover: false,
|
||||
});
|
||||
|
||||
function handleChange(isDark: boolean) {
|
||||
updatePreferences({
|
||||
theme: { mode: isDark ? 'dark' : 'light' },
|
||||
});
|
||||
}
|
||||
|
||||
const { isDark } = usePreferences();
|
||||
|
||||
const PRESETS = [
|
||||
{
|
||||
icon: Sun,
|
||||
name: 'light',
|
||||
title: $t('preferences.theme.light'),
|
||||
},
|
||||
{
|
||||
icon: MoonStar,
|
||||
name: 'dark',
|
||||
title: $t('preferences.theme.dark'),
|
||||
},
|
||||
{
|
||||
icon: SunMoon,
|
||||
name: 'auto',
|
||||
title: $t('preferences.followSystem'),
|
||||
},
|
||||
];
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<VbenTooltip :disabled="!shouldOnHover" side="bottom">
|
||||
<template #trigger>
|
||||
<ThemeButton
|
||||
:model-value="isDark"
|
||||
type="icon"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</template>
|
||||
<ToggleGroup
|
||||
:model-value="preferences.theme.mode"
|
||||
class="gap-2"
|
||||
type="single"
|
||||
variant="outline"
|
||||
@update:model-value="
|
||||
(val) => updatePreferences({ theme: { mode: val as ThemeModeType } })
|
||||
"
|
||||
>
|
||||
<ToggleGroupItem
|
||||
v-for="item in PRESETS"
|
||||
:key="item.name"
|
||||
:value="item.name"
|
||||
>
|
||||
<component :is="item.icon" class="size-5" />
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</VbenTooltip>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as UserDropdown } from './user-dropdown.vue';
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import type { AnyFunction } from '@vben/types';
|
||||
|
||||
import { computed, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import { useHoverToggle } from '@vben/hooks';
|
||||
import { LockKeyhole, LogOut } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { useLockStore } from '@vben/stores';
|
||||
import { isWindowsOs } from '@vben/utils';
|
||||
|
||||
import { useVbenModal } from '@vben-core/popup-ui';
|
||||
import {
|
||||
Badge,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
VbenAvatar,
|
||||
VbenIcon,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
import { useMagicKeys, whenever } from '@vueuse/core';
|
||||
|
||||
import { LockScreenModal } from '../lock-screen';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* 头像
|
||||
*/
|
||||
avatar?: string;
|
||||
/**
|
||||
* @zh_CN 描述
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* 是否启用快捷键
|
||||
*/
|
||||
enableShortcutKey?: boolean;
|
||||
/**
|
||||
* 菜单数组
|
||||
*/
|
||||
menus?: Array<{ handler: AnyFunction; icon?: Component; text: string }>;
|
||||
|
||||
/**
|
||||
* 标签文本
|
||||
*/
|
||||
tagText?: string;
|
||||
/**
|
||||
* 文本
|
||||
*/
|
||||
text?: string;
|
||||
/** 触发方式 */
|
||||
trigger?: 'both' | 'click' | 'hover';
|
||||
/** hover触发时,延迟响应的时间 */
|
||||
hoverDelay?: number;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'UserDropdown',
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
avatar: '',
|
||||
description: '',
|
||||
enableShortcutKey: true,
|
||||
menus: () => [],
|
||||
showShortcutKey: true,
|
||||
tagText: '',
|
||||
text: '',
|
||||
trigger: 'click',
|
||||
hoverDelay: 500,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ logout: [] }>();
|
||||
|
||||
const { globalLockScreenShortcutKey, globalLogoutShortcutKey } =
|
||||
usePreferences();
|
||||
const lockStore = useLockStore();
|
||||
const [LockModal, lockModalApi] = useVbenModal({
|
||||
connectedComponent: LockScreenModal,
|
||||
});
|
||||
const [LogoutModal, logoutModalApi] = useVbenModal({
|
||||
onConfirm() {
|
||||
handleSubmitLogout();
|
||||
},
|
||||
});
|
||||
|
||||
const refTrigger = useTemplateRef('refTrigger');
|
||||
const refContent = useTemplateRef('refContent');
|
||||
const [openPopover, hoverWatcher] = useHoverToggle(
|
||||
[refTrigger, refContent],
|
||||
() => props.hoverDelay,
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.trigger === 'hover' || props.trigger === 'both',
|
||||
(val) => {
|
||||
if (val) {
|
||||
hoverWatcher.enable();
|
||||
} else {
|
||||
hoverWatcher.disable();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
},
|
||||
);
|
||||
|
||||
const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
|
||||
|
||||
const enableLogoutShortcutKey = computed(() => {
|
||||
return props.enableShortcutKey && globalLogoutShortcutKey.value;
|
||||
});
|
||||
|
||||
const enableLockScreenShortcutKey = computed(() => {
|
||||
return props.enableShortcutKey && globalLockScreenShortcutKey.value;
|
||||
});
|
||||
|
||||
const enableShortcutKey = computed(() => {
|
||||
return props.enableShortcutKey && preferences.shortcutKeys.enable;
|
||||
});
|
||||
|
||||
function handleOpenLock() {
|
||||
lockModalApi.open();
|
||||
}
|
||||
|
||||
function handleSubmitLock(lockScreenPassword: string) {
|
||||
lockModalApi.close();
|
||||
lockStore.lockScreen(lockScreenPassword);
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
// emit
|
||||
logoutModalApi.open();
|
||||
openPopover.value = false;
|
||||
}
|
||||
|
||||
function handleSubmitLogout() {
|
||||
emit('logout');
|
||||
logoutModalApi.close();
|
||||
}
|
||||
|
||||
if (enableShortcutKey.value) {
|
||||
const keys = useMagicKeys();
|
||||
whenever(keys['Alt+KeyQ']!, () => {
|
||||
if (enableLogoutShortcutKey.value) {
|
||||
handleLogout();
|
||||
}
|
||||
});
|
||||
|
||||
whenever(keys['Alt+KeyL']!, () => {
|
||||
if (enableLockScreenShortcutKey.value) {
|
||||
handleOpenLock();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LockModal
|
||||
v-if="preferences.widget.lockScreen"
|
||||
:avatar="avatar"
|
||||
:text="text"
|
||||
@submit="handleSubmitLock"
|
||||
/>
|
||||
|
||||
<LogoutModal
|
||||
:cancel-text="$t('common.cancel')"
|
||||
:confirm-text="$t('common.confirm')"
|
||||
:fullscreen-button="false"
|
||||
:title="$t('common.prompt')"
|
||||
centered
|
||||
content-class="px-8 min-h-10"
|
||||
footer-class="border-none mb-3 mr-3"
|
||||
header-class="border-none"
|
||||
>
|
||||
{{ $t('ui.widgets.logoutTip') }}
|
||||
</LogoutModal>
|
||||
|
||||
<DropdownMenu v-model:open="openPopover">
|
||||
<DropdownMenuTrigger ref="refTrigger" :disabled="props.trigger === 'hover'">
|
||||
<div class="hover:bg-accent ml-1 mr-2 cursor-pointer rounded-full p-1.5">
|
||||
<div class="hover:text-accent-foreground flex-center">
|
||||
<VbenAvatar :alt="text" :src="avatar" class="size-8" dot />
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="mr-2 min-w-[240px] p-0 pb-1">
|
||||
<div ref="refContent">
|
||||
<DropdownMenuLabel class="flex items-center p-3">
|
||||
<VbenAvatar
|
||||
:alt="text"
|
||||
:src="avatar"
|
||||
class="size-12"
|
||||
dot
|
||||
dot-class="bottom-0 right-1 border-2 size-4 bg-green-500"
|
||||
/>
|
||||
<div class="ml-2 w-full">
|
||||
<div
|
||||
v-if="tagText || text || $slots.tagText"
|
||||
class="text-foreground mb-1 flex items-center text-sm font-medium"
|
||||
>
|
||||
{{ text }}
|
||||
<slot name="tagText">
|
||||
<Badge v-if="tagText" class="ml-2 text-green-400">
|
||||
{{ tagText }}
|
||||
</Badge>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="text-muted-foreground text-xs font-normal">
|
||||
{{ description }}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator v-if="menus?.length" />
|
||||
<DropdownMenuItem
|
||||
v-for="menu in menus"
|
||||
:key="menu.text"
|
||||
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
|
||||
@click="menu.handler"
|
||||
>
|
||||
<VbenIcon :icon="menu.icon" class="mr-2 size-4" />
|
||||
{{ menu.text }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
v-if="preferences.widget.lockScreen"
|
||||
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
|
||||
@click="handleOpenLock"
|
||||
>
|
||||
<LockKeyhole class="mr-2 size-4" />
|
||||
{{ $t('ui.widgets.lockScreen.title') }}
|
||||
<DropdownMenuShortcut v-if="enableLockScreenShortcutKey">
|
||||
{{ altView }} L
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator v-if="preferences.widget.lockScreen" />
|
||||
<DropdownMenuItem
|
||||
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut class="mr-2 size-4" />
|
||||
{{ $t('common.logout') }}
|
||||
<DropdownMenuShortcut v-if="enableLogoutShortcutKey">
|
||||
{{ altView }} Q
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
6
packages/effects/layouts/tsconfig.json
Normal file
6
packages/effects/layouts/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@vben/tsconfig/web.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user