implement comprehensive timezone selection with auto-detection
This commit is contained in:
parent
33545be45b
commit
1fa97fa8c5
@ -102,15 +102,30 @@ export function formatTimeInTimezone(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取时区偏移信息
|
* 获取时区偏移信息(简化高效版本)
|
||||||
* @param timezone 时区
|
* @param timezone 时区
|
||||||
*/
|
*/
|
||||||
export function getTimezoneOffset(timezone: string): string {
|
export function getTimezoneOffset(timezone: string): string {
|
||||||
try {
|
try {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const utc = new Date(now.getTime() + now.getTimezoneOffset() * 60000)
|
// 使用 Intl API 直接获取偏移量,更高效
|
||||||
const targetTime = new Date(utc.toLocaleString('en-US', { timeZone: timezone }))
|
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||||
const offset = (targetTime.getTime() - utc.getTime()) / 3600000
|
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 sign = offset >= 0 ? '+' : '-'
|
||||||
const hours = Math.abs(Math.floor(offset))
|
const hours = Math.abs(Math.floor(offset))
|
||||||
@ -121,3 +136,193 @@ export function getTimezoneOffset(timezone: string): string {
|
|||||||
return 'UTC+00:00'
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -28,12 +28,16 @@
|
|||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item :label="t('Timezone')">
|
<n-form-item :label="t('Timezone')">
|
||||||
|
<n-alert v-if="timezoneError" type="error" style="margin-bottom: 8px">
|
||||||
|
{{ timezoneError }}
|
||||||
|
</n-alert>
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="systemSettings.timezone"
|
v-model:value="systemSettings.timezone"
|
||||||
:options="timezoneOptions"
|
:options="timezoneOptions"
|
||||||
style="width: 250px"
|
style="width: 250px"
|
||||||
filterable
|
filterable
|
||||||
:placeholder="t('Select timezone')"
|
:placeholder="t('Select timezone')"
|
||||||
|
:disabled="timezoneOptions.length === 0"
|
||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
@ -210,7 +214,7 @@ import { Icon } from '@iconify/vue'
|
|||||||
import { getCurrentLocale, setLocale, locales, initLocale, t } from '../../shared/i18n'
|
import { getCurrentLocale, setLocale, locales, initLocale, t } from '../../shared/i18n'
|
||||||
import { useAuthStore } from '../../shared/stores/auth'
|
import { useAuthStore } from '../../shared/stores/auth'
|
||||||
import { getEnvironmentConfig, updateEnvironmentConfig, restartEnvironment, type EnvironmentConfig } from '../../shared/api/system'
|
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 message = useMessage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@ -254,20 +258,9 @@ const pageSizeOptions = [
|
|||||||
{ label: '100', value: 100 }
|
{ label: '100', value: 100 }
|
||||||
]
|
]
|
||||||
|
|
||||||
// 时区选项
|
// 时区选项 - 使用标准的 IANA 时区列表,按 UTC 偏移量分组(符合业内最佳实践)
|
||||||
const timezoneOptions = [
|
const timezoneOptions = ref<Array<{ type: string; label: string; key: string; children: Array<{ label: string; value: string }> }>>([])
|
||||||
{ label: 'Asia/Shanghai (中国标准时间)', value: 'Asia/Shanghai' },
|
const timezoneError = ref<string | null>(null)
|
||||||
{ 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' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 数据库类型选项
|
// 数据库类型选项
|
||||||
const dbTypeOptions = [
|
const dbTypeOptions = [
|
||||||
@ -404,6 +397,15 @@ onMounted(async () => {
|
|||||||
initLocale()
|
initLocale()
|
||||||
systemSettings.language = getCurrentLocale()
|
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) {
|
if (isAdmin.value) {
|
||||||
await loadEnvironmentConfig(false)
|
await loadEnvironmentConfig(false)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user