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:
@@ -2117,7 +2117,12 @@ export default {
|
||||
'6h': '近6小时',
|
||||
'24h': '近24小时',
|
||||
'7d': '近7天',
|
||||
'30d': '近30天'
|
||||
'30d': '近30天',
|
||||
custom: '自定义'
|
||||
},
|
||||
customTimeRange: {
|
||||
startTime: '开始时间',
|
||||
endTime: '结束时间'
|
||||
},
|
||||
fullscreen: {
|
||||
enter: '进入全屏'
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user