Merge branch 'main' into fix/lint

master^2
xingyu 2026-05-20 17:38:19 +08:00 committed by GitHub
commit 00990d9453
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 183 additions and 52 deletions

View File

@ -226,6 +226,7 @@ watch(
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
/>
</template>
<template #notification>

View File

@ -226,6 +226,7 @@ watch(
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
/>
</template>
<template #notification>

View File

@ -226,6 +226,7 @@ watch(
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
/>
</template>
<template #notification>

View File

@ -226,6 +226,7 @@ watch(
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
/>
</template>
<template #notification>

View File

@ -226,6 +226,7 @@ watch(
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
/>
</template>
<template #notification>

View File

@ -10,12 +10,17 @@ type LayoutType =
type ThemeModeType = 'auto' | 'dark' | 'light';
/**
*
*
* user-dropdown
* fixed
* header
* auto
*/
type PreferencesButtonPositionType = 'auto' | 'fixed' | 'header';
type PreferencesButtonPositionType =
| 'auto'
| 'fixed'
| 'header'
| 'user-dropdown';
type BuiltinThemeType =
| 'custom'

View File

@ -30,6 +30,7 @@ exports[`defaultPreferences immutability test > should not modify the config obj
"loginExpiredMode": "page",
"name": "Vben Admin",
"preferencesButtonPosition": "auto",
"timezone": "Asia/Shanghai",
"watermark": false,
"watermarkContent": "",
"zIndex": 200,

View File

@ -30,6 +30,7 @@ const defaultPreferences: Preferences = {
loginExpiredMode: 'page',
name: 'Vben Admin',
preferencesButtonPosition: 'auto',
timezone: 'Asia/Shanghai',
watermark: false,
watermarkContent: '',
zIndex: 200,

View File

@ -124,19 +124,19 @@ class PreferenceManager {
// 使用命名空间初始化存储管理器
this.cache = new StorageManager({ prefix: namespace });
// 合并初始偏好设置
// 合并初始偏好设置:前面的对象优先,后面的对象仅补齐缺失字段
this.initialPreferences = merge({}, overrides, defaultPreferences);
this.customPreferencesExtension = extension ?? null;
this.initialCustomPreferences = this.resolveCustomPreferencesDefaults(
this.customPreferencesExtension,
);
// 加载缓存的偏好设置并与初始配置合并
// 加载缓存的偏好设置,并仅用缓存补齐初始化配置中未显式设置的字段
const cachedPreferences = (await this.loadFromCache()) || {};
const mergedPreference = merge(
{},
this.initialPreferences, // 初始化配置优先,缓存仅补齐缺失字段
cachedPreferences,
this.initialPreferences,
);
// 更新偏好设置

View File

@ -155,6 +155,10 @@ interface AppPreferences {
name: string;
/** 偏好设置按钮位置 */
preferencesButtonPosition: PreferencesButtonPositionType;
/**
* @zh_CN
*/
timezone: string;
/**
* @zh_CN
*/

View File

@ -195,12 +195,12 @@ function usePreferences() {
*/
const preferencesButtonPosition = computed(() => {
const { enablePreferences, preferencesButtonPosition } = preferences.app;
// 如果没有启用偏好设置按钮
if (!enablePreferences) {
return {
fixed: false,
header: false,
userDropdown: false,
};
}
@ -211,12 +211,15 @@ function usePreferences() {
const contentIsMaximize = headerHidden && sidebarHidden;
const isHeaderPosition = preferencesButtonPosition === 'header';
const isUserDropdownPosition =
preferencesButtonPosition === 'user-dropdown';
// 如果设置了固定位置
if (preferencesButtonPosition !== 'auto') {
return {
fixed: preferencesButtonPosition === 'fixed',
header: isHeaderPosition,
userDropdown: isUserDropdownPosition,
};
}
@ -230,6 +233,7 @@ function usePreferences() {
return {
fixed,
header: !fixed,
userDropdown: !fixed && isUserDropdownPosition,
};
});

View File

@ -33,52 +33,61 @@ withDefaults(defineProps<Props>(), {
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
const REFERENCE_VALUE = 50;
const REFERENCE_VALUE = 100;
const accessStore = useAccessStore();
const { globalSearchShortcutKey, preferencesButtonPosition } = usePreferences();
const slots = useSlots();
const { refresh } = useRefresh();
/**
* 插槽列表类型
*/
type SlotItem = { index: number; name: string };
const rightSlots = computed(() => {
const list = [{ index: REFERENCE_VALUE + 100, name: 'user-dropdown' }];
const list: Array<SlotItem> = [];
//
if (preferences.widget.globalSearch) {
list.push({
index: REFERENCE_VALUE,
name: 'global-search',
});
}
//
if (preferencesButtonPosition.value.header) {
list.push({
index: REFERENCE_VALUE + 10,
name: 'preferences',
});
//
if (preferences.widget.themeToggle) {
list.push({
index: REFERENCE_VALUE + 20,
name: 'theme-toggle',
});
}
if (preferences.widget.languageToggle) {
list.push({
index: REFERENCE_VALUE + 30,
name: 'language-toggle',
});
}
if (preferences.widget.timezone) {
list.push({
index: REFERENCE_VALUE + 40,
name: 'timezone',
});
}
}
if (preferences.widget.themeToggle) {
list.push({
index: REFERENCE_VALUE + 20,
name: 'theme-toggle',
});
}
if (preferences.widget.languageToggle) {
list.push({
index: REFERENCE_VALUE + 30,
name: 'language-toggle',
});
}
if (preferences.widget.timezone) {
list.push({
index: REFERENCE_VALUE + 40,
name: 'timezone',
});
}
//
if (preferences.widget.fullscreen) {
list.push({
index: REFERENCE_VALUE + 50,
name: 'fullscreen',
});
}
//
if (preferences.widget.notification) {
list.push({
index: REFERENCE_VALUE + 60,
@ -87,17 +96,24 @@ const rightSlots = computed(() => {
}
Object.keys(slots).forEach((key) => {
const name = key.split('-');
// header-right-1
if (key.startsWith('header-right')) {
list.push({ index: Number(name[2]), name: key });
//
const slotIndex = Number(key.split('-')[2]);
const index = Number.isNaN(slotIndex) ? nextIndex(list) : slotIndex;
list.push({ index, name: key });
}
});
// 10001000
const userDropdownIndex = Math.min(1000, nextIndex(list));
list.push({ index: userDropdownIndex, name: 'user-dropdown' });
//
return list.toSorted((a, b) => a.index - b.index);
});
const leftSlots = computed(() => {
const list: Array<{ index: number; name: string }> = [];
const list: Array<SlotItem> = [];
//
if (preferences.widget.refresh) {
list.push({
index: 0,
@ -106,14 +122,28 @@ const leftSlots = computed(() => {
}
Object.keys(slots).forEach((key) => {
const name = key.split('-');
// header-left-1
if (key.startsWith('header-left')) {
list.push({ index: Number(name[2]), name: key });
//
const slotIndex = Number(key.split('-')[2]);
const index = Number.isNaN(slotIndex) ? nextIndex(list) : slotIndex;
list.push({ index, name: key });
}
});
//
return list.toSorted((a, b) => a.index - b.index);
});
/**
* 获取列表下一个索引值(用于排序)
* @param list 列表
*/
function nextIndex(list: Array<SlotItem>) {
const index =
list.length > 0 ? Math.max(...list.map((item) => item.index)) : 0;
return index + 1;
}
function clearPreferencesAndLogout() {
emit('clearPreferencesAndLogout');
}

View File

@ -1,6 +1,9 @@
<script setup lang="ts">
import { onMounted, ref, unref } from 'vue';
import { SUPPORT_LANGUAGES } from '@vben/constants';
import { $t } from '@vben/locales';
import { useTimezoneStore } from '@vben/stores';
import InputItem from '../input-item.vue';
import SelectItem from '../select-item.vue';
@ -11,6 +14,7 @@ defineOptions({
});
const appLocale = defineModel<string>('appLocale');
const appTimezone = defineModel<string>('appTimezone');
const appDynamicTitle = defineModel<boolean>('appDynamicTitle');
const appWatermark = defineModel<boolean>('appWatermark');
const appWatermarkContent = defineModel<string>('appWatermarkContent');
@ -18,12 +22,32 @@ const appEnableCheckUpdates = defineModel<boolean>('appEnableCheckUpdates');
const appEnableCopyPreferences = defineModel<boolean>(
'appEnableCopyPreferences',
);
const timezoneStore = useTimezoneStore();
const timezoneOptionsRef = ref<
{
label: string;
value: string;
}[]
>([]);
onMounted(async () => {
timezoneOptionsRef.value = await timezoneStore.getTimezoneOptions();
// Asia/Shanghai
const timezoneValue = unref(timezoneStore.timezone);
if (timezoneValue) {
appTimezone.value = timezoneValue;
}
});
</script>
<template>
<SelectItem v-model="appLocale" :items="SUPPORT_LANGUAGES">
{{ $t('preferences.language') }}
</SelectItem>
<SelectItem v-model="appTimezone" :items="timezoneOptionsRef">
{{ $t('preferences.timezone') }}
</SelectItem>
<SwitchItem v-model="appDynamicTitle">
{{ $t('preferences.dynamicTitle') }}
</SwitchItem>

View File

@ -38,6 +38,10 @@ const positionItems = computed((): SelectOption[] => [
label: $t('preferences.position.fixed'),
value: 'fixed',
},
{
label: $t('preferences.position.userDropdown'),
value: 'user-dropdown',
},
]);
</script>

View File

@ -65,6 +65,7 @@ const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
const message = globalShareState.getMessage();
const appLocale = defineModel<SupportedLanguagesType>('appLocale');
const appTimezone = defineModel<string>('appTimezone');
const appDynamicTitle = defineModel<boolean>('appDynamicTitle');
const appLayout = defineModel<LayoutType>('appLayout');
const appColorGrayMode = defineModel<boolean>('appColorGrayMode');
@ -359,6 +360,7 @@ function handleCustomPreferencesUpdate(updates: CustomPreferencesRecord) {
v-model:app-enable-check-updates="appEnableCheckUpdates"
v-model:app-enable-copy-preferences="appEnableCopyPreferences"
v-model:app-locale="appLocale"
v-model:app-timezone="appTimezone"
v-model:app-watermark="appWatermark"
v-model:app-watermark-content="appWatermarkContent"
/>

View File

@ -11,10 +11,26 @@ import { VbenButton } from '@vben-core/shadcn-ui';
import PreferencesDrawer from './preferences-drawer.vue';
interface Props {
/** 是否显示按钮 */
showButton?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showButton: true,
});
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
const [Drawer, drawerApi] = useVbenDrawer({
connectedComponent: PreferencesDrawer,
});
//
defineExpose({
open: () => drawerApi.open(),
});
/**
* preferences 转成 vue props
* preferences.widget.fullscreen=>widgetFullscreen
@ -56,17 +72,22 @@ const listen = computed(() => {
</script>
<template>
<div>
<Drawer v-bind="{ ...$attrs, ...attrs }" v-on="listen" />
<Drawer
v-bind="{ ...$attrs, ...attrs }"
v-on="listen"
@clear-preferences-and-logout="emit('clearPreferencesAndLogout')"
/>
<div @click="() => drawerApi.open()">
<slot>
<VbenButton
:title="$t('preferences.title')"
class="flex-col-center size-10 cursor-pointer rounded-l-lg rounded-r-none border-none bg-primary"
>
<Settings class="size-5" />
</VbenButton>
</slot>
</div>
<!-- 触发打开抽屉的按钮(可覆盖) -->
<slot>
<VbenButton
v-if="props.showButton"
:title="$t('preferences.title')"
class="flex-col-center size-10 cursor-pointer rounded-l-lg rounded-r-none border-none bg-primary"
@click="() => drawerApi.open()"
>
<Settings class="size-5" />
</VbenButton>
</slot>
</div>
</template>

View File

@ -6,7 +6,7 @@ import type { AnyFunction } from '@vben/types';
import { computed, useTemplateRef, watch } from 'vue';
import { useHoverToggle } from '@vben/hooks';
import { LockKeyhole, LogOut } from '@vben/icons';
import { LockKeyhole, LogOut, Settings } from '@vben/icons';
import { $t } from '@vben/locales';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore } from '@vben/stores';
@ -29,6 +29,7 @@ import {
import { useMagicKeys, whenever } from '@vueuse/core';
import { LockScreenModal } from '../lock-screen';
import { Preferences } from '../preferences';
interface Props {
/**
@ -82,10 +83,13 @@ const props = withDefaults(defineProps<Props>(), {
hoverDelay: 500,
});
const emit = defineEmits<{ logout: [] }>();
const emit = defineEmits<{ clearPreferencesAndLogout: []; logout: [] }>();
const { globalLockScreenShortcutKey, globalLogoutShortcutKey } =
usePreferences();
const {
globalLockScreenShortcutKey,
globalLogoutShortcutKey,
preferencesButtonPosition,
} = usePreferences();
const accessStore = useAccessStore();
const [LockModal, lockModalApi] = useVbenModal({
connectedComponent: LockScreenModal,
@ -98,6 +102,7 @@ const [LogoutModal, logoutModalApi] = useVbenModal({
const refTrigger = useTemplateRef('refTrigger');
const refContent = useTemplateRef('refContent');
const refPreferences = useTemplateRef('refPreferences');
const [openPopover, hoverWatcher] = useHoverToggle(
[refTrigger, refContent],
() => props.hoverDelay,
@ -151,6 +156,11 @@ function handleSubmitLogout() {
logoutModalApi.close();
}
// -
function handleOpenSettings() {
refPreferences.value?.open();
}
if (enableShortcutKey.value) {
const keys = useMagicKeys();
const logoutKey = keys['Alt+KeyQ'];
@ -195,6 +205,13 @@ if (enableShortcutKey.value) {
{{ $t('ui.widgets.logoutTip') }}
</LogoutModal>
<Preferences
v-if="preferencesButtonPosition.userDropdown"
ref="refPreferences"
:show-button="false"
@clear-preferences-and-logout="emit('clearPreferencesAndLogout')"
/>
<DropdownMenu v-model:open="openPopover">
<DropdownMenuTrigger ref="refTrigger" :disabled="props.trigger === 'hover'">
<div class="mr-2 ml-1 cursor-pointer rounded-full p-1.5 hover:bg-accent">
@ -241,6 +258,14 @@ if (enableShortcutKey.value) {
{{ menu.text }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="preferencesButtonPosition.userDropdown"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"
@click="handleOpenSettings"
>
<Settings class="mr-2 size-4" />
{{ $t('preferences.title') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="preferences.widget.lockScreen"
class="mx-1 flex cursor-pointer items-center rounded-sm py-1 leading-8"

View File

@ -38,6 +38,7 @@
"mode": "Mode",
"general": "General",
"language": "Language",
"timezone": "Timezone",
"dynamicTitle": "Dynamic Title",
"watermark": "Watermark",
"watermarkContent": "Please input Watermark content",
@ -46,7 +47,8 @@
"title": "Preferences Postion",
"header": "Header",
"auto": "Auto",
"fixed": "Fixed"
"fixed": "Fixed",
"userDropdown": "User Dropdown"
},
"sidebar": {
"buttons": "Show Buttons",

View File

@ -149,7 +149,7 @@
},
"lockScreen": {
"title": "Lock Screen",
"screenButton": "Locking",
"screenButton": "Unlock",
"password": "Password",
"placeholder": "Please enter password",
"unlock": "Click to unlock",

View File

@ -38,6 +38,7 @@
"mode": "模式",
"general": "通用",
"language": "语言",
"timezone": "时区",
"dynamicTitle": "动态标题",
"watermark": "水印",
"watermarkContent": "请输入水印文案",
@ -46,7 +47,8 @@
"title": "偏好设置位置",
"header": "顶栏",
"auto": "自动",
"fixed": "固定"
"fixed": "固定",
"userDropdown": "用户下拉窗"
},
"sidebar": {
"buttons": "显示按钮",

View File

@ -149,7 +149,7 @@
},
"lockScreen": {
"title": "锁定屏幕",
"screenButton": "",
"screenButton": "锁",
"password": "密码",
"placeholder": "请输入锁屏密码",
"unlock": "点击解锁",

View File

@ -251,6 +251,7 @@ onBeforeMount(() => {
tag-text="Pro"
trigger="both"
@logout="handleLogout"
@clear-preferences-and-logout="handleLogout"
/>
</template>
<template #notification>