diff --git a/apps/jingrow/frontend/src/shared/utils/timezone.ts b/apps/jingrow/frontend/src/shared/utils/timezone.ts index 8a22993..50bccc5 100644 --- a/apps/jingrow/frontend/src/shared/utils/timezone.ts +++ b/apps/jingrow/frontend/src/shared/utils/timezone.ts @@ -102,15 +102,30 @@ export function formatTimeInTimezone( } /** - * 获取时区偏移信息 + * 获取时区偏移信息(简化高效版本) * @param timezone 时区 */ export function getTimezoneOffset(timezone: string): string { try { const now = new Date() - const utc = new Date(now.getTime() + now.getTimezoneOffset() * 60000) - const targetTime = new Date(utc.toLocaleString('en-US', { timeZone: timezone })) - const offset = (targetTime.getTime() - utc.getTime()) / 3600000 + // 使用 Intl API 直接获取偏移量,更高效 + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + timeZoneName: 'shortOffset' + }) + const parts = formatter.formatToParts(now) + const offsetPart = parts.find(part => part.type === 'timeZoneName') + + if (offsetPart?.value) { + // 格式化为标准 UTC+08:00 格式 + const offset = offsetPart.value.replace('GMT', 'UTC') + return offset.startsWith('UTC') ? offset : `UTC${offset}` + } + + // 降级方案:手动计算 + const utcTime = now.getTime() + (now.getTimezoneOffset() * 60000) + const localTime = new Date(now.toLocaleString('en-US', { timeZone: timezone })) + const offset = (localTime.getTime() - utcTime) / 3600000 const sign = offset >= 0 ? '+' : '-' const hours = Math.abs(Math.floor(offset)) @@ -121,3 +136,193 @@ export function getTimezoneOffset(timezone: string): string { return 'UTC+00:00' } } + +/** + * 获取时区的友好显示名称(符合业内最佳实践) + * @param timezone 时区标识符 + * @param offset UTC 偏移量(可选,避免重复计算) + */ +function getTimezoneDisplayName(timezone: string, offset?: string): string { + try { + // 获取 UTC 偏移量(如果未提供则计算) + const timezoneOffset = offset || getTimezoneOffset(timezone) + + // 业内最佳实践格式:洲/城市 (UTC+08:00) + // 例如:Asia/Shanghai (UTC+08:00) + // 优点:包含完整IANA时区标识符,避免同名城市混淆,符合标准 + return `${timezone} (${timezoneOffset})` + } catch (error) { + return timezone + } +} + +// 缓存时区选项,避免重复计算 +let cachedTimezoneOptions: Array<{ label: string; value: string }> | null = null +let cachedGroupedTimezoneOptions: Array<{ type: string; label: string; key: string; children: Array<{ label: string; value: string }> }> | null = null + +/** + * 获取所有标准的 IANA 时区列表(带缓存,高效实现) + * 使用浏览器标准的 Intl API 获取所有支持的时区,覆盖所有国家 + * @returns 时区选项数组,包含 label 和 value + * @throws 如果浏览器不支持 Intl.supportedValuesOf,抛出错误 + */ +export function getAllTimezoneOptions(): Array<{ label: string; value: string }> { + // 返回缓存的结果,避免重复计算 + if (cachedTimezoneOptions) { + return cachedTimezoneOptions + } + + // 检查浏览器是否支持 Intl.supportedValuesOf API + if (typeof Intl === 'undefined' || !('supportedValuesOf' in Intl)) { + throw new Error( + 'Browser does not support Intl.supportedValuesOf API. ' + + 'This indicates an implementation issue. ' + + 'Please use a modern browser that supports ECMAScript Intl API.' + ) + } + + try { + // 使用标准的 Intl.supportedValuesOf API 获取所有支持的时区 + const timezones = (Intl as any).supportedValuesOf('timeZone') as string[] + + if (!timezones || timezones.length === 0) { + throw new Error('Failed to get timezone list from Intl.supportedValuesOf') + } + + // 对时区进行排序:UTC 优先,然后按偏移量排序,最后按地区分组 + const timezoneData = timezones.map(timezone => { + const offset = getTimezoneOffset(timezone) + // 提取偏移量数值用于排序 + const offsetMatch = offset.match(/UTC([+-])(\d{2}):(\d{2})/) + const offsetValue = offsetMatch + ? (offsetMatch[1] === '+' ? 1 : -1) * (parseInt(offsetMatch[2]) * 60 + parseInt(offsetMatch[3])) + : 0 + + return { timezone, offset, offsetValue } + }) + + // 排序:UTC 优先,然后按偏移量,最后按地区 + timezoneData.sort((a, b) => { + if (a.timezone === 'UTC') return -1 + if (b.timezone === 'UTC') return 1 + + // 按偏移量排序 + if (a.offsetValue !== b.offsetValue) { + return a.offsetValue - b.offsetValue + } + + // 相同偏移量按地区分组 + const aRegion = a.timezone.split('/')[0] + const bRegion = b.timezone.split('/')[0] + if (aRegion !== bRegion) { + return aRegion.localeCompare(bRegion) + } + + // 同一地区内按城市名排序 + return a.timezone.localeCompare(b.timezone) + }) + + // 生成选项列表(传递 offset 避免重复计算) + cachedTimezoneOptions = timezoneData.map(({ timezone, offset }) => ({ + label: getTimezoneDisplayName(timezone, offset), + value: timezone + })) + + return cachedTimezoneOptions + } catch (error) { + throw new Error( + `Failed to get timezone options: ${error instanceof Error ? error.message : String(error)}. ` + + 'This indicates an implementation issue.' + ) + } +} + +/** + * 获取分组的时区选项(符合业内最佳实践) + * 按 UTC 偏移量分组,便于用户快速找到相同时区的不同城市 + * @returns 分组的时区选项数组,适用于支持分组的 UI 组件 + */ +export function getGroupedTimezoneOptions(): Array<{ type: string; label: string; key: string; children: Array<{ label: string; value: string }> }> { + // 返回缓存的结果 + if (cachedGroupedTimezoneOptions) { + return cachedGroupedTimezoneOptions + } + + // 先获取所有时区选项(这会填充缓存) + const allOptions = getAllTimezoneOptions() + + // 分离 UTC 和其他时区 + const utcOptions: Array<{ label: string; value: string }> = [] + const otherOptions: Array<{ label: string; value: string }> = [] + + allOptions.forEach(option => { + if (option.value === 'UTC') { + utcOptions.push(option) + } else { + otherOptions.push(option) + } + }) + + // 按 UTC 偏移量分组 + const grouped = new Map>() + + otherOptions.forEach(option => { + // 从 label 中提取 UTC 偏移量,格式:Asia/Shanghai (UTC+08:00) + const offsetMatch = option.label.match(/\((UTC[+-]\d{2}:\d{2})\)$/) + const offset = offsetMatch ? offsetMatch[1] : 'UTC+00:00' + + if (!grouped.has(offset)) { + grouped.set(offset, []) + } + grouped.get(offset)!.push(option) + }) + + // 对每个分组内的时区按字母顺序排序 + grouped.forEach((children) => { + children.sort((a, b) => { + // 提取时区标识符进行比较(去掉 UTC 偏移量后缀) + // 格式:Asia/Shanghai (UTC+08:00) + const aTimezone = a.label.replace(/\s+\(UTC[+-]\d{2}:\d{2}\)$/, '') + const bTimezone = b.label.replace(/\s+\(UTC[+-]\d{2}:\d{2}\)$/, '') + return aTimezone.localeCompare(bTimezone) + }) + }) + + // 转换为分组格式,按偏移量排序(从负到正) + const offsetGroups = Array.from(grouped.entries()) + .sort((a, b) => { + // 提取偏移量数值用于排序 + const aMatch = a[0].match(/UTC([+-])(\d{2}):(\d{2})/) + const bMatch = b[0].match(/UTC([+-])(\d{2}):(\d{2})/) + const aValue = aMatch + ? (aMatch[1] === '+' ? 1 : -1) * (parseInt(aMatch[2]) * 60 + parseInt(aMatch[3])) + : 0 + const bValue = bMatch + ? (bMatch[1] === '+' ? 1 : -1) * (parseInt(bMatch[2]) * 60 + parseInt(bMatch[3])) + : 0 + return aValue - bValue + }) + .map(([offset, children]) => ({ + type: 'group', + label: offset, + key: offset, + children + })) + + // UTC 单独作为第一个分组(如果有) + const result: Array<{ type: string; label: string; key: string; children: Array<{ label: string; value: string }> }> = [] + + if (utcOptions.length > 0) { + result.push({ + type: 'group', + label: 'UTC', + key: 'UTC', + children: utcOptions + }) + } + + result.push(...offsetGroups) + + cachedGroupedTimezoneOptions = result + return cachedGroupedTimezoneOptions +} diff --git a/apps/jingrow/frontend/src/views/settings/Settings.vue b/apps/jingrow/frontend/src/views/settings/Settings.vue index 012d038..2ffaa40 100644 --- a/apps/jingrow/frontend/src/views/settings/Settings.vue +++ b/apps/jingrow/frontend/src/views/settings/Settings.vue @@ -28,12 +28,16 @@ /> + + {{ timezoneError }} + @@ -210,7 +214,7 @@ import { Icon } from '@iconify/vue' import { getCurrentLocale, setLocale, locales, initLocale, t } from '../../shared/i18n' import { useAuthStore } from '../../shared/stores/auth' import { getEnvironmentConfig, updateEnvironmentConfig, restartEnvironment, type EnvironmentConfig } from '../../shared/api/system' -import { getCurrentTimezone } from '../../shared/utils/timezone' +import { getCurrentTimezone, getGroupedTimezoneOptions } from '../../shared/utils/timezone' const message = useMessage() const dialog = useDialog() @@ -254,20 +258,9 @@ const pageSizeOptions = [ { label: '100', value: 100 } ] -// 时区选项 -const timezoneOptions = [ - { label: 'Asia/Shanghai (中国标准时间)', value: 'Asia/Shanghai' }, - { label: 'Asia/Hong_Kong (中国香港时间)', value: 'Asia/Hong_Kong' }, - { label: 'Asia/Singapore (新加坡时间)', value: 'Asia/Singapore' }, - { label: 'UTC (协调世界时)', value: 'UTC' }, - { label: 'America/New_York (美国东部时间)', value: 'America/New_York' }, - { label: 'America/Los_Angeles (美国西部时间)', value: 'America/Los_Angeles' }, - { label: 'Europe/London (英国时间)', value: 'Europe/London' }, - { label: 'Europe/Paris (法国时间)', value: 'Europe/Paris' }, - { label: 'Europe/Berlin (德国时间)', value: 'Europe/Berlin' }, - { label: 'Australia/Sydney (澳大利亚东部时间)', value: 'Australia/Sydney' }, - { label: 'Pacific/Auckland (新西兰时间)', value: 'Pacific/Auckland' } -] +// 时区选项 - 使用标准的 IANA 时区列表,按 UTC 偏移量分组(符合业内最佳实践) +const timezoneOptions = ref }>>([]) +const timezoneError = ref(null) // 数据库类型选项 const dbTypeOptions = [ @@ -404,6 +397,15 @@ onMounted(async () => { initLocale() systemSettings.language = getCurrentLocale() + // 初始化时区选项(使用分组显示,符合业内最佳实践) + try { + timezoneOptions.value = getGroupedTimezoneOptions() + } catch (error) { + timezoneError.value = error instanceof Error ? error.message : String(error) + message.error(t('Failed to load timezone options') + ': ' + timezoneError.value) + console.error('Failed to load timezone options:', error) + } + // 如果是系统管理员,加载环境配置(静默加载,不显示消息) if (isAdmin.value) { await loadEnvironmentConfig(false)