feat: 升级前端框架,适配手机端

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