Kaynağa Gözat

Merge branch 'fork/ming4762/timezone-20251020'

Jin Mao 7 ay önce
ebeveyn
işleme
ac6de0324c

+ 12 - 0
apps/backend-mock/api/timezone/getTimezone.ts

@@ -0,0 +1,12 @@
+import { eventHandler } from 'h3';
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
+import { getTimezone } from '~/utils/timezone-utils';
+
+export default eventHandler((event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
+  }
+  return useResponseSuccess(getTimezone());
+});

+ 11 - 0
apps/backend-mock/api/timezone/getTimezoneOptions.ts

@@ -0,0 +1,11 @@
+import { eventHandler } from 'h3';
+import { TIME_ZONE_OPTIONS } from '~/utils/mock-data';
+import { useResponseSuccess } from '~/utils/response';
+
+export default eventHandler(() => {
+  const data = TIME_ZONE_OPTIONS.map((o) => ({
+    label: `${o.timezone} (GMT${o.offset >= 0 ? `+${o.offset}` : o.offset})`,
+    value: o.timezone,
+  }));
+  return useResponseSuccess(data);
+});

+ 22 - 0
apps/backend-mock/api/timezone/setTimezone.ts

@@ -0,0 +1,22 @@
+import { eventHandler, readBody } from 'h3';
+import { verifyAccessToken } from '~/utils/jwt-utils';
+import { TIME_ZONE_OPTIONS } from '~/utils/mock-data';
+import { unAuthorizedResponse, useResponseSuccess } from '~/utils/response';
+import { setTimezone } from '~/utils/timezone-utils';
+
+export default eventHandler(async (event) => {
+  const userinfo = verifyAccessToken(event);
+  if (!userinfo) {
+    return unAuthorizedResponse(event);
+  }
+  const body = await readBody<{ timezone?: unknown }>(event);
+  const timezone =
+    typeof body?.timezone === 'string' ? body.timezone : undefined;
+  const allowed = TIME_ZONE_OPTIONS.some((o) => o.timezone === timezone);
+  if (!timezone || !allowed) {
+    setResponseStatus(event, 400);
+    return useResponseError('Bad Request', 'Invalid timezone');
+  }
+  setTimezone(timezone);
+  return useResponseSuccess({});
+});

+ 31 - 0
apps/backend-mock/utils/mock-data.ts

@@ -7,6 +7,11 @@ export interface UserInfo {
   homePath?: string;
 }
 
+export interface TimezoneOption {
+  offset: number;
+  timezone: string;
+}
+
 export const MOCK_USERS: UserInfo[] = [
   {
     id: 0,
@@ -388,3 +393,29 @@ export function getMenuIds(menus: any[]) {
   });
   return ids;
 }
+
+/**
+ * 时区选项
+ */
+export const TIME_ZONE_OPTIONS: TimezoneOption[] = [
+  {
+    offset: -5,
+    timezone: 'America/New_York',
+  },
+  {
+    offset: 0,
+    timezone: 'Europe/London',
+  },
+  {
+    offset: 8,
+    timezone: 'Asia/Shanghai',
+  },
+  {
+    offset: 9,
+    timezone: 'Asia/Tokyo',
+  },
+  {
+    offset: 9,
+    timezone: 'Asia/Seoul',
+  },
+];

+ 9 - 0
apps/backend-mock/utils/timezone-utils.ts

@@ -0,0 +1,9 @@
+let mockTimeZone: null | string = null;
+
+export const setTimezone = (timeZone: string) => {
+  mockTimeZone = timeZone;
+};
+
+export const getTimezone = () => {
+  return mockTimeZone;
+};

+ 22 - 1
packages/@core/base/shared/src/utils/date.ts

@@ -1,4 +1,9 @@
 import dayjs from 'dayjs';
+import timezone from 'dayjs/plugin/timezone';
+import utc from 'dayjs/plugin/utc';
+
+dayjs.extend(utc);
+dayjs.extend(timezone);
 
 export function formatDate(time: number | string, format = 'YYYY-MM-DD') {
   try {
@@ -6,7 +11,7 @@ export function formatDate(time: number | string, format = 'YYYY-MM-DD') {
     if (!date.isValid()) {
       throw new Error('Invalid date');
     }
-    return date.format(format);
+    return date.tz().format(format);
   } catch (error) {
     console.error(`Error formatting date: ${error}`);
     return time;
@@ -24,3 +29,19 @@ export function isDate(value: any): value is Date {
 export function isDayjsObject(value: any): value is dayjs.Dayjs {
   return dayjs.isDayjs(value);
 }
+
+/**
+ * 设置默认时区
+ * @param timezone
+ */
+export const setDefaultTimezone = (timezone?: string) => {
+  timezone ? dayjs.tz.setDefault(timezone) : dayjs.tz.setDefault();
+};
+
+/**
+ * 获取当前时区
+ * @returns 当前时区
+ */
+export const getTimezone = () => {
+  return dayjs.tz.guess();
+};

+ 10 - 0
packages/@core/base/typings/src/app.d.ts

@@ -93,6 +93,15 @@ type PageTransitionType = 'fade' | 'fade-down' | 'fade-slide' | 'fade-up';
  */
 type AuthPageLayoutType = 'panel-center' | 'panel-left' | 'panel-right';
 
+/**
+ * 时区选项
+ */
+interface TimezoneOption {
+  label: string;
+  offset: number;
+  timezone: string;
+}
+
 export type {
   AccessModeType,
   AuthPageLayoutType,
@@ -108,4 +117,5 @@ export type {
   PreferencesButtonPositionType,
   TabsStyleType,
   ThemeModeType,
+  TimezoneOption,
 };

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

@@ -133,6 +133,7 @@ exports[`defaultPreferences immutability test > should not modify the config obj
     "refresh": true,
     "sidebarToggle": true,
     "themeToggle": true,
+    "timezone": true,
   },
 }
 `;

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

@@ -134,6 +134,7 @@ const defaultPreferences: Preferences = {
     refresh: true,
     sidebarToggle: true,
     themeToggle: true,
+    timezone: true,
   },
 };
 

+ 33 - 2
packages/@core/preferences/src/constants.ts

@@ -1,4 +1,4 @@
-import type { BuiltinThemeType } from '@vben-core/typings';
+import type { BuiltinThemeType, TimezoneOption } from '@vben-core/typings';
 
 interface BuiltinThemePreset {
   color: string;
@@ -81,8 +81,39 @@ const BUILT_IN_THEME_PRESETS: BuiltinThemePreset[] = [
   },
 ];
 
+/**
+ * 时区选项
+ */
+const DEFAULT_TIME_ZONE_OPTIONS: TimezoneOption[] = [
+  {
+    offset: -5,
+    timezone: 'America/New_York',
+    label: 'America/New_York(GMT-5)',
+  },
+  {
+    offset: 0,
+    timezone: 'Europe/London',
+    label: 'Europe/London(GMT0)',
+  },
+  {
+    offset: 8,
+    timezone: 'Asia/Shanghai',
+    label: 'Asia/Shanghai(GMT+8)',
+  },
+  {
+    offset: 9,
+    timezone: 'Asia/Tokyo',
+    label: 'Asia/Tokyo(GMT+9)',
+  },
+  {
+    offset: 9,
+    timezone: 'Asia/Seoul',
+    label: 'Asia/Seoul(GMT+9)',
+  },
+];
+
 export const COLOR_PRESETS = [...BUILT_IN_THEME_PRESETS].slice(0, 7);
 
-export { BUILT_IN_THEME_PRESETS };
+export { BUILT_IN_THEME_PRESETS, DEFAULT_TIME_ZONE_OPTIONS };
 
 export type { BuiltinThemePreset };

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

@@ -275,6 +275,8 @@ interface WidgetPreferences {
   sidebarToggle: boolean;
   /** 是否显示主题切换部件 */
   themeToggle: boolean;
+  /** 是否显示时区部件 */
+  timezone: boolean;
 }
 
 interface Preferences {

+ 12 - 2
packages/effects/layouts/src/basic/header/header.vue

@@ -13,6 +13,7 @@ import {
   LanguageToggle,
   PreferencesButton,
   ThemeToggle,
+  TimezoneButton,
 } from '../../widgets';
 
 interface Props {
@@ -66,15 +67,21 @@ const rightSlots = computed(() => {
       name: 'language-toggle',
     });
   }
-  if (preferences.widget.fullscreen) {
+  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 + 50,
+      index: REFERENCE_VALUE + 60,
       name: 'notification',
     });
   }
@@ -166,6 +173,9 @@ function clearPreferencesAndLogout() {
         <template v-else-if="slot.name === 'fullscreen'">
           <VbenFullScreen class="mr-1" />
         </template>
+        <template v-else-if="slot.name === 'timezone'">
+          <TimezoneButton class="mr-1 mt-[2px]" />
+        </template>
       </slot>
     </template>
   </div>

+ 3 - 0
packages/effects/layouts/src/basic/layout.vue

@@ -302,6 +302,9 @@ const headerSlots = computed(() => {
         <template #notification>
           <slot name="notification"></slot>
         </template>
+        <template #timezone>
+          <slot name="timezone"></slot>
+        </template>
         <template v-for="item in headerSlots" #[item]>
           <slot :name="item"></slot>
         </template>

+ 1 - 0
packages/effects/layouts/src/widgets/index.ts

@@ -8,4 +8,5 @@ export * from './lock-screen';
 export * from './notification';
 export * from './preferences';
 export * from './theme-toggle';
+export * from './timezone';
 export * from './user-dropdown';

+ 1 - 0
packages/effects/layouts/src/widgets/timezone/index.ts

@@ -0,0 +1 @@
+export { default as TimezoneButton } from './timezone-button.vue';

+ 87 - 0
packages/effects/layouts/src/widgets/timezone/timezone-button.vue

@@ -0,0 +1,87 @@
+<script setup lang="ts">
+import { ref, unref } from 'vue';
+
+import { createIconifyIcon } from '@vben/icons';
+import { $t } from '@vben/locales';
+import { useTimezoneStore } from '@vben/stores';
+
+import { useVbenModal } from '@vben-core/popup-ui';
+import {
+  RadioGroup,
+  RadioGroupItem,
+  VbenIconButton,
+} from '@vben-core/shadcn-ui';
+
+const TimezoneIcon = createIconifyIcon('fluent-mdl2:world-clock');
+
+const timezoneStore = useTimezoneStore();
+
+const timezoneRef = ref<string | undefined>();
+
+const timezoneOptionsRef = ref<
+  {
+    label: string;
+    value: string;
+  }[]
+>([]);
+
+const [Modal, modalApi] = useVbenModal({
+  fullscreenButton: false,
+  onConfirm: async () => {
+    try {
+      modalApi.setState({ confirmLoading: true });
+      const timezone = unref(timezoneRef);
+      if (timezone) {
+        await timezoneStore.setTimezone(timezone);
+      }
+      modalApi.close();
+    } finally {
+      modalApi.setState({ confirmLoading: false });
+    }
+  },
+  async onOpenChange(isOpen) {
+    if (isOpen) {
+      timezoneRef.value = unref(timezoneStore.timezone);
+      timezoneOptionsRef.value = await timezoneStore.getTimezoneOptions();
+    }
+  },
+});
+
+const handleClick = () => {
+  modalApi.open();
+};
+</script>
+
+<template>
+  <div>
+    <VbenIconButton
+      :tooltip="$t('ui.widgets.timezone.setTimezone')"
+      class="hover:animate-[shrink_0.3s_ease-in-out]"
+      @click="handleClick"
+    >
+      <TimezoneIcon class="text-foreground size-4" />
+    </VbenIconButton>
+    <Modal :title="$t('ui.widgets.timezone.setTimezone')">
+      <div class="timezone-container">
+        <RadioGroup v-model="timezoneRef" class="flex flex-col gap-2">
+          <div
+            class="flex cursor-pointer items-center gap-2"
+            v-for="item in timezoneOptionsRef"
+            :key="`container${item.value}`"
+          >
+            <RadioGroupItem :id="item.value" :value="item.value" />
+            <label :for="item.value" class="cursor-pointer">{{
+              item.label
+            }}</label>
+          </div>
+        </RadioGroup>
+      </div>
+    </Modal>
+  </div>
+</template>
+
+<style scoped>
+.timezone-container {
+  padding-left: 20px;
+}
+</style>

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

@@ -102,6 +102,10 @@
       "errorPasswordTip": "Password error, please re-enter",
       "backToLogin": "Back to login",
       "entry": "Enter the system"
+    },
+    "timezone": {
+      "setTimezone": "Set Timezone",
+      "setSuccess": "Timezone set successfully"
     }
   }
 }

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

@@ -102,6 +102,10 @@
       "errorPasswordTip": "密码错误,请重新输入",
       "backToLogin": "返回登录",
       "entry": "进入系统"
+    },
+    "timezone": {
+      "setTimezone": "设置时区",
+      "setSuccess": "时区设置成功"
     }
   }
 }

+ 1 - 0
packages/stores/src/modules/index.ts

@@ -1,3 +1,4 @@
 export * from './access';
 export * from './tabbar';
+export * from './timezone';
 export * from './user';

+ 126 - 0
packages/stores/src/modules/timezone.ts

@@ -0,0 +1,126 @@
+import { ref, unref } from 'vue';
+
+import { DEFAULT_TIME_ZONE_OPTIONS } from '@vben-core/preferences';
+import { getTimezone, setDefaultTimezone } from '@vben-core/shared/utils';
+
+import { acceptHMRUpdate, defineStore } from 'pinia';
+
+interface TimezoneHandler {
+  getTimezone?: () => Promise<null | string | undefined>;
+  getTimezoneOptions?: () => Promise<
+    {
+      label: string;
+      value: string;
+    }[]
+  >;
+  setTimezone?: (timezone: string) => Promise<void>;
+}
+
+/**
+ * 默认时区处理模块
+ * 时区存储基于pinia存储插件
+ */
+const getDefaultTimezoneHandler = (): TimezoneHandler => {
+  return {
+    getTimezoneOptions: () => {
+      return Promise.resolve(
+        DEFAULT_TIME_ZONE_OPTIONS.map((item) => {
+          return {
+            label: item.label,
+            value: item.timezone,
+          };
+        }),
+      );
+    },
+  };
+};
+
+/**
+ * 自定义时区处理模块
+ */
+let customTimezoneHandler: null | Partial<TimezoneHandler> = null;
+const setTimezoneHandler = (handler: Partial<TimezoneHandler>) => {
+  customTimezoneHandler = handler;
+};
+
+/**
+ * 获取时区处理模块
+ */
+const getTimezoneHandler = () => {
+  return {
+    ...getDefaultTimezoneHandler(),
+    ...customTimezoneHandler,
+  };
+};
+
+/**
+ * timezone支持模块
+ */
+const useTimezoneStore = defineStore(
+  'core-timezone',
+  () => {
+    const timezoneRef = ref(
+      getTimezone() || new Intl.DateTimeFormat().resolvedOptions().timeZone,
+    );
+
+    /**
+     * 初始化时区
+     * Initialize the timezone
+     */
+    async function initTimezone() {
+      const timezoneHandler = getTimezoneHandler();
+      const timezone = await timezoneHandler.getTimezone?.();
+      if (timezone) {
+        timezoneRef.value = timezone;
+      }
+      // 设置dayjs默认时区
+      setDefaultTimezone(unref(timezoneRef));
+    }
+
+    /**
+     * 设置时区
+     * Set the timezone
+     * @param timezone 时区字符串
+     */
+    async function setTimezone(timezone: string) {
+      const timezoneHandler = getTimezoneHandler();
+      await timezoneHandler.setTimezone?.(timezone);
+      timezoneRef.value = timezone;
+      // 设置dayjs默认时区
+      setDefaultTimezone(timezone);
+    }
+
+    /**
+     * 获取时区选项
+     * Get the timezone options
+     */
+    async function getTimezoneOptions() {
+      const timezoneHandler = getTimezoneHandler();
+      return (await timezoneHandler.getTimezoneOptions?.()) || [];
+    }
+
+    initTimezone().catch((error) => {
+      console.error('Failed to initialize timezone during store setup:', error);
+    });
+
+    return {
+      timezone: timezoneRef,
+      setTimezone,
+      getTimezoneOptions,
+    };
+  },
+  {
+    persist: {
+      // 持久化
+      pick: ['timezone'],
+    },
+  },
+);
+
+export { setTimezoneHandler, useTimezoneStore };
+
+// 解决热更新问题
+const hot = import.meta.hot;
+if (hot) {
+  hot.accept(acceptHMRUpdate(useTimezoneStore, hot));
+}

+ 1 - 0
playground/src/api/core/index.ts

@@ -1,3 +1,4 @@
 export * from './auth';
 export * from './menu';
+export * from './timezone';
 export * from './user';

+ 26 - 0
playground/src/api/core/timezone.ts

@@ -0,0 +1,26 @@
+import { requestClient } from '#/api/request';
+
+/**
+ * 获取系统支持的时区列表
+ */
+export async function getTimezoneOptionsApi() {
+  return await requestClient.get<
+    {
+      label: string;
+      value: string;
+    }[]
+  >('/timezone/getTimezoneOptions');
+}
+/**
+ * 获取用户时区
+ */
+export async function getTimezoneApi(): Promise<null | string | undefined> {
+  return requestClient.get<null | string | undefined>('/timezone/getTimezone');
+}
+/**
+ * 设置用户时区
+ * @param timezone 时区
+ */
+export async function setTimezoneApi(timezone: string): Promise<void> {
+  return requestClient.post('/timezone/setTimezone', { timezone });
+}

+ 4 - 0
playground/src/bootstrap.ts

@@ -15,6 +15,7 @@ import { router } from '#/router';
 import { initComponentAdapter } from './adapter/component';
 import { initSetupVbenForm } from './adapter/form';
 import App from './app.vue';
+import { initTimezone } from './timezone-init';
 
 async function bootstrap(namespace: string) {
   // 初始化组件适配器
@@ -46,6 +47,9 @@ async function bootstrap(namespace: string) {
   // 配置 pinia-tore
   await initStores(app, { namespace });
 
+  // 初始化时区HANDLER
+  initTimezone();
+
   // 安装权限指令
   registerAccessDirective(app);
 

+ 20 - 0
playground/src/timezone-init.ts

@@ -0,0 +1,20 @@
+import { setTimezoneHandler } from '@vben/stores';
+
+import { getTimezoneApi, getTimezoneOptionsApi, setTimezoneApi } from '#/api';
+
+/**
+ * 初始化时区处理,通过API保存时区设置
+ */
+export function initTimezone() {
+  setTimezoneHandler({
+    getTimezone() {
+      return getTimezoneApi();
+    },
+    setTimezone(timezone: string) {
+      return setTimezoneApi(timezone);
+    },
+    getTimezoneOptions() {
+      return getTimezoneOptionsApi();
+    },
+  });
+}