Forráskód Böngészése

feat: increase support for multiple time zones

zhongming4762 7 hónapja
szülő
commit
0a8339a405
32 módosított fájl, 577 hozzáadás és 9 törlés
  1. 7 0
      apps/backend-mock/api/profile/timezone.ts
  2. 14 0
      apps/backend-mock/api/user/setTimezone.ts
  3. 12 0
      apps/backend-mock/api/user/timezone.ts
  4. 31 0
      apps/backend-mock/utils/mock-data.ts
  5. 10 0
      apps/backend-mock/utils/timezone-utils.ts
  6. 1 0
      apps/web-antd/src/api/core/index.ts
  7. 23 0
      apps/web-antd/src/api/core/user-profile.ts
  8. 38 2
      apps/web-antd/src/layouts/basic.vue
  9. 1 0
      apps/web-antd/src/store/index.ts
  10. 56 0
      apps/web-antd/src/store/user-profile.ts
  11. 1 0
      apps/web-ele/src/api/core/index.ts
  12. 23 0
      apps/web-ele/src/api/core/user-profile.ts
  13. 38 2
      apps/web-ele/src/layouts/basic.vue
  14. 1 0
      apps/web-ele/src/store/index.ts
  15. 56 0
      apps/web-ele/src/store/user-profile.ts
  16. 1 0
      apps/web-naive/src/api/core/index.ts
  17. 23 0
      apps/web-naive/src/api/core/user-profile.ts
  18. 39 2
      apps/web-naive/src/layouts/basic.vue
  19. 1 0
      apps/web-naive/src/store/index.ts
  20. 56 0
      apps/web-naive/src/store/user-profile.ts
  21. 22 1
      packages/@core/base/shared/src/utils/date.ts
  22. 1 0
      packages/@core/base/typings/src/index.ts
  23. 9 0
      packages/@core/base/typings/src/user-profile.d.ts
  24. 1 0
      packages/@core/preferences/src/config.ts
  25. 2 0
      packages/@core/preferences/src/types.ts
  26. 8 2
      packages/effects/layouts/src/basic/header/header.vue
  27. 3 0
      packages/effects/layouts/src/basic/layout.vue
  28. 1 0
      packages/effects/layouts/src/widgets/index.ts
  29. 1 0
      packages/effects/layouts/src/widgets/timezone/index.ts
  30. 89 0
      packages/effects/layouts/src/widgets/timezone/timezone-button.vue
  31. 4 0
      packages/locales/src/langs/en-US/ui.json
  32. 4 0
      packages/locales/src/langs/zh-CN/ui.json

+ 7 - 0
apps/backend-mock/api/profile/timezone.ts

@@ -0,0 +1,7 @@
+import { eventHandler } from 'h3';
+import { TIME_ZONE_OPTIONS } from '~/utils/mock-data';
+import { useResponseSuccess } from '~/utils/response';
+
+export default eventHandler(() => {
+  return useResponseSuccess(TIME_ZONE_OPTIONS);
+});

+ 14 - 0
apps/backend-mock/api/user/setTimezone.ts

@@ -0,0 +1,14 @@
+import { eventHandler, readBody } from 'h3';
+import { verifyAccessToken } from '~/utils/jwt-utils';
+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 { timezone } = await readBody(event);
+  setTimezone(timezone);
+  return useResponseSuccess();
+});

+ 12 - 0
apps/backend-mock/api/user/timezone.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());
+});

+ 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',
+  },
+];

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

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

+ 1 - 0
apps/web-antd/src/api/core/index.ts

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

+ 23 - 0
apps/web-antd/src/api/core/user-profile.ts

@@ -0,0 +1,23 @@
+import type { TimezoneOption } from '@vben/types';
+
+import { requestClient } from '#/api/request';
+
+/**
+ * 获取系统支持的时区列表
+ */
+export async function getTimezoneOptionsApi() {
+  return requestClient.get<TimezoneOption[]>('/profile/timezone');
+}
+/**
+ * 获取用户时区
+ */
+export async function getUserTimezoneApi(): Promise<null | string | undefined> {
+  return requestClient.get<null | string | undefined>('/user/timezone');
+}
+/**
+ * 设置用户时区
+ * @param timezone 时区
+ */
+export async function setUserTimezoneApi(timezone: string) {
+  return requestClient.post('/user/setTimezone', { timezone });
+}

+ 38 - 2
apps/web-antd/src/layouts/basic.vue

@@ -1,7 +1,8 @@
 <script lang="ts" setup>
+import type { ExtendedModalApi } from '@vben/common-ui';
 import type { NotificationItem } from '@vben/layouts';
 
-import { computed, ref, watch } from 'vue';
+import { computed, onMounted, ref, watch } from 'vue';
 
 import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
 import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
@@ -11,14 +12,18 @@ import {
   BasicLayout,
   LockScreen,
   Notification,
+  TimezoneButton,
   UserDropdown,
 } from '@vben/layouts';
 import { preferences } from '@vben/preferences';
 import { useAccessStore, useUserStore } from '@vben/stores';
 import { openWindow } from '@vben/utils';
 
+import { message } from 'ant-design-vue';
+
+import { getTimezoneOptionsApi } from '#/api';
 import { $t } from '#/locales';
-import { useAuthStore } from '#/store';
+import { useAuthStore, useUserProfileStore } from '#/store';
 import LoginForm from '#/views/_core/authentication/login.vue';
 
 const notifications = ref<NotificationItem[]>([
@@ -60,6 +65,29 @@ const showDot = computed(() =>
   notifications.value.some((item) => !item.isRead),
 );
 
+const userProfileStore = useUserProfileStore();
+const computedTimezone = computed(() => userProfileStore.timezone);
+
+const timezoneOptions = ref<string[]>([]);
+onMounted(async () => {
+  timezoneOptions.value = ((await getTimezoneOptionsApi()) || []).map(
+    (item) => item.timezone,
+  );
+});
+const handleSetTimezone = async (
+  timezone: string,
+  modalApi: ExtendedModalApi,
+) => {
+  try {
+    modalApi.setState({ confirmLoading: true });
+    await userProfileStore.setTimezone(timezone);
+    message.success($t('ui.widgets.timezone.setSuccess'));
+    modalApi.close();
+  } finally {
+    modalApi.setState({ confirmLoading: false });
+  }
+};
+
 const menus = computed(() => [
   {
     handler: () => {
@@ -147,6 +175,14 @@ watch(
         @make-all="handleMakeAll"
       />
     </template>
+    <template #timezone>
+      <TimezoneButton
+        :ok-handler="handleSetTimezone"
+        :timezone="computedTimezone"
+        :timezone-options="timezoneOptions"
+        name="out"
+      />
+    </template>
     <template #extra>
       <AuthenticationLoginExpiredModal
         v-model:open="accessStore.loginExpired"

+ 1 - 0
apps/web-antd/src/store/index.ts

@@ -1 +1,2 @@
 export * from './auth';
+export * from './user-profile';

+ 56 - 0
apps/web-antd/src/store/user-profile.ts

@@ -0,0 +1,56 @@
+import { ref } from 'vue';
+
+import { getTimezone, setDefaultTimezone } from '@vben/utils';
+
+import { acceptHMRUpdate, defineStore } from 'pinia';
+
+import { getUserTimezoneApi, setUserTimezoneApi } from '#/api';
+
+const useUserProfileStore = defineStore('user-profile', () => {
+  const timezoneRef = ref(
+    getTimezone() || new Intl.DateTimeFormat().resolvedOptions().timeZone,
+  );
+
+  /**
+   * 设置用户时区
+   * Set the user's timezone
+   * @param timezone 时区字符串
+   */
+  async function setTimezone(timezone: string) {
+    timezoneRef.value = timezone;
+    // 设置dayjs默认时区
+    setDefaultTimezone(timezone);
+    // 保存用户的时区设置
+    await setUserTimezoneApi(timezone);
+  }
+
+  /**
+   * 初始化用户时区
+   * Initialize the user's timezone
+   */
+  async function initTimezone() {
+    // 从服务器获取用户时区
+    const timezone = await getUserTimezoneApi();
+    if (timezone) {
+      timezoneRef.value = timezone;
+      // 设置dayjs默认时区
+      setDefaultTimezone(timezone);
+    }
+  }
+
+  initTimezone();
+
+  return {
+    timezone: timezoneRef,
+    setTimezone,
+    initTimezone,
+  };
+});
+
+export { useUserProfileStore };
+
+// 解决热更新问题
+const hot = import.meta.hot;
+if (hot) {
+  hot.accept(acceptHMRUpdate(useUserProfileStore, hot));
+}

+ 1 - 0
apps/web-ele/src/api/core/index.ts

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

+ 23 - 0
apps/web-ele/src/api/core/user-profile.ts

@@ -0,0 +1,23 @@
+import type { TimezoneOption } from '@vben/types';
+
+import { requestClient } from '#/api/request';
+
+/**
+ * 获取系统支持的时区列表
+ */
+export async function getTimezoneOptionsApi() {
+  return requestClient.get<TimezoneOption[]>('/profile/timezone');
+}
+/**
+ * 获取用户时区
+ */
+export async function getUserTimezoneApi(): Promise<null | string | undefined> {
+  return requestClient.get<null | string | undefined>('/user/timezone');
+}
+/**
+ * 设置用户时区
+ * @param timezone 时区
+ */
+export async function setUserTimezoneApi(timezone: string) {
+  return requestClient.post('/user/setTimezone', { timezone });
+}

+ 38 - 2
apps/web-ele/src/layouts/basic.vue

@@ -1,7 +1,8 @@
 <script lang="ts" setup>
+import type { ExtendedModalApi } from '@vben/common-ui';
 import type { NotificationItem } from '@vben/layouts';
 
-import { computed, ref, watch } from 'vue';
+import { computed, onMounted, ref, watch } from 'vue';
 
 import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
 import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
@@ -11,14 +12,18 @@ import {
   BasicLayout,
   LockScreen,
   Notification,
+  TimezoneButton,
   UserDropdown,
 } from '@vben/layouts';
 import { preferences } from '@vben/preferences';
 import { useAccessStore, useUserStore } from '@vben/stores';
 import { openWindow } from '@vben/utils';
 
+import { ElMessage } from 'element-plus';
+
+import { getTimezoneOptionsApi } from '#/api';
 import { $t } from '#/locales';
-import { useAuthStore } from '#/store';
+import { useAuthStore, useUserProfileStore } from '#/store';
 import LoginForm from '#/views/_core/authentication/login.vue';
 
 const notifications = ref<NotificationItem[]>([
@@ -60,6 +65,29 @@ const showDot = computed(() =>
   notifications.value.some((item) => !item.isRead),
 );
 
+const userProfileStore = useUserProfileStore();
+const computedTimezone = computed(() => userProfileStore.timezone);
+
+const timezoneOptions = ref<string[]>([]);
+onMounted(async () => {
+  timezoneOptions.value = ((await getTimezoneOptionsApi()) || []).map(
+    (item) => item.timezone,
+  );
+});
+const handleSetTimezone = async (
+  timezone: string,
+  modalApi: ExtendedModalApi,
+) => {
+  try {
+    modalApi.setState({ confirmLoading: true });
+    await userProfileStore.setTimezone(timezone);
+    ElMessage.success($t('ui.widgets.timezone.setSuccess'));
+    modalApi.close();
+  } finally {
+    modalApi.setState({ confirmLoading: false });
+  }
+};
+
 const menus = computed(() => [
   {
     handler: () => {
@@ -147,6 +175,14 @@ watch(
         @make-all="handleMakeAll"
       />
     </template>
+    <template #timezone>
+      <TimezoneButton
+        :ok-handler="handleSetTimezone"
+        :timezone="computedTimezone"
+        :timezone-options="timezoneOptions"
+        name="out"
+      />
+    </template>
     <template #extra>
       <AuthenticationLoginExpiredModal
         v-model:open="accessStore.loginExpired"

+ 1 - 0
apps/web-ele/src/store/index.ts

@@ -1 +1,2 @@
 export * from './auth';
+export * from './user-profile';

+ 56 - 0
apps/web-ele/src/store/user-profile.ts

@@ -0,0 +1,56 @@
+import { ref } from 'vue';
+
+import { getTimezone, setDefaultTimezone } from '@vben/utils';
+
+import { acceptHMRUpdate, defineStore } from 'pinia';
+
+import { getUserTimezoneApi, setUserTimezoneApi } from '#/api';
+
+const useUserProfileStore = defineStore('user-profile', () => {
+  const timezoneRef = ref(
+    getTimezone() || new Intl.DateTimeFormat().resolvedOptions().timeZone,
+  );
+
+  /**
+   * 设置用户时区
+   * Set the user's timezone
+   * @param timezone 时区字符串
+   */
+  async function setTimezone(timezone: string) {
+    timezoneRef.value = timezone;
+    // 设置dayjs默认时区
+    setDefaultTimezone(timezone);
+    // 保存用户的时区设置
+    await setUserTimezoneApi(timezone);
+  }
+
+  /**
+   * 初始化用户时区
+   * Initialize the user's timezone
+   */
+  async function initTimezone() {
+    // 从服务器获取用户时区
+    const timezone = await getUserTimezoneApi();
+    if (timezone) {
+      timezoneRef.value = timezone;
+      // 设置dayjs默认时区
+      setDefaultTimezone(timezone);
+    }
+  }
+
+  initTimezone();
+
+  return {
+    timezone: timezoneRef,
+    setTimezone,
+    initTimezone,
+  };
+});
+
+export { useUserProfileStore };
+
+// 解决热更新问题
+const hot = import.meta.hot;
+if (hot) {
+  hot.accept(acceptHMRUpdate(useUserProfileStore, hot));
+}

+ 1 - 0
apps/web-naive/src/api/core/index.ts

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

+ 23 - 0
apps/web-naive/src/api/core/user-profile.ts

@@ -0,0 +1,23 @@
+import type { TimezoneOption } from '@vben/types';
+
+import { requestClient } from '#/api/request';
+
+/**
+ * 获取系统支持的时区列表
+ */
+export async function getTimezoneOptionsApi() {
+  return requestClient.get<TimezoneOption[]>('/profile/timezone');
+}
+/**
+ * 获取用户时区
+ */
+export async function getUserTimezoneApi(): Promise<null | string | undefined> {
+  return requestClient.get<null | string | undefined>('/user/timezone');
+}
+/**
+ * 设置用户时区
+ * @param timezone 时区
+ */
+export async function setUserTimezoneApi(timezone: string) {
+  return requestClient.post('/user/setTimezone', { timezone });
+}

+ 39 - 2
apps/web-naive/src/layouts/basic.vue

@@ -1,7 +1,8 @@
 <script lang="ts" setup>
+import type { ExtendedModalApi } from '@vben/common-ui';
 import type { NotificationItem } from '@vben/layouts';
 
-import { computed, ref, watch } from 'vue';
+import { computed, onMounted, ref, watch } from 'vue';
 
 import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
 import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
@@ -11,16 +12,21 @@ import {
   BasicLayout,
   LockScreen,
   Notification,
+  TimezoneButton,
   UserDropdown,
 } from '@vben/layouts';
 import { preferences } from '@vben/preferences';
 import { useAccessStore, useUserStore } from '@vben/stores';
 import { openWindow } from '@vben/utils';
 
+import { useMessage } from 'naive-ui';
+
+import { getTimezoneOptionsApi } from '#/api';
 import { $t } from '#/locales';
-import { useAuthStore } from '#/store';
+import { useAuthStore, useUserProfileStore } from '#/store';
 import LoginForm from '#/views/_core/authentication/login.vue';
 
+const message = useMessage();
 const notifications = ref<NotificationItem[]>([
   {
     avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
@@ -60,6 +66,29 @@ const showDot = computed(() =>
   notifications.value.some((item) => !item.isRead),
 );
 
+const userProfileStore = useUserProfileStore();
+const computedTimezone = computed(() => userProfileStore.timezone);
+
+const timezoneOptions = ref<string[]>([]);
+onMounted(async () => {
+  timezoneOptions.value = ((await getTimezoneOptionsApi()) || []).map(
+    (item) => item.timezone,
+  );
+});
+const handleSetTimezone = async (
+  timezone: string,
+  modalApi: ExtendedModalApi,
+) => {
+  try {
+    modalApi.setState({ confirmLoading: true });
+    await userProfileStore.setTimezone(timezone);
+    message.success($t('ui.widgets.timezone.setSuccess'));
+    modalApi.close();
+  } finally {
+    modalApi.setState({ confirmLoading: false });
+  }
+};
+
 const menus = computed(() => [
   {
     handler: () => {
@@ -148,6 +177,14 @@ watch(
         @make-all="handleMakeAll"
       />
     </template>
+    <template #timezone>
+      <TimezoneButton
+        :ok-handler="handleSetTimezone"
+        :timezone="computedTimezone"
+        :timezone-options="timezoneOptions"
+        name="out"
+      />
+    </template>
     <template #extra>
       <AuthenticationLoginExpiredModal
         v-model:open="accessStore.loginExpired"

+ 1 - 0
apps/web-naive/src/store/index.ts

@@ -1 +1,2 @@
 export * from './auth';
+export * from './user-profile';

+ 56 - 0
apps/web-naive/src/store/user-profile.ts

@@ -0,0 +1,56 @@
+import { ref } from 'vue';
+
+import { getTimezone, setDefaultTimezone } from '@vben/utils';
+
+import { acceptHMRUpdate, defineStore } from 'pinia';
+
+import { getUserTimezoneApi, setUserTimezoneApi } from '#/api';
+
+const useUserProfileStore = defineStore('user-profile', () => {
+  const timezoneRef = ref(
+    getTimezone() || new Intl.DateTimeFormat().resolvedOptions().timeZone,
+  );
+
+  /**
+   * 设置用户时区
+   * Set the user's timezone
+   * @param timezone 时区字符串
+   */
+  async function setTimezone(timezone: string) {
+    timezoneRef.value = timezone;
+    // 设置dayjs默认时区
+    setDefaultTimezone(timezone);
+    // 保存用户的时区设置
+    await setUserTimezoneApi(timezone);
+  }
+
+  /**
+   * 初始化用户时区
+   * Initialize the user's timezone
+   */
+  async function initTimezone() {
+    // 从服务器获取用户时区
+    const timezone = await getUserTimezoneApi();
+    if (timezone) {
+      timezoneRef.value = timezone;
+      // 设置dayjs默认时区
+      setDefaultTimezone(timezone);
+    }
+  }
+
+  initTimezone();
+
+  return {
+    timezone: timezoneRef,
+    setTimezone,
+    initTimezone,
+  };
+});
+
+export { useUserProfileStore };
+
+// 解决热更新问题
+const hot = import.meta.hot;
+if (hot) {
+  hot.accept(acceptHMRUpdate(useUserProfileStore, hot));
+}

+ 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();
+};

+ 1 - 0
packages/@core/base/typings/src/index.ts

@@ -3,4 +3,5 @@ export type * from './basic';
 export type * from './helper';
 export type * from './menu-record';
 export type * from './tabs';
+export type * from './user-profile';
 export type * from './vue-router';

+ 9 - 0
packages/@core/base/typings/src/user-profile.d.ts

@@ -0,0 +1,9 @@
+/**
+ * 时区选项
+ */
+interface TimezoneOption {
+  offset: number;
+  timezone: string;
+}
+
+export type { TimezoneOption };

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

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

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

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

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

@@ -66,15 +66,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',
     });
   }

+ 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';

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

@@ -0,0 +1,89 @@
+<script setup lang="ts">
+import type { ExtendedModalApi } from '@vben-core/popup-ui';
+
+import { ref, unref, watch } from 'vue';
+
+import { createIconifyIcon } from '@vben/icons';
+import { $t } from '@vben/locales';
+
+import { useVbenModal } from '@vben-core/popup-ui';
+import {
+  RadioGroup,
+  RadioGroupItem,
+  VbenIconButton,
+} from '@vben-core/shadcn-ui';
+
+interface Props {
+  timezoneOptions: string[];
+  okHandler?: (
+    timezone: string,
+    modalApi: ExtendedModalApi,
+  ) => Promise<void> | void;
+  timezone?: string;
+}
+
+interface Listener {
+  change: (timezone: string) => void;
+}
+
+const props = defineProps<Props>();
+const emit = defineEmits<Listener>();
+
+const TimezoneIcon = createIconifyIcon('fluent-mdl2:world-clock');
+
+const [Modal, modalApi] = useVbenModal({
+  fullscreenButton: false,
+  onConfirm: () => {
+    props.okHandler?.(unref(timezoneValue), modalApi);
+  },
+});
+
+const handleClick = () => {
+  modalApi.open();
+};
+
+const timezoneValue = ref(props.timezone);
+watch(
+  () => props.timezone,
+  (newTimezone) => {
+    timezoneValue.value = newTimezone;
+  },
+);
+const handleClickItem = (timezone: string) => {
+  timezoneValue.value = timezone;
+  emit('change', timezone);
+};
+</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="timezoneValue" class="flex flex-col gap-2">
+          <div
+            class="flex cursor-pointer items-center gap-2"
+            v-for="item in props.timezoneOptions"
+            :key="`container${item}`"
+            @click="handleClickItem(item)"
+          >
+            <RadioGroupItem :id="item" :value="item" />
+            <label class="cursor-pointer">{{ item }}</label>
+          </div>
+        </RadioGroup>
+      </div>
+    </Modal>
+  </div>
+</template>
+
+<style scoped>
+.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": "时区设置成功"
     }
   }
 }