提交新版本
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.9",
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
196
packages/effects/layouts/src/authentication/authentication.vue
Normal file
196
packages/effects/layouts/src/authentication/authentication.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script setup lang="ts">
|
||||
import type { ToolbarType } from './types';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
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;
|
||||
logoDark?: string;
|
||||
pageTitle?: string;
|
||||
pageDescription?: string;
|
||||
sloganImage?: string;
|
||||
toolbar?: boolean;
|
||||
copyright?: boolean;
|
||||
toolbarList?: ToolbarType[];
|
||||
clickLogo?: () => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
appName: '',
|
||||
copyright: true,
|
||||
logo: '',
|
||||
logoDark: '',
|
||||
pageDescription: '',
|
||||
pageTitle: '',
|
||||
sloganImage: '',
|
||||
toolbar: true,
|
||||
toolbarList: () => ['color', 'language', 'layout', 'theme'],
|
||||
clickLogo: () => {},
|
||||
});
|
||||
|
||||
const { authPanelCenter, authPanelLeft, authPanelRight, isDark } =
|
||||
usePreferences();
|
||||
|
||||
/**
|
||||
* @zh_CN 根据主题选择合适的 logo 图标
|
||||
*/
|
||||
const logoSrc = computed(() => {
|
||||
// 如果是暗色主题且提供了 logoDark,则使用暗色主题的 logo
|
||||
if (isDark.value && props.logoDark) {
|
||||
return props.logoDark;
|
||||
}
|
||||
// 否则使用默认的 logo
|
||||
return props.logo;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[isDark ? 'dark' : '']"
|
||||
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"
|
||||
data-side="left"
|
||||
>
|
||||
<template v-if="copyright" #copyright>
|
||||
<slot name="copyright">
|
||||
<Copyright
|
||||
v-if="preferences.copyright.enable"
|
||||
v-bind="preferences.copyright"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
</AuthenticationFormView>
|
||||
|
||||
<slot name="logo">
|
||||
<!-- 头部 Logo 和应用名称 -->
|
||||
<div
|
||||
v-if="logoSrc || 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="logoSrc"
|
||||
:key="logoSrc"
|
||||
:alt="appName"
|
||||
:src="logoSrc"
|
||||
class="mr-2"
|
||||
width="42"
|
||||
/>
|
||||
<p v-if="appName" class="m-0 text-xl font-medium">
|
||||
{{ appName }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- 系统介绍 -->
|
||||
<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
|
||||
:key="authPanelLeft ? 'left' : authPanelRight ? 'right' : 'center'"
|
||||
class="flex-col-center mr-20 h-full"
|
||||
:class="{
|
||||
'enter-x': authPanelLeft,
|
||||
'-enter-x': authPanelRight,
|
||||
}"
|
||||
>
|
||||
<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%]"
|
||||
data-side="bottom"
|
||||
>
|
||||
<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-2/5 flex-1"
|
||||
data-side="right"
|
||||
>
|
||||
<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>
|
||||
38
packages/effects/layouts/src/authentication/form.vue
Normal file
38
packages/effects/layouts/src/authentication/form.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'AuthenticationFormView',
|
||||
});
|
||||
|
||||
defineProps<{
|
||||
dataSide?: 'bottom' | 'left' | 'right' | 'top';
|
||||
}>();
|
||||
</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="side-content mt-6 w-full sm:mx-auto md:max-w-md"
|
||||
:data-side="dataSide"
|
||||
/>
|
||||
</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>
|
||||
195
packages/effects/layouts/src/basic/header/header.vue
Normal file
195
packages/effects/layouts/src/basic/header/header.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<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,
|
||||
TimezoneButton,
|
||||
} 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.timezone) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 40,
|
||||
name: 'timezone',
|
||||
});
|
||||
}
|
||||
if (preferences.widget.fullscreen) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 50,
|
||||
name: 'fullscreen',
|
||||
});
|
||||
}
|
||||
if (preferences.widget.notification) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 60,
|
||||
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.toSorted((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.toSorted((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>
|
||||
<template v-else-if="slot.name === 'timezone'">
|
||||
<TimezoneButton class="mr-1 mt-[2px]" />
|
||||
</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>
|
||||
412
packages/effects/layouts/src/basic/layout.vue
Normal file
412
packages/effects/layouts/src/basic/layout.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SetupContext } from 'vue';
|
||||
import type { RouteLocationNormalizedLoaded } from 'vue-router';
|
||||
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
import { computed, onMounted, useSlots, watch } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { useRefresh } from '@vben/hooks';
|
||||
import { $t, i18n } from '@vben/locales';
|
||||
import {
|
||||
preferences,
|
||||
updatePreferences,
|
||||
usePreferences,
|
||||
} from '@vben/preferences';
|
||||
import { useAccessStore } 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 accessStore = useAccessStore();
|
||||
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');
|
||||
}
|
||||
|
||||
function autoCollapseMenuByRouteMeta(route: RouteLocationNormalizedLoaded) {
|
||||
// 只在双列模式下生效
|
||||
if (
|
||||
['header-mixed-nav', 'sidebar-mixed-nav'].includes(
|
||||
preferences.app.layout,
|
||||
) &&
|
||||
route.meta &&
|
||||
route.meta.hideInMenu
|
||||
) {
|
||||
sidebarExtraVisible.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
onMounted(() => {
|
||||
autoCollapseMenuByRouteMeta(route);
|
||||
});
|
||||
|
||||
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"
|
||||
:content-compact-width="preferences.app.contentCompactWidth"
|
||||
:content-padding="preferences.app.contentPadding"
|
||||
:content-padding-bottom="preferences.app.contentPaddingBottom"
|
||||
:content-padding-left="preferences.app.contentPaddingLeft"
|
||||
:content-padding-right="preferences.app.contentPaddingRight"
|
||||
:content-padding-top="preferences.app.contentPaddingTop"
|
||||
:footer-enable="preferences.footer.enable"
|
||||
:footer-fixed="preferences.footer.fixed"
|
||||
:footer-height="preferences.footer.height"
|
||||
:header-height="preferences.header.height"
|
||||
: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-extra-collapsed-width="preferences.sidebar.extraCollapsedWidth"
|
||||
:sidebar-hidden="preferences.sidebar.hidden"
|
||||
:sidebar-mixed-width="preferences.sidebar.mixedWidth"
|
||||
:sidebar-theme="sidebarTheme"
|
||||
:sidebar-width="preferences.sidebar.width"
|
||||
:side-collapse-width="preferences.sidebar.collapseWidth"
|
||||
:tabbar-enable="preferences.tabbar.enable"
|
||||
:tabbar-height="preferences.tabbar.height"
|
||||
:z-index="preferences.app.zIndex"
|
||||
@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"
|
||||
:fit="preferences.logo.fit"
|
||||
:class="logoClass"
|
||||
:collapsed="logoCollapsed"
|
||||
:src="preferences.logo.source"
|
||||
:src-dark="preferences.logo.sourceDark"
|
||||
: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 #timezone>
|
||||
<slot name="timezone"></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"
|
||||
:fit="preferences.logo.fit"
|
||||
:src="preferences.logo.source"
|
||||
:src-dark="preferences.logo.sourceDark"
|
||||
: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="accessStore.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>
|
||||
74
packages/effects/layouts/src/basic/menu/use-navigation.ts
Normal file
74
packages/effects/layouts/src/basic/menu/use-navigation.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { RouteRecordNormalized } from 'vue-router';
|
||||
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { isHttpUrl, openRouteInNewWindow, openWindow } from '@vben/utils';
|
||||
|
||||
function useNavigation() {
|
||||
const router = useRouter();
|
||||
const routeMetaMap = new Map<string, RouteRecordNormalized>();
|
||||
|
||||
// 初始化路由映射
|
||||
const initRouteMetaMap = () => {
|
||||
const routes = router.getRoutes();
|
||||
routes.forEach((route) => {
|
||||
routeMetaMap.set(route.path, route);
|
||||
});
|
||||
};
|
||||
|
||||
initRouteMetaMap();
|
||||
|
||||
// 监听路由变化
|
||||
router.afterEach(() => {
|
||||
initRouteMetaMap();
|
||||
});
|
||||
|
||||
// 检查是否应该在新窗口打开
|
||||
const shouldOpenInNewWindow = (path: string): boolean => {
|
||||
if (isHttpUrl(path)) {
|
||||
return true;
|
||||
}
|
||||
const route = routeMetaMap.get(path);
|
||||
// 如果有外链或者设置了在新窗口打开,返回 true
|
||||
return !!(route?.meta?.link || route?.meta?.openInNewWindow);
|
||||
};
|
||||
|
||||
const resolveHref = (path: string): string => {
|
||||
return router.resolve(path).href;
|
||||
};
|
||||
|
||||
const navigation = async (path: string) => {
|
||||
try {
|
||||
const route = routeMetaMap.get(path);
|
||||
const { openInNewWindow = false, query = {}, link } = route?.meta ?? {};
|
||||
|
||||
// 检查是否有外链
|
||||
if (link && typeof link === 'string') {
|
||||
openWindow(link, { target: '_blank' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isHttpUrl(path)) {
|
||||
openWindow(path, { target: '_blank' });
|
||||
} else if (openInNewWindow) {
|
||||
openRouteInNewWindow(resolveHref(path));
|
||||
} else {
|
||||
await router.push({
|
||||
path,
|
||||
query,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Navigation failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const willOpenedByWindow = (path: string) => {
|
||||
return shouldOpenInNewWindow(path);
|
||||
};
|
||||
|
||||
return { navigation, willOpenedByWindow };
|
||||
}
|
||||
|
||||
export { useNavigation };
|
||||
@@ -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 = uniqueByField([...searchHistory.value, to], 'path');
|
||||
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>
|
||||
14
packages/effects/layouts/src/widgets/index.ts
Normal file
14
packages/effects/layouts/src/widgets/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { default as Breadcrumb } from './breadcrumb.vue';
|
||||
export * from './check-updates';
|
||||
export { default as AuthenticationColorToggle } from './color-toggle.vue';
|
||||
export * from './global-search';
|
||||
export * from './help';
|
||||
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 './tenant-dropdown';
|
||||
export * from './theme-toggle';
|
||||
export * from './timezone';
|
||||
export * from './user-dropdown';
|
||||
39
packages/effects/layouts/src/widgets/language-toggle.vue
Normal file
39
packages/effects/layouts/src/widgets/language-toggle.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<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 | undefined) {
|
||||
if (!value) return;
|
||||
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 class="hover:animate-[shrink_0.3s_ease-in-out]">
|
||||
<Languages class="text-foreground size-4" />
|
||||
</VbenIconButton>
|
||||
</VbenDropdownRadioMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<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, getFieldComponentRef }] =
|
||||
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();
|
||||
}
|
||||
},
|
||||
onOpened() {
|
||||
requestAnimationFrame(() => {
|
||||
getFieldComponentRef('lockScreenPassword')
|
||||
?.$el?.querySelector('[name="lockScreenPassword"]')
|
||||
?.focus();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
167
packages/effects/layouts/src/widgets/lock-screen/lock-screen.vue
Normal file
167
packages/effects/layouts/src/widgets/lock-screen/lock-screen.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { LockKeyhole } from '@vben/icons';
|
||||
import { $t, useI18n } from '@vben/locales';
|
||||
import { storeToRefs, useAccessStore } 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 accessStore = useAccessStore();
|
||||
|
||||
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(accessStore);
|
||||
|
||||
const [Form, { form, validate, getFieldComponentRef }] = 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) {
|
||||
accessStore.unlockScreen();
|
||||
} else {
|
||||
form.setFieldError('password', $t('authentication.passwordErrorTip'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleUnlockForm() {
|
||||
showUnlockForm.value = !showUnlockForm.value;
|
||||
if (showUnlockForm.value) {
|
||||
requestAnimationFrame(() => {
|
||||
getFieldComponentRef('password')
|
||||
?.$el?.querySelector('[name="password"]')
|
||||
?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 fixed left-1/2 top-6 z-[2001] -translate-x-1/2 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 w-full items-center justify-center">
|
||||
<div class="flex w-full justify-center gap-4 px-4 sm:gap-6 md:gap-8">
|
||||
<div
|
||||
class="bg-accent relative flex h-[140px] w-[140px] items-center justify-center rounded-xl text-[36px] sm:h-[160px] sm:w-[160px] sm:text-[42px] md:h-[200px] md:w-[200px] md:text-[72px]"
|
||||
>
|
||||
<span
|
||||
class="absolute left-3 top-3 text-xs font-semibold sm:text-sm md:text-xl"
|
||||
>
|
||||
{{ meridiem }}
|
||||
</span>
|
||||
{{ hour }}
|
||||
</div>
|
||||
<div
|
||||
class="bg-accent flex h-[140px] w-[140px] items-center justify-center rounded-xl text-[36px] sm:h-[160px] sm:w-[160px] sm:text-[42px] md:h-[200px] md:w-[200px] md:text-[72px]"
|
||||
>
|
||||
{{ minute }}
|
||||
</div>
|
||||
</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-[90%] max-w-[300px] px-4">
|
||||
<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 text-xl md:text-2xl xl:text-xl 2xl:text-3xl"
|
||||
>
|
||||
<div v-if="showUnlockForm" class="enter-x mb-2 text-2xl md:text-3xl">
|
||||
{{ hour }}:{{ minute }}
|
||||
<span class="text-base md:text-lg">{{ meridiem }}</span>
|
||||
</div>
|
||||
<div class="text-xl md:text-3xl">{{ date }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationItem } from './types';
|
||||
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Bell, CircleCheckBig, CircleX, 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: [];
|
||||
open: [boolean];
|
||||
read: [NotificationItem];
|
||||
remove: [NotificationItem];
|
||||
viewAll: [];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
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) {
|
||||
// 如果通知项有链接,点击时跳转
|
||||
if (item.link) {
|
||||
navigateTo(item.link, item.query, item.state);
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(
|
||||
link: string,
|
||||
query?: Record<string, any>,
|
||||
state?: Record<string, any>,
|
||||
) {
|
||||
if (link.startsWith('http://') || link.startsWith('https://')) {
|
||||
// 外部链接,在新标签页打开
|
||||
window.open(link, '_blank');
|
||||
} else {
|
||||
// 内部路由链接,支持 query 参数和 state
|
||||
router.push({
|
||||
path: link,
|
||||
query: query || {},
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
toggle();
|
||||
emit('open', open.value);
|
||||
}
|
||||
</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="handleOpen">
|
||||
<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.id ?? 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"
|
||||
/>
|
||||
</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>
|
||||
<div
|
||||
class="absolute right-3 top-1/2 flex -translate-y-1/2 flex-col gap-2"
|
||||
>
|
||||
<VbenIconButton
|
||||
v-if="!item.isRead"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="h-6 px-2"
|
||||
:tooltip="$t('common.confirm')"
|
||||
@click.stop="emit('read', item)"
|
||||
>
|
||||
<CircleCheckBig class="size-4" />
|
||||
</VbenIconButton>
|
||||
<VbenIconButton
|
||||
v-if="item.isRead"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="text-destructive h-6 px-2"
|
||||
:tooltip="$t('common.delete')"
|
||||
@click.stop="emit('remove', item)"
|
||||
>
|
||||
<CircleX class="size-4" />
|
||||
</VbenIconButton>
|
||||
</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>
|
||||
17
packages/effects/layouts/src/widgets/notification/types.ts
Normal file
17
packages/effects/layouts/src/widgets/notification/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
interface NotificationItem {
|
||||
id: any;
|
||||
avatar: string;
|
||||
date: string;
|
||||
isRead?: boolean;
|
||||
message: string;
|
||||
title: string;
|
||||
/**
|
||||
* 跳转链接,可以是路由路径或完整 URL
|
||||
* @example '/dashboard' 或 'https://example.com'
|
||||
*/
|
||||
link?: string;
|
||||
query?: Record<string, any>;
|
||||
state?: Record<string, any>;
|
||||
}
|
||||
|
||||
export type { NotificationItem };
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { SUPPORT_LANGUAGES } from '@vben/constants';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import InputItem from '../input-item.vue';
|
||||
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 appWatermarkContent = defineModel<string>('appWatermarkContent');
|
||||
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"
|
||||
@update:model-value="
|
||||
(val) => {
|
||||
if (!val) appWatermarkContent = '';
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ $t('preferences.watermark') }}
|
||||
</SwitchItem>
|
||||
<InputItem
|
||||
v-if="appWatermark"
|
||||
v-model="appWatermarkContent"
|
||||
:placeholder="$t('preferences.watermarkContent')"
|
||||
>
|
||||
{{ $t('preferences.watermarkContent') }}
|
||||
</InputItem>
|
||||
<SwitchItem v-model="appEnableCheckUpdates">
|
||||
{{ $t('preferences.checkUpdates') }}
|
||||
</SwitchItem>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectOption } from '@vben/types';
|
||||
|
||||
import { useSlots } from 'vue';
|
||||
|
||||
import { CircleHelp, CircleX } 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>
|
||||
<div class="relative">
|
||||
<Input
|
||||
v-model="inputValue"
|
||||
class="h-8 w-[165px]"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
<CircleX
|
||||
v-if="inputValue"
|
||||
class="hover:text-foreground text-foreground/60 absolute right-2 top-1/2 size-3 -translate-y-1/2 transform cursor-pointer"
|
||||
@click="() => (inputValue = '')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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" @click.stop />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,163 @@
|
||||
<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], [_, isDarkPrev]) => {
|
||||
const theme = builtinThemePresets.value.find(
|
||||
(item) => item.type === themeType,
|
||||
);
|
||||
if (theme) {
|
||||
const primaryColor = isDark
|
||||
? theme.darkPrimaryColor || theme.primaryColor
|
||||
: theme.primaryColor;
|
||||
|
||||
if (!(theme.type === 'custom' && isDark !== isDarkPrev)) {
|
||||
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-9 my-2 size-5 rounded-md"
|
||||
></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="size-full px-9 py-2" @click.stop="selectColor">
|
||||
<div class="flex-center relative size-5 rounded-sm">
|
||||
<UserRoundPen
|
||||
class="z-1 absolute 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,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 class="hover:animate-[shrink_0.3s_ease-in-out]">
|
||||
<Settings class="text-foreground size-4" />
|
||||
</VbenIconButton>
|
||||
</Preferences>
|
||||
</template>
|
||||
@@ -0,0 +1,488 @@
|
||||
<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, Pin, PinOff, 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 appWatermarkContent = defineModel<string>('appWatermarkContent');
|
||||
const appEnableCheckUpdates = defineModel<boolean>('appEnableCheckUpdates');
|
||||
const appEnableStickyPreferencesNavigationBar = defineModel<boolean>(
|
||||
'appEnableStickyPreferencesNavigationBar',
|
||||
);
|
||||
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="!border-0 sm:max-w-sm"
|
||||
>
|
||||
<template #extra>
|
||||
<div class="flex items-center">
|
||||
<VbenIconButton
|
||||
:disabled="!diffPreference"
|
||||
:tooltip="$t('preferences.resetTip')"
|
||||
class="relative"
|
||||
@click="handleReset"
|
||||
>
|
||||
<span
|
||||
v-if="diffPreference"
|
||||
class="bg-primary absolute right-0.5 top-0.5 h-2 w-2 rounded"
|
||||
></span>
|
||||
<RotateCw class="size-4" />
|
||||
</VbenIconButton>
|
||||
<VbenIconButton
|
||||
:tooltip="
|
||||
appEnableStickyPreferencesNavigationBar
|
||||
? $t('preferences.disableStickyPreferencesNavigationBar')
|
||||
: $t('preferences.enableStickyPreferencesNavigationBar')
|
||||
"
|
||||
class="relative"
|
||||
@click="
|
||||
() =>
|
||||
(appEnableStickyPreferencesNavigationBar =
|
||||
!appEnableStickyPreferencesNavigationBar)
|
||||
"
|
||||
>
|
||||
<PinOff
|
||||
v-if="appEnableStickyPreferencesNavigationBar"
|
||||
class="size-4"
|
||||
/>
|
||||
<Pin v-else class="size-4" />
|
||||
</VbenIconButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<VbenSegmented
|
||||
v-model="activeTab"
|
||||
:tabs="tabs"
|
||||
:class="{
|
||||
'sticky-tabs-header': appEnableStickyPreferencesNavigationBar,
|
||||
}"
|
||||
>
|
||||
<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"
|
||||
v-model:app-watermark-content="appWatermarkContent"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
:deep(.sticky-tabs-header [role='tablist']) {
|
||||
position: sticky;
|
||||
top: -12px;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
interface Tenant {
|
||||
id?: number;
|
||||
name: string;
|
||||
packageId: number;
|
||||
contactName: string;
|
||||
contactMobile: string;
|
||||
accountCount: number;
|
||||
expireTime: Date;
|
||||
websites: string[];
|
||||
status: number;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: 'TenantDropdown',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
tenantList?: Tenant[];
|
||||
visitTenantId?: null | number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
// 租户列表
|
||||
const tenants = computed(() => props.tenantList ?? []);
|
||||
|
||||
async function handleChange(id: number | undefined) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const tenant = tenants.value.find((item) => item.id === id);
|
||||
|
||||
emit('success', tenant);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
class="hover:bg-accent ml-1 mr-2 h-8 w-32 cursor-pointer rounded-full p-1.5"
|
||||
>
|
||||
<IconifyIcon icon="lucide:align-justify" class="mr-4" />
|
||||
{{
|
||||
tenants.find((item) => item.id === visitTenantId)?.name ||
|
||||
$t('page.tenant.placeholder')
|
||||
}}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent class="w-40 p-0 pb-1">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
v-for="tenant in tenants"
|
||||
:key="tenant.id"
|
||||
:disabled="tenant.id === visitTenantId"
|
||||
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
|
||||
@click="handleChange(tenant.id)"
|
||||
>
|
||||
<template v-if="tenant.id === visitTenantId">
|
||||
<IconifyIcon icon="lucide:check" class="mr-2" />
|
||||
{{ tenant.name }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ tenant.name }}
|
||||
</template>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</template>
|
||||
@@ -0,0 +1,188 @@
|
||||
<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)`,
|
||||
];
|
||||
const animate = document.documentElement.animate(
|
||||
{
|
||||
clipPath: isDark.value ? [...clipPath].toReversed() : clipPath,
|
||||
},
|
||||
{
|
||||
duration: 450,
|
||||
easing: 'ease-in',
|
||||
pseudoElement: isDark.value
|
||||
? '::view-transition-old(root)'
|
||||
: '::view-transition-new(root)',
|
||||
},
|
||||
);
|
||||
animate.onfinish = () => {
|
||||
transition.skipTransition();
|
||||
};
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VbenButton
|
||||
:aria-label="theme"
|
||||
:class="[`is-${theme}`]"
|
||||
aria-live="polite"
|
||||
class="theme-toggle cursor-pointer border-none bg-none hover:animate-[shrink_0.3s_ease-in-out]"
|
||||
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;
|
||||
|
||||
transform-origin: center center;
|
||||
transition: transform 1.6s cubic-bezier(0.25, 0, 0.2, 1);
|
||||
|
||||
&:hover > svg > & {
|
||||
@apply fill-foreground/90;
|
||||
}
|
||||
}
|
||||
|
||||
&__sun-beams {
|
||||
@apply stroke-foreground/90 stroke-[2px];
|
||||
|
||||
transform-origin: center center;
|
||||
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);
|
||||
|
||||
&: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>
|
||||
1
packages/effects/layouts/src/widgets/timezone/index.ts
Normal file
1
packages/effects/layouts/src/widgets/timezone/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as TimezoneButton } from './timezone-button.vue';
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, unref } from 'vue';
|
||||
|
||||
import { createIconifyIcon } from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
import { useTimezoneStore } from '@vben/stores';
|
||||
|
||||
import { useVbenModal } from '@vben-core/popup-ui';
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
VbenIconButton,
|
||||
} from '@vben-core/shadcn-ui';
|
||||
|
||||
const TimezoneIcon = createIconifyIcon('fluent-mdl2:world-clock');
|
||||
|
||||
const timezoneStore = useTimezoneStore();
|
||||
|
||||
const timezoneRef = ref<string | undefined>();
|
||||
|
||||
const timezoneOptionsRef = ref<
|
||||
{
|
||||
label: string;
|
||||
value: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
const timezone = unref(timezoneRef);
|
||||
if (timezone) {
|
||||
await timezoneStore.setTimezone(timezone);
|
||||
}
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.setState({ confirmLoading: false });
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
timezoneRef.value = unref(timezoneStore.timezone);
|
||||
timezoneOptionsRef.value = await timezoneStore.getTimezoneOptions();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
modalApi.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VbenIconButton
|
||||
:tooltip="$t('ui.widgets.timezone.setTimezone')"
|
||||
class="hover:animate-[shrink_0.3s_ease-in-out]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<TimezoneIcon class="text-foreground size-4" />
|
||||
</VbenIconButton>
|
||||
<Modal :title="$t('ui.widgets.timezone.setTimezone')">
|
||||
<div class="timezone-container">
|
||||
<RadioGroup v-model="timezoneRef" class="flex flex-col gap-2">
|
||||
<div
|
||||
class="flex cursor-pointer items-center gap-2"
|
||||
v-for="item in timezoneOptionsRef"
|
||||
:key="`container${item.value}`"
|
||||
>
|
||||
<RadioGroupItem :id="item.value" :value="item.value" />
|
||||
<label :for="item.value" class="cursor-pointer">{{
|
||||
item.label
|
||||
}}</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timezone-container {
|
||||
padding-left: 20px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user