Files
sub2api/frontend/src/views/admin/ops/components/OpsSettingsDialog.vue
IanShaw027 b98fb013ae feat(ops): 添加自动刷新配置功能
功能特性:
- 支持配置启用/禁用自动刷新
- 可配置刷新间隔(15秒/30秒/60秒)
- 实时倒计时显示,用户可见下次刷新时间
- 手动刷新自动重置倒计时
- 页面卸载时自动清理定时器

用户体验:
- 默认禁用,用户可根据需求开启
- 与现有 OpsConcurrencyCard 5秒刷新保持一致
- 倒计时带旋转动画,视觉反馈清晰
- 配置修改后立即生效,无需刷新页面

技术实现:
- ops.ts: 添加 auto_refresh_enabled 和 auto_refresh_interval_seconds 配置
- OpsSettingsDialog.vue: 添加自动刷新配置界面
- OpsDashboard.vue: 实现主刷新逻辑和双定时器设计
- OpsDashboardHeader.vue: 倒计时显示组件

配置说明:
- auto_refresh_enabled: 是否启用(默认 false)
- auto_refresh_interval_seconds: 刷新间隔(默认 30 秒,范围 15-300 秒)
2026-01-12 17:07:07 +08:00

546 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { opsAPI } from '@/api/admin/ops'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import Toggle from '@/components/common/Toggle.vue'
import type { OpsAlertRuntimeSettings, EmailNotificationConfig, AlertSeverity, OpsAdvancedSettings, OpsMetricThresholds } from '../types'
const { t } = useI18n()
const appStore = useAppStore()
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
close: []
saved: []
}>()
const loading = ref(false)
const saving = ref(false)
// 运行时设置
const runtimeSettings = ref<OpsAlertRuntimeSettings | null>(null)
// 邮件通知配置
const emailConfig = ref<EmailNotificationConfig | null>(null)
// 高级设置
const advancedSettings = ref<OpsAdvancedSettings | null>(null)
// 指标阈值配置
const metricThresholds = ref<OpsMetricThresholds>({
sla_percent_min: 99.5,
latency_p99_ms_max: 2000,
ttft_p99_ms_max: 500,
request_error_rate_percent_max: 5,
upstream_error_rate_percent_max: 5
})
// 加载所有配置
async function loadAllSettings() {
loading.value = true
try {
const [runtime, email, advanced, thresholds] = await Promise.all([
opsAPI.getAlertRuntimeSettings(),
opsAPI.getEmailNotificationConfig(),
opsAPI.getAdvancedSettings(),
opsAPI.getMetricThresholds()
])
runtimeSettings.value = runtime
emailConfig.value = email
advancedSettings.value = advanced
// 如果后端返回了阈值,使用后端的值;否则保持默认值
if (thresholds && Object.keys(thresholds).length > 0) {
metricThresholds.value = {
sla_percent_min: thresholds.sla_percent_min ?? 99.5,
latency_p99_ms_max: thresholds.latency_p99_ms_max ?? 2000,
ttft_p99_ms_max: thresholds.ttft_p99_ms_max ?? 500,
request_error_rate_percent_max: thresholds.request_error_rate_percent_max ?? 5,
upstream_error_rate_percent_max: thresholds.upstream_error_rate_percent_max ?? 5
}
}
} catch (err: any) {
console.error('[OpsSettingsDialog] Failed to load settings', err)
appStore.showError(err?.response?.data?.detail || t('admin.ops.settings.loadFailed'))
} finally {
loading.value = false
}
}
// 监听弹窗打开
watch(() => props.show, (show) => {
if (show) {
loadAllSettings()
}
})
// 邮件输入
const alertRecipientInput = ref('')
const reportRecipientInput = ref('')
// 严重级别选项
const severityOptions: Array<{ value: AlertSeverity | ''; label: string }> = [
{ value: '', label: t('admin.ops.email.minSeverityAll') },
{ value: 'critical', label: t('common.critical') },
{ value: 'warning', label: t('common.warning') },
{ value: 'info', label: t('common.info') }
]
// 验证邮箱
function isValidEmailAddress(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
// 添加收件人
function addRecipient(target: 'alert' | 'report') {
if (!emailConfig.value) return
const raw = (target === 'alert' ? alertRecipientInput.value : reportRecipientInput.value).trim()
if (!raw) return
if (!isValidEmailAddress(raw)) {
appStore.showError(t('common.invalidEmail'))
return
}
const normalized = raw.toLowerCase()
const list = target === 'alert' ? emailConfig.value.alert.recipients : emailConfig.value.report.recipients
if (!list.includes(normalized)) {
list.push(normalized)
}
if (target === 'alert') alertRecipientInput.value = ''
else reportRecipientInput.value = ''
}
// 移除收件人
function removeRecipient(target: 'alert' | 'report', email: string) {
if (!emailConfig.value) return
const list = target === 'alert' ? emailConfig.value.alert.recipients : emailConfig.value.report.recipients
const idx = list.indexOf(email)
if (idx >= 0) list.splice(idx, 1)
}
// 验证
const validation = computed(() => {
const errors: string[] = []
// 验证运行时设置
if (runtimeSettings.value) {
const evalSeconds = runtimeSettings.value.evaluation_interval_seconds
if (!Number.isFinite(evalSeconds) || evalSeconds < 1 || evalSeconds > 86400) {
errors.push(t('admin.ops.runtime.validation.evalIntervalRange'))
}
}
// 验证邮件配置
if (emailConfig.value) {
if (emailConfig.value.alert.enabled && emailConfig.value.alert.recipients.length === 0) {
errors.push(t('admin.ops.email.validation.alertRecipientsRequired'))
}
if (emailConfig.value.report.enabled && emailConfig.value.report.recipients.length === 0) {
errors.push(t('admin.ops.email.validation.reportRecipientsRequired'))
}
}
// 验证高级设置
if (advancedSettings.value) {
const { error_log_retention_days, minute_metrics_retention_days, hourly_metrics_retention_days } = advancedSettings.value.data_retention
if (error_log_retention_days < 1 || error_log_retention_days > 365) {
errors.push(t('admin.ops.settings.validation.retentionDaysRange'))
}
if (minute_metrics_retention_days < 1 || minute_metrics_retention_days > 365) {
errors.push(t('admin.ops.settings.validation.retentionDaysRange'))
}
if (hourly_metrics_retention_days < 1 || hourly_metrics_retention_days > 365) {
errors.push(t('admin.ops.settings.validation.retentionDaysRange'))
}
}
// 验证指标阈值
if (metricThresholds.value.sla_percent_min != null && (metricThresholds.value.sla_percent_min < 0 || metricThresholds.value.sla_percent_min > 100)) {
errors.push('SLA最低百分比必须在0-100之间')
}
if (metricThresholds.value.latency_p99_ms_max != null && metricThresholds.value.latency_p99_ms_max < 0) {
errors.push('延迟P99最大值必须大于等于0')
}
if (metricThresholds.value.ttft_p99_ms_max != null && metricThresholds.value.ttft_p99_ms_max < 0) {
errors.push('TTFT P99最大值必须大于等于0')
}
if (metricThresholds.value.request_error_rate_percent_max != null && (metricThresholds.value.request_error_rate_percent_max < 0 || metricThresholds.value.request_error_rate_percent_max > 100)) {
errors.push('请求错误率最大值必须在0-100之间')
}
if (metricThresholds.value.upstream_error_rate_percent_max != null && (metricThresholds.value.upstream_error_rate_percent_max < 0 || metricThresholds.value.upstream_error_rate_percent_max > 100)) {
errors.push('上游错误率最大值必须在0-100之间')
}
return { valid: errors.length === 0, errors }
})
// 保存所有配置
async function saveAllSettings() {
if (!validation.value.valid) {
appStore.showError(validation.value.errors[0])
return
}
saving.value = true
try {
await Promise.all([
runtimeSettings.value ? opsAPI.updateAlertRuntimeSettings(runtimeSettings.value) : Promise.resolve(),
emailConfig.value ? opsAPI.updateEmailNotificationConfig(emailConfig.value) : Promise.resolve(),
advancedSettings.value ? opsAPI.updateAdvancedSettings(advancedSettings.value) : Promise.resolve(),
opsAPI.updateMetricThresholds(metricThresholds.value)
])
appStore.showSuccess(t('admin.ops.settings.saveSuccess'))
emit('saved')
emit('close')
} catch (err: any) {
console.error('[OpsSettingsDialog] Failed to save settings', err)
appStore.showError(err?.response?.data?.message || err?.response?.data?.detail || t('admin.ops.settings.saveFailed'))
} finally {
saving.value = false
}
}
</script>
<template>
<BaseDialog :show="show" :title="t('admin.ops.settings.title')" width="extra-wide" @close="emit('close')">
<div v-if="loading" class="py-10 text-center text-sm text-gray-500">
{{ t('common.loading') }}
</div>
<div v-else-if="runtimeSettings && emailConfig && advancedSettings" class="space-y-6">
<!-- 验证错误 -->
<div v-if="!validation.valid" class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-200">
<div class="font-bold">{{ t('admin.ops.settings.validation.title') }}</div>
<ul class="mt-1 list-disc space-y-1 pl-4">
<li v-for="msg in validation.errors" :key="msg">{{ msg }}</li>
</ul>
</div>
<!-- 数据采集频率 -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.settings.dataCollection') }}</h4>
<div>
<label class="input-label">{{ t('admin.ops.settings.evaluationInterval') }}</label>
<input
v-model.number="runtimeSettings.evaluation_interval_seconds"
type="number"
min="1"
max="86400"
class="input"
/>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.evaluationIntervalHint') }}</p>
</div>
</div>
<!-- 预警配置 -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.settings.alertConfig') }}</h4>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.ops.settings.enableAlert') }}</label>
</div>
<Toggle v-model="emailConfig.alert.enabled" />
</div>
<div v-if="emailConfig.alert.enabled">
<label class="input-label">{{ t('admin.ops.settings.alertRecipients') }}</label>
<div class="flex gap-2">
<input
v-model="alertRecipientInput"
type="email"
class="input"
:placeholder="t('admin.ops.settings.emailPlaceholder')"
@keydown.enter.prevent="addRecipient('alert')"
/>
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addRecipient('alert')">
{{ t('common.add') }}
</button>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<span
v-for="email in emailConfig.alert.recipients"
:key="email"
class="inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ email }}
<button type="button" class="text-blue-700/80 hover:text-blue-900" @click="removeRecipient('alert', email)">×</button>
</span>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.settings.recipientsHint') }}
</p>
</div>
<div v-if="emailConfig.alert.enabled">
<label class="input-label">{{ t('admin.ops.settings.minSeverity') }}</label>
<Select v-model="emailConfig.alert.min_severity" :options="severityOptions" />
</div>
</div>
</div>
<!-- 评估报告配置 -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.settings.reportConfig') }}</h4>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{ t('admin.ops.settings.enableReport') }}</label>
</div>
<Toggle v-model="emailConfig.report.enabled" />
</div>
<div v-if="emailConfig.report.enabled">
<label class="input-label">{{ t('admin.ops.settings.reportRecipients') }}</label>
<div class="flex gap-2">
<input
v-model="reportRecipientInput"
type="email"
class="input"
:placeholder="t('admin.ops.settings.emailPlaceholder')"
@keydown.enter.prevent="addRecipient('report')"
/>
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addRecipient('report')">
{{ t('common.add') }}
</button>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<span
v-for="email in emailConfig.report.recipients"
:key="email"
class="inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{{ email }}
<button type="button" class="text-blue-700/80 hover:text-blue-900" @click="removeRecipient('report', email)">×</button>
</span>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.ops.settings.recipientsHint') }}
</p>
</div>
<div v-if="emailConfig.report.enabled" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.dailySummary') }}</label>
<Toggle v-model="emailConfig.report.daily_summary_enabled" />
</div>
<div v-if="emailConfig.report.daily_summary_enabled">
<input v-model="emailConfig.report.daily_summary_schedule" type="text" class="input" placeholder="0 9 * * *" />
</div>
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.weeklySummary') }}</label>
<Toggle v-model="emailConfig.report.weekly_summary_enabled" />
</div>
<div v-if="emailConfig.report.weekly_summary_enabled">
<input v-model="emailConfig.report.weekly_summary_schedule" type="text" class="input" placeholder="0 9 * * 1" />
</div>
</div>
</div>
</div>
<!-- 指标阈值配置 -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.settings.metricThresholds') }}</h4>
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.settings.metricThresholdsHint') }}</p>
<div class="space-y-4">
<div>
<label class="input-label">{{ t('admin.ops.settings.slaMinPercent') }}</label>
<input
v-model.number="metricThresholds.sla_percent_min"
type="number"
min="0"
max="100"
step="0.1"
class="input"
/>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.slaMinPercentHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.ops.settings.latencyP99MaxMs') }}</label>
<input
v-model.number="metricThresholds.latency_p99_ms_max"
type="number"
min="0"
step="100"
class="input"
/>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.latencyP99MaxMsHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.ops.settings.ttftP99MaxMs') }}</label>
<input
v-model.number="metricThresholds.ttft_p99_ms_max"
type="number"
min="0"
step="50"
class="input"
/>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.ttftP99MaxMsHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.ops.settings.requestErrorRateMaxPercent') }}</label>
<input
v-model.number="metricThresholds.request_error_rate_percent_max"
type="number"
min="0"
max="100"
step="0.1"
class="input"
/>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.requestErrorRateMaxPercentHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.ops.settings.upstreamErrorRateMaxPercent') }}</label>
<input
v-model.number="metricThresholds.upstream_error_rate_percent_max"
type="number"
min="0"
max="100"
step="0.1"
class="input"
/>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.upstreamErrorRateMaxPercentHint') }}</p>
</div>
</div>
</div>
<!-- 高级设置 -->
<details class="rounded-2xl bg-gray-50 dark:bg-dark-700/50">
<summary class="cursor-pointer p-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.ops.settings.advancedSettings') }}
</summary>
<div class="space-y-4 px-4 pb-4">
<!-- 数据保留策略 -->
<div class="space-y-3">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.dataRetention') }}</h5>
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.enableCleanup') }}</label>
<Toggle v-model="advancedSettings.data_retention.cleanup_enabled" />
</div>
<div v-if="advancedSettings.data_retention.cleanup_enabled">
<label class="input-label">{{ t('admin.ops.settings.cleanupSchedule') }}</label>
<input
v-model="advancedSettings.data_retention.cleanup_schedule"
type="text"
class="input"
placeholder="0 2 * * *"
/>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.cleanupScheduleHint') }}</p>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<label class="input-label">{{ t('admin.ops.settings.errorLogRetentionDays') }}</label>
<input
v-model.number="advancedSettings.data_retention.error_log_retention_days"
type="number"
min="1"
max="365"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.ops.settings.minuteMetricsRetentionDays') }}</label>
<input
v-model.number="advancedSettings.data_retention.minute_metrics_retention_days"
type="number"
min="1"
max="365"
class="input"
/>
</div>
<div>
<label class="input-label">{{ t('admin.ops.settings.hourlyMetricsRetentionDays') }}</label>
<input
v-model.number="advancedSettings.data_retention.hourly_metrics_retention_days"
type="number"
min="1"
max="365"
class="input"
/>
</div>
</div>
<p class="text-xs text-gray-500">{{ t('admin.ops.settings.retentionDaysHint') }}</p>
</div>
<!-- 预聚合任务 -->
<div class="space-y-3">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.aggregation') }}</h5>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.settings.enableAggregation') }}</label>
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.settings.aggregationHint') }}</p>
</div>
<Toggle v-model="advancedSettings.aggregation.aggregation_enabled" />
</div>
</div>
<!-- 错误过滤 -->
<div class="space-y-3">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">错误过滤</h5>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">忽略 count_tokens 错误</label>
<p class="mt-1 text-xs text-gray-500">
启用后count_tokens 请求的错误将不计入运维监控的统计和告警中但仍会存储在数据库中
</p>
</div>
<Toggle v-model="advancedSettings.ignore_count_tokens_errors" />
</div>
</div>
<!-- 自动刷新 -->
<div class="space-y-3">
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">自动刷新</h5>
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">启用自动刷新</label>
<p class="mt-1 text-xs text-gray-500">
自动刷新仪表板数据启用后会定期拉取最新数据
</p>
</div>
<Toggle v-model="advancedSettings.auto_refresh_enabled" />
</div>
<div v-if="advancedSettings.auto_refresh_enabled">
<label class="input-label">刷新间隔</label>
<Select
v-model="advancedSettings.auto_refresh_interval_seconds"
:options="[
{ value: 15, label: '15 秒' },
{ value: 30, label: '30 秒' },
{ value: 60, label: '60 秒' }
]"
/>
</div>
</div>
</div>
</details>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<button class="btn btn-secondary" @click="emit('close')">{{ t('common.cancel') }}</button>
<button class="btn btn-primary" :disabled="saving || !validation.valid" @click="saveAllSettings">
{{ saving ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</BaseDialog>
</template>