feat(ops): 添加自定义时间范围选择功能

功能特性:
- 在时间段选择器中增加"自定义"选项
- 点击后弹出对话框,支持选择任意时间范围
- 使用 HTML5 datetime-local 输入框,体验友好
- 自定义时显示格式化的时间范围标签(MM-DD HH:mm ~ MM-DD HH:mm)
- 默认初始化为最近1小时

技术实现:
- 扩展 TimeRange 类型支持 'custom'
- 添加 customStartTime 和 customEndTime 状态管理
- 创建 buildApiParams 辅助函数统一处理 API 参数
- 当选择自定义时,使用 start_time 和 end_time 参数替代 time_range
- 更新所有相关 API 调用支持自定义时间范围

国际化:
- 添加"自定义"、"开始时间"、"结束时间"翻译
This commit is contained in:
IanShaw027
2026-01-15 19:50:47 +08:00
parent 38961ba10e
commit 930e9ee55c
3 changed files with 135 additions and 71 deletions

View File

@@ -2117,7 +2117,12 @@ export default {
'6h': '近6小时',
'24h': '近24小时',
'7d': '近7天',
'30d': '近30天'
'30d': '近30天',
custom: '自定义'
},
customTimeRange: {
startTime: '开始时间',
endTime: '结束时间'
},
fullscreen: {
enter: '进入全屏'

View File

@@ -23,10 +23,13 @@
:auto-refresh-enabled="autoRefreshEnabled"
:auto-refresh-countdown="autoRefreshCountdown"
:fullscreen="isFullscreen"
:custom-start-time="customStartTime"
:custom-end-time="customEndTime"
@update:time-range="onTimeRangeChange"
@update:platform="onPlatformChange"
@update:group="onGroupChange"
@update:query-mode="onQueryModeChange"
@update:custom-time-range="onCustomTimeRangeChange"
@refresh="fetchData"
@open-request-details="handleOpenRequestDetails"
@open-error-details="openErrorDetails"
@@ -148,8 +151,8 @@ const { t } = useI18n()
const opsEnabled = computed(() => adminSettingsStore.opsMonitoringEnabled)
type TimeRange = '5m' | '30m' | '1h' | '6h' | '24h'
const allowedTimeRanges = new Set<TimeRange>(['5m', '30m', '1h', '6h', '24h'])
type TimeRange = '5m' | '30m' | '1h' | '6h' | '24h' | 'custom'
const allowedTimeRanges = new Set<TimeRange>(['5m', '30m', '1h', '6h', '24h', 'custom'])
type QueryMode = 'auto' | 'raw' | 'preagg'
const allowedQueryModes = new Set<QueryMode>(['auto', 'raw', 'preagg'])
@@ -163,6 +166,8 @@ const timeRange = ref<TimeRange>('1h')
const platform = ref<string>('')
const groupId = ref<number | null>(null)
const queryMode = ref<QueryMode>('auto')
const customStartTime = ref<string | null>(null)
const customEndTime = ref<string | null>(null)
const QUERY_KEYS = {
timeRange: 'tr',
@@ -420,6 +425,11 @@ function onTimeRangeChange(v: string | number | boolean | null) {
timeRange.value = v as TimeRange
}
function onCustomTimeRangeChange(startTime: string, endTime: string) {
customStartTime.value = startTime
customEndTime.value = endTime
}
function onSettingsSaved() {
loadThresholds()
fetchData()
@@ -458,18 +468,25 @@ function openError(id: number) {
showErrorModal.value = true
}
function buildApiParams() {
const params: any = {
platform: platform.value || undefined,
group_id: groupId.value ?? undefined,
mode: queryMode.value
}
if (timeRange.value === 'custom' && customStartTime.value && customEndTime.value) {
params.start_time = customStartTime.value
params.end_time = customEndTime.value
} else {
params.time_range = timeRange.value
}
return params
}
async function refreshOverviewWithCancel(fetchSeq: number, signal: AbortSignal) {
if (!opsEnabled.value) return
try {
const data = await opsAPI.getDashboardOverview(
{
time_range: timeRange.value,
platform: platform.value || undefined,
group_id: groupId.value ?? undefined,
mode: queryMode.value
},
{ signal }
)
const data = await opsAPI.getDashboardOverview(buildApiParams(), { signal })
if (fetchSeq !== dashboardFetchSeq) return
overview.value = data
} catch (err: any) {
@@ -483,15 +500,7 @@ async function refreshThroughputTrendWithCancel(fetchSeq: number, signal: AbortS
if (!opsEnabled.value) return
loadingTrend.value = true
try {
const data = await opsAPI.getThroughputTrend(
{
time_range: timeRange.value,
platform: platform.value || undefined,
group_id: groupId.value ?? undefined,
mode: queryMode.value
},
{ signal }
)
const data = await opsAPI.getThroughputTrend(buildApiParams(), { signal })
if (fetchSeq !== dashboardFetchSeq) return
throughputTrend.value = data
} catch (err: any) {
@@ -509,15 +518,7 @@ async function refreshLatencyHistogramWithCancel(fetchSeq: number, signal: Abort
if (!opsEnabled.value) return
loadingLatency.value = true
try {
const data = await opsAPI.getLatencyHistogram(
{
time_range: timeRange.value,
platform: platform.value || undefined,
group_id: groupId.value ?? undefined,
mode: queryMode.value
},
{ signal }
)
const data = await opsAPI.getLatencyHistogram(buildApiParams(), { signal })
if (fetchSeq !== dashboardFetchSeq) return
latencyHistogram.value = data
} catch (err: any) {
@@ -535,15 +536,7 @@ async function refreshErrorTrendWithCancel(fetchSeq: number, signal: AbortSignal
if (!opsEnabled.value) return
loadingErrorTrend.value = true
try {
const data = await opsAPI.getErrorTrend(
{
time_range: timeRange.value,
platform: platform.value || undefined,
group_id: groupId.value ?? undefined,
mode: queryMode.value
},
{ signal }
)
const data = await opsAPI.getErrorTrend(buildApiParams(), { signal })
if (fetchSeq !== dashboardFetchSeq) return
errorTrend.value = data
} catch (err: any) {
@@ -561,15 +554,7 @@ async function refreshErrorDistributionWithCancel(fetchSeq: number, signal: Abor
if (!opsEnabled.value) return
loadingErrorDistribution.value = true
try {
const data = await opsAPI.getErrorDistribution(
{
time_range: timeRange.value,
platform: platform.value || undefined,
group_id: groupId.value ?? undefined,
mode: queryMode.value
},
{ signal }
)
const data = await opsAPI.getErrorDistribution(buildApiParams(), { signal })
if (fetchSeq !== dashboardFetchSeq) return
errorDistribution.value = data
} catch (err: any) {

View File

@@ -26,6 +26,8 @@ interface Props {
autoRefreshEnabled?: boolean
autoRefreshCountdown?: number
fullscreen?: boolean
customStartTime?: string | null
customEndTime?: string | null
}
interface Emits {
@@ -33,6 +35,7 @@ interface Emits {
(e: 'update:group', value: number | null): void
(e: 'update:timeRange', value: string): void
(e: 'update:queryMode', value: string): void
(e: 'update:customTimeRange', startTime: string, endTime: string): void
(e: 'refresh'): void
(e: 'openRequestDetails', preset?: OpsRequestDetailsPreset): void
(e: 'openErrorDetails', kind: 'request' | 'upstream'): void
@@ -85,6 +88,23 @@ watch(
// --- Filters ---
const showCustomTimeRangeDialog = ref(false)
const customStartTimeInput = ref('')
const customEndTimeInput = ref('')
function formatCustomTimeRangeLabel(startTime: string, endTime: string): string {
const start = new Date(startTime)
const end = new Date(endTime)
const formatDate = (d: Date) => {
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const minute = String(d.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
return `${formatDate(start)} ~ ${formatDate(end)}`
}
const groups = ref<Array<{ id: number; name: string; platform: string }>>([])
const platformOptions = computed(() => [
@@ -100,7 +120,13 @@ const timeRangeOptions = computed(() => [
{ value: '30m', label: t('admin.ops.timeRange.30m') },
{ value: '1h', label: t('admin.ops.timeRange.1h') },
{ value: '6h', label: t('admin.ops.timeRange.6h') },
{ value: '24h', label: t('admin.ops.timeRange.24h') }
{ value: '24h', label: t('admin.ops.timeRange.24h') },
{
value: 'custom',
label: props.timeRange === 'custom' && props.customStartTime && props.customEndTime
? `${t('admin.ops.timeRange.custom')} (${formatCustomTimeRangeLabel(props.customStartTime, props.customEndTime)})`
: t('admin.ops.timeRange.custom')
}
])
const queryModeOptions = computed(() => [
@@ -149,7 +175,32 @@ function handleGroupChange(val: string | number | boolean | null) {
}
function handleTimeRangeChange(val: string | number | boolean | null) {
emit('update:timeRange', String(val || '1h'))
const newValue = String(val || '1h')
if (newValue === 'custom') {
// 初始化为最近1小时
const now = new Date()
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000)
customStartTimeInput.value = oneHourAgo.toISOString().slice(0, 16)
customEndTimeInput.value = now.toISOString().slice(0, 16)
showCustomTimeRangeDialog.value = true
} else {
emit('update:timeRange', newValue)
}
}
function handleCustomTimeRangeConfirm() {
if (!customStartTimeInput.value || !customEndTimeInput.value) return
const startTime = new Date(customStartTimeInput.value).toISOString()
const endTime = new Date(customEndTimeInput.value).toISOString()
emit('update:timeRange', 'custom')
emit('update:customTimeRange', startTime, endTime)
showCustomTimeRangeDialog.value = false
}
function handleCustomTimeRangeCancel() {
showCustomTimeRangeDialog.value = false
// 如果当前不是 custom不需要做任何事
// 如果当前是 custom保持不变
}
function handleQueryModeChange(val: string | number | boolean | null) {
@@ -164,11 +215,6 @@ function openErrorDetails(kind: 'request' | 'upstream') {
emit('openErrorDetails', kind)
}
const updatedAtLabel = computed(() => {
if (!props.lastUpdated) return t('common.unknown')
return props.lastUpdated.toLocaleTimeString()
})
// --- Threshold checking helpers ---
type ThresholdLevel = 'normal' | 'warning' | 'critical'
@@ -829,25 +875,11 @@ function handleToolbarRefresh() {
</span>
<span>·</span>
<span>{{ t('common.refresh') }}: {{ updatedAtLabel }}</span>
<span>{{ t('common.refresh') }}: {{ props.lastUpdated ? props.lastUpdated.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).replace(/\//g, '-') : t('common.unknown') }}</span>
<template v-if="props.autoRefreshEnabled && props.autoRefreshCountdown !== undefined">
<span>·</span>
<span class="flex items-center gap-1">
<svg class="h-3 w-3 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ t('admin.ops.settings.autoRefreshCountdown', { seconds: props.autoRefreshCountdown }) }}</span>
</span>
</template>
<template v-if="systemMetrics">
<span>·</span>
<span>
{{ t('admin.ops.collectedAt') }} {{ formatTimeShort(systemMetrics.created_at) }}
({{ t('admin.ops.window') }} {{ systemMetrics.window_minutes }}m)
</span>
<span>剩余 {{ props.autoRefreshCountdown }}s</span>
</template>
</div>
</div>
@@ -1534,5 +1566,47 @@ function handleToolbarRefresh() {
</div>
</div>
</BaseDialog>
<!-- Custom Time Range Dialog -->
<BaseDialog :show="showCustomTimeRangeDialog" :title="t('admin.ops.timeRange.custom')" width="narrow" @close="handleCustomTimeRangeCancel">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ t('admin.ops.customTimeRange.startTime') }}
</label>
<input
v-model="customStartTimeInput"
type="datetime-local"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-dark-600 dark:bg-dark-800 dark:text-white"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ t('admin.ops.customTimeRange.endTime') }}
</label>
<input
v-model="customEndTimeInput"
type="datetime-local"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-dark-600 dark:bg-dark-800 dark:text-white"
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
@click="handleCustomTimeRangeCancel"
>
{{ t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600"
@click="handleCustomTimeRangeConfirm"
>
{{ t('common.confirm') }}
</button>
</div>
</div>
</BaseDialog>
</div>
</template>