diff --git a/apps/backend-mock/api/timezone/getTimezone.ts b/apps/backend-mock/api/timezone/getTimezone.ts new file mode 100644 index 00000000000..0cbcb6ec01f --- /dev/null +++ b/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()); +}); diff --git a/apps/backend-mock/api/timezone/getTimezoneOptions.ts b/apps/backend-mock/api/timezone/getTimezoneOptions.ts new file mode 100644 index 00000000000..6c241864ecb --- /dev/null +++ b/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); +}); diff --git a/apps/backend-mock/api/timezone/setTimezone.ts b/apps/backend-mock/api/timezone/setTimezone.ts new file mode 100644 index 00000000000..34d8f19e2cf --- /dev/null +++ b/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({}); +}); diff --git a/apps/backend-mock/utils/mock-data.ts b/apps/backend-mock/utils/mock-data.ts index 689de2a1612..ee38e8ef91f 100644 --- a/apps/backend-mock/utils/mock-data.ts +++ b/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', + }, +]; diff --git a/apps/backend-mock/utils/timezone-utils.ts b/apps/backend-mock/utils/timezone-utils.ts new file mode 100644 index 00000000000..da35f920f04 --- /dev/null +++ b/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; +}; diff --git a/packages/@core/base/shared/src/utils/date.ts b/packages/@core/base/shared/src/utils/date.ts index 3736b9ad591..4b80308b953 100644 --- a/packages/@core/base/shared/src/utils/date.ts +++ b/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(); +}; diff --git a/packages/@core/base/typings/src/app.d.ts b/packages/@core/base/typings/src/app.d.ts index d2e86aec4e3..f2b443359d1 100644 --- a/packages/@core/base/typings/src/app.d.ts +++ b/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, }; diff --git a/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap b/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap index b3f159805d7..a6452b39930 100644 --- a/packages/@core/preferences/__tests__/__snapshots__/config.test.ts.snap +++ b/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, }, } `; diff --git a/packages/@core/preferences/src/config.ts b/packages/@core/preferences/src/config.ts index b8b7212aabd..5b8d7236398 100644 --- a/packages/@core/preferences/src/config.ts +++ b/packages/@core/preferences/src/config.ts @@ -134,6 +134,7 @@ const defaultPreferences: Preferences = { refresh: true, sidebarToggle: true, themeToggle: true, + timezone: true, }, }; diff --git a/packages/@core/preferences/src/constants.ts b/packages/@core/preferences/src/constants.ts index 430ee3b002c..7ec2007d027 100644 --- a/packages/@core/preferences/src/constants.ts +++ b/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 }; diff --git a/packages/@core/preferences/src/types.ts b/packages/@core/preferences/src/types.ts index 0c90da806f9..584e919f569 100644 --- a/packages/@core/preferences/src/types.ts +++ b/packages/@core/preferences/src/types.ts @@ -275,6 +275,8 @@ interface WidgetPreferences { sidebarToggle: boolean; /** 是否显示主题切换部件 */ themeToggle: boolean; + /** 是否显示时区部件 */ + timezone: boolean; } interface Preferences { diff --git a/packages/effects/layouts/src/basic/header/header.vue b/packages/effects/layouts/src/basic/header/header.vue index 7a8cc857a01..26c92a6462d 100644 --- a/packages/effects/layouts/src/basic/header/header.vue +++ b/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() { + diff --git a/packages/effects/layouts/src/basic/layout.vue b/packages/effects/layouts/src/basic/layout.vue index 35f28c1d379..6644e8a9e34 100644 --- a/packages/effects/layouts/src/basic/layout.vue +++ b/packages/effects/layouts/src/basic/layout.vue @@ -302,6 +302,9 @@ const headerSlots = computed(() => { + diff --git a/packages/effects/layouts/src/widgets/index.ts b/packages/effects/layouts/src/widgets/index.ts index f6a4a7ba5b5..0f9e11a976e 100644 --- a/packages/effects/layouts/src/widgets/index.ts +++ b/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'; diff --git a/packages/effects/layouts/src/widgets/timezone/index.ts b/packages/effects/layouts/src/widgets/timezone/index.ts new file mode 100644 index 00000000000..eef486ddbf0 --- /dev/null +++ b/packages/effects/layouts/src/widgets/timezone/index.ts @@ -0,0 +1 @@ +export { default as TimezoneButton } from './timezone-button.vue'; diff --git a/packages/effects/layouts/src/widgets/timezone/timezone-button.vue b/packages/effects/layouts/src/widgets/timezone/timezone-button.vue new file mode 100644 index 00000000000..f426b94cd9c --- /dev/null +++ b/packages/effects/layouts/src/widgets/timezone/timezone-button.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/packages/locales/src/langs/en-US/ui.json b/packages/locales/src/langs/en-US/ui.json index 0179c2252ae..f920ee3399c 100644 --- a/packages/locales/src/langs/en-US/ui.json +++ b/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" } } } diff --git a/packages/locales/src/langs/zh-CN/ui.json b/packages/locales/src/langs/zh-CN/ui.json index da2dbeb04f9..3433bcb5846 100644 --- a/packages/locales/src/langs/zh-CN/ui.json +++ b/packages/locales/src/langs/zh-CN/ui.json @@ -102,6 +102,10 @@ "errorPasswordTip": "密码错误,请重新输入", "backToLogin": "返回登录", "entry": "进入系统" + }, + "timezone": { + "setTimezone": "设置时区", + "setSuccess": "时区设置成功" } } } diff --git a/packages/stores/src/modules/index.ts b/packages/stores/src/modules/index.ts index ec764ae8754..bb9a66f4809 100644 --- a/packages/stores/src/modules/index.ts +++ b/packages/stores/src/modules/index.ts @@ -1,3 +1,4 @@ export * from './access'; export * from './tabbar'; +export * from './timezone'; export * from './user'; diff --git a/packages/stores/src/modules/timezone.ts b/packages/stores/src/modules/timezone.ts new file mode 100644 index 00000000000..c0b8873e848 --- /dev/null +++ b/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; + getTimezoneOptions?: () => Promise< + { + label: string; + value: string; + }[] + >; + setTimezone?: (timezone: string) => Promise; +} + +/** + * 默认时区处理模块 + * 时区存储基于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 = null; +const setTimezoneHandler = (handler: Partial) => { + 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)); +} diff --git a/playground/src/api/core/index.ts b/playground/src/api/core/index.ts index 28a5aef47ef..7134366bc20 100644 --- a/playground/src/api/core/index.ts +++ b/playground/src/api/core/index.ts @@ -1,3 +1,4 @@ export * from './auth'; export * from './menu'; +export * from './timezone'; export * from './user'; diff --git a/playground/src/api/core/timezone.ts b/playground/src/api/core/timezone.ts new file mode 100644 index 00000000000..13d44d19641 --- /dev/null +++ b/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 { + return requestClient.get('/timezone/getTimezone'); +} +/** + * 设置用户时区 + * @param timezone 时区 + */ +export async function setTimezoneApi(timezone: string): Promise { + return requestClient.post('/timezone/setTimezone', { timezone }); +} diff --git a/playground/src/bootstrap.ts b/playground/src/bootstrap.ts index f0a668b4468..fed07340086 100644 --- a/playground/src/bootstrap.ts +++ b/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); diff --git a/playground/src/timezone-init.ts b/playground/src/timezone-init.ts new file mode 100644 index 00000000000..1d82bd47320 --- /dev/null +++ b/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(); + }, + }); +}