implement comprehensive timezone selection with auto-detection

This commit is contained in:
jingrow 2025-12-27 00:10:56 +08:00
parent 33545be45b
commit 1fa97fa8c5
2 changed files with 226 additions and 19 deletions

View File

@ -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<string, Array<{ label: string; value: string }>>()
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
}

View File

@ -28,12 +28,16 @@
/>
</n-form-item>
<n-form-item :label="t('Timezone')">
<n-alert v-if="timezoneError" type="error" style="margin-bottom: 8px">
{{ timezoneError }}
</n-alert>
<n-select
v-model:value="systemSettings.timezone"
:options="timezoneOptions"
style="width: 250px"
filterable
:placeholder="t('Select timezone')"
:disabled="timezoneOptions.length === 0"
/>
</n-form-item>
</n-form>
@ -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<Array<{ type: string; label: string; key: string; children: Array<{ label: string; value: string }> }>>([])
const timezoneError = ref<string | null>(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)