refactor(project): business changed its name to effects, and universal-ui changed its name to common-ui

This commit is contained in:
vben
2024-07-13 17:25:15 +08:00
parent 5e0b01c725
commit 7eff46d80c
186 changed files with 110 additions and 107 deletions

View File

@@ -0,0 +1,21 @@
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
clean: true,
declaration: true,
entries: [
{
builder: 'mkdist',
input: './src',
loaders: ['vue'],
pattern: ['**/*.vue'],
},
{
builder: 'mkdist',
format: 'esm',
input: './src',
loaders: ['js'],
pattern: ['**/*.ts'],
},
],
});

View File

@@ -0,0 +1,55 @@
{
"name": "@vben/layouts",
"version": "5.0.0",
"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",
"scripts": {
"build": "pnpm unbuild",
"prepublishOnly": "npm run build"
},
"files": [
"dist"
],
"sideEffects": [
"**/*.css"
],
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./dist/index.mjs"
}
},
"publishConfig": {
"exports": {
".": {
"default": "./dist/index.mjs"
}
}
},
"dependencies": {
"@vben-core/helpers": "workspace:*",
"@vben-core/iconify": "workspace:*",
"@vben-core/layout-ui": "workspace:*",
"@vben-core/locales": "workspace:*",
"@vben-core/menu-ui": "workspace:*",
"@vben-core/preferences": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/stores": "workspace:*",
"@vben-core/tabs-ui": "workspace:*",
"@vben-core/toolkit": "workspace:*",
"@vben-core/typings": "workspace:*",
"@vueuse/core": "^10.11.0",
"vue": "^3.4.31",
"vue-router": "^4.4.0"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config/postcss';

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@vben-core/locales';
import { preferences, usePreferences } from '@vben-core/preferences';
import AuthenticationFromView from './from-view.vue';
import SloganIcon from './icons/slogan.vue';
import Toolbar from './toolbar.vue';
defineOptions({
name: 'Authentication',
});
const { authPanelCenter, authPanelLeft, authPanelRight } = usePreferences();
const appName = computed(() => preferences.app.name);
</script>
<template>
<div class="flex min-h-full flex-1 select-none overflow-x-hidden">
<AuthenticationFromView
v-if="authPanelLeft"
class="-enter-x min-h-full w-2/5"
transition-name="slide-left"
/>
<div class="absolute left-0 top-0 z-10 flex flex-1">
<div
:class="
authPanelLeft || authPanelCenter
? 'lg:text-foreground'
: 'lg:text-white'
"
class="text-foreground ml-4 mt-4 flex flex-1 items-center sm:left-6 sm:top-6"
>
<img
:alt="appName"
:src="preferences.logo.source"
:width="42"
class="mr-2"
/>
<p class="text-xl font-medium">
{{ appName }}
</p>
</div>
</div>
<div v-if="!authPanelCenter" class="relative hidden w-0 flex-1 lg:block">
<div class="bg-authentication absolute inset-0 h-full w-full">
<div class="flex-col-center -enter-x mr-20 h-full">
<SloganIcon :alt="appName" class="animate-float h-64 w-2/5" />
<div class="text-1xl mt-6 font-sans text-white lg:text-2xl">
{{ $t('authentication.pageTitle') }}
</div>
<div class="dark:text-muted-foreground mt-2 text-white/60">
{{ $t('authentication.pageDesc') }}
</div>
</div>
</div>
</div>
<div v-if="authPanelCenter" class="flex-center bg-authentication w-full">
<AuthenticationFromView
class="enter-y md:bg-background w-full rounded-3xl pb-20 shadow-2xl md:w-2/3 lg:w-1/2 xl:w-2/5"
>
<template #toolbar>
<Toolbar class="bg-muted" />
</template>
</AuthenticationFromView>
</div>
<AuthenticationFromView
v-if="authPanelRight"
class="enter-x min-h-full w-2/5 flex-1"
/>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { preferences } from '@vben-core/preferences';
import { Copyright } from '../basic/copyright';
import Toolbar from './toolbar.vue';
defineOptions({
name: 'AuthenticationFormView',
});
</script>
<template>
<div class="flex-col-center relative px-6 py-10 lg:flex-initial lg:px-8">
<slot name="toolbar">
<Toolbar />
</slot>
<RouterView v-slot="{ Component, route }">
<Transition appear mode="out-in" name="slide-right">
<KeepAlive :include="['Login']">
<component
:is="Component"
:key="route.fullPath"
class="mt-6 w-full sm:mx-auto md:max-w-md"
/>
</KeepAlive>
</Transition>
</RouterView>
<div
class="text-muted-foreground absolute bottom-3 flex text-center text-xs"
>
<Copyright
v-if="preferences.copyright.enable"
v-bind="preferences.copyright"
/>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export { default as AuthPageLayout } from './authentication.vue';

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import {
AuthenticationColorToggle,
AuthenticationLayoutToggle,
LanguageToggle,
ThemeToggle,
} from '../widgets';
defineOptions({
name: 'AuthenticationToolbar',
});
</script>
<template>
<div
class="flex-center bg-accent absolute right-2 top-4 rounded-3xl px-3 py-1"
>
<div class="hidden md:flex">
<AuthenticationColorToggle />
<AuthenticationLayoutToggle />
</div>
<LanguageToggle />
<ThemeToggle />
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script lang="ts" setup>
import type { RouteLocationNormalizedLoaded } from 'vue-router';
import { preferences, usePreferences } from '@vben-core/preferences';
import { Spinner } from '@vben-core/shadcn-ui';
import { storeToRefs, useCoreTabbarStore } from '@vben-core/stores';
import { IFrameRouterView } from '../../iframe';
import { useContentSpinner } from './use-content-spinner';
defineOptions({ name: 'LayoutContent' });
const tabbarStore = useCoreTabbarStore();
const { keepAlive } = usePreferences();
const { spinning } = useContentSpinner();
const { getCachedTabs, getExcludeCachedTabs, renderRouteView } =
storeToRefs(tabbarStore);
// 页面切换动画
function getTransitionName(route: RouteLocationNormalizedLoaded) {
// 如果偏好设置未设置,则不使用动画
const { tabbar, transition } = preferences;
const transitionName = transition.name;
if (!transitionName || !transition.enable) {
return;
}
// 标签页未启用或者未开启缓存,则使用全局配置动画
if (!tabbar.enable || !keepAlive) {
return transitionName;
}
// 如果页面已经加载过,则不使用动画
// if (route.meta.loaded) {
// return;
// }
// 已经打开且已经加载过的页面不使用动画
const inTabs = getCachedTabs.value.includes(route.name as string);
return inTabs && route.meta.loaded ? undefined : transitionName;
}
</script>
<template>
<div class="relative h-full">
<Spinner
v-if="preferences.transition.loading"
:spinning="spinning"
class="h-[var(--vben-content-client-height)]"
/>
<IFrameRouterView />
<RouterView v-slot="{ Component, route }">
<Transition :name="getTransitionName(route)" appear mode="out-in">
<KeepAlive
v-if="keepAlive"
:exclude="getExcludeCachedTabs"
:include="getCachedTabs"
>
<component
:is="Component"
v-if="renderRouteView"
v-show="!route.meta.iframeSrc"
:key="route.fullPath"
/>
</KeepAlive>
<component
:is="Component"
v-else-if="renderRouteView"
:key="route.fullPath"
/>
</Transition>
</RouterView>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as LayoutContent } from './content.vue';

View File

@@ -0,0 +1,50 @@
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { preferences } from '@vben-core/preferences';
function useContentSpinner() {
const spinning = ref(false);
const startTime = ref(0);
const router = useRouter();
const minShowTime = 500;
const enableLoading = computed(() => preferences.transition.loading);
const onEnd = () => {
if (!enableLoading.value) {
return;
}
const processTime = performance.now() - startTime.value;
if (processTime < minShowTime) {
setTimeout(() => {
spinning.value = false;
}, minShowTime - processTime);
} else {
spinning.value = false;
}
};
router.beforeEach((to) => {
if (to.meta.loaded || !enableLoading.value || to.meta.iframeSrc) {
return true;
}
startTime.value = performance.now();
spinning.value = true;
return true;
});
router.afterEach((to) => {
if (to.meta.loaded || !enableLoading.value || to.meta.iframeSrc) {
return true;
}
// 关闭加载动画
onEnd();
return true;
});
return { spinning };
}
export { useContentSpinner };

View File

@@ -0,0 +1,44 @@
<script lang="ts" setup>
interface Props {
companyName: string;
companySiteLink?: string;
date: string;
icp?: string;
icpLink?: string;
}
defineOptions({
name: 'Copyright',
});
withDefaults(defineProps<Props>(), {
companyName: 'Vben Admin Pro',
companySiteLink: '',
date: '2024',
icp: '',
icpLink: '',
});
</script>
<template>
<div class="text-md flex-center">
<a
v-if="icp"
:href="icpLink || 'javascript:void 0'"
class="hover:text-primary-hover"
target="_blank"
>
{{ icp }}
</a>
Copyright © {{ date }}
<a
v-if="companyName"
:href="companySiteLink || 'javascript:void 0'"
class="hover:text-primary-hover mx-1"
target="_blank"
>
{{ companyName }}
</a>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as Copyright } from './copyright.vue';

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
defineOptions({
name: 'LayoutFooter',
});
</script>
<template>
<div class="flex-center text-muted-foreground relative h-full w-full text-xs">
<slot></slot>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as LayoutFooter } from './footer.vue';

View File

@@ -0,0 +1,47 @@
<script lang="ts" setup>
import { preferences, usePreferences } from '@vben-core/preferences';
import { VbenFullScreen } from '@vben-core/shadcn-ui';
import { useCoreAccessStore } from '@vben-core/stores';
import { GlobalSearch, LanguageToggle, ThemeToggle } from '../../widgets';
interface Props {
/**
* Logo 主题
*/
theme?: string;
}
defineOptions({
name: 'LayoutHeader',
});
withDefaults(defineProps<Props>(), {
theme: 'light',
});
const accessStore = useCoreAccessStore();
const { globalSearchShortcutKey } = usePreferences();
</script>
<template>
<div class="flex-center hidden lg:block">
<slot name="breadcrumb"></slot>
</div>
<div 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">
<GlobalSearch
v-if="preferences.widget.globalSearch"
:enable-shortcut-key="globalSearchShortcutKey"
:menus="accessStore.accessMenus"
class="mr-4"
/>
<ThemeToggle v-if="preferences.widget.themeToggle" class="mr-2" />
<LanguageToggle v-if="preferences.widget.languageToggle" class="mr-2" />
<VbenFullScreen v-if="preferences.widget.fullscreen" class="mr-2" />
<slot v-if="preferences.widget.notification" name="notification"></slot>
<slot name="user-dropdown"></slot>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as LayoutHeader } from './header.vue';

View File

@@ -0,0 +1 @@
export { default as BasicLayout } from './layout.vue';

View File

@@ -0,0 +1,288 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { VbenAdminLayout } from '@vben-core/layout-ui';
import { $t } from '@vben-core/locales';
import {
preferences,
updatePreferences,
usePreferences,
} from '@vben-core/preferences';
import { VbenBackTop, VbenLogo } from '@vben-core/shadcn-ui';
import { mapTree } from '@vben-core/toolkit';
import { MenuRecordRaw } from '@vben-core/typings';
import { Breadcrumb, CozeAssistant, Preferences } from '../widgets';
import { LayoutContent } from './content';
import { Copyright } from './copyright';
import { LayoutFooter } from './footer';
import { LayoutHeader } from './header';
import {
LayoutExtraMenu,
LayoutMenu,
LayoutMixedMenu,
useExtraMenu,
useMixedMenu,
} from './menu';
import { LayoutTabbar, LayoutTabbarTools } from './tabbar';
defineOptions({ name: 'BasicLayout' });
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
const { isDark, isHeaderNav, isMixedNav, isSideMixedNav, layout } =
usePreferences();
const headerMenuTheme = computed(() => {
return isDark.value ? 'dark' : 'light';
});
const theme = computed(() => {
const dark = isDark.value || preferences.theme.semiDarkMenu;
return dark ? 'dark' : 'light';
});
const logoClass = computed(() => {
let cls = '';
const { collapsed, collapsedShowTitle } = preferences.sidebar;
if (collapsedShowTitle && collapsed && !isMixedNav.value) {
cls += ' mx-auto';
}
if (isSideMixedNav.value) {
cls += ' flex-center';
}
return cls;
});
const isMenuRounded = computed(() => {
return preferences.navigation.styleType === 'rounded';
});
const logoCollapse = computed(() => {
if (isHeaderNav.value || isMixedNav.value) {
return false;
}
const { isMobile } = preferences.app;
const { collapsed } = preferences.sidebar;
if (!collapsed && isMobile) {
return false;
}
return collapsed || isSideMixedNav.value;
});
const showHeaderNav = computed(() => {
return isHeaderNav.value || isMixedNav.value;
});
const {
extraActiveMenu,
extraMenus,
handleDefaultSelect,
handleMenuMouseEnter,
handleMixedMenuSelect,
handleSideMouseLeave,
sidebarExtraVisible,
} = useExtraMenu();
const {
handleMenuSelect,
headerActive,
headerMenus,
sideActive,
sideMenus,
sideVisible,
} = useMixedMenu();
function wrapperMenus(menus: MenuRecordRaw[]) {
return mapTree(menus, (item) => {
return { ...item, name: $t(item.name) };
});
}
function toggleSidebar() {
updatePreferences({
sidebar: {
hidden: !preferences.sidebar.hidden,
},
});
}
function clearPreferencesAndLogout() {
emit('clearPreferencesAndLogout');
}
</script>
<template>
<VbenAdminLayout
v-model:sidebar-extra-visible="sidebarExtraVisible"
:content-compact="preferences.app.contentCompact"
:footer-enable="preferences.footer.enable"
:footer-fixed="preferences.footer.fixed"
:header-hidden="preferences.header.hidden"
:header-mode="preferences.header.mode"
:header-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="sideVisible"
:sidebar-expand-on-hover="preferences.sidebar.expandOnHover"
:sidebar-extra-collapse="preferences.sidebar.extraCollapse"
:sidebar-hidden="preferences.sidebar.hidden"
:sidebar-semi-dark="preferences.theme.semiDarkMenu"
:sidebar-theme="theme"
:sidebar-width="preferences.sidebar.width"
:tabbar-enable="preferences.tabbar.enable"
@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 } })
"
>
<template v-if="preferences.app.enablePreferences" #preferences>
<Preferences @clear-preferences-and-logout="clearPreferencesAndLogout" />
</template>
<template #floating-groups>
<CozeAssistant
v-if="preferences.widget.aiAssistant"
:is-mobile="preferences.app.isMobile"
/>
<VbenBackTop />
</template>
<!-- logo -->
<template #logo>
<VbenLogo
:alt="preferences.app.name"
:class="logoClass"
:collapse="logoCollapse"
:src="preferences.logo.source"
:text="preferences.app.name"
:theme="showHeaderNav ? headerMenuTheme : theme"
/>
</template>
<!-- 头部区域 -->
<template #header>
<LayoutHeader :theme="theme">
<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="headerMenuTheme"
class="w-full"
mode="horizontal"
@select="handleMenuSelect"
/>
</template>
<template #user-dropdown>
<slot name="user-dropdown"></slot>
</template>
<template #notification>
<slot name="notification"></slot>
</template>
</LayoutHeader>
</template>
<!-- 侧边菜单区域 -->
<template #menu>
<LayoutMenu
:accordion="preferences.navigation.accordion"
:collapse="preferences.sidebar.collapsed"
:collapse-show-title="preferences.sidebar.collapsedShowTitle"
:default-active="sideActive"
:menus="wrapperMenus(sideMenus)"
:rounded="isMenuRounded"
:theme="theme"
mode="vertical"
@select="handleMenuSelect"
/>
</template>
<template #mixed-menu>
<LayoutMixedMenu
:active-path="extraActiveMenu"
:collapse="!preferences.sidebar.collapsedShowTitle"
:menus="wrapperMenus(headerMenus)"
:rounded="isMenuRounded"
:theme="theme"
@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="theme"
/>
</template>
<template #side-extra-title>
<VbenLogo
v-if="preferences.logo.enable"
:alt="preferences.app.name"
:text="preferences.app.name"
:theme="theme"
/>
</template>
<template #tabbar>
<LayoutTabbar
v-if="preferences.tabbar.enable"
:show-icon="preferences.tabbar.showIcon"
/>
</template>
<template #tabbar-tools>
<LayoutTabbarTools v-if="preferences.tabbar.enable" />
</template>
<!-- 主体内容 -->
<template #content>
<LayoutContent />
</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>
<Transition v-if="preferences.widget.lockScreen" name="slide-up">
<slot name="lock-screen"></slot>
</Transition>
</template>
</VbenAdminLayout>
</template>

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
import type { MenuRecordRaw } from '@vben-core/typings';
import { useRoute } from 'vue-router';
import { Menu, MenuProps } from '@vben-core/menu-ui';
import { useNavigation } from './use-navigation';
interface Props extends MenuProps {
collspae?: boolean;
menus: MenuRecordRaw[];
}
withDefaults(defineProps<Props>(), {
accordion: true,
menus: () => [],
});
const route = useRoute();
const { navigation } = useNavigation();
async function handleSelect(key: string) {
await navigation(key);
}
</script>
<template>
<Menu
:accordion="accordion"
:collapse="collapse"
:default-active="route.path"
:menus="menus"
:rounded="rounded"
:theme="theme"
mode="vertical"
@select="handleSelect"
/>
</template>

View File

@@ -0,0 +1,5 @@
export { default as LayoutExtraMenu } from './extra-menu.vue';
export { default as LayoutMenu } from './menu.vue';
export { default as LayoutMixedMenu } from './mixed-menu.vue';
export * from './use-extra-menu';
export * from './use-mixed-menu';

View File

@@ -0,0 +1,36 @@
<script lang="ts" setup>
import type { MenuRecordRaw } from '@vben-core/typings';
import { Menu, MenuProps } from '@vben-core/menu-ui';
interface Props extends MenuProps {
menus?: MenuRecordRaw[];
}
const props = withDefaults(defineProps<Props>(), {
accordion: true,
menus: () => [],
});
const emit = defineEmits<{
select: [string, string?];
}>();
function handleMenuSelect(key: string) {
emit('select', key, props.mode);
}
</script>
<template>
<Menu
:accordion="accordion"
:collapse="collapse"
:collapse-show-title="collapseShowTitle"
:default-active="defaultActive"
:menus="menus"
:mode="mode"
:rounded="rounded"
:theme="theme"
@select="handleMenuSelect"
/>
</template>

View File

@@ -0,0 +1,48 @@
<script lang="ts" setup>
import type { NormalMenuProps } from '@vben-core/menu-ui';
import type { MenuRecordRaw } from '@vben-core/typings';
import { onBeforeMount } from 'vue';
import { useRoute } from 'vue-router';
import { findMenuByPath } from '@vben-core/helpers';
import { NormalMenu } from '@vben-core/menu-ui';
interface Props extends NormalMenuProps {}
const props = defineProps<Props>();
const emit = defineEmits<{
defaultSelect: [MenuRecordRaw, MenuRecordRaw?];
enter: [MenuRecordRaw];
select: [MenuRecordRaw];
}>();
const route = useRoute();
function handleSelect(menu: MenuRecordRaw) {
emit('select', menu);
}
onBeforeMount(() => {
const menu = findMenuByPath(props.menus || [], route.path);
if (menu) {
const rootMenu = (props.menus || []).find(
(item) => item.path === menu.parents?.[0],
);
emit('defaultSelect', menu, rootMenu);
}
});
</script>
<template>
<NormalMenu
:active-path="activePath"
:collapse="collapse"
:menus="menus"
:rounded="rounded"
:theme="theme"
@enter="(menu) => emit('enter', menu)"
@select="handleSelect"
/>
</template>

View File

@@ -0,0 +1,92 @@
import type { MenuRecordRaw } from '@vben-core/typings';
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { findRootMenuByPath } from '@vben-core/helpers';
import { preferences } from '@vben-core/preferences';
import { useCoreAccessStore } from '@vben-core/stores';
import { useNavigation } from './use-navigation';
function useExtraMenu() {
const accessStore = useCoreAccessStore();
const { navigation } = useNavigation();
const menus = computed(() => accessStore.accessMenus);
const route = useRoute();
const extraMenus = ref<MenuRecordRaw[]>([]);
const sidebarExtraVisible = ref<boolean>(false);
const extraActiveMenu = ref('');
/**
* 选择混合菜单事件
* @param menu
*/
const handleMixedMenuSelect = async (menu: MenuRecordRaw) => {
extraMenus.value = menu?.children ?? [];
extraActiveMenu.value = menu.parents?.[0] ?? menu.path;
const hasChildren = extraMenus.value.length > 0;
sidebarExtraVisible.value = hasChildren;
if (!hasChildren) {
await navigation(menu.path);
}
};
/**
* 选择默认菜单事件
* @param menu
* @param rootMenu
*/
const handleDefaultSelect = (
menu: MenuRecordRaw,
rootMenu?: MenuRecordRaw,
) => {
extraMenus.value = rootMenu?.children ?? [];
extraActiveMenu.value = menu.parents?.[0] ?? menu.path;
if (preferences.sidebar.expandOnHover) {
sidebarExtraVisible.value = extraMenus.value.length > 0;
}
};
/**
* 侧边菜单鼠标移出事件
*/
const handleSideMouseLeave = () => {
if (preferences.sidebar.expandOnHover) {
return;
}
sidebarExtraVisible.value = false;
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
menus.value,
route.path,
);
extraActiveMenu.value = rootMenuPath ?? findMenu?.path ?? '';
extraMenus.value = rootMenu?.children ?? [];
};
const handleMenuMouseEnter = (menu: MenuRecordRaw) => {
if (!preferences.sidebar.expandOnHover) {
const { findMenu } = findRootMenuByPath(menus.value, menu.path);
extraMenus.value = findMenu?.children ?? [];
extraActiveMenu.value = menu.parents?.[0] ?? menu.path;
sidebarExtraVisible.value = extraMenus.value.length > 0;
}
};
return {
extraActiveMenu,
extraMenus,
handleDefaultSelect,
handleMenuMouseEnter,
handleMixedMenuSelect,
handleSideMouseLeave,
sidebarExtraVisible,
};
}
export { useExtraMenu };

View File

@@ -0,0 +1,120 @@
import type { MenuRecordRaw } from '@vben-core/typings';
import { computed, onBeforeMount, ref } from 'vue';
import { useRoute } from 'vue-router';
import { findRootMenuByPath } from '@vben-core/helpers';
import { preferences, usePreferences } from '@vben-core/preferences';
import { useCoreAccessStore } from '@vben-core/stores';
import { useNavigation } from './use-navigation';
function useMixedMenu() {
const accessStore = useCoreAccessStore();
const { navigation } = useNavigation();
const route = useRoute();
const splitSideMenus = ref<MenuRecordRaw[]>([]);
const rootMenuPath = ref<string>('');
const { isMixedNav } = usePreferences();
const needSplit = computed(
() => preferences.navigation.split && isMixedNav.value,
);
const sideVisible = computed(() => {
const enableSidebar = preferences.sidebar.enable;
if (needSplit.value) {
return enableSidebar && splitSideMenus.value.length > 0;
}
return enableSidebar;
});
const menus = computed(() => accessStore.accessMenus);
/**
* 头部菜单
*/
const headerMenus = computed(() => {
if (!needSplit.value) {
return menus.value;
}
return menus.value.map((item) => {
return {
...item,
children: [],
};
});
});
/**
* 侧边菜单
*/
const sideMenus = computed(() => {
return needSplit.value ? splitSideMenus.value : menus.value;
});
/**
* 侧边菜单激活路径
*/
const sideActive = computed(() => {
return route.path;
});
/**
* 头部菜单激活路径
*/
const headerActive = computed(() => {
if (!needSplit.value) {
return route.path;
}
return rootMenuPath.value;
});
/**
* 菜单点击事件处理
* @param key 菜单路径
* @param mode 菜单模式
*/
const handleMenuSelect = (key: string, mode?: string) => {
if (!needSplit.value || mode === 'vertical') {
navigation(key);
return;
}
const rootMenu = menus.value.find((item) => item.path === key);
rootMenuPath.value = rootMenu?.path ?? '';
splitSideMenus.value = rootMenu?.children ?? [];
if (splitSideMenus.value.length === 0) {
navigation(key);
}
};
/**
* 计算侧边菜单
* @param path 路由路径
*/
function calcSideMenus(path: string = route.path) {
let { rootMenu } = findRootMenuByPath(menus.value, path);
if (!rootMenu) {
rootMenu = menus.value.find((item) => item.path === path);
}
rootMenuPath.value = rootMenu?.path ?? '';
splitSideMenus.value = rootMenu?.children ?? [];
}
// 初始化计算侧边菜单
onBeforeMount(() => {
calcSideMenus();
});
return {
handleMenuSelect,
headerActive,
headerMenus,
sideActive,
sideMenus,
sideVisible,
};
}
export { useMixedMenu };

View File

@@ -0,0 +1,19 @@
import { useRouter } from 'vue-router';
import { isHttpUrl, openWindow } from '@vben-core/toolkit';
function useNavigation() {
const router = useRouter();
const navigation = async (path: string) => {
if (isHttpUrl(path)) {
openWindow(path, { target: '_blank' });
} else {
await router.push(path);
}
};
return { navigation };
}
export { useNavigation };

View File

@@ -0,0 +1,3 @@
export { default as LayoutTabbar } from './tabbar.vue';
export { default as LayoutTabbarTools } from './tabbar-tools.vue';
export * from './use-tabs';

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { preferences } from '@vben-core/preferences';
import { TabsToolMore, TabsToolScreen } from '@vben-core/tabs-ui';
import { updateContentScreen, useTabs } from './use-tabs';
const route = useRoute();
const { createContextMenus } = useTabs();
const menus = computed(() => {
return createContextMenus(route);
});
</script>
<template>
<div class="flex-center h-full">
<TabsToolMore :menus="menus" />
<TabsToolScreen
:screen="preferences.sidebar.hidden"
@change="updateContentScreen"
@update:screen="updateContentScreen"
/>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script lang="ts" setup>
import { useRoute } from 'vue-router';
import { preferences } from '@vben-core/preferences';
import { useCoreTabbarStore } from '@vben-core/stores';
import { TabsView } from '@vben-core/tabs-ui';
import { useTabs } from './use-tabs';
defineOptions({
name: 'LayoutTabbar',
});
defineProps<{ showIcon?: boolean }>();
const route = useRoute();
const coreTabbarStore = useCoreTabbarStore();
const {
createContextMenus,
currentActive,
currentTabs,
handleClick,
handleClose,
handleUnpinTab,
} = useTabs();
// 刷新后如果不保持tab状态关闭其他tab
if (!preferences.tabbar.persist) {
coreTabbarStore.closeOtherTabs(route);
}
</script>
<template>
<TabsView
:active="currentActive"
:menus="createContextMenus"
:show-icon="showIcon"
:tabs="currentTabs"
@close="handleClose"
@unpin-tab="handleUnpinTab"
@update:active="handleClick"
/>
</template>

View File

@@ -0,0 +1,244 @@
import type { IContextMenuItem } from '@vben-core/tabs-ui';
import type { TabItem } from '@vben-core/typings';
import type {
RouteLocationNormalized,
RouteLocationNormalizedGeneric,
} from 'vue-router';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
IcRoundClose,
IcRoundFitScreen,
IcRoundMultipleStop,
IcRoundRefresh,
IcRoundTableView,
IcTwotoneFitScreen,
MdiArrowExpandHorizontal,
MdiFormatHorizontalAlignLeft,
MdiFormatHorizontalAlignRight,
MdiPin,
MdiPinOff,
} from '@vben-core/iconify';
import { $t, useI18n } from '@vben-core/locales';
import { updatePreferences, usePreferences } from '@vben-core/preferences';
import {
storeToRefs,
useCoreAccessStore,
useCoreTabbarStore,
} from '@vben-core/stores';
import { filterTree, openWindow } from '@vben-core/toolkit';
function updateContentScreen(screen: boolean) {
updatePreferences({
header: {
hidden: !!screen,
},
sidebar: {
hidden: !!screen,
},
});
}
function useTabs() {
const router = useRouter();
const route = useRoute();
const accessStore = useCoreAccessStore();
const { contentIsMaximize } = usePreferences();
const coreTabbarStore = useCoreTabbarStore();
const { accessMenus } = storeToRefs(accessStore);
const currentActive = computed(() => {
return route.path;
});
const { locale } = useI18n();
const currentTabs = ref<RouteLocationNormalizedGeneric[]>();
watch([() => coreTabbarStore.getTabs, () => locale.value], ([tabs, _]) => {
currentTabs.value = tabs.map((item) => wrapperTabLocale(item));
});
/**
* 初始化固定标签页
*/
const initAffixTabs = () => {
const affixTabs = filterTree(router.getRoutes(), (route) => {
return !!route.meta?.affixTab;
});
coreTabbarStore.setAffixTabs(affixTabs);
};
// 点击tab,跳转路由
const handleClick = (key: string) => {
router.push(key);
};
// 关闭tab
const handleClose = async (key: string) => {
await coreTabbarStore.closeTabByKey(key, router);
};
function wrapperTabLocale(tab: RouteLocationNormalizedGeneric) {
return {
...tab,
meta: {
...tab.meta,
title: $t(tab.meta.title as string),
},
};
}
watch(
() => accessMenus.value,
() => {
initAffixTabs();
},
{ immediate: true },
);
watch(
() => route.path,
() => {
coreTabbarStore.addTab(route as RouteLocationNormalized);
},
{ immediate: true },
);
const createContextMenus = (tab: TabItem) => {
const tabs = coreTabbarStore.getTabs;
const affixTabs = coreTabbarStore.affixTabs;
const index = tabs.findIndex((item) => item.path === tab.path);
const disabled = tabs.length <= 1;
const { meta } = tab;
const affixTab = meta?.affixTab ?? false;
const isCurrentTab = route.path === tab.path;
// 当前处于最左侧或者减去固定标签页的数量等于0
const closeLeftDisabled =
index === 0 || index - affixTabs.length <= 0 || !isCurrentTab;
const closeRightDisabled = !isCurrentTab || index === tabs.length - 1;
const closeOtherDisabled =
disabled || !isCurrentTab || tabs.length - affixTabs.length <= 1;
const menus: IContextMenuItem[] = [
{
handler: async () => {
if (!contentIsMaximize.value) {
await router.push(tab.fullPath);
}
updateContentScreen(!contentIsMaximize.value);
},
icon: contentIsMaximize.value ? IcRoundFitScreen : IcTwotoneFitScreen,
key: contentIsMaximize.value ? 'restore-maximize' : 'maximize',
text: contentIsMaximize.value
? $t('preferences.tabbar.contextMenu.restoreMaximize')
: $t('preferences.tabbar.contextMenu.maximize'),
},
{
disabled: !isCurrentTab,
handler: async () => {
await coreTabbarStore.refresh(router);
},
icon: IcRoundRefresh,
key: 'reload',
text: $t('preferences.tabbar.contextMenu.reload'),
},
{
disabled: !!affixTab || disabled,
handler: async () => {
await coreTabbarStore.closeTab(tab, router);
},
icon: IcRoundClose,
key: 'close',
text: $t('preferences.tabbar.contextMenu.close'),
},
{
handler: async () => {
await (affixTab
? coreTabbarStore.unpinTab(tab)
: coreTabbarStore.pinTab(tab));
},
icon: affixTab ? MdiPinOff : MdiPin,
key: 'affix',
text: affixTab
? $t('preferences.tabbar.contextMenu.unpin')
: $t('preferences.tabbar.contextMenu.pin'),
},
{
handler: async () => {
const { hash, origin } = location;
const path = tab.fullPath;
const fullPath = path.startsWith('/') ? path : `/${path}`;
const url = `${origin}${hash ? '/#' : ''}${fullPath}`;
openWindow(url, { target: '_blank' });
},
icon: IcRoundTableView,
key: 'open-in-new-window',
separator: true,
text: $t('preferences.tabbar.contextMenu.openInNewWindow'),
},
{
disabled: closeLeftDisabled,
handler: async () => {
await coreTabbarStore.closeLeftTabs(tab);
},
icon: MdiFormatHorizontalAlignLeft,
key: 'close-left',
text: $t('preferences.tabbar.contextMenu.closeLeft'),
},
{
disabled: closeRightDisabled,
handler: async () => {
await coreTabbarStore.closeRightTabs(tab);
},
icon: MdiFormatHorizontalAlignRight,
key: 'close-right',
separator: true,
text: $t('preferences.tabbar.contextMenu.closeRight'),
},
{
disabled: closeOtherDisabled,
handler: async () => {
await coreTabbarStore.closeOtherTabs(tab);
},
icon: MdiArrowExpandHorizontal,
key: 'close-other',
text: $t('preferences.tabbar.contextMenu.closeOther'),
},
{
disabled,
handler: async () => {
await coreTabbarStore.closeAllTabs(router);
},
icon: IcRoundMultipleStop,
key: 'close-all',
text: $t('preferences.tabbar.contextMenu.closeAll'),
},
];
return menus;
};
/**
* 取消固定标签页
*/
const handleUnpinTab = async (tab: TabItem) => {
await coreTabbarStore.unpinTab(tab);
};
return {
createContextMenus,
currentActive,
currentTabs,
handleClick,
handleClose,
handleUnpinTab,
};
}
export { updateContentScreen, useTabs };

View File

@@ -0,0 +1,85 @@
<script lang="ts" setup>
import type { RouteLocationNormalized } from 'vue-router';
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { preferences } from '@vben-core/preferences';
import { Spinner } from '@vben-core/shadcn-ui';
import { useCoreTabbarStore } from '@vben-core/stores';
defineOptions({ name: 'IFrameRouterView' });
const spinningList = ref<boolean[]>([]);
const coreTabbarStore = useCoreTabbarStore();
const route = useRoute();
const enableTabbar = computed(() => preferences.tabbar.enable);
const iframeRoutes = computed(() => {
if (!enableTabbar.value) {
return route.meta.iframeSrc ? [route] : [];
}
return coreTabbarStore.getTabs.filter((tab) => !!tab.meta?.iframeSrc);
});
const tabNames = computed(
() => new Set(iframeRoutes.value.map((item) => item.name as string)),
);
const showIframe = computed(() => iframeRoutes.value.length > 0);
function routeShow(tabItem: RouteLocationNormalized) {
return tabItem.name === route.name;
}
function canRender(tabItem: RouteLocationNormalized) {
const { meta, name } = tabItem;
if (!name || !coreTabbarStore.renderRouteView) {
return false;
}
if (!enableTabbar.value) {
return routeShow(tabItem);
}
// 跟随 keepAlive 状态,与其他tab页保持一致
if (
!meta?.keepAlive &&
tabNames.value.has(name as string) &&
name !== route.name
) {
return false;
}
return coreTabbarStore.getTabs.some((tab) => tab.name === name);
}
function hideLoading(index: number) {
spinningList.value[index] = false;
}
function showSpinning(index: number) {
const curSpinning = spinningList.value[index];
// 首次加载时显示loading
return curSpinning === undefined ? true : curSpinning;
}
</script>
<template>
<template v-if="showIframe">
<template v-for="(item, index) in iframeRoutes" :key="item.fullPath">
<div
v-if="canRender(item)"
v-show="routeShow(item)"
class="relative size-full"
>
<Spinner :spinning="showSpinning(index)" />
<iframe
:src="item.meta.iframeSrc as string"
class="size-full"
@load="hideLoading(index)"
></iframe>
</div>
</template>
</template>
</template>

View File

@@ -0,0 +1,6 @@
<script lang="ts" setup>
defineOptions({ name: 'IFrameView' });
</script>
<template>
<div></div>
</template>

View File

@@ -0,0 +1,2 @@
export { default as IFrameRouterView } from './iframe-router-view.vue';
export { default as IFrameView } from './iframe-view.vue';

View File

@@ -0,0 +1,4 @@
export * from './authentication';
export * from './basic';
export * from './iframe';
export * from './widgets';

View File

@@ -0,0 +1,90 @@
<script lang="ts" setup>
import type { IBreadcrumb } from '@vben-core/shadcn-ui';
import type { BreadcrumbStyleType } from '@vben-core/typings';
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { $t } from '@vben-core/locales';
import { VbenBackgroundBreadcrumb, VbenBreadcrumb } from '@vben-core/shadcn-ui';
interface Props {
hideWhenOnlyOne?: boolean;
showHome?: boolean;
showIcon?: boolean;
type?: BreadcrumbStyleType;
}
const props = withDefaults(defineProps<Props>(), {
showHome: false,
showIcon: false,
type: 'normal',
});
const route = useRoute();
const router = useRouter();
const breadcrumbs = computed((): IBreadcrumb[] => {
const matched = route.matched;
const resultBreadcrumb: IBreadcrumb[] = [];
for (const match of matched) {
const {
meta,
path,
// children = []
} = match;
const { hideChildrenInMenu, hideInBreadcrumb, icon, name, title } =
meta || {};
if (hideInBreadcrumb || hideChildrenInMenu || !path) {
continue;
}
resultBreadcrumb.push({
icon: icon as string,
path: path || route.path,
title: $t((title || name) as string),
// items: children.map((child) => {
// return {
// icon: child?.meta?.icon as string,
// path: child.path,
// title: child?.meta?.title as string,
// };
// }),
});
}
if (props.showHome) {
resultBreadcrumb.unshift({
icon: 'mdi:home-outline',
isHome: true,
path: '/',
});
}
if (props.hideWhenOnlyOne && resultBreadcrumb.length === 1) {
return [];
}
return resultBreadcrumb;
});
function handleSelect(path: string) {
router.push(path);
}
</script>
<template>
<VbenBreadcrumb
v-if="type === 'normal'"
:breadcrumbs="breadcrumbs"
:show-icon="showIcon"
class="ml-2"
@select="handleSelect"
/>
<VbenBackgroundBreadcrumb
v-if="type === 'background'"
:breadcrumbs="breadcrumbs"
:show-icon="showIcon"
class="ml-2"
@select="handleSelect"
/>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { BuiltinThemeType } from '@vben-core/typings';
import { IcRoundColorLens } from '@vben-core/iconify';
import {
COLOR_PRESETS,
preferences,
updatePreferences,
} from '@vben-core/preferences';
import { VbenIconButton } from '@vben-core/shadcn-ui';
defineOptions({
name: 'AuthenticationColorToggle',
});
function handleUpdate(value: BuiltinThemeType) {
updatePreferences({
theme: {
builtinType: value,
},
});
}
</script>
<template>
<div class="group relative flex items-center overflow-hidden">
<div
class="ease-ou flex w-0 overflow-hidden transition-all duration-500 group-hover:w-60"
>
<template v-for="preset in COLOR_PRESETS" :key="preset.color">
<VbenIconButton
class="flex-center flex-shrink-0"
@click="handleUpdate(preset.type)"
>
<div
:style="{ backgroundColor: preset.color }"
class="flex-center relative size-5 rounded-full hover:scale-110"
>
<svg
v-if="preferences.theme.builtinType === preset.type"
class="h-3.5 w-3.5 text-white"
height="1em"
viewBox="0 0 15 15"
width="1em"
>
<path
clip-rule="evenodd"
d="M11.467 3.727c.289.189.37.576.181.865l-4.25 6.5a.625.625 0 0 1-.944.12l-2.75-2.5a.625.625 0 0 1 .841-.925l2.208 2.007l3.849-5.886a.625.625 0 0 1 .865-.181"
fill="currentColor"
fill-rule="evenodd"
/>
</svg>
</div>
</VbenIconButton>
</template>
</div>
<VbenIconButton>
<IcRoundColorLens class="text-primary size-5" />
</VbenIconButton>
</div>
</template>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useScriptTag } from '@vueuse/core';
interface AssistantProps {
botIcon?: string;
botId?: string;
botTitle?: string;
isMobile?: boolean;
}
const props = withDefaults(defineProps<AssistantProps>(), {
botIcon:
'https://cdn.jsdelivr.net/npm/@vbenjs/static-source@0.1.3/source/avatar-v1-transparent-bg.webp',
botId: '7374674983739621392',
botTitle: 'Vben Admin Assistant',
isMobile: false,
});
let client: any;
const wrapperEl = ref();
const { load, unload } = useScriptTag(
'https://sf-cdn.coze.com/obj/unpkg-va/flow-platform/chat-app-sdk/0.1.0-beta.4/libs/oversea/index.js',
() => {
client = new (window as any).CozeWebSDK.WebChatClient({
componentProps: {
icon: props.botIcon,
layout: props.isMobile ? 'mobile' : 'pc',
// lang: 'zh-CN',
title: props.botTitle,
},
config: {
bot_id: props.botId,
},
el: wrapperEl.value,
});
},
{
manual: true,
},
);
onMounted(() => {
load();
});
onUnmounted(() => {
unload();
client?.destroy();
});
</script>
<template>
<div ref="wrapperEl" class="coze-vben-admin-assistant"></div>
</template>
<style>
.coze-vben-admin-assistant {
position: fixed;
right: 30px;
bottom: 60px;
z-index: 1000;
img {
width: 50px !important;
height: 50px !important;
border-radius: 50%;
}
}
</style>

View File

@@ -0,0 +1,146 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@vben-core/typings';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import {
IcRoundArrowDownward,
IcRoundArrowUpward,
IcRoundSearch,
IcRoundSubdirectoryArrowLeft,
MdiKeyboardEsc,
} from '@vben-core/iconify';
import { $t } from '@vben-core/locales';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@vben-core/shadcn-ui';
import { isWindowsOs } from '@vben-core/toolkit';
import { useMagicKeys, useToggle, whenever } from '@vueuse/core';
import SearchPanel from './search-panel.vue';
defineOptions({
name: 'GlobalSearch',
});
const props = withDefaults(
defineProps<{ enableShortcutKey?: boolean; menus: MenuRecordRaw[] }>(),
{
enableShortcutKey: true,
menus: () => [],
},
);
const [open, toggleOpen] = useToggle();
const keyword = ref('');
function handleClose() {
open.value = false;
keyword.value = '';
}
const keys = useMagicKeys();
const cmd = isWindowsOs() ? keys['ctrl+k'] : keys['cmd+k'];
whenever(cmd, () => {
if (props.enableShortcutKey) {
open.value = true;
}
});
const preventDefaultBrowserSearchHotKey = (event: KeyboardEvent) => {
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
}
};
const toggleKeydownListener = () => {
if (props.enableShortcutKey) {
window.addEventListener('keydown', preventDefaultBrowserSearchHotKey);
} else {
window.removeEventListener('keydown', preventDefaultBrowserSearchHotKey);
}
};
watch(() => props.enableShortcutKey, toggleKeydownListener);
onMounted(() => {
toggleKeydownListener();
onUnmounted(() => {
window.removeEventListener('keydown', preventDefaultBrowserSearchHotKey);
});
});
</script>
<template>
<div>
<Dialog :open="open">
<DialogTrigger as-child>
<div
class="md:bg-accent group flex h-8 cursor-pointer items-center gap-3 rounded-2xl border-none bg-none px-2 py-0.5 outline-none"
@click="toggleOpen()"
>
<IcRoundSearch
class="text-muted-foreground group-hover:text-foreground size-4 group-hover:opacity-100"
/>
<span
class="text-muted-foreground group-hover:text-foreground hidden text-sm duration-300 md:block"
>
{{ $t('widgets.search.title') }}
</span>
<span
v-if="enableShortcutKey"
class="bg-background border-foreground/60 text-muted-foreground group-hover:text-foreground relative hidden rounded-sm rounded-r-xl px-1.5 py-1 text-xs leading-none group-hover:opacity-100 md:block"
>
{{ isWindowsOs() ? 'Ctrl' : '⌘' }}
<kbd>K</kbd>
</span>
<span v-else></span>
</div>
</DialogTrigger>
<DialogContent
class="top-0 h-full w-full -translate-y-0 border-none p-0 shadow-xl sm:top-[10%] sm:h-[unset] sm:w-[600px] sm:rounded-2xl"
@close="handleClose"
>
<DialogHeader>
<DialogTitle
class="border-border flex h-12 items-center gap-5 border-b px-5 font-normal"
>
<IcRoundSearch class="mt-1 size-4" />
<input
v-model="keyword"
:placeholder="$t('widgets.search.searchNavigate')"
class="ring-none placeholder:text-muted-foreground w-[80%] rounded-md border border-none bg-transparent p-2 text-sm outline-none ring-0 ring-offset-transparent focus-visible:ring-transparent"
/>
</DialogTitle>
<DialogDescription />
</DialogHeader>
<SearchPanel :keyword="keyword" :menus="menus" @close="handleClose" />
<DialogFooter
class="text-muted-foreground border-border hidden flex-row rounded-b-2xl border-t px-4 py-2 text-xs sm:flex sm:justify-start sm:gap-x-4"
>
<div class="flex items-center">
<IcRoundSubdirectoryArrowLeft class="mr-1" />
{{ $t('widgets.search.select') }}
</div>
<div class="flex items-center">
<IcRoundArrowUpward class="mr-2" />
<IcRoundArrowDownward class="mr-2" />
{{ $t('widgets.search.navigate') }}
</div>
<div class="flex items-center">
<MdiKeyboardEsc class="mr-1" />
{{ $t('widgets.search.close') }}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as GlobalSearch } from './global-search.vue';

View File

@@ -0,0 +1,280 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@vben-core/typings';
import { nextTick, onMounted, ref, shallowRef, watch } from 'vue';
import { useRouter } from 'vue-router';
import { IcRoundClose, IcRoundSearchOff } from '@vben-core/iconify';
import { $t } from '@vben-core/locales';
import { VbenIcon, VbenScrollbar } from '@vben-core/shadcn-ui';
import { mapTree, traverseTreeValues, uniqueByField } from '@vben-core/toolkit';
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];
searchHistory.value.push(to);
handleClose();
await nextTick();
router.push(to.path);
}
// 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 = activeIndex.value - 1 >= 0 ? 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-4 sm:max-h-[450px]">
<!-- 无搜索结果 -->
<div
v-if="keyword && searchResults.length === 0"
class="text-muted-foreground text-center"
>
<IcRoundSearchOff class="size-12" />
<p class="my-10 text-xs">
{{ $t('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('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('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)"
>
<IcRoundClose />
</div>
</li>
</ul>
</div>
</VbenScrollbar>
</template>

View File

@@ -0,0 +1,11 @@
export { default as Breadcrumb } from './breadcrumb.vue';
export { default as AuthenticationColorToggle } from './color-toggle.vue';
export { default as CozeAssistant } from './coze-assistant.vue';
export * from './global-search';
export { default as LanguageToggle } from './language-toggle.vue';
export { default as AuthenticationLayoutToggle } from './layout-toggle.vue';
export * from './lock-screen';
export * from './notification';
export * from './preferences';
export * from './theme-toggle';
export * from './user-dropdown';

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { SupportedLanguagesType } from '@vben-core/typings';
import { IcBaselineLanguage } from '@vben-core/iconify';
import { loadLocaleMessages } from '@vben-core/locales';
import {
SUPPORT_LANGUAGES,
preferences,
updatePreferences,
} from '@vben-core/preferences';
import { VbenDropdownRadioMenu, VbenIconButton } from '@vben-core/shadcn-ui';
defineOptions({
name: 'LanguageToggle',
});
const menus = SUPPORT_LANGUAGES;
async function handleUpdate(value: string) {
const locale = value as SupportedLanguagesType;
updatePreferences({
app: {
locale,
},
});
// 更改预览
await loadLocaleMessages(locale);
}
</script>
<template>
<div>
<VbenDropdownRadioMenu
:menus="menus"
:model-value="preferences.app.locale"
@update:model-value="handleUpdate"
>
<VbenIconButton>
<IcBaselineLanguage class="size-5" />
</VbenIconButton>
</VbenDropdownRadioMenu>
</div>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { VbenDropdownMenuItem } from '@vben-core/shadcn-ui';
import type { AuthPageLayoutType } from '@vben-core/typings';
import { computed } from 'vue';
import { MdiDockBottom, MdiDockLeft, MdiDockRight } from '@vben-core/iconify';
import { $t } from '@vben-core/locales';
import {
preferences,
updatePreferences,
usePreferences,
} from '@vben-core/preferences';
import { VbenDropdownRadioMenu, VbenIconButton } from '@vben-core/shadcn-ui';
defineOptions({
name: 'AuthenticationLayoutToggle',
});
const menus = computed((): VbenDropdownMenuItem[] => [
{
icon: MdiDockLeft,
key: 'panel-left',
text: $t('authentication.layout.alignLeft'),
},
{
icon: MdiDockBottom,
key: 'panel-center',
text: $t('authentication.layout.center'),
},
{
icon: MdiDockRight,
key: 'panel-right',
text: $t('authentication.layout.alignRight'),
},
]);
const { authPanelCenter, authPanelLeft, authPanelRight } = usePreferences();
function handleUpdate(value: string) {
updatePreferences({
app: {
authPageLayout: value as AuthPageLayoutType,
},
});
}
</script>
<template>
<VbenDropdownRadioMenu
:menus="menus"
:model-value="preferences.app.authPageLayout"
@update:model-value="handleUpdate"
>
<VbenIconButton>
<MdiDockRight v-if="authPanelRight" class="size-5" />
<MdiDockLeft v-if="authPanelLeft" class="size-5" />
<MdiDockBottom v-if="authPanelCenter" class="size-5" />
</VbenIconButton>
</VbenDropdownRadioMenu>
</template>

View File

@@ -0,0 +1,2 @@
export { default as LockScreen } from './lock-screen.vue';
export { default as LockScreenModal } from './lock-screen-modal.vue';

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import type { RegisterEmits } from './typings';
import { computed, reactive } from 'vue';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
VbenAvatar,
VbenButton,
VbenInputPassword,
} from '@vben-core/shadcn-ui';
interface Props {
avatar?: string;
text?: string;
}
defineOptions({
name: 'LockScreenModal',
});
withDefaults(defineProps<Props>(), {
avatar: '',
text: '',
});
const emit = defineEmits<{
submit: RegisterEmits['submit'];
}>();
const formState = reactive({
lockScreenPassword: '',
submitted: false,
});
const openModal = defineModel<boolean>('open');
const passwordStatus = computed(() => {
return formState.submitted && !formState.lockScreenPassword
? 'error'
: 'default';
});
function handleClose() {
openModal.value = false;
}
function handleSubmit() {
formState.submitted = true;
if (passwordStatus.value !== 'default') {
return;
}
emit('submit', {
lockScreenPassword: formState.lockScreenPassword,
});
}
</script>
<template>
<div>
<Dialog :open="openModal">
<DialogContent
class="top-0 h-full w-full -translate-y-0 border-none p-0 shadow-xl sm:top-[20%] sm:h-[unset] sm:w-[600px] sm:rounded-2xl"
@close="handleClose"
>
<DialogDescription />
<DialogHeader>
<DialogTitle
class="border-border flex h-8 items-center px-5 font-normal"
>
{{ $t('widgets.lockScreen.title') }}
</DialogTitle>
</DialogHeader>
<div
class="mb-10 flex w-full flex-col items-center"
@keypress.enter.prevent="handleSubmit"
>
<div class="w-2/3">
<div class="ml-2 flex w-full flex-col items-center">
<VbenAvatar
:src="avatar"
class="size-24"
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>
<VbenInputPassword
v-model="formState.lockScreenPassword"
:error-tip="$t('widgets.lockScreen.placeholder')"
:label="$t('widgets.lockScreen.password')"
:placeholder="$t('widgets.lockScreen.placeholder')"
:status="passwordStatus"
name="password"
required
type="password"
/>
<VbenButton class="w-full" @click="handleSubmit">
{{ $t('widgets.lockScreen.screenButton') }}
</VbenButton>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</template>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, reactive, ref, watchEffect } from 'vue';
import { IcRoundLock } from '@vben-core/iconify';
import { $t, useI18n } from '@vben-core/locales';
import {
VbenAvatar,
VbenButton,
VbenInputPassword,
} from '@vben-core/shadcn-ui';
import { useDateFormat, useNow } from '@vueuse/core';
interface Props {
avatar?: string;
cachedPassword?: string;
}
defineOptions({
name: 'LockScreen',
});
const props = withDefaults(defineProps<Props>(), {
avatar: '',
cachedPassword: undefined,
});
const emit = defineEmits<{ toLogin: []; unlock: [string] }>();
const { locale } = useI18n();
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 validPass = ref(true);
const formState = reactive({
password: '',
submitted: false,
});
const passwordStatus = computed(() => {
if (formState.submitted && !formState.password) {
return 'error';
}
if (formState.submitted && !validPass.value) {
return 'error';
}
return 'default';
});
const errorTip = computed(() => {
return props.cachedPassword === undefined || !formState.password
? $t('widgets.lockScreen.placeholder')
: $t('widgets.lockScreen.errorPasswordTip');
});
watchEffect(() => {
if (!formState.password) {
validPass.value = true;
}
});
function handleSubmit() {
formState.submitted = true;
if (passwordStatus.value !== 'default') {
return;
}
if (props.cachedPassword !== formState.password) {
validPass.value = false;
return;
}
emit('unlock', formState.password);
}
function toggleUnlockForm() {
showUnlockForm.value = !showUnlockForm.value;
}
</script>
<template>
<div class="bg-background fixed z-[2000] size-full">
<transition name="slide-left">
<div v-show="!showUnlockForm" class="size-full">
<div
class="flex-col-center text-foreground/80 hover:text-foreground group my-4 cursor-pointer text-xl font-semibold"
@click="toggleUnlockForm"
>
<IcRoundLock
class="size-5 transition-all duration-300 group-hover:scale-125"
/>
<span>{{ $t('widgets.lockScreen.unlock') }}</span>
</div>
<div class="flex h-full justify-center px-[10%]">
<div
class="bg-accent flex-center relative mb-14 mr-20 h-4/5 w-2/5 flex-auto rounded-3xl text-center text-[260px]"
>
<span class="absolute left-4 top-4 text-xl font-semibold">
{{ meridiem }}
</span>
{{ hour }}
</div>
<div
class="bg-accent flex-center mb-14 h-4/5 w-2/5 flex-auto rounded-3xl text-center text-[260px]"
>
{{ minute }}
</div>
</div>
</div>
</transition>
<transition name="slide-right">
<div
v-if="showUnlockForm"
class="flex-center size-full"
@keypress.enter.prevent="handleSubmit"
>
<div class="flex-col-center mb-10 w-[300px]">
<VbenAvatar :src="avatar" class="enter-x mb-6 size-20" />
<div class="enter-x mb-2 w-full items-center">
<VbenInputPassword
v-model="formState.password"
:autofocus="true"
:error-tip="errorTip"
:label="$t('widgets.lockScreen.password')"
:placeholder="$t('widgets.lockScreen.placeholder')"
:status="passwordStatus"
name="password"
required
type="password"
/>
</div>
<VbenButton class="enter-x w-full" @click="handleSubmit">
{{ $t('widgets.lockScreen.entry') }}
</VbenButton>
<VbenButton
class="enter-x my-2 w-full"
variant="ghost"
@click="$emit('toLogin')"
>
{{ $t('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-gray-300 xl:text-xl 2xl:text-3xl"
>
<div v-if="showUnlockForm" class="enter-x mb-2 text-3xl">
{{ hour }}:{{ minute }} <span class="text-lg">{{ meridiem }}</span>
</div>
<div class="text-3xl">{{ date }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,9 @@
interface LockAndRegisterParams {
lockScreenPassword: string;
}
interface RegisterEmits {
submit: [LockAndRegisterParams];
}
export type { LockAndRegisterParams, RegisterEmits };

View File

@@ -0,0 +1,3 @@
export { default as Notification } from './notification.vue';
export type * from './types';

View File

@@ -0,0 +1,183 @@
<script lang="ts" setup>
import type { NotificationItem } from './types';
import {
IcRoundMarkEmailRead,
IcRoundNotificationsNone,
} from '@vben-core/iconify';
import { $t } from '@vben-core/locales';
import {
VbenButton,
VbenIconButton,
VbenPopover,
VbenScrollbar,
} from '@vben-core/shadcn-ui';
import { useToggle } from '@vueuse/core';
interface Props {
/**
* 显示圆点
*/
dot?: boolean;
/**
* 消息列表
*/
notifications?: NotificationItem[];
}
defineOptions({ name: 'NotificationPopup' });
withDefaults(defineProps<Props>(), {
dot: false,
notifications: () => [],
});
const emit = defineEmits<{
clear: [];
makeAll: [];
read: [NotificationItem];
viewAll: [];
}>();
const [open, toggle] = useToggle();
function close() {
open.value = false;
}
function handleViewAll() {
emit('viewAll');
close();
}
function handleMakeAll() {
emit('makeAll');
}
function handleClear() {
emit('clear');
}
function handleClick(item: NotificationItem) {
emit('read', item);
}
</script>
<template>
<VbenPopover
v-model:open="open"
content-class="relative right-2 w-[360px] p-0"
>
<template #trigger>
<div class="flex-center mr-2 h-full" @click.stop="toggle()">
<VbenIconButton class="bell-button relative">
<span
v-if="dot"
class="bg-primary absolute right-0.5 top-0.5 h-2 w-2 rounded"
></span>
<IcRoundNotificationsNone class="size-5" />
</VbenIconButton>
</div>
</template>
<div class="relative">
<div class="flex items-center justify-between p-4 py-3">
<div class="text-foreground">{{ $t('widgets.notifications') }}</div>
<VbenIconButton
:tooltip="$t('widgets.markAllAsRead')"
@click="handleMakeAll"
>
<IcRoundMarkEmailRead />
</VbenIconButton>
</div>
<VbenScrollbar v-if="notifications.length > 0">
<ul class="!flex max-h-[360px] w-full flex-col">
<template v-for="item in notifications" :key="item.title">
<li
class="hover:bg-accent border-border relative flex w-full cursor-pointer items-start gap-5 border-t px-3 py-3"
@click="handleClick(item)"
>
<span
v-if="!item.isRead"
class="bg-primary absolute right-2 top-2 h-2 w-2 rounded"
></span>
<span
class="relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full"
>
<img
:src="item.avatar"
class="aspect-square h-full w-full object-cover"
role="img"
/>
</span>
<div class="flex flex-col gap-1 leading-none">
<p class="font-semibold">{{ item.title }}</p>
<p class="text-muted-foreground my-1 line-clamp-2 text-xs">
{{ item.message }}
</p>
<p class="text-muted-foreground line-clamp-2 text-xs">
{{ item.date }}
</p>
</div>
</li>
</template>
</ul>
</VbenScrollbar>
<template v-else>
<div class="flex-center text-muted-foreground min-h-[150px] w-full">
{{ $t('common.noData') }}
</div>
</template>
<div
class="border-border flex items-center justify-between border-t px-4 py-3"
>
<VbenButton size="sm" variant="ghost" @click="handleClear">
{{ $t('widgets.clearNotifications') }}
</VbenButton>
<VbenButton size="sm" @click="handleViewAll">
{{ $t('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>

View File

@@ -0,0 +1,9 @@
interface NotificationItem {
avatar: string;
date: string;
isRead?: boolean;
message: string;
title: string;
}
export type { NotificationItem };

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
interface Props {
title?: string;
}
defineOptions({
name: 'PreferenceBlock',
});
withDefaults(defineProps<Props>(), {
title: '',
});
</script>
<template>
<div class="flex flex-col py-4">
<h3 class="mb-3 font-semibold leading-none tracking-tight">
{{ title }}
</h3>
<slot></slot>
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { $t } from '@vben-core/locales';
import SwitchItem from '../switch-item.vue';
defineOptions({
name: 'PreferenceAnimation',
});
const transitionProgress = defineModel<boolean>('transitionProgress', {
// 默认值
default: false,
});
const transitionName = defineModel<string>('transitionName');
const transitionEnable = defineModel<boolean>('transitionEnable');
const transitionLoading = defineModel<boolean>('transitionLoading');
const transitionPreset = ['fade', 'fade-slide', 'fade-up', 'fade-down'];
function handleClick(value: string) {
transitionName.value = value;
}
</script>
<template>
<SwitchItem v-model="transitionProgress">
{{ $t('preferences.animation.progress') }}
</SwitchItem>
<SwitchItem v-model="transitionLoading">
{{ $t('preferences.animation.loading') }}
</SwitchItem>
<SwitchItem v-model="transitionEnable">
{{ $t('preferences.animation.transition') }}
</SwitchItem>
<div
v-if="transitionEnable"
class="mb-2 mt-3 flex justify-between gap-3 px-2"
>
<div
v-for="item in transitionPreset"
:key="item"
:class="{
'outline-box-active': transitionName === item,
}"
class="outline-box p-2"
@click="handleClick(item)"
>
<div :class="`${item}-slow`" class="bg-accent h-10 w-12 rounded-md"></div>
</div>
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { SelectOption } from '@vben-core/typings';
import { $t } from '@vben-core/locales';
import { SUPPORT_LANGUAGES } from '@vben-core/preferences';
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 localeItems: SelectOption[] = SUPPORT_LANGUAGES.map((item) => ({
label: item.text,
value: item.key,
}));
</script>
<template>
<SelectItem v-model="appLocale" :items="localeItems">
{{ $t('preferences.language') }}
</SelectItem>
<SwitchItem v-model="appDynamicTitle">
{{ $t('preferences.dynamicTitle') }}
</SwitchItem>
</template>

View File

@@ -0,0 +1,19 @@
export { default as Block } from './block.vue';
export { default as Animation } from './general/animation.vue';
export { default as General } from './general/general.vue';
export { default as Breadcrumb } from './layout/breadcrumb.vue';
export { default as Content } from './layout/content.vue';
export { default as Copyright } from './layout/copyright.vue';
export { default as Footer } from './layout/footer.vue';
export { default as Header } from './layout/header.vue';
export { default as Layout } from './layout/layout.vue';
export { default as Navigation } from './layout/navigation.vue';
export { default as Sidebar } from './layout/sidebar.vue';
export { default as Tabbar } from './layout/tabbar.vue';
export { default as Widget } from './layout/widget.vue';
export { default as GlobalShortcutKeys } from './shortcut-keys/global.vue';
export { default as SwitchItem } from './switch-item.vue';
export { default as BuiltinTheme } from './theme/builtin.vue';
export { default as ColorMode } from './theme/color-mode.vue';
export { default as Radius } from './theme/radius.vue';
export { default as Theme } from './theme/theme.vue';

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import type { SelectOption } from '@vben-core/typings';
import { useSlots } from 'vue';
import { MdiQuestionMarkCircleOutline } from '@vben-core/iconify';
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>
<MdiQuestionMarkCircleOutline class="ml-1 cursor-help" />
</template>
<slot name="tip"></slot>
</VbenTooltip>
</span>
<Input v-model="inputValue" class="h-8 w-[165px]" />
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { SelectOption } from '@vben-core/typings';
import { computed } from 'vue';
import { $t } from '@vben-core/locales';
import SwitchItem from '../switch-item.vue';
import ToggleItem from '../toggle-item.vue';
defineOptions({
name: 'PreferenceBreadcrumbConfig',
});
const props = defineProps<{ disabled?: boolean }>();
const breadcrumbEnable = defineModel<boolean>('breadcrumbEnable');
const breadcrumbShowIcon = defineModel<boolean>('breadcrumbShowIcon');
const breadcrumbStyleType = defineModel<string>('breadcrumbStyleType');
const breadcrumbShowHome = defineModel<boolean>('breadcrumbShowHome');
const breadcrumbHideOnlyOne = defineModel<boolean>('breadcrumbHideOnlyOne');
const typeItems: SelectOption[] = [
{ label: $t('preferences.normal'), value: 'normal' },
{ label: $t('preferences.breadcrumb.background'), value: 'background' },
];
const disableItem = computed(() => {
return !breadcrumbEnable.value || props.disabled;
});
</script>
<template>
<SwitchItem v-model="breadcrumbEnable" :disabled="disabled">
{{ $t('preferences.breadcrumb.enable') }}
</SwitchItem>
<SwitchItem v-model="breadcrumbHideOnlyOne" :disabled="disableItem">
{{ $t('preferences.breadcrumb.hideOnlyOne') }}
</SwitchItem>
<SwitchItem v-model="breadcrumbShowIcon" :disabled="disableItem">
{{ $t('preferences.breadcrumb.icon') }}
</SwitchItem>
<SwitchItem
v-model="breadcrumbShowHome"
:disabled="disableItem || !breadcrumbShowIcon"
>
{{ $t('preferences.breadcrumb.home') }}
</SwitchItem>
<ToggleItem
v-model="breadcrumbStyleType"
:disabled="disableItem"
:items="typeItems"
>
{{ $t('preferences.breadcrumb.style') }}
</ToggleItem>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { type Component, computed } from 'vue';
import { $t } from '@vben-core/locales';
import { ContentCompact, ContentWide } from '../../icons';
defineOptions({
name: 'PreferenceLayoutContent',
});
const modelValue = defineModel<string>({ default: 'wide' });
const components: Record<string, Component> = {
compact: ContentCompact,
wide: ContentWide,
};
const PRESET = computed(() => [
{
name: $t('preferences.wide'),
type: 'wide',
},
{
name: '定宽',
type: 'compact',
},
]);
function activeClass(theme: string): string[] {
return theme === modelValue.value ? ['outline-box-active'] : [];
}
</script>
<template>
<div class="flex w-full gap-5">
<template v-for="theme in PRESET" :key="theme.name">
<div
class="flex w-[100px] cursor-pointer flex-col"
@click="modelValue = theme.type"
>
<div :class="activeClass(theme.type)" class="outline-box flex-center">
<component :is="components[theme.type]" />
</div>
<div class="text-muted-foreground mt-2 text-center text-xs">
{{ theme.name }}
</div>
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@vben-core/locales';
import InputItem from '../input-item.vue';
import SwitchItem from '../switch-item.vue';
defineOptions({
name: 'PreferenceBreadcrumbConfig',
});
const props = defineProps<{ disabled: boolean }>();
const copyrightEnable = defineModel<boolean>('copyrightEnable');
const copyrightDate = defineModel<string>('copyrightDate');
const copyrightIcp = defineModel<string>('copyrightIcp');
const copyrightIcpLink = defineModel<string>('copyrightIcpLink');
const copyrightCompanyName = defineModel<string>('copyrightCompanyName');
const copyrightCompanySiteLink = defineModel<string>(
'copyrightCompanySiteLink',
);
const itemDisabled = computed(() => props.disabled || !copyrightEnable.value);
</script>
<template>
<SwitchItem v-model="copyrightEnable" :disabled="disabled">
{{ $t('preferences.copyright.enable') }}
</SwitchItem>
<InputItem v-model="copyrightCompanyName" :disabled="itemDisabled">
{{ $t('preferences.copyright.companyName') }}
</InputItem>
<InputItem v-model="copyrightCompanySiteLink" :disabled="itemDisabled">
{{ $t('preferences.copyright.companySiteLink') }}
</InputItem>
<InputItem v-model="copyrightDate" :disabled="itemDisabled">
{{ $t('preferences.copyright.date') }}
</InputItem>
<InputItem v-model="copyrightIcp" :disabled="itemDisabled">
{{ $t('preferences.copyright.icp') }}
</InputItem>
<InputItem v-model="copyrightIcpLink" :disabled="itemDisabled">
{{ $t('preferences.copyright.icpLink') }}
</InputItem>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { $t } from '@vben-core/locales';
import SwitchItem from '../switch-item.vue';
defineOptions({
name: 'PreferenceBreadcrumbConfig',
});
const footerEnable = defineModel<boolean>('footerEnable');
const footerFixed = defineModel<boolean>('footerFixed');
</script>
<template>
<SwitchItem v-model="footerEnable">
{{ $t('preferences.footer.visible') }}
</SwitchItem>
<SwitchItem v-model="footerFixed" :disabled="!footerEnable">
{{ $t('preferences.footer.fixed') }}
</SwitchItem>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { LayoutHeaderModeType, SelectOption } from '@vben-core/typings';
import { $t } from '@vben-core/locales';
import SelectItem from '../select-item.vue';
import SwitchItem from '../switch-item.vue';
defineOptions({
name: 'PreferenceBreadcrumbConfig',
});
defineProps<{ disabled: boolean }>();
const headerEnable = defineModel<boolean>('headerEnable');
const headerMode = defineModel<LayoutHeaderModeType>('headerMode');
const localeItems: SelectOption[] = [
{
label: $t('preferences.header.modeStatic'),
value: 'static',
},
{
label: $t('preferences.header.modeFixed'),
value: 'fixed',
},
{
label: $t('preferences.header.modeAuto'),
value: 'auto',
},
{
label: $t('preferences.header.modeAutoScroll'),
value: 'auto-scroll',
},
];
</script>
<template>
<SwitchItem v-model="headerEnable" :disabled="disabled">
{{ $t('preferences.header.visible') }}
</SwitchItem>
<SelectItem
v-model="headerMode"
:disabled="!headerEnable"
:items="localeItems"
>
{{ $t('preferences.mode') }}
</SelectItem>
</template>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import type { LayoutType } from '@vben-core/typings';
import { type Component, computed } from 'vue';
import { MdiQuestionMarkCircleOutline } from '@vben-core/iconify';
import { $t } from '@vben-core/locales';
import { VbenTooltip } from '@vben-core/shadcn-ui';
import {
FullContent,
HeaderNav,
MixedNav,
SidebarMixedNav,
SidebarNav,
} from '../../icons';
interface PresetItem {
name: string;
tip: string;
type: LayoutType;
}
defineOptions({
name: 'PreferenceLayout',
});
const modelValue = defineModel<LayoutType>({ default: 'sidebar-nav' });
const components: Record<LayoutType, Component> = {
'full-content': FullContent,
'header-nav': HeaderNav,
'mixed-nav': MixedNav,
'sidebar-mixed-nav': SidebarMixedNav,
'sidebar-nav': SidebarNav,
};
const PRESET = computed((): PresetItem[] => [
{
name: $t('preferences.vertical'),
tip: $t('preferences.verticalTip'),
type: 'sidebar-nav',
},
{
name: $t('preferences.twoColumn'),
tip: $t('preferences.twoColumnTip'),
type: 'sidebar-mixed-nav',
},
{
name: $t('preferences.horizontal'),
tip: $t('preferences.horizontalTip'),
type: 'header-nav',
},
{
name: $t('preferences.mixedMenu'),
tip: $t('preferences.mixedMenuTip'),
type: 'mixed-nav',
},
{
name: $t('preferences.fullContent'),
tip: $t('preferences.fullContentTip'),
type: 'full-content',
},
]);
function activeClass(theme: string): string[] {
return theme === modelValue.value ? ['outline-box-active'] : [];
}
</script>
<template>
<div class="flex w-full flex-wrap gap-5">
<template v-for="theme in PRESET" :key="theme.name">
<div
class="flex w-[100px] cursor-pointer flex-col"
@click="modelValue = theme.type"
>
<div :class="activeClass(theme.type)" class="outline-box flex-center">
<component :is="components[theme.type]" />
</div>
<div
class="text-muted-foreground flex-center hover:text-foreground mt-2 text-center text-xs"
>
{{ theme.name }}
<VbenTooltip v-if="theme.tip" side="bottom">
<template #trigger>
<MdiQuestionMarkCircleOutline class="ml-1 cursor-help" />
</template>
{{ theme.tip }}
</VbenTooltip>
</div>
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { SelectOption } from '@vben-core/typings';
import { $t } from '@vben-core/locales';
import SwitchItem from '../switch-item.vue';
import ToggleItem from '../toggle-item.vue';
defineOptions({
name: 'PreferenceNavigationConfig',
});
defineProps<{ disabled?: boolean; disabledNavigationSplit?: boolean }>();
const navigationStyleType = defineModel<string>('navigationStyleType');
const navigationSplit = defineModel<boolean>('navigationSplit');
const navigationAccordion = defineModel<boolean>('navigationAccordion');
const stylesItems: SelectOption[] = [
{ label: $t('preferences.rounded'), value: 'rounded' },
{ label: $t('preferences.plain'), value: 'plain' },
];
</script>
<template>
<ToggleItem
v-model="navigationStyleType"
:disabled="disabled"
:items="stylesItems"
>
{{ $t('preferences.navigationMenu.style') }}
</ToggleItem>
<SwitchItem
v-model="navigationSplit"
:disabled="disabledNavigationSplit || disabled"
>
{{ $t('preferences.navigationMenu.split') }}
<template #tip>
{{ $t('preferences.navigationMenu.splitTip') }}
</template>
</SwitchItem>
<SwitchItem v-model="navigationAccordion" :disabled="disabled">
{{ $t('preferences.navigationMenu.accordion') }}
</SwitchItem>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { $t } from '@vben-core/locales';
import NumberFieldItem from '../number-field-item.vue';
import SwitchItem from '../switch-item.vue';
defineOptions({
name: 'PreferenceBreadcrumbConfig',
});
defineProps<{ disabled: boolean }>();
const sidebarEnable = defineModel<boolean>('sidebarEnable');
const sidebarWidth = defineModel<number>('sidebarWidth');
const sidebarCollapsedShowTitle = defineModel<boolean>(
'sidebarCollapsedShowTitle',
);
const sidebarCollapsed = defineModel<boolean>('sidebarCollapsed');
</script>
<template>
<SwitchItem v-model="sidebarEnable" :disabled="disabled">
{{ $t('preferences.sidebar.visible') }}
</SwitchItem>
<SwitchItem v-model="sidebarCollapsed" :disabled="!sidebarEnable || disabled">
{{ $t('preferences.sidebar.collapsed') }}
</SwitchItem>
<SwitchItem
v-model="sidebarCollapsedShowTitle"
:disabled="!sidebarEnable || disabled"
>
{{ $t('preferences.sidebar.collapsedShowTitle') }}
</SwitchItem>
<NumberFieldItem
v-model="sidebarWidth"
:disabled="!sidebarEnable || disabled"
:max="320"
:min="160"
:step="10"
>
{{ $t('preferences.sidebar.width') }}
</NumberFieldItem>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { $t } from '@vben-core/locales';
import SwitchItem from '../switch-item.vue';
defineOptions({
name: 'PreferenceTabsConfig',
});
defineProps<{ disabled?: boolean }>();
const tabbarEnable = defineModel<boolean>('tabbarEnable');
const tabbarShowIcon = defineModel<boolean>('tabbarShowIcon');
const tabbarPersist = defineModel<boolean>('tabbarPersist');
</script>
<template>
<SwitchItem v-model="tabbarEnable" :disabled="disabled">
{{ $t('preferences.tabbar.enable') }}
</SwitchItem>
<SwitchItem v-model="tabbarShowIcon" :disabled="!tabbarEnable">
{{ $t('preferences.tabbar.icon') }}
</SwitchItem>
<SwitchItem v-model="tabbarPersist" :disabled="!tabbarEnable">
{{ $t('preferences.tabbar.persist') }}
</SwitchItem>
</template>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { $t } from '@vben-core/locales';
import SwitchItem from '../switch-item.vue';
defineOptions({
name: 'PreferenceInterfaceControl',
});
const widgetGlobalSearch = defineModel<boolean>('widgetGlobalSearch');
const widgetFullscreen = defineModel<boolean>('widgetFullscreen');
const widgetLanguageToggle = defineModel<boolean>('widgetLanguageToggle');
const widgetNotification = defineModel<boolean>('widgetNotification');
const widgetThemeToggle = defineModel<boolean>('widgetThemeToggle');
const widgetAiAssistant = defineModel<boolean>('widgetAiAssistant');
const widgetSidebarToggle = defineModel<boolean>('widgetSidebarToggle');
const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
</script>
<template>
<SwitchItem v-model="widgetGlobalSearch">
{{ $t('preferences.widget.globalSearch') }}
</SwitchItem>
<SwitchItem v-model="widgetThemeToggle">
{{ $t('preferences.widget.themeToggle') }}
</SwitchItem>
<SwitchItem v-model="widgetLanguageToggle">
{{ $t('preferences.widget.languageToggle') }}
</SwitchItem>
<SwitchItem v-model="widgetFullscreen">
{{ $t('preferences.widget.fullscreen') }}
</SwitchItem>
<SwitchItem v-model="widgetNotification">
{{ $t('preferences.widget.notification') }}
</SwitchItem>
<SwitchItem v-model="widgetAiAssistant">
{{ $t('preferences.widget.aiAssistant') }}
</SwitchItem>
<SwitchItem v-model="widgetLockScreen">
{{ $t('preferences.widget.lockScreen') }}
</SwitchItem>
<SwitchItem v-model="widgetSidebarToggle">
{{ $t('preferences.widget.sidebarToggle') }}
</SwitchItem>
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import type { SelectOption } from '@vben-core/typings';
import { useSlots } from 'vue';
import { MdiQuestionMarkCircleOutline } from '@vben-core/iconify';
import {
NumberField,
NumberFieldContent,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
VbenTooltip,
} from '@vben-core/shadcn-ui';
defineOptions({
name: 'PreferenceSelectItem',
});
withDefaults(
defineProps<{
disabled?: boolean;
items?: SelectOption[];
placeholder?: string;
}>(),
{
disabled: false,
placeholder: '',
items: () => [],
},
);
const inputValue = defineModel<number>();
const slots = useSlots();
</script>
<template>
<div
:class="{
'hover:bg-accent': !slots.tip,
'pointer-events-none opacity-50': disabled,
}"
class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1"
>
<span class="flex items-center text-sm">
<slot></slot>
<VbenTooltip v-if="slots.tip" side="bottom">
<template #trigger>
<MdiQuestionMarkCircleOutline class="ml-1 cursor-help" />
</template>
<slot name="tip"></slot>
</VbenTooltip>
</span>
<NumberField v-model="inputValue" v-bind="$attrs" class="w-[165px]">
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
</div>
</template>

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { SelectOption } from '@vben-core/typings';
import { useSlots } from 'vue';
import { MdiQuestionMarkCircleOutline } from '@vben-core/iconify';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
VbenTooltip,
} from '@vben-core/shadcn-ui';
defineOptions({
name: 'PreferenceSelectItem',
});
withDefaults(
defineProps<{
disabled?: boolean;
items?: SelectOption[];
placeholder?: string;
}>(),
{
disabled: false,
placeholder: '',
items: () => [],
},
);
const selectValue = defineModel<string>();
const slots = useSlots();
</script>
<template>
<div
:class="{
'hover:bg-accent': !slots.tip,
'pointer-events-none opacity-50': disabled,
}"
class="my-1 flex w-full items-center justify-between rounded-md px-2 py-1"
>
<span class="flex items-center text-sm">
<slot></slot>
<VbenTooltip v-if="slots.tip" side="bottom">
<template #trigger>
<MdiQuestionMarkCircleOutline class="ml-1 cursor-help" />
</template>
<slot name="tip"></slot>
</VbenTooltip>
</span>
<Select v-model="selectValue">
<SelectTrigger class="h-8 w-[165px]">
<SelectValue :placeholder="placeholder" />
</SelectTrigger>
<SelectContent>
<template v-for="item in items" :key="item.value">
<SelectItem :value="item.value"> {{ item.label }} </SelectItem>
</template>
</SelectContent>
</Select>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@vben-core/locales';
import { isWindowsOs } from '@vben-core/toolkit';
import SwitchItem from '../switch-item.vue';
defineOptions({
name: 'PreferenceGeneralConfig',
});
const shortcutKeysEnable = defineModel<boolean>('shortcutKeysEnable');
const shortcutKeysGlobalSearch = defineModel<boolean>(
'shortcutKeysGlobalSearch',
);
const shortcutKeysLogout = defineModel<boolean>('shortcutKeysLogout');
const shortcutKeysPreferences = defineModel<boolean>('shortcutKeysPreferences');
const shortcutKeysLockScreen = defineModel<boolean>('shortcutKeysLockScreen');
const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
</script>
<template>
<SwitchItem v-model="shortcutKeysEnable">
{{ $t('preferences.shortcutKeys.title') }}
</SwitchItem>
<SwitchItem
v-model="shortcutKeysGlobalSearch"
:disabled="!shortcutKeysEnable"
>
{{ $t('preferences.shortcutKeys.search') }}
<template #shortcut>
{{ isWindowsOs() ? 'Ctrl' : '⌘' }}
<kbd> K </kbd>
</template>
</SwitchItem>
<SwitchItem v-model="shortcutKeysLogout" :disabled="!shortcutKeysEnable">
{{ $t('preferences.shortcutKeys.logout') }}
<template #shortcut> {{ altView }} Q </template>
</SwitchItem>
<SwitchItem v-model="shortcutKeysPreferences" :disabled="!shortcutKeysEnable">
{{ $t('preferences.shortcutKeys.preferences') }}
<template #shortcut> {{ altView }} , </template>
</SwitchItem>
<SwitchItem v-model="shortcutKeysLockScreen" :disabled="!shortcutKeysEnable">
{{ $t('widgets.lockScreen.title') }}
<template #shortcut> {{ altView }} L </template>
</SwitchItem>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { useSlots } from 'vue';
import { MdiQuestionMarkCircleOutline } from '@vben-core/iconify';
import { Switch, VbenTooltip } from '@vben-core/shadcn-ui';
defineOptions({
name: 'PreferenceSwitchItem',
});
withDefaults(defineProps<{ disabled?: boolean }>(), {
disabled: false,
});
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" side="bottom">
<template #trigger>
<MdiQuestionMarkCircleOutline class="ml-1 cursor-help" />
</template>
<slot name="tip"></slot>
</VbenTooltip>
</span>
<span v-if="$slots.shortcut" class="ml-auto mr-2 text-xs opacity-60">
<slot name="shortcut"></slot>
</span>
<Switch v-model:checked="checked" @click.stop />
</div>
</template>

View File

@@ -0,0 +1,137 @@
<script setup lang="ts">
import type { BuiltinThemeType } from '@vben-core/typings';
import { computed, ref } from 'vue';
import { MdiEditBoxOutline } from '@vben-core/iconify';
import { $t } from '@vben-core/locales';
import {
BUILT_IN_THEME_PRESETS,
type BuiltinThemePreset,
} from '@vben-core/preferences';
import { TinyColor, convertToHsl } from '@vben-core/toolkit';
defineOptions({
name: 'PreferenceBuiltinTheme',
});
const props = defineProps<{ isDark: boolean }>();
const colorInput = ref();
const modelValue = defineModel<BuiltinThemeType>({ default: 'default' });
const themeColorPrimary = defineModel<string>('themeColorPrimary');
const inputValue = computed(() => {
return new TinyColor(themeColorPrimary.value).toHexString();
});
function typeView(name: BuiltinThemeType) {
switch (name) {
case 'default': {
return $t('preferences.theme.builtin.default');
}
case 'violet': {
return $t('preferences.theme.builtin.violet');
}
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 'deep-blue': {
return $t('preferences.theme.builtin.deepBlue');
}
case 'green': {
return $t('preferences.theme.builtin.green');
}
case 'deep-green': {
return $t('preferences.theme.builtin.deepGreen');
}
case 'orange': {
return $t('preferences.theme.builtin.orange');
}
case 'yellow': {
return $t('preferences.theme.builtin.yellow');
}
case 'zinc': {
return $t('preferences.theme.builtin.zinc');
}
case 'neutral': {
return $t('preferences.theme.builtin.neutral');
}
case 'slate': {
return $t('preferences.theme.builtin.slate');
}
case 'gray': {
return $t('preferences.theme.builtin.gray');
}
case 'custom': {
return $t('preferences.theme.builtin.custom');
}
}
}
function handleSelect(theme: BuiltinThemePreset) {
modelValue.value = theme.type;
const primaryColor = props.isDark
? theme.darkPrimaryColor || theme.primaryColor
: theme.primaryColor;
themeColorPrimary.value = primaryColor || theme.color;
}
function handleInputChange(e: Event) {
const target = e.target as HTMLInputElement;
themeColorPrimary.value = convertToHsl(target.value);
}
function selectColor() {
colorInput.value?.[0]?.click?.();
}
</script>
<template>
<div class="flex w-full flex-wrap justify-between">
<template v-for="theme in BUILT_IN_THEME_PRESETS" :key="theme.type">
<div class="flex cursor-pointer flex-col" @click="handleSelect(theme)">
<div
:class="{
'outline-box-active': theme.type === modelValue,
}"
class="outline-box flex-center group cursor-pointer"
>
<template v-if="theme.type !== 'custom'">
<div
:style="{ backgroundColor: theme.color }"
class="mx-10 my-2 size-5 rounded-md"
></div>
</template>
<template v-else>
<div class="size-full px-10 py-2" @click.stop="selectColor">
<div class="flex-center relative size-5 rounded-sm">
<MdiEditBoxOutline
class="absolute z-10 size-5 opacity-60 group-hover:opacity-100"
/>
<input
ref="colorInput"
:value="inputValue"
class="absolute inset-0 opacity-0"
type="color"
@input="handleInputChange"
/>
</div>
</div>
</template>
</div>
<div class="text-muted-foreground my-2 text-center text-xs">
{{ typeView(theme.type) }}
</div>
</div>
</template>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import { $t } from '@vben-core/locales';
import SwitchItem from '../switch-item.vue';
defineOptions({
name: 'PreferenceColorMode',
});
const appColorWeakMode = defineModel<boolean>('appColorWeakMode', {
default: false,
});
const appColorGrayMode = defineModel<boolean>('appColorGrayMode', {
default: false,
});
</script>
<template>
<SwitchItem v-model="appColorWeakMode">
{{ $t('preferences.theme.weakMode') }}
</SwitchItem>
<SwitchItem v-model="appColorGrayMode">
{{ $t('preferences.theme.grayMode') }}
</SwitchItem>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import { ToggleGroup, ToggleGroupItem } from '@vben-core/shadcn-ui';
defineOptions({
name: 'PreferenceColorMode',
});
const modelValue = defineModel<string | undefined>('themeRadius', {
default: '0.5',
});
const items = [
{ label: '0', value: '0' },
{ label: '0.25', value: '0.25' },
{ label: '0.5', value: '0.5' },
{ label: '0.75', value: '0.75' },
{ label: '1', value: '1' },
];
</script>
<template>
<ToggleGroup
v-model="modelValue"
class="gap-2"
size="sm"
type="single"
variant="outline"
>
<template v-for="item in items" :key="item.value">
<ToggleGroupItem
:value="item.value"
class="data-[state=on]:bg-primary data-[state=on]:text-primary-foreground h-7 w-16 rounded-sm"
>
{{ item.label }}
</ToggleGroupItem>
</template>
</ToggleGroup>
</template>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { ThemeModeType } from '@vben-core/typings';
import type { Component } from 'vue';
import {
IcRoundMotionPhotosAuto,
IcRoundWbSunny,
MdiMoonAndStars,
} from '@vben-core/iconify';
import { $t } from '@vben-core/locales';
import SwitchItem from '../switch-item.vue';
defineOptions({
name: 'PreferenceTheme',
});
const modelValue = defineModel<string>({ default: 'auto' });
const appSemiDarkMenu = defineModel<boolean>('appSemiDarkMenu', {
default: true,
});
const THEME_PRESET: Array<{ icon: Component; name: ThemeModeType }> = [
{
icon: IcRoundWbSunny,
name: 'light',
},
{
icon: MdiMoonAndStars,
name: 'dark',
},
{
icon: IcRoundMotionPhotosAuto,
name: 'auto',
},
];
function activeClass(theme: string): string[] {
return theme === modelValue.value ? ['outline-box-active'] : [];
}
function nameView(name: string) {
switch (name) {
case 'light': {
return $t('preferences.theme.light');
}
case 'dark': {
return $t('preferences.theme.dark');
}
case 'auto': {
return $t('preferences.followSystem');
}
}
}
</script>
<template>
<div class="flex w-full flex-wrap justify-between">
<template v-for="theme in THEME_PRESET" :key="theme.name">
<div
class="flex cursor-pointer flex-col"
@click="modelValue = theme.name"
>
<div
:class="activeClass(theme.name)"
class="outline-box flex-center py-4"
>
<component :is="theme.icon" class="mx-9 size-5" />
</div>
<div class="text-muted-foreground mt-2 text-center text-xs">
{{ nameView(theme.name) }}
</div>
</div>
</template>
<SwitchItem
v-model="appSemiDarkMenu"
:disabled="modelValue !== 'light'"
class="mt-6"
>
{{ $t('preferences.theme.darkMenu') }}
</SwitchItem>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { SelectOption } from '@vben-core/typings';
import { ToggleGroup, ToggleGroupItem } from '@vben-core/shadcn-ui';
defineOptions({
name: 'PreferenceToggleItem',
});
withDefaults(defineProps<{ disabled?: boolean; items: SelectOption[] }>(), {
disabled: false,
items: () => [],
});
const modelValue = defineModel<string>();
</script>
<template>
<div
:class="{
'pointer-events-none opacity-50': disabled,
}"
class="hover:bg-accent flex w-full items-center justify-between rounded-md px-2 py-2"
disabled
>
<span class="text-sm"><slot></slot></span>
<ToggleGroup
v-model="modelValue"
class="gap-2"
size="sm"
type="single"
variant="outline"
>
<template v-for="item in items" :key="item.value">
<ToggleGroupItem
:value="item.value"
class="data-[state=on]:bg-primary data-[state=on]:text-primary-foreground h-7 rounded-sm"
>
{{ item.label }}
</ToggleGroupItem>
</template>
</ToggleGroup>
</div>
</template>

View File

@@ -0,0 +1,119 @@
<template>
<svg
class="custom-radio-image"
fill="none"
height="66"
width="104"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<rect
id="svg_1"
fill="currentColor"
fill-opacity="0.02"
height="66"
rx="4"
stroke="null"
width="104"
x="0.13514"
y="0.13514"
/>
<rect
id="svg_8"
fill="hsl(var(--primary))"
height="9.07027"
stroke="null"
width="104.07934"
x="-0.07419"
y="-0.05773"
/>
<rect
id="svg_3"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="7.52486"
x="15.58168"
y="3.20832"
/>
<path
id="svg_12"
d="m98.19822,2.872c0,-0.54338 0.45662,-1 1,-1l1.925,0c0.54338,0 1,0.45662 1,1l0,2.4c0,0.54338 -0.45662,1 -1,1l-1.925,0c-0.54338,0 -1,-0.45662 -1,-1l0,-2.4z"
fill="#ffffff"
opacity="undefined"
stroke="null"
/>
<rect
id="svg_13"
fill="currentColor"
fill-opacity="0.08"
height="21.51892"
rx="2"
stroke="null"
width="41.98275"
x="45.37589"
y="13.53192"
/>
<path
id="svg_14"
d="m16.4123,15.53192c0,-1.08676 0.74096,-2 1.62271,-2l21.74653,0c0.88175,0 1.62271,0.91324 1.62271,2l0,17.24865c0,1.08676 -0.74096,2 -1.62271,2l-21.74653,0c-0.88175,0 -1.62271,-0.91324 -1.62271,-2l0,-17.24865z"
fill="currentColor"
fill-opacity="0.08"
opacity="undefined"
stroke="null"
/>
<rect
id="svg_15"
fill="currentColor"
fill-opacity="0.08"
height="21.65405"
rx="2"
stroke="null"
width="71.10636"
x="16.54743"
y="39.34689"
/>
<rect
id="svg_21"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="7.52486"
x="28.14924"
y="3.07319"
/>
<rect
id="svg_22"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="7.52486"
x="41.25735"
y="3.20832"
/>
<rect
id="svg_23"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="7.52486"
x="54.23033"
y="3.07319"
/>
<rect
id="svg_4"
fill="#ffffff"
height="7.13843"
rx="2"
stroke="null"
width="7.78397"
x="1.5327"
y="0.881"
/>
</g>
</svg>
</template>

View File

@@ -0,0 +1,50 @@
<template>
<svg
class="custom-radio-image"
fill="none"
height="66"
width="104"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
id="svg_1"
d="m0.13514,4.13514c0,-2.17352 1.82648,-4 4,-4l96,0c2.17352,0 4,1.82648 4,4l0,58c0,2.17352 -1.82648,4 -4,4l-96,0c-2.17352,0 -4,-1.82648 -4,-4l0,-58z"
fill="currentColor"
fill-opacity="0.02"
opacity="undefined"
stroke="null"
/>
<rect
id="svg_13"
fill="currentColor"
fill-opacity="0.08"
height="26.57155"
rx="2"
stroke="null"
width="53.18333"
x="45.79979"
y="3.77232"
/>
<path
id="svg_14"
d="m4.28142,5.96169c0,-1.37748 1.06465,-2.53502 2.33158,-2.53502l31.2463,0c1.26693,0 2.33158,1.15754 2.33158,2.53502l0,21.86282c0,1.37748 -1.06465,2.53502 -2.33158,2.53502l-31.2463,0c-1.26693,0 -2.33158,-1.15754 -2.33158,-2.53502l0,-21.86282z"
fill="currentColor"
fill-opacity="0.08"
opacity="undefined"
stroke="null"
/>
<rect
id="svg_15"
fill="currentColor"
fill-opacity="0.08"
height="25.02247"
rx="2"
stroke="null"
width="94.39371"
x="4.56735"
y="34.92584"
/>
</g>
</svg>
</template>

View File

@@ -0,0 +1,119 @@
<template>
<svg
class="custom-radio-image"
fill="none"
height="66"
width="104"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<rect
id="svg_1"
fill="currentColor"
fill-opacity="0.02"
height="66"
rx="4"
stroke="null"
width="104"
x="0.13514"
y="0.13514"
/>
<rect
id="svg_8"
fill="hsl(var(--primary))"
height="9.07027"
stroke="null"
width="104.07934"
x="-0.07419"
y="-0.05773"
/>
<rect
id="svg_3"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="7.52486"
x="15.58168"
y="3.20832"
/>
<path
id="svg_12"
d="m98.19822,2.872c0,-0.54338 0.45662,-1 1,-1l1.925,0c0.54338,0 1,0.45662 1,1l0,2.4c0,0.54338 -0.45662,1 -1,1l-1.925,0c-0.54338,0 -1,-0.45662 -1,-1l0,-2.4z"
fill="#ffffff"
opacity="undefined"
stroke="null"
/>
<rect
id="svg_13"
fill="currentColor"
fill-opacity="0.08"
height="21.51892"
rx="2"
stroke="null"
width="53.60438"
x="43.484"
y="13.66705"
/>
<path
id="svg_14"
d="m3.43932,15.53192c0,-1.08676 1.03344,-2 2.26323,-2l30.33036,0c1.22979,0 2.26323,0.91324 2.26323,2l0,17.24865c0,1.08676 -1.03344,2 -2.26323,2l-30.33036,0c-1.22979,0 -2.26323,-0.91324 -2.26323,-2l0,-17.24865z"
fill="currentColor"
fill-opacity="0.08"
opacity="undefined"
stroke="null"
/>
<rect
id="svg_15"
fill="currentColor"
fill-opacity="0.08"
height="21.65405"
rx="2"
stroke="null"
width="95.02528"
x="3.30419"
y="39.34689"
/>
<rect
id="svg_21"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="7.52486"
x="28.14924"
y="3.07319"
/>
<rect
id="svg_22"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="7.52486"
x="41.25735"
y="3.20832"
/>
<rect
id="svg_23"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="7.52486"
x="54.23033"
y="3.07319"
/>
<rect
id="svg_4"
fill="#ffffff"
height="7.13843"
rx="2"
stroke="null"
width="7.78397"
x="1.5327"
y="0.881"
/>
</g>
</svg>
</template>

View File

@@ -0,0 +1,10 @@
import HeaderNav from './header-nav.vue';
export { default as ContentCompact } from './content-compact.vue';
export { default as FullContent } from './full-content.vue';
export { default as MixedNav } from './mixed-nav.vue';
export { default as SidebarMixedNav } from './sidebar-mixed-nav.vue';
export { default as SidebarNav } from './sidebar-nav.vue';
const ContentWide = HeaderNav;
export { ContentWide, HeaderNav };

View File

@@ -0,0 +1,161 @@
<template>
<svg
class="custom-radio-image"
fill="none"
height="66"
width="104"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<rect
id="svg_1"
fill="currentColor"
fill-opacity="0.02"
height="66"
rx="4"
stroke="null"
width="104"
x="0.13514"
y="0.13514"
/>
<rect
id="svg_8"
fill="hsl(var(--primary))"
height="9.07027"
stroke="null"
width="104.07934"
x="-0.07419"
y="-0.05773"
/>
<rect
id="svg_3"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="7.52486"
x="15.58168"
y="3.20832"
/>
<path
id="svg_12"
d="m98.19822,2.872c0,-0.54338 0.45662,-1 1,-1l1.925,0c0.54338,0 1,0.45662 1,1l0,2.4c0,0.54338 -0.45662,1 -1,1l-1.925,0c-0.54338,0 -1,-0.45662 -1,-1l0,-2.4z"
fill="#ffffff"
opacity="undefined"
stroke="null"
/>
<rect
id="svg_13"
fill="currentColor"
fill-opacity="0.08"
height="21.51892"
rx="2"
stroke="null"
width="44.13071"
x="53.37873"
y="13.45652"
/>
<path
id="svg_14"
d="m19.4393,15.74245c0,-1.08676 0.79001,-2 1.73013,-2l23.18605,0c0.94011,0 1.73013,0.91324 1.73013,2l0,17.24865c0,1.08676 -0.79001,2 -1.73013,2l-23.18605,0c-0.94011,0 -1.73013,-0.91324 -1.73013,-2l0,-17.24865z"
fill="currentColor"
fill-opacity="0.08"
opacity="undefined"
stroke="null"
/>
<rect
id="svg_15"
fill="currentColor"
fill-opacity="0.08"
height="21.65405"
rx="2"
stroke="null"
width="78.39372"
x="19.93575"
y="39.34689"
/>
<rect
id="svg_21"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="7.52486"
x="28.14924"
y="3.07319"
/>
<rect
id="svg_22"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="7.52486"
x="41.25735"
y="3.20832"
/>
<rect
id="svg_23"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="7.52486"
x="54.23033"
y="3.07319"
/>
<rect
id="svg_4"
fill="#ffffff"
height="7.13843"
rx="2"
stroke="null"
width="7.78397"
x="1.5327"
y="0.881"
/>
<rect
id="svg_5"
fill="currentColor"
fill-opacity="0.08"
height="56.81191"
stroke="null"
width="15.44642"
x="-0.06423"
y="9.03113"
/>
<path
id="svg_2"
d="m2.38669,15.38074c0,-0.20384 0.27195,-0.37513 0.59557,-0.37513l7.98149,0c0.32362,0 0.59557,0.17129 0.59557,0.37513l0,3.23525c0,0.20384 -0.27195,0.37513 -0.59557,0.37513l-7.98149,0c-0.32362,0 -0.59557,-0.17129 -0.59557,-0.37513l0,-3.23525z"
fill="currentColor"
fill-opacity="0.08"
opacity="undefined"
stroke="null"
/>
<path
id="svg_6"
d="m2.38669,28.43336c0,-0.20384 0.27195,-0.37513 0.59557,-0.37513l7.98149,0c0.32362,0 0.59557,0.17129 0.59557,0.37513l0,3.23525c0,0.20384 -0.27195,0.37513 -0.59557,0.37513l-7.98149,0c-0.32362,0 -0.59557,-0.17129 -0.59557,-0.37513l0,-3.23525z"
fill="currentColor"
fill-opacity="0.08"
opacity="undefined"
stroke="null"
/>
<path
id="svg_7"
d="m2.17616,41.27545c0,-0.20384 0.27195,-0.37513 0.59557,-0.37513l7.98149,0c0.32362,0 0.59557,0.17129 0.59557,0.37513l0,3.23525c0,0.20384 -0.27195,0.37513 -0.59557,0.37513l-7.98149,0c-0.32362,0 -0.59557,-0.17129 -0.59557,-0.37513l0,-3.23525z"
fill="currentColor"
fill-opacity="0.08"
opacity="undefined"
stroke="null"
/>
<path
id="svg_9"
d="m2.17616,54.32806c0,-0.20384 0.27195,-0.37513 0.59557,-0.37513l7.98149,0c0.32362,0 0.59557,0.17129 0.59557,0.37513l0,3.23525c0,0.20384 -0.27195,0.37513 -0.59557,0.37513l-7.98149,0c-0.32362,0 -0.59557,-0.17129 -0.59557,-0.37513l0,-3.23525z"
fill="currentColor"
fill-opacity="0.08"
opacity="undefined"
stroke="null"
/>
</g>
</svg>
</template>

View File

@@ -0,0 +1,12 @@
<template>
<svg
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.9 12.66a1 1 0 0 1 0-1.32l1.28-1.44a1 1 0 0 0 .12-1.17l-2-3.46a1 1 0 0 0-1.07-.48l-1.88.38a1 1 0 0 1-1.15-.66l-.61-1.83a1 1 0 0 0-.95-.68h-4a1 1 0 0 0-1 .68l-.56 1.83a1 1 0 0 1-1.15.66L5 4.79a1 1 0 0 0-1 .48L2 8.73a1 1 0 0 0 .1 1.17l1.27 1.44a1 1 0 0 1 0 1.32L2.1 14.1a1 1 0 0 0-.1 1.17l2 3.46a1 1 0 0 0 1.07.48l1.88-.38a1 1 0 0 1 1.15.66l.61 1.83a1 1 0 0 0 1 .68h4a1 1 0 0 0 .95-.68l.61-1.83a1 1 0 0 1 1.15-.66l1.88.38a1 1 0 0 0 1.07-.48l2-3.46a1 1 0 0 0-.12-1.17ZM18.41 14l.8.9l-1.28 2.22l-1.18-.24a3 3 0 0 0-3.45 2L12.92 20h-2.56L10 18.86a3 3 0 0 0-3.45-2l-1.18.24l-1.3-2.21l.8-.9a3 3 0 0 0 0-4l-.8-.9l1.28-2.2l1.18.24a3 3 0 0 0 3.45-2L10.36 4h2.56l.38 1.14a3 3 0 0 0 3.45 2l1.18-.24l1.28 2.22l-.8.9a3 3 0 0 0 0 3.98m-6.77-6a4 4 0 1 0 4 4a4 4 0 0 0-4-4m0 6a2 2 0 1 1 2-2a2 2 0 0 1-2 2"
/>
</svg>
</template>

View File

@@ -0,0 +1,173 @@
<template>
<svg
class="custom-radio-image"
fill="none"
height="66"
width="104"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<rect
id="svg_1"
fill="currentColor"
fill-opacity="0.02"
height="66"
rx="4"
stroke="null"
width="104"
x="0.13514"
y="0.13514"
/>
<path
id="svg_2"
d="m-3.37838,3.7543a1.93401,4.02457 0 0 1 1.93401,-4.02457l11.3488,0l0,66.40541l-11.3488,0a1.93401,4.02457 0 0 1 -1.93401,-4.02457l0,-58.35627z"
fill="hsl(var(--primary))"
stroke="null"
/>
<rect
id="svg_3"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="5.47439"
x="1.64059"
y="15.46086"
/>
<rect
id="svg_4"
fill="#ffffff"
height="7.67897"
rx="2"
stroke="null"
width="8.18938"
x="0.58676"
y="1.42154"
/>
<rect
id="svg_8"
fill="currentColor"
fill-opacity="0.08"
height="9.07027"
rx="2"
stroke="null"
width="75.91967"
x="25.38277"
y="1.42876"
/>
<rect
id="svg_9"
fill="#b2b2b2"
height="4.4"
rx="1"
stroke="null"
width="3.925"
x="27.91529"
y="3.69284"
/>
<rect
id="svg_10"
fill="#b2b2b2"
height="4.4"
rx="1"
stroke="null"
width="3.925"
x="80.75054"
y="3.62876"
/>
<rect
id="svg_11"
fill="#b2b2b2"
height="4.4"
rx="1"
stroke="null"
width="3.925"
x="87.78868"
y="3.69981"
/>
<rect
id="svg_12"
fill="#b2b2b2"
height="4.4"
rx="1"
stroke="null"
width="3.925"
x="94.6847"
y="3.62876"
/>
<rect
id="svg_13"
fill="currentColor"
fill-opacity="0.08"
height="21.51892"
rx="2"
stroke="null"
width="42.9287"
x="58.75427"
y="14.613"
/>
<rect
id="svg_14"
fill="currentColor"
fill-opacity="0.08"
height="20.97838"
rx="2"
stroke="null"
width="28.36894"
x="26.14342"
y="14.613"
/>
<rect
id="svg_15"
fill="currentColor"
fill-opacity="0.08"
height="21.65405"
rx="2"
stroke="null"
width="75.09493"
x="26.34264"
y="39.68822"
/>
<rect
id="svg_5"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="5.47439"
x="1.79832"
y="28.39462"
/>
<rect
id="svg_6"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="5.47439"
x="1.64059"
y="41.80156"
/>
<rect
id="svg_7"
fill="#e5e5e5"
height="2.789"
rx="1.395"
stroke="null"
width="5.47439"
x="1.64059"
y="55.36623"
/>
<rect
id="svg_16"
fill="currentColor"
fill-opacity="0.08"
height="65.72065"
stroke="null"
width="12.49265"
x="9.85477"
y="-0.02618"
/>
</g>
</svg>
</template>

View File

@@ -0,0 +1,153 @@
<template>
<svg
class="custom-radio-image"
fill="none"
height="66"
width="104"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<rect
id="svg_1"
fill="currentColor"
fill-opacity="0.02"
height="66"
rx="4"
stroke="null"
width="104"
/>
<path
id="svg_2"
d="m-3.37838,3.61916a4.4919,4.02457 0 0 1 4.4919,-4.02457l26.35848,0l0,66.40541l-26.35848,0a4.4919,4.02457 0 0 1 -4.4919,-4.02457l0,-58.35627z"
fill="hsl(var(--primary))"
stroke="null"
/>
<rect
id="svg_3"
fill="#e5e5e5"
height="2.789"
rx="1.395"
width="17.66"
x="4.906"
y="23.884"
/>
<rect
id="svg_4"
fill="#ffffff"
height="9.706"
rx="2"
width="9.811"
x="8.83"
y="5.881"
/>
<path
id="svg_5"
d="m4.906,35.833c0,-0.75801 0.63699,-1.395 1.395,-1.395l14.87,0c0.75801,0 1.395,0.63699 1.395,1.395l0,-0.001c0,0.75801 -0.63699,1.395 -1.395,1.395l-14.87,0c-0.75801,0 -1.395,-0.63699 -1.395,-1.395l0,0.001z"
fill="#ffffff"
opacity="undefined"
/>
<rect
id="svg_6"
fill="#ffffff"
height="2.789"
rx="1.395"
width="17.66"
x="4.906"
y="44.992"
/>
<rect
id="svg_7"
fill="#ffffff"
height="2.789"
rx="1.395"
width="17.66"
x="4.906"
y="55.546"
/>
<rect
id="svg_8"
fill="currentColor"
fill-opacity="0.08"
height="9.07027"
rx="2"
stroke="null"
width="73.53879"
x="28.97986"
y="1.42876"
/>
<rect
id="svg_9"
fill="#b2b2b2"
height="4.4"
rx="1"
stroke="null"
width="3.925"
x="32.039"
y="3.89903"
/>
<rect
id="svg_10"
fill="#b2b2b2"
height="4.4"
rx="1"
stroke="null"
width="3.925"
x="80.75054"
y="3.62876"
/>
<rect
id="svg_11"
fill="#b2b2b2"
height="4.4"
rx="1"
stroke="null"
width="3.925"
x="87.58249"
y="3.49362"
/>
<rect
id="svg_12"
fill="#b2b2b2"
height="4.4"
rx="1"
stroke="null"
width="3.925"
x="94.6847"
y="3.62876"
/>
<rect
id="svg_13"
fill="currentColor"
fill-opacity="0.08"
height="21.51892"
rx="2"
stroke="null"
width="45.63141"
x="56.05157"
y="14.613"
/>
<rect
id="svg_14"
fill="currentColor"
fill-opacity="0.08"
height="20.97838"
rx="2"
stroke="null"
width="22.82978"
x="29.38527"
y="14.613"
/>
<rect
id="svg_15"
fill="currentColor"
fill-opacity="0.08"
height="21.65405"
rx="2"
stroke="null"
width="72.45771"
x="28.97986"
y="39.48203"
/>
</g>
</svg>
</template>

View File

@@ -0,0 +1,2 @@
export { default as Preferences } from './preferences.vue';
export * from './use-open-preferences';

View File

@@ -0,0 +1,425 @@
<script setup lang="ts">
import type { SegmentedItem } from '@vben-core/shadcn-ui';
import type {
BreadcrumbStyleType,
BuiltinThemeType,
ContentCompactType,
LayoutHeaderModeType,
LayoutType,
NavigationStyleType,
SupportedLanguagesType,
ThemeModeType,
} from '@vben-core/typings';
import { computed, ref } from 'vue';
import { IcRoundFolderCopy, IcRoundRestartAlt } from '@vben-core/iconify';
import { $t, loadLocaleMessages } from '@vben-core/locales';
import {
clearPreferencesCache,
preferences,
resetPreferences,
usePreferences,
} from '@vben-core/preferences';
import {
VbenButton,
VbenIconButton,
VbenSegmented,
VbenSheet,
useToast,
} from '@vben-core/shadcn-ui';
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';
import IconSetting from './icons/setting.vue';
import { useOpenPreferences } from './use-open-preferences';
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
const { toast } = useToast();
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 appSemiDarkMenu = defineModel<boolean>('appSemiDarkMenu');
const appContentCompact = defineModel<ContentCompactType>('appContentCompact');
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 sidebarEnable = defineModel<boolean>('sidebarEnable');
const sidebarWidth = defineModel<number>('sidebarWidth');
const sidebarCollapsed = defineModel<boolean>('sidebarCollapsed');
const sidebarCollapsedShowTitle = defineModel<boolean>(
'sidebarCollapsedShowTitle',
);
const headerEnable = defineModel<boolean>('headerEnable');
const headerMode = defineModel<LayoutHeaderModeType>('headerMode');
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 tabbarPersist = defineModel<boolean>('tabbarPersist');
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 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 shortcutKeysGlobalPreferences = defineModel<boolean>(
'shortcutKeysGlobalPreferences',
);
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 widgetAiAssistant = defineModel<boolean>('widgetAiAssistant');
const widgetSidebarToggle = defineModel<boolean>('widgetSidebarToggle');
const widgetLockScreen = defineModel<boolean>('widgetLockScreen');
const {
diffPreference,
isDark,
isFullContent,
isHeaderNav,
isMixedNav,
isSideMixedNav,
isSideMode,
isSideNav,
} = usePreferences();
const { copy } = useClipboard();
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
);
});
const { openPreferences } = useOpenPreferences();
async function handleCopy() {
await copy(JSON.stringify(diffPreference.value, null, 2));
toast({
description: $t('preferences.copyPreferences'),
title: $t('preferences.copyPreferencesSuccess'),
});
}
async function handleClearCache() {
resetPreferences();
clearPreferencesCache();
emit('clearPreferencesAndLogout');
}
async function handleReset() {
if (!diffPreference.value) {
return;
}
resetPreferences();
await loadLocaleMessages(preferences.app.locale);
toast({
description: $t('preferences.resetTitle'),
title: $t('preferences.resetSuccess'),
});
}
</script>
<template>
<div class="z-100 fixed right-0 top-1/2">
<VbenSheet
v-model:open="openPreferences"
:description="$t('preferences.subtitle')"
:title="$t('preferences.title')"
>
<template #trigger>
<VbenButton
:title="$t('preferences.title')"
class="bg-primary flex-col-center h-12 w-12 cursor-pointer rounded-l-lg rounded-r-none border-none"
>
<IconSetting
class="duration-3000 fill-primary-foreground animate-spin text-2xl"
/>
</VbenButton>
</template>
<template #extra>
<div class="flex items-center">
<VbenIconButton
:disabled="!diffPreference"
:tooltip="$t('preferences.resetTip')"
class="relative"
>
<span
v-if="diffPreference"
class="bg-primary absolute right-0.5 top-0.5 h-2 w-2 rounded"
></span>
<IcRoundRestartAlt class="size-5" @click="handleReset" />
</VbenIconButton>
</div>
</template>
<div class="p-4 pt-4">
<VbenSegmented v-model="activeTab" :tabs="tabs">
<template #general>
<Block :title="$t('preferences.general')">
<General
v-model:app-dynamic-title="appDynamicTitle"
v-model:app-locale="appLocale"
/>
</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:app-semi-dark-menu="appSemiDarkMenu"
/>
</Block>
<!-- <Block :title="$t('preferences.theme-color')">
<ThemeColor
v-model="themeColorPrimary"
:color-primary-presets="colorPrimaryPresets"
/>
</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-collapsed="sidebarCollapsed"
v-model:sidebar-collapsed-show-title="sidebarCollapsedShowTitle"
v-model:sidebar-enable="sidebarEnable"
v-model:sidebar-width="sidebarWidth"
:disabled="!isSideMode"
/>
</Block>
<Block :title="$t('preferences.header.title')">
<Header
v-model:headerEnable="headerEnable"
v-model:headerMode="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)
"
/>
</Block>
<Block :title="$t('preferences.tabbar.title')">
<Tabbar
v-model:tabbar-enable="tabbarEnable"
v-model:tabbar-persist="tabbarPersist"
v-model:tabbar-show-icon="tabbarShowIcon"
/>
</Block>
<Block :title="$t('preferences.widget.title')">
<Widget
v-model:widget-ai-assistant="widgetAiAssistant"
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-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 :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"
v-model:shortcut-keys-preferences="
shortcutKeysGlobalPreferences
"
/>
</Block>
</template>
</VbenSegmented>
</div>
<template #footer>
<VbenButton
:disabled="!diffPreference"
class="mx-4 w-full"
size="sm"
variant="default"
@click="handleCopy"
>
<IcRoundFolderCopy class="mr-2 size-3" />
{{ $t('preferences.copyPreferences') }}
</VbenButton>
<VbenButton
:disabled="!diffPreference"
class="mr-4 w-full"
size="sm"
variant="ghost"
@click="handleClearCache"
>
<IcRoundRestartAlt class="mr-2 size-4" />
{{ $t('preferences.clearAndLogout') }}
</VbenButton>
</template>
</VbenSheet>
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { loadLocaleMessages } from '@vben-core/locales';
import { preferences, updatePreferences } from '@vben-core/preferences';
import { capitalizeFirstLetter } from '@vben-core/toolkit';
import Preferences from './preferences-sheet.vue';
/**
* preferences 转成 vue props
* preferences.widget.aiAssistant=>widgetAiAssistant
*/
const attrs = computed(() => {
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(preferences)) {
for (const [subKey, subValue] of Object.entries(value)) {
result[`${key}${capitalizeFirstLetter(subKey)}`] = subValue;
}
}
return result;
});
/**
* preferences 转成 vue listener
* preferences.widget.aiAssistant=>@update:widgetAiAssistant
*/
const listen = computed(() => {
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(preferences)) {
if (typeof value === 'object') {
for (const subKey of Object.keys(value)) {
result[`update:${key}${capitalizeFirstLetter(subKey)}`] = (
val: any,
) => {
updatePreferences({ [key]: { [subKey]: val } });
if (key === 'app' && subKey === 'locale') {
loadLocaleMessages(val);
}
};
}
} else {
result[key] = value;
}
}
return result;
});
</script>
<template>
<Preferences v-bind="attrs" v-on="listen" />
</template>

View File

@@ -0,0 +1,16 @@
import { ref } from 'vue';
const openPreferences = ref(false);
function useOpenPreferences() {
function handleOpenPreference() {
openPreferences.value = true;
}
return {
handleOpenPreference,
openPreferences,
};
}
export { useOpenPreferences };

View File

@@ -0,0 +1 @@
export { default as ThemeToggle } from './theme-toggle.vue';

View File

@@ -0,0 +1,190 @@
<script lang="ts" setup>
import { computed, nextTick } from 'vue';
import { VbenButton } from '@vben-core/shadcn-ui';
import { useNamespace } from '@vben-core/toolkit';
interface Props {
/**
* 类型
*/
type?: 'icon' | 'normal';
}
defineOptions({
name: 'ThemeToggleButton',
});
const props = withDefaults(defineProps<Props>(), {
type: 'normal',
});
const isDark = defineModel<boolean>();
const { b, e, is } = useNamespace('theme-toggle');
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: '6px' },
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-expect-error: Transition API
const transition = document.startViewTransition(async () => {
isDark.value = !isDark.value;
await nextTick();
});
transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
];
document.documentElement.animate(
{
clipPath: isDark.value ? [...clipPath].reverse() : clipPath,
},
{
duration: 450,
easing: 'ease-in',
pseudoElement: isDark.value
? '::view-transition-old(root)'
: '::view-transition-new(root)',
},
);
});
}
</script>
<template>
<VbenButton
:aria-label="theme"
:class="[b(), is(theme), `is-${theme}`]"
aria-live="polite"
class="theme-toggle cursor-pointer border-none bg-none"
v-bind="bindProps"
@click.stop="toggleTheme"
>
<svg aria-hidden="true" height="24" viewBox="0 0 24 24" width="24">
<mask
id="theme-toggle-moon"
:class="e('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="e('sun')"
class="theme-toggle__sun"
cx="12"
cy="12"
mask="url(#theme-toggle-moon)"
r="11"
/>
<g :class="e('sun-beams')" 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 lang="scss" scoped>
.theme-toggle {
&__moon {
& > circle {
transition: transform 0.5s cubic-bezier(0, 0, 0.3, 1);
}
}
&__sun {
fill: hsl(var(--foreground) / 80%);
stroke: none;
transition: transform 1.6s cubic-bezier(0.25, 0, 0.2, 1);
transform-origin: center center;
&:hover > svg > & {
fill: hsl(var(--foreground));
}
}
&__sun-beams {
stroke: hsl(var(--foreground) / 80%);
stroke-width: 2px;
transition:
transform 1.6s cubic-bezier(0.5, 1.5, 0.75, 1.25),
opacity 0.6s cubic-bezier(0.25, 0, 0.3, 1);
transform-origin: center center;
&:hover > svg > & {
stroke: hsl(var(--foreground));
}
}
&.is-light {
.theme-toggle__sun {
transform: scale(0.5);
}
.theme-toggle__sun-beams {
transform: rotateZ(0.25turn);
}
}
&.is-dark {
.theme-toggle__moon {
& > circle {
transform: translateX(-20px);
}
}
.theme-toggle__sun-beams {
opacity: 0;
}
}
&:hover > svg {
.theme-toggle__sun,
.theme-toggle__moon {
fill: hsl(var(--foreground));
}
}
}
</style>

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
import type { ThemeModeType } from '@vben-core/typings';
import {
IcRoundMotionPhotosAuto,
IcRoundWbSunny,
MdiMoonAndStars,
} from '@vben-core/iconify';
import { $t } from '@vben-core/locales';
import {
preferences,
updatePreferences,
usePreferences,
} from '@vben-core/preferences';
import {
ToggleGroup,
ToggleGroupItem,
VbenTooltip,
} from '@vben-core/shadcn-ui';
import ThemeButton from './theme-button.vue';
defineOptions({
name: 'ThemeToggle',
});
withDefaults(defineProps<{ shouldOnHover?: boolean }>(), {
shouldOnHover: false,
});
function handleChange(isDark: boolean) {
updatePreferences({
theme: { mode: isDark ? 'dark' : 'light' },
});
}
const { isDark } = usePreferences();
const PRESETS = [
{
icon: IcRoundWbSunny,
name: 'light',
title: $t('preferences.theme.light'),
},
{
icon: MdiMoonAndStars,
name: 'dark',
title: $t('preferences.theme.dark'),
},
{
icon: IcRoundMotionPhotosAuto,
name: 'auto',
title: $t('preferences.followSystem'),
},
];
</script>
<template>
<div>
<VbenTooltip :disabled="!shouldOnHover" side="bottom">
<template #trigger>
<ThemeButton
:model-value="isDark"
type="icon"
@update:model-value="handleChange"
/>
</template>
<ToggleGroup
:model-value="preferences.theme.mode"
class="gap-2"
type="single"
variant="outline"
@update:model-value="
(val) => updatePreferences({ theme: { mode: val as ThemeModeType } })
"
>
<ToggleGroupItem
v-for="item in PRESETS"
:key="item.name"
:value="item.name"
>
<component :is="item.icon" class="size-5" />
</ToggleGroupItem>
</ToggleGroup>
</VbenTooltip>
</div>
</template>

View File

@@ -0,0 +1 @@
export { default as UserDropdown } from './user-dropdown.vue';

View File

@@ -0,0 +1,244 @@
<script setup lang="ts">
import type { AnyFunction } from '@vben-core/typings';
import type { Component } from 'vue';
import { computed, ref } from 'vue';
import {
IcRoundLock,
IcRoundLogout,
IcRoundSettingsSuggest,
} from '@vben-core/iconify';
import { $t } from '@vben-core/locales';
import { preferences, usePreferences } from '@vben-core/preferences';
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
VbenAlertDialog,
VbenAvatar,
VbenIcon,
} from '@vben-core/shadcn-ui';
import { isWindowsOs } from '@vben-core/toolkit';
import { useMagicKeys, whenever } from '@vueuse/core';
import { LockScreenModal } from '../lock-screen';
import { useOpenPreferences } from '../preferences';
interface Props {
/**
* 头像
*/
avatar?: string;
/**
* @zh_CN 描述
*/
description?: string;
/**
* 是否启用快捷键
*/
enableShortcutKey?: boolean;
/**
* 菜单数组
*/
menus?: Array<{ handler: AnyFunction; icon?: Component; text: string }>;
/**
* 标签文本
*/
tagText?: string;
/**
* 文本
*/
text?: string;
}
defineOptions({
name: 'UserDropdown',
});
const props = withDefaults(defineProps<Props>(), {
avatar: '',
description: '',
enableShortcutKey: true,
menus: () => [],
showShortcutKey: true,
tagText: '',
text: '',
});
const emit = defineEmits<{ lockScreen: [string]; logout: [] }>();
const openPopover = ref(false);
const openDialog = ref(false);
const openLock = ref(false);
const {
globalLockScreenShortcutKey,
globalLogoutShortcutKey,
globalPreferencesShortcutKey,
} = usePreferences();
const { handleOpenPreference } = useOpenPreferences();
const altView = computed(() => (isWindowsOs() ? 'Alt' : '⌥'));
const enableLogoutShortcutKey = computed(() => {
return props.enableShortcutKey && globalLogoutShortcutKey.value;
});
const enableLockScreenShortcutKey = computed(() => {
return props.enableShortcutKey && globalLockScreenShortcutKey.value;
});
const enableShortcutKey = computed(() => {
return props.enableShortcutKey && preferences.shortcutKeys.enable;
});
const enablePreferencesShortcutKey = computed(() => {
return props.enableShortcutKey && globalPreferencesShortcutKey.value;
});
function handleOpenLock() {
openLock.value = true;
}
function handleSubmitLock({
lockScreenPassword,
}: {
lockScreenPassword: string;
}) {
openLock.value = false;
emit('lockScreen', lockScreenPassword);
}
function handleLogout() {
// emit
openDialog.value = true;
openPopover.value = false;
}
function handleSubmitLogout() {
emit('logout');
openDialog.value = false;
}
if (enableShortcutKey.value) {
const keys = useMagicKeys();
whenever(keys['Alt+KeyQ'], () => {
if (enableLogoutShortcutKey.value) {
handleLogout();
}
});
whenever(keys['Alt+Comma'], () => {
if (enablePreferencesShortcutKey.value) {
handleOpenPreference();
}
});
whenever(keys['Alt+KeyL'], () => {
if (enableLockScreenShortcutKey.value) {
handleOpenLock();
}
});
}
</script>
<template>
<LockScreenModal
v-if="preferences.widget.lockScreen"
v-model:open="openLock"
:avatar="avatar"
:text="text"
@submit="handleSubmitLock"
/>
<VbenAlertDialog
v-model:open="openDialog"
:cancel-text="$t('common.cancel')"
:content="$t('widgets.logoutTip')"
:submit-text="$t('common.confirm')"
:title="$t('common.prompt')"
@submit="handleSubmitLogout"
/>
<DropdownMenu>
<DropdownMenuTrigger>
<div class="hover:bg-accent ml-1 mr-2 cursor-pointer rounded-full p-1.5">
<div class="hover:text-accent-foreground flex-center">
<VbenAvatar :alt="text" :src="avatar" class="size-8" dot />
</div>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent class="mr-2 min-w-[240px] p-0 pb-1">
<DropdownMenuLabel class="flex items-center p-3">
<VbenAvatar
:alt="text"
:src="avatar"
class="size-12"
dot
dot-class="bottom-0 right-1 border-2 size-4 bg-green-500"
/>
<div class="ml-2 w-full">
<div
class="text-foreground mb-1 flex items-center text-sm font-medium"
>
{{ text }}
<Badge class="ml-2 text-green-400">
{{ tagText }}
</Badge>
</div>
<div class="text-muted-foreground text-xs font-normal">
{{ description }}
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
v-for="menu in menus"
:key="menu.text"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="menu.handler"
>
<VbenIcon :icon="menu.icon" class="mr-2 size-5" />
{{ menu.text }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleOpenPreference"
>
<IcRoundSettingsSuggest class="mr-2 size-5" />
{{ $t('preferences.title') }}
<DropdownMenuShortcut v-if="enablePreferencesShortcutKey">
{{ altView }} ,
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
v-if="preferences.widget.lockScreen"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleOpenLock"
>
<IcRoundLock class="mr-2 size-5" />
{{ $t('widgets.lockScreen.title') }}
<DropdownMenuShortcut v-if="enableLockScreenShortcutKey">
{{ altView }} L
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleLogout"
>
<IcRoundLogout class="mr-2 size-5" />
{{ $t('common.logout') }}
<DropdownMenuShortcut v-if="enableLogoutShortcutKey">
{{ altView }} Q
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>

View File

@@ -0,0 +1 @@
export { default } from '@vben/tailwind-config';

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@vben/tsconfig/library.json",
"include": ["src"],
"exclude": ["node_modules"]
}