Quellcode durchsuchen

Merge branch 'main' into fix/lint

xingyu vor 3 Wochen
Ursprung
Commit
00990d9453

+ 1 - 0
apps/web-antd/src/layouts/basic.vue

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

+ 1 - 0
apps/web-antdv-next/src/layouts/basic.vue

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

+ 1 - 0
apps/web-ele/src/layouts/basic.vue

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

+ 1 - 0
apps/web-naive/src/layouts/basic.vue

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

+ 1 - 0
apps/web-tdesign/src/layouts/basic.vue

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

+ 7 - 2
packages/@core/base/typings/src/app.d.ts

@@ -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'

+ 1 - 0
packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap

@@ -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,

+ 1 - 0
packages/@core/preferences/src/config.ts

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

+ 3 - 3
packages/@core/preferences/src/preferences.ts

@@ -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,
     );
 
     // 更新偏好设置

+ 4 - 0
packages/@core/preferences/src/types.ts

@@ -155,6 +155,10 @@ interface AppPreferences {
   name: string;
   /** 偏好设置按钮位置 */
   preferencesButtonPosition: PreferencesButtonPositionType;
+  /**
+   * @zh_CN 应用时区
+   */
+  timezone: string;
   /**
    * @zh_CN 是否开启水印
    */

+ 5 - 1
packages/@core/preferences/src/use-preferences.ts

@@ -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,
     };
   });
 

+ 57 - 27
packages/effects/layouts/src/basic/header/header.vue

@@ -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 });
     }
   });
+  // 最后追加用户下拉框,若是索引值超过1000时则固定在1000(适配用户按钮不在最后的场景)
+  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');
 }

+ 24 - 0
packages/effects/layouts/src/widgets/preferences/blocks/general/general.vue

@@ -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>

+ 4 - 0
packages/effects/layouts/src/widgets/preferences/blocks/layout/widget.vue

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

+ 2 - 0
packages/effects/layouts/src/widgets/preferences/preferences-drawer.vue

@@ -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"
               />

+ 32 - 11
packages/effects/layouts/src/widgets/preferences/preferences.vue

@@ -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>

+ 29 - 4
packages/effects/layouts/src/widgets/user-dropdown/user-dropdown.vue

@@ -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"

+ 3 - 1
packages/locales/src/langs/en-US/preferences.json

@@ -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",

+ 1 - 1
packages/locales/src/langs/en-US/ui.json

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

+ 3 - 1
packages/locales/src/langs/zh-CN/preferences.json

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

+ 1 - 1
packages/locales/src/langs/zh-CN/ui.json

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

+ 1 - 0
playground/src/layouts/basic.vue

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